mirror of
https://github.com/streamyfin/streamyfin.git
synced 2025-08-20 18:37:18 +02:00
Merge branch 'develop' into feat/i18n
This commit is contained in:
5
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
5
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -44,8 +44,13 @@ body:
|
|||||||
description: What version of Streamyfin are you running?
|
description: What version of Streamyfin are you running?
|
||||||
options:
|
options:
|
||||||
- 0.25.0
|
- 0.25.0
|
||||||
|
- 0.24.0
|
||||||
|
- 0.23.0
|
||||||
|
<<<<<<< Updated upstream
|
||||||
|
=======
|
||||||
- 0.22.0
|
- 0.22.0
|
||||||
- 0.21.0
|
- 0.21.0
|
||||||
|
>>>>>>> Stashed changes
|
||||||
- older
|
- older
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -35,4 +35,6 @@ credentials.json
|
|||||||
*.ipa
|
*.ipa
|
||||||
.continuerc.json
|
.continuerc.json
|
||||||
|
|
||||||
.vscode/
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
.ruby-lsp
|
||||||
3
.idea/.gitignore
generated
vendored
3
.idea/.gitignore
generated
vendored
@@ -1,3 +0,0 @@
|
|||||||
# Default ignored files
|
|
||||||
/shelf/
|
|
||||||
/workspace.xml
|
|
||||||
329
.idea/caches/deviceStreaming.xml
generated
329
.idea/caches/deviceStreaming.xml
generated
@@ -1,329 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="DeviceStreaming">
|
|
||||||
<option name="deviceSelectionList">
|
|
||||||
<list>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="27" />
|
|
||||||
<option name="brand" value="DOCOMO" />
|
|
||||||
<option name="codename" value="F01L" />
|
|
||||||
<option name="id" value="F01L" />
|
|
||||||
<option name="manufacturer" value="FUJITSU" />
|
|
||||||
<option name="name" value="F-01L" />
|
|
||||||
<option name="screenDensity" value="360" />
|
|
||||||
<option name="screenX" value="720" />
|
|
||||||
<option name="screenY" value="1280" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="28" />
|
|
||||||
<option name="brand" value="DOCOMO" />
|
|
||||||
<option name="codename" value="SH-01L" />
|
|
||||||
<option name="id" value="SH-01L" />
|
|
||||||
<option name="manufacturer" value="SHARP" />
|
|
||||||
<option name="name" value="AQUOS sense2 SH-01L" />
|
|
||||||
<option name="screenDensity" value="480" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2160" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="Lenovo" />
|
|
||||||
<option name="codename" value="TB370FU" />
|
|
||||||
<option name="id" value="TB370FU" />
|
|
||||||
<option name="manufacturer" value="Lenovo" />
|
|
||||||
<option name="name" value="Tab P12" />
|
|
||||||
<option name="screenDensity" value="340" />
|
|
||||||
<option name="screenX" value="1840" />
|
|
||||||
<option name="screenY" value="2944" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="31" />
|
|
||||||
<option name="brand" value="samsung" />
|
|
||||||
<option name="codename" value="a51" />
|
|
||||||
<option name="id" value="a51" />
|
|
||||||
<option name="manufacturer" value="Samsung" />
|
|
||||||
<option name="name" value="Galaxy A51" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2400" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="akita" />
|
|
||||||
<option name="id" value="akita" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 8a" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2400" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="33" />
|
|
||||||
<option name="brand" value="samsung" />
|
|
||||||
<option name="codename" value="b0q" />
|
|
||||||
<option name="id" value="b0q" />
|
|
||||||
<option name="manufacturer" value="Samsung" />
|
|
||||||
<option name="name" value="Galaxy S22 Ultra" />
|
|
||||||
<option name="screenDensity" value="600" />
|
|
||||||
<option name="screenX" value="1440" />
|
|
||||||
<option name="screenY" value="3088" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="32" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="bluejay" />
|
|
||||||
<option name="id" value="bluejay" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 6a" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2400" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="caiman" />
|
|
||||||
<option name="id" value="caiman" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 9 Pro" />
|
|
||||||
<option name="screenDensity" value="360" />
|
|
||||||
<option name="screenX" value="960" />
|
|
||||||
<option name="screenY" value="2142" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="comet" />
|
|
||||||
<option name="id" value="comet" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 9 Pro Fold" />
|
|
||||||
<option name="screenDensity" value="390" />
|
|
||||||
<option name="screenX" value="2076" />
|
|
||||||
<option name="screenY" value="2152" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="29" />
|
|
||||||
<option name="brand" value="samsung" />
|
|
||||||
<option name="codename" value="crownqlteue" />
|
|
||||||
<option name="id" value="crownqlteue" />
|
|
||||||
<option name="manufacturer" value="Samsung" />
|
|
||||||
<option name="name" value="Galaxy Note9" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="2220" />
|
|
||||||
<option name="screenY" value="1080" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="samsung" />
|
|
||||||
<option name="codename" value="dm3q" />
|
|
||||||
<option name="id" value="dm3q" />
|
|
||||||
<option name="manufacturer" value="Samsung" />
|
|
||||||
<option name="name" value="Galaxy S23 Ultra" />
|
|
||||||
<option name="screenDensity" value="600" />
|
|
||||||
<option name="screenX" value="1440" />
|
|
||||||
<option name="screenY" value="3088" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="samsung" />
|
|
||||||
<option name="codename" value="e1q" />
|
|
||||||
<option name="id" value="e1q" />
|
|
||||||
<option name="manufacturer" value="Samsung" />
|
|
||||||
<option name="name" value="Galaxy S24" />
|
|
||||||
<option name="screenDensity" value="480" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2340" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="33" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="felix" />
|
|
||||||
<option name="id" value="felix" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel Fold" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="2208" />
|
|
||||||
<option name="screenY" value="1840" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="felix" />
|
|
||||||
<option name="id" value="felix" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel Fold" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="2208" />
|
|
||||||
<option name="screenY" value="1840" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="33" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="felix_camera" />
|
|
||||||
<option name="id" value="felix_camera" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel Fold (Camera-enabled)" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="2208" />
|
|
||||||
<option name="screenY" value="1840" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="33" />
|
|
||||||
<option name="brand" value="samsung" />
|
|
||||||
<option name="codename" value="gts8uwifi" />
|
|
||||||
<option name="id" value="gts8uwifi" />
|
|
||||||
<option name="manufacturer" value="Samsung" />
|
|
||||||
<option name="name" value="Galaxy Tab S8 Ultra" />
|
|
||||||
<option name="screenDensity" value="320" />
|
|
||||||
<option name="screenX" value="1848" />
|
|
||||||
<option name="screenY" value="2960" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="husky" />
|
|
||||||
<option name="id" value="husky" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 8 Pro" />
|
|
||||||
<option name="screenDensity" value="390" />
|
|
||||||
<option name="screenX" value="1008" />
|
|
||||||
<option name="screenY" value="2244" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="30" />
|
|
||||||
<option name="brand" value="motorola" />
|
|
||||||
<option name="codename" value="java" />
|
|
||||||
<option name="id" value="java" />
|
|
||||||
<option name="manufacturer" value="Motorola" />
|
|
||||||
<option name="name" value="G20" />
|
|
||||||
<option name="screenDensity" value="280" />
|
|
||||||
<option name="screenX" value="720" />
|
|
||||||
<option name="screenY" value="1600" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="komodo" />
|
|
||||||
<option name="id" value="komodo" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 9 Pro XL" />
|
|
||||||
<option name="screenDensity" value="360" />
|
|
||||||
<option name="screenX" value="1008" />
|
|
||||||
<option name="screenY" value="2244" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="33" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="lynx" />
|
|
||||||
<option name="id" value="lynx" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 7a" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2400" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="31" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="oriole" />
|
|
||||||
<option name="id" value="oriole" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 6" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2400" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="33" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="panther" />
|
|
||||||
<option name="id" value="panther" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 7" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2400" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="samsung" />
|
|
||||||
<option name="codename" value="q5q" />
|
|
||||||
<option name="id" value="q5q" />
|
|
||||||
<option name="manufacturer" value="Samsung" />
|
|
||||||
<option name="name" value="Galaxy Z Fold5" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="1812" />
|
|
||||||
<option name="screenY" value="2176" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="samsung" />
|
|
||||||
<option name="codename" value="q6q" />
|
|
||||||
<option name="id" value="q6q" />
|
|
||||||
<option name="manufacturer" value="Samsung" />
|
|
||||||
<option name="name" value="Galaxy Z Fold6" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="1856" />
|
|
||||||
<option name="screenY" value="2160" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="30" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="r11" />
|
|
||||||
<option name="id" value="r11" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel Watch" />
|
|
||||||
<option name="screenDensity" value="320" />
|
|
||||||
<option name="screenX" value="384" />
|
|
||||||
<option name="screenY" value="384" />
|
|
||||||
<option name="type" value="WEAR_OS" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="30" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="redfin" />
|
|
||||||
<option name="id" value="redfin" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 5" />
|
|
||||||
<option name="screenDensity" value="440" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2340" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="shiba" />
|
|
||||||
<option name="id" value="shiba" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 8" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2400" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="33" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="tangorpro" />
|
|
||||||
<option name="id" value="tangorpro" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel Tablet" />
|
|
||||||
<option name="screenDensity" value="320" />
|
|
||||||
<option name="screenX" value="1600" />
|
|
||||||
<option name="screenY" value="2560" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="tokay" />
|
|
||||||
<option name="id" value="tokay" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 9" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2424" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
</list>
|
|
||||||
</option>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
6
.idea/misc.xml
generated
6
.idea/misc.xml
generated
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="ProjectRootManager">
|
|
||||||
<output url="file://$PROJECT_DIR$/out" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
8
.idea/modules.xml
generated
8
.idea/modules.xml
generated
@@ -1,8 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="ProjectModuleManager">
|
|
||||||
<modules>
|
|
||||||
<module fileurl="file://$PROJECT_DIR$/.idea/streamyfin.iml" filepath="$PROJECT_DIR$/.idea/streamyfin.iml" />
|
|
||||||
</modules>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
9
.idea/streamyfin.iml
generated
9
.idea/streamyfin.iml
generated
@@ -1,9 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<module type="JAVA_MODULE" version="4">
|
|
||||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
|
||||||
<exclude-output />
|
|
||||||
<content url="file://$MODULE_DIR$" />
|
|
||||||
<orderEntry type="inheritedJdk" />
|
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
|
||||||
</component>
|
|
||||||
</module>
|
|
||||||
6
.idea/vcs.xml
generated
6
.idea/vcs.xml
generated
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="VcsDirectoryMappings">
|
|
||||||
<mapping directory="" vcs="Git" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
@@ -8,7 +8,7 @@ Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Exp
|
|||||||
<img width=150 src="./assets/images/screenshots/screenshot1.png" />
|
<img width=150 src="./assets/images/screenshots/screenshot1.png" />
|
||||||
<img width=150 src="./assets/images/screenshots/screenshot3.png" />
|
<img width=150 src="./assets/images/screenshots/screenshot3.png" />
|
||||||
<img width=150 src="./assets/images/screenshots/screenshot2.png" />
|
<img width=150 src="./assets/images/screenshots/screenshot2.png" />
|
||||||
<img width=150 src="./assets/images/jellyseerr.PNG"/>
|
<img width=159 src="./assets/images/jellyseerr.PNG"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## 🌟 Features
|
## 🌟 Features
|
||||||
@@ -70,7 +70,7 @@ Or download the APKs [here on GitHub](https://github.com/streamyfin/streamyfin/r
|
|||||||
|
|
||||||
### Beta testing
|
### Beta testing
|
||||||
|
|
||||||
To access the Streamyfin beta, you need to subscribe to the Member tier (or higher) on [Patreon](https://www.patreon.com/streamyfin). This will give you immediate access to the 🧪-public-beta channel on Discord and i'll know that you have subscribed. This is where i'll post APKs and IPAs. This won't give automatic access to the TestFlight however, so you need to send me a DM with the email you use for Apple so that i can manually add you.
|
To access the Streamyfin beta, you need to subscribe to the Member tier (or higher) on [Patreon](https://www.patreon.com/streamyfin). This will give you immediate access to the 🧪-public-beta channel on Discord and i'll know that you have subscribed. This is where I post APKs and IPAs. This won't give automatic access to the TestFlight, however, so you need to send me a DM with the email you use for Apple so that i can manually add you.
|
||||||
|
|
||||||
**Note**: Everyone who is actively contributing to the source code of Streamyfin will have automatic access to the betas.
|
**Note**: Everyone who is actively contributing to the source code of Streamyfin will have automatic access to the betas.
|
||||||
|
|
||||||
@@ -90,7 +90,7 @@ We welcome any help to make Streamyfin better. If you'd like to contribute, plea
|
|||||||
1. Use node `>20`
|
1. Use node `>20`
|
||||||
2. Install dependencies `bun i && bun run submodule-reload`
|
2. Install dependencies `bun i && bun run submodule-reload`
|
||||||
3. Make sure you have xcode and/or android studio installed.
|
3. Make sure you have xcode and/or android studio installed.
|
||||||
4. Create an expo dev build by running `npx expo run:ios` or `npx expo run:android`. This will open a simulator on you computer and run the app.
|
4. Create an expo dev build by running `npx expo run:ios` or `npx expo run:android`. This will open a simulator on your computer and run the app.
|
||||||
|
|
||||||
## 📄 License
|
## 📄 License
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { MovieCard } from "@/components/downloads/MovieCard";
|
|||||||
import { SeriesCard } from "@/components/downloads/SeriesCard";
|
import { SeriesCard } from "@/components/downloads/SeriesCard";
|
||||||
import { DownloadedItem, useDownload } from "@/providers/DownloadProvider";
|
import { DownloadedItem, useDownload } from "@/providers/DownloadProvider";
|
||||||
import { queueAtom } from "@/utils/atoms/queue";
|
import { queueAtom } from "@/utils/atoms/queue";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import {DownloadMethod, useSettings} from "@/utils/atoms/settings";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { useNavigation, useRouter } from "expo-router";
|
import { useNavigation, useRouter } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
@@ -99,7 +99,7 @@ export default function page() {
|
|||||||
>
|
>
|
||||||
<View className="py-4">
|
<View className="py-4">
|
||||||
<View className="mb-4 flex flex-col space-y-4 px-4">
|
<View className="mb-4 flex flex-col space-y-4 px-4">
|
||||||
{settings?.downloadMethod === "remux" && (
|
{settings?.downloadMethod === DownloadMethod.Remux && (
|
||||||
<View className="bg-neutral-900 p-4 rounded-2xl">
|
<View className="bg-neutral-900 p-4 rounded-2xl">
|
||||||
<Text className="text-lg font-bold">{t("home.downloads.queue")}</Text>
|
<Text className="text-lg font-bold">{t("home.downloads.queue")}</Text>
|
||||||
<Text className="text-xs opacity-70 text-red-600">
|
<Text className="text-xs opacity-70 text-red-600">
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { Feather, Ionicons } from "@expo/vector-icons";
|
|||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useFocusEffect, useRouter } from "expo-router";
|
import { useFocusEffect, useRouter } from "expo-router";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
|
||||||
import {useTranslation } from "react-i18next";
|
import {useTranslation } from "react-i18next";
|
||||||
|
import { Linking, TouchableOpacity, View } from "react-native";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -19,7 +19,7 @@ export default function page() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="bg-neutral-900 h-full py-32 px-4 space-y-4">
|
<View className="bg-neutral-900 h-full py-16 px-4 space-y-8">
|
||||||
<View>
|
<View>
|
||||||
<Text className="text-3xl font-bold text-center mb-2">
|
<Text className="text-3xl font-bold text-center mb-2">
|
||||||
{t("home.intro.welcome_to_streamyfin")}
|
{t("home.intro.welcome_to_streamyfin")}
|
||||||
@@ -83,25 +83,55 @@ export default function page() {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
<View className="flex flex-row items-center mt-4">
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
}}
|
||||||
|
className="flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<Feather name="settings" size={28} color={"white"} />
|
||||||
|
</View>
|
||||||
|
<View className="shrink ml-2">
|
||||||
|
<Text className="font-bold mb-1">Centralised Settings Plugin</Text>
|
||||||
|
<Text className="shrink text-xs">
|
||||||
|
Configure settings from a centralised location on your Jellyfin
|
||||||
|
server. All client settings for all users will be synced
|
||||||
|
automatically.{" "}
|
||||||
|
<Text
|
||||||
|
className="text-purple-600"
|
||||||
|
onPress={() => {
|
||||||
|
Linking.openURL(
|
||||||
|
"https://github.com/streamyfin/jellyfin-plugin-streamyfin"
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Read more
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<Button
|
||||||
|
onPress={() => {
|
||||||
|
router.back();
|
||||||
|
}}
|
||||||
|
className="mt-4"
|
||||||
|
>
|
||||||
|
{t("home.intro.done_button")}
|
||||||
|
</Button>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
router.back();
|
||||||
|
router.push("/settings");
|
||||||
|
}}
|
||||||
|
className="mt-4"
|
||||||
|
>
|
||||||
|
<Text className="text-purple-600 text-center">{t("home.intro.go_to_settings_button")}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Button
|
|
||||||
onPress={() => {
|
|
||||||
router.back();
|
|
||||||
}}
|
|
||||||
className="mt-4"
|
|
||||||
>
|
|
||||||
{t("home.intro.done_button")}
|
|
||||||
</Button>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
router.back();
|
|
||||||
router.push("/settings");
|
|
||||||
}}
|
|
||||||
className="mt-4"
|
|
||||||
>
|
|
||||||
<Text className="text-purple-600 text-center">{t("home.intro.go_to_settings_button")}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { clearLogs } from "@/utils/log";
|
|||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { useNavigation, useRouter } from "expo-router";
|
import { useNavigation, useRouter } from "expo-router";
|
||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
import { useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
import { ScrollView, TouchableOpacity, View } from "react-native";
|
import { ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { Switch, View } from "react-native";
|
import { Switch, View } from "react-native";
|
||||||
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const [settings, updateSettings] = useSettings();
|
const [settings, updateSettings, pluginSettings] = useSettings();
|
||||||
const user = useAtomValue(userAtom);
|
const user = useAtomValue(userAtom);
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
|
|
||||||
@@ -35,7 +36,10 @@ export default function page() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="px-4">
|
<DisabledSetting
|
||||||
|
disabled={pluginSettings?.hiddenLibraries?.locked === true}
|
||||||
|
className="px-4"
|
||||||
|
>
|
||||||
<ListGroup>
|
<ListGroup>
|
||||||
{data?.map((view) => (
|
{data?.map((view) => (
|
||||||
<ListItem key={view.Id} title={view.Name} onPress={() => {}}>
|
<ListItem key={view.Id} title={view.Name} onPress={() => {}}>
|
||||||
@@ -56,6 +60,6 @@ export default function page() {
|
|||||||
Select the libraries you want to hide from the Library tab and home page
|
Select the libraries you want to hide from the Library tab and home page
|
||||||
sections.
|
sections.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</DisabledSetting>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,81 +1,16 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
|
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
|
||||||
import { OptimizedServerForm } from "@/components/settings/OptimizedServerForm";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getOrSetDeviceId } from "@/utils/device";
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
import { getStatistics } from "@/utils/optimize-server";
|
|
||||||
import { useMutation } from "@tanstack/react-query";
|
|
||||||
import { useNavigation } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
|
||||||
import { toast } from "sonner-native";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const navigation = useNavigation();
|
const [settings, updateSettings, pluginSettings] = useSettings();
|
||||||
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [settings, updateSettings] = useSettings();
|
|
||||||
|
|
||||||
const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] =
|
|
||||||
useState<string>(settings?.optimizedVersionsServerUrl || "");
|
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
|
||||||
mutationFn: async (newVal: string) => {
|
|
||||||
if (newVal.length === 0 || !newVal.startsWith("http")) {
|
|
||||||
toast.error(t("home.settings.toasts.invalid_url"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedUrl = newVal.endsWith("/") ? newVal : newVal + "/";
|
|
||||||
|
|
||||||
updateSettings({
|
|
||||||
optimizedVersionsServerUrl: updatedUrl,
|
|
||||||
});
|
|
||||||
|
|
||||||
return await getStatistics({
|
|
||||||
url: settings?.optimizedVersionsServerUrl,
|
|
||||||
authHeader: api?.accessToken,
|
|
||||||
deviceId: getOrSetDeviceId(),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onSuccess: (data) => {
|
|
||||||
if (data) {
|
|
||||||
toast.success(t("home.settings.toasts.connected"));
|
|
||||||
} else {
|
|
||||||
toast.error(t("home.settings.toasts.could_not_connect"));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast.error(t("home.settings.toasts.could_not_connect"));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const onSave = (newVal: string) => {
|
|
||||||
saveMutation.mutate(newVal);
|
|
||||||
};
|
|
||||||
|
|
||||||
// useEffect(() => {
|
|
||||||
// navigation.setOptions({
|
|
||||||
// title: "Optimized Server",
|
|
||||||
// headerRight: () =>
|
|
||||||
// saveMutation.isPending ? (
|
|
||||||
// <ActivityIndicator size={"small"} color={"white"} />
|
|
||||||
// ) : (
|
|
||||||
// <TouchableOpacity onPress={() => onSave(optimizedVersionsServerUrl)}>
|
|
||||||
// <Text className="text-blue-500">Save</Text>
|
|
||||||
// </TouchableOpacity>
|
|
||||||
// ),
|
|
||||||
// });
|
|
||||||
// }, [navigation, optimizedVersionsServerUrl, saveMutation.isPending]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="p-4">
|
<DisabledSetting
|
||||||
|
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
|
||||||
|
className="p-4"
|
||||||
|
>
|
||||||
<JellyseerrSettings />
|
<JellyseerrSettings />
|
||||||
</View>
|
</DisabledSetting>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useNavigation } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import React, {useEffect, useMemo, useState} from "react";
|
||||||
import {
|
import {
|
||||||
Linking,
|
Linking,
|
||||||
Switch,
|
Switch,
|
||||||
@@ -16,12 +15,14 @@ import {
|
|||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [settings, updateSettings] = useSettings();
|
const [settings, updateSettings, pluginSettings] = useSettings();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
|
const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
|
||||||
@@ -37,68 +38,80 @@ export default function page() {
|
|||||||
Linking.openURL("https://github.com/fredrikburmester/marlin-search");
|
Linking.openURL("https://github.com/fredrikburmester/marlin-search");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const disabled = useMemo(() => {
|
||||||
|
return pluginSettings?.searchEngine?.locked === true && pluginSettings?.marlinServerUrl?.locked === true
|
||||||
|
}, [pluginSettings]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
navigation.setOptions({
|
if (!pluginSettings?.marlinServerUrl?.locked) {
|
||||||
headerRight: () => (
|
navigation.setOptions({
|
||||||
<TouchableOpacity onPress={() => onSave(value)}>
|
headerRight: () => (
|
||||||
<Text className="text-blue-500">{t("home.settings.plugins.marlin_search.save_button")}</Text>
|
<TouchableOpacity onPress={() => onSave(value)}>
|
||||||
</TouchableOpacity>
|
<Text className="text-blue-500">{t("home.settings.plugins.marlin_search.save_button")}</Text>
|
||||||
),
|
</TouchableOpacity>
|
||||||
});
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
}, [navigation, value]);
|
}, [navigation, value]);
|
||||||
|
|
||||||
if (!settings) return null;
|
if (!settings) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="px-4">
|
<DisabledSetting
|
||||||
|
disabled={disabled}
|
||||||
|
className="px-4"
|
||||||
|
>
|
||||||
<ListGroup>
|
<ListGroup>
|
||||||
<ListItem
|
<DisabledSetting
|
||||||
title={t("home.settings.plugins.marlin_search.enable_marlin_search")}
|
disabled={pluginSettings?.searchEngine?.locked === true}
|
||||||
onPress={() => {
|
showText={!pluginSettings?.marlinServerUrl?.locked}
|
||||||
updateSettings({ searchEngine: "Jellyfin" });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Switch
|
<ListItem
|
||||||
value={settings.searchEngine === "Marlin"}
|
title={t("home.settings.plugins.marlin_search.enable_marlin_search")}
|
||||||
onValueChange={(value) => {
|
onPress={() => {
|
||||||
updateSettings({ searchEngine: value ? "Marlin" : "Jellyfin" });
|
updateSettings({ searchEngine: "Jellyfin" });
|
||||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
</ListItem>
|
<Switch
|
||||||
|
value={settings.searchEngine === "Marlin"}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
updateSettings({ searchEngine: value ? "Marlin" : "Jellyfin" });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
</DisabledSetting>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
|
|
||||||
<View
|
<DisabledSetting
|
||||||
className={`mt-2 ${
|
disabled={pluginSettings?.marlinServerUrl?.locked === true}
|
||||||
settings.searchEngine === "Marlin" ? "" : "opacity-50"
|
showText={!pluginSettings?.searchEngine?.locked}
|
||||||
}`}
|
className="mt-2 flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4"
|
||||||
>
|
>
|
||||||
<View className="flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4">
|
<View
|
||||||
<View
|
className={`flex flex-row items-center bg-neutral-900 h-11 pr-4`}
|
||||||
className={`flex flex-row items-center bg-neutral-900 h-11 pr-4`}
|
>
|
||||||
>
|
<Text className="mr-4">{t("home.settings.plugins.marlin_search.url")}</Text>
|
||||||
<Text className="mr-4">{t("home.settings.plugins.marlin_search.url")}</Text>
|
<TextInput
|
||||||
<TextInput
|
editable={settings.searchEngine === "Marlin"}
|
||||||
editable={settings.searchEngine === "Marlin"}
|
className="text-white"
|
||||||
className="text-white"
|
placeholder={t("home.settings.plugins.marlin_search.server_url_placeholder")}
|
||||||
placeholder={t("home.settings.plugins.marlin_search.server_url_placeholder")}
|
value={value}
|
||||||
value={value}
|
keyboardType="url"
|
||||||
keyboardType="url"
|
returnKeyType="done"
|
||||||
returnKeyType="done"
|
autoCapitalize="none"
|
||||||
autoCapitalize="none"
|
textContentType="URL"
|
||||||
textContentType="URL"
|
onChangeText={(text) => setValue(text)}
|
||||||
onChangeText={(text) => setValue(text)}
|
/>
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
<Text className="px-4 text-xs text-neutral-500 mt-1">
|
</DisabledSetting>
|
||||||
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
|
<Text className="px-4 text-xs text-neutral-500 mt-1">
|
||||||
<Text className="text-blue-500" onPress={handleOpenLink}>
|
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
|
||||||
{t("home.settings.plugins.marlin_search.read_more_about_marlin")}
|
<Text className="text-blue-500" onPress={handleOpenLink}>
|
||||||
</Text>
|
{t("home.settings.plugins.marlin_search.read_more_about_marlin")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</Text>
|
||||||
</View>
|
</DisabledSetting>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
@@ -18,7 +19,7 @@ export default function page() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [settings, updateSettings] = useSettings();
|
const [settings, updateSettings, pluginSettings] = useSettings();
|
||||||
|
|
||||||
const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] =
|
const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] =
|
||||||
useState<string>(settings?.optimizedVersionsServerUrl || "");
|
useState<string>(settings?.optimizedVersionsServerUrl || "");
|
||||||
@@ -59,25 +60,30 @@ export default function page() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
navigation.setOptions({
|
if (!pluginSettings?.optimizedVersionsServerUrl?.locked) {
|
||||||
title: t("home.settings.downloads.optimized_server"),
|
navigation.setOptions({
|
||||||
headerRight: () =>
|
title: t("home.settings.downloads.optimized_server"),
|
||||||
saveMutation.isPending ? (
|
headerRight: () =>
|
||||||
<ActivityIndicator size={"small"} color={"white"} />
|
saveMutation.isPending ? (
|
||||||
) : (
|
<ActivityIndicator size={"small"} color={"white"} />
|
||||||
<TouchableOpacity onPress={() => onSave(optimizedVersionsServerUrl)}>
|
) : (
|
||||||
<Text className="text-blue-500">{t("home.settings.downloads.save_button")}</Text>
|
<TouchableOpacity onPress={() => onSave(optimizedVersionsServerUrl)}>
|
||||||
</TouchableOpacity>
|
<Text className="text-blue-500">{t("home.settings.downloads.save_button")}</Text>
|
||||||
),
|
</TouchableOpacity>
|
||||||
});
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
}, [navigation, optimizedVersionsServerUrl, saveMutation.isPending]);
|
}, [navigation, optimizedVersionsServerUrl, saveMutation.isPending]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="p-4">
|
<DisabledSetting
|
||||||
|
disabled={pluginSettings?.optimizedVersionsServerUrl?.locked === true}
|
||||||
|
className="p-4"
|
||||||
|
>
|
||||||
<OptimizedServerForm
|
<OptimizedServerForm
|
||||||
value={optimizedVersionsServerUrl}
|
value={optimizedVersionsServerUrl}
|
||||||
onChangeValue={setOptimizedVersionsServerUrl}
|
onChangeValue={setOptimizedVersionsServerUrl}
|
||||||
/>
|
/>
|
||||||
</View>
|
</DisabledSetting>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,23 +2,23 @@ import { Text } from "@/components/common/Text";
|
|||||||
import { ListGroup } from "@/components/list/ListGroup";
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useNavigation } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { Linking, Switch, View } from "react-native";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Linking, Switch } from "react-native";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const navigation = useNavigation();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
const [settings, updateSettings] = useSettings();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [settings, updateSettings, pluginSettings] = useSettings();
|
||||||
|
|
||||||
const handleOpenLink = () => {
|
const handleOpenLink = () => {
|
||||||
Linking.openURL(
|
Linking.openURL(
|
||||||
@@ -50,13 +50,21 @@ export default function page() {
|
|||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const disabled = useMemo(
|
||||||
|
() =>
|
||||||
|
pluginSettings?.usePopularPlugin?.locked === true &&
|
||||||
|
pluginSettings?.mediaListCollectionIds?.locked === true,
|
||||||
|
[pluginSettings]
|
||||||
|
);
|
||||||
|
|
||||||
if (!settings) return null;
|
if (!settings) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="px-4 pt-4">
|
<DisabledSetting disabled={disabled} className="px-4 pt-4">
|
||||||
<ListGroup title={t("home.settings.plugins.popular_lists.enable_plugin")} className="">
|
<ListGroup title={t("home.settings.plugins.popular_lists.enable_plugin")} className="">
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.plugins.popular_lists.enable_popular_lists")}
|
title={t("home.settings.plugins.popular_lists.enable_popular_lists")}
|
||||||
|
disabled={pluginSettings?.usePopularPlugin?.locked}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
updateSettings({ usePopularPlugin: true });
|
updateSettings({ usePopularPlugin: true });
|
||||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||||
@@ -64,9 +72,10 @@ export default function page() {
|
|||||||
>
|
>
|
||||||
<Switch
|
<Switch
|
||||||
value={settings.usePopularPlugin}
|
value={settings.usePopularPlugin}
|
||||||
onValueChange={(value) => {
|
disabled={pluginSettings?.usePopularPlugin?.locked}
|
||||||
updateSettings({ usePopularPlugin: value });
|
onValueChange={(usePopularPlugin) =>
|
||||||
}}
|
updateSettings({ usePopularPlugin })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
@@ -89,8 +98,17 @@ export default function page() {
|
|||||||
<>
|
<>
|
||||||
<ListGroup title="Media List Collections" className="mt-4">
|
<ListGroup title="Media List Collections" className="mt-4">
|
||||||
{mediaListCollections?.map((mlc) => (
|
{mediaListCollections?.map((mlc) => (
|
||||||
<ListItem key={mlc.Id} title={mlc.Name}>
|
<ListItem
|
||||||
|
key={mlc.Id}
|
||||||
|
title={mlc.Name}
|
||||||
|
disabled={
|
||||||
|
pluginSettings?.mediaListCollectionIds?.locked
|
||||||
|
}
|
||||||
|
>
|
||||||
<Switch
|
<Switch
|
||||||
|
disabled={
|
||||||
|
pluginSettings?.mediaListCollectionIds?.locked
|
||||||
|
}
|
||||||
value={settings.mediaListCollectionIds?.includes(
|
value={settings.mediaListCollectionIds?.includes(
|
||||||
mlc.Id!
|
mlc.Id!
|
||||||
)}
|
)}
|
||||||
@@ -131,6 +149,6 @@ export default function page() {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</View>
|
</DisabledSetting>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,129 +0,0 @@
|
|||||||
import { Chromecast } from "@/components/Chromecast";
|
|
||||||
import { ItemImage } from "@/components/common/ItemImage";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
|
||||||
import { SongsList } from "@/components/music/SongsList";
|
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
|
||||||
import ArtistPoster from "@/components/posters/ArtistPoster";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
|
||||||
import { t } from "i18next";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { ScrollView, TouchableOpacity, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
|
|
||||||
export default function page() {
|
|
||||||
const searchParams = useLocalSearchParams();
|
|
||||||
const { collectionId, artistId, albumId } = searchParams as {
|
|
||||||
collectionId: string;
|
|
||||||
artistId: string;
|
|
||||||
albumId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
|
|
||||||
const navigation = useNavigation();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
navigation.setOptions({
|
|
||||||
headerRight: () => (
|
|
||||||
<View className="">
|
|
||||||
<Chromecast />
|
|
||||||
</View>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: album } = useQuery({
|
|
||||||
queryKey: ["album", albumId, artistId],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api) return null;
|
|
||||||
const response = await getItemsApi(api).getItems({
|
|
||||||
userId: user?.Id,
|
|
||||||
ids: [albumId],
|
|
||||||
});
|
|
||||||
const data = response.data.Items?.[0];
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
enabled: !!api && !!user?.Id && !!albumId,
|
|
||||||
staleTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: songs,
|
|
||||||
isLoading,
|
|
||||||
isError,
|
|
||||||
} = useQuery<{
|
|
||||||
Items: BaseItemDto[];
|
|
||||||
TotalRecordCount: number;
|
|
||||||
}>({
|
|
||||||
queryKey: ["songs", artistId, albumId],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api)
|
|
||||||
return {
|
|
||||||
Items: [],
|
|
||||||
TotalRecordCount: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await getItemsApi(api).getItems({
|
|
||||||
userId: user?.Id,
|
|
||||||
parentId: albumId,
|
|
||||||
fields: [
|
|
||||||
"ItemCounts",
|
|
||||||
"PrimaryImageAspectRatio",
|
|
||||||
"CanDelete",
|
|
||||||
"MediaSourceCount",
|
|
||||||
],
|
|
||||||
sortBy: ["ParentIndexNumber", "IndexNumber", "SortName"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = response.data.Items;
|
|
||||||
|
|
||||||
return {
|
|
||||||
Items: data || [],
|
|
||||||
TotalRecordCount: response.data.TotalRecordCount || 0,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
enabled: !!api && !!user?.Id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
if (!album) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ParallaxScrollView
|
|
||||||
headerHeight={400}
|
|
||||||
headerImage={
|
|
||||||
<ItemImage
|
|
||||||
variant={"Primary"}
|
|
||||||
item={album}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<View className="px-4 mb-8">
|
|
||||||
<Text className="font-bold text-2xl mb-2">{album?.Name}</Text>
|
|
||||||
<Text className="text-neutral-500">
|
|
||||||
{t("item_card.x_songs", { count: songs?.TotalRecordCount })}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View className="px-4">
|
|
||||||
<SongsList
|
|
||||||
albumId={albumId}
|
|
||||||
songs={songs?.Items}
|
|
||||||
collectionId={collectionId}
|
|
||||||
artistId={artistId}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</ParallaxScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
import ArtistPoster from "@/components/posters/ArtistPoster";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
|
||||||
import { t } from "i18next";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { FlatList, ScrollView, TouchableOpacity, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { ItemImage } from "@/components/common/ItemImage";
|
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
|
||||||
|
|
||||||
export default function page() {
|
|
||||||
const searchParams = useLocalSearchParams();
|
|
||||||
const { artistId } = searchParams as {
|
|
||||||
artistId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
|
|
||||||
const navigation = useNavigation();
|
|
||||||
|
|
||||||
const [startIndex, setStartIndex] = useState<number>(0);
|
|
||||||
|
|
||||||
const { data: artist } = useQuery({
|
|
||||||
queryKey: ["album", artistId],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api) return null;
|
|
||||||
const response = await getItemsApi(api).getItems({
|
|
||||||
userId: user?.Id,
|
|
||||||
ids: [artistId],
|
|
||||||
});
|
|
||||||
const data = response.data.Items?.[0];
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
enabled: !!api && !!user?.Id && !!artistId,
|
|
||||||
staleTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: albums,
|
|
||||||
isLoading,
|
|
||||||
isError,
|
|
||||||
} = useQuery<{
|
|
||||||
Items: BaseItemDto[];
|
|
||||||
TotalRecordCount: number;
|
|
||||||
}>({
|
|
||||||
queryKey: ["albums", artistId, startIndex],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api)
|
|
||||||
return {
|
|
||||||
Items: [],
|
|
||||||
TotalRecordCount: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await getItemsApi(api).getItems({
|
|
||||||
userId: user?.Id,
|
|
||||||
parentId: artistId,
|
|
||||||
sortOrder: ["Descending", "Descending", "Ascending"],
|
|
||||||
includeItemTypes: ["MusicAlbum"],
|
|
||||||
recursive: true,
|
|
||||||
fields: [
|
|
||||||
"ParentId",
|
|
||||||
"PrimaryImageAspectRatio",
|
|
||||||
"ParentId",
|
|
||||||
"PrimaryImageAspectRatio",
|
|
||||||
],
|
|
||||||
collapseBoxSetItems: false,
|
|
||||||
albumArtistIds: [artistId],
|
|
||||||
startIndex,
|
|
||||||
limit: 100,
|
|
||||||
sortBy: ["PremiereDate", "ProductionYear", "SortName"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = response.data.Items;
|
|
||||||
|
|
||||||
return {
|
|
||||||
Items: data || [],
|
|
||||||
TotalRecordCount: response.data.TotalRecordCount || 0,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
enabled: !!api && !!user?.Id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
if (!artist || !albums) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ParallaxScrollView
|
|
||||||
headerHeight={400}
|
|
||||||
headerImage={
|
|
||||||
<ItemImage
|
|
||||||
variant={"Primary"}
|
|
||||||
item={artist}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<View className="px-4 mb-8">
|
|
||||||
<Text className="font-bold text-2xl mb-2">{artist?.Name}</Text>
|
|
||||||
<Text className="text-neutral-500">
|
|
||||||
{t("item_card.x_albums", { count: albums.TotalRecordCount })}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View className="flex flex-row flex-wrap justify-between px-4">
|
|
||||||
{albums.Items.map((item, idx) => (
|
|
||||||
<TouchableItemRouter
|
|
||||||
item={item}
|
|
||||||
style={{ width: "30%", marginBottom: 20 }}
|
|
||||||
key={idx}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col gap-y-2">
|
|
||||||
<ArtistPoster item={item} />
|
|
||||||
<Text numberOfLines={2}>{item.Name}</Text>
|
|
||||||
<Text className="opacity-50 text-xs">{item.ProductionYear}</Text>
|
|
||||||
</View>
|
|
||||||
</TouchableItemRouter>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</ParallaxScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
|
||||||
import ArtistPoster from "@/components/posters/ArtistPoster";
|
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { getArtistsApi, getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { router, useLocalSearchParams } from "expo-router";
|
|
||||||
import { t } from "i18next";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
import { FlatList, TouchableOpacity, View } from "react-native";
|
|
||||||
|
|
||||||
export default function page() {
|
|
||||||
const searchParams = useLocalSearchParams();
|
|
||||||
const { collectionId } = searchParams as { collectionId: string };
|
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
|
|
||||||
const { data: collection } = useQuery({
|
|
||||||
queryKey: ["collection", collectionId],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api) return null;
|
|
||||||
const response = await getItemsApi(api).getItems({
|
|
||||||
userId: user?.Id,
|
|
||||||
ids: [collectionId],
|
|
||||||
});
|
|
||||||
const data = response.data.Items?.[0];
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
enabled: !!api && !!user?.Id && !!collectionId,
|
|
||||||
staleTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [startIndex, setStartIndex] = useState<number>(0);
|
|
||||||
|
|
||||||
const { data, isLoading, isError } = useQuery<{
|
|
||||||
Items: BaseItemDto[];
|
|
||||||
TotalRecordCount: number;
|
|
||||||
}>({
|
|
||||||
queryKey: ["collection-items", collection?.Id, startIndex],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api || !collectionId)
|
|
||||||
return {
|
|
||||||
Items: [],
|
|
||||||
TotalRecordCount: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await getArtistsApi(api).getArtists({
|
|
||||||
sortBy: ["SortName"],
|
|
||||||
sortOrder: ["Ascending"],
|
|
||||||
fields: ["PrimaryImageAspectRatio", "SortName"],
|
|
||||||
imageTypeLimit: 1,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
|
|
||||||
parentId: collectionId,
|
|
||||||
userId: user?.Id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = response.data.Items;
|
|
||||||
|
|
||||||
return {
|
|
||||||
Items: data || [],
|
|
||||||
TotalRecordCount: response.data.TotalRecordCount || 0,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
enabled: !!collection?.Id && !!api && !!user?.Id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const totalItems = useMemo(() => {
|
|
||||||
return data?.TotalRecordCount;
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
if (!data) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FlatList
|
|
||||||
contentContainerStyle={{
|
|
||||||
padding: 16,
|
|
||||||
paddingBottom: 140,
|
|
||||||
}}
|
|
||||||
ListHeaderComponent={
|
|
||||||
<View className="mb-4">
|
|
||||||
<Text className="font-bold text-3xl mb-2">{t("item_card.artists")}</Text>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
nestedScrollEnabled
|
|
||||||
data={data.Items}
|
|
||||||
numColumns={3}
|
|
||||||
columnWrapperStyle={{
|
|
||||||
justifyContent: "space-between",
|
|
||||||
}}
|
|
||||||
renderItem={({ item, index }) => (
|
|
||||||
<TouchableItemRouter
|
|
||||||
style={{
|
|
||||||
maxWidth: "30%",
|
|
||||||
width: "100%",
|
|
||||||
}}
|
|
||||||
key={index}
|
|
||||||
item={item}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col gap-y-2">
|
|
||||||
{collection?.CollectionType === "movies" && (
|
|
||||||
<MoviePoster item={item} />
|
|
||||||
)}
|
|
||||||
{collection?.CollectionType === "music" && (
|
|
||||||
<ArtistPoster item={item} />
|
|
||||||
)}
|
|
||||||
<Text>{item.Name}</Text>
|
|
||||||
<Text className="opacity-50 text-xs">{item.ProductionYear}</Text>
|
|
||||||
</View>
|
|
||||||
</TouchableItemRouter>
|
|
||||||
)}
|
|
||||||
keyExtractor={(item) => item.Id || ""}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -112,7 +112,7 @@ const page: React.FC = () => {
|
|||||||
genres: selectedGenres,
|
genres: selectedGenres,
|
||||||
tags: selectedTags,
|
tags: selectedTags,
|
||||||
years: selectedYears.map((year) => parseInt(year)),
|
years: selectedYears.map((year) => parseInt(year)),
|
||||||
includeItemTypes: ["Movie", "Series", "MusicAlbum"],
|
includeItemTypes: ["Movie", "Series"],
|
||||||
});
|
});
|
||||||
|
|
||||||
return response.data || null;
|
return response.data || null;
|
||||||
|
|||||||
@@ -29,10 +29,13 @@ import {
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
import React, {useCallback, useEffect, useMemo, useRef, useState} from "react";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||||
|
import RequestModal from "@/components/jellyseerr/RequestModal";
|
||||||
|
import {ANIME_KEYWORD_ID} from "@/utils/jellyseerr/server/api/themoviedb/constants";
|
||||||
|
import {MediaRequestBody} from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
||||||
|
|
||||||
const Page: React.FC = () => {
|
const Page: React.FC = () => {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
@@ -52,6 +55,7 @@ const Page: React.FC = () => {
|
|||||||
|
|
||||||
const [issueType, setIssueType] = useState<IssueType>();
|
const [issueType, setIssueType] = useState<IssueType>();
|
||||||
const [issueMessage, setIssueMessage] = useState<string>();
|
const [issueMessage, setIssueMessage] = useState<string>();
|
||||||
|
const advancedReqModalRef = useRef<BottomSheetModal>(null);
|
||||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -75,7 +79,7 @@ const Page: React.FC = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const canRequest = useJellyseerrCanRequest(details);
|
const [canRequest, hasAdvancedRequestPermission] = useJellyseerrCanRequest(details);
|
||||||
|
|
||||||
const renderBackdrop = useCallback(
|
const renderBackdrop = useCallback(
|
||||||
(props: BottomSheetBackdropProps) => (
|
(props: BottomSheetBackdropProps) => (
|
||||||
@@ -101,19 +105,27 @@ const Page: React.FC = () => {
|
|||||||
}, [jellyseerrApi, details, result, issueType, issueMessage]);
|
}, [jellyseerrApi, details, result, issueType, issueMessage]);
|
||||||
|
|
||||||
const request = useCallback(async () => {
|
const request = useCallback(async () => {
|
||||||
requestMedia(
|
const body: MediaRequestBody = {
|
||||||
mediaTitle,
|
mediaId: Number(result.id!!),
|
||||||
{
|
mediaType: result.mediaType!!,
|
||||||
mediaId: Number(result.id!!),
|
tvdbId: details?.externalIds?.tvdbId,
|
||||||
mediaType: result.mediaType!!,
|
seasons: (details as TvDetails)?.seasons
|
||||||
tvdbId: details?.externalIds?.tvdbId,
|
?.filter?.((s) => s.seasonNumber !== 0)
|
||||||
seasons: (details as TvDetails)?.seasons
|
?.map?.((s) => s.seasonNumber),
|
||||||
?.filter?.((s) => s.seasonNumber !== 0)
|
}
|
||||||
?.map?.((s) => s.seasonNumber),
|
|
||||||
},
|
if (hasAdvancedRequestPermission) {
|
||||||
refetch
|
advancedReqModalRef?.current?.present?.(body)
|
||||||
);
|
return
|
||||||
}, [details, result, requestMedia]);
|
}
|
||||||
|
|
||||||
|
requestMedia(mediaTitle, body, refetch);
|
||||||
|
}, [details, result, requestMedia, hasAdvancedRequestPermission]);
|
||||||
|
|
||||||
|
const isAnime = useMemo(
|
||||||
|
() => (details?.keywords.some(k => k.id === ANIME_KEYWORD_ID) || false) && result.mediaType === MediaType.TV,
|
||||||
|
[details]
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (details) {
|
if (details) {
|
||||||
@@ -232,6 +244,10 @@ const Page: React.FC = () => {
|
|||||||
result={result as TvResult}
|
result={result as TvResult}
|
||||||
details={details as TvDetails}
|
details={details as TvDetails}
|
||||||
refetch={refetch}
|
refetch={refetch}
|
||||||
|
hasAdvancedRequest={hasAdvancedRequestPermission}
|
||||||
|
onAdvancedRequest={(data) =>
|
||||||
|
advancedReqModalRef?.current?.present(data)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<DetailFacts
|
<DetailFacts
|
||||||
@@ -242,6 +258,17 @@ const Page: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</ParallaxScrollView>
|
</ParallaxScrollView>
|
||||||
|
<RequestModal
|
||||||
|
ref={advancedReqModalRef}
|
||||||
|
title={mediaTitle}
|
||||||
|
id={result.id!!}
|
||||||
|
type={result.mediaType as MediaType}
|
||||||
|
isAnime={isAnime}
|
||||||
|
onRequested={() => {
|
||||||
|
advancedReqModalRef?.current?.close()
|
||||||
|
refetch()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<BottomSheetModal
|
<BottomSheetModal
|
||||||
ref={bottomSheetModalRef}
|
ref={bottomSheetModalRef}
|
||||||
enableDynamicSizing
|
enableDynamicSizing
|
||||||
|
|||||||
@@ -153,8 +153,6 @@ const Page = () => {
|
|||||||
itemType = "Series";
|
itemType = "Series";
|
||||||
} else if (library.CollectionType === "boxsets") {
|
} else if (library.CollectionType === "boxsets") {
|
||||||
itemType = "BoxSet";
|
itemType = "BoxSet";
|
||||||
} else if (library.CollectionType === "music") {
|
|
||||||
itemType = "MusicAlbum";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await getItemsApi(api).getItems({
|
const response = await getItemsApi(api).getItems({
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import * as DropdownMenu from "zeego/dropdown-menu";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function IndexLayout() {
|
export default function IndexLayout() {
|
||||||
const [settings, updateSettings] = useSettings();
|
const [settings, updateSettings, pluginSettings] = useSettings();
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -28,6 +28,7 @@ export default function IndexLayout() {
|
|||||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
|
!pluginSettings?.libraryOptions?.locked &&
|
||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger>
|
<DropdownMenu.Trigger>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
|
|||||||
@@ -36,7 +36,11 @@ export default function index() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const libraries = useMemo(
|
const libraries = useMemo(
|
||||||
() => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
|
() =>
|
||||||
|
data
|
||||||
|
?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!))
|
||||||
|
.filter((l) => l.CollectionType !== "music")
|
||||||
|
.filter((l) => l.CollectionType !== "books") || [],
|
||||||
[data, settings?.hiddenLibraries]
|
[data, settings?.hiddenLibraries]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
|||||||
import { Tag } from "@/components/GenreTags";
|
import { Tag } from "@/components/GenreTags";
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
import { JellyserrIndexPage } from "@/components/jellyseerr/JellyseerrIndexPage";
|
import { JellyserrIndexPage } from "@/components/jellyseerr/JellyseerrIndexPage";
|
||||||
import AlbumCover from "@/components/posters/AlbumCover";
|
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
import SeriesPoster from "@/components/posters/SeriesPoster";
|
import SeriesPoster from "@/components/posters/SeriesPoster";
|
||||||
import { LoadingSkeleton } from "@/components/search/LoadingSkeleton";
|
import { LoadingSkeleton } from "@/components/search/LoadingSkeleton";
|
||||||
@@ -187,52 +186,19 @@ export default function search() {
|
|||||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: artists, isFetching: l4 } = useQuery({
|
|
||||||
queryKey: ["search", "artists", debouncedSearch],
|
|
||||||
queryFn: () =>
|
|
||||||
searchFn({
|
|
||||||
query: debouncedSearch,
|
|
||||||
types: ["MusicArtist"],
|
|
||||||
}),
|
|
||||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: albums, isFetching: l5 } = useQuery({
|
|
||||||
queryKey: ["search", "albums", debouncedSearch],
|
|
||||||
queryFn: () =>
|
|
||||||
searchFn({
|
|
||||||
query: debouncedSearch,
|
|
||||||
types: ["MusicAlbum"],
|
|
||||||
}),
|
|
||||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: songs, isFetching: l6 } = useQuery({
|
|
||||||
queryKey: ["search", "songs", debouncedSearch],
|
|
||||||
queryFn: () =>
|
|
||||||
searchFn({
|
|
||||||
query: debouncedSearch,
|
|
||||||
types: ["Audio"],
|
|
||||||
}),
|
|
||||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const noResults = useMemo(() => {
|
const noResults = useMemo(() => {
|
||||||
return !(
|
return !(
|
||||||
artists?.length ||
|
|
||||||
albums?.length ||
|
|
||||||
songs?.length ||
|
|
||||||
movies?.length ||
|
movies?.length ||
|
||||||
episodes?.length ||
|
episodes?.length ||
|
||||||
series?.length ||
|
series?.length ||
|
||||||
collections?.length ||
|
collections?.length ||
|
||||||
actors?.length
|
actors?.length
|
||||||
);
|
);
|
||||||
}, [artists, episodes, albums, songs, movies, series, collections, actors]);
|
}, [episodes, movies, series, collections, actors]);
|
||||||
|
|
||||||
const loading = useMemo(() => {
|
const loading = useMemo(() => {
|
||||||
return l1 || l2 || l3 || l4 || l5 || l6 || l7 || l8;
|
return l1 || l2 || l3 || l7 || l8;
|
||||||
}, [l1, l2, l3, l4, l5, l6, l7, l8]);
|
}, [l1, l2, l3, l7, l8]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -368,48 +334,6 @@ export default function search() {
|
|||||||
</TouchableItemRouter>
|
</TouchableItemRouter>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<SearchItemWrapper
|
|
||||||
ids={artists?.map((m) => m.Id!)}
|
|
||||||
header={t("search.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={t("search.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={t("search.songs")}
|
|
||||||
renderItem={(item: BaseItemDto) => (
|
|
||||||
<TouchableItemRouter
|
|
||||||
item={item}
|
|
||||||
key={item.Id}
|
|
||||||
className="flex flex-col w-28 mr-2"
|
|
||||||
>
|
|
||||||
<AlbumCover id={item.AlbumId} />
|
|
||||||
<ItemCardText item={item} />
|
|
||||||
</TouchableItemRouter>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<JellyserrIndexPage searchQuery={debouncedSearch} />
|
<JellyserrIndexPage searchQuery={debouncedSearch} />
|
||||||
|
|||||||
@@ -25,15 +25,6 @@ export default function Layout() {
|
|||||||
animation: "fade",
|
animation: "fade",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
|
||||||
name="music-player"
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
autoHideHomeIndicator: true,
|
|
||||||
title: "",
|
|
||||||
animation: "fade",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,421 +0,0 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { Controls } from "@/components/video-player/controls/Controls";
|
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
|
||||||
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
|
|
||||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
|
||||||
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
|
||||||
import { secondsToTicks } from "@/utils/secondsToTicks";
|
|
||||||
import { Api } from "@jellyfin/sdk";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import {
|
|
||||||
getPlaystateApi,
|
|
||||||
getUserLibraryApi,
|
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { useFocusEffect, useLocalSearchParams } from "expo-router";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
|
||||||
import { Pressable, useWindowDimensions, View } from "react-native";
|
|
||||||
import { useSharedValue } from "react-native-reanimated";
|
|
||||||
import Video, { OnProgressData, VideoRef } from "react-native-video";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
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 { t } = useTranslation();
|
|
||||||
|
|
||||||
const firstTime = useRef(true);
|
|
||||||
|
|
||||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
|
||||||
const [showControls, setShowControls] = useState(true);
|
|
||||||
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
|
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
|
||||||
const [isBuffering, setIsBuffering] = useState(true);
|
|
||||||
|
|
||||||
const progress = useSharedValue(0);
|
|
||||||
const isSeeking = useSharedValue(false);
|
|
||||||
const cacheProgress = useSharedValue(0);
|
|
||||||
|
|
||||||
const lightHapticFeedback = useHaptic("light");
|
|
||||||
|
|
||||||
const {
|
|
||||||
itemId,
|
|
||||||
audioIndex: audioIndexStr,
|
|
||||||
subtitleIndex: subtitleIndexStr,
|
|
||||||
mediaSourceId,
|
|
||||||
bitrateValue: bitrateValueStr,
|
|
||||||
} = useLocalSearchParams<{
|
|
||||||
itemId: string;
|
|
||||||
audioIndex: string;
|
|
||||||
subtitleIndex: string;
|
|
||||||
mediaSourceId: string;
|
|
||||||
bitrateValue: string;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
|
|
||||||
const subtitleIndex = subtitleIndexStr
|
|
||||||
? parseInt(subtitleIndexStr, 10)
|
|
||||||
: undefined;
|
|
||||||
const bitrateValue = bitrateValueStr
|
|
||||||
? parseInt(bitrateValueStr, 10)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: item,
|
|
||||||
isLoading: isLoadingItem,
|
|
||||||
isError: isErrorItem,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["item", itemId],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api) return;
|
|
||||||
const res = await getUserLibraryApi(api).getItem({
|
|
||||||
itemId,
|
|
||||||
userId: user?.Id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.data;
|
|
||||||
},
|
|
||||||
enabled: !!itemId && !!api,
|
|
||||||
staleTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: stream,
|
|
||||||
isLoading: isLoadingStreamUrl,
|
|
||||||
isError: isErrorStreamUrl,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["stream-url"],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api) return;
|
|
||||||
const res = await getStreamUrl({
|
|
||||||
api,
|
|
||||||
item,
|
|
||||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
|
||||||
userId: user?.Id,
|
|
||||||
audioStreamIndex: audioIndex,
|
|
||||||
maxStreamingBitrate: bitrateValue,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
subtitleStreamIndex: subtitleIndex,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res) return null;
|
|
||||||
|
|
||||||
const { mediaSource, sessionId, url } = res;
|
|
||||||
|
|
||||||
if (!sessionId || !mediaSource || !url) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
mediaSource,
|
|
||||||
sessionId,
|
|
||||||
url,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const poster = usePoster(item, api);
|
|
||||||
const videoSource = useVideoSource(item, api, poster, stream?.url);
|
|
||||||
|
|
||||||
const togglePlay = useCallback(
|
|
||||||
async (ticks: number) => {
|
|
||||||
lightHapticFeedback();
|
|
||||||
if (isPlaying) {
|
|
||||||
videoRef.current?.pause();
|
|
||||||
await getPlaystateApi(api!).onPlaybackProgress({
|
|
||||||
itemId: item?.Id!,
|
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
positionTicks: Math.floor(ticks),
|
|
||||||
isPaused: true,
|
|
||||||
playMethod: stream?.url.includes("m3u8")
|
|
||||||
? "Transcode"
|
|
||||||
: "DirectStream",
|
|
||||||
playSessionId: stream?.sessionId,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
videoRef.current?.resume();
|
|
||||||
await getPlaystateApi(api!).onPlaybackProgress({
|
|
||||||
itemId: item?.Id!,
|
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
positionTicks: Math.floor(ticks),
|
|
||||||
isPaused: false,
|
|
||||||
playMethod: stream?.url.includes("m3u8")
|
|
||||||
? "Transcode"
|
|
||||||
: "DirectStream",
|
|
||||||
playSessionId: stream?.sessionId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[
|
|
||||||
isPlaying,
|
|
||||||
api,
|
|
||||||
item,
|
|
||||||
videoRef,
|
|
||||||
settings,
|
|
||||||
audioIndex,
|
|
||||||
subtitleIndex,
|
|
||||||
mediaSourceId,
|
|
||||||
stream,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
const play = useCallback(() => {
|
|
||||||
videoRef.current?.resume();
|
|
||||||
reportPlaybackStart();
|
|
||||||
}, [videoRef]);
|
|
||||||
|
|
||||||
const pause = useCallback(() => {
|
|
||||||
videoRef.current?.pause();
|
|
||||||
}, [videoRef]);
|
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
|
||||||
setIsPlaybackStopped(true);
|
|
||||||
videoRef.current?.pause();
|
|
||||||
reportPlaybackStopped();
|
|
||||||
}, [videoRef]);
|
|
||||||
|
|
||||||
const seek = useCallback(
|
|
||||||
(seconds: number) => {
|
|
||||||
videoRef.current?.seek(seconds);
|
|
||||||
},
|
|
||||||
[videoRef]
|
|
||||||
);
|
|
||||||
|
|
||||||
const reportPlaybackStopped = async () => {
|
|
||||||
if (!item?.Id) return;
|
|
||||||
await getPlaystateApi(api!).onPlaybackStopped({
|
|
||||||
itemId: item.Id,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
positionTicks: Math.floor(progress.value),
|
|
||||||
playSessionId: stream?.sessionId,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const reportPlaybackStart = async () => {
|
|
||||||
if (!item?.Id) return;
|
|
||||||
await getPlaystateApi(api!).onPlaybackStart({
|
|
||||||
itemId: item?.Id,
|
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
|
||||||
playSessionId: stream?.sessionId,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const onProgress = useCallback(
|
|
||||||
async (data: OnProgressData) => {
|
|
||||||
if (isSeeking.value === true) return;
|
|
||||||
if (isPlaybackStopped === true) return;
|
|
||||||
|
|
||||||
const ticks = data.currentTime * 10000000;
|
|
||||||
|
|
||||||
progress.value = secondsToTicks(data.currentTime);
|
|
||||||
cacheProgress.value = secondsToTicks(data.playableDuration);
|
|
||||||
setIsBuffering(data.playableDuration === 0);
|
|
||||||
|
|
||||||
if (!item?.Id || data.currentTime === 0) return;
|
|
||||||
|
|
||||||
await getPlaystateApi(api!).onPlaybackProgress({
|
|
||||||
itemId: item.Id!,
|
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
positionTicks: Math.round(ticks),
|
|
||||||
isPaused: !isPlaying,
|
|
||||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
|
||||||
playSessionId: stream?.sessionId,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[
|
|
||||||
item,
|
|
||||||
isPlaying,
|
|
||||||
api,
|
|
||||||
isPlaybackStopped,
|
|
||||||
audioIndex,
|
|
||||||
subtitleIndex,
|
|
||||||
mediaSourceId,
|
|
||||||
stream,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
useFocusEffect(
|
|
||||||
useCallback(() => {
|
|
||||||
play();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
stop();
|
|
||||||
};
|
|
||||||
}, [play, stop])
|
|
||||||
);
|
|
||||||
|
|
||||||
useOrientation();
|
|
||||||
useOrientationSettings();
|
|
||||||
|
|
||||||
useWebSocket({
|
|
||||||
isPlaying: isPlaying,
|
|
||||||
pauseVideo: pause,
|
|
||||||
playVideo: play,
|
|
||||||
stopPlayback: stop,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isLoadingItem || isLoadingStreamUrl)
|
|
||||||
return (
|
|
||||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isErrorItem || isErrorStreamUrl)
|
|
||||||
return (
|
|
||||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
|
||||||
<Text className="text-white">{t("player.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">{t("player.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;
|
|
||||||
}
|
|
||||||
@@ -416,7 +416,6 @@ const Player = () => {
|
|||||||
playWhenInactive={true}
|
playWhenInactive={true}
|
||||||
allowsExternalPlayback={true}
|
allowsExternalPlayback={true}
|
||||||
playInBackground={true}
|
playInBackground={true}
|
||||||
pictureInPicture={true}
|
|
||||||
showNotificationControls={true}
|
showNotificationControls={true}
|
||||||
ignoreSilentSwitch="ignore"
|
ignoreSilentSwitch="ignore"
|
||||||
fullscreen={false}
|
fullscreen={false}
|
||||||
@@ -534,7 +533,6 @@ export function useVideoSource(
|
|||||||
startPosition,
|
startPosition,
|
||||||
headers: getAuthHeaders(api),
|
headers: getAuthHeaders(api),
|
||||||
metadata: {
|
metadata: {
|
||||||
artist: item?.AlbumArtist ?? undefined,
|
|
||||||
title: item?.Name || "Unknown",
|
title: item?.Name || "Unknown",
|
||||||
description: item?.Overview ?? undefined,
|
description: item?.Overview ?? undefined,
|
||||||
imageUri: poster,
|
imageUri: poster,
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import { Link, Stack, usePathname } from "expo-router";
|
import { Link, Stack } from "expo-router";
|
||||||
import { StyleSheet } from "react-native";
|
import { StyleSheet } from "react-native";
|
||||||
|
|
||||||
import { ThemedText } from "@/components/ThemedText";
|
import { ThemedText } from "@/components/ThemedText";
|
||||||
import { ThemedView } from "@/components/ThemedView";
|
import { ThemedView } from "@/components/ThemedView";
|
||||||
import { useEffect } from "react";
|
|
||||||
|
|
||||||
export default function NotFoundScreen() {
|
export default function NotFoundScreen() {
|
||||||
const pathname = usePathname();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stack.Screen options={{ title: "Oops!" }} />
|
<Stack.Screen options={{ title: "Oops!" }} />
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { Input } from "@/components/common/Input";
|
import { Input } from "@/components/common/Input";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
|
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
|
||||||
import { PreviousServersList } from "@/components/PreviousServersList";
|
import { PreviousServersList } from "@/components/PreviousServersList";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
||||||
import {
|
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
Ionicons,
|
|
||||||
MaterialCommunityIcons,
|
|
||||||
MaterialIcons,
|
|
||||||
} from "@expo/vector-icons";
|
|
||||||
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { getSystemApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
@@ -310,6 +306,15 @@ const CredentialsSchema = z.object({
|
|||||||
>
|
>
|
||||||
{t("server.connect_button")}
|
{t("server.connect_button")}
|
||||||
</Button>
|
</Button>
|
||||||
|
<JellyfinServerDiscovery
|
||||||
|
onServerSelect={(server) => {
|
||||||
|
setServerURL(server.address);
|
||||||
|
if (server.serverName) {
|
||||||
|
setServerName(server.serverName);
|
||||||
|
}
|
||||||
|
handleConnect(server.address);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<PreviousServersList
|
<PreviousServersList
|
||||||
onServerSelect={(s) => {
|
onServerSelect={(s) => {
|
||||||
handleConnect(s.address);
|
handleConnect(s.address);
|
||||||
|
|||||||
46
augmentations/api.ts
Normal file
46
augmentations/api.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { Api, AUTHORIZATION_HEADER } from "@jellyfin/sdk";
|
||||||
|
import { AxiosRequestConfig, AxiosResponse } from "axios";
|
||||||
|
import { StreamyfinPluginConfig } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
|
declare module "@jellyfin/sdk" {
|
||||||
|
interface Api {
|
||||||
|
get<T, D = any>(
|
||||||
|
url: string,
|
||||||
|
config?: AxiosRequestConfig<D>
|
||||||
|
): Promise<AxiosResponse<T>>;
|
||||||
|
post<T, D = any>(
|
||||||
|
url: string,
|
||||||
|
data: D,
|
||||||
|
config?: AxiosRequestConfig<D>
|
||||||
|
): Promise<AxiosResponse<T>>;
|
||||||
|
getStreamyfinPluginConfig(): Promise<AxiosResponse<StreamyfinPluginConfig>>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Api.prototype.get = function <T, D = any>(
|
||||||
|
url: string,
|
||||||
|
config: AxiosRequestConfig<D> = {}
|
||||||
|
): Promise<AxiosResponse<T>> {
|
||||||
|
return this.axiosInstance.get<T>(`${this.basePath}${url}`, {
|
||||||
|
...(config ?? {}),
|
||||||
|
headers: { [AUTHORIZATION_HEADER]: this.authorizationHeader },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
Api.prototype.post = function <T, D = any>(
|
||||||
|
url: string,
|
||||||
|
data: D,
|
||||||
|
config: AxiosRequestConfig<D>
|
||||||
|
): Promise<AxiosResponse<T>> {
|
||||||
|
return this.axiosInstance.post<T>(`${this.basePath}${url}`, {
|
||||||
|
...(config || {}),
|
||||||
|
data,
|
||||||
|
headers: { [AUTHORIZATION_HEADER]: this.authorizationHeader },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
Api.prototype.getStreamyfinPluginConfig = function (): Promise<
|
||||||
|
AxiosResponse<StreamyfinPluginConfig>
|
||||||
|
> {
|
||||||
|
return this.get<StreamyfinPluginConfig>("/Streamyfin/config");
|
||||||
|
};
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export * from "./api";
|
||||||
export * from "./mmkv";
|
export * from "./mmkv";
|
||||||
export * from "./number";
|
export * from "./number";
|
||||||
export * from "./string";
|
export * from "./string";
|
||||||
|
|||||||
@@ -13,5 +13,10 @@ MMKV.prototype.get = function <T> (key: string): T | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
MMKV.prototype.setAny = function (key: string, value: any | undefined): void {
|
MMKV.prototype.setAny = function (key: string, value: any | undefined): void {
|
||||||
this.set(key, JSON.stringify(value));
|
if (value === undefined) {
|
||||||
|
this.delete(key)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.set(key, JSON.stringify(value));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,25 +1,23 @@
|
|||||||
declare global {
|
declare global {
|
||||||
interface Number {
|
interface Number {
|
||||||
bytesToReadable(): string;
|
bytesToReadable(decimals?: number): string;
|
||||||
secondsToMilliseconds(): number;
|
secondsToMilliseconds(): number;
|
||||||
minutesToMilliseconds(): number;
|
minutesToMilliseconds(): number;
|
||||||
hoursToMilliseconds(): number;
|
hoursToMilliseconds(): number;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Number.prototype.bytesToReadable = function () {
|
Number.prototype.bytesToReadable = function (decimals: number = 2) {
|
||||||
const bytes = this.valueOf();
|
const bytes = this.valueOf();
|
||||||
const gb = bytes / 1e9;
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
|
||||||
if (gb >= 1) return `${gb.toFixed(0)} GB`;
|
const k = 1024;
|
||||||
|
const dm = decimals < 0 ? 0 : decimals;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||||
|
|
||||||
const mb = bytes / 1024.0 / 1024.0;
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
if (mb >= 1) return `${mb.toFixed(0)} MB`;
|
|
||||||
|
|
||||||
const kb = bytes / 1024.0;
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||||
if (kb >= 1) return `${kb.toFixed(0)} KB`;
|
|
||||||
|
|
||||||
return `${bytes.toFixed(2)} B`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Number.prototype.secondsToMilliseconds = function () {
|
Number.prototype.secondsToMilliseconds = function () {
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ export const BITRATES: Bitrate[] = [
|
|||||||
key: "2 Mb/s",
|
key: "2 Mb/s",
|
||||||
value: 2000000,
|
value: 2000000,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "1 Mb/s",
|
||||||
|
value: 1000000,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "500 Kb/s",
|
key: "500 Kb/s",
|
||||||
value: 500000,
|
value: 500000,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
|
|||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { queueActions, queueAtom } from "@/utils/atoms/queue";
|
import { queueActions, queueAtom } from "@/utils/atoms/queue";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import {DownloadMethod, useSettings} from "@/utils/atoms/settings";
|
||||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server";
|
import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server";
|
||||||
@@ -76,7 +76,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
[user]
|
[user]
|
||||||
);
|
);
|
||||||
const usingOptimizedServer = useMemo(
|
const usingOptimizedServer = useMemo(
|
||||||
() => settings?.downloadMethod === "optimized",
|
() => settings?.downloadMethod === DownloadMethod.Optimized,
|
||||||
[settings]
|
[settings]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
44
components/JellyfinServerDiscovery.tsx
Normal file
44
components/JellyfinServerDiscovery.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { View, Text, TouchableOpacity } from "react-native";
|
||||||
|
import { useJellyfinDiscovery } from "@/hooks/useJellyfinDiscovery";
|
||||||
|
import { Button } from "./Button";
|
||||||
|
import { ListGroup } from "./list/ListGroup";
|
||||||
|
import { ListItem } from "./list/ListItem";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onServerSelect?: (server: { address: string; serverName?: string }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const JellyfinServerDiscovery: React.FC<Props> = ({ onServerSelect }) => {
|
||||||
|
const { servers, isSearching, startDiscovery } = useJellyfinDiscovery();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="mt-2">
|
||||||
|
<Button onPress={startDiscovery} color="black">
|
||||||
|
<Text className="text-white text-center">
|
||||||
|
{isSearching ? "Searching..." : "Search for local servers"}
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{servers.length ? (
|
||||||
|
<ListGroup title="Servers" className="mt-4">
|
||||||
|
{servers.map((server) => (
|
||||||
|
<ListItem
|
||||||
|
key={server.address}
|
||||||
|
onPress={() =>
|
||||||
|
onServerSelect?.({
|
||||||
|
address: server.address,
|
||||||
|
serverName: server.serverName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
title={server.address}
|
||||||
|
showArrow
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ListGroup>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default JellyfinServerDiscovery;
|
||||||
@@ -1,13 +1,11 @@
|
|||||||
import { tc } from "@/utils/textTools";
|
|
||||||
import {
|
import {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useEffect, useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
import { convertBitsToMegabitsOrGigabits } from "@/utils/bToMb";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof View> {
|
interface Props extends React.ComponentProps<typeof View> {
|
||||||
@@ -32,6 +30,27 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
|||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const commonPrefix = useMemo(() => {
|
||||||
|
const mediaSources = item.MediaSources || [];
|
||||||
|
if (!mediaSources.length) return "";
|
||||||
|
|
||||||
|
let commonPrefix = "";
|
||||||
|
for (let i = 0; i < mediaSources[0].Name!.length; i++) {
|
||||||
|
const char = mediaSources[0].Name![i];
|
||||||
|
if (mediaSources.every((source) => source.Name![i] === char)) {
|
||||||
|
commonPrefix += char;
|
||||||
|
} else {
|
||||||
|
commonPrefix = commonPrefix.slice(0, -1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return commonPrefix;
|
||||||
|
}, [item.MediaSources]);
|
||||||
|
|
||||||
|
const name = (name?: string | null) => {
|
||||||
|
return name?.replace(commonPrefix, "").toLowerCase();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className="flex shrink"
|
className="flex shrink"
|
||||||
@@ -66,9 +85,7 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemTitle>
|
<DropdownMenu.ItemTitle>
|
||||||
{`${name(source.Name)} - ${convertBitsToMegabitsOrGigabits(
|
{`${name(source.Name)}`}
|
||||||
source.Size
|
|
||||||
)}`}
|
|
||||||
</DropdownMenu.ItemTitle>
|
</DropdownMenu.ItemTitle>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
))}
|
))}
|
||||||
@@ -77,9 +94,3 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const name = (name?: string | null) => {
|
|
||||||
if (name && name.length > 40)
|
|
||||||
return name.substring(0, 20) + " [...] " + name.substring(name.length - 20);
|
|
||||||
return name;
|
|
||||||
};
|
|
||||||
|
|||||||
108
components/common/Dropdown.tsx
Normal file
108
components/common/Dropdown.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||||
|
import {TouchableOpacity, View, ViewProps} from "react-native";
|
||||||
|
import {Text} from "@/components/common/Text";
|
||||||
|
import React, {PropsWithChildren, ReactNode, useEffect, useState} from "react";
|
||||||
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
|
|
||||||
|
interface Props<T> {
|
||||||
|
data: T[]
|
||||||
|
disabled?: boolean
|
||||||
|
placeholderText?: string,
|
||||||
|
keyExtractor: (item: T) => string
|
||||||
|
titleExtractor: (item: T) => string | undefined
|
||||||
|
title: string | ReactNode,
|
||||||
|
label: string,
|
||||||
|
onSelected: (...item: T[]) => void
|
||||||
|
multi?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Dropdown = <T extends unknown>({
|
||||||
|
data,
|
||||||
|
disabled,
|
||||||
|
placeholderText,
|
||||||
|
keyExtractor,
|
||||||
|
titleExtractor,
|
||||||
|
title,
|
||||||
|
label,
|
||||||
|
onSelected,
|
||||||
|
multi = false,
|
||||||
|
...props
|
||||||
|
}: PropsWithChildren<Props<T> & ViewProps>) => {
|
||||||
|
const [selected, setSelected] = useState<T[]>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selected !== undefined) {
|
||||||
|
onSelected(...selected)
|
||||||
|
}
|
||||||
|
}, [selected]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DisabledSetting
|
||||||
|
disabled={disabled === true}
|
||||||
|
showText={false}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger>
|
||||||
|
{typeof title === 'string' ? (
|
||||||
|
<View className="flex flex-col">
|
||||||
|
<Text className="opacity-50 mb-1 text-xs">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
|
||||||
|
<Text style={{}} className="" numberOfLines={1}>
|
||||||
|
{selected?.length !== undefined ? selected.map(titleExtractor).join(",") : placeholderText}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{title}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Content
|
||||||
|
loop={false}
|
||||||
|
side="bottom"
|
||||||
|
align="center"
|
||||||
|
alignOffset={0}
|
||||||
|
avoidCollisions={true}
|
||||||
|
collisionPadding={0}
|
||||||
|
sideOffset={0}
|
||||||
|
>
|
||||||
|
<DropdownMenu.Label>{label}</DropdownMenu.Label>
|
||||||
|
{data.map((item, idx) => (
|
||||||
|
multi ? (
|
||||||
|
<DropdownMenu.CheckboxItem
|
||||||
|
value={selected?.some(s => keyExtractor(s) == keyExtractor(item)) ? 'on' : 'off'}
|
||||||
|
key={keyExtractor(item)}
|
||||||
|
onValueChange={(next, previous) =>
|
||||||
|
setSelected((p) => {
|
||||||
|
const prev = p || []
|
||||||
|
if (next == 'on') {
|
||||||
|
return [...prev, item]
|
||||||
|
}
|
||||||
|
return [...prev.filter(p => keyExtractor(p) !== keyExtractor(item))]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemTitle>{titleExtractor(item)}</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.CheckboxItem>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key={keyExtractor(item)}
|
||||||
|
onSelect={() => setSelected([item])}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemTitle>{titleExtractor(item)}</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
</DisabledSetting>
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Dropdown;
|
||||||
@@ -26,18 +26,6 @@ export const itemRouter = (
|
|||||||
return `/(auth)/(tabs)/${from}/series/${item.Id}`;
|
return `/(auth)/(tabs)/${from}/series/${item.Id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.Type === "MusicAlbum") {
|
|
||||||
return `/(auth)/(tabs)/${from}/albums/${item.Id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.Type === "Audio") {
|
|
||||||
return `/(auth)/(tabs)/${from}/albums/${item.AlbumId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.Type === "MusicArtist") {
|
|
||||||
return `/(auth)/(tabs)/${from}/artists/${item.Id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.Type === "Person" || item.Type === "Actor") {
|
if (item.Type === "Person" || item.Type === "Actor") {
|
||||||
return `/(auth)/(tabs)/${from}/actors/${item.Id}`;
|
return `/(auth)/(tabs)/${from}/actors/${item.Id}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import {DownloadMethod, useSettings} from "@/utils/atoms/settings";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { JobStatus } from "@/utils/optimize-server";
|
import { JobStatus } from "@/utils/optimize-server";
|
||||||
import { formatTimeString } from "@/utils/time";
|
import { formatTimeString } from "@/utils/time";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
@@ -9,7 +8,6 @@ import { checkForExistingDownloads } from "@kesha-antonov/react-native-backgroun
|
|||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { FFmpegKit } from "ffmpeg-kit-react-native";
|
import { FFmpegKit } from "ffmpeg-kit-react-native";
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
@@ -63,7 +61,7 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|||||||
mutationFn: async (id: string) => {
|
mutationFn: async (id: string) => {
|
||||||
if (!process) throw new Error("No active download");
|
if (!process) throw new Error("No active download");
|
||||||
|
|
||||||
if (settings?.downloadMethod === "optimized") {
|
if (settings?.downloadMethod === DownloadMethod.Optimized) {
|
||||||
try {
|
try {
|
||||||
const tasks = await checkForExistingDownloads();
|
const tasks = await checkForExistingDownloads();
|
||||||
for (const task of tasks) {
|
for (const task of tasks) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { FontAwesome, Ionicons } from "@expo/vector-icons";
|
import { FontAwesome, Ionicons } from "@expo/vector-icons";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||||
import { FilterSheet } from "./FilterSheet";
|
import { FilterSheet } from "./FilterSheet";
|
||||||
|
|
||||||
|
|||||||
@@ -55,14 +55,6 @@ export const Favorites = () => {
|
|||||||
() => fetchFavoritesByType("Playlist"),
|
() => fetchFavoritesByType("Playlist"),
|
||||||
[fetchFavoritesByType]
|
[fetchFavoritesByType]
|
||||||
);
|
);
|
||||||
const fetchFavoriteMusicAlbum = useCallback(
|
|
||||||
() => fetchFavoritesByType("MusicAlbum"),
|
|
||||||
[fetchFavoritesByType]
|
|
||||||
);
|
|
||||||
const fetchFavoriteAudio = useCallback(
|
|
||||||
() => fetchFavoritesByType("Audio"),
|
|
||||||
[fetchFavoritesByType]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex flex-co gap-y-4">
|
<View className="flex flex-co gap-y-4">
|
||||||
@@ -103,18 +95,6 @@ export const Favorites = () => {
|
|||||||
title={t("favorites.playlists")}
|
title={t("favorites.playlists")}
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
/>
|
/>
|
||||||
<ScrollingCollectionList
|
|
||||||
queryFn={fetchFavoriteMusicAlbum}
|
|
||||||
queryKey={["home", "favorites", "musicAlbums"]}
|
|
||||||
title={t("favorites.music_albums")}
|
|
||||||
hideIfEmpty
|
|
||||||
/>
|
|
||||||
<ScrollingCollectionList
|
|
||||||
queryFn={fetchFavoriteAudio}
|
|
||||||
queryKey={["home", "favorites", "audio"]}
|
|
||||||
title={t("favorites.audio")}
|
|
||||||
hideIfEmpty
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||||
@@ -6,9 +7,11 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
|
import { useRouter, useSegments } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useCallback, useMemo } from "react";
|
import React, { useCallback, useMemo } from "react";
|
||||||
import { Dimensions, TouchableOpacity, View, ViewProps } from "react-native";
|
import { Dimensions, View, ViewProps } from "react-native";
|
||||||
|
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
runOnJS,
|
runOnJS,
|
||||||
useSharedValue,
|
useSharedValue,
|
||||||
@@ -18,11 +21,7 @@ import Carousel, {
|
|||||||
ICarouselInstance,
|
ICarouselInstance,
|
||||||
Pagination,
|
Pagination,
|
||||||
} from "react-native-reanimated-carousel";
|
} from "react-native-reanimated-carousel";
|
||||||
import { itemRouter, TouchableItemRouter } from "../common/TouchableItemRouter";
|
import { itemRouter } from "../common/TouchableItemRouter";
|
||||||
import { Loader } from "../Loader";
|
|
||||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
|
||||||
import { useRouter, useSegments } from "expo-router";
|
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
@@ -162,7 +161,7 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
|||||||
const tap = Gesture.Tap()
|
const tap = Gesture.Tap()
|
||||||
.maxDuration(2000)
|
.maxDuration(2000)
|
||||||
.onBegin(() => {
|
.onBegin(() => {
|
||||||
opacity.value = withTiming(0.5, { duration: 100 });
|
opacity.value = withTiming(0.8, { duration: 100 });
|
||||||
})
|
})
|
||||||
.onEnd(() => {
|
.onEnd(() => {
|
||||||
runOnJS(handleRoute)();
|
runOnJS(handleRoute)();
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import {TouchableOpacity, View} from "react-native";
|
import {TouchableOpacity, View} from "react-native";
|
||||||
import {Text} from "@/components/common/Text";
|
import {Text} from "@/components/common/Text";
|
||||||
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
|
|
||||||
interface StepperProps {
|
interface StepperProps {
|
||||||
value: number,
|
value: number,
|
||||||
|
disabled?: boolean,
|
||||||
step: number,
|
step: number,
|
||||||
min: number,
|
min: number,
|
||||||
max: number,
|
max: number,
|
||||||
@@ -12,6 +14,7 @@ interface StepperProps {
|
|||||||
|
|
||||||
export const Stepper: React.FC<StepperProps> = ({
|
export const Stepper: React.FC<StepperProps> = ({
|
||||||
value,
|
value,
|
||||||
|
disabled,
|
||||||
step,
|
step,
|
||||||
min,
|
min,
|
||||||
max,
|
max,
|
||||||
@@ -19,7 +22,11 @@ export const Stepper: React.FC<StepperProps> = ({
|
|||||||
appendValue
|
appendValue
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<View className="flex flex-row items-center">
|
<DisabledSetting
|
||||||
|
disabled={disabled === true}
|
||||||
|
showText={false}
|
||||||
|
className="flex flex-row items-center"
|
||||||
|
>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => onUpdate(Math.max(min, value - step))}
|
onPress={() => onUpdate(Math.max(min, value - step))}
|
||||||
className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
|
className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
|
||||||
@@ -39,6 +46,6 @@ export const Stepper: React.FC<StepperProps> = ({
|
|||||||
>
|
>
|
||||||
<Text>+</Text>
|
<Text>+</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</DisabledSetting>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -10,7 +10,7 @@ const CastSlide: React.FC<
|
|||||||
{ details?: MovieDetails | TvDetails } & ViewProps
|
{ details?: MovieDetails | TvDetails } & ViewProps
|
||||||
> = ({ details, ...props }) => {
|
> = ({ details, ...props }) => {
|
||||||
return (
|
return (
|
||||||
details?.credits?.cast?.length &&
|
details?.credits?.cast &&
|
||||||
details?.credits?.cast?.length > 0 && (
|
details?.credits?.cast?.length > 0 && (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<Text className="text-lg font-bold mb-2 px-4">Cast</Text>
|
<Text className="text-lg font-bold mb-2 px-4">Cast</Text>
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ const Facts: React.FC<
|
|||||||
> = ({ title, facts, ...props }) =>
|
> = ({ title, facts, ...props }) =>
|
||||||
facts &&
|
facts &&
|
||||||
facts?.length > 0 && (
|
facts?.length > 0 && (
|
||||||
<View className="flex flex-row justify-between py-2" {...props}>
|
<View className="flex flex-col justify-between py-2" {...props}>
|
||||||
<Text className="font-bold">{title}</Text>
|
<Text className="font-bold text-start">{title}</Text>
|
||||||
|
|
||||||
<View className="flex flex-col items-end">
|
<View className="flex flex-col items-end">
|
||||||
{facts.map((f, idx) =>
|
{facts.map((f, idx) =>
|
||||||
|
|||||||
233
components/jellyseerr/RequestModal.tsx
Normal file
233
components/jellyseerr/RequestModal.tsx
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
import React, {forwardRef, useCallback, useMemo, useState} from "react";
|
||||||
|
import {View, ViewProps} from "react-native";
|
||||||
|
import {useJellyseerr} from "@/hooks/useJellyseerr";
|
||||||
|
import {useQuery} from "@tanstack/react-query";
|
||||||
|
import {MediaType} from "@/utils/jellyseerr/server/constants/media";
|
||||||
|
import {BottomSheetBackdrop, BottomSheetBackdropProps, BottomSheetModal, BottomSheetView} from "@gorhom/bottom-sheet";
|
||||||
|
import Dropdown from "@/components/common/Dropdown";
|
||||||
|
import {QualityProfile, RootFolder, Tag} from "@/utils/jellyseerr/server/api/servarr/base";
|
||||||
|
import {MediaRequestBody} from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
||||||
|
import {BottomSheetModalMethods} from "@gorhom/bottom-sheet/lib/typescript/types";
|
||||||
|
import {Button} from "@/components/Button";
|
||||||
|
import {Text} from "@/components/common/Text";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id: number;
|
||||||
|
title: string,
|
||||||
|
type: MediaType;
|
||||||
|
isAnime?: boolean;
|
||||||
|
is4k?: boolean;
|
||||||
|
onRequested?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RequestModal = forwardRef<BottomSheetModalMethods, Props & Omit<ViewProps, 'id'>>(({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
type,
|
||||||
|
isAnime = false,
|
||||||
|
onRequested,
|
||||||
|
...props
|
||||||
|
}, ref) => {
|
||||||
|
const {jellyseerrApi, jellyseerrUser, requestMedia} = useJellyseerr();
|
||||||
|
const [requestOverrides, setRequestOverrides] =
|
||||||
|
useState<MediaRequestBody>({
|
||||||
|
mediaId: Number(id),
|
||||||
|
mediaType: type,
|
||||||
|
userId: jellyseerrUser?.id
|
||||||
|
});
|
||||||
|
|
||||||
|
const [modalRequestProps, setModalRequestProps] = useState<MediaRequestBody>();
|
||||||
|
|
||||||
|
const {data: serviceSettings} = useQuery({
|
||||||
|
queryKey: ["jellyseerr", "request", type, 'service'],
|
||||||
|
queryFn: async () => jellyseerrApi?.service(type == 'movie' ? 'radarr' : 'sonarr'),
|
||||||
|
enabled: !!jellyseerrApi && !!jellyseerrUser,
|
||||||
|
refetchOnMount: 'always'
|
||||||
|
});
|
||||||
|
|
||||||
|
const {data: users} = useQuery({
|
||||||
|
queryKey: ["jellyseerr", "users"],
|
||||||
|
queryFn: async () => jellyseerrApi?.user({take: 1000, sort: 'displayname'}),
|
||||||
|
enabled: !!jellyseerrApi && !!jellyseerrUser,
|
||||||
|
refetchOnMount: 'always'
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultService = useMemo(
|
||||||
|
() => serviceSettings?.find?.(v => v.isDefault),
|
||||||
|
[serviceSettings]
|
||||||
|
);
|
||||||
|
|
||||||
|
const {data: defaultServiceDetails} = useQuery({
|
||||||
|
queryKey: ["jellyseerr", "request", type, 'service', 'details', defaultService?.id],
|
||||||
|
queryFn: async () => {
|
||||||
|
setRequestOverrides((prev) => ({
|
||||||
|
...prev,
|
||||||
|
serverId: defaultService?.id
|
||||||
|
}))
|
||||||
|
return jellyseerrApi?.serviceDetails(type === 'movie' ? 'radarr' : 'sonarr', defaultService!!.id)
|
||||||
|
},
|
||||||
|
enabled: !!jellyseerrApi && !!jellyseerrUser && !!defaultService,
|
||||||
|
refetchOnMount: 'always',
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultProfile: QualityProfile = useMemo(
|
||||||
|
() => defaultServiceDetails?.profiles
|
||||||
|
.find(p =>
|
||||||
|
p.id === (isAnime ? defaultServiceDetails.server?.activeAnimeProfileId : defaultServiceDetails.server?.activeProfileId)
|
||||||
|
),
|
||||||
|
[defaultServiceDetails]
|
||||||
|
);
|
||||||
|
|
||||||
|
const defaultFolder: RootFolder = useMemo(
|
||||||
|
() => defaultServiceDetails?.rootFolders
|
||||||
|
.find(f =>
|
||||||
|
f.path === (isAnime ? defaultServiceDetails?.server.activeAnimeDirectory : defaultServiceDetails.server?.activeDirectory)
|
||||||
|
),
|
||||||
|
[defaultServiceDetails]
|
||||||
|
);
|
||||||
|
|
||||||
|
const defaultTags: Tag[] = useMemo(
|
||||||
|
() => {
|
||||||
|
const tags = defaultServiceDetails?.tags
|
||||||
|
.filter(t =>
|
||||||
|
(isAnime
|
||||||
|
? defaultServiceDetails?.server.activeAnimeTags
|
||||||
|
: defaultServiceDetails?.server.activeTags
|
||||||
|
)?.includes(t.id)
|
||||||
|
) ?? []
|
||||||
|
|
||||||
|
console.log(tags)
|
||||||
|
return tags
|
||||||
|
},
|
||||||
|
[defaultServiceDetails]
|
||||||
|
);
|
||||||
|
|
||||||
|
const seasonTitle = useMemo(
|
||||||
|
() => modalRequestProps?.seasons?.length ? `Season (${modalRequestProps?.seasons})` : undefined,
|
||||||
|
[modalRequestProps?.seasons]
|
||||||
|
);
|
||||||
|
|
||||||
|
const request = useCallback(() => {requestMedia(
|
||||||
|
seasonTitle ? `${title}, ${seasonTitle}` : title,
|
||||||
|
{
|
||||||
|
is4k: defaultService?.is4k || defaultServiceDetails?.server.is4k,
|
||||||
|
profileId: defaultProfile.id,
|
||||||
|
rootFolder: defaultFolder.path,
|
||||||
|
tags: defaultTags.map(t => t.id),
|
||||||
|
...modalRequestProps,
|
||||||
|
...requestOverrides
|
||||||
|
},
|
||||||
|
onRequested
|
||||||
|
)
|
||||||
|
}, [requestOverrides, defaultProfile, defaultFolder, defaultTags]);
|
||||||
|
|
||||||
|
const pathTitleExtractor = (item: RootFolder) => `${item.path} (${item.freeSpace.bytesToReadable()})`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BottomSheetModal
|
||||||
|
ref={ref}
|
||||||
|
enableDynamicSizing
|
||||||
|
enableDismissOnClose
|
||||||
|
onDismiss={() => setModalRequestProps(undefined)}
|
||||||
|
handleIndicatorStyle={{
|
||||||
|
backgroundColor: "white",
|
||||||
|
}}
|
||||||
|
backgroundStyle={{
|
||||||
|
backgroundColor: "#171717",
|
||||||
|
}}
|
||||||
|
backdropComponent={(sheetProps: BottomSheetBackdropProps) =>
|
||||||
|
<BottomSheetBackdrop
|
||||||
|
{...sheetProps}
|
||||||
|
disappearsOnIndex={-1}
|
||||||
|
appearsOnIndex={0}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(data) => {
|
||||||
|
setModalRequestProps(data?.data as MediaRequestBody)
|
||||||
|
return <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">Advanced</Text>
|
||||||
|
{seasonTitle &&
|
||||||
|
<Text className="text-neutral-300">{seasonTitle}</Text>
|
||||||
|
}
|
||||||
|
</View>
|
||||||
|
<View className="flex flex-col space-y-2">
|
||||||
|
{(defaultService && defaultServiceDetails && users) && (
|
||||||
|
<>
|
||||||
|
<Dropdown
|
||||||
|
data={defaultServiceDetails.profiles}
|
||||||
|
titleExtractor={(item) => item.name}
|
||||||
|
placeholderText={defaultProfile.name}
|
||||||
|
keyExtractor={(item) => item.id.toString()}
|
||||||
|
label={"Quality Profile"}
|
||||||
|
onSelected={(item) =>
|
||||||
|
item && setRequestOverrides((prev) => ({
|
||||||
|
...prev,
|
||||||
|
profileId: item?.id
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
title={"Quality Profile"}
|
||||||
|
/>
|
||||||
|
<Dropdown
|
||||||
|
data={defaultServiceDetails.rootFolders}
|
||||||
|
titleExtractor={pathTitleExtractor}
|
||||||
|
placeholderText={defaultFolder ? pathTitleExtractor(defaultFolder) : ""}
|
||||||
|
keyExtractor={(item) => item.id.toString()}
|
||||||
|
label={"Root Folder"}
|
||||||
|
onSelected={(item) =>
|
||||||
|
item && setRequestOverrides((prev) => ({
|
||||||
|
...prev,
|
||||||
|
rootFolder: item.path
|
||||||
|
}))}
|
||||||
|
title={"Root Folder"}
|
||||||
|
/>
|
||||||
|
<Dropdown
|
||||||
|
multi={true}
|
||||||
|
data={defaultServiceDetails.tags}
|
||||||
|
titleExtractor={(item) => item.label}
|
||||||
|
placeholderText={defaultTags.map(t => t.label).join(",")}
|
||||||
|
keyExtractor={(item) => item.id.toString()}
|
||||||
|
label={"Tags"}
|
||||||
|
onSelected={(...item) =>
|
||||||
|
item && setRequestOverrides((prev) => ({
|
||||||
|
...prev,
|
||||||
|
tags: item.map(i => i.id)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
title={"Tags"}
|
||||||
|
/>
|
||||||
|
<Dropdown
|
||||||
|
data={users}
|
||||||
|
titleExtractor={(item) => item.displayName}
|
||||||
|
placeholderText={jellyseerrUser!!.displayName}
|
||||||
|
keyExtractor={(item) => item.id.toString() || ""}
|
||||||
|
label={"Request As"}
|
||||||
|
onSelected={(item) =>
|
||||||
|
item && setRequestOverrides((prev) => ({
|
||||||
|
...prev,
|
||||||
|
userId: item?.id
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
title={"Request As"}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</View>
|
||||||
|
<Button
|
||||||
|
className="mt-auto"
|
||||||
|
onPress={request}
|
||||||
|
color="purple"
|
||||||
|
>
|
||||||
|
Request
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</BottomSheetView>
|
||||||
|
}}
|
||||||
|
</BottomSheetModal>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default RequestModal;
|
||||||
@@ -1,23 +1,30 @@
|
|||||||
import React, {useCallback} from "react";
|
|
||||||
import {
|
|
||||||
useJellyseerr,
|
|
||||||
} from "@/hooks/useJellyseerr";
|
|
||||||
import {TouchableOpacity, ViewProps} from "react-native";
|
|
||||||
import Slide, {SlideProps} from "@/components/jellyseerr/discover/Slide";
|
|
||||||
import {COMPANY_LOGO_IMAGE_FILTER, Network} from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
|
|
||||||
import GenericSlideCard from "@/components/jellyseerr/discover/GenericSlideCard";
|
import GenericSlideCard from "@/components/jellyseerr/discover/GenericSlideCard";
|
||||||
import {Studio} from "@/utils/jellyseerr/src/components/Discover/StudioSlider";
|
import Slide, { SlideProps } from "@/components/jellyseerr/discover/Slide";
|
||||||
import {router, useSegments} from "expo-router";
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
|
import {
|
||||||
|
COMPANY_LOGO_IMAGE_FILTER,
|
||||||
|
Network,
|
||||||
|
} from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
|
||||||
|
import { Studio } from "@/utils/jellyseerr/src/components/Discover/StudioSlider";
|
||||||
|
import { router, useSegments } from "expo-router";
|
||||||
|
import React, { useCallback } from "react";
|
||||||
|
import { TouchableOpacity, ViewProps } from "react-native";
|
||||||
|
|
||||||
const CompanySlide: React.FC<{data: Network[] | Studio[]} & SlideProps & ViewProps> = ({ slide, data, ...props }) => {
|
const CompanySlide: React.FC<
|
||||||
|
{ data: Network[] | Studio[] } & SlideProps & ViewProps
|
||||||
|
> = ({ slide, data, ...props }) => {
|
||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
const { jellyseerrApi } = useJellyseerr();
|
const { jellyseerrApi } = useJellyseerr();
|
||||||
const from = segments[2];
|
const from = segments[2];
|
||||||
|
|
||||||
const navigate = useCallback(({id, image, name}: Network | Studio) => router.push({
|
const navigate = useCallback(
|
||||||
pathname: `/(auth)/(tabs)/${from}/jellyseerr/company/${id}`,
|
({ id, image, name }: Network | Studio) =>
|
||||||
params: {id, image, name, type: slide.type }
|
router.push({
|
||||||
}), [slide]);
|
pathname: `/(auth)/(tabs)/${from}/jellyseerr/company/${id}`,
|
||||||
|
params: { id, image, name, type: slide.type },
|
||||||
|
}),
|
||||||
|
[slide]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Slide
|
<Slide
|
||||||
@@ -30,7 +37,10 @@ const CompanySlide: React.FC<{data: Network[] | Studio[]} & SlideProps & ViewPro
|
|||||||
<GenericSlideCard
|
<GenericSlideCard
|
||||||
className="w-28 rounded-lg overflow-hidden border border-neutral-900 p-4"
|
className="w-28 rounded-lg overflow-hidden border border-neutral-900 p-4"
|
||||||
id={item.id.toString()}
|
id={item.id.toString()}
|
||||||
url={jellyseerrApi?.imageProxy(item.image, COMPANY_LOGO_IMAGE_FILTER)}
|
url={jellyseerrApi?.imageProxy(
|
||||||
|
item.image,
|
||||||
|
COMPANY_LOGO_IMAGE_FILTER
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,55 +1,66 @@
|
|||||||
import React, {useCallback} from "react";
|
|
||||||
import {Endpoints, useJellyseerr,} from "@/hooks/useJellyseerr";
|
|
||||||
import {TouchableOpacity, ViewProps} from "react-native";
|
|
||||||
import Slide, {SlideProps} from "@/components/jellyseerr/discover/Slide";
|
|
||||||
import GenericSlideCard from "@/components/jellyseerr/discover/GenericSlideCard";
|
import GenericSlideCard from "@/components/jellyseerr/discover/GenericSlideCard";
|
||||||
import {router, useSegments} from "expo-router";
|
import Slide, { SlideProps } from "@/components/jellyseerr/discover/Slide";
|
||||||
import {useQuery} from "@tanstack/react-query";
|
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import {DiscoverSliderType} from "@/utils/jellyseerr/server/constants/discover";
|
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||||
import {genreColorMap} from "@/utils/jellyseerr/src/components/Discover/constants";
|
import { GenreSliderItem } from "@/utils/jellyseerr/server/interfaces/api/discoverInterfaces";
|
||||||
import {GenreSliderItem} from "@/utils/jellyseerr/server/interfaces/api/discoverInterfaces";
|
import { genreColorMap } from "@/utils/jellyseerr/src/components/Discover/constants";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { router, useSegments } from "expo-router";
|
||||||
|
import React, { useCallback } from "react";
|
||||||
|
import { TouchableOpacity, ViewProps } from "react-native";
|
||||||
|
|
||||||
const GenreSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
|
const GenreSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
|
||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
const { jellyseerrApi } = useJellyseerr();
|
const { jellyseerrApi } = useJellyseerr();
|
||||||
const from = segments[2];
|
const from = segments[2];
|
||||||
|
|
||||||
const navigate = useCallback((genre: GenreSliderItem) => router.push({
|
const navigate = useCallback(
|
||||||
pathname: `/(auth)/(tabs)/${from}/jellyseerr/genre/${genre.id}`,
|
(genre: GenreSliderItem) =>
|
||||||
params: {type: slide.type, name: genre.name}
|
router.push({
|
||||||
}), [slide]);
|
pathname: `/(auth)/(tabs)/${from}/jellyseerr/genre/${genre.id}`,
|
||||||
|
params: { type: slide.type, name: genre.name },
|
||||||
|
}),
|
||||||
|
[slide]
|
||||||
|
);
|
||||||
|
|
||||||
const {data, isFetching, isLoading } = useQuery({
|
const { data, isFetching, isLoading } = useQuery({
|
||||||
queryKey: ['jellyseerr', 'discover', slide.type, slide.id],
|
queryKey: ["jellyseerr", "discover", slide.type, slide.id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
return jellyseerrApi?.getGenreSliders(
|
return jellyseerrApi?.getGenreSliders(
|
||||||
slide.type == DiscoverSliderType.MOVIE_GENRES
|
slide.type == DiscoverSliderType.MOVIE_GENRES
|
||||||
? Endpoints.MOVIE
|
? Endpoints.MOVIE
|
||||||
: Endpoints.TV
|
: Endpoints.TV
|
||||||
)
|
);
|
||||||
},
|
},
|
||||||
enabled: !!jellyseerrApi
|
enabled: !!jellyseerrApi,
|
||||||
})
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
data && <Slide
|
data && (
|
||||||
{...props}
|
<Slide
|
||||||
slide={slide}
|
{...props}
|
||||||
data={data}
|
slide={slide}
|
||||||
keyExtractor={(item) => item.id.toString()}
|
data={data}
|
||||||
renderItem={(item, index) => (
|
keyExtractor={(item) => item.id.toString()}
|
||||||
<TouchableOpacity className="mr-2" onPress={() => navigate(item)}>
|
renderItem={(item, index) => (
|
||||||
<GenericSlideCard
|
<TouchableOpacity className="mr-2" onPress={() => navigate(item)}>
|
||||||
className="w-28 rounded-lg overflow-hidden border border-neutral-900"
|
<GenericSlideCard
|
||||||
id={item.id.toString()}
|
className="w-28 rounded-lg overflow-hidden border border-neutral-900"
|
||||||
title={item.name}
|
id={item.id.toString()}
|
||||||
colors={[]}
|
title={item.name}
|
||||||
contentFit={"cover"}
|
colors={[]}
|
||||||
url={jellyseerrApi?.imageProxy(item.backdrops?.[0], `w780_filter(duotone,${genreColorMap[item.id] ?? genreColorMap[0]})`)}
|
contentFit={"cover"}
|
||||||
/>
|
url={jellyseerrApi?.imageProxy(
|
||||||
</TouchableOpacity>
|
item.backdrops?.[0],
|
||||||
)}
|
`w780_filter(duotone,${
|
||||||
/>
|
genreColorMap[item.id] ?? genreColorMap[0]
|
||||||
|
})`
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -60,8 +60,6 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
|||||||
_itemType = "Series";
|
_itemType = "Series";
|
||||||
} else if (library.CollectionType === "boxsets") {
|
} else if (library.CollectionType === "boxsets") {
|
||||||
_itemType = "BoxSet";
|
_itemType = "BoxSet";
|
||||||
} else if (library.CollectionType === "music") {
|
|
||||||
_itemType = "MusicAlbum";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return _itemType;
|
return _itemType;
|
||||||
@@ -76,8 +74,6 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
|||||||
nameStr = "series";
|
nameStr = "series";
|
||||||
} else if (library.CollectionType === "boxsets") {
|
} else if (library.CollectionType === "boxsets") {
|
||||||
nameStr = "box sets";
|
nameStr = "box sets";
|
||||||
} else if (library.CollectionType === "music") {
|
|
||||||
nameStr = "albums";
|
|
||||||
} else {
|
} else {
|
||||||
nameStr = "items";
|
nameStr = "items";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { PropsWithChildren, ReactNode } from "react";
|
import { PropsWithChildren, ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
@@ -6,7 +7,6 @@ import {
|
|||||||
ViewProps,
|
ViewProps,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
|
|
||||||
interface Props extends TouchableOpacityProps, ViewProps {
|
interface Props extends TouchableOpacityProps, ViewProps {
|
||||||
title?: string | null | undefined;
|
title?: string | null | undefined;
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { useRouter } from "expo-router";
|
|
||||||
import { View, ViewProps } from "react-native";
|
|
||||||
import { SongsListItem } from "./SongsListItem";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
|
||||||
songs?: BaseItemDto[] | null;
|
|
||||||
collectionId: string;
|
|
||||||
artistId: string;
|
|
||||||
albumId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SongsList: React.FC<Props> = ({
|
|
||||||
collectionId,
|
|
||||||
artistId,
|
|
||||||
albumId,
|
|
||||||
songs = [],
|
|
||||||
...props
|
|
||||||
}) => {
|
|
||||||
const router = useRouter();
|
|
||||||
return (
|
|
||||||
<View className="flex flex-col space-y-2" {...props}>
|
|
||||||
{songs?.map((item: BaseItemDto, index: number) => (
|
|
||||||
<SongsListItem
|
|
||||||
key={item.Id}
|
|
||||||
item={item}
|
|
||||||
index={index}
|
|
||||||
collectionId={collectionId}
|
|
||||||
artistId={artistId}
|
|
||||||
albumId={albumId}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
|
|
||||||
import { runtimeTicksToSeconds } from "@/utils/time";
|
|
||||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { useRouter } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useCallback } from "react";
|
|
||||||
import { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
|
|
||||||
import CastContext, {
|
|
||||||
PlayServicesState,
|
|
||||||
useCastDevice,
|
|
||||||
useRemoteMediaClient,
|
|
||||||
} from "react-native-google-cast";
|
|
||||||
|
|
||||||
interface Props extends TouchableOpacityProps {
|
|
||||||
collectionId: string;
|
|
||||||
artistId: string;
|
|
||||||
albumId: string;
|
|
||||||
item: BaseItemDto;
|
|
||||||
index: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SongsListItem: React.FC<Props> = ({
|
|
||||||
collectionId,
|
|
||||||
artistId,
|
|
||||||
albumId,
|
|
||||||
item,
|
|
||||||
index,
|
|
||||||
...props
|
|
||||||
}) => {
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
const castDevice = useCastDevice();
|
|
||||||
const router = useRouter();
|
|
||||||
const client = useRemoteMediaClient();
|
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
|
||||||
|
|
||||||
const { setPlaySettings } = usePlaySettings();
|
|
||||||
|
|
||||||
const openSelect = () => {
|
|
||||||
if (!castDevice?.deviceId) {
|
|
||||||
play("device");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const options = ["Chromecast", "Device", "Cancel"];
|
|
||||||
const cancelButtonIndex = 2;
|
|
||||||
|
|
||||||
showActionSheetWithOptions(
|
|
||||||
{
|
|
||||||
options,
|
|
||||||
cancelButtonIndex,
|
|
||||||
},
|
|
||||||
(selectedIndex: number | undefined) => {
|
|
||||||
switch (selectedIndex) {
|
|
||||||
case 0:
|
|
||||||
play("cast");
|
|
||||||
break;
|
|
||||||
case 1:
|
|
||||||
play("device");
|
|
||||||
break;
|
|
||||||
case cancelButtonIndex:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const play = useCallback(async (type: "device" | "cast") => {
|
|
||||||
if (!user?.Id || !api || !item.Id) {
|
|
||||||
console.warn("No user, api or item", user, api, item.Id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await setPlaySettings({
|
|
||||||
item,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!data?.url) {
|
|
||||||
throw new Error("play-music ~ No stream url");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "cast" && client) {
|
|
||||||
await CastContext.getPlayServicesState().then((state) => {
|
|
||||||
if (state && state !== PlayServicesState.SUCCESS)
|
|
||||||
CastContext.showPlayServicesErrorDialog(state);
|
|
||||||
else {
|
|
||||||
client.loadMedia({
|
|
||||||
mediaInfo: {
|
|
||||||
contentUrl: data.url!,
|
|
||||||
contentType: "video/mp4",
|
|
||||||
metadata: {
|
|
||||||
type: item.Type === "Episode" ? "tvShow" : "movie",
|
|
||||||
title: item.Name || "",
|
|
||||||
subtitle: item.Overview || "",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
startTime: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
console.log("Playing on device", data.url, item.Id);
|
|
||||||
router.push("/music-player");
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
openSelect();
|
|
||||||
}}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<View className="flex flex-row items-center space-x-4 bg-neutral-900 border-neutral-800 px-4 py-4 rounded-xl">
|
|
||||||
<Text className="opacity-50">{index + 1}</Text>
|
|
||||||
<View>
|
|
||||||
<Text className="mb-0.5 font-semibold">{item.Name}</Text>
|
|
||||||
<Text className="opacity-50 text-xs">
|
|
||||||
{runtimeTicksToSeconds(item.RunTimeTicks)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
|
||||||
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { View } from "react-native";
|
|
||||||
|
|
||||||
type ArtistPosterProps = {
|
|
||||||
item?: BaseItemDto | null;
|
|
||||||
id?: string | null;
|
|
||||||
showProgress?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const AlbumCover: React.FC<ArtistPosterProps> = ({ item, id }) => {
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
|
|
||||||
const url = useMemo(() => {
|
|
||||||
const u = getPrimaryImageUrl({
|
|
||||||
api,
|
|
||||||
item,
|
|
||||||
});
|
|
||||||
return u;
|
|
||||||
}, [item]);
|
|
||||||
|
|
||||||
const url2 = useMemo(() => {
|
|
||||||
const u = getPrimaryImageUrlById({
|
|
||||||
api,
|
|
||||||
id,
|
|
||||||
quality: 85,
|
|
||||||
width: 300,
|
|
||||||
});
|
|
||||||
return u;
|
|
||||||
}, [item]);
|
|
||||||
|
|
||||||
if (!item && id)
|
|
||||||
return (
|
|
||||||
<View className="relative rounded-lg overflow-hidden border border-neutral-900">
|
|
||||||
<Image
|
|
||||||
key={id}
|
|
||||||
id={id}
|
|
||||||
source={
|
|
||||||
url2
|
|
||||||
? {
|
|
||||||
uri: url2,
|
|
||||||
}
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
cachePolicy={"memory-disk"}
|
|
||||||
contentFit="cover"
|
|
||||||
style={{
|
|
||||||
aspectRatio: "1/1",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (item)
|
|
||||||
return (
|
|
||||||
<View className="relative rounded-md overflow-hidden border border-neutral-900">
|
|
||||||
<Image
|
|
||||||
key={item.Id}
|
|
||||||
id={item.Id}
|
|
||||||
source={
|
|
||||||
url
|
|
||||||
? {
|
|
||||||
uri: url,
|
|
||||||
}
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
cachePolicy={"memory-disk"}
|
|
||||||
contentFit="cover"
|
|
||||||
style={{
|
|
||||||
aspectRatio: "1/1",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AlbumCover;
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { View } from "react-native";
|
|
||||||
|
|
||||||
type ArtistPosterProps = {
|
|
||||||
item: BaseItemDto;
|
|
||||||
showProgress?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ArtistPoster: React.FC<ArtistPosterProps> = ({
|
|
||||||
item,
|
|
||||||
showProgress = false,
|
|
||||||
}) => {
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
|
|
||||||
const url = useMemo(
|
|
||||||
() =>
|
|
||||||
getPrimaryImageUrl({
|
|
||||||
api,
|
|
||||||
item,
|
|
||||||
}),
|
|
||||||
[item]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!url)
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
className="rounded-lg overflow-hidden border border-neutral-900"
|
|
||||||
style={{
|
|
||||||
aspectRatio: "1/1",
|
|
||||||
}}
|
|
||||||
></View>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className="relative rounded-md overflow-hidden border border-neutral-900">
|
|
||||||
<Image
|
|
||||||
key={item.Id}
|
|
||||||
id={item.Id}
|
|
||||||
source={{
|
|
||||||
uri: url,
|
|
||||||
}}
|
|
||||||
cachePolicy={"memory-disk"}
|
|
||||||
contentFit="cover"
|
|
||||||
style={{
|
|
||||||
aspectRatio: "1/1",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ArtistPoster;
|
|
||||||
@@ -57,7 +57,7 @@ const JellyseerrPoster: React.FC<Props> = ({ item, ...props }) => {
|
|||||||
[item]
|
[item]
|
||||||
);
|
);
|
||||||
|
|
||||||
const canRequest = useJellyseerrCanRequest(item);
|
const [canRequest] = useJellyseerrCanRequest(item);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableJellyseerrRouter
|
<TouchableJellyseerrRouter
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
|
|||||||
import { Loader } from "../Loader";
|
import { Loader } from "../Loader";
|
||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
import {MovieDetails} from "@/utils/jellyseerr/server/models/Movie";
|
import {MovieDetails} from "@/utils/jellyseerr/server/models/Movie";
|
||||||
|
import {MediaRequestBody} from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
||||||
|
|
||||||
const JellyseerrSeasonEpisodes: React.FC<{
|
const JellyseerrSeasonEpisodes: React.FC<{
|
||||||
details: TvDetails;
|
details: TvDetails;
|
||||||
@@ -102,8 +103,17 @@ const JellyseerrSeasons: React.FC<{
|
|||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
result?: TvResult;
|
result?: TvResult;
|
||||||
details?: TvDetails;
|
details?: TvDetails;
|
||||||
|
hasAdvancedRequest?: boolean,
|
||||||
|
onAdvancedRequest?: (data: MediaRequestBody) => void;
|
||||||
refetch: (options?: (RefetchOptions | undefined)) => Promise<QueryObserverResult<TvDetails | MovieDetails | undefined, Error>>;
|
refetch: (options?: (RefetchOptions | undefined)) => Promise<QueryObserverResult<TvDetails | MovieDetails | undefined, Error>>;
|
||||||
}> = ({ isLoading, result, details, refetch }) => {
|
}> = ({
|
||||||
|
isLoading,
|
||||||
|
result,
|
||||||
|
details,
|
||||||
|
refetch,
|
||||||
|
hasAdvancedRequest,
|
||||||
|
onAdvancedRequest,
|
||||||
|
}) => {
|
||||||
if (!details) return null;
|
if (!details) return null;
|
||||||
|
|
||||||
const { jellyseerrApi, requestMedia } = useJellyseerr();
|
const { jellyseerrApi, requestMedia } = useJellyseerr();
|
||||||
@@ -143,7 +153,7 @@ const JellyseerrSeasons: React.FC<{
|
|||||||
|
|
||||||
const requestAll = useCallback(() => {
|
const requestAll = useCallback(() => {
|
||||||
if (details && jellyseerrApi) {
|
if (details && jellyseerrApi) {
|
||||||
requestMedia(result?.name!!, {
|
const body: MediaRequestBody = {
|
||||||
mediaId: details.id,
|
mediaId: details.id,
|
||||||
mediaType: MediaType.TV,
|
mediaType: MediaType.TV,
|
||||||
tvdbId: details.externalIds?.tvdbId,
|
tvdbId: details.externalIds?.tvdbId,
|
||||||
@@ -152,9 +162,15 @@ const JellyseerrSeasons: React.FC<{
|
|||||||
(s) => s.status === MediaStatus.UNKNOWN && s.seasonNumber !== 0
|
(s) => s.status === MediaStatus.UNKNOWN && s.seasonNumber !== 0
|
||||||
)
|
)
|
||||||
.map((s) => s.seasonNumber),
|
.map((s) => s.seasonNumber),
|
||||||
});
|
}
|
||||||
|
|
||||||
|
if (hasAdvancedRequest) {
|
||||||
|
return onAdvancedRequest?.(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
requestMedia(result?.name!!, body, refetch);
|
||||||
}
|
}
|
||||||
}, [jellyseerrApi, seasons, details]);
|
}, [jellyseerrApi, seasons, details, hasAdvancedRequest, onAdvancedRequest]);
|
||||||
|
|
||||||
const promptRequestAll = useCallback(
|
const promptRequestAll = useCallback(
|
||||||
() =>
|
() =>
|
||||||
@@ -173,18 +189,20 @@ const JellyseerrSeasons: React.FC<{
|
|||||||
|
|
||||||
const requestSeason = useCallback(async (canRequest: Boolean, seasonNumber: number) => {
|
const requestSeason = useCallback(async (canRequest: Boolean, seasonNumber: number) => {
|
||||||
if (canRequest) {
|
if (canRequest) {
|
||||||
requestMedia(
|
const body: MediaRequestBody = {
|
||||||
`${result?.name!!}, Season ${seasonNumber}`,
|
mediaId: details.id,
|
||||||
{
|
mediaType: MediaType.TV,
|
||||||
mediaId: details.id,
|
tvdbId: details.externalIds?.tvdbId,
|
||||||
mediaType: MediaType.TV,
|
seasons: [seasonNumber],
|
||||||
tvdbId: details.externalIds?.tvdbId,
|
}
|
||||||
seasons: [seasonNumber],
|
|
||||||
},
|
if (hasAdvancedRequest) {
|
||||||
refetch
|
return onAdvancedRequest?.(body)
|
||||||
)
|
}
|
||||||
|
|
||||||
|
requestMedia(`${result?.name!!}, Season ${seasonNumber}`, body, refetch);
|
||||||
}
|
}
|
||||||
}, [requestMedia]);
|
}, [requestMedia, hasAdvancedRequest, onAdvancedRequest]);
|
||||||
|
|
||||||
if (isLoading)
|
if (isLoading)
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
import { ListItem } from "../list/ListItem";
|
import { ListItem } from "../list/ListItem";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import {useSettings} from "@/utils/atoms/settings";
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
export const AudioToggles: React.FC<Props> = ({ ...props }) => {
|
export const AudioToggles: React.FC<Props> = ({ ...props }) => {
|
||||||
const media = useMedia();
|
const media = useMedia();
|
||||||
|
const [_, __, pluginSettings] = useSettings();
|
||||||
const { settings, updateSettings } = media;
|
const { settings, updateSettings } = media;
|
||||||
const cultures = media.cultures;
|
const cultures = media.cultures;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -28,9 +30,13 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
</Text>
|
</Text>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ListItem title={t("home.settings.audio.set_audio_track")}>
|
<ListItem
|
||||||
|
title={t("home.settings.audio.set_audio_track")}
|
||||||
|
disabled={pluginSettings?.rememberAudioSelections?.locked}
|
||||||
|
>
|
||||||
<Switch
|
<Switch
|
||||||
value={settings.rememberAudioSelections}
|
value={settings.rememberAudioSelections}
|
||||||
|
disabled={pluginSettings?.rememberAudioSelections?.locked}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
updateSettings({ rememberAudioSelections: value })
|
updateSettings({ rememberAudioSelections: value })
|
||||||
}
|
}
|
||||||
|
|||||||
26
components/settings/DisabledSetting.tsx
Normal file
26
components/settings/DisabledSetting.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import {View, ViewProps} from "react-native";
|
||||||
|
import {Text} from "@/components/common/Text";
|
||||||
|
|
||||||
|
const DisabledSetting: React.FC<{disabled: boolean, showText?: boolean, text?: string} & ViewProps> = ({
|
||||||
|
disabled = false,
|
||||||
|
showText = true,
|
||||||
|
text,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}) => (
|
||||||
|
<View
|
||||||
|
pointerEvents={disabled ? "none" : "auto"}
|
||||||
|
style={{
|
||||||
|
opacity: disabled ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View {...props}>
|
||||||
|
{disabled && showText &&
|
||||||
|
<Text className="text-center text-red-700 my-4">{text ?? "Currently disabled by admin."}</Text>
|
||||||
|
}
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default DisabledSetting;
|
||||||
@@ -1,35 +1,47 @@
|
|||||||
import { Stepper } from "@/components/inputs/Stepper";
|
import { Stepper } from "@/components/inputs/Stepper";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { Settings, useSettings } from "@/utils/atoms/settings";
|
import { DownloadMethod, Settings, useSettings } from "@/utils/atoms/settings";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import React from "react";
|
import React, { useMemo } from "react";
|
||||||
import { Switch, TouchableOpacity, View } from "react-native";
|
import { Switch, TouchableOpacity } from "react-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
import { ListItem } from "../list/ListItem";
|
import { ListItem } from "../list/ListItem";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
|
|
||||||
export const DownloadSettings: React.FC = ({ ...props }) => {
|
export const DownloadSettings: React.FC = ({ ...props }) => {
|
||||||
const [settings, updateSettings] = useSettings();
|
const [settings, updateSettings, pluginSettings] = useSettings();
|
||||||
const { setProcesses } = useDownload();
|
const { setProcesses } = useDownload();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const allDisabled = useMemo(
|
||||||
|
() =>
|
||||||
|
pluginSettings?.downloadMethod?.locked === true &&
|
||||||
|
pluginSettings?.remuxConcurrentLimit?.locked === true &&
|
||||||
|
pluginSettings?.autoDownload.locked === true,
|
||||||
|
[pluginSettings]
|
||||||
|
);
|
||||||
|
|
||||||
if (!settings) return null;
|
if (!settings) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props} className="mb-4">
|
<DisabledSetting disabled={allDisabled} {...props} className="mb-4">
|
||||||
<ListGroup title={t("home.settings.downloads.downloads_title")}>
|
<ListGroup title={t("home.settings.downloads.downloads_title")}>
|
||||||
<ListItem title={t("home.settings.downloads.download_method")}>
|
<ListItem
|
||||||
|
title={t("home.settings.downloads.download_method")}
|
||||||
|
disabled={pluginSettings?.downloadMethod?.locked}
|
||||||
|
>
|
||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger>
|
<DropdownMenu.Trigger>
|
||||||
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
|
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
|
||||||
<Text className="mr-1 text-[#8E8D91]">
|
<Text className="mr-1 text-[#8E8D91]">
|
||||||
{settings.downloadMethod === "remux"
|
{settings.downloadMethod === DownloadMethod.Remux
|
||||||
? t("home.settings.downloads.default")
|
? t("home.settings.downloads.default")
|
||||||
: t("home.settings.downloads.optimized")}
|
: t("home.settings.downloads.optimized")}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -53,7 +65,7 @@ export const DownloadSettings: React.FC = ({ ...props }) => {
|
|||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
key="1"
|
key="1"
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
updateSettings({ downloadMethod: "remux" });
|
updateSettings({ downloadMethod: DownloadMethod.Remux });
|
||||||
setProcesses([]);
|
setProcesses([]);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -62,7 +74,7 @@ export const DownloadSettings: React.FC = ({ ...props }) => {
|
|||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
key="2"
|
key="2"
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
updateSettings({ downloadMethod: "optimized" });
|
updateSettings({ downloadMethod: DownloadMethod.Optimized });
|
||||||
setProcesses([]);
|
setProcesses([]);
|
||||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||||
}}
|
}}
|
||||||
@@ -75,7 +87,10 @@ export const DownloadSettings: React.FC = ({ ...props }) => {
|
|||||||
|
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.downloads.remux_max_download")}
|
title={t("home.settings.downloads.remux_max_download")}
|
||||||
disabled={settings.downloadMethod !== "remux"}
|
disabled={
|
||||||
|
pluginSettings?.remuxConcurrentLimit?.locked ||
|
||||||
|
settings.downloadMethod !== DownloadMethod.Remux
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Stepper
|
<Stepper
|
||||||
value={settings.remuxConcurrentLimit}
|
value={settings.remuxConcurrentLimit}
|
||||||
@@ -92,22 +107,31 @@ export const DownloadSettings: React.FC = ({ ...props }) => {
|
|||||||
|
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.downloads.auto_download")}
|
title={t("home.settings.downloads.auto_download")}
|
||||||
disabled={settings.downloadMethod !== "optimized"}
|
disabled={
|
||||||
|
pluginSettings?.autoDownload?.locked ||
|
||||||
|
settings.downloadMethod !== DownloadMethod.Optimized
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Switch
|
<Switch
|
||||||
disabled={settings.downloadMethod !== "optimized"}
|
disabled={
|
||||||
|
pluginSettings?.autoDownload?.locked ||
|
||||||
|
settings.downloadMethod !== DownloadMethod.Optimized
|
||||||
|
}
|
||||||
value={settings.autoDownload}
|
value={settings.autoDownload}
|
||||||
onValueChange={(value) => updateSettings({ autoDownload: value })}
|
onValueChange={(value) => updateSettings({ autoDownload: value })}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
<ListItem
|
<ListItem
|
||||||
disabled={settings.downloadMethod !== "optimized"}
|
disabled={
|
||||||
|
pluginSettings?.optimizedVersionsServerUrl?.locked ||
|
||||||
|
settings.downloadMethod !== DownloadMethod.Optimized
|
||||||
|
}
|
||||||
onPress={() => router.push("/settings/optimized-server/page")}
|
onPress={() => router.push("/settings/optimized-server/page")}
|
||||||
showArrow
|
showArrow
|
||||||
title={t("home.settings.downloads.optimized_versions_server")}
|
title={t("home.settings.downloads.optimized_versions_server")}
|
||||||
></ListItem>
|
></ListItem>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
</View>
|
</DisabledSetting>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export const JellyseerrSettings = () => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const [settings, updateSettings] = useSettings();
|
const [settings, updateSettings, pluginSettings] = useSettings();
|
||||||
|
|
||||||
const [promptForJellyseerrPass, setPromptForJellyseerrPass] =
|
const [promptForJellyseerrPass, setPromptForJellyseerrPass] =
|
||||||
useState<boolean>(false);
|
useState<boolean>(false);
|
||||||
|
|||||||
@@ -1,74 +1,64 @@
|
|||||||
import React from "react";
|
import React, {useMemo} from "react";
|
||||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
import { ViewProps } from "react-native";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
import { ListItem } from "../list/ListItem";
|
import { ListItem } from "../list/ListItem";
|
||||||
import { Text } from "../common/Text";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
|
import {Stepper} from "@/components/inputs/Stepper";
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
export const MediaToggles: React.FC<Props> = ({ ...props }) => {
|
export const MediaToggles: React.FC<Props> = ({ ...props }) => {
|
||||||
const [settings, updateSettings] = useSettings();
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [settings, updateSettings, pluginSettings] = useSettings();
|
||||||
|
|
||||||
if (!settings) return null;
|
if (!settings) return null;
|
||||||
|
|
||||||
const renderSkipControl = (
|
const disabled = useMemo(() => (
|
||||||
value: number,
|
pluginSettings?.forwardSkipTime?.locked === true &&
|
||||||
onDecrease: () => void,
|
pluginSettings?.rewindSkipTime?.locked === true
|
||||||
onIncrease: () => void
|
),
|
||||||
) => (
|
[pluginSettings]
|
||||||
<View className="flex flex-row items-center">
|
)
|
||||||
<TouchableOpacity
|
|
||||||
onPress={onDecrease}
|
|
||||||
className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<Text>-</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<Text className="w-12 h-8 bg-neutral-800 first-letter:px-3 py-2 flex items-center justify-center">
|
|
||||||
{value}s
|
|
||||||
</Text>
|
|
||||||
<TouchableOpacity
|
|
||||||
className="w-8 h-8 bg-neutral-800 rounded-r-lg flex items-center justify-center"
|
|
||||||
onPress={onIncrease}
|
|
||||||
>
|
|
||||||
<Text>+</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<DisabledSetting
|
||||||
|
disabled={disabled}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
<ListGroup title={t("home.settings.media_controls.media_controls_title")}>
|
<ListGroup title={t("home.settings.media_controls.media_controls_title")}>
|
||||||
<ListItem title={t("home.settings.media_controls.forward_skip_length")}>
|
<ListItem
|
||||||
{renderSkipControl(
|
title={t("home.settings.media_controls.forward_skip_length")}
|
||||||
settings.forwardSkipTime,
|
disabled={pluginSettings?.forwardSkipTime?.locked}
|
||||||
() =>
|
>
|
||||||
updateSettings({
|
<Stepper
|
||||||
forwardSkipTime: Math.max(0, settings.forwardSkipTime - 5),
|
value={settings.forwardSkipTime}
|
||||||
}),
|
disabled={pluginSettings?.forwardSkipTime?.locked}
|
||||||
() =>
|
step={5}
|
||||||
updateSettings({
|
appendValue="s"
|
||||||
forwardSkipTime: Math.min(60, settings.forwardSkipTime + 5),
|
min={0}
|
||||||
})
|
max={60}
|
||||||
)}
|
onUpdate={(forwardSkipTime) => updateSettings({forwardSkipTime})}
|
||||||
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
<ListItem title={t("home.settings.media_controls.rewind_length")}>
|
<ListItem
|
||||||
{renderSkipControl(
|
title={t("home.settings.media_controls.rewind_length")}
|
||||||
settings.rewindSkipTime,
|
disabled={pluginSettings?.rewindSkipTime?.locked}
|
||||||
() =>
|
>
|
||||||
updateSettings({
|
<Stepper
|
||||||
rewindSkipTime: Math.max(0, settings.rewindSkipTime - 5),
|
value={settings.rewindSkipTime}
|
||||||
}),
|
disabled={pluginSettings?.rewindSkipTime?.locked}
|
||||||
() =>
|
step={5}
|
||||||
updateSettings({
|
appendValue="s"
|
||||||
rewindSkipTime: Math.min(60, settings.rewindSkipTime + 5),
|
min={0}
|
||||||
})
|
max={60}
|
||||||
)}
|
onUpdate={(rewindSkipTime) => updateSettings({rewindSkipTime})}
|
||||||
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
</View>
|
</DisabledSetting>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,20 +9,19 @@ import * as BackgroundFetch from "expo-background-fetch";
|
|||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
import * as ScreenOrientation from "expo-screen-orientation";
|
||||||
import * as TaskManager from "expo-task-manager";
|
import * as TaskManager from "expo-task-manager";
|
||||||
import React, { useEffect } from "react";
|
import React, {useEffect, useMemo} from "react";
|
||||||
import { Linking, Switch, TouchableOpacity, ViewProps } from "react-native";
|
import { Linking, Switch, TouchableOpacity } from "react-native";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
import { ListItem } from "../list/ListItem";
|
import { ListItem } from "../list/ListItem";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
interface Props extends ViewProps {}
|
import Dropdown from "@/components/common/Dropdown";
|
||||||
|
|
||||||
export const OtherSettings: React.FC = () => {
|
export const OtherSettings: React.FC = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [settings, updateSettings] = useSettings();
|
const [settings, updateSettings, pluginSettings] = useSettings();
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -56,146 +55,114 @@ export const OtherSettings: React.FC = () => {
|
|||||||
/**********************
|
/**********************
|
||||||
*********************/
|
*********************/
|
||||||
|
|
||||||
|
const disabled = useMemo(() => (
|
||||||
|
pluginSettings?.autoRotate?.locked === true &&
|
||||||
|
pluginSettings?.defaultVideoOrientation?.locked === true &&
|
||||||
|
pluginSettings?.safeAreaInControlsEnabled?.locked === true &&
|
||||||
|
pluginSettings?.showCustomMenuLinks?.locked === true &&
|
||||||
|
pluginSettings?.hiddenLibraries?.locked === true &&
|
||||||
|
pluginSettings?.disableHapticFeedback?.locked === true
|
||||||
|
), [pluginSettings]);
|
||||||
|
|
||||||
|
const orientations = [
|
||||||
|
ScreenOrientation.OrientationLock.DEFAULT,
|
||||||
|
ScreenOrientation.OrientationLock.PORTRAIT_UP,
|
||||||
|
ScreenOrientation.OrientationLock.LANDSCAPE_LEFT,
|
||||||
|
ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT
|
||||||
|
]
|
||||||
|
|
||||||
if (!settings) return null;
|
if (!settings) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListGroup title={t("home.settings.other.other_title")} className="">
|
<DisabledSetting
|
||||||
<ListItem title={t("home.settings.other.auto_rotate")}>
|
disabled={disabled}
|
||||||
<Switch
|
>
|
||||||
value={settings.autoRotate}
|
<ListGroup title={t("home.settings.other.other_title")} className="">
|
||||||
onValueChange={(value) => updateSettings({ autoRotate: value })}
|
<ListItem
|
||||||
/>
|
title={t("home.settings.other.auto_rotate")}
|
||||||
</ListItem>
|
disabled={pluginSettings?.autoRotate?.locked}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
value={settings.autoRotate}
|
||||||
|
disabled={pluginSettings?.autoRotate?.locked}
|
||||||
|
onValueChange={(value) => updateSettings({autoRotate: value})}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
<ListItem title={t("home.settings.other.video_orientation")} disabled={settings.autoRotate}>
|
<ListItem
|
||||||
<DropdownMenu.Root>
|
title={t("home.settings.other.video_orientation")}
|
||||||
<DropdownMenu.Trigger>
|
disabled={pluginSettings?.defaultVideoOrientation?.locked || settings.autoRotate}
|
||||||
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
|
>
|
||||||
<Text className="mr-1 text-[#8E8D91]">
|
<Dropdown
|
||||||
{ScreenOrientationEnum[settings.defaultVideoOrientation]}
|
data={orientations}
|
||||||
</Text>
|
disabled={pluginSettings?.defaultVideoOrientation?.locked || settings.autoRotate}
|
||||||
<Ionicons name="chevron-expand-sharp" size={18} color="#5A5960" />
|
keyExtractor={String}
|
||||||
</TouchableOpacity>
|
titleExtractor={(item) =>
|
||||||
</DropdownMenu.Trigger>
|
ScreenOrientationEnum[item]
|
||||||
<DropdownMenu.Content
|
}
|
||||||
loop={true}
|
title={
|
||||||
side="bottom"
|
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
|
||||||
align="start"
|
<Text className="mr-1 text-[#8E8D91]">
|
||||||
alignOffset={0}
|
{ScreenOrientationEnum[settings.defaultVideoOrientation]}
|
||||||
avoidCollisions={true}
|
</Text>
|
||||||
collisionPadding={8}
|
<Ionicons name="chevron-expand-sharp" size={18} color="#5A5960"/>
|
||||||
sideOffset={8}
|
</TouchableOpacity>
|
||||||
>
|
}
|
||||||
<DropdownMenu.Label>Orientation</DropdownMenu.Label>
|
label="Orientation"
|
||||||
<DropdownMenu.Item
|
onSelected={(defaultVideoOrientation) =>
|
||||||
key="1"
|
updateSettings({defaultVideoOrientation})
|
||||||
onSelect={() => {
|
}
|
||||||
updateSettings({
|
/>
|
||||||
defaultVideoOrientation:
|
</ListItem>
|
||||||
ScreenOrientation.OrientationLock.DEFAULT,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>
|
|
||||||
{
|
|
||||||
ScreenOrientationEnum[
|
|
||||||
ScreenOrientation.OrientationLock.DEFAULT
|
|
||||||
]
|
|
||||||
}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key="2"
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({
|
|
||||||
defaultVideoOrientation:
|
|
||||||
ScreenOrientation.OrientationLock.PORTRAIT_UP,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>
|
|
||||||
{
|
|
||||||
ScreenOrientationEnum[
|
|
||||||
ScreenOrientation.OrientationLock.PORTRAIT_UP
|
|
||||||
]
|
|
||||||
}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key="3"
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({
|
|
||||||
defaultVideoOrientation:
|
|
||||||
ScreenOrientation.OrientationLock.LANDSCAPE_LEFT,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>
|
|
||||||
{
|
|
||||||
ScreenOrientationEnum[
|
|
||||||
ScreenOrientation.OrientationLock.LANDSCAPE_LEFT
|
|
||||||
]
|
|
||||||
}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key="4"
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({
|
|
||||||
defaultVideoOrientation:
|
|
||||||
ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>
|
|
||||||
{
|
|
||||||
ScreenOrientationEnum[
|
|
||||||
ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT
|
|
||||||
]
|
|
||||||
}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
<ListItem title={t("home.settings.other.safe_area_in_controls")}>
|
<ListItem
|
||||||
<Switch
|
title={t("home.settings.other.safe_area_in_controls")}
|
||||||
value={settings.safeAreaInControlsEnabled}
|
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
||||||
onValueChange={(value) =>
|
>
|
||||||
updateSettings({ safeAreaInControlsEnabled: value })
|
<Switch
|
||||||
}
|
value={settings.safeAreaInControlsEnabled}
|
||||||
/>
|
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
||||||
</ListItem>
|
onValueChange={(value) =>
|
||||||
|
updateSettings({safeAreaInControlsEnabled: value})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.other.show_custom_menu_links")}
|
title={t("home.settings.other.show_custom_menu_links")}
|
||||||
onPress={() =>
|
disabled={pluginSettings?.showCustomMenuLinks?.locked}
|
||||||
Linking.openURL(
|
onPress={() =>
|
||||||
"https://jellyfin.org/docs/general/clients/web-config/#custom-menu-links"
|
Linking.openURL(
|
||||||
)
|
"https://jellyfin.org/docs/general/clients/web-config/#custom-menu-links"
|
||||||
}
|
)
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
value={settings.showCustomMenuLinks}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
updateSettings({ showCustomMenuLinks: value })
|
|
||||||
}
|
}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
value={settings.showCustomMenuLinks}
|
||||||
|
disabled={pluginSettings?.showCustomMenuLinks?.locked}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateSettings({showCustomMenuLinks: value})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => router.push("/settings/hide-libraries/page")}
|
||||||
|
title="Hide Libraries"
|
||||||
|
showArrow
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
<ListItem
|
||||||
<ListItem
|
title="Disable Haptic Feedback"
|
||||||
onPress={() => router.push("/settings/hide-libraries/page")}
|
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
||||||
title="Hide Libraries"
|
>
|
||||||
showArrow
|
<Switch
|
||||||
/>
|
value={settings.disableHapticFeedback}
|
||||||
<ListItem title="Disable Haptic Feedback">
|
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
||||||
<Switch
|
onValueChange={(disableHapticFeedback) =>
|
||||||
value={settings.disableHapticFeedback}
|
updateSettings({disableHapticFeedback})
|
||||||
onValueChange={(value) =>
|
}
|
||||||
updateSettings({ disableHapticFeedback: value })
|
/>
|
||||||
}
|
</ListItem>
|
||||||
/>
|
</ListGroup>
|
||||||
</ListItem>
|
</DisabledSetting>
|
||||||
</ListGroup>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
import { Button } from "@/components/Button";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { clearLogs } from "@/utils/log";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import * as FileSystem from "expo-file-system";
|
import * as FileSystem from "expo-file-system";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import * as Progress from "react-native-progress";
|
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
import { ListItem } from "../list/ListItem";
|
import { ListItem } from "../list/ListItem";
|
||||||
|
|||||||
@@ -8,11 +8,15 @@ import { ListItem } from "../list/ListItem";
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
|
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {useSettings} from "@/utils/atoms/settings";
|
||||||
|
import {Stepper} from "@/components/inputs/Stepper";
|
||||||
|
import Dropdown from "@/components/common/Dropdown";
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
||||||
const media = useMedia();
|
const media = useMedia();
|
||||||
|
const [_, __, pluginSettings] = useSettings();
|
||||||
const { settings, updateSettings } = media;
|
const { settings, updateSettings } = media;
|
||||||
const cultures = media.cultures;
|
const cultures = media.cultures;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -38,8 +42,11 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ListItem title={t("home.settings.subtitles.subtitle_language")}>
|
<ListItem title={t("home.settings.subtitles.subtitle_language")}>
|
||||||
<DropdownMenu.Root>
|
<Dropdown
|
||||||
<DropdownMenu.Trigger>
|
data={[{DisplayName: "None", ThreeLetterISOLanguageName: "none-subs" },...(cultures ?? [])]}
|
||||||
|
keyExtractor={(item) => item?.ThreeLetterISOLanguageName ?? "unknown"}
|
||||||
|
titleExtractor={(item) => item?.DisplayName}
|
||||||
|
title={
|
||||||
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
|
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
|
||||||
<Text className="mr-1 text-[#8E8D91]">
|
<Text className="mr-1 text-[#8E8D91]">
|
||||||
{settings?.defaultSubtitleLanguage?.DisplayName || t("home.settings.subtitles.none")}
|
{settings?.defaultSubtitleLanguage?.DisplayName || t("home.settings.subtitles.none")}
|
||||||
@@ -50,48 +57,28 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
color="#5A5960"
|
color="#5A5960"
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</DropdownMenu.Trigger>
|
}
|
||||||
<DropdownMenu.Content
|
label="Languages"
|
||||||
loop={true}
|
onSelected={(defaultSubtitleLanguage) =>
|
||||||
side="bottom"
|
updateSettings({
|
||||||
align="start"
|
defaultSubtitleLanguage: defaultSubtitleLanguage.DisplayName === t("home.settings.subtitles.none")
|
||||||
alignOffset={0}
|
? null
|
||||||
avoidCollisions={true}
|
: defaultSubtitleLanguage
|
||||||
collisionPadding={8}
|
})
|
||||||
sideOffset={8}
|
}
|
||||||
>
|
/>
|
||||||
<DropdownMenu.Label>Languages</DropdownMenu.Label>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={"none-subs"}
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({
|
|
||||||
defaultSubtitleLanguage: null,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>{t("home.settings.subtitles.none")}</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
{cultures?.map((l) => (
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={l?.ThreeLetterISOLanguageName ?? "unknown"}
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({
|
|
||||||
defaultSubtitleLanguage: l,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>
|
|
||||||
{l.DisplayName}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
))}
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
<ListItem title={t("home.settings.subtitles.subtitle_mode")}>
|
<ListItem
|
||||||
<DropdownMenu.Root>
|
title={t("home.settings.subtitles.subtitle_mode")}
|
||||||
<DropdownMenu.Trigger>
|
disabled={pluginSettings?.subtitleMode?.locked}
|
||||||
|
>
|
||||||
|
<Dropdown
|
||||||
|
data={subtitleModes}
|
||||||
|
disabled={pluginSettings?.subtitleMode?.locked}
|
||||||
|
keyExtractor={String}
|
||||||
|
titleExtractor={String}
|
||||||
|
title={
|
||||||
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
|
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
|
||||||
<Text className="mr-1 text-[#8E8D91]">
|
<Text className="mr-1 text-[#8E8D91]">
|
||||||
{settings?.subtitleMode || "Loading"}
|
{settings?.subtitleMode || "Loading"}
|
||||||
@@ -102,68 +89,39 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
color="#5A5960"
|
color="#5A5960"
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</DropdownMenu.Trigger>
|
}
|
||||||
<DropdownMenu.Content
|
label="Subtitle Mode"
|
||||||
loop={true}
|
onSelected={(subtitleMode) =>
|
||||||
side="bottom"
|
updateSettings({subtitleMode})
|
||||||
align="start"
|
}
|
||||||
alignOffset={0}
|
/>
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={8}
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Label>Subtitle Mode</DropdownMenu.Label>
|
|
||||||
{subtitleModes?.map((l) => (
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={l}
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({
|
|
||||||
subtitleMode: l,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>{l}</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
))}
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
<ListItem title={t("home.settings.subtitles.set_subtitle_track")}>
|
<ListItem
|
||||||
|
title={t("home.settings.subtitles.set_subtitle_track")}
|
||||||
|
disabled={pluginSettings?.rememberSubtitleSelections?.locked}
|
||||||
|
>
|
||||||
<Switch
|
<Switch
|
||||||
value={settings.rememberSubtitleSelections}
|
value={settings.rememberSubtitleSelections}
|
||||||
|
disabled={pluginSettings?.rememberSubtitleSelections?.locked}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
updateSettings({ rememberSubtitleSelections: value })
|
updateSettings({ rememberSubtitleSelections: value })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
<ListItem title={t("home.settings.subtitles.subtitle_size")}>
|
<ListItem
|
||||||
<View className="flex flex-row items-center">
|
title={t("home.settings.subtitles.subtitle_size")}
|
||||||
<TouchableOpacity
|
disabled={pluginSettings?.subtitleSize?.locked}
|
||||||
onPress={() =>
|
>
|
||||||
updateSettings({
|
<Stepper
|
||||||
subtitleSize: Math.max(0, settings.subtitleSize - 5),
|
value={settings.subtitleSize}
|
||||||
})
|
disabled={pluginSettings?.subtitleSize?.locked}
|
||||||
}
|
step={5}
|
||||||
className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
|
min={0}
|
||||||
>
|
max={120}
|
||||||
<Text>-</Text>
|
onUpdate={(subtitleSize) => updateSettings({subtitleSize})}
|
||||||
</TouchableOpacity>
|
/>
|
||||||
<Text className="w-12 h-8 bg-neutral-800 px-3 py-2 flex items-center justify-center">
|
|
||||||
{settings.subtitleSize}
|
|
||||||
</Text>
|
|
||||||
<TouchableOpacity
|
|
||||||
className="w-8 h-8 bg-neutral-800 rounded-r-lg flex items-center justify-center"
|
|
||||||
onPress={() =>
|
|
||||||
updateSettings({
|
|
||||||
subtitleSize: Math.min(120, settings.subtitleSize + 5),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Text>+</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</ListItem>
|
</ListItem>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -17,14 +17,7 @@ export const commonScreenOptions: ICommonScreenOptions = {
|
|||||||
headerLeft: () => <HeaderBackButton />,
|
headerLeft: () => <HeaderBackButton />,
|
||||||
};
|
};
|
||||||
|
|
||||||
const routes = [
|
const routes = ["actors/[actorId]", "items/page", "series/[id]"];
|
||||||
"actors/[actorId]",
|
|
||||||
"albums/[albumId]",
|
|
||||||
"artists/index",
|
|
||||||
"artists/[artistId]",
|
|
||||||
"items/page",
|
|
||||||
"series/[id]",
|
|
||||||
];
|
|
||||||
|
|
||||||
export const nestedTabPageScreenOptions: Record<string, ICommonScreenOptions> =
|
export const nestedTabPageScreenOptions: Record<string, ICommonScreenOptions> =
|
||||||
Object.fromEntries(routes.map((route) => [route, commonScreenOptions]));
|
Object.fromEntries(routes.map((route) => [route, commonScreenOptions]));
|
||||||
|
|||||||
@@ -35,12 +35,7 @@ import * as ScreenOrientation from "expo-screen-orientation";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { debounce } from "lodash";
|
import { debounce } from "lodash";
|
||||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import {
|
import { TouchableOpacity, useWindowDimensions, View } from "react-native";
|
||||||
Pressable,
|
|
||||||
TouchableOpacity,
|
|
||||||
useWindowDimensions,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import { Slider } from "react-native-awesome-slider";
|
import { Slider } from "react-native-awesome-slider";
|
||||||
import {
|
import {
|
||||||
runOnJS,
|
runOnJS,
|
||||||
@@ -59,6 +54,8 @@ import DropdownViewTranscoding from "./dropdown/DropdownViewTranscoding";
|
|||||||
import { EpisodeList } from "./EpisodeList";
|
import { EpisodeList } from "./EpisodeList";
|
||||||
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
|
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
|
||||||
import SkipButton from "./SkipButton";
|
import SkipButton from "./SkipButton";
|
||||||
|
import { useControlsTimeout } from "./useControlsTimeout";
|
||||||
|
import { VideoTouchOverlay } from "./VideoTouchOverlay";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -89,6 +86,8 @@ interface Props {
|
|||||||
isVlc?: boolean;
|
isVlc?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CONTROLS_TIMEOUT = 4000;
|
||||||
|
|
||||||
export const Controls: React.FC<Props> = ({
|
export const Controls: React.FC<Props> = ({
|
||||||
item,
|
item,
|
||||||
seek,
|
seek,
|
||||||
@@ -121,6 +120,12 @@ export const Controls: React.FC<Props> = ({
|
|||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
|
const [episodeView, setEpisodeView] = useState(false);
|
||||||
|
const [isSliding, setIsSliding] = useState(false);
|
||||||
|
|
||||||
|
// Used when user changes audio through audio button on device.
|
||||||
|
const [showAudioSlider, setShowAudioSlider] = useState(false);
|
||||||
|
|
||||||
const { height: screenHeight, width: screenWidth } = useWindowDimensions();
|
const { height: screenHeight, width: screenWidth } = useWindowDimensions();
|
||||||
const { previousItem, nextItem } = useAdjacentItems({ item });
|
const { previousItem, nextItem } = useAdjacentItems({ item });
|
||||||
const {
|
const {
|
||||||
@@ -139,6 +144,23 @@ export const Controls: React.FC<Props> = ({
|
|||||||
const wasPlayingRef = useRef(false);
|
const wasPlayingRef = useRef(false);
|
||||||
const lastProgressRef = useRef<number>(0);
|
const lastProgressRef = useRef<number>(0);
|
||||||
|
|
||||||
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
prefetchAllTrickplayImages();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (item) {
|
||||||
|
progress.value = isVlc
|
||||||
|
? ticksToMs(item?.UserData?.PlaybackPositionTicks)
|
||||||
|
: item?.UserData?.PlaybackPositionTicks || 0;
|
||||||
|
max.value = isVlc
|
||||||
|
? ticksToMs(item.RunTimeTicks || 0)
|
||||||
|
: item.RunTimeTicks || 0;
|
||||||
|
}
|
||||||
|
}, [item, isVlc]);
|
||||||
|
|
||||||
const { bitrateValue, subtitleIndex, audioIndex } = useLocalSearchParams<{
|
const { bitrateValue, subtitleIndex, audioIndex } = useLocalSearchParams<{
|
||||||
bitrateValue: string;
|
bitrateValue: string;
|
||||||
audioIndex: string;
|
audioIndex: string;
|
||||||
@@ -161,8 +183,6 @@ export const Controls: React.FC<Props> = ({
|
|||||||
isVlc
|
isVlc
|
||||||
);
|
);
|
||||||
|
|
||||||
const lightHapticFeedback = useHaptic("light");
|
|
||||||
|
|
||||||
const goToPreviousItem = useCallback(() => {
|
const goToPreviousItem = useCallback(() => {
|
||||||
if (!previousItem || !settings) return;
|
if (!previousItem || !settings) return;
|
||||||
|
|
||||||
@@ -266,20 +286,19 @@ export const Controls: React.FC<Props> = ({
|
|||||||
[updateTimes]
|
[updateTimes]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
const hideControls = useCallback(() => {
|
||||||
if (item) {
|
setShowControls(false);
|
||||||
progress.value = isVlc
|
setShowAudioSlider(false);
|
||||||
? ticksToMs(item?.UserData?.PlaybackPositionTicks)
|
|
||||||
: item?.UserData?.PlaybackPositionTicks || 0;
|
|
||||||
max.value = isVlc
|
|
||||||
? ticksToMs(item.RunTimeTicks || 0)
|
|
||||||
: item.RunTimeTicks || 0;
|
|
||||||
}
|
|
||||||
}, [item, isVlc]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
prefetchAllTrickplayImages();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const { handleControlsInteraction } = useControlsTimeout({
|
||||||
|
showControls,
|
||||||
|
isSliding,
|
||||||
|
episodeView,
|
||||||
|
onHideControls: hideControls,
|
||||||
|
timeout: CONTROLS_TIMEOUT,
|
||||||
|
});
|
||||||
|
|
||||||
const toggleControls = () => {
|
const toggleControls = () => {
|
||||||
if (showControls) {
|
if (showControls) {
|
||||||
setShowAudioSlider(false);
|
setShowAudioSlider(false);
|
||||||
@@ -300,16 +319,13 @@ export const Controls: React.FC<Props> = ({
|
|||||||
isSeeking.value = true;
|
isSeeking.value = true;
|
||||||
}, [showControls, isPlaying]);
|
}, [showControls, isPlaying]);
|
||||||
|
|
||||||
const [isSliding, setIsSliding] = useState(false);
|
|
||||||
const handleSliderComplete = useCallback(
|
const handleSliderComplete = useCallback(
|
||||||
async (value: number) => {
|
async (value: number) => {
|
||||||
isSeeking.value = false;
|
isSeeking.value = false;
|
||||||
progress.value = value;
|
progress.value = value;
|
||||||
setIsSliding(false);
|
setIsSliding(false);
|
||||||
|
|
||||||
await seek(
|
seek(Math.max(0, Math.floor(isVlc ? value : ticksToSeconds(value))));
|
||||||
Math.max(0, Math.floor(isVlc ? value : ticksToSeconds(value)))
|
|
||||||
);
|
|
||||||
if (wasPlayingRef.current === true) play();
|
if (wasPlayingRef.current === true) play();
|
||||||
},
|
},
|
||||||
[isVlc]
|
[isVlc]
|
||||||
@@ -339,7 +355,7 @@ export const Controls: React.FC<Props> = ({
|
|||||||
const newTime = isVlc
|
const newTime = isVlc
|
||||||
? Math.max(0, curr - secondsToMs(settings.rewindSkipTime))
|
? Math.max(0, curr - secondsToMs(settings.rewindSkipTime))
|
||||||
: Math.max(0, ticksToSeconds(curr) - settings.rewindSkipTime);
|
: Math.max(0, ticksToSeconds(curr) - settings.rewindSkipTime);
|
||||||
await seek(newTime);
|
seek(newTime);
|
||||||
if (wasPlayingRef.current === true) play();
|
if (wasPlayingRef.current === true) play();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -357,7 +373,7 @@ export const Controls: React.FC<Props> = ({
|
|||||||
const newTime = isVlc
|
const newTime = isVlc
|
||||||
? curr + secondsToMs(settings.forwardSkipTime)
|
? curr + secondsToMs(settings.forwardSkipTime)
|
||||||
: ticksToSeconds(curr) + settings.forwardSkipTime;
|
: ticksToSeconds(curr) + settings.forwardSkipTime;
|
||||||
await seek(Math.max(0, newTime));
|
seek(Math.max(0, newTime));
|
||||||
if (wasPlayingRef.current === true) play();
|
if (wasPlayingRef.current === true) play();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -365,81 +381,6 @@ export const Controls: React.FC<Props> = ({
|
|||||||
}
|
}
|
||||||
}, [settings, isPlaying, isVlc]);
|
}, [settings, isPlaying, isVlc]);
|
||||||
|
|
||||||
const toggleIgnoreSafeAreas = useCallback(() => {
|
|
||||||
setIgnoreSafeAreas((prev) => !prev);
|
|
||||||
lightHapticFeedback();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const memoizedRenderBubble = useCallback(() => {
|
|
||||||
if (!trickPlayUrl || !trickplayInfo) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const { x, y, url } = trickPlayUrl;
|
|
||||||
const tileWidth = 150;
|
|
||||||
const tileHeight = 150 / trickplayInfo.aspectRatio!;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: -57,
|
|
||||||
bottom: 15,
|
|
||||||
paddingTop: 30,
|
|
||||||
paddingBottom: 5,
|
|
||||||
width: tileWidth * 1.5,
|
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.6)",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: tileWidth,
|
|
||||||
height: tileHeight,
|
|
||||||
alignSelf: "center",
|
|
||||||
transform: [{ scale: 1.4 }],
|
|
||||||
borderRadius: 5,
|
|
||||||
}}
|
|
||||||
className="bg-neutral-800 overflow-hidden"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
cachePolicy={"memory-disk"}
|
|
||||||
style={{
|
|
||||||
width: 150 * trickplayInfo?.data.TileWidth!,
|
|
||||||
height:
|
|
||||||
(150 / trickplayInfo.aspectRatio!) *
|
|
||||||
trickplayInfo?.data.TileHeight!,
|
|
||||||
transform: [
|
|
||||||
{ translateX: -x * tileWidth },
|
|
||||||
{ translateY: -y * tileHeight },
|
|
||||||
],
|
|
||||||
resizeMode: "cover",
|
|
||||||
}}
|
|
||||||
source={{ uri: url }}
|
|
||||||
contentFit="cover"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
marginTop: 30,
|
|
||||||
fontSize: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{`${time.hours > 0 ? `${time.hours}:` : ""}${
|
|
||||||
time.minutes < 10 ? `0${time.minutes}` : time.minutes
|
|
||||||
}:${time.seconds < 10 ? `0${time.seconds}` : time.seconds}`}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}, [trickPlayUrl, trickplayInfo, time]);
|
|
||||||
|
|
||||||
const [EpisodeView, setEpisodeView] = useState(false);
|
|
||||||
|
|
||||||
const switchOnEpisodeMode = () => {
|
|
||||||
setEpisodeView(true);
|
|
||||||
if (isPlaying) togglePlay();
|
|
||||||
};
|
|
||||||
|
|
||||||
const goToItem = useCallback(
|
const goToItem = useCallback(
|
||||||
async (itemId: string) => {
|
async (itemId: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -486,8 +427,77 @@ export const Controls: React.FC<Props> = ({
|
|||||||
[settings, subtitleIndex, audioIndex]
|
[settings, subtitleIndex, audioIndex]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Used when user changes audio through audio button on device.
|
const toggleIgnoreSafeAreas = useCallback(() => {
|
||||||
const [showAudioSlider, setShowAudioSlider] = useState(false);
|
setIgnoreSafeAreas((prev) => !prev);
|
||||||
|
lightHapticFeedback();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const switchOnEpisodeMode = useCallback(() => {
|
||||||
|
setEpisodeView(true);
|
||||||
|
if (isPlaying) togglePlay();
|
||||||
|
}, [isPlaying, togglePlay]);
|
||||||
|
|
||||||
|
const memoizedRenderBubble = useCallback(() => {
|
||||||
|
if (!trickPlayUrl || !trickplayInfo) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const { x, y, url } = trickPlayUrl;
|
||||||
|
const tileWidth = 150;
|
||||||
|
const tileHeight = 150 / trickplayInfo.aspectRatio!;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: -62,
|
||||||
|
bottom: 0,
|
||||||
|
paddingTop: 30,
|
||||||
|
paddingBottom: 5,
|
||||||
|
width: tileWidth * 1.5,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: tileWidth,
|
||||||
|
height: tileHeight,
|
||||||
|
alignSelf: "center",
|
||||||
|
transform: [{ scale: 1.4 }],
|
||||||
|
borderRadius: 5,
|
||||||
|
}}
|
||||||
|
className="bg-neutral-800 overflow-hidden"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
cachePolicy={"memory-disk"}
|
||||||
|
style={{
|
||||||
|
width: 150 * trickplayInfo?.data.TileWidth!,
|
||||||
|
height:
|
||||||
|
(150 / trickplayInfo.aspectRatio!) *
|
||||||
|
trickplayInfo?.data.TileHeight!,
|
||||||
|
transform: [
|
||||||
|
{ translateX: -x * tileWidth },
|
||||||
|
{ translateY: -y * tileHeight },
|
||||||
|
],
|
||||||
|
resizeMode: "cover",
|
||||||
|
}}
|
||||||
|
source={{ uri: url }}
|
||||||
|
contentFit="cover"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
marginTop: 30,
|
||||||
|
fontSize: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{`${time.hours > 0 ? `${time.hours}:` : ""}${
|
||||||
|
time.minutes < 10 ? `0${time.minutes}` : time.minutes
|
||||||
|
}:${time.seconds < 10 ? `0${time.seconds}` : time.seconds}`}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}, [trickPlayUrl, trickplayInfo, time]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ControlProvider
|
<ControlProvider
|
||||||
@@ -495,7 +505,7 @@ export const Controls: React.FC<Props> = ({
|
|||||||
mediaSource={mediaSource}
|
mediaSource={mediaSource}
|
||||||
isVideoLoaded={isVideoLoaded}
|
isVideoLoaded={isVideoLoaded}
|
||||||
>
|
>
|
||||||
{EpisodeView ? (
|
{episodeView ? (
|
||||||
<EpisodeList
|
<EpisodeList
|
||||||
item={item}
|
item={item}
|
||||||
close={() => setEpisodeView(false)}
|
close={() => setEpisodeView(false)}
|
||||||
@@ -503,23 +513,12 @@ export const Controls: React.FC<Props> = ({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Pressable
|
<VideoTouchOverlay
|
||||||
onPressIn={() => {
|
screenWidth={screenWidth}
|
||||||
toggleControls();
|
screenHeight={screenHeight}
|
||||||
}}
|
showControls={showControls}
|
||||||
style={{
|
onToggleControls={toggleControls}
|
||||||
position: "absolute",
|
/>
|
||||||
width: screenWidth,
|
|
||||||
height: screenHeight,
|
|
||||||
backgroundColor: "black",
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
top: 0,
|
|
||||||
bottom: 0,
|
|
||||||
opacity: showControls ? 0.5 : 0,
|
|
||||||
}}
|
|
||||||
></Pressable>
|
|
||||||
|
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
{
|
{
|
||||||
@@ -533,7 +532,7 @@ export const Controls: React.FC<Props> = ({
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
pointerEvents={showControls ? "auto" : "none"}
|
pointerEvents={showControls ? "auto" : "none"}
|
||||||
className={`flex flex-row w-full p-4 `}
|
className={`flex flex-row w-full pt-2`}
|
||||||
>
|
>
|
||||||
<View className="mr-auto">
|
<View className="mr-auto">
|
||||||
<VideoProvider
|
<VideoProvider
|
||||||
@@ -557,7 +556,7 @@ export const Controls: React.FC<Props> = ({
|
|||||||
onPress={() => {
|
onPress={() => {
|
||||||
switchOnEpisodeMode();
|
switchOnEpisodeMode();
|
||||||
}}
|
}}
|
||||||
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
className="aspect-square flex flex-col rounded-xl items-center justify-center p-2"
|
||||||
>
|
>
|
||||||
<Ionicons name="list" size={24} color="white" />
|
<Ionicons name="list" size={24} color="white" />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -565,7 +564,7 @@ export const Controls: React.FC<Props> = ({
|
|||||||
{previousItem && !offline && (
|
{previousItem && !offline && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={goToPreviousItem}
|
onPress={goToPreviousItem}
|
||||||
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
className="aspect-square flex flex-col rounded-xl items-center justify-center p-2"
|
||||||
>
|
>
|
||||||
<Ionicons name="play-skip-back" size={24} color="white" />
|
<Ionicons name="play-skip-back" size={24} color="white" />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -574,7 +573,7 @@ export const Controls: React.FC<Props> = ({
|
|||||||
{nextItem && !offline && (
|
{nextItem && !offline && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={goToNextItem}
|
onPress={goToNextItem}
|
||||||
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
className="aspect-square flex flex-col rounded-xl items-center justify-center p-2"
|
||||||
>
|
>
|
||||||
<Ionicons name="play-skip-forward" size={24} color="white" />
|
<Ionicons name="play-skip-forward" size={24} color="white" />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -583,7 +582,7 @@ export const Controls: React.FC<Props> = ({
|
|||||||
{/* {mediaSource?.TranscodingUrl && ( */}
|
{/* {mediaSource?.TranscodingUrl && ( */}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={toggleIgnoreSafeAreas}
|
onPress={toggleIgnoreSafeAreas}
|
||||||
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
className="aspect-square flex flex-col rounded-xl items-center justify-center p-2"
|
||||||
>
|
>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name={ignoreSafeAreas ? "contract-outline" : "expand"}
|
name={ignoreSafeAreas ? "contract-outline" : "expand"}
|
||||||
@@ -600,7 +599,7 @@ export const Controls: React.FC<Props> = ({
|
|||||||
);
|
);
|
||||||
router.back();
|
router.back();
|
||||||
}}
|
}}
|
||||||
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
className="aspect-square flex flex-col rounded-xl items-center justify-center p-2"
|
||||||
>
|
>
|
||||||
<Ionicons name="close" size={24} color="white" />
|
<Ionicons name="close" size={24} color="white" />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -730,10 +729,11 @@ export const Controls: React.FC<Props> = ({
|
|||||||
bottom: settings?.safeAreaInControlsEnabled ? insets.bottom : 0,
|
bottom: settings?.safeAreaInControlsEnabled ? insets.bottom : 0,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
className={`flex flex-col p-4`}
|
className={`flex flex-col px-2`}
|
||||||
|
onTouchStart={handleControlsInteraction}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
className="shrink flex flex-col justify-center h-full mb-2"
|
className="shrink flex flex-col justify-center h-full"
|
||||||
style={{
|
style={{
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
@@ -747,10 +747,12 @@ export const Controls: React.FC<Props> = ({
|
|||||||
}}
|
}}
|
||||||
pointerEvents={showControls ? "box-none" : "none"}
|
pointerEvents={showControls ? "box-none" : "none"}
|
||||||
>
|
>
|
||||||
<Text className="font-bold">{item?.Name}</Text>
|
|
||||||
{item?.Type === "Episode" && (
|
{item?.Type === "Episode" && (
|
||||||
<Text className="opacity-50">{item.SeriesName}</Text>
|
<Text className="opacity-50">
|
||||||
|
{`${item.SeriesName} - ${item.SeasonName} Episode ${item.IndexNumber}`}
|
||||||
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
<Text className="font-bold text-xl">{item?.Name}</Text>
|
||||||
{item?.Type === "Movie" && (
|
{item?.Type === "Movie" && (
|
||||||
<Text className="text-xs opacity-50">
|
<Text className="text-xs opacity-50">
|
||||||
{item?.ProductionYear}
|
{item?.ProductionYear}
|
||||||
@@ -785,7 +787,7 @@ export const Controls: React.FC<Props> = ({
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View
|
<View
|
||||||
className={`flex flex-col-reverse py-4 pb-1 px-4 rounded-lg items-center bg-neutral-800`}
|
className={`flex flex-col-reverse rounded-lg items-center my-2`}
|
||||||
style={{
|
style={{
|
||||||
opacity: showControls ? 1 : 0,
|
opacity: showControls ? 1 : 0,
|
||||||
}}
|
}}
|
||||||
@@ -801,19 +803,7 @@ export const Controls: React.FC<Props> = ({
|
|||||||
bubbleTextColor: "#666",
|
bubbleTextColor: "#666",
|
||||||
heartbeatColor: "#999",
|
heartbeatColor: "#999",
|
||||||
}}
|
}}
|
||||||
renderThumb={() => (
|
renderThumb={() => null}
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: 18,
|
|
||||||
height: 18,
|
|
||||||
left: -2,
|
|
||||||
borderRadius: 10,
|
|
||||||
backgroundColor: "#fff",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
cache={cacheProgress}
|
cache={cacheProgress}
|
||||||
onSlidingStart={handleSliderStart}
|
onSlidingStart={handleSliderStart}
|
||||||
onSlidingComplete={handleSliderComplete}
|
onSlidingComplete={handleSliderComplete}
|
||||||
@@ -828,7 +818,7 @@ export const Controls: React.FC<Props> = ({
|
|||||||
minimumValue={min}
|
minimumValue={min}
|
||||||
maximumValue={max}
|
maximumValue={max}
|
||||||
/>
|
/>
|
||||||
<View className="flex flex-row items-center justify-between mt-0.5">
|
<View className="flex flex-row items-center justify-between mt-2">
|
||||||
<Text className="text-[12px] text-neutral-400">
|
<Text className="text-[12px] text-neutral-400">
|
||||||
{formatTimeString(currentTime, isVlc ? "ms" : "s")}
|
{formatTimeString(currentTime, isVlc ? "ms" : "s")}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -1,26 +1,26 @@
|
|||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { runtimeTicksToSeconds } from "@/utils/time";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { atom, useAtom } from "jotai";
|
|
||||||
import { useEffect, useMemo, useState, useRef } from "react";
|
|
||||||
import { View, TouchableOpacity } from "react-native";
|
|
||||||
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { DownloadSingleItem } from "@/components/DownloadItem";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import {
|
import {
|
||||||
HorizontalScroll,
|
HorizontalScroll,
|
||||||
HorizontalScrollRef,
|
HorizontalScrollRef,
|
||||||
} from "@/components/common/HorrizontalScroll";
|
} from "@/components/common/HorrizontalScroll";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||||
|
import { DownloadSingleItem } from "@/components/DownloadItem";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
import {
|
import {
|
||||||
SeasonDropdown,
|
SeasonDropdown,
|
||||||
SeasonIndexState,
|
SeasonIndexState,
|
||||||
} from "@/components/series/SeasonDropdown";
|
} from "@/components/series/SeasonDropdown";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
|
import { runtimeTicksToSeconds } from "@/utils/time";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { atom, useAtom } from "jotai";
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
|
|||||||
38
components/video-player/controls/VideoTouchOverlay.tsx
Normal file
38
components/video-player/controls/VideoTouchOverlay.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { Pressable } from "react-native";
|
||||||
|
import { useTapDetection } from "./useTapDetection";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
screenWidth: number;
|
||||||
|
screenHeight: number;
|
||||||
|
showControls: boolean;
|
||||||
|
onToggleControls: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VideoTouchOverlay = ({
|
||||||
|
screenWidth,
|
||||||
|
screenHeight,
|
||||||
|
showControls,
|
||||||
|
onToggleControls,
|
||||||
|
}: Props) => {
|
||||||
|
const { handleTouchStart, handleTouchEnd } = useTapDetection({
|
||||||
|
onValidTap: onToggleControls,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
width: screenWidth,
|
||||||
|
height: screenHeight,
|
||||||
|
backgroundColor: "black",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
opacity: showControls ? 0.75 : 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -74,7 +74,7 @@ const DropdownViewDirect: React.FC<DropdownViewDirectProps> = ({
|
|||||||
return (
|
return (
|
||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger>
|
<DropdownMenu.Trigger>
|
||||||
<TouchableOpacity className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2">
|
<TouchableOpacity className="aspect-square flex flex-col rounded-xl items-center justify-center p-2">
|
||||||
<Ionicons name="ellipsis-horizontal" size={24} color={"white"} />
|
<Ionicons name="ellipsis-horizontal" size={24} color={"white"} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
|
|||||||
<View>
|
<View>
|
||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger>
|
<DropdownMenu.Trigger>
|
||||||
<TouchableOpacity className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2">
|
<TouchableOpacity className="aspect-square flex flex-col rounded-xl items-center justify-center p-2">
|
||||||
<Ionicons name="ellipsis-horizontal" size={24} color={"white"} />
|
<Ionicons name="ellipsis-horizontal" size={24} color={"white"} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
|
|||||||
56
components/video-player/controls/useControlsTimeout.ts
Normal file
56
components/video-player/controls/useControlsTimeout.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
interface UseControlsTimeoutProps {
|
||||||
|
showControls: boolean;
|
||||||
|
isSliding: boolean;
|
||||||
|
episodeView: boolean;
|
||||||
|
onHideControls: () => void;
|
||||||
|
timeout?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useControlsTimeout = ({
|
||||||
|
showControls,
|
||||||
|
isSliding,
|
||||||
|
episodeView,
|
||||||
|
onHideControls,
|
||||||
|
timeout = 4000,
|
||||||
|
}: UseControlsTimeoutProps) => {
|
||||||
|
const controlsTimeoutRef = useRef<NodeJS.Timeout>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const resetControlsTimeout = () => {
|
||||||
|
if (controlsTimeoutRef.current) {
|
||||||
|
clearTimeout(controlsTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showControls && !isSliding && !episodeView) {
|
||||||
|
controlsTimeoutRef.current = setTimeout(() => {
|
||||||
|
onHideControls();
|
||||||
|
}, timeout);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
resetControlsTimeout();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (controlsTimeoutRef.current) {
|
||||||
|
clearTimeout(controlsTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [showControls, isSliding, episodeView, timeout, onHideControls]);
|
||||||
|
|
||||||
|
const handleControlsInteraction = () => {
|
||||||
|
if (showControls) {
|
||||||
|
if (controlsTimeoutRef.current) {
|
||||||
|
clearTimeout(controlsTimeoutRef.current);
|
||||||
|
}
|
||||||
|
controlsTimeoutRef.current = setTimeout(() => {
|
||||||
|
onHideControls();
|
||||||
|
}, timeout);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleControlsInteraction,
|
||||||
|
};
|
||||||
|
};
|
||||||
48
components/video-player/controls/useTapDetection.tsx
Normal file
48
components/video-player/controls/useTapDetection.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { useRef } from "react";
|
||||||
|
import { GestureResponderEvent } from "react-native";
|
||||||
|
|
||||||
|
interface TapDetectionOptions {
|
||||||
|
maxDuration?: number;
|
||||||
|
maxDistance?: number;
|
||||||
|
onValidTap?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTapDetection = ({
|
||||||
|
maxDuration = 200,
|
||||||
|
maxDistance = 10,
|
||||||
|
onValidTap,
|
||||||
|
}: TapDetectionOptions = {}) => {
|
||||||
|
const touchStartTime = useRef(0);
|
||||||
|
const touchStartPosition = useRef({ x: 0, y: 0 });
|
||||||
|
|
||||||
|
const handleTouchStart = (event: GestureResponderEvent) => {
|
||||||
|
touchStartTime.current = Date.now();
|
||||||
|
touchStartPosition.current = {
|
||||||
|
x: event.nativeEvent.pageX,
|
||||||
|
y: event.nativeEvent.pageY,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchEnd = (event: GestureResponderEvent) => {
|
||||||
|
const touchEndTime = Date.now();
|
||||||
|
const touchEndPosition = {
|
||||||
|
x: event.nativeEvent.pageX,
|
||||||
|
y: event.nativeEvent.pageY,
|
||||||
|
};
|
||||||
|
|
||||||
|
const touchDuration = touchEndTime - touchStartTime.current;
|
||||||
|
const touchDistance = Math.sqrt(
|
||||||
|
Math.pow(touchEndPosition.x - touchStartPosition.current.x, 2) +
|
||||||
|
Math.pow(touchEndPosition.y - touchStartPosition.current.y, 2)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (touchDuration < maxDuration && touchDistance < maxDistance) {
|
||||||
|
onValidTap?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleTouchStart,
|
||||||
|
handleTouchEnd,
|
||||||
|
};
|
||||||
|
};
|
||||||
106
hooks/useJellyfinDiscovery.tsx
Normal file
106
hooks/useJellyfinDiscovery.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import dgram from "react-native-udp";
|
||||||
|
|
||||||
|
const JELLYFIN_DISCOVERY_PORT = 7359;
|
||||||
|
const DISCOVERY_MESSAGE = "Who is JellyfinServer?";
|
||||||
|
|
||||||
|
interface ServerInfo {
|
||||||
|
address: string;
|
||||||
|
port: number;
|
||||||
|
serverId?: string;
|
||||||
|
serverName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useJellyfinDiscovery = () => {
|
||||||
|
const [servers, setServers] = useState<ServerInfo[]>([]);
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
|
||||||
|
const startDiscovery = useCallback(() => {
|
||||||
|
setIsSearching(true);
|
||||||
|
setServers([]);
|
||||||
|
|
||||||
|
const discoveredServers = new Set<string>();
|
||||||
|
let discoveryTimeout: NodeJS.Timeout;
|
||||||
|
|
||||||
|
const socket = dgram.createSocket({
|
||||||
|
type: "udp4",
|
||||||
|
reusePort: true,
|
||||||
|
debug: __DEV__,
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("error", (err) => {
|
||||||
|
console.error("Socket error:", err);
|
||||||
|
socket.close();
|
||||||
|
setIsSearching(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.bind(0, () => {
|
||||||
|
console.log("UDP socket bound successfully");
|
||||||
|
|
||||||
|
try {
|
||||||
|
socket.setBroadcast(true);
|
||||||
|
const messageBuffer = new TextEncoder().encode(DISCOVERY_MESSAGE);
|
||||||
|
|
||||||
|
socket.send(
|
||||||
|
messageBuffer,
|
||||||
|
0,
|
||||||
|
messageBuffer.length,
|
||||||
|
JELLYFIN_DISCOVERY_PORT,
|
||||||
|
"255.255.255.255",
|
||||||
|
(err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error("Failed to send discovery message:", err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log("Discovery message sent successfully");
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
discoveryTimeout = setTimeout(() => {
|
||||||
|
setIsSearching(false);
|
||||||
|
socket.close();
|
||||||
|
}, 5000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error during discovery:", error);
|
||||||
|
setIsSearching(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("message", (msg, rinfo: any) => {
|
||||||
|
if (discoveredServers.has(rinfo.address)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = new TextDecoder().decode(msg);
|
||||||
|
const serverInfo = JSON.parse(response);
|
||||||
|
discoveredServers.add(rinfo.address);
|
||||||
|
|
||||||
|
const newServer: ServerInfo = {
|
||||||
|
address: `http://${rinfo.address}:${serverInfo.Port || 8096}`,
|
||||||
|
port: serverInfo.Port || 8096,
|
||||||
|
serverId: serverInfo.Id,
|
||||||
|
serverName: serverInfo.Name,
|
||||||
|
};
|
||||||
|
|
||||||
|
setServers((prev) => [...prev, newServer]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error parsing server response:", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(discoveryTimeout);
|
||||||
|
if (isSearching) {
|
||||||
|
setIsSearching(false);
|
||||||
|
}
|
||||||
|
socket.close();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
servers,
|
||||||
|
isSearching,
|
||||||
|
startDiscovery,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -35,6 +35,11 @@ import {
|
|||||||
} from "@/utils/jellyseerr/server/models/Person";
|
} from "@/utils/jellyseerr/server/models/Person";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import {GenreSliderItem} from "@/utils/jellyseerr/server/interfaces/api/discoverInterfaces";
|
import {GenreSliderItem} from "@/utils/jellyseerr/server/interfaces/api/discoverInterfaces";
|
||||||
|
import {UserResultsResponse} from "@/utils/jellyseerr/server/interfaces/api/userInterfaces";
|
||||||
|
import {
|
||||||
|
ServiceCommonServer,
|
||||||
|
ServiceCommonServerWithDetails
|
||||||
|
} from "@/utils/jellyseerr/server/interfaces/api/serviceInterfaces";
|
||||||
|
|
||||||
interface SearchParams {
|
interface SearchParams {
|
||||||
query: string;
|
query: string;
|
||||||
@@ -67,6 +72,8 @@ export enum Endpoints {
|
|||||||
MOVIE = "/movie",
|
MOVIE = "/movie",
|
||||||
RATINGS = "/ratings",
|
RATINGS = "/ratings",
|
||||||
ISSUE = "/issue",
|
ISSUE = "/issue",
|
||||||
|
USER = "/user",
|
||||||
|
SERVICE = "/service",
|
||||||
TV = "/tv",
|
TV = "/tv",
|
||||||
SETTINGS = "/settings",
|
SETTINGS = "/settings",
|
||||||
NETWORK = "/network",
|
NETWORK = "/network",
|
||||||
@@ -283,6 +290,12 @@ export class JellyseerrApi {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async user(params: any) {
|
||||||
|
return this.axios
|
||||||
|
?.get<UserResultsResponse>(`${Endpoints.API_V1}${Endpoints.USER}`, { params })
|
||||||
|
.then(({data}) => data.results)
|
||||||
|
}
|
||||||
|
|
||||||
imageProxy(
|
imageProxy(
|
||||||
path?: string,
|
path?: string,
|
||||||
filter: string = "original",
|
filter: string = "original",
|
||||||
@@ -316,6 +329,18 @@ export class JellyseerrApi {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async service(type: 'radarr' | 'sonarr') {
|
||||||
|
return this.axios
|
||||||
|
?.get<ServiceCommonServer[]>(Endpoints.API_V1 + Endpoints.SERVICE + `/${type}`)
|
||||||
|
.then(({data}) => data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async serviceDetails(type: 'radarr' | 'sonarr', id: number) {
|
||||||
|
return this.axios
|
||||||
|
?.get<ServiceCommonServerWithDetails>(Endpoints.API_V1 + Endpoints.SERVICE + `/${type}` + `/${id}`)
|
||||||
|
.then(({data}) => data);
|
||||||
|
}
|
||||||
|
|
||||||
private setInterceptors() {
|
private setInterceptors() {
|
||||||
this.axios.interceptors.response.use(
|
this.axios.interceptors.response.use(
|
||||||
async (response) => {
|
async (response) => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"submodule-reload": "git submodule update --init --remote --recursive",
|
"submodule-reload": "git submodule update --init --remote --recursive",
|
||||||
|
"clean": "echo y | expo prebuild --clean",
|
||||||
"start": "bun run submodule-reload && expo start",
|
"start": "bun run submodule-reload && expo start",
|
||||||
"reset-project": "node ./scripts/reset-project.js",
|
"reset-project": "node ./scripts/reset-project.js",
|
||||||
"android": "bun run submodule-reload && expo run:android",
|
"android": "bun run submodule-reload && expo run:android",
|
||||||
@@ -17,14 +18,15 @@
|
|||||||
"preset": "jest-expo"
|
"preset": "jest-expo"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bottom-tabs/react-navigation": "^0.7.1",
|
"@bottom-tabs/react-navigation": "0.7.8",
|
||||||
|
"react-native-bottom-tabs": "0.7.8",
|
||||||
"@config-plugins/ffmpeg-kit-react-native": "^8.0.0",
|
"@config-plugins/ffmpeg-kit-react-native": "^8.0.0",
|
||||||
"@expo/react-native-action-sheet": "^4.1.0",
|
"@expo/react-native-action-sheet": "^4.1.0",
|
||||||
"@expo/vector-icons": "^14.0.4",
|
"@expo/vector-icons": "^14.0.4",
|
||||||
"@futurejj/react-native-visibility-sensor": "^1.3.5",
|
"@futurejj/react-native-visibility-sensor": "^1.3.5",
|
||||||
"@gorhom/bottom-sheet": "^4.6.4",
|
"@gorhom/bottom-sheet": "^4.6.4",
|
||||||
"@jellyfin/sdk": "^0.11.0",
|
"@jellyfin/sdk": "^0.11.0",
|
||||||
"@kesha-antonov/react-native-background-downloader": "3.1.2",
|
"@kesha-antonov/react-native-background-downloader": "3.2.6",
|
||||||
"@react-native-async-storage/async-storage": "1.23.1",
|
"@react-native-async-storage/async-storage": "1.23.1",
|
||||||
"@react-native-community/netinfo": "11.3.1",
|
"@react-native-community/netinfo": "11.3.1",
|
||||||
"@react-native-menu/menu": "^1.1.6",
|
"@react-native-menu/menu": "^1.1.6",
|
||||||
@@ -75,7 +77,6 @@
|
|||||||
"react-i18next": "^15.4.0",
|
"react-i18next": "^15.4.0",
|
||||||
"react-native": "0.74.5",
|
"react-native": "0.74.5",
|
||||||
"react-native-awesome-slider": "^2.5.6",
|
"react-native-awesome-slider": "^2.5.6",
|
||||||
"react-native-bottom-tabs": "0.7.8",
|
|
||||||
"react-native-circular-progress": "^1.4.1",
|
"react-native-circular-progress": "^1.4.1",
|
||||||
"react-native-compressor": "^1.9.0",
|
"react-native-compressor": "^1.9.0",
|
||||||
"react-native-country-flag": "^2.0.2",
|
"react-native-country-flag": "^2.0.2",
|
||||||
@@ -96,6 +97,7 @@
|
|||||||
"react-native-screens": "3.31.1",
|
"react-native-screens": "3.31.1",
|
||||||
"react-native-svg": "15.2.0",
|
"react-native-svg": "15.2.0",
|
||||||
"react-native-tab-view": "^3.5.2",
|
"react-native-tab-view": "^3.5.2",
|
||||||
|
"react-native-udp": "^4.1.7",
|
||||||
"react-native-uitextview": "^1.4.0",
|
"react-native-uitextview": "^1.4.0",
|
||||||
"react-native-url-polyfill": "^2.0.0",
|
"react-native-url-polyfill": "^2.0.0",
|
||||||
"react-native-uuid": "^2.0.2",
|
"react-native-uuid": "^2.0.2",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useSettings } from "@/utils/atoms/settings";
|
import {DownloadMethod, useSettings} from "@/utils/atoms/settings";
|
||||||
import { getOrSetDeviceId } from "@/utils/device";
|
import { getOrSetDeviceId } from "@/utils/device";
|
||||||
import { useLog, writeToLog } from "@/utils/log";
|
import { useLog, writeToLog } from "@/utils/log";
|
||||||
import {
|
import {
|
||||||
@@ -108,7 +108,7 @@ function useDownloadProvider() {
|
|||||||
const url = settings?.optimizedVersionsServerUrl;
|
const url = settings?.optimizedVersionsServerUrl;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
settings?.downloadMethod !== "optimized" ||
|
settings?.downloadMethod !== DownloadMethod.Optimized ||
|
||||||
!url ||
|
!url ||
|
||||||
!deviceId ||
|
!deviceId ||
|
||||||
!authHeader
|
!authHeader
|
||||||
@@ -168,7 +168,7 @@ function useDownloadProvider() {
|
|||||||
},
|
},
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
refetchInterval: 2000,
|
refetchInterval: 2000,
|
||||||
enabled: settings?.downloadMethod === "optimized",
|
enabled: settings?.downloadMethod === DownloadMethod.Optimized,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import "@/augmentations";
|
||||||
import { useInterval } from "@/hooks/useInterval";
|
import { useInterval } from "@/hooks/useInterval";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
import { Api, Jellyfin } from "@jellyfin/sdk";
|
import { Api, Jellyfin } from "@jellyfin/sdk";
|
||||||
@@ -19,8 +20,9 @@ import React, {
|
|||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import uuid from "react-native-uuid";
|
import uuid from "react-native-uuid";
|
||||||
import { getDeviceName } from "react-native-device-info";
|
import { getDeviceName } from "react-native-device-info";
|
||||||
import { toast } from "sonner-native";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
|
|
||||||
interface Server {
|
interface Server {
|
||||||
address: string;
|
address: string;
|
||||||
@@ -73,6 +75,14 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
const [user, setUser] = useAtom(userAtom);
|
const [user, setUser] = useAtom(userAtom);
|
||||||
const [isPolling, setIsPolling] = useState<boolean>(false);
|
const [isPolling, setIsPolling] = useState<boolean>(false);
|
||||||
const [secret, setSecret] = useState<string | null>(null);
|
const [secret, setSecret] = useState<string | null>(null);
|
||||||
|
const [
|
||||||
|
settings,
|
||||||
|
updateSettings,
|
||||||
|
pluginSettings,
|
||||||
|
setPluginSettings,
|
||||||
|
refreshStreamyfinPluginSettings,
|
||||||
|
] = useSettings();
|
||||||
|
const { clearAllJellyseerData, setJellyseerrUser } = useJellyseerr();
|
||||||
|
|
||||||
useQuery({
|
useQuery({
|
||||||
queryKey: ["user", api],
|
queryKey: ["user", api],
|
||||||
@@ -229,6 +239,18 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
storage.set("user", JSON.stringify(auth.data.User));
|
storage.set("user", JSON.stringify(auth.data.User));
|
||||||
setApi(jellyfin.createApi(api?.basePath, auth.data?.AccessToken));
|
setApi(jellyfin.createApi(api?.basePath, auth.data?.AccessToken));
|
||||||
storage.set("token", auth.data?.AccessToken);
|
storage.set("token", auth.data?.AccessToken);
|
||||||
|
|
||||||
|
const recentPluginSettings = await refreshStreamyfinPluginSettings();
|
||||||
|
if (recentPluginSettings?.jellyseerrServerUrl?.value) {
|
||||||
|
const jellyseerrApi = new JellyseerrApi(
|
||||||
|
recentPluginSettings.jellyseerrServerUrl.value
|
||||||
|
);
|
||||||
|
await jellyseerrApi.test().then((result) => {
|
||||||
|
if (result.isValid && result.requiresPass) {
|
||||||
|
jellyseerrApi.login(username, password).then(setJellyseerrUser);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (axios.isAxiosError(error)) {
|
if (axios.isAxiosError(error)) {
|
||||||
@@ -265,6 +287,8 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
storage.delete("token");
|
storage.delete("token");
|
||||||
setUser(null);
|
setUser(null);
|
||||||
|
setPluginSettings(undefined);
|
||||||
|
await clearAllJellyseerData();
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
console.error("Logout failed:", error);
|
console.error("Logout failed:", error);
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ type PlaySettingsContextType = {
|
|||||||
setPlayUrl: React.Dispatch<React.SetStateAction<string | null>>;
|
setPlayUrl: React.Dispatch<React.SetStateAction<string | null>>;
|
||||||
playSessionId?: string | null;
|
playSessionId?: string | null;
|
||||||
setOfflineSettings: (data: PlaybackType) => void;
|
setOfflineSettings: (data: PlaybackType) => void;
|
||||||
setMusicPlaySettings: (item: BaseItemDto, url: string) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const PlaySettingsContext = createContext<PlaySettingsContextType | undefined>(
|
const PlaySettingsContext = createContext<PlaySettingsContextType | undefined>(
|
||||||
@@ -61,13 +60,6 @@ export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({
|
|||||||
_setPlaySettings(data);
|
_setPlaySettings(data);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setMusicPlaySettings = (item: BaseItemDto, url: string) => {
|
|
||||||
setPlaySettings({
|
|
||||||
item: item,
|
|
||||||
});
|
|
||||||
setPlayUrl(url);
|
|
||||||
};
|
|
||||||
|
|
||||||
const setPlaySettings = useCallback(
|
const setPlaySettings = useCallback(
|
||||||
async (
|
async (
|
||||||
dataOrUpdater:
|
dataOrUpdater:
|
||||||
@@ -147,7 +139,6 @@ export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({
|
|||||||
setPlaySettings,
|
setPlaySettings,
|
||||||
playUrl,
|
playUrl,
|
||||||
setPlayUrl,
|
setPlayUrl,
|
||||||
setMusicPlaySettings,
|
|
||||||
setOfflineSettings,
|
setOfflineSettings,
|
||||||
playSessionId,
|
playSessionId,
|
||||||
mediaSource,
|
mediaSource,
|
||||||
|
|||||||
@@ -242,9 +242,6 @@
|
|||||||
"episodes": "Episodes",
|
"episodes": "Episodes",
|
||||||
"collections": "Collections",
|
"collections": "Collections",
|
||||||
"actors": "Actors",
|
"actors": "Actors",
|
||||||
"artists": "Artists",
|
|
||||||
"albums": "Albums",
|
|
||||||
"songs": "Songs",
|
|
||||||
"request_movies": "Request Movies",
|
"request_movies": "Request Movies",
|
||||||
"request_series": "Request Series",
|
"request_series": "Request Series",
|
||||||
"recently_added": "Recently Added",
|
"recently_added": "Recently Added",
|
||||||
@@ -297,9 +294,7 @@
|
|||||||
"episodes": "Episodes",
|
"episodes": "Episodes",
|
||||||
"videos": "Videos",
|
"videos": "Videos",
|
||||||
"boxsets": "Boxsets",
|
"boxsets": "Boxsets",
|
||||||
"playlists": "Playlists",
|
"playlists": "Playlists"
|
||||||
"music_albums": "Music Albums",
|
|
||||||
"audio": "Audio"
|
|
||||||
},
|
},
|
||||||
"custom_links": {
|
"custom_links": {
|
||||||
"no_links": "No links"
|
"no_links": "No links"
|
||||||
@@ -341,9 +336,6 @@
|
|||||||
"show_more": "Show more",
|
"show_more": "Show more",
|
||||||
"show_less": "Show less",
|
"show_less": "Show less",
|
||||||
"appeared_in": "Appeared in",
|
"appeared_in": "Appeared in",
|
||||||
"x_songs": "{{count}} songs",
|
|
||||||
"x_albums": "{{count}} albums",
|
|
||||||
"artists": "Artists",
|
|
||||||
"could_not_load_item": "Could not load item",
|
"could_not_load_item": "Could not load item",
|
||||||
"none": "None",
|
"none": "None",
|
||||||
"download": {
|
"download": {
|
||||||
|
|||||||
@@ -242,9 +242,6 @@
|
|||||||
"episodes": "Épisodes",
|
"episodes": "Épisodes",
|
||||||
"collections": "Collections",
|
"collections": "Collections",
|
||||||
"actors": "Acteurs",
|
"actors": "Acteurs",
|
||||||
"artists": "Artistes",
|
|
||||||
"albums": "Albums",
|
|
||||||
"songs": "Chansons",
|
|
||||||
"request_movies": "Demander un film",
|
"request_movies": "Demander un film",
|
||||||
"request_series": "Demander une série",
|
"request_series": "Demander une série",
|
||||||
"recently_added": "Ajoutés récemment",
|
"recently_added": "Ajoutés récemment",
|
||||||
@@ -297,9 +294,7 @@
|
|||||||
"episodes": "Épisodes",
|
"episodes": "Épisodes",
|
||||||
"videos": "Vidéos",
|
"videos": "Vidéos",
|
||||||
"boxsets": "Coffrets",
|
"boxsets": "Coffrets",
|
||||||
"playlists": "Listes de lecture",
|
"playlists": "Listes de lecture"
|
||||||
"music_albums": "Albums de musique",
|
|
||||||
"audio": "Audio"
|
|
||||||
},
|
},
|
||||||
"custom_links": {
|
"custom_links": {
|
||||||
"no_links": "Aucun lien"
|
"no_links": "Aucun lien"
|
||||||
@@ -341,9 +336,6 @@
|
|||||||
"show_more": "Afficher plus",
|
"show_more": "Afficher plus",
|
||||||
"show_less": "Afficher moins",
|
"show_less": "Afficher moins",
|
||||||
"appeared_in": "Apparu dans",
|
"appeared_in": "Apparu dans",
|
||||||
"x_songs": "{{count}} chansons",
|
|
||||||
"x_albums": "{{count}} albums",
|
|
||||||
"artists": "Artistes",
|
|
||||||
"could_not_load_item": "Impossible de charger l'item",
|
"could_not_load_item": "Impossible de charger l'item",
|
||||||
"none": "Aucun",
|
"none": "Aucun",
|
||||||
"download": {
|
"download": {
|
||||||
|
|||||||
@@ -48,5 +48,20 @@ export const useJellyseerrCanRequest = (
|
|||||||
return userHasPermission && !canNotRequest;
|
return userHasPermission && !canNotRequest;
|
||||||
}, [item, jellyseerrUser]);
|
}, [item, jellyseerrUser]);
|
||||||
|
|
||||||
return canRequest;
|
const hasAdvancedRequestPermission = useMemo(() => {
|
||||||
|
if (!jellyseerrUser) return false;
|
||||||
|
|
||||||
|
return hasPermission(
|
||||||
|
[
|
||||||
|
Permission.REQUEST_ADVANCED,
|
||||||
|
Permission.MANAGE_REQUESTS
|
||||||
|
],
|
||||||
|
jellyseerrUser.permissions,
|
||||||
|
{type: 'or'}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[jellyseerrUser]
|
||||||
|
);
|
||||||
|
|
||||||
|
return [canRequest, hasAdvancedRequestPermission];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom } from "jotai";
|
||||||
import { useEffect } from "react";
|
import {useCallback, useEffect, useMemo} from "react";
|
||||||
import { getLocales } from "expo-localization";
|
|
||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
import * as ScreenOrientation from "expo-screen-orientation";
|
||||||
import { storage } from "../mmkv";
|
import { storage } from "../mmkv";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import {
|
import {
|
||||||
CultureDto,
|
CultureDto,
|
||||||
|
PluginStatus,
|
||||||
SubtitlePlaybackMode,
|
SubtitlePlaybackMode,
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import {apiAtom} from "@/providers/JellyfinProvider";
|
||||||
|
import {getPluginsApi} from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import {writeErrorLog} from "@/utils/log";
|
||||||
|
|
||||||
|
const STREAMYFIN_PLUGIN_ID = "1e9e5d386e6746158719e98a5c34f004"
|
||||||
|
const STREAMYFIN_PLUGIN_SETTINGS = "STREAMYFIN_PLUGIN_SETTINGS"
|
||||||
|
|
||||||
export type DownloadQuality = "original" | "high" | "low";
|
export type DownloadQuality = "original" | "high" | "low";
|
||||||
|
|
||||||
@@ -60,6 +66,11 @@ export type DefaultLanguageOption = {
|
|||||||
label: string;
|
label: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export enum DownloadMethod {
|
||||||
|
Remux = "remux",
|
||||||
|
Optimized = "optimized"
|
||||||
|
}
|
||||||
|
|
||||||
export type Settings = {
|
export type Settings = {
|
||||||
autoRotate?: boolean;
|
autoRotate?: boolean;
|
||||||
forceLandscapeInVideoPlayer?: boolean;
|
forceLandscapeInVideoPlayer?: boolean;
|
||||||
@@ -83,7 +94,7 @@ export type Settings = {
|
|||||||
forwardSkipTime: number;
|
forwardSkipTime: number;
|
||||||
rewindSkipTime: number;
|
rewindSkipTime: number;
|
||||||
optimizedVersionsServerUrl?: string | null;
|
optimizedVersionsServerUrl?: string | null;
|
||||||
downloadMethod: "optimized" | "remux";
|
downloadMethod: DownloadMethod;
|
||||||
autoDownload: boolean;
|
autoDownload: boolean;
|
||||||
showCustomMenuLinks: boolean;
|
showCustomMenuLinks: boolean;
|
||||||
disableHapticFeedback: boolean;
|
disableHapticFeedback: boolean;
|
||||||
@@ -94,6 +105,16 @@ export type Settings = {
|
|||||||
hiddenLibraries?: string[];
|
hiddenLibraries?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface Lockable<T> {
|
||||||
|
locked: boolean;
|
||||||
|
value: T
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PluginLockableSettings = { [K in keyof Settings]: Lockable<Settings[K]> };
|
||||||
|
export type StreamyfinPluginConfig = {
|
||||||
|
settings: PluginLockableSettings
|
||||||
|
}
|
||||||
|
|
||||||
const loadSettings = (): Settings => {
|
const loadSettings = (): Settings => {
|
||||||
const defaultValues: Settings = {
|
const defaultValues: Settings = {
|
||||||
autoRotate: true,
|
autoRotate: true,
|
||||||
@@ -124,7 +145,7 @@ const loadSettings = (): Settings => {
|
|||||||
forwardSkipTime: 30,
|
forwardSkipTime: 30,
|
||||||
rewindSkipTime: 10,
|
rewindSkipTime: 10,
|
||||||
optimizedVersionsServerUrl: null,
|
optimizedVersionsServerUrl: null,
|
||||||
downloadMethod: "remux",
|
downloadMethod: DownloadMethod.Remux,
|
||||||
autoDownload: false,
|
autoDownload: false,
|
||||||
showCustomMenuLinks: false,
|
showCustomMenuLinks: false,
|
||||||
disableHapticFeedback: false,
|
disableHapticFeedback: false,
|
||||||
@@ -153,16 +174,76 @@ const saveSettings = (settings: Settings) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const settingsAtom = atom<Settings | null>(null);
|
export const settingsAtom = atom<Settings | null>(null);
|
||||||
|
export const pluginSettingsAtom = atom(storage.get<PluginLockableSettings>(STREAMYFIN_PLUGIN_SETTINGS));
|
||||||
|
|
||||||
export const useSettings = () => {
|
export const useSettings = () => {
|
||||||
const [settings, setSettings] = useAtom(settingsAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [_settings, setSettings] = useAtom(settingsAtom);
|
||||||
|
const [pluginSettings, _setPluginSettings] = useAtom(pluginSettingsAtom);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (settings === null) {
|
if (_settings === null) {
|
||||||
const loadedSettings = loadSettings();
|
const loadedSettings = loadSettings();
|
||||||
setSettings(loadedSettings);
|
setSettings(loadedSettings);
|
||||||
}
|
}
|
||||||
}, [settings, setSettings]);
|
}, [_settings, setSettings]);
|
||||||
|
|
||||||
|
const setPluginSettings = useCallback((settings: PluginLockableSettings | undefined) => {
|
||||||
|
storage.setAny(STREAMYFIN_PLUGIN_SETTINGS, settings)
|
||||||
|
_setPluginSettings(settings)
|
||||||
|
},
|
||||||
|
[_setPluginSettings]
|
||||||
|
)
|
||||||
|
|
||||||
|
const refreshStreamyfinPluginSettings = useCallback(
|
||||||
|
async () => {
|
||||||
|
if (!api)
|
||||||
|
return
|
||||||
|
|
||||||
|
const plugins = await getPluginsApi(api).getPlugins().then(({data}) => data);
|
||||||
|
|
||||||
|
if (plugins && plugins.length > 0) {
|
||||||
|
const streamyfinPlugin = plugins.find(plugin => plugin.Id === STREAMYFIN_PLUGIN_ID);
|
||||||
|
|
||||||
|
if (!streamyfinPlugin || streamyfinPlugin.Status != PluginStatus.Active) {
|
||||||
|
writeErrorLog(
|
||||||
|
"Streamyfin plugin is currently not active.\n" +
|
||||||
|
`Current status is: ${streamyfinPlugin?.Status}`
|
||||||
|
);
|
||||||
|
setPluginSettings(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = await api.getStreamyfinPluginConfig()
|
||||||
|
.then(({data}) => data.settings)
|
||||||
|
|
||||||
|
setPluginSettings(settings);
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[api]
|
||||||
|
)
|
||||||
|
|
||||||
|
// We do not want to save over users pre-existing settings in case admin ever removes/unlocks a setting.
|
||||||
|
// If admin sets locked to false but provides a value,
|
||||||
|
// use user settings first and fallback on admin setting if required.
|
||||||
|
const settings: Settings = useMemo(() => {
|
||||||
|
const overrideSettings = Object.entries(pluginSettings || {})
|
||||||
|
.reduce((acc, [key, setting]) => {
|
||||||
|
if (setting) {
|
||||||
|
const {value, locked} = setting
|
||||||
|
acc = Object.assign(acc, {
|
||||||
|
[key]: locked ? value : _settings?.[key as keyof Settings] ?? value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
}, {} as Settings)
|
||||||
|
|
||||||
|
return {
|
||||||
|
..._settings,
|
||||||
|
...overrideSettings
|
||||||
|
}
|
||||||
|
}, [_settings, setSettings, pluginSettings, _setPluginSettings, setPluginSettings])
|
||||||
|
|
||||||
const updateSettings = (update: Partial<Settings>) => {
|
const updateSettings = (update: Partial<Settings>) => {
|
||||||
if (settings) {
|
if (settings) {
|
||||||
@@ -173,5 +254,5 @@ export const useSettings = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return [settings, updateSettings] as const;
|
return [settings, updateSettings, pluginSettings, setPluginSettings, refreshStreamyfinPluginSettings] as const;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ import {
|
|||||||
* readonly Unknown: "unknown";
|
* readonly Unknown: "unknown";
|
||||||
readonly Movies: "movies";
|
readonly Movies: "movies";
|
||||||
readonly Tvshows: "tvshows";
|
readonly Tvshows: "tvshows";
|
||||||
readonly Music: "music";
|
|
||||||
readonly Musicvideos: "musicvideos";
|
|
||||||
readonly Trailers: "trailers";
|
readonly Trailers: "trailers";
|
||||||
readonly Homevideos: "homevideos";
|
readonly Homevideos: "homevideos";
|
||||||
readonly Boxsets: "boxsets";
|
readonly Boxsets: "boxsets";
|
||||||
@@ -33,8 +31,6 @@ export const colletionTypeToItemType = (
|
|||||||
return BaseItemKind.Series;
|
return BaseItemKind.Series;
|
||||||
case CollectionType.Homevideos:
|
case CollectionType.Homevideos:
|
||||||
return BaseItemKind.Video;
|
return BaseItemKind.Video;
|
||||||
case CollectionType.Musicvideos:
|
|
||||||
return BaseItemKind.MusicVideo;
|
|
||||||
case CollectionType.Books:
|
case CollectionType.Books:
|
||||||
return BaseItemKind.Book;
|
return BaseItemKind.Book;
|
||||||
case CollectionType.Playlists:
|
case CollectionType.Playlists:
|
||||||
|
|||||||
Reference in New Issue
Block a user