mirror of
https://github.com/streamyfin/streamyfin.git
synced 2025-08-20 18:37:18 +02:00
Compare commits
1 Commits
feat/atv
...
refactor/b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6752888bb0 |
26
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
26
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: '❌ bug'
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Smartphone (please complete the following information):**
|
||||
- Device: [e.g. iPhone15Pro]
|
||||
- OS: [e.g. iOS18]
|
||||
- Version [e.g. 0.3.1]
|
||||
59
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
59
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,59 +0,0 @@
|
||||
name: Bug report
|
||||
description: Create a report to help us improve
|
||||
title: "[Bug]: "
|
||||
labels:
|
||||
- ["❌ bug"]
|
||||
projects:
|
||||
- ["fredrikburmester/5"]
|
||||
assignees:
|
||||
- fredrikburmester
|
||||
|
||||
body:
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: What happened?
|
||||
description: Also tell us, what did you expect to happen?
|
||||
placeholder: A clear and concise description of what the bug is.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: repro
|
||||
attributes:
|
||||
label: Reproduction steps
|
||||
description: "How do you trigger this bug? Please walk us through it step by step."
|
||||
placeholder: |
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: device
|
||||
attributes:
|
||||
label: Which device and operating system are you using?
|
||||
description: e.g. iPhone 15, iOS 18.1.1
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: version
|
||||
attributes:
|
||||
label: Version
|
||||
description: What version of Streamyfin are you running?
|
||||
options:
|
||||
- 0.23.0
|
||||
- 0.22.0
|
||||
- 0.21.0
|
||||
- older
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: If applicable, please add screenshots to help explain your problem.
|
||||
You can drag and drop images here or paste them directly into the comment box.
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -9,7 +9,6 @@ npm-debug.*
|
||||
*.mobileprovision
|
||||
*.orig.*
|
||||
web-build/
|
||||
modules/vlc-player/android/build
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
@@ -27,12 +26,8 @@ package-lock.json
|
||||
/ios
|
||||
/android
|
||||
|
||||
modules/player/android
|
||||
|
||||
pc-api-7079014811501811218-719-3b9f15aeccf8.json
|
||||
credentials.json
|
||||
*.apk
|
||||
*.ipa
|
||||
.continuerc.json
|
||||
|
||||
.vscode/
|
||||
4
.gitmodules
vendored
4
.gitmodules
vendored
@@ -1,4 +0,0 @@
|
||||
[submodule "utils/jellyseerr"]
|
||||
path = utils/jellyseerr
|
||||
url = https://github.com/herrrta/jellyseerr
|
||||
branch = models
|
||||
3
.idea/.gitignore
generated
vendored
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>
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -8,8 +8,5 @@
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[swift]": {
|
||||
"editor.defaultFormatter": "sswg.swift-lang"
|
||||
}
|
||||
}
|
||||
|
||||
43
README.md
43
README.md
@@ -15,10 +15,10 @@ Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Exp
|
||||
|
||||
- 🚀 **Skp intro / credits support**
|
||||
- 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking.
|
||||
- 📺 **Picture in Picture** (iPhone only): Watch movies in PiP mode on your iPhone.
|
||||
- 🔊 **Background audio**: Stream music in the background, even when locking the phone.
|
||||
- 📥 **Download media** (Experimental): Save your media locally and watch it offline.
|
||||
- 📡 **Chromecast** (Experimental): Cast your media to any Chromecast-enabled device.
|
||||
- 🤖 **Jellyseerr integration**: Request media directly in the app.
|
||||
|
||||
## 🧪 Experimental Features
|
||||
|
||||
@@ -70,9 +70,11 @@ Or download the APKs [here on GitHub](https://github.com/fredrikburmester/stream
|
||||
|
||||
### Beta testing
|
||||
|
||||
To access the Streamyfin beta, you need to subscribe to the Member tier (or higher) on [Patreon](https://www.patreon.com/streamyfin). This will give you immediate access to the 🧪-public-beta channel on Discord and i'll know that you have subscribed. This is where i'll post APKs and IPAs. This won't give automatic access to the TestFlight however, so you need to send me a DM with the email you use for Apple so that i can manually add you.
|
||||
Get the latest updates by using the TestFlight version of the app.
|
||||
|
||||
**Note**: Everyone who is actively contributing to the source code of Streamyfin will have automatic access to the betas.
|
||||
<a href="https://testflight.apple.com/join/CWBaAAK2">
|
||||
<img height=75 alt="Get the beta on TestFlight" src="./assets/Get_the_beta_on_Testflight.svg"/>
|
||||
</a>
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
@@ -87,10 +89,36 @@ We welcome any help to make Streamyfin better. If you'd like to contribute, plea
|
||||
|
||||
### Development info
|
||||
|
||||
1. Use node `>20`
|
||||
2. Install dependencies `bun i && bun run submodule-reload`
|
||||
3. Make sure you have xcode and/or android studio installed.
|
||||
4. Create an expo dev build by running `npx expo run:ios` or `npx expo run:android`. This will open a simulator on you computer and run the app.
|
||||
1. Use node `20`
|
||||
2. Install dependencies `bun i`
|
||||
3. Create an expo dev build by running `npx expo run:ios` or `npx expo run:android`.
|
||||
|
||||
## Extended chromecast controls
|
||||
|
||||
Add this to AppDelegate.mm:
|
||||
|
||||
```
|
||||
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
|
||||
{
|
||||
// @generated begin react-native-google-cast-didFinishLaunchingWithOptions - expo prebuild (DO NOT MODIFY) sync-8901be60b982d2ae9c658b1e8c50634d61bb5091
|
||||
#if __has_include(<GoogleCast/GoogleCast.h>)
|
||||
...
|
||||
|
||||
[GCKCastContext sharedInstance].useDefaultExpandedMediaControls = true;`
|
||||
#endif
|
||||
```
|
||||
|
||||
Add this to Info.plist:
|
||||
|
||||
```
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_googlecast._tcp</string>
|
||||
<string>_CC1AD845._googlecast._tcp</string>
|
||||
</array>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>${PRODUCT_NAME} uses the local network to discover Cast-enabled devices on your WiFi network.</string>
|
||||
```
|
||||
|
||||
## 📄 License
|
||||
|
||||
@@ -125,7 +153,6 @@ I'd like to thank the following people and projects for their contributions to S
|
||||
|
||||
- [Reiverr](https://github.com/aleksilassila/reiverr) for great help with understanding the Jellyfin API.
|
||||
- [Jellyfin TS SDK](https://github.com/jellyfin/jellyfin-sdk-typescript) for the TypeScript SDK.
|
||||
- [Jellyseerr](https://github.com/Fallenbagel/jellyseerr) for enabling API integration with their project.
|
||||
- The Jellyfin devs for always being helpful in the Discord.
|
||||
|
||||
## Star History
|
||||
|
||||
64
app.json
64
app.json
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "Streamyfin",
|
||||
"slug": "streamyfin",
|
||||
"version": "0.23.0",
|
||||
"version": "0.18.0",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "streamyfin",
|
||||
@@ -23,10 +23,7 @@
|
||||
"NSLocalNetworkUsageDescription": "The app needs access to your local network to connect to your Jellyfin server.",
|
||||
"NSAppTransportSecurity": {
|
||||
"NSAllowsArbitraryLoads": true
|
||||
},
|
||||
"UISupportsTrueScreenSizeOnMac": true,
|
||||
"UIFileSharingEnabled": true,
|
||||
"LSSupportsOpeningDocumentsInPlace": true
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"usesNonExemptEncryption": false
|
||||
@@ -36,43 +33,80 @@
|
||||
},
|
||||
"android": {
|
||||
"jsEngine": "hermes",
|
||||
"versionCode": 49,
|
||||
"versionCode": 46,
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/adaptive_icon.png"
|
||||
},
|
||||
"package": "com.fredrikburmester.streamyfin",
|
||||
"permissions": []
|
||||
"permissions": [
|
||||
"android.permission.FOREGROUND_SERVICE",
|
||||
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"
|
||||
]
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
"expo-font",
|
||||
"@config-plugins/ffmpeg-kit-react-native",
|
||||
[
|
||||
"react-native-google-cast",
|
||||
{
|
||||
"useDefaultExpandedMediaControls": true
|
||||
}
|
||||
],
|
||||
[
|
||||
"react-native-video",
|
||||
{
|
||||
"enableNotificationControls": true,
|
||||
"enableBackgroundAudio": true
|
||||
"enableBackgroundAudio": true,
|
||||
"androidExtensions": {
|
||||
"useExoplayerRtsp": false,
|
||||
"useExoplayerSmoothStreaming": false,
|
||||
"useExoplayerHls": true,
|
||||
"useExoplayerDash": false
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
"./plugins/withAndroidMainActivityAttributes",
|
||||
{
|
||||
"com.reactnative.googlecast.RNGCExpandedControllerActivity": true
|
||||
}
|
||||
],
|
||||
["./plugins/withExpandedController.js"],
|
||||
[
|
||||
"expo-build-properties",
|
||||
{
|
||||
"ios": {
|
||||
"deploymentTarget": "15.6",
|
||||
"useFrameworks": "static"
|
||||
"deploymentTarget": "15.6"
|
||||
},
|
||||
"android": {
|
||||
"android": {
|
||||
"compileSdkVersion": 34,
|
||||
"targetSdkVersion": 34,
|
||||
"buildToolsVersion": "34.0.0"
|
||||
},
|
||||
"minSdkVersion": 24,
|
||||
"usesCleartextTraffic": true,
|
||||
"packagingOptions": {
|
||||
"jniLibs": {
|
||||
"useLegacyPackaging": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-screen-orientation",
|
||||
{
|
||||
"initialOrientation": "DEFAULT"
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-sensors",
|
||||
{
|
||||
"motionPermission": "Allow Streamyfin to access your device motion for landscape video watching."
|
||||
}
|
||||
],
|
||||
"expo-asset",
|
||||
["react-native-edge-to-edge"],
|
||||
["react-native-bottom-tabs"],
|
||||
["@react-native-tvos/config-tv"]
|
||||
]
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { Chromecast } from "@/components/Chromecast";
|
||||
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
|
||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||
import { Stack, useRouter } from "expo-router";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
|
||||
export default function IndexLayout() {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
@@ -18,10 +22,12 @@ export default function IndexLayout() {
|
||||
headerShadowVisible: false,
|
||||
headerRight: () => (
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
<Chromecast />
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push("/(auth)/settings");
|
||||
}}
|
||||
className="p-2 "
|
||||
>
|
||||
<Feather name="settings" color={"white"} size={22} />
|
||||
</TouchableOpacity>
|
||||
@@ -29,10 +35,28 @@ export default function IndexLayout() {
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="downloads"
|
||||
options={{
|
||||
title: "Downloads",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="settings"
|
||||
options={{
|
||||
title: "Settings",
|
||||
headerRight: () => (
|
||||
<View className="">
|
||||
<Ionicons
|
||||
name="file-tray-full-outline"
|
||||
size={22}
|
||||
color="white"
|
||||
onPress={() => {
|
||||
router.push("/logs");
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||
|
||||
123
app/(auth)/(tabs)/(home)/downloads.tsx
Normal file
123
app/(auth)/(tabs)/(home)/downloads.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ActiveDownloads } from "@/components/downloads/ActiveDownloads";
|
||||
import { MovieCard } from "@/components/downloads/MovieCard";
|
||||
import { SeriesCard } from "@/components/downloads/SeriesCard";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { queueAtom } from "@/utils/atoms/queue";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { router } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
const downloads: React.FC = () => {
|
||||
const [queue, setQueue] = useAtom(queueAtom);
|
||||
const { removeProcess, downloadedFiles } = useDownload();
|
||||
|
||||
const [settings] = useSettings();
|
||||
|
||||
const movies = useMemo(
|
||||
() => downloadedFiles?.filter((f) => f.Type === "Movie") || [],
|
||||
[downloadedFiles]
|
||||
);
|
||||
|
||||
const groupedBySeries = useMemo(() => {
|
||||
const episodes = downloadedFiles?.filter((f) => f.Type === "Episode");
|
||||
const series: { [key: string]: BaseItemDto[] } = {};
|
||||
episodes?.forEach((e) => {
|
||||
if (!series[e.SeriesName!]) series[e.SeriesName!] = [];
|
||||
series[e.SeriesName!].push(e);
|
||||
});
|
||||
return Object.values(series);
|
||||
}, [downloadedFiles]);
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: 100,
|
||||
}}
|
||||
>
|
||||
<View className="py-4">
|
||||
<View className="mb-4 flex flex-col space-y-4 px-4">
|
||||
{settings?.downloadMethod === "remux" && (
|
||||
<View className="bg-neutral-900 p-4 rounded-2xl">
|
||||
<Text className="text-lg font-bold">Queue</Text>
|
||||
<Text className="text-xs opacity-70 text-red-600">
|
||||
Queue and downloads will be lost on app restart
|
||||
</Text>
|
||||
<View className="flex flex-col space-y-2 mt-2">
|
||||
{queue.map((q) => (
|
||||
<TouchableOpacity
|
||||
onPress={() =>
|
||||
router.push(`/(auth)/items/page?id=${q.item.Id}`)
|
||||
}
|
||||
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
|
||||
>
|
||||
<View>
|
||||
<Text className="font-semibold">{q.item.Name}</Text>
|
||||
<Text className="text-xs opacity-50">{q.item.Type}</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
removeProcess(q.id);
|
||||
setQueue((prev) => {
|
||||
if (!prev) return [];
|
||||
return [...prev.filter((i) => i.id !== q.id)];
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Ionicons name="close" size={24} color="red" />
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{queue.length === 0 && (
|
||||
<Text className="opacity-50">No items in queue</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<ActiveDownloads />
|
||||
</View>
|
||||
|
||||
{movies.length > 0 && (
|
||||
<View className="mb-4">
|
||||
<View className="flex flex-row items-center justify-between mb-2 px-4">
|
||||
<Text className="text-lg font-bold">Movies</Text>
|
||||
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
|
||||
<Text className="text-xs font-bold">{movies?.length}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View className="px-4 flex flex-row">
|
||||
{movies?.map((item: BaseItemDto) => (
|
||||
<View className="mb-2 last:mb-0" key={item.Id}>
|
||||
<MovieCard item={item} />
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
{groupedBySeries?.map((items: BaseItemDto[], index: number) => (
|
||||
<SeriesCard items={items} key={items[0].SeriesId} />
|
||||
))}
|
||||
{downloadedFiles?.length === 0 && (
|
||||
<View className="flex px-4">
|
||||
<Text className="opacity-50">No downloaded items</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
export default downloads;
|
||||
@@ -5,7 +5,8 @@ import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionLi
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||
import { TAB_HEIGHT } from "@/constants/Values";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||
@@ -24,10 +25,11 @@ import {
|
||||
import NetInfo from "@react-native-community/netinfo";
|
||||
import { QueryFunction, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigation, useRouter } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Platform,
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
@@ -52,6 +54,7 @@ type MediaListSection = {
|
||||
type Section = ScrollingCollectionListSection | MediaListSection;
|
||||
|
||||
export default function index() {
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
|
||||
const api = useAtomValue(apiAtom);
|
||||
@@ -63,9 +66,28 @@ export default function index() {
|
||||
const [isConnected, setIsConnected] = useState<boolean | null>(null);
|
||||
const [loadingRetry, setLoadingRetry] = useState(false);
|
||||
|
||||
const { downloadedFiles } = useDownload();
|
||||
const navigation = useNavigation();
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
useEffect(() => {
|
||||
const hasDownloads = downloadedFiles && downloadedFiles.length > 0;
|
||||
navigation.setOptions({
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push("/(auth)/downloads");
|
||||
}}
|
||||
className="p-2"
|
||||
>
|
||||
<Feather
|
||||
name="download"
|
||||
color={hasDownloads ? Colors.primary : "white"}
|
||||
size={22}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
),
|
||||
});
|
||||
}, [downloadedFiles, navigation, router]);
|
||||
|
||||
const checkConnection = useCallback(async () => {
|
||||
setLoadingRetry(true);
|
||||
@@ -143,13 +165,28 @@ export default function index() {
|
||||
);
|
||||
}, [userViews]);
|
||||
|
||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||
|
||||
const refetch = useCallback(async () => {
|
||||
setLoading(true);
|
||||
await invalidateCache();
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["home"],
|
||||
refetchType: "all",
|
||||
type: "all",
|
||||
exact: false,
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["home"],
|
||||
refetchType: "all",
|
||||
type: "all",
|
||||
exact: false,
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["item"],
|
||||
refetchType: "all",
|
||||
type: "all",
|
||||
exact: false,
|
||||
});
|
||||
setLoading(false);
|
||||
}, []);
|
||||
}, [queryClient]);
|
||||
|
||||
const createCollectionConfig = useCallback(
|
||||
(
|
||||
@@ -166,7 +203,7 @@ export default function index() {
|
||||
(
|
||||
await getUserLibraryApi(api).getLatestMedia({
|
||||
userId: user?.Id,
|
||||
limit: 20,
|
||||
limit: 50,
|
||||
fields: ["PrimaryImageAspectRatio", "Path"],
|
||||
imageTypeLimit: 1,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
@@ -205,7 +242,7 @@ export default function index() {
|
||||
const ss: Section[] = [
|
||||
{
|
||||
title: "Continue Watching",
|
||||
queryKey: ["home", "resumeItems"],
|
||||
queryKey: ["home", "resumeItems", user.Id],
|
||||
queryFn: async () =>
|
||||
(
|
||||
await getItemsApi(api).getResumeItems({
|
||||
@@ -219,7 +256,7 @@ export default function index() {
|
||||
},
|
||||
{
|
||||
title: "Next Up",
|
||||
queryKey: ["home", "nextUp-all"],
|
||||
queryKey: ["home", "nextUp-all", user?.Id],
|
||||
queryFn: async () =>
|
||||
(
|
||||
await getTvShowsApi(api).getNextUp({
|
||||
@@ -283,6 +320,50 @@ export default function index() {
|
||||
return ss;
|
||||
}, [api, user?.Id, collections, mediaListCollections]);
|
||||
|
||||
if (isConnected === false) {
|
||||
return (
|
||||
<View className="flex flex-col items-center justify-center h-full -mt-6 px-8">
|
||||
<Text className="text-3xl font-bold mb-2">No Internet</Text>
|
||||
<Text className="text-center opacity-70">
|
||||
No worries, you can still watch{"\n"}downloaded content.
|
||||
</Text>
|
||||
<View className="mt-4">
|
||||
<Button
|
||||
color="purple"
|
||||
onPress={() => router.push("/(auth)/downloads")}
|
||||
justify="center"
|
||||
iconRight={
|
||||
<Ionicons name="arrow-forward" size={20} color="white" />
|
||||
}
|
||||
>
|
||||
Go to downloads
|
||||
</Button>
|
||||
<Button
|
||||
color="black"
|
||||
onPress={() => {
|
||||
checkConnection();
|
||||
}}
|
||||
justify="center"
|
||||
className="mt-2"
|
||||
iconRight={
|
||||
loadingRetry ? null : (
|
||||
<Ionicons name="refresh" size={20} color="white" />
|
||||
)
|
||||
}
|
||||
>
|
||||
{loadingRetry ? (
|
||||
<ActivityIndicator size={"small"} color={"white"} />
|
||||
) : (
|
||||
"Retry"
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
if (e1 || e2)
|
||||
return (
|
||||
<View className="flex flex-col items-center justify-center h-full -mt-6">
|
||||
@@ -307,11 +388,15 @@ export default function index() {
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={loading} onRefresh={refetch} />
|
||||
}
|
||||
key={"home"}
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: 16,
|
||||
}}
|
||||
style={{
|
||||
marginBottom: TAB_HEIGHT,
|
||||
}}
|
||||
>
|
||||
<View className="flex flex-col space-y-4">
|
||||
<LargeMovieCarousel />
|
||||
|
||||
@@ -2,20 +2,20 @@ import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ListItem } from "@/components/ListItem";
|
||||
import { SettingToggles } from "@/components/settings/SettingToggles";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { clearLogs, useLog } from "@/utils/log";
|
||||
import { clearLogs, readFromLog } from "@/utils/log";
|
||||
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useAtom } from "jotai";
|
||||
import { Alert, ScrollView, View } from "react-native";
|
||||
import * as Progress from "react-native-progress";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { toast } from "sonner-native";
|
||||
|
||||
export default function settings() {
|
||||
const { logout } = useJellyfin();
|
||||
const { logs } = useLog();
|
||||
const { deleteAllFiles } = useDownload();
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
@@ -34,11 +34,16 @@ export default function settings() {
|
||||
userId: user?.Id,
|
||||
});
|
||||
if (res.status === 200) {
|
||||
Haptics.notificationAsync(
|
||||
Haptics.NotificationFeedbackType.Success
|
||||
);
|
||||
Alert.alert("Success", "Quick connect authorized");
|
||||
} else {
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
||||
Alert.alert("Error", "Invalid code");
|
||||
}
|
||||
} catch (e) {
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
||||
Alert.alert("Error", "Invalid code");
|
||||
}
|
||||
}
|
||||
@@ -55,6 +60,13 @@ export default function settings() {
|
||||
}}
|
||||
>
|
||||
<View className="p-4 flex flex-col gap-y-4">
|
||||
{/* <Button
|
||||
onPress={() => {
|
||||
registerBackgroundFetchAsync();
|
||||
}}
|
||||
>
|
||||
registerBackgroundFetchAsync
|
||||
</Button> */}
|
||||
<View>
|
||||
<Text className="font-bold text-lg mb-2">User Info</Text>
|
||||
|
||||
@@ -63,9 +75,6 @@ export default function settings() {
|
||||
<ListItem title="Server" subTitle={api?.basePath} />
|
||||
<ListItem title="Token" subTitle={api?.accessToken} />
|
||||
</View>
|
||||
<Button className="my-2.5" color="black" onPress={logout}>
|
||||
Log out
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
@@ -78,27 +87,40 @@ export default function settings() {
|
||||
<SettingToggles />
|
||||
|
||||
<View>
|
||||
<Text className="font-bold text-lg mb-2">Logs</Text>
|
||||
<Text className="font-bold text-lg mb-2">Account and storage</Text>
|
||||
<View className="flex flex-col space-y-2">
|
||||
{logs?.map((log, index) => (
|
||||
<View key={index} className="bg-neutral-900 rounded-xl p-3">
|
||||
<Text
|
||||
className={`
|
||||
mb-1
|
||||
${log.level === "INFO" && "text-blue-500"}
|
||||
${log.level === "ERROR" && "text-red-500"}
|
||||
`}
|
||||
>
|
||||
{log.level}
|
||||
</Text>
|
||||
<Text uiTextView selectable className="text-xs">
|
||||
{log.message}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
{logs?.length === 0 && (
|
||||
<Text className="opacity-50">No logs available</Text>
|
||||
)}
|
||||
<Button color="black" onPress={logout}>
|
||||
Log out
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
onPress={async () => {
|
||||
try {
|
||||
await deleteAllFiles();
|
||||
Haptics.notificationAsync(
|
||||
Haptics.NotificationFeedbackType.Success
|
||||
);
|
||||
} catch (e) {
|
||||
Haptics.notificationAsync(
|
||||
Haptics.NotificationFeedbackType.Error
|
||||
);
|
||||
toast.error("Error deleting files");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete all downloaded files
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
onPress={async () => {
|
||||
await clearLogs();
|
||||
Haptics.notificationAsync(
|
||||
Haptics.NotificationFeedbackType.Success
|
||||
);
|
||||
}}
|
||||
>
|
||||
Delete all logs
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Chromecast } from "@/components/Chromecast";
|
||||
import { ItemImage } from "@/components/common/ItemImage";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
@@ -27,6 +28,16 @@ export default function page() {
|
||||
|
||||
const navigation = useNavigation();
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerRight: () => (
|
||||
<View className="">
|
||||
<Chromecast />
|
||||
</View>
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
const { data: album } = useQuery({
|
||||
queryKey: ["album", albumId, artistId],
|
||||
queryFn: async () => {
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import * as ScreenOrientation from "expo-screen-orientation";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { FlatList, View } from "react-native";
|
||||
@@ -40,6 +41,10 @@ const page: React.FC = () => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const navigation = useNavigation();
|
||||
const [orientation, setOrientation] = useState(
|
||||
ScreenOrientation.Orientation.PORTRAIT_UP
|
||||
);
|
||||
|
||||
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
|
||||
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
||||
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
|
||||
@@ -99,12 +104,9 @@ const page: React.FC = () => {
|
||||
"CanDelete",
|
||||
"MediaSourceCount",
|
||||
],
|
||||
// true is needed for merged versions
|
||||
recursive: true,
|
||||
genres: selectedGenres,
|
||||
tags: selectedTags,
|
||||
years: selectedYears.map((year) => parseInt(year)),
|
||||
includeItemTypes: ["Movie", "Series", "MusicAlbum"],
|
||||
});
|
||||
|
||||
return response.data || null;
|
||||
@@ -169,7 +171,8 @@ const page: React.FC = () => {
|
||||
key={item.Id}
|
||||
style={{
|
||||
width: "100%",
|
||||
marginBottom: 16,
|
||||
marginBottom:
|
||||
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 4 : 16,
|
||||
}}
|
||||
item={item}
|
||||
>
|
||||
@@ -383,7 +386,9 @@ const page: React.FC = () => {
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemSize={255}
|
||||
numColumns={5}
|
||||
numColumns={
|
||||
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5
|
||||
}
|
||||
onEndReached={() => {
|
||||
if (hasNextPage) {
|
||||
fetchNextPage();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ItemContent } from "@/components/ItemContent";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
@@ -22,18 +22,16 @@ const Page: React.FC = () => {
|
||||
const { data: item, isError } = useQuery({
|
||||
queryKey: ["item", id],
|
||||
queryFn: async () => {
|
||||
if (!api || !user || !id) return;
|
||||
const res = await getUserLibraryApi(api).getItem({
|
||||
itemId: id,
|
||||
const res = await getUserItemData({
|
||||
api,
|
||||
userId: user?.Id,
|
||||
itemId: id,
|
||||
});
|
||||
|
||||
return res.data;
|
||||
return res;
|
||||
},
|
||||
staleTime: 0,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
enabled: !!id && !!api,
|
||||
staleTime: 60 * 1000 * 5, // 5 minutes
|
||||
});
|
||||
|
||||
const opacity = useSharedValue(1);
|
||||
@@ -44,25 +42,20 @@ const Page: React.FC = () => {
|
||||
});
|
||||
|
||||
const fadeOut = (callback: any) => {
|
||||
setTimeout(() => {
|
||||
opacity.value = withTiming(0, { duration: 500 }, (finished) => {
|
||||
if (finished) {
|
||||
runOnJS(callback)();
|
||||
}
|
||||
});
|
||||
}, 100);
|
||||
opacity.value = withTiming(0, { duration: 300 }, (finished) => {
|
||||
if (finished) {
|
||||
runOnJS(callback)();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const fadeIn = (callback: any) => {
|
||||
setTimeout(() => {
|
||||
opacity.value = withTiming(1, { duration: 500 }, (finished) => {
|
||||
if (finished) {
|
||||
runOnJS(callback)();
|
||||
}
|
||||
});
|
||||
}, 100);
|
||||
opacity.value = withTiming(1, { duration: 300 }, (finished) => {
|
||||
if (finished) {
|
||||
runOnJS(callback)();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (item) {
|
||||
fadeOut(() => {});
|
||||
@@ -85,24 +78,14 @@ const Page: React.FC = () => {
|
||||
style={[animatedStyle]}
|
||||
className="absolute top-0 left-0 flex flex-col items-start h-screen w-screen px-4 z-50 bg-black"
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
height: item?.Type === "Episode" ? 300 : 450,
|
||||
}}
|
||||
className="bg-transparent rounded-lg mb-4 w-full"
|
||||
></View>
|
||||
<View className="h-6 bg-neutral-900 rounded mb-4 w-14"></View>
|
||||
<View className="h-10 bg-neutral-900 rounded-lg mb-2 w-1/2"></View>
|
||||
<View className="h-3 bg-neutral-900 rounded mb-3 w-8"></View>
|
||||
<View className="flex flex-row space-x-1 mb-8">
|
||||
<View className="h-6 bg-neutral-900 rounded mb-3 w-14"></View>
|
||||
<View className="h-6 bg-neutral-900 rounded mb-3 w-14"></View>
|
||||
<View className="h-6 bg-neutral-900 rounded mb-3 w-14"></View>
|
||||
</View>
|
||||
<View className="h-3 bg-neutral-900 rounded w-2/3 mb-1"></View>
|
||||
<View className="h-10 bg-neutral-900 rounded-lg w-full mb-2"></View>
|
||||
<View className="h-12 bg-neutral-900 rounded-lg w-full mb-2"></View>
|
||||
<View className="h-24 bg-neutral-900 rounded-lg mb-1 w-full"></View>
|
||||
<View className="h-[350px] bg-transparent rounded-lg mb-4 w-full"></View>
|
||||
<View className="h-6 bg-neutral-900 rounded mb-1 w-12"></View>
|
||||
<View className="h-12 bg-neutral-900 rounded-lg mb-1 w-1/2"></View>
|
||||
<View className="h-12 bg-neutral-900 rounded-lg w-2/3 mb-10"></View>
|
||||
<View className="h-4 bg-neutral-900 rounded-lg mb-1 w-full"></View>
|
||||
<View className="h-12 bg-neutral-900 rounded-lg mb-1 w-full"></View>
|
||||
<View className="h-12 bg-neutral-900 rounded-lg mb-1 w-full"></View>
|
||||
<View className="h-4 bg-neutral-900 rounded-lg mb-1 w-1/4"></View>
|
||||
</Animated.View>
|
||||
{item && <ItemContent item={item} />}
|
||||
</View>
|
||||
|
||||
@@ -1,320 +0,0 @@
|
||||
import { Button } from "@/components/Button";
|
||||
import { Input } from "@/components/common/Input";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { GenreTags } from "@/components/GenreTags";
|
||||
import { OverviewText } from "@/components/OverviewText";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
import { JellyserrRatings } from "@/components/Ratings";
|
||||
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import {
|
||||
IssueType,
|
||||
IssueTypeName,
|
||||
} from "@/utils/jellyseerr/server/constants/issue";
|
||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
|
||||
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetBackdropProps,
|
||||
BottomSheetModal,
|
||||
BottomSheetView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import React, { useCallback, useRef, useState } from "react";
|
||||
import { Modal, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
const Page: React.FC = () => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const params = useLocalSearchParams();
|
||||
const {
|
||||
mediaTitle,
|
||||
releaseYear,
|
||||
canRequest: canRequestString,
|
||||
posterSrc,
|
||||
...result
|
||||
} = params as unknown as {
|
||||
mediaTitle: string;
|
||||
releaseYear: number;
|
||||
canRequest: string;
|
||||
posterSrc: string;
|
||||
} & Partial<MovieResult | TvResult>;
|
||||
|
||||
const canRequest = canRequestString === "true";
|
||||
const { jellyseerrApi, requestMedia } = useJellyseerr();
|
||||
|
||||
const [issueType, setIssueType] = useState<IssueType>();
|
||||
const [issueMessage, setIssueMessage] = useState<string>();
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
const [isIssueTypeModalVisible, setIsIssueTypeModalVisible] = useState(false);
|
||||
|
||||
const {
|
||||
data: details,
|
||||
isFetching,
|
||||
isLoading,
|
||||
} = useQuery({
|
||||
enabled: !!jellyseerrApi && !!result && !!result.id,
|
||||
queryKey: ["jellyseerr", "detail", result.mediaType, result.id],
|
||||
staleTime: 0,
|
||||
refetchOnMount: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchOnWindowFocus: true,
|
||||
retryOnMount: true,
|
||||
queryFn: async () => {
|
||||
return result.mediaType === MediaType.MOVIE
|
||||
? jellyseerrApi?.movieDetails(result.id!!)
|
||||
: jellyseerrApi?.tvDetails(result.id!!);
|
||||
},
|
||||
});
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: BottomSheetBackdropProps) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
/>
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
const submitIssue = useCallback(() => {
|
||||
if (result.id && issueType && issueMessage && details) {
|
||||
jellyseerrApi
|
||||
?.submitIssue(details.mediaInfo.id, Number(issueType), issueMessage)
|
||||
.then(() => {
|
||||
setIssueType(undefined);
|
||||
setIssueMessage(undefined);
|
||||
bottomSheetModalRef?.current?.close();
|
||||
});
|
||||
}
|
||||
}, [jellyseerrApi, details, result, issueType, issueMessage]);
|
||||
|
||||
const request = useCallback(
|
||||
() =>
|
||||
requestMedia(mediaTitle, {
|
||||
mediaId: Number(result.id!!),
|
||||
mediaType: result.mediaType!!,
|
||||
tvdbId: details?.externalIds?.tvdbId,
|
||||
seasons: (details as TvDetails)?.seasons
|
||||
?.filter?.((s) => s.seasonNumber !== 0)
|
||||
?.map?.((s) => s.seasonNumber),
|
||||
}),
|
||||
[details, result, requestMedia]
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
className="flex-1 relative"
|
||||
style={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<ParallaxScrollView
|
||||
className="flex-1 opacity-100"
|
||||
headerHeight={300}
|
||||
headerImage={
|
||||
<View>
|
||||
{result.backdropPath ? (
|
||||
<Image
|
||||
cachePolicy={"memory-disk"}
|
||||
transition={300}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
source={{
|
||||
uri: `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${result.backdropPath}`,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
className="flex flex-col items-center justify-center border border-neutral-800 bg-neutral-900"
|
||||
>
|
||||
<Ionicons
|
||||
name="image-outline"
|
||||
size={24}
|
||||
color="white"
|
||||
style={{ opacity: 0.4 }}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
}
|
||||
>
|
||||
<View className="flex flex-col">
|
||||
<View className="space-y-4">
|
||||
<View className="px-4">
|
||||
<View className="flex flex-row justify-between w-full">
|
||||
<View className="flex flex-col w-56">
|
||||
<JellyserrRatings result={result as MovieResult | TvResult} />
|
||||
<Text
|
||||
uiTextView
|
||||
selectable
|
||||
className="font-bold text-2xl mb-1"
|
||||
>
|
||||
{mediaTitle}
|
||||
</Text>
|
||||
<Text className="opacity-50">{releaseYear}</Text>
|
||||
</View>
|
||||
<Image
|
||||
className="absolute bottom-1 right-1 rounded-lg w-28 aspect-[10/15] border-2 border-neutral-800/50 drop-shadow-2xl"
|
||||
cachePolicy={"memory-disk"}
|
||||
transition={300}
|
||||
source={{
|
||||
uri: posterSrc,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
<View className="mb-4">
|
||||
<GenreTags genres={details?.genres?.map((g) => g.name) || []} />
|
||||
</View>
|
||||
{canRequest ? (
|
||||
<Button color="purple" onPress={request}>
|
||||
Request
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100"
|
||||
color="transparent"
|
||||
onPress={() => bottomSheetModalRef?.current?.present()}
|
||||
iconLeft={
|
||||
<Ionicons name="warning-outline" size={24} color="white" />
|
||||
}
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
}}
|
||||
>
|
||||
Report issue
|
||||
</Button>
|
||||
)}
|
||||
<OverviewText text={result.overview} className="mt-4" />
|
||||
</View>
|
||||
|
||||
{result.mediaType === MediaType.TV && (
|
||||
<JellyseerrSeasons
|
||||
isLoading={isLoading || isFetching}
|
||||
result={result as TvResult}
|
||||
details={details as TvDetails}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</ParallaxScrollView>
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetModalRef}
|
||||
enableDynamicSizing
|
||||
handleIndicatorStyle={{
|
||||
backgroundColor: "white",
|
||||
}}
|
||||
backgroundStyle={{
|
||||
backgroundColor: "#171717",
|
||||
}}
|
||||
backdropComponent={renderBackdrop}
|
||||
>
|
||||
<BottomSheetView>
|
||||
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
|
||||
<View>
|
||||
<Text className="font-bold text-2xl text-neutral-100">
|
||||
Whats wrong?
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex flex-col space-y-2 items-start">
|
||||
<View className="flex flex-col">
|
||||
<View className="flex flex-col">
|
||||
<Text className="opacity-50 mb-1 text-xs">Issue Type</Text>
|
||||
<TouchableOpacity
|
||||
className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between"
|
||||
onPress={() => setIsIssueTypeModalVisible(true)}
|
||||
>
|
||||
<Text className="" numberOfLines={1}>
|
||||
{issueType ? IssueTypeName[issueType] : "Select an issue"}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name="chevron-down"
|
||||
size={16}
|
||||
color="white"
|
||||
style={{ opacity: 0.5 }}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Modal
|
||||
visible={isIssueTypeModalVisible}
|
||||
transparent
|
||||
animationType="slide"
|
||||
onRequestClose={() => setIsIssueTypeModalVisible(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
className="flex-1 bg-black/50"
|
||||
activeOpacity={1}
|
||||
onPress={() => setIsIssueTypeModalVisible(false)}
|
||||
>
|
||||
<View className="mt-auto bg-neutral-900 rounded-t-xl">
|
||||
<View className="p-4 border-b border-neutral-800">
|
||||
<Text className="text-lg font-bold text-center">
|
||||
Select Issue Type
|
||||
</Text>
|
||||
</View>
|
||||
<View className="max-h-[50%]">
|
||||
{Object.entries(IssueTypeName)
|
||||
.reverse()
|
||||
.map(([key, value]) => (
|
||||
<TouchableOpacity
|
||||
key={key}
|
||||
className="p-4 border-b border-neutral-800"
|
||||
onPress={() => {
|
||||
setIssueType(key as unknown as IssueType);
|
||||
setIsIssueTypeModalVisible(false);
|
||||
}}
|
||||
>
|
||||
<Text className="text-center">{value}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
className="p-4 border-t border-neutral-800"
|
||||
onPress={() => setIsIssueTypeModalVisible(false)}
|
||||
>
|
||||
<Text className="text-center text-purple-400">
|
||||
Cancel
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Input
|
||||
className="w-full"
|
||||
placeholder="(optional) Describe the issue..."
|
||||
value={issueMessage}
|
||||
keyboardType="default"
|
||||
returnKeyType="done"
|
||||
autoCapitalize="none"
|
||||
textContentType="none"
|
||||
maxLength={254}
|
||||
onChangeText={setIssueMessage}
|
||||
/>
|
||||
</View>
|
||||
<Button className="mt-auto" onPress={submitIssue} color="purple">
|
||||
Submit
|
||||
</Button>
|
||||
</View>
|
||||
</BottomSheetView>
|
||||
</BottomSheetModal>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -1,21 +1,13 @@
|
||||
import { ItemImage } from "@/components/common/ItemImage";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { HourHeader } from "@/components/livetv/HourHeader";
|
||||
import { LiveTVGuideRow } from "@/components/livetv/LiveTVGuideRow";
|
||||
import { TAB_HEIGHT } from "@/constants/Values";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Dimensions,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Button, Dimensions, ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
const HOUR_HEIGHT = 30;
|
||||
@@ -86,6 +78,8 @@ export default function page() {
|
||||
|
||||
const screenWidth = Dimensions.get("window").width;
|
||||
|
||||
const memoizedChannels = useMemo(() => channels?.Items || [], [channels]);
|
||||
|
||||
const [scrollX, setScrollX] = useState(0);
|
||||
|
||||
const handleNextPage = useCallback(() => {
|
||||
@@ -106,15 +100,24 @@ export default function page() {
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: 16,
|
||||
}}
|
||||
style={{
|
||||
marginBottom: TAB_HEIGHT,
|
||||
}}
|
||||
>
|
||||
<PageButtons
|
||||
currentPage={currentPage}
|
||||
onPrevPage={handlePrevPage}
|
||||
onNextPage={handleNextPage}
|
||||
isNextDisabled={
|
||||
!channels || (channels?.Items?.length || 0) < ITEMS_PER_PAGE
|
||||
}
|
||||
/>
|
||||
<View className="flex flex-row bg-neutral-800 w-full items-end">
|
||||
<Button
|
||||
title="Previous"
|
||||
onPress={handlePrevPage}
|
||||
disabled={currentPage === 1}
|
||||
/>
|
||||
<Button
|
||||
title="Next"
|
||||
onPress={handleNextPage}
|
||||
disabled={
|
||||
!channels || (channels?.Items?.length || 0) < ITEMS_PER_PAGE
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="flex flex-row">
|
||||
<View className="flex flex-col w-[64px]">
|
||||
@@ -163,57 +166,3 @@ export default function page() {
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
interface PageButtonsProps {
|
||||
currentPage: number;
|
||||
onPrevPage: () => void;
|
||||
onNextPage: () => void;
|
||||
isNextDisabled: boolean;
|
||||
}
|
||||
|
||||
const PageButtons: React.FC<PageButtonsProps> = ({
|
||||
currentPage,
|
||||
onPrevPage,
|
||||
onNextPage,
|
||||
isNextDisabled,
|
||||
}) => {
|
||||
return (
|
||||
<View className="flex flex-row justify-between items-center bg-neutral-800 w-full px-4 py-2">
|
||||
<TouchableOpacity
|
||||
onPress={onPrevPage}
|
||||
disabled={currentPage === 1}
|
||||
className="flex flex-row items-center"
|
||||
>
|
||||
<Ionicons
|
||||
name="chevron-back"
|
||||
size={24}
|
||||
color={currentPage === 1 ? "gray" : "white"}
|
||||
/>
|
||||
<Text
|
||||
className={`ml-1 ${
|
||||
currentPage === 1 ? "text-gray-500" : "text-white"
|
||||
}`}
|
||||
>
|
||||
Previous
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<Text className="text-white">Page {currentPage}</Text>
|
||||
<TouchableOpacity
|
||||
onPress={onNextPage}
|
||||
disabled={isNextDisabled}
|
||||
className="flex flex-row items-center"
|
||||
>
|
||||
<Text
|
||||
className={`mr-1 ${isNextDisabled ? "text-gray-500" : "text-white"}`}
|
||||
>
|
||||
Next
|
||||
</Text>
|
||||
<Ionicons
|
||||
name="chevron-forward"
|
||||
size={24}
|
||||
color={isNextDisabled ? "gray" : "white"}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,7 +5,10 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useAtom } from "jotai";
|
||||
import React from "react";
|
||||
import { ScrollView, View } from "react-native";
|
||||
import {
|
||||
ScrollView,
|
||||
View
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
export default function page() {
|
||||
@@ -24,6 +27,9 @@ export default function page() {
|
||||
paddingBottom: 16,
|
||||
paddingTop: 8,
|
||||
}}
|
||||
style={{
|
||||
marginBottom: TAB_HEIGHT,
|
||||
}}
|
||||
>
|
||||
<View className="flex flex-col space-y-2">
|
||||
<ScrollingCollectionList
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
import { NextUp } from "@/components/series/NextUp";
|
||||
import { SeasonPicker } from "@/components/series/SeasonPicker";
|
||||
import { SeriesHeader } from "@/components/series/SeriesHeader";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useEffect, useMemo } from "react";
|
||||
import React from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
|
||||
const page: React.FC = () => {
|
||||
const navigation = useNavigation();
|
||||
const params = useLocalSearchParams();
|
||||
const { id: seriesId, seasonIndex } = params as {
|
||||
id: string;
|
||||
@@ -58,21 +56,6 @@ const page: React.FC = () => {
|
||||
[item]
|
||||
);
|
||||
|
||||
const { data: allEpisodes, isLoading } = useQuery({
|
||||
queryKey: ["AllEpisodes", item?.Id],
|
||||
queryFn: async () => {
|
||||
const res = await getTvShowsApi(api!).getEpisodes({
|
||||
seriesId: item?.Id!,
|
||||
userId: user?.Id!,
|
||||
enableUserData: true,
|
||||
fields: ["MediaSources", "MediaStreams", "Overview"],
|
||||
});
|
||||
return res?.data.Items || [];
|
||||
},
|
||||
staleTime: 60,
|
||||
enabled: !!api && !!user?.Id && !!item?.Id,
|
||||
});
|
||||
|
||||
if (!item || !backdropUrl) return null;
|
||||
|
||||
return (
|
||||
@@ -107,7 +90,10 @@ const page: React.FC = () => {
|
||||
}
|
||||
>
|
||||
<View className="flex flex-col pt-4">
|
||||
<SeriesHeader item={item} />
|
||||
<View className="px-4 py-4">
|
||||
<Text className="text-3xl font-bold">{item?.Name}</Text>
|
||||
<Text className="">{item?.Overview}</Text>
|
||||
</View>
|
||||
<View className="mb-4">
|
||||
<NextUp seriesId={seriesId} />
|
||||
</View>
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import {
|
||||
useFocusEffect,
|
||||
useLocalSearchParams,
|
||||
useNavigation,
|
||||
} from "expo-router";
|
||||
import * as ScreenOrientation from "expo-screen-orientation";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useEffect, useMemo } from "react";
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo } from "react";
|
||||
import { FlatList, useWindowDimensions, View } from "react-native";
|
||||
|
||||
import { Text } from "@/components/common/Text";
|
||||
@@ -30,7 +35,6 @@ import {
|
||||
import {
|
||||
BaseItemDto,
|
||||
BaseItemDtoQueryResult,
|
||||
BaseItemKind,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import {
|
||||
getFilterApi,
|
||||
@@ -39,7 +43,9 @@ import {
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { colletionTypeToItemType } from "@/utils/collectionTypeToItemType";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
|
||||
const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter);
|
||||
|
||||
const Page = () => {
|
||||
const searchParams = useLocalSearchParams();
|
||||
@@ -59,6 +65,8 @@ const Page = () => {
|
||||
sortOrderPreferenceAtom
|
||||
);
|
||||
|
||||
const { orientation } = useOrientation();
|
||||
|
||||
useEffect(() => {
|
||||
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
|
||||
if (sop) {
|
||||
@@ -137,18 +145,6 @@ const Page = () => {
|
||||
}): Promise<BaseItemDtoQueryResult | null> => {
|
||||
if (!api || !library) return null;
|
||||
|
||||
let itemType: BaseItemKind | undefined;
|
||||
|
||||
// This fix makes sure to only return 1 type of items, if defined.
|
||||
// This is because the underlying directory some times contains other types, and we don't want to show them.
|
||||
if (library.CollectionType === "movies") {
|
||||
itemType = "Movie";
|
||||
} else if (library.CollectionType === "tvshows") {
|
||||
itemType = "Series";
|
||||
} else if (library.CollectionType === "boxsets") {
|
||||
itemType = "BoxSet";
|
||||
}
|
||||
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user?.Id,
|
||||
parentId: libraryId,
|
||||
@@ -157,14 +153,12 @@ const Page = () => {
|
||||
sortBy: [sortBy[0], "SortName", "ProductionYear"],
|
||||
sortOrder: [sortOrder[0]],
|
||||
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
|
||||
// true is needed for merged versions
|
||||
recursive: true,
|
||||
recursive: false,
|
||||
imageTypeLimit: 1,
|
||||
fields: ["PrimaryImageAspectRatio", "SortName"],
|
||||
genres: selectedGenres,
|
||||
tags: selectedTags,
|
||||
years: selectedYears.map((year) => parseInt(year)),
|
||||
includeItemTypes: itemType ? [itemType] : undefined,
|
||||
});
|
||||
|
||||
return response.data || null;
|
||||
@@ -237,7 +231,14 @@ const Page = () => {
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
alignSelf: "center",
|
||||
alignSelf:
|
||||
orientation === ScreenOrientation.OrientationLock.PORTRAIT_UP
|
||||
? index % nrOfCols === 0
|
||||
? "flex-end"
|
||||
: (index + 1) % nrOfCols === 0
|
||||
? "flex-start"
|
||||
: "center"
|
||||
: "center",
|
||||
width: "89%",
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,44 +1,12 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Stack } from "expo-router";
|
||||
import { useState } from "react";
|
||||
import { Modal, Platform, TouchableOpacity, View } from "react-native";
|
||||
import { Platform } from "react-native";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
|
||||
export default function IndexLayout() {
|
||||
const [settings, updateSettings] = useSettings();
|
||||
const [isMenuVisible, setIsMenuVisible] = useState(false);
|
||||
const [activeSubmenu, setActiveSubmenu] = useState<string | null>(null);
|
||||
|
||||
const MenuItem = ({
|
||||
label,
|
||||
selected,
|
||||
onPress,
|
||||
disabled = false,
|
||||
}: {
|
||||
label: string;
|
||||
selected?: boolean;
|
||||
onPress: () => void;
|
||||
disabled?: boolean;
|
||||
}) => (
|
||||
<TouchableOpacity
|
||||
className={`p-4 border-b border-neutral-800 flex-row items-center justify-between ${
|
||||
disabled ? "opacity-50" : ""
|
||||
}`}
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Text className="text-base">{label}</Text>
|
||||
{selected && <Ionicons name="checkmark" size={24} color="white" />}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
const MenuSection = ({ title }: { title: string }) => (
|
||||
<View className="p-4 border-b border-neutral-800 bg-neutral-800/30">
|
||||
<Text className="text-sm opacity-50 font-medium">{title}</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
if (!settings?.libraryOptions) return null;
|
||||
|
||||
@@ -54,167 +22,163 @@ export default function IndexLayout() {
|
||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||
headerShadowVisible: false,
|
||||
headerRight: () => (
|
||||
<Modal
|
||||
visible={isMenuVisible}
|
||||
transparent
|
||||
animationType="slide"
|
||||
onRequestClose={() => {
|
||||
setIsMenuVisible(false);
|
||||
setActiveSubmenu(null);
|
||||
}}
|
||||
>
|
||||
<TouchableOpacity
|
||||
className="flex-1 bg-black/50"
|
||||
activeOpacity={1}
|
||||
onPress={() => {
|
||||
setIsMenuVisible(false);
|
||||
setActiveSubmenu(null);
|
||||
}}
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<Ionicons
|
||||
name="ellipsis-horizontal-outline"
|
||||
size={24}
|
||||
color="white"
|
||||
/>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
align={"end"}
|
||||
alignOffset={-10}
|
||||
avoidCollisions={false}
|
||||
collisionPadding={0}
|
||||
loop={false}
|
||||
side={"bottom"}
|
||||
sideOffset={10}
|
||||
>
|
||||
<View className="mt-auto bg-neutral-900 rounded-t-xl">
|
||||
{!activeSubmenu ? (
|
||||
<>
|
||||
<MenuSection title="Display" />
|
||||
<MenuItem
|
||||
label="Display"
|
||||
onPress={() => setActiveSubmenu("display")}
|
||||
/>
|
||||
<MenuItem
|
||||
label="Image style"
|
||||
onPress={() => setActiveSubmenu("imageStyle")}
|
||||
/>
|
||||
<MenuItem
|
||||
label="Show titles"
|
||||
selected={settings.libraryOptions.showTitles}
|
||||
disabled={
|
||||
settings.libraryOptions.imageStyle === "poster"
|
||||
}
|
||||
onPress={() => {
|
||||
if (settings.libraryOptions.imageStyle === "poster")
|
||||
return;
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
showTitles: !settings.libraryOptions.showTitles,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<MenuItem
|
||||
label="Show stats"
|
||||
selected={settings.libraryOptions.showStats}
|
||||
onPress={() => {
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
showStats: !settings.libraryOptions.showStats,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : activeSubmenu === "display" ? (
|
||||
<>
|
||||
<View className="p-4 border-b border-neutral-800 flex-row items-center">
|
||||
<TouchableOpacity
|
||||
onPress={() => setActiveSubmenu(null)}
|
||||
>
|
||||
<Ionicons
|
||||
name="chevron-back"
|
||||
size={24}
|
||||
color="white"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<Text className="text-lg font-bold ml-2">Display</Text>
|
||||
</View>
|
||||
<MenuItem
|
||||
label="Row"
|
||||
selected={settings.libraryOptions.display === "row"}
|
||||
onPress={() => {
|
||||
<DropdownMenu.Label>Display</DropdownMenu.Label>
|
||||
<DropdownMenu.Group key="display-group">
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger key="image-style-trigger">
|
||||
Display
|
||||
</DropdownMenu.SubTrigger>
|
||||
<DropdownMenu.SubContent
|
||||
alignOffset={-10}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={0}
|
||||
loop={true}
|
||||
sideOffset={10}
|
||||
>
|
||||
<DropdownMenu.CheckboxItem
|
||||
key="display-option-1"
|
||||
value={settings.libraryOptions.display === "row"}
|
||||
onValueChange={() =>
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
display: "row",
|
||||
},
|
||||
});
|
||||
setActiveSubmenu(null);
|
||||
}}
|
||||
/>
|
||||
<MenuItem
|
||||
label="List"
|
||||
selected={settings.libraryOptions.display === "list"}
|
||||
onPress={() => {
|
||||
})
|
||||
}
|
||||
>
|
||||
<DropdownMenu.ItemIndicator />
|
||||
<DropdownMenu.ItemTitle key="display-title-1">
|
||||
Row
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
<DropdownMenu.CheckboxItem
|
||||
key="display-option-2"
|
||||
value={settings.libraryOptions.display === "list"}
|
||||
onValueChange={() =>
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
display: "list",
|
||||
},
|
||||
});
|
||||
setActiveSubmenu(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : activeSubmenu === "imageStyle" ? (
|
||||
<>
|
||||
<View className="p-4 border-b border-neutral-800 flex-row items-center">
|
||||
<TouchableOpacity
|
||||
onPress={() => setActiveSubmenu(null)}
|
||||
>
|
||||
<Ionicons
|
||||
name="chevron-back"
|
||||
size={24}
|
||||
color="white"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<Text className="text-lg font-bold ml-2">
|
||||
Image Style
|
||||
</Text>
|
||||
</View>
|
||||
<MenuItem
|
||||
label="Poster"
|
||||
selected={
|
||||
settings.libraryOptions.imageStyle === "poster"
|
||||
})
|
||||
}
|
||||
onPress={() => {
|
||||
>
|
||||
<DropdownMenu.ItemIndicator />
|
||||
<DropdownMenu.ItemTitle key="display-title-2">
|
||||
List
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger key="image-style-trigger">
|
||||
Image style
|
||||
</DropdownMenu.SubTrigger>
|
||||
<DropdownMenu.SubContent
|
||||
alignOffset={-10}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={0}
|
||||
loop={true}
|
||||
sideOffset={10}
|
||||
>
|
||||
<DropdownMenu.CheckboxItem
|
||||
key="poster-option"
|
||||
value={settings.libraryOptions.imageStyle === "poster"}
|
||||
onValueChange={() =>
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
imageStyle: "poster",
|
||||
},
|
||||
});
|
||||
setActiveSubmenu(null);
|
||||
}}
|
||||
/>
|
||||
<MenuItem
|
||||
label="Cover"
|
||||
selected={
|
||||
settings.libraryOptions.imageStyle === "cover"
|
||||
})
|
||||
}
|
||||
onPress={() => {
|
||||
>
|
||||
<DropdownMenu.ItemIndicator />
|
||||
<DropdownMenu.ItemTitle key="poster-title">
|
||||
Poster
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
<DropdownMenu.CheckboxItem
|
||||
key="cover-option"
|
||||
value={settings.libraryOptions.imageStyle === "cover"}
|
||||
onValueChange={() =>
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
imageStyle: "cover",
|
||||
},
|
||||
});
|
||||
setActiveSubmenu(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<TouchableOpacity
|
||||
className="p-4 border-t border-neutral-800"
|
||||
onPress={() => {
|
||||
setIsMenuVisible(false);
|
||||
setActiveSubmenu(null);
|
||||
})
|
||||
}
|
||||
>
|
||||
<DropdownMenu.ItemIndicator />
|
||||
<DropdownMenu.ItemTitle key="cover-title">
|
||||
Cover
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
</DropdownMenu.Group>
|
||||
<DropdownMenu.Group key="show-titles-group">
|
||||
<DropdownMenu.CheckboxItem
|
||||
disabled={settings.libraryOptions.imageStyle === "poster"}
|
||||
key="show-titles-option"
|
||||
value={settings.libraryOptions.showTitles}
|
||||
onValueChange={(newValue) => {
|
||||
if (settings.libraryOptions.imageStyle === "poster")
|
||||
return;
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
showTitles: newValue === "on" ? true : false,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Text className="text-center text-purple-400">Done</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
<DropdownMenu.ItemIndicator />
|
||||
<DropdownMenu.ItemTitle key="show-titles-title">
|
||||
Show titles
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
<DropdownMenu.CheckboxItem
|
||||
key="show-stats-option"
|
||||
value={settings.libraryOptions.showStats}
|
||||
onValueChange={(newValue) => {
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
showStats: newValue === "on" ? true : false,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemIndicator />
|
||||
<DropdownMenu.ItemTitle key="show-stats-title">
|
||||
Show stats
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
</DropdownMenu.Group>
|
||||
|
||||
<DropdownMenu.Separator />
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {commonScreenOptions, nestedTabPageScreenOptions} from "@/components/stacks/NestedTabPageStack";
|
||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||
import { Stack } from "expo-router";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
@@ -29,10 +29,6 @@ export default function SearchLayout() {
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="jellyseerr/page"
|
||||
options={commonScreenOptions}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
|
||||
import { Input } from "@/components/common/Input";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
@@ -7,6 +8,7 @@ import { Loader } from "@/components/Loader";
|
||||
import AlbumCover from "@/components/posters/AlbumCover";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import SeriesPoster from "@/components/posters/SeriesPoster";
|
||||
import { TAB_HEIGHT } from "@/constants/Values";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
@@ -20,7 +22,6 @@ import axios from "axios";
|
||||
import { Href, router, useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, {
|
||||
PropsWithChildren,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
@@ -30,15 +31,6 @@ import React, {
|
||||
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useDebounce } from "use-debounce";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
|
||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
||||
import { Tag } from "@/components/GenreTags";
|
||||
import DiscoverSlide from "@/components/jellyseerr/DiscoverSlide";
|
||||
import { sortBy } from "lodash";
|
||||
|
||||
type SearchType = "Library" | "Discover";
|
||||
|
||||
const exampleSearches = [
|
||||
"Lord of the rings",
|
||||
@@ -55,7 +47,6 @@ export default function search() {
|
||||
|
||||
const { q, prev } = params as { q: string; prev: Href<string> };
|
||||
|
||||
const [searchType, setSearchType] = useState<SearchType>("Library");
|
||||
const [search, setSearch] = useState<string>("");
|
||||
|
||||
const [debouncedSearch] = useDebounce(search, 500);
|
||||
@@ -64,7 +55,6 @@ export default function search() {
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const [settings] = useSettings();
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
|
||||
const searchEngine = useMemo(() => {
|
||||
return settings?.searchEngine || "Jellyfin";
|
||||
@@ -82,43 +72,37 @@ export default function search() {
|
||||
types: BaseItemKind[];
|
||||
query: string;
|
||||
}): Promise<BaseItemDto[]> => {
|
||||
if (!api || !query) return [];
|
||||
if (!api) return [];
|
||||
|
||||
try {
|
||||
if (searchEngine === "Jellyfin") {
|
||||
const searchApi = await getSearchApi(api).getSearchHints({
|
||||
searchTerm: query,
|
||||
limit: 10,
|
||||
includeItemTypes: types,
|
||||
});
|
||||
if (searchEngine === "Jellyfin") {
|
||||
const searchApi = await getSearchApi(api).getSearchHints({
|
||||
searchTerm: query,
|
||||
limit: 10,
|
||||
includeItemTypes: types,
|
||||
});
|
||||
|
||||
return (searchApi.data.SearchHints as BaseItemDto[]) || [];
|
||||
} else {
|
||||
if (!settings?.marlinServerUrl) return [];
|
||||
const url = `${
|
||||
settings.marlinServerUrl
|
||||
}/search?q=${encodeURIComponent(query)}&includeItemTypes=${types
|
||||
.map((type) => encodeURIComponent(type))
|
||||
.join("&includeItemTypes=")}`;
|
||||
return searchApi.data.SearchHints as BaseItemDto[];
|
||||
} else {
|
||||
const url = `${settings?.marlinServerUrl}/search?q=${encodeURIComponent(
|
||||
query
|
||||
)}&includeItemTypes=${types
|
||||
.map((type) => encodeURIComponent(type))
|
||||
.join("&includeItemTypes=")}`;
|
||||
|
||||
const response1 = await axios.get(url);
|
||||
const ids = response1.data.ids;
|
||||
const response1 = await axios.get(url);
|
||||
const ids = response1.data.ids;
|
||||
|
||||
if (!ids || !ids.length) return [];
|
||||
if (!ids || !ids.length) return [];
|
||||
|
||||
const response2 = await getItemsApi(api).getItems({
|
||||
ids,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
});
|
||||
const response2 = await getItemsApi(api).getItems({
|
||||
ids,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
});
|
||||
|
||||
return (response2.data.Items as BaseItemDto[]) || [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error during search:", error);
|
||||
return []; // Ensure an empty array is returned in case of an error
|
||||
return response2.data.Items as BaseItemDto[];
|
||||
}
|
||||
},
|
||||
[api, searchEngine, settings]
|
||||
[api, settings]
|
||||
);
|
||||
|
||||
const navigation = useNavigation();
|
||||
@@ -144,51 +128,9 @@ export default function search() {
|
||||
query: debouncedSearch,
|
||||
types: ["Movie"],
|
||||
}),
|
||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||
enabled: debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const { data: jellyseerrResults, isFetching: j1 } = useQuery({
|
||||
queryKey: ["search", "jellyseerrResults", debouncedSearch],
|
||||
queryFn: async () => {
|
||||
const response = await jellyseerrApi?.search({
|
||||
query: new URLSearchParams(debouncedSearch).toString(),
|
||||
page: 1, // todo: maybe rework page & page-size if first results are not enough...
|
||||
language: "en",
|
||||
});
|
||||
|
||||
return response?.results;
|
||||
},
|
||||
enabled:
|
||||
!!jellyseerrApi &&
|
||||
searchType === "Discover" &&
|
||||
debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const { data: jellyseerrDiscoverSettings, isFetching: j2 } = useQuery({
|
||||
queryKey: ["search", "jellyseerrDiscoverSettings", debouncedSearch],
|
||||
queryFn: async () => jellyseerrApi?.discoverSettings(),
|
||||
enabled:
|
||||
!!jellyseerrApi &&
|
||||
searchType === "Discover" &&
|
||||
debouncedSearch.length == 0,
|
||||
});
|
||||
|
||||
const jellyseerrMovieResults: MovieResult[] | undefined = useMemo(
|
||||
() =>
|
||||
jellyseerrResults?.filter(
|
||||
(r) => r.mediaType === MediaType.MOVIE
|
||||
) as MovieResult[],
|
||||
[jellyseerrResults]
|
||||
);
|
||||
|
||||
const jellyseerrTvResults: TvResult[] | undefined = useMemo(
|
||||
() =>
|
||||
jellyseerrResults?.filter(
|
||||
(r) => r.mediaType === MediaType.TV
|
||||
) as TvResult[],
|
||||
[jellyseerrResults]
|
||||
);
|
||||
|
||||
const { data: series, isFetching: l2 } = useQuery({
|
||||
queryKey: ["search", "series", debouncedSearch],
|
||||
queryFn: () =>
|
||||
@@ -196,7 +138,7 @@ export default function search() {
|
||||
query: debouncedSearch,
|
||||
types: ["Series"],
|
||||
}),
|
||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||
enabled: debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const { data: episodes, isFetching: l3 } = useQuery({
|
||||
@@ -206,7 +148,7 @@ export default function search() {
|
||||
query: debouncedSearch,
|
||||
types: ["Episode"],
|
||||
}),
|
||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||
enabled: debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const { data: collections, isFetching: l7 } = useQuery({
|
||||
@@ -216,7 +158,7 @@ export default function search() {
|
||||
query: debouncedSearch,
|
||||
types: ["BoxSet"],
|
||||
}),
|
||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||
enabled: debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const { data: actors, isFetching: l8 } = useQuery({
|
||||
@@ -226,7 +168,7 @@ export default function search() {
|
||||
query: debouncedSearch,
|
||||
types: ["Person"],
|
||||
}),
|
||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||
enabled: debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const { data: artists, isFetching: l4 } = useQuery({
|
||||
@@ -236,7 +178,7 @@ export default function search() {
|
||||
query: debouncedSearch,
|
||||
types: ["MusicArtist"],
|
||||
}),
|
||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||
enabled: debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const { data: albums, isFetching: l5 } = useQuery({
|
||||
@@ -246,7 +188,7 @@ export default function search() {
|
||||
query: debouncedSearch,
|
||||
types: ["MusicAlbum"],
|
||||
}),
|
||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||
enabled: debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const { data: songs, isFetching: l6 } = useQuery({
|
||||
@@ -256,7 +198,7 @@ export default function search() {
|
||||
query: debouncedSearch,
|
||||
types: ["Audio"],
|
||||
}),
|
||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||
enabled: debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const noResults = useMemo(() => {
|
||||
@@ -268,25 +210,13 @@ export default function search() {
|
||||
episodes?.length ||
|
||||
series?.length ||
|
||||
collections?.length ||
|
||||
actors?.length ||
|
||||
jellyseerrMovieResults?.length ||
|
||||
jellyseerrTvResults?.length
|
||||
actors?.length
|
||||
);
|
||||
}, [
|
||||
artists,
|
||||
episodes,
|
||||
albums,
|
||||
songs,
|
||||
movies,
|
||||
series,
|
||||
collections,
|
||||
actors,
|
||||
jellyseerrResults,
|
||||
]);
|
||||
}, [artists, episodes, albums, songs, movies, series, collections, actors]);
|
||||
|
||||
const loading = useMemo(() => {
|
||||
return l1 || l2 || l3 || l4 || l5 || l6 || l7 || l8 || j1 || j2;
|
||||
}, [l1, l2, l3, l4, l5, l6, l7, l8, j1, j2]);
|
||||
return l1 || l2 || l3 || l4 || l5 || l6 || l7 || l8;
|
||||
}, [l1, l2, l3, l4, l5, l6, l7, l8]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -296,6 +226,10 @@ export default function search() {
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: 16,
|
||||
}}
|
||||
style={{
|
||||
marginBottom: TAB_HEIGHT,
|
||||
}}
|
||||
>
|
||||
<View className="flex flex-col pt-2">
|
||||
@@ -311,28 +245,6 @@ export default function search() {
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
{jellyseerrApi && (
|
||||
<View className="flex flex-row flex-wrap space-x-2 px-4 mb-2">
|
||||
<TouchableOpacity onPress={() => setSearchType("Library")}>
|
||||
<Tag
|
||||
text="Library"
|
||||
textClass="p-1"
|
||||
className={
|
||||
searchType === "Library" ? "bg-neutral-600" : undefined
|
||||
}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => setSearchType("Discover")}>
|
||||
<Tag
|
||||
text="Discover"
|
||||
textClass="p-1"
|
||||
className={
|
||||
searchType === "Discover" ? "bg-neutral-600" : undefined
|
||||
}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
{!!q && (
|
||||
<View className="px-4 flex flex-col space-y-2">
|
||||
<Text className="text-neutral-500 ">
|
||||
@@ -340,153 +252,130 @@ export default function search() {
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{searchType === "Library" && (
|
||||
<>
|
||||
<SearchItemWrapper
|
||||
header="Movies"
|
||||
ids={movies?.map((m) => m.Id!)}
|
||||
renderItem={(item: BaseItemDto) => (
|
||||
<TouchableItemRouter
|
||||
key={item.Id}
|
||||
className="flex flex-col w-28 mr-2"
|
||||
item={item}
|
||||
>
|
||||
<MoviePoster item={item} key={item.Id} />
|
||||
<Text numberOfLines={2} className="mt-2">
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text className="opacity-50 text-xs">
|
||||
{item.ProductionYear}
|
||||
</Text>
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
ids={series?.map((m) => m.Id!)}
|
||||
header="Series"
|
||||
renderItem={(item: BaseItemDto) => (
|
||||
<TouchableItemRouter
|
||||
key={item.Id}
|
||||
item={item}
|
||||
className="flex flex-col w-28 mr-2"
|
||||
>
|
||||
<SeriesPoster item={item} key={item.Id} />
|
||||
<Text numberOfLines={2} className="mt-2">
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text className="opacity-50 text-xs">
|
||||
{item.ProductionYear}
|
||||
</Text>
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
ids={episodes?.map((m) => m.Id!)}
|
||||
header="Episodes"
|
||||
renderItem={(item: BaseItemDto) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className="flex flex-col w-44 mr-2"
|
||||
>
|
||||
<ContinueWatchingPoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
ids={collections?.map((m) => m.Id!)}
|
||||
header="Collections"
|
||||
renderItem={(item: BaseItemDto) => (
|
||||
<TouchableItemRouter
|
||||
key={item.Id}
|
||||
item={item}
|
||||
className="flex flex-col w-28 mr-2"
|
||||
>
|
||||
<MoviePoster item={item} key={item.Id} />
|
||||
<Text numberOfLines={2} className="mt-2">
|
||||
{item.Name}
|
||||
</Text>
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
ids={actors?.map((m) => m.Id!)}
|
||||
header="Actors"
|
||||
renderItem={(item: BaseItemDto) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className="flex flex-col w-28 mr-2"
|
||||
>
|
||||
<MoviePoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
ids={artists?.map((m) => m.Id!)}
|
||||
header="Artists"
|
||||
renderItem={(item: BaseItemDto) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className="flex flex-col w-28 mr-2"
|
||||
>
|
||||
<AlbumCover id={item.Id} />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
ids={albums?.map((m) => m.Id!)}
|
||||
header="Albums"
|
||||
renderItem={(item: BaseItemDto) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className="flex flex-col w-28 mr-2"
|
||||
>
|
||||
<AlbumCover id={item.Id} />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
ids={songs?.map((m) => m.Id!)}
|
||||
header="Songs"
|
||||
renderItem={(item: BaseItemDto) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className="flex flex-col w-28 mr-2"
|
||||
>
|
||||
<AlbumCover id={item.AlbumId} />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{searchType === "Discover" && (
|
||||
<>
|
||||
<SearchItemWrapper
|
||||
header="Request Movies"
|
||||
items={jellyseerrMovieResults}
|
||||
renderItem={(item: MovieResult) => (
|
||||
<JellyseerrPoster item={item} key={item.id} />
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
header="Request Series"
|
||||
items={jellyseerrTvResults}
|
||||
renderItem={(item: TvResult) => (
|
||||
<JellyseerrPoster item={item} key={item.id} />
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<SearchItemWrapper
|
||||
header="Movies"
|
||||
ids={movies?.map((m) => m.Id!)}
|
||||
renderItem={(item) => (
|
||||
<TouchableItemRouter
|
||||
key={item.Id}
|
||||
className="flex flex-col w-28 mr-2"
|
||||
item={item}
|
||||
>
|
||||
<MoviePoster item={item} key={item.Id} />
|
||||
<Text numberOfLines={2} className="mt-2">
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text className="opacity-50 text-xs">
|
||||
{item.ProductionYear}
|
||||
</Text>
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
ids={series?.map((m) => m.Id!)}
|
||||
header="Series"
|
||||
renderItem={(item) => (
|
||||
<TouchableItemRouter
|
||||
key={item.Id}
|
||||
item={item}
|
||||
className="flex flex-col w-28 mr-2"
|
||||
>
|
||||
<SeriesPoster item={item} key={item.Id} />
|
||||
<Text numberOfLines={2} className="mt-2">
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text className="opacity-50 text-xs">
|
||||
{item.ProductionYear}
|
||||
</Text>
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
ids={episodes?.map((m) => m.Id!)}
|
||||
header="Episodes"
|
||||
renderItem={(item) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className="flex flex-col w-44 mr-2"
|
||||
>
|
||||
<ContinueWatchingPoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
ids={collections?.map((m) => m.Id!)}
|
||||
header="Collections"
|
||||
renderItem={(item) => (
|
||||
<TouchableItemRouter
|
||||
key={item.Id}
|
||||
item={item}
|
||||
className="flex flex-col w-28 mr-2"
|
||||
>
|
||||
<MoviePoster item={item} key={item.Id} />
|
||||
<Text numberOfLines={2} className="mt-2">
|
||||
{item.Name}
|
||||
</Text>
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
ids={actors?.map((m) => m.Id!)}
|
||||
header="Actors"
|
||||
renderItem={(item) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className="flex flex-col w-28 mr-2"
|
||||
>
|
||||
<MoviePoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
ids={artists?.map((m) => m.Id!)}
|
||||
header="Artists"
|
||||
renderItem={(item) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className="flex flex-col w-28 mr-2"
|
||||
>
|
||||
<AlbumCover id={item.Id} />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
ids={albums?.map((m) => m.Id!)}
|
||||
header="Albums"
|
||||
renderItem={(item) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className="flex flex-col w-28 mr-2"
|
||||
>
|
||||
<AlbumCover id={item.Id} />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
ids={songs?.map((m) => m.Id!)}
|
||||
header="Songs"
|
||||
renderItem={(item) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className="flex flex-col w-28 mr-2"
|
||||
>
|
||||
<AlbumCover id={item.AlbumId} />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
{loading ? (
|
||||
<View className="mt-4 flex justify-center items-center">
|
||||
<Loader />
|
||||
@@ -500,7 +389,7 @@ export default function search() {
|
||||
"{debouncedSearch}"
|
||||
</Text>
|
||||
</View>
|
||||
) : debouncedSearch.length === 0 && searchType === "Library" ? (
|
||||
) : debouncedSearch.length === 0 ? (
|
||||
<View className="mt-4 flex flex-col items-center space-y-2">
|
||||
{exampleSearches.map((e) => (
|
||||
<TouchableOpacity
|
||||
@@ -512,15 +401,6 @@ export default function search() {
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
) : debouncedSearch.length === 0 && searchType === "Discover" ? (
|
||||
<View className="flex flex-col px-4">
|
||||
{sortBy?.(
|
||||
jellyseerrDiscoverSettings?.filter((s) => s.enabled),
|
||||
"order"
|
||||
).map((slide) => (
|
||||
<DiscoverSlide key={slide.id} slide={slide} />
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</ScrollView>
|
||||
@@ -528,19 +408,13 @@ export default function search() {
|
||||
);
|
||||
}
|
||||
|
||||
type Props<T> = {
|
||||
type Props = {
|
||||
ids?: string[] | null;
|
||||
items?: T[];
|
||||
renderItem: (item: any) => React.ReactNode;
|
||||
renderItem: (item: BaseItemDto) => React.ReactNode;
|
||||
header?: string;
|
||||
};
|
||||
|
||||
const SearchItemWrapper = <T extends unknown>({
|
||||
ids,
|
||||
items,
|
||||
renderItem,
|
||||
header,
|
||||
}: PropsWithChildren<Props<T>>) => {
|
||||
const SearchItemWrapper: React.FC<Props> = ({ ids, renderItem, header }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
@@ -570,7 +444,7 @@ const SearchItemWrapper = <T extends unknown>({
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
if (!data && (!items || items.length === 0)) return null;
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -580,11 +454,7 @@ const SearchItemWrapper = <T extends unknown>({
|
||||
className="px-4 mb-2"
|
||||
showsHorizontalScrollIndicator={false}
|
||||
>
|
||||
{data && data?.length > 0
|
||||
? data.map((item) => renderItem(item))
|
||||
: items && items?.length > 0
|
||||
? items.map((i) => renderItem(i))
|
||||
: undefined}
|
||||
{data.map((item) => renderItem(item))}
|
||||
</ScrollView>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,79 +1,87 @@
|
||||
import React from "react";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
import { withLayoutContext } from "expo-router";
|
||||
|
||||
import {
|
||||
createNativeBottomTabNavigator,
|
||||
NativeBottomTabNavigationEventMap,
|
||||
} from "@bottom-tabs/react-navigation";
|
||||
|
||||
const { Navigator } = createNativeBottomTabNavigator();
|
||||
|
||||
import { BottomTabNavigationOptions } from "@react-navigation/bottom-tabs";
|
||||
|
||||
import { TabBarIcon } from "@/components/navigation/TabBarIcon";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import type {
|
||||
ParamListBase,
|
||||
TabNavigationState,
|
||||
} from "@react-navigation/native";
|
||||
import { SystemBars } from "react-native-edge-to-edge";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export const NativeTabs = withLayoutContext<
|
||||
BottomTabNavigationOptions,
|
||||
typeof Navigator,
|
||||
TabNavigationState<ParamListBase>,
|
||||
NativeBottomTabNavigationEventMap
|
||||
>(Navigator);
|
||||
import { BlurView } from "expo-blur";
|
||||
import * as NavigationBar from "expo-navigation-bar";
|
||||
import { Tabs } from "expo-router";
|
||||
import React, { useEffect } from "react";
|
||||
import { Platform, StyleSheet } from "react-native";
|
||||
|
||||
export default function TabLayout() {
|
||||
const [settings] = useSettings();
|
||||
useEffect(() => {
|
||||
if (Platform.OS === "android") {
|
||||
NavigationBar.setBackgroundColorAsync("#121212");
|
||||
NavigationBar.setBorderColorAsync("#121212");
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SystemBars hidden={false} style="light" />
|
||||
<NativeTabs
|
||||
sidebarAdaptable
|
||||
ignoresTopSafeArea
|
||||
barTintColor={Platform.OS === "android" ? "#121212" : undefined}
|
||||
tabBarActiveTintColor={Colors.primary}
|
||||
scrollEdgeAppearance="default"
|
||||
>
|
||||
<NativeTabs.Screen redirect name="index" />
|
||||
<NativeTabs.Screen
|
||||
name="(home)"
|
||||
options={{
|
||||
title: "Home",
|
||||
tabBarIcon:
|
||||
Platform.OS == "android"
|
||||
? ({ color, focused, size }) =>
|
||||
require("@/assets/icons/house.fill.png")
|
||||
: () => ({ sfSymbol: "house" }),
|
||||
}}
|
||||
/>
|
||||
<NativeTabs.Screen
|
||||
name="(search)"
|
||||
options={{
|
||||
title: "Search",
|
||||
tabBarIcon:
|
||||
Platform.OS == "android"
|
||||
? ({ color, focused, size }) =>
|
||||
require("@/assets/icons/magnifyingglass.png")
|
||||
: () => ({ sfSymbol: "magnifyingglass" }),
|
||||
}}
|
||||
/>
|
||||
<NativeTabs.Screen
|
||||
name="(libraries)"
|
||||
options={{
|
||||
title: "Library",
|
||||
tabBarIcon:
|
||||
Platform.OS == "android"
|
||||
? ({ color, focused, size }) =>
|
||||
require("@/assets/icons/server.rack.png")
|
||||
: () => ({ sfSymbol: "rectangle.stack" }),
|
||||
}}
|
||||
/>
|
||||
</NativeTabs>
|
||||
</>
|
||||
<Tabs
|
||||
initialRouteName="home"
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: Colors.tabIconSelected,
|
||||
headerShown: false,
|
||||
tabBarStyle: {
|
||||
position: "absolute",
|
||||
borderTopLeftRadius: 0,
|
||||
borderTopRightRadius: 0,
|
||||
borderTopWidth: 0,
|
||||
paddingTop: 8,
|
||||
paddingBottom: Platform.OS === "android" ? 8 : 26,
|
||||
height: Platform.OS === "android" ? 58 : 74,
|
||||
},
|
||||
tabBarBackground: () =>
|
||||
Platform.OS === "ios" ? (
|
||||
<BlurView
|
||||
experimentalBlurMethod="dimezisBlurView"
|
||||
intensity={95}
|
||||
style={{
|
||||
...StyleSheet.absoluteFillObject,
|
||||
overflow: "hidden",
|
||||
borderTopLeftRadius: 0,
|
||||
borderTopRightRadius: 0,
|
||||
backgroundColor: "black",
|
||||
}}
|
||||
/>
|
||||
) : undefined,
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen redirect name="index" />
|
||||
<Tabs.Screen
|
||||
name="(home)"
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "Home",
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<TabBarIcon
|
||||
name={focused ? "home" : "home-outline"}
|
||||
color={color}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="(search)"
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "Search",
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<TabBarIcon name={focused ? "search" : "search"} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="(libraries)"
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "Library",
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<TabBarIcon
|
||||
name={focused ? "apps" : "apps-outline"}
|
||||
color={color}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
308
app/(auth)/play-music.tsx
Normal file
308
app/(auth)/play-music.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import AlbumCover from "@/components/posters/AlbumCover";
|
||||
import { Controls } from "@/components/video-player/Controls";
|
||||
import { useAndroidNavigationBar } from "@/hooks/useAndroidNavigationBar";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
|
||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
PlaybackType,
|
||||
usePlaySettings,
|
||||
} from "@/providers/PlaySettingsProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
||||
import { secondsToTicks } from "@/utils/secondsToTicks";
|
||||
import { Api } from "@jellyfin/sdk";
|
||||
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { Image } from "expo-image";
|
||||
import { useFocusEffect } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { debounce } from "lodash";
|
||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { Dimensions, Pressable, StatusBar, View } from "react-native";
|
||||
import { useSharedValue } from "react-native-reanimated";
|
||||
import Video, { OnProgressData, VideoRef } from "react-native-video";
|
||||
|
||||
export default function page() {
|
||||
const { playSettings, playUrl, playSessionId } = usePlaySettings();
|
||||
const api = useAtomValue(apiAtom);
|
||||
const [settings] = useSettings();
|
||||
const videoRef = useRef<VideoRef | null>(null);
|
||||
const poster = usePoster(playSettings, api);
|
||||
const videoSource = useVideoSource(playSettings, api, poster, playUrl);
|
||||
const firstTime = useRef(true);
|
||||
|
||||
const screenDimensions = Dimensions.get("screen");
|
||||
|
||||
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);
|
||||
|
||||
if (!playSettings || !playUrl || !api || !videoSource || !playSettings.item)
|
||||
return null;
|
||||
|
||||
const togglePlay = useCallback(
|
||||
async (ticks: number) => {
|
||||
console.log("togglePlay");
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
if (isPlaying) {
|
||||
videoRef.current?.pause();
|
||||
await getPlaystateApi(api).onPlaybackProgress({
|
||||
itemId: playSettings.item?.Id!,
|
||||
audioStreamIndex: playSettings.audioIndex
|
||||
? playSettings.audioIndex
|
||||
: undefined,
|
||||
subtitleStreamIndex: playSettings.subtitleIndex
|
||||
? playSettings.subtitleIndex
|
||||
: undefined,
|
||||
mediaSourceId: playSettings.mediaSource?.Id!,
|
||||
positionTicks: Math.floor(ticks),
|
||||
isPaused: true,
|
||||
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||
playSessionId: playSessionId ? playSessionId : undefined,
|
||||
});
|
||||
} else {
|
||||
videoRef.current?.resume();
|
||||
await getPlaystateApi(api).onPlaybackProgress({
|
||||
itemId: playSettings.item?.Id!,
|
||||
audioStreamIndex: playSettings.audioIndex
|
||||
? playSettings.audioIndex
|
||||
: undefined,
|
||||
subtitleStreamIndex: playSettings.subtitleIndex
|
||||
? playSettings.subtitleIndex
|
||||
: undefined,
|
||||
mediaSourceId: playSettings.mediaSource?.Id!,
|
||||
positionTicks: Math.floor(ticks),
|
||||
isPaused: false,
|
||||
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||
playSessionId: playSessionId ? playSessionId : undefined,
|
||||
});
|
||||
}
|
||||
},
|
||||
[isPlaying, api, playSettings?.item?.Id, videoRef, settings]
|
||||
);
|
||||
|
||||
const play = useCallback(() => {
|
||||
console.log("play");
|
||||
videoRef.current?.resume();
|
||||
reportPlaybackStart();
|
||||
}, [videoRef]);
|
||||
|
||||
const pause = useCallback(() => {
|
||||
console.log("play");
|
||||
videoRef.current?.pause();
|
||||
}, [videoRef]);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
console.log("stop");
|
||||
setIsPlaybackStopped(true);
|
||||
videoRef.current?.pause();
|
||||
reportPlaybackStopped();
|
||||
}, [videoRef]);
|
||||
|
||||
const reportPlaybackStopped = async () => {
|
||||
await getPlaystateApi(api).onPlaybackStopped({
|
||||
itemId: playSettings?.item?.Id!,
|
||||
mediaSourceId: playSettings.mediaSource?.Id!,
|
||||
positionTicks: Math.floor(progress.value),
|
||||
playSessionId: playSessionId ? playSessionId : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const reportPlaybackStart = async () => {
|
||||
await getPlaystateApi(api).onPlaybackStart({
|
||||
itemId: playSettings?.item?.Id!,
|
||||
audioStreamIndex: playSettings.audioIndex
|
||||
? playSettings.audioIndex
|
||||
: undefined,
|
||||
subtitleStreamIndex: playSettings.subtitleIndex
|
||||
? playSettings.subtitleIndex
|
||||
: undefined,
|
||||
mediaSourceId: playSettings.mediaSource?.Id!,
|
||||
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||
playSessionId: playSessionId ? playSessionId : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
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 (!playSettings?.item?.Id || data.currentTime === 0) return;
|
||||
|
||||
await getPlaystateApi(api).onPlaybackProgress({
|
||||
itemId: playSettings.item.Id,
|
||||
audioStreamIndex: playSettings.audioIndex
|
||||
? playSettings.audioIndex
|
||||
: undefined,
|
||||
subtitleStreamIndex: playSettings.subtitleIndex
|
||||
? playSettings.subtitleIndex
|
||||
: undefined,
|
||||
mediaSourceId: playSettings.mediaSource?.Id!,
|
||||
positionTicks: Math.round(ticks),
|
||||
isPaused: !isPlaying,
|
||||
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||
playSessionId: playSessionId ? playSessionId : undefined,
|
||||
});
|
||||
},
|
||||
[playSettings?.item.Id, isPlaying, api, isPlaybackStopped]
|
||||
);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
play();
|
||||
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [play, stop])
|
||||
);
|
||||
|
||||
const { orientation } = useOrientation();
|
||||
useOrientationSettings();
|
||||
useAndroidNavigationBar();
|
||||
|
||||
useWebSocket({
|
||||
isPlaying: isPlaying,
|
||||
pauseVideo: pause,
|
||||
playVideo: play,
|
||||
stopPlayback: stop,
|
||||
});
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
width: screenDimensions.width,
|
||||
height: screenDimensions.height,
|
||||
position: "relative",
|
||||
}}
|
||||
className="flex flex-col items-center justify-center"
|
||||
>
|
||||
<StatusBar hidden />
|
||||
|
||||
<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"
|
||||
>
|
||||
<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={playSettings.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}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePoster(
|
||||
playSettings: PlaybackType | null,
|
||||
api: Api | null
|
||||
): string | undefined {
|
||||
const poster = useMemo(() => {
|
||||
if (!playSettings?.item || !api) return undefined;
|
||||
return playSettings.item.Type === "Audio"
|
||||
? `${api.basePath}/Items/${playSettings.item.AlbumId}/Images/Primary?tag=${playSettings.item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
|
||||
: getBackdropUrl({
|
||||
api,
|
||||
item: playSettings.item,
|
||||
quality: 70,
|
||||
width: 200,
|
||||
});
|
||||
}, [playSettings?.item, api]);
|
||||
|
||||
return poster ?? undefined;
|
||||
}
|
||||
|
||||
export function useVideoSource(
|
||||
playSettings: PlaybackType | null,
|
||||
api: Api | null,
|
||||
poster: string | undefined,
|
||||
playUrl?: string | null
|
||||
) {
|
||||
const videoSource = useMemo(() => {
|
||||
if (!playSettings || !api || !playUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startPosition = playSettings.item?.UserData?.PlaybackPositionTicks
|
||||
? Math.round(playSettings.item.UserData.PlaybackPositionTicks / 10000)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
uri: playUrl,
|
||||
isNetwork: true,
|
||||
startPosition,
|
||||
headers: getAuthHeaders(api),
|
||||
metadata: {
|
||||
artist: playSettings.item?.AlbumArtist ?? undefined,
|
||||
title: playSettings.item?.Name || "Unknown",
|
||||
description: playSettings.item?.Overview ?? undefined,
|
||||
imageUri: poster,
|
||||
subtitle: playSettings.item?.Album ?? undefined,
|
||||
},
|
||||
};
|
||||
}, [playSettings, api, poster]);
|
||||
|
||||
return videoSource;
|
||||
}
|
||||
180
app/(auth)/play-offline-video.tsx
Normal file
180
app/(auth)/play-offline-video.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { Controls } from "@/components/video-player/Controls";
|
||||
import { useAndroidNavigationBar } from "@/hooks/useAndroidNavigationBar";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
PlaybackType,
|
||||
usePlaySettings,
|
||||
} from "@/providers/PlaySettingsProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import orientationToOrientationLock from "@/utils/OrientationLockConverter";
|
||||
import { secondsToTicks } from "@/utils/secondsToTicks";
|
||||
import { Api } from "@jellyfin/sdk";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import * as NavigationBar from "expo-navigation-bar";
|
||||
import { useFocusEffect } from "expo-router";
|
||||
import * as ScreenOrientation from "expo-screen-orientation";
|
||||
import { useAtomValue } from "jotai";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Dimensions, Platform, Pressable, StatusBar, View } from "react-native";
|
||||
import { useSharedValue } from "react-native-reanimated";
|
||||
import Video, { OnProgressData, VideoRef } from "react-native-video";
|
||||
|
||||
export default function page() {
|
||||
const { playSettings, playUrl } = usePlaySettings();
|
||||
|
||||
const api = useAtomValue(apiAtom);
|
||||
const videoRef = useRef<VideoRef | null>(null);
|
||||
const videoSource = useVideoSource(playSettings, api, playUrl);
|
||||
const firstTime = useRef(true);
|
||||
|
||||
const screenDimensions = Dimensions.get("screen");
|
||||
|
||||
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 togglePlay = useCallback(async () => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
if (isPlaying) {
|
||||
videoRef.current?.pause();
|
||||
} else {
|
||||
videoRef.current?.resume();
|
||||
}
|
||||
}, [isPlaying]);
|
||||
|
||||
const play = useCallback(() => {
|
||||
setIsPlaying(true);
|
||||
videoRef.current?.resume();
|
||||
}, [videoRef]);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
setIsPlaying(false);
|
||||
videoRef.current?.pause();
|
||||
}, [videoRef]);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
play();
|
||||
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [play, stop])
|
||||
);
|
||||
|
||||
const { orientation } = useOrientation();
|
||||
useOrientationSettings();
|
||||
useAndroidNavigationBar();
|
||||
|
||||
const onProgress = useCallback(async (data: OnProgressData) => {
|
||||
if (isSeeking.value === true) return;
|
||||
progress.value = secondsToTicks(data.currentTime);
|
||||
cacheProgress.value = secondsToTicks(data.playableDuration);
|
||||
setIsBuffering(data.playableDuration === 0);
|
||||
}, []);
|
||||
|
||||
if (!playSettings || !playUrl || !api || !videoSource || !playSettings.item)
|
||||
return null;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
width: screenDimensions.width,
|
||||
height: screenDimensions.height,
|
||||
position: "relative",
|
||||
}}
|
||||
className="flex flex-col items-center justify-center"
|
||||
>
|
||||
<StatusBar hidden />
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
setShowControls(!showControls);
|
||||
}}
|
||||
className="absolute z-0 h-full w-full"
|
||||
>
|
||||
<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;
|
||||
}
|
||||
}}
|
||||
playWhenInactive={true}
|
||||
allowsExternalPlayback={true}
|
||||
playInBackground={true}
|
||||
pictureInPicture={true}
|
||||
showNotificationControls={true}
|
||||
ignoreSilentSwitch="ignore"
|
||||
fullscreen={false}
|
||||
onPlaybackStateChanged={(state) => {
|
||||
if (isSeeking.value === false) setIsPlaying(state.isPlaying);
|
||||
}}
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
<Controls
|
||||
item={playSettings.item}
|
||||
videoRef={videoRef}
|
||||
togglePlay={togglePlay}
|
||||
isPlaying={isPlaying}
|
||||
isSeeking={isSeeking}
|
||||
progress={progress}
|
||||
cacheProgress={cacheProgress}
|
||||
isBuffering={isBuffering}
|
||||
showControls={showControls}
|
||||
setShowControls={setShowControls}
|
||||
setIgnoreSafeAreas={setIgnoreSafeAreas}
|
||||
ignoreSafeAreas={ignoreSafeAreas}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function useVideoSource(
|
||||
playSettings: PlaybackType | null,
|
||||
api: Api | null,
|
||||
playUrl?: string | null
|
||||
) {
|
||||
const videoSource = useMemo(() => {
|
||||
if (!playSettings || !api || !playUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startPosition = 0;
|
||||
|
||||
return {
|
||||
uri: playUrl,
|
||||
isNetwork: false,
|
||||
startPosition,
|
||||
metadata: {
|
||||
artist: playSettings.item?.AlbumArtist ?? undefined,
|
||||
title: playSettings.item?.Name || "Unknown",
|
||||
description: playSettings.item?.Overview ?? undefined,
|
||||
subtitle: playSettings.item?.Album ?? undefined,
|
||||
},
|
||||
};
|
||||
}, [playSettings, api]);
|
||||
|
||||
return videoSource;
|
||||
}
|
||||
340
app/(auth)/play-video.tsx
Normal file
340
app/(auth)/play-video.tsx
Normal file
@@ -0,0 +1,340 @@
|
||||
import { Controls } from "@/components/video-player/Controls";
|
||||
import { useAndroidNavigationBar } from "@/hooks/useAndroidNavigationBar";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
|
||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
PlaybackType,
|
||||
usePlaySettings,
|
||||
} from "@/providers/PlaySettingsProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
||||
import { secondsToTicks } from "@/utils/secondsToTicks";
|
||||
import { Api } from "@jellyfin/sdk";
|
||||
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useFocusEffect } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { Dimensions, Pressable, StatusBar, View } from "react-native";
|
||||
import { useSharedValue } from "react-native-reanimated";
|
||||
import Video, {
|
||||
OnProgressData,
|
||||
SelectedTrackType,
|
||||
VideoRef,
|
||||
} from "react-native-video";
|
||||
|
||||
export default function page() {
|
||||
const { playSettings, playUrl, playSessionId } = usePlaySettings();
|
||||
const api = useAtomValue(apiAtom);
|
||||
const [settings] = useSettings();
|
||||
const videoRef = useRef<VideoRef | null>(null);
|
||||
const poster = usePoster(playSettings, api);
|
||||
const videoSource = useVideoSource(playSettings, api, poster, playUrl);
|
||||
const firstTime = useRef(true);
|
||||
|
||||
const screenDimensions = Dimensions.get("screen");
|
||||
|
||||
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);
|
||||
|
||||
if (!playSettings || !playUrl || !api || !videoSource || !playSettings.item)
|
||||
return null;
|
||||
|
||||
const togglePlay = useCallback(
|
||||
async (ticks: number) => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
if (isPlaying) {
|
||||
videoRef.current?.pause();
|
||||
await getPlaystateApi(api).onPlaybackProgress({
|
||||
itemId: playSettings.item?.Id!,
|
||||
audioStreamIndex: playSettings.audioIndex
|
||||
? playSettings.audioIndex
|
||||
: undefined,
|
||||
subtitleStreamIndex: playSettings.subtitleIndex
|
||||
? playSettings.subtitleIndex
|
||||
: undefined,
|
||||
mediaSourceId: playSettings.mediaSource?.Id!,
|
||||
positionTicks: Math.floor(ticks),
|
||||
isPaused: true,
|
||||
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||
playSessionId: playSessionId ? playSessionId : undefined,
|
||||
});
|
||||
} else {
|
||||
videoRef.current?.resume();
|
||||
await getPlaystateApi(api).onPlaybackProgress({
|
||||
itemId: playSettings.item?.Id!,
|
||||
audioStreamIndex: playSettings.audioIndex
|
||||
? playSettings.audioIndex
|
||||
: undefined,
|
||||
subtitleStreamIndex: playSettings.subtitleIndex
|
||||
? playSettings.subtitleIndex
|
||||
: undefined,
|
||||
mediaSourceId: playSettings.mediaSource?.Id!,
|
||||
positionTicks: Math.floor(ticks),
|
||||
isPaused: false,
|
||||
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||
playSessionId: playSessionId ? playSessionId : undefined,
|
||||
});
|
||||
}
|
||||
},
|
||||
[isPlaying, api, playSettings?.item?.Id, videoRef, settings]
|
||||
);
|
||||
|
||||
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 reportPlaybackStopped = async () => {
|
||||
await getPlaystateApi(api).onPlaybackStopped({
|
||||
itemId: playSettings?.item?.Id!,
|
||||
mediaSourceId: playSettings.mediaSource?.Id!,
|
||||
positionTicks: Math.floor(progress.value),
|
||||
playSessionId: playSessionId ? playSessionId : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const reportPlaybackStart = async () => {
|
||||
await getPlaystateApi(api).onPlaybackStart({
|
||||
itemId: playSettings?.item?.Id!,
|
||||
audioStreamIndex: playSettings.audioIndex
|
||||
? playSettings.audioIndex
|
||||
: undefined,
|
||||
subtitleStreamIndex: playSettings.subtitleIndex
|
||||
? playSettings.subtitleIndex
|
||||
: undefined,
|
||||
mediaSourceId: playSettings.mediaSource?.Id!,
|
||||
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||
playSessionId: playSessionId ? playSessionId : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
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 (!playSettings?.item?.Id || data.currentTime === 0) return;
|
||||
|
||||
await getPlaystateApi(api).onPlaybackProgress({
|
||||
itemId: playSettings.item.Id,
|
||||
audioStreamIndex: playSettings.audioIndex
|
||||
? playSettings.audioIndex
|
||||
: undefined,
|
||||
subtitleStreamIndex: playSettings.subtitleIndex
|
||||
? playSettings.subtitleIndex
|
||||
: undefined,
|
||||
mediaSourceId: playSettings.mediaSource?.Id!,
|
||||
positionTicks: Math.round(ticks),
|
||||
isPaused: !isPlaying,
|
||||
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||
playSessionId: playSessionId ? playSessionId : undefined,
|
||||
});
|
||||
},
|
||||
[playSettings?.item.Id, isPlaying, api, isPlaybackStopped]
|
||||
);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
play();
|
||||
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [play, stop])
|
||||
);
|
||||
|
||||
const { orientation } = useOrientation();
|
||||
useOrientationSettings();
|
||||
useAndroidNavigationBar();
|
||||
|
||||
useWebSocket({
|
||||
isPlaying: isPlaying,
|
||||
pauseVideo: pause,
|
||||
playVideo: play,
|
||||
stopPlayback: stop,
|
||||
});
|
||||
|
||||
const selectedSubtitleTrack = useMemo(() => {
|
||||
const a = playSettings?.mediaSource?.MediaStreams?.find(
|
||||
(s) => s.Index === playSettings.subtitleIndex
|
||||
);
|
||||
console.log(a);
|
||||
return a;
|
||||
}, [playSettings]);
|
||||
|
||||
const [hlsSubTracks, setHlsSubTracks] = useState<
|
||||
{
|
||||
index: number;
|
||||
language?: string | undefined;
|
||||
selected?: boolean | undefined;
|
||||
title?: string | undefined;
|
||||
type: any;
|
||||
}[]
|
||||
>([]);
|
||||
|
||||
const selectedTextTrack = useMemo(() => {
|
||||
for (let st of hlsSubTracks) {
|
||||
if (st.title === selectedSubtitleTrack?.DisplayTitle) {
|
||||
return {
|
||||
type: SelectedTrackType.TITLE,
|
||||
value: selectedSubtitleTrack?.DisplayTitle ?? "",
|
||||
};
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}, [hlsSubTracks]);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
width: screenDimensions.width,
|
||||
height: screenDimensions.height,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<StatusBar hidden />
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
setShowControls(!showControls);
|
||||
}}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: screenDimensions.width,
|
||||
height: screenDimensions.height,
|
||||
zIndex: 0,
|
||||
}}
|
||||
>
|
||||
<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) => {
|
||||
if (isSeeking.value === false) setIsPlaying(state.isPlaying);
|
||||
}}
|
||||
onTextTracks={(data) => {
|
||||
console.log("onTextTracks ~", data);
|
||||
setHlsSubTracks(data.textTracks as any);
|
||||
}}
|
||||
selectedTextTrack={selectedTextTrack}
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
<Controls
|
||||
item={playSettings.item}
|
||||
videoRef={videoRef}
|
||||
togglePlay={togglePlay}
|
||||
isPlaying={isPlaying}
|
||||
isSeeking={isSeeking}
|
||||
progress={progress}
|
||||
cacheProgress={cacheProgress}
|
||||
isBuffering={isBuffering}
|
||||
showControls={showControls}
|
||||
setShowControls={setShowControls}
|
||||
setIgnoreSafeAreas={setIgnoreSafeAreas}
|
||||
ignoreSafeAreas={ignoreSafeAreas}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePoster(
|
||||
playSettings: PlaybackType | null,
|
||||
api: Api | null
|
||||
): string | undefined {
|
||||
const poster = useMemo(() => {
|
||||
if (!playSettings?.item || !api) return undefined;
|
||||
return playSettings.item.Type === "Audio"
|
||||
? `${api.basePath}/Items/${playSettings.item.AlbumId}/Images/Primary?tag=${playSettings.item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
|
||||
: getBackdropUrl({
|
||||
api,
|
||||
item: playSettings.item,
|
||||
quality: 70,
|
||||
width: 200,
|
||||
});
|
||||
}, [playSettings?.item, api]);
|
||||
|
||||
return poster ?? undefined;
|
||||
}
|
||||
|
||||
export function useVideoSource(
|
||||
playSettings: PlaybackType | null,
|
||||
api: Api | null,
|
||||
poster: string | undefined,
|
||||
playUrl?: string | null
|
||||
) {
|
||||
const videoSource = useMemo(() => {
|
||||
if (!playSettings || !api || !playUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startPosition = playSettings.item?.UserData?.PlaybackPositionTicks
|
||||
? Math.round(playSettings.item.UserData.PlaybackPositionTicks / 10000)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
uri: playUrl,
|
||||
isNetwork: true,
|
||||
startPosition,
|
||||
headers: getAuthHeaders(api),
|
||||
metadata: {
|
||||
artist: playSettings.item?.AlbumArtist ?? undefined,
|
||||
title: playSettings.item?.Name || "Unknown",
|
||||
description: playSettings.item?.Overview ?? undefined,
|
||||
imageUri: poster,
|
||||
subtitle: playSettings.item?.Album ?? undefined,
|
||||
},
|
||||
};
|
||||
}, [playSettings, api, poster]);
|
||||
|
||||
return videoSource;
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { Stack } from "expo-router";
|
||||
import React from "react";
|
||||
import { SystemBars } from "react-native-edge-to-edge";
|
||||
|
||||
export default function Layout() {
|
||||
return (
|
||||
<>
|
||||
<SystemBars hidden />
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
name="direct-player"
|
||||
options={{
|
||||
headerShown: false,
|
||||
autoHideHomeIndicator: true,
|
||||
title: "",
|
||||
animation: "fade",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="transcoding-player"
|
||||
options={{
|
||||
headerShown: false,
|
||||
autoHideHomeIndicator: true,
|
||||
title: "",
|
||||
animation: "fade",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="music-player"
|
||||
options={{
|
||||
headerShown: false,
|
||||
autoHideHomeIndicator: true,
|
||||
title: "",
|
||||
animation: "fade",
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,442 +0,0 @@
|
||||
import { BITRATES } from "@/components/BitrateSelector";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { Controls } from "@/components/video-player/controls/Controls";
|
||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||
import { VlcPlayerView } from "@/modules/vlc-player";
|
||||
import {
|
||||
PlaybackStatePayload,
|
||||
ProgressUpdatePayload,
|
||||
VlcPlayerViewRef,
|
||||
} from "@/modules/vlc-player/src/VlcPlayer.types";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import native from "@/utils/profiles/native";
|
||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||
import { Api } from "@jellyfin/sdk";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import {
|
||||
getPlaystateApi,
|
||||
getUserLibraryApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useFocusEffect, useGlobalSearchParams } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Alert, Platform, View } from "react-native";
|
||||
import { useSharedValue } from "react-native-reanimated";
|
||||
|
||||
export default function page() {
|
||||
const videoRef = useRef<VlcPlayerViewRef>(null);
|
||||
const user = useAtomValue(userAtom);
|
||||
const api = useAtomValue(apiAtom);
|
||||
|
||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
||||
const [showControls, _setShowControls] = useState(true);
|
||||
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isBuffering, setIsBuffering] = useState(true);
|
||||
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
||||
|
||||
const progress = useSharedValue(0);
|
||||
const isSeeking = useSharedValue(false);
|
||||
const cacheProgress = useSharedValue(0);
|
||||
|
||||
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
||||
|
||||
const setShowControls = useCallback((show: boolean) => {
|
||||
_setShowControls(show);
|
||||
}, []);
|
||||
|
||||
const {
|
||||
itemId,
|
||||
audioIndex: audioIndexStr,
|
||||
subtitleIndex: subtitleIndexStr,
|
||||
mediaSourceId,
|
||||
bitrateValue: bitrateValueStr,
|
||||
} = useGlobalSearchParams<{
|
||||
itemId: string;
|
||||
audioIndex: string;
|
||||
subtitleIndex: string;
|
||||
mediaSourceId: string;
|
||||
bitrateValue: string;
|
||||
}>();
|
||||
const [settings] = useSettings();
|
||||
|
||||
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
|
||||
const subtitleIndex = subtitleIndexStr ? parseInt(subtitleIndexStr, 10) : -1;
|
||||
const bitrateValue = bitrateValueStr
|
||||
? parseInt(bitrateValueStr, 10)
|
||||
: BITRATES[0].value;
|
||||
|
||||
const {
|
||||
data: item,
|
||||
isLoading: isLoadingItem,
|
||||
isError: isErrorItem,
|
||||
} = useQuery({
|
||||
queryKey: ["item", itemId],
|
||||
queryFn: async () => {
|
||||
const res = await getUserLibraryApi(api!).getItem({
|
||||
itemId,
|
||||
userId: user?.Id,
|
||||
});
|
||||
|
||||
return res.data;
|
||||
},
|
||||
enabled: !!itemId,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const {
|
||||
data: stream,
|
||||
isLoading: isLoadingStreamUrl,
|
||||
isError: isErrorStreamUrl,
|
||||
} = useQuery({
|
||||
queryKey: ["stream-url", itemId, mediaSourceId, bitrateValue],
|
||||
queryFn: async () => {
|
||||
const res = await getStreamUrl({
|
||||
api,
|
||||
item,
|
||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
||||
userId: user?.Id,
|
||||
audioStreamIndex: audioIndex,
|
||||
maxStreamingBitrate: bitrateValue,
|
||||
mediaSourceId: mediaSourceId,
|
||||
subtitleStreamIndex: subtitleIndex,
|
||||
deviceProfile: native,
|
||||
});
|
||||
|
||||
if (!res) return null;
|
||||
|
||||
const { mediaSource, sessionId, url } = res;
|
||||
|
||||
if (!sessionId || !mediaSource || !url) {
|
||||
Alert.alert("Error", "Failed to get stream url");
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
mediaSource,
|
||||
sessionId,
|
||||
url,
|
||||
};
|
||||
},
|
||||
enabled: !!itemId && !!item,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const togglePlay = useCallback(async () => {
|
||||
if (!api) return;
|
||||
|
||||
if (isPlaying) {
|
||||
await videoRef.current?.pause();
|
||||
|
||||
if (stream) {
|
||||
await getPlaystateApi(api).onPlaybackProgress({
|
||||
itemId: item?.Id!,
|
||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||
mediaSourceId: mediaSourceId,
|
||||
positionTicks: msToTicks(progress.value),
|
||||
isPaused: true,
|
||||
playMethod: stream.url?.includes("m3u8")
|
||||
? "Transcode"
|
||||
: "DirectStream",
|
||||
playSessionId: stream.sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
console.log("Actually marked as paused");
|
||||
} else {
|
||||
videoRef.current?.play();
|
||||
if (stream) {
|
||||
await getPlaystateApi(api).onPlaybackProgress({
|
||||
itemId: item?.Id!,
|
||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||
mediaSourceId: mediaSourceId,
|
||||
positionTicks: msToTicks(progress.value),
|
||||
isPaused: false,
|
||||
playMethod: stream?.url.includes("m3u8")
|
||||
? "Transcode"
|
||||
: "DirectStream",
|
||||
playSessionId: stream.sessionId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [
|
||||
isPlaying,
|
||||
api,
|
||||
item,
|
||||
stream,
|
||||
videoRef,
|
||||
audioIndex,
|
||||
subtitleIndex,
|
||||
mediaSourceId,
|
||||
progress.value,
|
||||
]);
|
||||
|
||||
const reportPlaybackStopped = useCallback(async () => {
|
||||
const currentTimeInTicks = msToTicks(progress.value);
|
||||
|
||||
await getPlaystateApi(api!).onPlaybackStopped({
|
||||
itemId: item?.Id!,
|
||||
mediaSourceId: mediaSourceId,
|
||||
positionTicks: currentTimeInTicks,
|
||||
playSessionId: stream?.sessionId!,
|
||||
});
|
||||
|
||||
revalidateProgressCache();
|
||||
}, [api, item, mediaSourceId, stream]);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
reportPlaybackStopped();
|
||||
setIsPlaybackStopped(true);
|
||||
videoRef.current?.stop();
|
||||
}, [videoRef, reportPlaybackStopped]);
|
||||
|
||||
// TODO: unused should remove.
|
||||
const reportPlaybackStart = useCallback(async () => {
|
||||
if (!stream) return;
|
||||
await getPlaystateApi(api!).onPlaybackStart({
|
||||
itemId: item?.Id!,
|
||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||
mediaSourceId: mediaSourceId,
|
||||
playMethod: stream.url?.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||
playSessionId: stream?.sessionId ? stream?.sessionId : undefined,
|
||||
});
|
||||
}, [api, item, mediaSourceId, stream]);
|
||||
|
||||
const onProgress = useCallback(
|
||||
async (data: ProgressUpdatePayload) => {
|
||||
if (isSeeking.value === true) return;
|
||||
if (isPlaybackStopped === true) return;
|
||||
|
||||
const { currentTime } = data.nativeEvent;
|
||||
|
||||
if (isBuffering) {
|
||||
setIsBuffering(false);
|
||||
}
|
||||
|
||||
progress.value = currentTime;
|
||||
|
||||
const currentTimeInTicks = msToTicks(currentTime);
|
||||
|
||||
if (!item?.Id || !stream) return;
|
||||
|
||||
await getPlaystateApi(api!).onPlaybackProgress({
|
||||
itemId: item.Id,
|
||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||
mediaSourceId: mediaSourceId,
|
||||
positionTicks: Math.floor(currentTimeInTicks),
|
||||
isPaused: !isPlaying,
|
||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||
playSessionId: stream.sessionId,
|
||||
});
|
||||
},
|
||||
[item?.Id, isPlaying, api, isPlaybackStopped, audioIndex, subtitleIndex]
|
||||
);
|
||||
|
||||
useWebSocket({
|
||||
isPlaying: isPlaying,
|
||||
togglePlay: togglePlay,
|
||||
stopPlayback: stop,
|
||||
});
|
||||
|
||||
const onPlaybackStateChanged = useCallback((e: PlaybackStatePayload) => {
|
||||
const { state, isBuffering, isPlaying } = e.nativeEvent;
|
||||
|
||||
if (state === "Playing") {
|
||||
setIsPlaying(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (state === "Paused") {
|
||||
setIsPlaying(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPlaying) {
|
||||
setIsPlaying(true);
|
||||
setIsBuffering(false);
|
||||
} else if (isBuffering) {
|
||||
setIsBuffering(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startPosition = useMemo(() => {
|
||||
return item?.UserData?.PlaybackPositionTicks
|
||||
? ticksToSeconds(item.UserData.PlaybackPositionTicks)
|
||||
: 0;
|
||||
}, [item]);
|
||||
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
return async () => {
|
||||
stop();
|
||||
console.log("Unmounted");
|
||||
};
|
||||
}, [])
|
||||
);
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
let initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
|
||||
let externalTrack = { name: "", DeliveryUrl: "" };
|
||||
|
||||
const allSubs =
|
||||
stream?.mediaSource.MediaStreams?.filter(
|
||||
(sub) => sub.Type === "Subtitle"
|
||||
) || [];
|
||||
const chosenSubtitleTrack = allSubs.find(
|
||||
(sub) => sub.Index === subtitleIndex
|
||||
);
|
||||
const allAudio =
|
||||
stream?.mediaSource.MediaStreams?.filter(
|
||||
(audio) => audio.Type === "Audio"
|
||||
) || [];
|
||||
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
|
||||
|
||||
// Direct playback CASE
|
||||
if (!bitrateValue) {
|
||||
// If Subtitle is embedded we can use the position to select it straight away.
|
||||
if (chosenSubtitleTrack && !chosenSubtitleTrack.DeliveryUrl) {
|
||||
initOptions.push(`--sub-track=${allSubs.indexOf(chosenSubtitleTrack)}`);
|
||||
} else if (chosenSubtitleTrack && chosenSubtitleTrack.DeliveryUrl) {
|
||||
// If Subtitle is external we need to pass the URL to the player.
|
||||
externalTrack = {
|
||||
name: chosenSubtitleTrack.DisplayTitle || "",
|
||||
DeliveryUrl: `${api?.basePath || ""}${chosenSubtitleTrack.DeliveryUrl}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (chosenAudioTrack)
|
||||
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
|
||||
} else {
|
||||
// Transcoded playback CASE
|
||||
if (chosenSubtitleTrack?.DeliveryMethod === "Hls") {
|
||||
externalTrack = {
|
||||
name: `subs ${chosenSubtitleTrack.DisplayTitle}`,
|
||||
DeliveryUrl: "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!item || isLoadingItem || isLoadingStreamUrl || !stream)
|
||||
return (
|
||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
|
||||
if (isErrorItem || isErrorStreamUrl)
|
||||
return (
|
||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
||||
<Text className="text-white">Error</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: "black" }}>
|
||||
<View
|
||||
style={{
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
position: "relative",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
opacity: showControls ? (Platform.OS === "android" ? 0.7 : 0.5) : 1,
|
||||
}}
|
||||
>
|
||||
<VlcPlayerView
|
||||
ref={videoRef}
|
||||
source={{
|
||||
uri: stream.url,
|
||||
autoplay: true,
|
||||
isNetwork: true,
|
||||
startPosition,
|
||||
externalTrack,
|
||||
initOptions,
|
||||
}}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
onVideoProgress={onProgress}
|
||||
progressUpdateInterval={1000}
|
||||
onVideoStateChange={onPlaybackStateChanged}
|
||||
onVideoLoadStart={() => {}}
|
||||
onVideoLoadEnd={() => {
|
||||
setIsVideoLoaded(true);
|
||||
}}
|
||||
onVideoError={(e) => {
|
||||
console.error("Video Error:", e.nativeEvent);
|
||||
Alert.alert(
|
||||
"Error",
|
||||
"An error occurred while playing the video. Check logs in settings."
|
||||
);
|
||||
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
{videoRef.current && (
|
||||
<Controls
|
||||
mediaSource={stream?.mediaSource}
|
||||
item={item}
|
||||
videoRef={videoRef}
|
||||
togglePlay={togglePlay}
|
||||
isPlaying={isPlaying}
|
||||
isSeeking={isSeeking}
|
||||
progress={progress}
|
||||
cacheProgress={cacheProgress}
|
||||
isBuffering={isBuffering}
|
||||
showControls={showControls}
|
||||
setShowControls={setShowControls}
|
||||
setIgnoreSafeAreas={setIgnoreSafeAreas}
|
||||
ignoreSafeAreas={ignoreSafeAreas}
|
||||
isVideoLoaded={isVideoLoaded}
|
||||
play={videoRef.current?.play}
|
||||
pause={videoRef.current?.pause}
|
||||
seek={videoRef.current?.seekTo}
|
||||
enableTrickplay={true}
|
||||
getAudioTracks={videoRef.current?.getAudioTracks}
|
||||
getSubtitleTracks={videoRef.current?.getSubtitleTracks}
|
||||
setSubtitleTrack={videoRef.current.setSubtitleTrack}
|
||||
setSubtitleURL={videoRef.current.setSubtitleURL}
|
||||
setAudioTrack={videoRef.current.setAudioTrack}
|
||||
stop={stop}
|
||||
isVlc
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePoster(
|
||||
item: BaseItemDto,
|
||||
api: Api | null
|
||||
): string | undefined {
|
||||
const poster = useMemo(() => {
|
||||
if (!item || !api) return undefined;
|
||||
return item.Type === "Audio"
|
||||
? `${api.basePath}/Items/${item.AlbumId}/Images/Primary?tag=${item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
|
||||
: getBackdropUrl({
|
||||
api,
|
||||
item: item,
|
||||
quality: 70,
|
||||
width: 200,
|
||||
});
|
||||
}, [item, api]);
|
||||
|
||||
return poster ?? undefined;
|
||||
}
|
||||
@@ -1,413 +0,0 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { Controls } from "@/components/video-player/controls/Controls";
|
||||
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 { Image } from "expo-image";
|
||||
import { useFocusEffect, useLocalSearchParams } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { Pressable, useWindowDimensions, View } from "react-native";
|
||||
import { useSharedValue } from "react-native-reanimated";
|
||||
import Video, { OnProgressData, VideoRef } from "react-native-video";
|
||||
|
||||
export default function page() {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const [settings] = useSettings();
|
||||
const videoRef = useRef<VideoRef | null>(null);
|
||||
const windowDimensions = useWindowDimensions();
|
||||
|
||||
const firstTime = useRef(true);
|
||||
|
||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
||||
const [showControls, setShowControls] = useState(true);
|
||||
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isBuffering, setIsBuffering] = useState(true);
|
||||
|
||||
const progress = useSharedValue(0);
|
||||
const isSeeking = useSharedValue(false);
|
||||
const cacheProgress = useSharedValue(0);
|
||||
|
||||
const {
|
||||
itemId,
|
||||
audioIndex: audioIndexStr,
|
||||
subtitleIndex: subtitleIndexStr,
|
||||
mediaSourceId,
|
||||
bitrateValue: bitrateValueStr,
|
||||
} = useLocalSearchParams<{
|
||||
itemId: string;
|
||||
audioIndex: string;
|
||||
subtitleIndex: string;
|
||||
mediaSourceId: string;
|
||||
bitrateValue: string;
|
||||
}>();
|
||||
|
||||
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
|
||||
const subtitleIndex = subtitleIndexStr
|
||||
? parseInt(subtitleIndexStr, 10)
|
||||
: undefined;
|
||||
const bitrateValue = bitrateValueStr
|
||||
? parseInt(bitrateValueStr, 10)
|
||||
: undefined;
|
||||
|
||||
const {
|
||||
data: item,
|
||||
isLoading: isLoadingItem,
|
||||
isError: isErrorItem,
|
||||
} = useQuery({
|
||||
queryKey: ["item", itemId],
|
||||
queryFn: async () => {
|
||||
if (!api) return;
|
||||
const res = await getUserLibraryApi(api).getItem({
|
||||
itemId,
|
||||
userId: user?.Id,
|
||||
});
|
||||
|
||||
return res.data;
|
||||
},
|
||||
enabled: !!itemId && !!api,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const {
|
||||
data: stream,
|
||||
isLoading: isLoadingStreamUrl,
|
||||
isError: isErrorStreamUrl,
|
||||
} = useQuery({
|
||||
queryKey: ["stream-url"],
|
||||
queryFn: async () => {
|
||||
if (!api) return;
|
||||
const res = await getStreamUrl({
|
||||
api,
|
||||
item,
|
||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
||||
userId: user?.Id,
|
||||
audioStreamIndex: audioIndex,
|
||||
maxStreamingBitrate: bitrateValue,
|
||||
mediaSourceId: mediaSourceId,
|
||||
subtitleStreamIndex: subtitleIndex,
|
||||
});
|
||||
|
||||
if (!res) return null;
|
||||
|
||||
const { mediaSource, sessionId, url } = res;
|
||||
|
||||
if (!sessionId || !mediaSource || !url) return null;
|
||||
|
||||
return {
|
||||
mediaSource,
|
||||
sessionId,
|
||||
url,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const poster = usePoster(item, api);
|
||||
const videoSource = useVideoSource(item, api, poster, stream?.url);
|
||||
|
||||
const togglePlay = useCallback(
|
||||
async (ticks: number) => {
|
||||
if (isPlaying) {
|
||||
videoRef.current?.pause();
|
||||
await getPlaystateApi(api!).onPlaybackProgress({
|
||||
itemId: item?.Id!,
|
||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||
mediaSourceId: mediaSourceId,
|
||||
positionTicks: Math.floor(ticks),
|
||||
isPaused: true,
|
||||
playMethod: stream?.url.includes("m3u8")
|
||||
? "Transcode"
|
||||
: "DirectStream",
|
||||
playSessionId: stream?.sessionId,
|
||||
});
|
||||
} else {
|
||||
videoRef.current?.resume();
|
||||
await getPlaystateApi(api!).onPlaybackProgress({
|
||||
itemId: item?.Id!,
|
||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||
mediaSourceId: mediaSourceId,
|
||||
positionTicks: Math.floor(ticks),
|
||||
isPaused: false,
|
||||
playMethod: stream?.url.includes("m3u8")
|
||||
? "Transcode"
|
||||
: "DirectStream",
|
||||
playSessionId: stream?.sessionId,
|
||||
});
|
||||
}
|
||||
},
|
||||
[
|
||||
isPlaying,
|
||||
api,
|
||||
item,
|
||||
videoRef,
|
||||
settings,
|
||||
audioIndex,
|
||||
subtitleIndex,
|
||||
mediaSourceId,
|
||||
stream,
|
||||
]
|
||||
);
|
||||
|
||||
const play = useCallback(() => {
|
||||
console.log("play");
|
||||
videoRef.current?.resume();
|
||||
reportPlaybackStart();
|
||||
}, [videoRef]);
|
||||
|
||||
const pause = useCallback(() => {
|
||||
console.log("play");
|
||||
videoRef.current?.pause();
|
||||
}, [videoRef]);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
console.log("stop");
|
||||
setIsPlaybackStopped(true);
|
||||
videoRef.current?.pause();
|
||||
reportPlaybackStopped();
|
||||
}, [videoRef]);
|
||||
|
||||
const seek = useCallback(
|
||||
(seconds: number) => {
|
||||
videoRef.current?.seek(seconds);
|
||||
},
|
||||
[videoRef]
|
||||
);
|
||||
|
||||
const reportPlaybackStopped = async () => {
|
||||
if (!item?.Id) return;
|
||||
await getPlaystateApi(api!).onPlaybackStopped({
|
||||
itemId: item.Id,
|
||||
mediaSourceId: mediaSourceId,
|
||||
positionTicks: Math.floor(progress.value),
|
||||
playSessionId: stream?.sessionId,
|
||||
});
|
||||
};
|
||||
|
||||
const reportPlaybackStart = async () => {
|
||||
if (!item?.Id) return;
|
||||
await getPlaystateApi(api!).onPlaybackStart({
|
||||
itemId: item?.Id,
|
||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||
mediaSourceId: mediaSourceId,
|
||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||
playSessionId: stream?.sessionId,
|
||||
});
|
||||
};
|
||||
|
||||
const onProgress = useCallback(
|
||||
async (data: OnProgressData) => {
|
||||
if (isSeeking.value === true) return;
|
||||
if (isPlaybackStopped === true) return;
|
||||
|
||||
const ticks = data.currentTime * 10000000;
|
||||
|
||||
progress.value = secondsToTicks(data.currentTime);
|
||||
cacheProgress.value = secondsToTicks(data.playableDuration);
|
||||
setIsBuffering(data.playableDuration === 0);
|
||||
|
||||
if (!item?.Id || data.currentTime === 0) return;
|
||||
|
||||
await getPlaystateApi(api!).onPlaybackProgress({
|
||||
itemId: item.Id!,
|
||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||
mediaSourceId: mediaSourceId,
|
||||
positionTicks: Math.round(ticks),
|
||||
isPaused: !isPlaying,
|
||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||
playSessionId: stream?.sessionId,
|
||||
});
|
||||
},
|
||||
[
|
||||
item,
|
||||
isPlaying,
|
||||
api,
|
||||
isPlaybackStopped,
|
||||
audioIndex,
|
||||
subtitleIndex,
|
||||
mediaSourceId,
|
||||
stream,
|
||||
]
|
||||
);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
play();
|
||||
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [play, stop])
|
||||
);
|
||||
|
||||
useWebSocket({
|
||||
isPlaying: isPlaying,
|
||||
pauseVideo: pause,
|
||||
playVideo: play,
|
||||
stopPlayback: stop,
|
||||
});
|
||||
|
||||
if (isLoadingItem || isLoadingStreamUrl)
|
||||
return (
|
||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
|
||||
if (isErrorItem || isErrorStreamUrl)
|
||||
return (
|
||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
||||
<Text className="text-white">Error</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
if (!item || !stream)
|
||||
return (
|
||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
||||
<Text className="text-white">Error</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
width: windowDimensions.width,
|
||||
height: windowDimensions.height,
|
||||
position: "relative",
|
||||
}}
|
||||
className="flex flex-col items-center justify-center"
|
||||
>
|
||||
<View className="h-screen w-screen top-0 left-0 flex flex-col items-center justify-center p-4 absolute z-0">
|
||||
<Image
|
||||
source={poster}
|
||||
style={{ width: "100%", height: "100%", resizeMode: "contain" }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
setShowControls(!showControls);
|
||||
}}
|
||||
className="absolute z-0 h-full w-full opacity-0"
|
||||
>
|
||||
{videoSource && (
|
||||
<Video
|
||||
ref={videoRef}
|
||||
source={videoSource}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
resizeMode={ignoreSafeAreas ? "cover" : "contain"}
|
||||
onProgress={onProgress}
|
||||
onError={() => {}}
|
||||
onLoad={() => {
|
||||
if (firstTime.current === true) {
|
||||
play();
|
||||
firstTime.current = false;
|
||||
}
|
||||
}}
|
||||
progressUpdateInterval={500}
|
||||
playWhenInactive={true}
|
||||
allowsExternalPlayback={true}
|
||||
playInBackground={true}
|
||||
pictureInPicture={true}
|
||||
showNotificationControls={true}
|
||||
ignoreSilentSwitch="ignore"
|
||||
fullscreen={false}
|
||||
onPlaybackStateChanged={(state) => {
|
||||
setIsPlaying(state.isPlaying);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
<Controls
|
||||
item={item}
|
||||
videoRef={videoRef}
|
||||
togglePlay={togglePlay}
|
||||
isPlaying={isPlaying}
|
||||
isSeeking={isSeeking}
|
||||
progress={progress}
|
||||
cacheProgress={cacheProgress}
|
||||
isBuffering={isBuffering}
|
||||
showControls={showControls}
|
||||
setShowControls={setShowControls}
|
||||
setIgnoreSafeAreas={setIgnoreSafeAreas}
|
||||
ignoreSafeAreas={ignoreSafeAreas}
|
||||
enableTrickplay={false}
|
||||
pause={pause}
|
||||
play={play}
|
||||
seek={seek}
|
||||
isVlc={false}
|
||||
stop={stop}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePoster(
|
||||
item: BaseItemDto | null | undefined,
|
||||
api: Api | null
|
||||
): string | undefined {
|
||||
const poster = useMemo(() => {
|
||||
if (!item || !api) return undefined;
|
||||
return item.Type === "Audio"
|
||||
? `${api.basePath}/Items/${item.AlbumId}/Images/Primary?tag=${item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
|
||||
: getBackdropUrl({
|
||||
api,
|
||||
item: item,
|
||||
quality: 70,
|
||||
width: 200,
|
||||
});
|
||||
}, [item, api]);
|
||||
|
||||
return poster ?? undefined;
|
||||
}
|
||||
|
||||
export function useVideoSource(
|
||||
item: BaseItemDto | null | undefined,
|
||||
api: Api | null,
|
||||
poster: string | undefined,
|
||||
url?: string | null
|
||||
) {
|
||||
const videoSource = useMemo(() => {
|
||||
if (!item || !api || !url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startPosition = item?.UserData?.PlaybackPositionTicks
|
||||
? Math.round(item.UserData.PlaybackPositionTicks / 10000)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
uri: url,
|
||||
isNetwork: true,
|
||||
startPosition,
|
||||
headers: getAuthHeaders(api),
|
||||
metadata: {
|
||||
artist: item?.AlbumArtist ?? undefined,
|
||||
title: item?.Name || "Unknown",
|
||||
description: item?.Overview ?? undefined,
|
||||
imageUri: poster,
|
||||
subtitle: item?.Album ?? undefined,
|
||||
},
|
||||
};
|
||||
}, [item, api, poster]);
|
||||
|
||||
return videoSource;
|
||||
}
|
||||
@@ -1,551 +0,0 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { Controls } from "@/components/video-player/controls/Controls";
|
||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||
import { TrackInfo } from "@/modules/vlc-player";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import transcoding from "@/utils/profiles/transcoding";
|
||||
import { secondsToTicks } from "@/utils/secondsToTicks";
|
||||
import { Api } from "@jellyfin/sdk";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import {
|
||||
getPlaystateApi,
|
||||
getUserLibraryApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useFocusEffect, useLocalSearchParams } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { View } from "react-native";
|
||||
import { useSharedValue } from "react-native-reanimated";
|
||||
import Video, {
|
||||
OnProgressData,
|
||||
SelectedTrack,
|
||||
SelectedTrackType,
|
||||
VideoRef,
|
||||
} from "react-native-video";
|
||||
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
||||
|
||||
const Player = () => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const [settings] = useSettings();
|
||||
const videoRef = useRef<VideoRef | null>(null);
|
||||
|
||||
const firstTime = useRef(true);
|
||||
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
||||
|
||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
||||
const [showControls, _setShowControls] = useState(true);
|
||||
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isBuffering, setIsBuffering] = useState(true);
|
||||
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
||||
|
||||
const setShowControls = useCallback((show: boolean) => {
|
||||
_setShowControls(show);
|
||||
}, []);
|
||||
|
||||
const progress = useSharedValue(0);
|
||||
const isSeeking = useSharedValue(false);
|
||||
const cacheProgress = useSharedValue(0);
|
||||
|
||||
const {
|
||||
itemId,
|
||||
audioIndex: audioIndexStr,
|
||||
subtitleIndex: subtitleIndexStr,
|
||||
mediaSourceId,
|
||||
bitrateValue: bitrateValueStr,
|
||||
} = useLocalSearchParams<{
|
||||
itemId: string;
|
||||
audioIndex: string;
|
||||
subtitleIndex: string;
|
||||
mediaSourceId: string;
|
||||
bitrateValue: string;
|
||||
}>();
|
||||
|
||||
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
|
||||
const subtitleIndex = subtitleIndexStr
|
||||
? parseInt(subtitleIndexStr, 10)
|
||||
: undefined;
|
||||
const bitrateValue = bitrateValueStr
|
||||
? parseInt(bitrateValueStr, 10)
|
||||
: undefined;
|
||||
|
||||
const {
|
||||
data: item,
|
||||
isLoading: isLoadingItem,
|
||||
isError: isErrorItem,
|
||||
} = useQuery({
|
||||
queryKey: ["item", itemId],
|
||||
queryFn: async () => {
|
||||
if (!api) {
|
||||
throw new Error("No api");
|
||||
}
|
||||
|
||||
if (!itemId) {
|
||||
console.warn("No itemId");
|
||||
return null;
|
||||
}
|
||||
|
||||
const res = await getUserLibraryApi(api).getItem({
|
||||
itemId,
|
||||
userId: user?.Id,
|
||||
});
|
||||
|
||||
return res.data;
|
||||
},
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
// TODO: NEED TO FIND A WAY TO FROM SWITCHING TO IMAGE BASED TO TEXT BASED SUBTITLES, THERE IS A BUG.
|
||||
// MOST LIKELY LIKELY NEED A MASSIVE REFACTOR.
|
||||
const {
|
||||
data: stream,
|
||||
isLoading: isLoadingStreamUrl,
|
||||
isError: isErrorStreamUrl,
|
||||
} = useQuery({
|
||||
queryKey: ["stream-url", itemId, bitrateValue, mediaSourceId, audioIndex],
|
||||
|
||||
queryFn: async () => {
|
||||
if (!api) {
|
||||
throw new Error("No api");
|
||||
}
|
||||
|
||||
if (!item) {
|
||||
console.warn("No item", itemId, item);
|
||||
return null;
|
||||
}
|
||||
|
||||
const res = await getStreamUrl({
|
||||
api,
|
||||
item,
|
||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
||||
userId: user?.Id,
|
||||
audioStreamIndex: audioIndex,
|
||||
maxStreamingBitrate: bitrateValue,
|
||||
mediaSourceId: mediaSourceId,
|
||||
subtitleStreamIndex: subtitleIndex,
|
||||
deviceProfile: transcoding,
|
||||
});
|
||||
|
||||
if (!res) return null;
|
||||
|
||||
const { mediaSource, sessionId, url } = res;
|
||||
|
||||
if (!sessionId || !mediaSource || !url) {
|
||||
console.warn("No sessionId or mediaSource or url", url);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
mediaSource,
|
||||
sessionId,
|
||||
url,
|
||||
};
|
||||
},
|
||||
enabled: !!item,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const poster = usePoster(item, api);
|
||||
const videoSource = useVideoSource(item, api, poster, stream?.url);
|
||||
|
||||
const togglePlay = useCallback(async () => {
|
||||
if (isPlaying) {
|
||||
videoRef.current?.pause();
|
||||
await getPlaystateApi(api!).onPlaybackProgress({
|
||||
itemId: item?.Id!,
|
||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||
mediaSourceId: mediaSourceId,
|
||||
positionTicks: Math.floor(progress.value),
|
||||
isPaused: true,
|
||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||
playSessionId: stream?.sessionId,
|
||||
});
|
||||
} else {
|
||||
videoRef.current?.resume();
|
||||
await getPlaystateApi(api!).onPlaybackProgress({
|
||||
itemId: item?.Id!,
|
||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||
mediaSourceId: mediaSourceId,
|
||||
positionTicks: Math.floor(progress.value),
|
||||
isPaused: false,
|
||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||
playSessionId: stream?.sessionId,
|
||||
});
|
||||
}
|
||||
}, [
|
||||
isPlaying,
|
||||
api,
|
||||
item,
|
||||
videoRef,
|
||||
settings,
|
||||
stream,
|
||||
audioIndex,
|
||||
subtitleIndex,
|
||||
mediaSourceId,
|
||||
]);
|
||||
|
||||
const play = useCallback(() => {
|
||||
videoRef.current?.resume();
|
||||
reportPlaybackStart();
|
||||
}, [videoRef]);
|
||||
|
||||
const pause = useCallback(() => {
|
||||
videoRef.current?.pause();
|
||||
}, [videoRef]);
|
||||
|
||||
const seek = useCallback(
|
||||
(seconds: number) => {
|
||||
videoRef.current?.seek(seconds);
|
||||
},
|
||||
[videoRef]
|
||||
);
|
||||
|
||||
const reportPlaybackStopped = async () => {
|
||||
if (!item?.Id) return;
|
||||
await getPlaystateApi(api!).onPlaybackStopped({
|
||||
itemId: item.Id,
|
||||
mediaSourceId: mediaSourceId,
|
||||
positionTicks: Math.floor(progress.value),
|
||||
playSessionId: stream?.sessionId,
|
||||
});
|
||||
revalidateProgressCache();
|
||||
};
|
||||
|
||||
const stop = useCallback(() => {
|
||||
reportPlaybackStopped();
|
||||
videoRef.current?.pause();
|
||||
setIsPlaybackStopped(true);
|
||||
}, [videoRef, reportPlaybackStopped]);
|
||||
|
||||
const reportPlaybackStart = async () => {
|
||||
if (!item?.Id) return;
|
||||
await getPlaystateApi(api!).onPlaybackStart({
|
||||
itemId: item.Id,
|
||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||
mediaSourceId: mediaSourceId,
|
||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||
playSessionId: stream?.sessionId,
|
||||
});
|
||||
};
|
||||
|
||||
const onProgress = useCallback(
|
||||
async (data: OnProgressData) => {
|
||||
if (isSeeking.value === true) return;
|
||||
if (isPlaybackStopped === true) return;
|
||||
|
||||
const ticks = secondsToTicks(data.currentTime);
|
||||
|
||||
progress.value = ticks;
|
||||
cacheProgress.value = secondsToTicks(data.playableDuration);
|
||||
|
||||
console.log(
|
||||
"onProgress ~",
|
||||
ticks,
|
||||
isPlaying,
|
||||
`AUDIO index: ${audioIndex} SUB index" ${subtitleIndex}`
|
||||
);
|
||||
|
||||
// TODO: Use this when streaming with HLS url, but NOT when direct playing
|
||||
// TODO: since playable duration is always 0 then.
|
||||
setIsBuffering(data.playableDuration === 0);
|
||||
|
||||
if (!item?.Id || data.currentTime === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await getPlaystateApi(api!).onPlaybackProgress({
|
||||
itemId: item.Id,
|
||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||
mediaSourceId: mediaSourceId,
|
||||
positionTicks: Math.round(ticks),
|
||||
isPaused: !isPlaying,
|
||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||
playSessionId: stream?.sessionId,
|
||||
});
|
||||
},
|
||||
[
|
||||
item,
|
||||
isPlaying,
|
||||
api,
|
||||
isPlaybackStopped,
|
||||
isSeeking,
|
||||
stream,
|
||||
mediaSourceId,
|
||||
audioIndex,
|
||||
subtitleIndex,
|
||||
]
|
||||
);
|
||||
|
||||
useWebSocket({
|
||||
isPlaying: isPlaying,
|
||||
togglePlay: togglePlay,
|
||||
stopPlayback: stop,
|
||||
});
|
||||
|
||||
const [selectedTextTrack, setSelectedTextTrack] = useState<
|
||||
SelectedTrack | undefined
|
||||
>();
|
||||
|
||||
const [embededTextTracks, setEmbededTextTracks] = useState<
|
||||
{
|
||||
index: number;
|
||||
language?: string | undefined;
|
||||
selected?: boolean | undefined;
|
||||
title?: string | undefined;
|
||||
type: any;
|
||||
}[]
|
||||
>([]);
|
||||
|
||||
const [audioTracks, setAudioTracks] = useState<TrackInfo[]>([]);
|
||||
const [selectedAudioTrack, setSelectedAudioTrack] = useState<
|
||||
SelectedTrack | undefined
|
||||
>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTextTrack === undefined) {
|
||||
const subtitleHelper = new SubtitleHelper(
|
||||
stream?.mediaSource.MediaStreams ?? []
|
||||
);
|
||||
const embeddedTrackIndex = subtitleHelper.getEmbeddedTrackIndex(
|
||||
subtitleIndex!
|
||||
);
|
||||
|
||||
// Most likely the subtitle is burned in.
|
||||
if (embeddedTrackIndex === -1) return;
|
||||
console.log(
|
||||
"Setting selected text track",
|
||||
subtitleIndex,
|
||||
embeddedTrackIndex
|
||||
);
|
||||
setSelectedTextTrack({
|
||||
type: SelectedTrackType.INDEX,
|
||||
value: embeddedTrackIndex,
|
||||
});
|
||||
}
|
||||
}, [embededTextTracks]);
|
||||
|
||||
const getAudioTracks = (): TrackInfo[] => {
|
||||
return audioTracks.map((t) => ({
|
||||
name: t.name,
|
||||
index: t.index,
|
||||
}));
|
||||
};
|
||||
|
||||
const getSubtitleTracks = (): TrackInfo[] => {
|
||||
return embededTextTracks.map((t) => ({
|
||||
name: t.title ?? "",
|
||||
index: t.index,
|
||||
language: t.language,
|
||||
}));
|
||||
};
|
||||
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
return async () => {
|
||||
stop();
|
||||
};
|
||||
}, [])
|
||||
);
|
||||
|
||||
if (isLoadingItem || isLoadingStreamUrl)
|
||||
return (
|
||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
|
||||
if (isErrorItem || isErrorStreamUrl)
|
||||
return (
|
||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
||||
<Text className="text-white">Error</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: "black" }}>
|
||||
<View
|
||||
style={{
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
position: "relative",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
opacity: showControls ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{videoSource ? (
|
||||
<>
|
||||
<Video
|
||||
ref={videoRef}
|
||||
source={videoSource}
|
||||
style={{
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
}}
|
||||
resizeMode={ignoreSafeAreas ? "cover" : "contain"}
|
||||
onProgress={onProgress}
|
||||
onError={(e) => {
|
||||
console.error("Error playing video", e);
|
||||
}}
|
||||
onLoad={() => {
|
||||
if (firstTime.current === true) {
|
||||
play();
|
||||
firstTime.current = false;
|
||||
}
|
||||
}}
|
||||
progressUpdateInterval={500}
|
||||
playWhenInactive={true}
|
||||
allowsExternalPlayback={true}
|
||||
playInBackground={true}
|
||||
pictureInPicture={true}
|
||||
showNotificationControls={true}
|
||||
ignoreSilentSwitch="ignore"
|
||||
fullscreen={false}
|
||||
onPlaybackStateChanged={(state) => {
|
||||
if (isSeeking.value === false) setIsPlaying(state.isPlaying);
|
||||
}}
|
||||
onTextTracks={(data) => {
|
||||
setEmbededTextTracks(data.textTracks as any);
|
||||
}}
|
||||
onBuffer={(e) => {
|
||||
setIsBuffering(e.isBuffering);
|
||||
}}
|
||||
onAudioTracks={(e) => {
|
||||
console.log("onAudioTracks: ", e.audioTracks);
|
||||
setAudioTracks(
|
||||
e.audioTracks.map((t) => ({
|
||||
index: t.index,
|
||||
name: t.title ?? "",
|
||||
language: t.language,
|
||||
}))
|
||||
);
|
||||
}}
|
||||
selectedTextTrack={selectedTextTrack}
|
||||
selectedAudioTrack={selectedAudioTrack}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Text>No video source...</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{item && (
|
||||
<Controls
|
||||
mediaSource={stream?.mediaSource}
|
||||
videoRef={videoRef}
|
||||
enableTrickplay={true}
|
||||
item={item}
|
||||
togglePlay={togglePlay}
|
||||
isPlaying={isPlaying}
|
||||
isSeeking={isSeeking}
|
||||
progress={progress}
|
||||
cacheProgress={cacheProgress}
|
||||
isBuffering={isBuffering}
|
||||
showControls={showControls}
|
||||
setShowControls={setShowControls}
|
||||
setIgnoreSafeAreas={setIgnoreSafeAreas}
|
||||
ignoreSafeAreas={ignoreSafeAreas}
|
||||
seek={seek}
|
||||
play={play}
|
||||
pause={pause}
|
||||
stop={stop}
|
||||
getSubtitleTracks={getSubtitleTracks}
|
||||
setSubtitleTrack={(i) => {
|
||||
if (i === -1) {
|
||||
setSelectedTextTrack({
|
||||
type: SelectedTrackType.DISABLED,
|
||||
value: undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setSelectedTextTrack({
|
||||
type: SelectedTrackType.INDEX,
|
||||
value: i,
|
||||
});
|
||||
}}
|
||||
getAudioTracks={getAudioTracks}
|
||||
setAudioTrack={(i) => {
|
||||
console.log("setAudioTrack ~", i);
|
||||
setSelectedAudioTrack({
|
||||
type: SelectedTrackType.INDEX,
|
||||
value: i,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export function usePoster(
|
||||
item: BaseItemDto | null | undefined,
|
||||
api: Api | null
|
||||
): string | undefined {
|
||||
const poster = useMemo(() => {
|
||||
if (!item || !api) return undefined;
|
||||
return item.Type === "Audio"
|
||||
? `${api.basePath}/Items/${item.AlbumId}/Images/Primary?tag=${item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
|
||||
: getBackdropUrl({
|
||||
api,
|
||||
item: item,
|
||||
quality: 70,
|
||||
width: 200,
|
||||
});
|
||||
}, [item, api]);
|
||||
|
||||
return poster ?? undefined;
|
||||
}
|
||||
|
||||
export function useVideoSource(
|
||||
item: BaseItemDto | null | undefined,
|
||||
api: Api | null,
|
||||
poster: string | undefined,
|
||||
url?: string | null
|
||||
) {
|
||||
const videoSource = useMemo(() => {
|
||||
if (!item || !api || !url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startPosition = item?.UserData?.PlaybackPositionTicks
|
||||
? Math.round(item.UserData.PlaybackPositionTicks / 10000)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
uri: url,
|
||||
isNetwork: true,
|
||||
startPosition,
|
||||
headers: getAuthHeaders(api),
|
||||
metadata: {
|
||||
artist: item?.AlbumArtist ?? undefined,
|
||||
title: item?.Name || "Unknown",
|
||||
description: item?.Overview ?? undefined,
|
||||
imageUri: poster,
|
||||
subtitle: item?.Album ?? undefined,
|
||||
},
|
||||
};
|
||||
}, [item, api, poster, url]);
|
||||
|
||||
return videoSource;
|
||||
}
|
||||
|
||||
export default Player;
|
||||
@@ -1,46 +0,0 @@
|
||||
import { useGlobalSearchParams } from "expo-router";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Alert, Dimensions, View } from "react-native";
|
||||
import YoutubePlayer, { PLAYER_STATES } from "react-native-youtube-iframe";
|
||||
|
||||
export default function page() {
|
||||
const searchParams = useGlobalSearchParams();
|
||||
console.log(searchParams);
|
||||
|
||||
const { url } = searchParams as { url: string };
|
||||
|
||||
const videoId = useMemo(() => {
|
||||
return url.split("v=")[1];
|
||||
}, [url]);
|
||||
|
||||
const [playing, setPlaying] = useState(false);
|
||||
|
||||
const onStateChange = useCallback((state: PLAYER_STATES) => {
|
||||
if (state === "ended") {
|
||||
setPlaying(false);
|
||||
Alert.alert("video has finished playing!");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const togglePlaying = useCallback(() => {
|
||||
setPlaying((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
togglePlaying();
|
||||
}, []);
|
||||
|
||||
const screenWidth = Dimensions.get("screen").width;
|
||||
|
||||
return (
|
||||
<View className="flex flex-col bg-black items-center justify-center h-full">
|
||||
<YoutubePlayer
|
||||
height={300}
|
||||
play={playing}
|
||||
videoId={videoId}
|
||||
onChangeState={onStateChange}
|
||||
width={screenWidth}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
378
app/_layout.tsx
378
app/_layout.tsx
@@ -1,26 +1,225 @@
|
||||
import "@/augmentations";
|
||||
import { JellyfinProvider } from "@/providers/JellyfinProvider";
|
||||
import { DownloadProvider } from "@/providers/DownloadProvider";
|
||||
import {
|
||||
getOrSetDeviceId,
|
||||
getTokenFromStoraage,
|
||||
JellyfinProvider,
|
||||
} from "@/providers/JellyfinProvider";
|
||||
import { JobQueueProvider } from "@/providers/JobQueueProvider";
|
||||
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
||||
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
||||
import { LogProvider } from "@/utils/log";
|
||||
import { orientationAtom } from "@/utils/atoms/orientation";
|
||||
import { Settings, useSettings } from "@/utils/atoms/settings";
|
||||
import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server";
|
||||
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import {
|
||||
checkForExistingDownloads,
|
||||
completeHandler,
|
||||
download,
|
||||
} from "@kesha-antonov/react-native-background-downloader";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import * as BackgroundFetch from "expo-background-fetch";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import { useFonts } from "expo-font";
|
||||
import { useKeepAwake } from "expo-keep-awake";
|
||||
import { Stack } from "expo-router";
|
||||
import * as Linking from "expo-linking";
|
||||
import * as Notifications from "expo-notifications";
|
||||
import { router, Stack } from "expo-router";
|
||||
import * as ScreenOrientation from "expo-screen-orientation";
|
||||
import * as SplashScreen from "expo-splash-screen";
|
||||
import { Provider as JotaiProvider } from "jotai";
|
||||
import { useEffect } from "react";
|
||||
import { Appearance } from "react-native";
|
||||
import { SystemBars } from "react-native-edge-to-edge";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import * as TaskManager from "expo-task-manager";
|
||||
import { Provider as JotaiProvider, useAtom } from "jotai";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { AppState } from "react-native";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import "react-native-reanimated";
|
||||
import { Toaster } from "sonner-native";
|
||||
|
||||
SplashScreen.preventAutoHideAsync();
|
||||
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
shouldShowAlert: true,
|
||||
shouldPlaySound: true,
|
||||
shouldSetBadge: false,
|
||||
}),
|
||||
});
|
||||
|
||||
function useNotificationObserver() {
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
function redirect(notification: Notifications.Notification) {
|
||||
const url = notification.request.content.data?.url;
|
||||
if (url) {
|
||||
router.push(url);
|
||||
}
|
||||
}
|
||||
|
||||
Notifications.getLastNotificationResponseAsync().then((response) => {
|
||||
if (!isMounted || !response?.notification) {
|
||||
return;
|
||||
}
|
||||
redirect(response?.notification);
|
||||
});
|
||||
|
||||
const subscription = Notifications.addNotificationResponseReceivedListener(
|
||||
(response) => {
|
||||
redirect(response.notification);
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
subscription.remove();
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
|
||||
console.log("TaskManager ~ trigger");
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
const settingsData = await AsyncStorage.getItem("settings");
|
||||
|
||||
if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||
|
||||
const settings: Partial<Settings> = JSON.parse(settingsData);
|
||||
const url = settings?.optimizedVersionsServerUrl;
|
||||
|
||||
if (!settings?.autoDownload || !url)
|
||||
return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||
|
||||
const token = await getTokenFromStoraage();
|
||||
const deviceId = await getOrSetDeviceId();
|
||||
const baseDirectory = FileSystem.documentDirectory;
|
||||
|
||||
if (!token || !deviceId || !baseDirectory)
|
||||
return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||
|
||||
console.log({
|
||||
token,
|
||||
url,
|
||||
deviceId,
|
||||
});
|
||||
|
||||
const jobs = await getAllJobsByDeviceId({
|
||||
deviceId,
|
||||
authHeader: token,
|
||||
url,
|
||||
});
|
||||
|
||||
console.log("TaskManager ~ Active jobs: ", jobs.length);
|
||||
|
||||
for (let job of jobs) {
|
||||
if (job.status === "completed") {
|
||||
const downloadUrl = url + "download/" + job.id;
|
||||
console.log({
|
||||
token,
|
||||
deviceId,
|
||||
baseDirectory,
|
||||
url,
|
||||
downloadUrl,
|
||||
});
|
||||
|
||||
const tasks = await checkForExistingDownloads();
|
||||
|
||||
if (tasks.find((task) => task.id === job.id)) {
|
||||
console.log("TaskManager ~ Download already in progress: ", job.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
download({
|
||||
id: job.id,
|
||||
url: url + "download/" + job.id,
|
||||
destination: `${baseDirectory}${job.item.Id}.mp4`,
|
||||
headers: {
|
||||
Authorization: token,
|
||||
},
|
||||
})
|
||||
.begin(() => {
|
||||
console.log("TaskManager ~ Download started: ", job.id);
|
||||
})
|
||||
.done(() => {
|
||||
console.log("TaskManager ~ Download completed: ", job.id);
|
||||
saveDownloadedItemInfo(job.item);
|
||||
completeHandler(job.id);
|
||||
cancelJobById({
|
||||
authHeader: token,
|
||||
id: job.id,
|
||||
url: url,
|
||||
});
|
||||
Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: job.item.Name,
|
||||
body: "Download completed",
|
||||
data: {
|
||||
url: `/downloads`,
|
||||
},
|
||||
},
|
||||
trigger: null,
|
||||
});
|
||||
})
|
||||
.error((error) => {
|
||||
console.log("TaskManager ~ Download error: ", job.id, error);
|
||||
completeHandler(job.id);
|
||||
Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: job.item.Name,
|
||||
body: "Download failed",
|
||||
data: {
|
||||
url: `/downloads`,
|
||||
},
|
||||
},
|
||||
trigger: null,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Auto download started: ${new Date(now).toISOString()}`);
|
||||
|
||||
// Be sure to return the successful result type!
|
||||
return BackgroundFetch.BackgroundFetchResult.NewData;
|
||||
});
|
||||
|
||||
const checkAndRequestPermissions = async () => {
|
||||
try {
|
||||
const hasAskedBefore = await AsyncStorage.getItem(
|
||||
"hasAskedForNotificationPermission"
|
||||
);
|
||||
|
||||
if (hasAskedBefore !== "true") {
|
||||
const { status } = await Notifications.requestPermissionsAsync();
|
||||
|
||||
if (status === "granted") {
|
||||
writeToLog("INFO", "Notification permissions granted.");
|
||||
console.log("Notification permissions granted.");
|
||||
} else {
|
||||
writeToLog("ERROR", "Notification permissions denied.");
|
||||
console.log("Notification permissions denied.");
|
||||
}
|
||||
|
||||
await AsyncStorage.setItem("hasAskedForNotificationPermission", "true");
|
||||
} else {
|
||||
console.log("Already asked for notification permissions before.");
|
||||
}
|
||||
} catch (error) {
|
||||
writeToLog(
|
||||
"ERROR",
|
||||
"Error checking/requesting notification permissions:",
|
||||
error
|
||||
);
|
||||
console.error("Error checking/requesting notification permissions:", error);
|
||||
}
|
||||
};
|
||||
|
||||
export default function RootLayout() {
|
||||
const [loaded] = useFonts({
|
||||
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
|
||||
@@ -32,8 +231,6 @@ export default function RootLayout() {
|
||||
}
|
||||
}, [loaded]);
|
||||
|
||||
Appearance.setColorScheme("dark");
|
||||
|
||||
if (!loaded) {
|
||||
return null;
|
||||
}
|
||||
@@ -45,30 +242,91 @@ export default function RootLayout() {
|
||||
);
|
||||
}
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 0,
|
||||
refetchOnMount: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchOnWindowFocus: true,
|
||||
retryOnMount: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function Layout() {
|
||||
const [settings, updateSettings] = useSettings();
|
||||
const [orientation, setOrientation] = useAtom(orientationAtom);
|
||||
|
||||
useKeepAwake();
|
||||
useNotificationObserver();
|
||||
|
||||
const queryClientRef = useRef<QueryClient>(
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 0,
|
||||
refetchOnMount: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchOnWindowFocus: true,
|
||||
retryOnMount: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
checkAndRequestPermissions();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (settings?.autoRotate === true)
|
||||
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.DEFAULT);
|
||||
else
|
||||
ScreenOrientation.lockAsync(
|
||||
ScreenOrientation.OrientationLock.PORTRAIT_UP
|
||||
);
|
||||
}, [settings]);
|
||||
|
||||
const appState = useRef(AppState.currentState);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = AppState.addEventListener("change", (nextAppState) => {
|
||||
if (
|
||||
appState.current.match(/inactive|background/) &&
|
||||
nextAppState === "active"
|
||||
) {
|
||||
checkForExistingDownloads();
|
||||
}
|
||||
});
|
||||
|
||||
checkForExistingDownloads();
|
||||
|
||||
return () => {
|
||||
subscription.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = ScreenOrientation.addOrientationChangeListener(
|
||||
(event) => {
|
||||
setOrientation(event.orientationInfo.orientation);
|
||||
}
|
||||
);
|
||||
|
||||
ScreenOrientation.getOrientationAsync().then((initialOrientation) => {
|
||||
setOrientation(initialOrientation);
|
||||
});
|
||||
|
||||
return () => {
|
||||
ScreenOrientation.removeOrientationChangeListener(subscription);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const url = Linking.useURL();
|
||||
|
||||
if (url) {
|
||||
const { hostname, path, queryParams } = Linking.parse(url);
|
||||
}
|
||||
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<QueryClientProvider client={queryClientRef.current}>
|
||||
<ActionSheetProvider>
|
||||
<JellyfinProvider>
|
||||
<PlaySettingsProvider>
|
||||
<LogProvider>
|
||||
<WebSocketProvider>
|
||||
<JobQueueProvider>
|
||||
<JellyfinProvider>
|
||||
<PlaySettingsProvider>
|
||||
<DownloadProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<SystemBars style="light" hidden={false} />
|
||||
<StatusBar style="light" backgroundColor="#000" />
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<Stack initialRouteName="/home">
|
||||
<Stack.Screen
|
||||
@@ -76,32 +334,45 @@ function Layout() {
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
header: () => null,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/player"
|
||||
name="(auth)/play-video"
|
||||
options={{
|
||||
headerShown: false,
|
||||
autoHideHomeIndicator: true,
|
||||
title: "",
|
||||
header: () => null,
|
||||
animation: "fade",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/trailer/page"
|
||||
name="logs"
|
||||
options={{
|
||||
headerShown: false,
|
||||
presentation: "modal",
|
||||
title: "Logs",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/play-offline-video"
|
||||
options={{
|
||||
headerShown: false,
|
||||
autoHideHomeIndicator: true,
|
||||
title: "",
|
||||
animation: "fade",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/play-music"
|
||||
options={{
|
||||
headerShown: false,
|
||||
autoHideHomeIndicator: true,
|
||||
title: "",
|
||||
animation: "fade",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="login"
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: "",
|
||||
headerTransparent: true,
|
||||
}}
|
||||
options={{ headerShown: false, title: "Login" }}
|
||||
/>
|
||||
<Stack.Screen name="+not-found" />
|
||||
</Stack>
|
||||
@@ -121,12 +392,33 @@ function Layout() {
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</WebSocketProvider>
|
||||
</LogProvider>
|
||||
</PlaySettingsProvider>
|
||||
</JellyfinProvider>
|
||||
</DownloadProvider>
|
||||
</PlaySettingsProvider>
|
||||
</JellyfinProvider>
|
||||
</JobQueueProvider>
|
||||
</ActionSheetProvider>
|
||||
</QueryClientProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
|
||||
async function saveDownloadedItemInfo(item: BaseItemDto) {
|
||||
try {
|
||||
const downloadedItems = await AsyncStorage.getItem("downloadedItems");
|
||||
let items: BaseItemDto[] = downloadedItems
|
||||
? JSON.parse(downloadedItems)
|
||||
: [];
|
||||
|
||||
const existingItemIndex = items.findIndex((i) => i.Id === item.Id);
|
||||
if (existingItemIndex !== -1) {
|
||||
items[existingItemIndex] = item;
|
||||
} else {
|
||||
items.push(item);
|
||||
}
|
||||
|
||||
await AsyncStorage.setItem("downloadedItems", JSON.stringify(items));
|
||||
} catch (error) {
|
||||
writeToLog("ERROR", "Failed to save downloaded item information:", error);
|
||||
console.error("Failed to save downloaded item information:", error);
|
||||
}
|
||||
}
|
||||
|
||||
172
app/login.tsx
172
app/login.tsx
@@ -2,10 +2,12 @@ import { Button } from "@/components/Button";
|
||||
import { Input } from "@/components/common/Input";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getSystemApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
@@ -13,7 +15,6 @@ import {
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
SafeAreaView,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
|
||||
@@ -27,6 +28,7 @@ const Login: React.FC = () => {
|
||||
const { setServer, login, removeServer, initiateQuickConnect } =
|
||||
useJellyfin();
|
||||
const [api] = useAtom(apiAtom);
|
||||
const router = useRouter();
|
||||
const params = useLocalSearchParams();
|
||||
|
||||
const {
|
||||
@@ -65,23 +67,6 @@ const Login: React.FC = () => {
|
||||
})();
|
||||
}, [_apiUrl, _username, _password]);
|
||||
|
||||
const navigation = useNavigation();
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerTitle: serverName,
|
||||
headerLeft: () =>
|
||||
api?.basePath ? (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
removeServer();
|
||||
}}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
) : null,
|
||||
});
|
||||
}, [serverName, navigation, api?.basePath]);
|
||||
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const handleLogin = async () => {
|
||||
@@ -89,7 +74,17 @@ const Login: React.FC = () => {
|
||||
try {
|
||||
const result = CredentialsSchema.safeParse(credentials);
|
||||
if (result.success) {
|
||||
await login(credentials.username, credentials.password);
|
||||
try {
|
||||
await login(credentials.username, credentials.password);
|
||||
} catch (loginError) {
|
||||
if (loginError instanceof Error) {
|
||||
setError(loginError.message);
|
||||
} else {
|
||||
setError("An unexpected error occurred during login");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setError("Invalid credentials format");
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
@@ -120,21 +115,74 @@ const Login: React.FC = () => {
|
||||
* - Logs errors and timeout information to the console.
|
||||
*/
|
||||
async function checkUrl(url: string) {
|
||||
url = url.endsWith("/") ? url.slice(0, -1) : url;
|
||||
setLoadingServerCheck(true);
|
||||
writeToLog("INFO", `Checking URL: ${url}`);
|
||||
|
||||
const timeout = 5000; // 5 seconds timeout
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${url}/System/Info/Public`, {
|
||||
mode: "cors",
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = (await response.json()) as PublicSystemInfo;
|
||||
setServerName(data.ServerName || "");
|
||||
return url;
|
||||
// Try HTTPS first
|
||||
const httpsUrl = `https://${url}/System/Info/Public`;
|
||||
try {
|
||||
const response = await fetch(httpsUrl, {
|
||||
mode: "cors",
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = (await response.json()) as PublicSystemInfo;
|
||||
setServerName(data.ServerName || "");
|
||||
return `https://${url}`;
|
||||
} else {
|
||||
writeToLog(
|
||||
"WARN",
|
||||
`HTTPS connection failed with status: ${response.status}`
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
writeToLog("WARN", "HTTPS connection failed - trying HTTP", e);
|
||||
}
|
||||
|
||||
// If HTTPS didn't work, try HTTP
|
||||
const httpUrl = `http://${url}/System/Info/Public`;
|
||||
try {
|
||||
const response = await fetch(httpUrl, {
|
||||
mode: "cors",
|
||||
signal: controller.signal,
|
||||
});
|
||||
writeToLog("INFO", `HTTP response status: ${response.status}`);
|
||||
if (response.ok) {
|
||||
const data = (await response.json()) as PublicSystemInfo;
|
||||
setServerName(data.ServerName || "");
|
||||
return `http://${url}`;
|
||||
} else {
|
||||
writeToLog(
|
||||
"WARN",
|
||||
`HTTP connection failed with status: ${response.status}`
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
writeToLog("ERROR", "HTTP connection failed", e);
|
||||
}
|
||||
|
||||
// If neither worked, return undefined
|
||||
writeToLog(
|
||||
"ERROR",
|
||||
`Failed to connect to ${url} using both HTTPS and HTTP`
|
||||
);
|
||||
return undefined;
|
||||
} catch (e) {
|
||||
const error = e as Error;
|
||||
if (error.name === "AbortError") {
|
||||
writeToLog("ERROR", `Request to ${url} timed out`, error);
|
||||
} else {
|
||||
writeToLog("ERROR", `Unexpected error checking ${url}`, error);
|
||||
}
|
||||
return undefined;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
setLoadingServerCheck(false);
|
||||
}
|
||||
}
|
||||
@@ -158,7 +206,9 @@ const Login: React.FC = () => {
|
||||
const handleConnect = async (url: string) => {
|
||||
url = url.trim();
|
||||
|
||||
const result = await checkUrl(url);
|
||||
const result = await checkUrl(
|
||||
url.startsWith("http") ? new URL(url).host : url
|
||||
);
|
||||
|
||||
if (result === undefined) {
|
||||
Alert.alert(
|
||||
@@ -168,7 +218,7 @@ const Login: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
setServer({ address: url });
|
||||
setServer({ address: result });
|
||||
};
|
||||
|
||||
const handleQuickConnect = async () => {
|
||||
@@ -193,21 +243,48 @@ const Login: React.FC = () => {
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
style={{ flex: 1, height: "100%" }}
|
||||
>
|
||||
<View className="flex flex-col h-full relative items-center justify-center">
|
||||
<View className="px-4 -mt-20 w-full">
|
||||
<View className="flex flex-col space-y-2">
|
||||
<Text className="text-2xl font-bold -mb-2">
|
||||
Log in
|
||||
<>
|
||||
{serverName ? (
|
||||
<>
|
||||
{" to "}
|
||||
<Text className="text-purple-600">{serverName}</Text>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
<View className="flex flex-col w-full h-full relative items-center justify-center">
|
||||
<View className="absolute top-4 right-4">
|
||||
<Ionicons
|
||||
name="file-tray-full-outline"
|
||||
size={22}
|
||||
color="white"
|
||||
onPress={() => {
|
||||
router.push("/logs");
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
<View className="px-4 -mt-20">
|
||||
<View className="mb-4">
|
||||
<Text className="text-3xl font-bold mb-1">
|
||||
{serverName || "Streamyfin"}
|
||||
</Text>
|
||||
<Text className="text-xs text-neutral-400">{serverURL}</Text>
|
||||
<View className="bg-neutral-900 rounded-xl p-4 mb-2 flex flex-row items-center justify-between">
|
||||
<Text className="">URL</Text>
|
||||
<Text numberOfLines={1} className="shrink">
|
||||
{api.basePath}
|
||||
</Text>
|
||||
</View>
|
||||
<Button
|
||||
color="black"
|
||||
onPress={() => {
|
||||
removeServer();
|
||||
}}
|
||||
justify="between"
|
||||
iconLeft={
|
||||
<Ionicons
|
||||
name="arrow-back-outline"
|
||||
size={18}
|
||||
color={"white"}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Change server
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<View className="flex flex-col space-y-2">
|
||||
<Text className="text-2xl font-bold">Log in</Text>
|
||||
<Input
|
||||
placeholder="Username"
|
||||
onChangeText={(text) =>
|
||||
@@ -266,7 +343,7 @@ const Login: React.FC = () => {
|
||||
<SafeAreaView style={{ flex: 1 }}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
style={{ flex: 1, height: "100%" }}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<View className="flex flex-col h-full relative items-center justify-center w-full">
|
||||
<View className="flex flex-col gap-y-2 px-4 w-full -mt-36">
|
||||
@@ -281,7 +358,7 @@ const Login: React.FC = () => {
|
||||
/>
|
||||
<Text className="text-3xl font-bold">Streamyfin</Text>
|
||||
<Text className="text-neutral-500">
|
||||
Enter the URL to your Jellyfin server
|
||||
Connect to your Jellyfin server
|
||||
</Text>
|
||||
<Input
|
||||
placeholder="Server URL"
|
||||
@@ -293,9 +370,6 @@ const Login: React.FC = () => {
|
||||
textContentType="URL"
|
||||
maxLength={500}
|
||||
/>
|
||||
<Text className="text-xs text-neutral-500">
|
||||
Make sure to include http or https
|
||||
</Text>
|
||||
</View>
|
||||
<View className="mb-2 absolute bottom-0 left-0 w-full px-4">
|
||||
<Button
|
||||
|
||||
58
app/logs.tsx
Normal file
58
app/logs.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { readFromLog } from "@/utils/log";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
const Logs: React.FC = () => {
|
||||
const { data: logs } = useQuery({
|
||||
queryKey: ["logs"],
|
||||
queryFn: async () => (await readFromLog()).reverse(),
|
||||
refetchOnReconnect: true,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnMount: true,
|
||||
});
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
className="flex-1 p-4"
|
||||
contentContainerStyle={{ gap: 10, paddingBottom: insets.top }}
|
||||
>
|
||||
<View className="flex flex-col">
|
||||
{logs?.map((log, index) => (
|
||||
<View key={index} className="border-b-neutral-800 border py-3">
|
||||
<View className="flex flex-row justify-between items-center mb-2">
|
||||
<Text
|
||||
className={`
|
||||
text-xs
|
||||
${log.level === "INFO" && "text-blue-500"}
|
||||
${log.level === "ERROR" && "text-red-500"}
|
||||
`}
|
||||
>
|
||||
{log.level}
|
||||
</Text>
|
||||
<Text className="text-xs text-neutral-500">
|
||||
{new Date(log.timestamp).toLocaleString()}
|
||||
</Text>
|
||||
</View>
|
||||
<Text uiTextView selectable className="text-xs mb-1">
|
||||
{log.message}
|
||||
</Text>
|
||||
{log.data && (
|
||||
<Text uiTextView selectable className="text-xs">
|
||||
{log.data}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
{logs?.length === 0 && (
|
||||
<Text className="opacity-50">No logs available</Text>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
export default Logs;
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 112 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.3 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 20 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB |
@@ -1,65 +0,0 @@
|
||||
<svg
|
||||
type="certified"
|
||||
viewBox="0 0 80 80"
|
||||
preserveAspectRatio="xMidYMid"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
>
|
||||
<g transform="translate(2.29, 0)">
|
||||
<path
|
||||
d="M42.1942857,18.8022857 C44.3794286,18.608 49.1565714,18.7177143 51.4902857,21.0057143 C51.6297143,21.1451429 51.5085714,21.4605714 51.3097143,21.408 C47.8902857,20.4868571 42.5577143,25.0217143 39.1017143,22.0891429 C39.008,22.9485714 38.2331429,27.0857143 32.3314286,26.4731429 C32.192,26.4594286 32.1371429,26.304 32.24,26.2171429 C33.1542857,25.44 34.2765714,23.2891429 33.3142857,21.9154286 C30.3108571,23.9085714 28.7565714,23.9954286 23.2182857,21.5954286 C23.0377143,21.5177143 23.1451429,21.2228571 23.3577143,21.1748571 C24.5074286,20.9165714 27.2434286,19.9222857 29.696,19.4582857 C30.1645714,19.3691429 30.624,19.3165714 31.0674286,19.312 C28.528,18.7062857 27.4217143,18.1805714 25.7485714,18.1874286 C25.5657143,18.1897143 25.4742857,17.9611429 25.6068571,17.8354286 C28.224,15.3188571 32.9691429,15.1885714 35.2548571,17.0628571 L33.2068571,12.7862857 L35.696,12.4114286 C35.696,12.4114286 36.3451429,14.6925714 36.9257143,16.7428571 C39.5177143,13.904 43.5268571,14.192 44.8777143,16.672 C44.9577143,16.8182857 44.8251429,16.992 44.6605714,16.9622857 C43.3005714,16.7314286 42.3702857,17.8628571 42.1737143,18.7977143 L42.1942857,18.8022857"
|
||||
id="Fill-2"
|
||||
fill="#00912D"
|
||||
></path>
|
||||
<mask id="mask-2" fill="white">
|
||||
<polygon
|
||||
points="0.137142857 0.921142857 75.0534777 0.921142857 75.0534777 79.8628571 0.137142857 79.8628571"
|
||||
></polygon>
|
||||
</mask>
|
||||
<path
|
||||
d="M13.0491429,59.1817143 C9.90628571,55.3554286 7.86971429,50.576 7.51771429,44.9622857 C6.912,35.2342857 10.2354286,26.0845714 23.1794286,21.4834286 C23.1908571,21.5245714 23.1725714,21.5748571 23.2182857,21.5954286 C23.0377143,21.5177143 23.1451429,21.2228571 23.3577143,21.1748571 C24.5074286,20.9165714 27.2434286,19.92 29.696,19.4582857 C30.1645714,19.3691429 30.624,19.3165714 31.0674286,19.3097143 C28.528,18.7062857 27.4217143,18.1805714 25.7485714,18.1874286 C25.5657143,18.1897143 25.4742857,17.9611429 25.6068571,17.8331429 C28.224,15.3165714 32.9691429,15.1885714 35.2548571,17.0628571 L33.2068571,12.784 L35.696,12.4114286 C35.696,12.4114286 36.3451429,14.6902857 36.9257143,16.7428571 C39.5177143,13.904 43.5268571,14.192 44.8777143,16.672 C44.9577143,16.8182857 44.8251429,16.992 44.6605714,16.9622857 C43.3005714,16.7314286 42.3702857,17.8628571 42.1737143,18.7977143 L42.1942857,18.8022857 C44.3794286,18.608 49.1565714,18.7177143 51.4902857,21.0057143 C51.328,20.8502857 51.1337143,20.7245714 50.9508571,20.5874286 C60.2765714,23.504 66.7474286,30.1531429 67.44,41.2251429 C67.8811429,48.2948571 65.5702857,54.3885714 61.568,59.1154286 C62.784,59.2891429 63.9931429,59.4925714 65.2045714,59.6937143 C70.304,53.4537143 73.2502857,45.5428571 73.2502857,37.056 C73.2502857,17.7165714 57.5337143,2.56685714 37.472,2.56685714 C17.4102857,2.56685714 1.69371429,17.7165714 1.69371429,37.056 C1.69371429,45.5565714 4.64,53.472 9.744,59.7097143 C10.8434286,59.5268571 11.9451429,59.3462857 13.0491429,59.1817143"
|
||||
fill="#FFD700"
|
||||
mask="url(#mask-2)"
|
||||
></path>
|
||||
<path
|
||||
d="M9.744,59.7097143 C4.64,53.472 1.69371429,45.5565714 1.69371429,37.056 C1.69371429,17.7165714 17.4102857,2.56685714 37.472,2.56685714 C57.5337143,2.56685714 73.2502857,17.7165714 73.2502857,37.056 C73.2502857,45.5428571 70.304,53.4537143 65.2045714,59.6937143 C65.8125714,59.7942857 66.4205714,59.8742857 67.0285714,59.984 C71.9497143,53.6457143 74.8937143,45.6982857 74.8937143,37.056 C74.8937143,16.3862857 58.1394286,0.921142857 37.472,0.921142857 C16.8022857,0.921142857 0.048,16.3862857 0.048,37.056 C0.048,45.7074286 2.99885714,53.6594286 7.92914286,59.9977143 C8.53257143,59.8902857 9.13828571,59.8102857 9.744,59.7097143"
|
||||
fill="#FA6E0F"
|
||||
mask="url(#mask-2)"
|
||||
></path>
|
||||
<path
|
||||
d="M58.2857143,74.9394286 C62.3748571,75.1954286 65.7874286,77.2137143 67.8468571,79.9474286 C67.9131429,80.0182857 68.0114286,80.016 68.0411429,79.9382857 C68.7451429,77.0971429 68.9394286,74.0662857 68.5851429,71.0125714 C68.5874286,70.9805714 68.6125714,70.9577143 68.6537143,70.9485714 C70.576,70.3428571 72.7017143,70.0137143 74.9645714,70.0457143 C75.0857143,70.0594286 75.0834286,69.9405714 74.9554286,69.8194286 C72.5577143,67.4994286 69.6297143,65.6914286 66.416,64.5417143 C65.3051429,67.68 64.2217143,70.816 63.1565714,73.9634286 C63.136,74.0228571 63.0514286,74.0594286 62.9645714,74.0434286 L58.2857143,74.9394286"
|
||||
fill="#0AC855"
|
||||
mask="url(#mask-2)"
|
||||
></path>
|
||||
<path
|
||||
d="M62.9645714,74.0434286 L58.2857143,74.9394286 C58.2857143,74.9394286 58.3451429,74.512 58.528,73.3325714 C60.9417143,73.6754286 62.9645714,74.0434286 62.9645714,74.0434286"
|
||||
fill="#0B4902"
|
||||
></path>
|
||||
<g transform="translate(0, 20.57)">
|
||||
<mask id="mask-4" fill="white">
|
||||
<polygon
|
||||
points="0.137142857 0.016 67.4935952 0.016 67.4935952 59.2914286 0.137142857 59.2914286"
|
||||
></polygon>
|
||||
</mask>
|
||||
<path
|
||||
d="M13.0765714,38.6057143 C29.1177143,36.2605714 45.5222857,36.2354286 61.568,38.544 C65.5702857,33.8171429 67.8811429,27.7234286 67.44,20.6537143 C66.7474286,9.58171429 60.2765714,2.93257143 50.9508571,0.016 C51.1337143,0.153142857 51.328,0.278857143 51.4902857,0.434285714 C51.6297143,0.573714286 51.5085714,0.889142857 51.3097143,0.836571429 C47.8902857,-0.0845714286 42.5577143,4.45028571 39.1017143,1.51771429 C39.008,2.37485714 38.2331429,6.51428571 32.3314286,5.90171429 C32.192,5.888 32.1371429,5.73257143 32.24,5.64571429 C33.1542857,4.86857143 34.2765714,2.71542857 33.3142857,1.344 C30.3108571,3.33714286 28.7565714,3.424 23.2182857,1.024 C23.1725714,1.00342857 23.1908571,0.953142857 23.1794286,0.912 C10.2354286,5.51314286 6.912,14.6628571 7.51771429,24.3908571 C7.86971429,30.0091429 9.93142857,34.7748571 13.0765714,38.6057143"
|
||||
fill="#FA3200"
|
||||
mask="url(#mask-4)"
|
||||
></path>
|
||||
<path
|
||||
d="M12.0868571,53.472 C12,53.488 11.9154286,53.4514286 11.8948571,53.392 C10.8274286,50.2445714 9.73485714,47.0971429 8.62171429,43.9611429 C5.41028571,45.1108571 2.49371429,46.9302857 0.0982857143,49.248 C-0.0297142857,49.3691429 -0.032,49.488 0.0891428571,49.4742857 C2.352,49.4422857 4.47771429,49.7714286 6.4,50.3771429 C6.44114286,50.3862857 6.46628571,50.4091429 6.46857143,50.4411429 C6.11428571,53.4948571 6.30857143,56.5257143 7.01257143,59.3668571 C7.04228571,59.4445714 7.14057143,59.4468571 7.20685714,59.376 C9.26628571,56.6422857 12.6742857,54.624 16.7657143,54.368 L12.0868571,53.472"
|
||||
fill="#0AC855"
|
||||
mask="url(#mask-4)"
|
||||
></path>
|
||||
</g>
|
||||
<path
|
||||
d="M62.9645714,74.0434286 C46.192,71.104 28.8571429,71.104 12.0868571,74.0434286 C12,74.0594286 11.9154286,74.0228571 11.8948571,73.9634286 C10.3428571,69.3851429 8.74285714,64.8182857 7.09257143,60.2628571 C7.06971429,60.1988571 7.14057143,60.1257143 7.248,60.1074286 C27.1885714,56.464 47.8605714,56.464 67.8034286,60.1074286 C67.9108571,60.1257143 67.9817143,60.1988571 67.9565714,60.2628571 C66.3085714,64.8182857 64.7085714,69.3851429 63.1565714,73.9634286 C63.136,74.0228571 63.0514286,74.0594286 62.9645714,74.0434286"
|
||||
fill="#00912D"
|
||||
></path>
|
||||
<path
|
||||
d="M12.0868571,74.0434286 L16.7657143,74.9394286 C16.7657143,74.9394286 16.704,74.512 16.5211429,73.3325714 C14.1074286,73.6754286 12.0868571,74.0434286 12.0868571,74.0434286"
|
||||
fill="#0B4902"
|
||||
></path>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 7.4 KiB |
@@ -1,3 +0,0 @@
|
||||
export * from "./mmkv";
|
||||
export * from "./number";
|
||||
export * from "./string";
|
||||
@@ -1,17 +0,0 @@
|
||||
import {MMKV} from "react-native-mmkv";
|
||||
|
||||
declare module "react-native-mmkv" {
|
||||
interface MMKV {
|
||||
get<T>(key: string): T | undefined
|
||||
setAny(key: string, value: any | undefined): void
|
||||
}
|
||||
}
|
||||
|
||||
MMKV.prototype.get = function <T> (key: string): T | undefined {
|
||||
const serializedItem = this.getString(key);
|
||||
return serializedItem ? JSON.parse(serializedItem) : undefined;
|
||||
}
|
||||
|
||||
MMKV.prototype.setAny = function (key: string, value: any | undefined): void {
|
||||
this.set(key, JSON.stringify(value));
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
declare global {
|
||||
interface Number {
|
||||
bytesToReadable(): string;
|
||||
secondsToMilliseconds(): number
|
||||
minutesToMilliseconds(): number
|
||||
hoursToMilliseconds(): number
|
||||
}
|
||||
}
|
||||
|
||||
Number.prototype.bytesToReadable = function () {
|
||||
const bytes = this.valueOf();
|
||||
const gb = bytes / 1e9;
|
||||
|
||||
if (gb >= 1) return `${gb.toFixed(2)} GB`;
|
||||
|
||||
const mb = bytes / 1024.0 / 1024.0;
|
||||
if (mb >= 1) return `${mb.toFixed(2)} MB`;
|
||||
|
||||
const kb = bytes / 1024.0;
|
||||
if (kb >= 1) return `${kb.toFixed(2)} KB`;
|
||||
|
||||
return `${bytes.toFixed(2)} B`;
|
||||
}
|
||||
|
||||
Number.prototype.secondsToMilliseconds = function () {
|
||||
return this.valueOf() * 1000
|
||||
}
|
||||
|
||||
Number.prototype.minutesToMilliseconds = function () {
|
||||
return this.valueOf() * (60).secondsToMilliseconds()
|
||||
}
|
||||
|
||||
Number.prototype.hoursToMilliseconds = function () {
|
||||
return this.valueOf() * (60).minutesToMilliseconds()
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -1,16 +0,0 @@
|
||||
declare global {
|
||||
interface String {
|
||||
toTitle(): string;
|
||||
}
|
||||
}
|
||||
|
||||
String.prototype.toTitle = function () {
|
||||
return this
|
||||
.replaceAll("_", " ")
|
||||
.replace(
|
||||
/\w\S*/g,
|
||||
text => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Modal, TouchableOpacity, View } from "react-native";
|
||||
import { useMemo } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { Text } from "./common/Text";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof View> {
|
||||
source?: MediaSourceInfo;
|
||||
source: MediaSourceInfo;
|
||||
onChange: (value: number) => void;
|
||||
selected?: number | undefined;
|
||||
selected?: number | null;
|
||||
}
|
||||
|
||||
export const AudioTrackSelector: React.FC<Props> = ({
|
||||
@@ -16,10 +16,8 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
||||
selected,
|
||||
...props
|
||||
}) => {
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
|
||||
const audioStreams = useMemo(
|
||||
() => source?.MediaStreams?.filter((x) => x.Type === "Audio"),
|
||||
() => source.MediaStreams?.filter((x) => x.Type === "Audio"),
|
||||
[source]
|
||||
);
|
||||
|
||||
@@ -27,80 +25,50 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
||||
() => audioStreams?.find((x) => x.Index === selected),
|
||||
[audioStreams, selected]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<View
|
||||
className="flex shrink"
|
||||
style={{
|
||||
minWidth: 50,
|
||||
}}
|
||||
>
|
||||
<View className="flex flex-col" {...props}>
|
||||
<Text className="opacity-50 mb-1 text-xs">Audio</Text>
|
||||
<TouchableOpacity
|
||||
className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between"
|
||||
onPress={() => setIsModalVisible(true)}
|
||||
>
|
||||
<Text className="" numberOfLines={1}>
|
||||
{selectedAudioSteam?.DisplayTitle}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name="chevron-down"
|
||||
size={16}
|
||||
color="white"
|
||||
style={{ opacity: 0.5 }}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Modal
|
||||
visible={isModalVisible}
|
||||
transparent
|
||||
animationType="slide"
|
||||
onRequestClose={() => setIsModalVisible(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
className="flex-1 bg-black/50"
|
||||
activeOpacity={1}
|
||||
onPress={() => setIsModalVisible(false)}
|
||||
>
|
||||
<View className="mt-auto bg-neutral-900 rounded-t-xl">
|
||||
<View className="p-4 border-b border-neutral-800">
|
||||
<Text className="text-lg font-bold text-center">
|
||||
Audio Streams
|
||||
<View
|
||||
className="flex shrink"
|
||||
style={{
|
||||
minWidth: 50,
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className="flex flex-col" {...props}>
|
||||
<Text className="opacity-50 mb-1 text-xs">Audio</Text>
|
||||
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text className="" numberOfLines={1}>
|
||||
{selectedAudioSteam?.DisplayTitle}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="max-h-[50%]">
|
||||
{audioStreams?.map((audio, idx: number) => (
|
||||
<TouchableOpacity
|
||||
key={idx.toString()}
|
||||
className={`p-4 border-b border-neutral-800 flex-row items-center justify-between`}
|
||||
onPress={() => {
|
||||
if (audio.Index !== null && audio.Index !== undefined) {
|
||||
onChange(audio.Index);
|
||||
setIsModalVisible(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text>{audio.DisplayTitle}</Text>
|
||||
{audio.Index === selected && (
|
||||
<Ionicons name="checkmark" size={24} color="white" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
className="p-4 border-t border-neutral-800"
|
||||
onPress={() => setIsModalVisible(false)}
|
||||
>
|
||||
<Text className="text-center text-purple-400">Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>Audio streams</DropdownMenu.Label>
|
||||
{audioStreams?.map((audio, idx: number) => (
|
||||
<DropdownMenu.Item
|
||||
key={idx.toString()}
|
||||
onSelect={() => {
|
||||
if (audio.Index !== null && audio.Index !== undefined)
|
||||
onChange(audio.Index);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{audio.DisplayTitle}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { TouchableOpacity, View, Modal } from "react-native";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { Text } from "./common/Text";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useMemo } from "react";
|
||||
|
||||
export type Bitrate = {
|
||||
key: string;
|
||||
value: number | undefined;
|
||||
height?: number;
|
||||
};
|
||||
|
||||
export const BITRATES: Bitrate[] = [
|
||||
@@ -26,14 +27,17 @@ export const BITRATES: Bitrate[] = [
|
||||
{
|
||||
key: "2 Mb/s",
|
||||
value: 2000000,
|
||||
height: 720,
|
||||
},
|
||||
{
|
||||
key: "500 Kb/s",
|
||||
value: 500000,
|
||||
height: 480,
|
||||
},
|
||||
{
|
||||
key: "250 Kb/s",
|
||||
value: 250000,
|
||||
height: 480,
|
||||
},
|
||||
].sort((a, b) => (b.value || Infinity) - (a.value || Infinity));
|
||||
|
||||
@@ -49,8 +53,6 @@ export const BitrateSelector: React.FC<Props> = ({
|
||||
inverted,
|
||||
...props
|
||||
}) => {
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
if (inverted)
|
||||
return BITRATES.sort(
|
||||
@@ -59,81 +61,49 @@ export const BitrateSelector: React.FC<Props> = ({
|
||||
return BITRATES.sort(
|
||||
(a, b) => (b.value || Infinity) - (a.value || Infinity)
|
||||
);
|
||||
}, [inverted]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<View
|
||||
className="flex shrink"
|
||||
style={{
|
||||
minWidth: 60,
|
||||
maxWidth: 200,
|
||||
}}
|
||||
>
|
||||
<View className="flex flex-col" {...props}>
|
||||
<Text className="opacity-50 mb-1 text-xs">Quality</Text>
|
||||
<TouchableOpacity
|
||||
className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between"
|
||||
onPress={() => setIsModalVisible(true)}
|
||||
>
|
||||
<Text className="" numberOfLines={1}>
|
||||
{BITRATES.find((b) => b.value === selected?.value)?.key}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name="chevron-down"
|
||||
size={16}
|
||||
color="white"
|
||||
style={{ opacity: 0.5 }}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Modal
|
||||
visible={isModalVisible}
|
||||
transparent
|
||||
animationType="slide"
|
||||
onRequestClose={() => setIsModalVisible(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
className="flex-1 bg-black/50"
|
||||
activeOpacity={1}
|
||||
onPress={() => setIsModalVisible(false)}
|
||||
>
|
||||
<View className="mt-auto bg-neutral-900 rounded-t-xl">
|
||||
<View className="p-4 border-b border-neutral-800">
|
||||
<Text className="text-lg font-bold text-center">
|
||||
Select Quality
|
||||
<View
|
||||
className="flex shrink"
|
||||
style={{
|
||||
minWidth: 60,
|
||||
maxWidth: 200,
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className="flex flex-col" {...props}>
|
||||
<Text className="opacity-50 mb-1 text-xs">Quality</Text>
|
||||
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text style={{}} className="" numberOfLines={1}>
|
||||
{BITRATES.find((b) => b.value === selected?.value)?.key}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="max-h-[50%]">
|
||||
{sorted.map((bitrate) => (
|
||||
<TouchableOpacity
|
||||
key={bitrate.key}
|
||||
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
|
||||
onPress={() => {
|
||||
onChange(bitrate);
|
||||
setIsModalVisible(false);
|
||||
}}
|
||||
>
|
||||
<Text>{bitrate.key}</Text>
|
||||
{bitrate.value === selected?.value && (
|
||||
<Ionicons name="checkmark" size={24} color="white" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
className="p-4 border-t border-neutral-800"
|
||||
onPress={() => setIsModalVisible(false)}
|
||||
>
|
||||
<Text className="text-center text-purple-400">Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={false}
|
||||
side="bottom"
|
||||
align="center"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={0}
|
||||
sideOffset={0}
|
||||
>
|
||||
<DropdownMenu.Label>Bitrates</DropdownMenu.Label>
|
||||
{sorted.map((b) => (
|
||||
<DropdownMenu.Item
|
||||
key={b.key}
|
||||
onSelect={() => {
|
||||
onChange(b);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>{b.key}</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import * as Haptics from "expo-haptics";
|
||||
import React, { PropsWithChildren, ReactNode, useMemo } from "react";
|
||||
import { Text, TouchableOpacity, View } from "react-native";
|
||||
import { Loader } from "./Loader";
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ComponentProps<typeof TouchableOpacity> {
|
||||
interface ButtonProps extends React.ComponentProps<typeof TouchableOpacity> {
|
||||
onPress?: () => void;
|
||||
className?: string;
|
||||
textClassName?: string;
|
||||
@@ -46,13 +46,14 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
||||
<TouchableOpacity
|
||||
className={`
|
||||
p-3 rounded-xl items-center justify-center
|
||||
${(loading || disabled) && "opacity-50"}
|
||||
${loading || (disabled && "opacity-50")}
|
||||
${colorClasses}
|
||||
${className}
|
||||
`}
|
||||
onPress={() => {
|
||||
if (!loading && !disabled && onPress) {
|
||||
onPress();
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}
|
||||
}}
|
||||
disabled={disabled || loading}
|
||||
|
||||
87
components/Chromecast.tsx
Normal file
87
components/Chromecast.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { BlurView } from "expo-blur";
|
||||
import React, { useEffect } from "react";
|
||||
import { Platform, TouchableOpacity, ViewProps } from "react-native";
|
||||
import GoogleCast, {
|
||||
CastContext,
|
||||
useCastDevice,
|
||||
useDevices,
|
||||
useMediaStatus,
|
||||
useRemoteMediaClient,
|
||||
} from "react-native-google-cast";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
width?: number;
|
||||
height?: number;
|
||||
background?: "blur" | "transparent";
|
||||
}
|
||||
|
||||
export const Chromecast: React.FC<Props> = ({
|
||||
width = 48,
|
||||
height = 48,
|
||||
background = "transparent",
|
||||
...props
|
||||
}) => {
|
||||
const client = useRemoteMediaClient();
|
||||
const castDevice = useCastDevice();
|
||||
const devices = useDevices();
|
||||
const sessionManager = GoogleCast.getSessionManager();
|
||||
const discoveryManager = GoogleCast.getDiscoveryManager();
|
||||
const mediaStatus = useMediaStatus();
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (!discoveryManager) {
|
||||
return;
|
||||
}
|
||||
|
||||
await discoveryManager.startDiscovery();
|
||||
})();
|
||||
}, [client, devices, castDevice, sessionManager, discoveryManager]);
|
||||
|
||||
if (background === "transparent")
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||
else CastContext.showCastDialog();
|
||||
}}
|
||||
className="rounded-full h-10 w-10 flex items-center justify-center b"
|
||||
{...props}
|
||||
>
|
||||
<Feather name="cast" size={22} color={"white"} />
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
if (Platform.OS === "android")
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||
else CastContext.showCastDialog();
|
||||
}}
|
||||
className="rounded-full h-10 w-10 flex items-center justify-center bg-neutral-800/80"
|
||||
{...props}
|
||||
>
|
||||
<Feather name="cast" size={22} color={"white"} />
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||
else CastContext.showCastDialog();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<BlurView
|
||||
intensity={100}
|
||||
className="rounded-full overflow-hidden h-10 aspect-square flex items-center justify-center"
|
||||
{...props}
|
||||
>
|
||||
<Feather name="cast" size={22} color={"white"} />
|
||||
</BlurView>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
@@ -1,30 +1,27 @@
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { useMemo, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import { WatchedIndicator } from "./WatchedIndicator";
|
||||
import React from "react";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
|
||||
type ContinueWatchingPosterProps = {
|
||||
item: BaseItemDto;
|
||||
useEpisodePoster?: boolean;
|
||||
size?: "small" | "normal";
|
||||
showPlayButton?: boolean;
|
||||
};
|
||||
|
||||
const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
||||
item,
|
||||
useEpisodePoster = false,
|
||||
size = "normal",
|
||||
showPlayButton = false,
|
||||
}) => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
|
||||
/**
|
||||
* Get horizontal poster for movie and episode, with failover to primary.
|
||||
* Get horrizontal poster for movie and episode, with failover to primary.
|
||||
*/
|
||||
const url = useMemo(() => {
|
||||
if (!api) return;
|
||||
@@ -76,23 +73,16 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
||||
${size === "small" ? "w-32" : "w-44"}
|
||||
`}
|
||||
>
|
||||
<View className="w-full h-full flex items-center justify-center">
|
||||
<Image
|
||||
key={item.Id}
|
||||
id={item.Id}
|
||||
source={{
|
||||
uri: url,
|
||||
}}
|
||||
cachePolicy={"memory-disk"}
|
||||
contentFit="cover"
|
||||
className="w-full h-full"
|
||||
/>
|
||||
{showPlayButton && (
|
||||
<View className="absolute inset-0 flex items-center justify-center">
|
||||
<Ionicons name="play-circle" size={40} color="white" />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Image
|
||||
key={item.Id}
|
||||
id={item.Id}
|
||||
source={{
|
||||
uri: url,
|
||||
}}
|
||||
cachePolicy={"memory-disk"}
|
||||
contentFit="cover"
|
||||
className="w-full h-full"
|
||||
/>
|
||||
{!progress && <WatchedIndicator item={item} />}
|
||||
{progress > 0 && (
|
||||
<>
|
||||
|
||||
346
components/DownloadItem.tsx
Normal file
346
components/DownloadItem.tsx
Normal file
@@ -0,0 +1,346 @@
|
||||
import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { queueActions, queueAtom } from "@/utils/atoms/queue";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import ios from "@/utils/profiles/ios";
|
||||
import native from "@/utils/profiles/native";
|
||||
import old from "@/utils/profiles/old";
|
||||
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetBackdropProps,
|
||||
BottomSheetModal,
|
||||
BottomSheetView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { router, useFocusEffect } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { Alert, TouchableOpacity, View, ViewProps } from "react-native";
|
||||
import { AudioTrackSelector } from "./AudioTrackSelector";
|
||||
import { Bitrate, BITRATES, BitrateSelector } from "./BitrateSelector";
|
||||
import { Button } from "./Button";
|
||||
import { Text } from "./common/Text";
|
||||
import { Loader } from "./Loader";
|
||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||
import ProgressCircle from "./ProgressCircle";
|
||||
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
|
||||
import { toast } from "sonner-native";
|
||||
import iosFmp4 from "@/utils/profiles/iosFmp4";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
|
||||
interface DownloadProps extends ViewProps {
|
||||
item: BaseItemDto;
|
||||
}
|
||||
|
||||
export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const [queue, setQueue] = useAtom(queueAtom);
|
||||
const [settings] = useSettings();
|
||||
const { processes, startBackgroundDownload } = useDownload();
|
||||
const { startRemuxing } = useRemuxHlsToMp4(item);
|
||||
|
||||
const [selectedMediaSource, setSelectedMediaSource] = useState<
|
||||
MediaSourceInfo | undefined
|
||||
>(undefined);
|
||||
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
||||
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
||||
useState<number>(0);
|
||||
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
|
||||
key: "Max",
|
||||
value: undefined,
|
||||
});
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (!settings) return;
|
||||
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
|
||||
getDefaultPlaySettings(item, settings);
|
||||
|
||||
// 4. Set states
|
||||
setSelectedMediaSource(mediaSource);
|
||||
setSelectedAudioStream(audioIndex ?? 0);
|
||||
setSelectedSubtitleStream(subtitleIndex ?? -1);
|
||||
setMaxBitrate(bitrate);
|
||||
}, [item, settings])
|
||||
);
|
||||
|
||||
const userCanDownload = useMemo(() => {
|
||||
return user?.Policy?.EnableContentDownloading;
|
||||
}, [user]);
|
||||
|
||||
/**
|
||||
* Bottom sheet
|
||||
*/
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
|
||||
const handlePresentModalPress = useCallback(() => {
|
||||
bottomSheetModalRef.current?.present();
|
||||
}, []);
|
||||
|
||||
const handleSheetChanges = useCallback((index: number) => {}, []);
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
bottomSheetModalRef.current?.dismiss();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Start download
|
||||
*/
|
||||
const initiateDownload = useCallback(async () => {
|
||||
if (!api || !user?.Id || !item.Id || !selectedMediaSource?.Id) {
|
||||
throw new Error(
|
||||
"DownloadItem ~ initiateDownload: No api or user or item"
|
||||
);
|
||||
}
|
||||
|
||||
let deviceProfile: any = iosFmp4;
|
||||
|
||||
if (settings?.deviceProfile === "Native") {
|
||||
deviceProfile = native;
|
||||
} else if (settings?.deviceProfile === "Old") {
|
||||
deviceProfile = old;
|
||||
}
|
||||
|
||||
const response = await api.axiosInstance.post(
|
||||
`${api.basePath}/Items/${item.Id}/PlaybackInfo`,
|
||||
{
|
||||
DeviceProfile: deviceProfile,
|
||||
UserId: user.Id,
|
||||
MaxStreamingBitrate: maxBitrate.value,
|
||||
StartTimeTicks: 0,
|
||||
EnableTranscoding: maxBitrate.value ? true : undefined,
|
||||
AutoOpenLiveStream: true,
|
||||
AllowVideoStreamCopy: maxBitrate.value ? false : true,
|
||||
MediaSourceId: selectedMediaSource?.Id,
|
||||
AudioStreamIndex: selectedAudioStream,
|
||||
SubtitleStreamIndex: selectedSubtitleStream,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
let url: string | undefined = undefined;
|
||||
let fileExtension: string | undefined | null = "mp4";
|
||||
|
||||
const mediaSource: MediaSourceInfo = response.data.MediaSources.find(
|
||||
(source: MediaSourceInfo) => source.Id === selectedMediaSource?.Id
|
||||
);
|
||||
|
||||
if (!mediaSource) {
|
||||
throw new Error("No media source");
|
||||
}
|
||||
|
||||
if (mediaSource.SupportsDirectPlay) {
|
||||
if (item.MediaType === "Video") {
|
||||
url = `${api.basePath}/Videos/${item.Id}/stream.mp4?mediaSourceId=${item.Id}&static=true&mediaSourceId=${mediaSource.Id}&deviceId=${api.deviceInfo.id}&api_key=${api.accessToken}`;
|
||||
} else if (item.MediaType === "Audio") {
|
||||
console.log("Using direct stream for audio!");
|
||||
const searchParams = new URLSearchParams({
|
||||
UserId: user.Id,
|
||||
DeviceId: api.deviceInfo.id,
|
||||
MaxStreamingBitrate: "140000000",
|
||||
Container:
|
||||
"opus,webm|opus,mp3,aac,m4a|aac,m4b|aac,flac,webma,webm|webma,wav,ogg",
|
||||
TranscodingContainer: "mp4",
|
||||
TranscodingProtocol: "hls",
|
||||
AudioCodec: "aac",
|
||||
api_key: api.accessToken,
|
||||
StartTimeTicks: "0",
|
||||
EnableRedirection: "true",
|
||||
EnableRemoteMedia: "false",
|
||||
});
|
||||
url = `${api.basePath}/Audio/${
|
||||
item.Id
|
||||
}/universal?${searchParams.toString()}`;
|
||||
}
|
||||
} else if (mediaSource.TranscodingUrl) {
|
||||
url = `${api.basePath}${mediaSource.TranscodingUrl}`;
|
||||
fileExtension = mediaSource.TranscodingContainer;
|
||||
}
|
||||
|
||||
if (!url) throw new Error("No url");
|
||||
if (!fileExtension) throw new Error("No file extension");
|
||||
|
||||
if (settings?.downloadMethod === "optimized") {
|
||||
return await startBackgroundDownload(url, item, fileExtension);
|
||||
} else {
|
||||
return await startRemuxing(url);
|
||||
}
|
||||
}, [
|
||||
api,
|
||||
item,
|
||||
startBackgroundDownload,
|
||||
user?.Id,
|
||||
selectedMediaSource,
|
||||
selectedAudioStream,
|
||||
selectedSubtitleStream,
|
||||
maxBitrate,
|
||||
settings?.downloadMethod,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Check if item is downloaded
|
||||
*/
|
||||
const { downloadedFiles } = useDownload();
|
||||
|
||||
const isDownloaded = useMemo(() => {
|
||||
if (!downloadedFiles) return false;
|
||||
|
||||
return downloadedFiles.some((file) => file.Id === item.Id);
|
||||
}, [downloadedFiles, item.Id]);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: BottomSheetBackdropProps) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
/>
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
const process = useMemo(() => {
|
||||
if (!processes) return null;
|
||||
|
||||
return processes.find((process) => process?.item?.Id === item.Id);
|
||||
}, [processes, item.Id]);
|
||||
|
||||
return (
|
||||
<View
|
||||
className="bg-neutral-800/80 rounded-full h-10 w-10 flex items-center justify-center"
|
||||
{...props}
|
||||
>
|
||||
{process && process?.item.Id === item.Id ? (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push("/downloads");
|
||||
}}
|
||||
>
|
||||
{process.progress === 0 ? (
|
||||
<Loader />
|
||||
) : (
|
||||
<View className="-rotate-45">
|
||||
<ProgressCircle
|
||||
size={24}
|
||||
fill={process.progress}
|
||||
width={4}
|
||||
tintColor="#9334E9"
|
||||
backgroundColor="#bdc3c7"
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
) : queue.some((i) => i.id === item.Id) ? (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push("/downloads");
|
||||
}}
|
||||
>
|
||||
<Ionicons name="hourglass" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
) : isDownloaded ? (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push("/downloads");
|
||||
}}
|
||||
>
|
||||
<Ionicons name="cloud-download" size={26} color="#9333ea" />
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity onPress={handlePresentModalPress}>
|
||||
<Ionicons name="cloud-download-outline" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetModalRef}
|
||||
enableDynamicSizing
|
||||
handleIndicatorStyle={{
|
||||
backgroundColor: "white",
|
||||
}}
|
||||
backgroundStyle={{
|
||||
backgroundColor: "#171717",
|
||||
}}
|
||||
onChange={handleSheetChanges}
|
||||
backdropComponent={renderBackdrop}
|
||||
>
|
||||
<BottomSheetView>
|
||||
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
|
||||
<Text className="font-bold text-2xl text-neutral-10">
|
||||
Download options
|
||||
</Text>
|
||||
<View className="flex flex-col space-y-2 w-full items-start">
|
||||
<BitrateSelector
|
||||
inverted
|
||||
onChange={(val) => setMaxBitrate(val)}
|
||||
selected={maxBitrate}
|
||||
/>
|
||||
<MediaSourceSelector
|
||||
item={item}
|
||||
onChange={setSelectedMediaSource}
|
||||
selected={selectedMediaSource}
|
||||
/>
|
||||
{selectedMediaSource && (
|
||||
<View className="flex flex-col space-y-2">
|
||||
<AudioTrackSelector
|
||||
source={selectedMediaSource}
|
||||
onChange={setSelectedAudioStream}
|
||||
selected={selectedAudioStream}
|
||||
/>
|
||||
<SubtitleTrackSelector
|
||||
source={selectedMediaSource}
|
||||
onChange={setSelectedSubtitleStream}
|
||||
selected={selectedSubtitleStream}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Button
|
||||
className="mt-auto"
|
||||
onPress={() => {
|
||||
if (userCanDownload === true) {
|
||||
if (!item.Id) {
|
||||
throw new Error("No item id");
|
||||
}
|
||||
closeModal();
|
||||
if (settings?.downloadMethod === "remux") {
|
||||
queueActions.enqueue(queue, setQueue, {
|
||||
id: item.Id,
|
||||
execute: async () => {
|
||||
await initiateDownload();
|
||||
},
|
||||
item,
|
||||
});
|
||||
} else {
|
||||
initiateDownload();
|
||||
}
|
||||
} else {
|
||||
toast.error("You are not allowed to download files.");
|
||||
}
|
||||
}}
|
||||
color="purple"
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
<View className="opacity-70 text-center w-full flex items-center">
|
||||
{settings?.downloadMethod === "optimized" ? (
|
||||
<Text className="text-xs">Using optimized server</Text>
|
||||
) : (
|
||||
<Text className="text-xs">Using default method</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</BottomSheetView>
|
||||
</BottomSheetModal>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -1,43 +1,25 @@
|
||||
// GenreTags.tsx
|
||||
import React from "react";
|
||||
import {View, ViewProps} from "react-native";
|
||||
import { View } from "react-native";
|
||||
import { Text } from "./common/Text";
|
||||
|
||||
interface TagProps {
|
||||
tags?: string[];
|
||||
textClass?: ViewProps["className"]
|
||||
interface GenreTagsProps {
|
||||
genres?: string[];
|
||||
}
|
||||
|
||||
export const Tag: React.FC<{ text: string, textClass?: ViewProps["className"]} & ViewProps> = ({
|
||||
text,
|
||||
textClass,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<View className="bg-neutral-800 rounded-full px-2 py-1" {...props}>
|
||||
<Text className={textClass}>{text}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export const Tags: React.FC<TagProps & ViewProps> = ({ tags, textClass = "text-xs", ...props }) => {
|
||||
if (!tags || tags.length === 0) return null;
|
||||
export const GenreTags: React.FC<GenreTagsProps> = ({ genres }) => {
|
||||
if (!genres || genres.length === 0) return null;
|
||||
|
||||
return (
|
||||
<View className={`flex flex-row flex-wrap gap-1 ${props.className}`} {...props}>
|
||||
{tags.map((tag, idx) => (
|
||||
<View>
|
||||
<Tag key={idx} textClass={textClass} text={tag}/>
|
||||
<View className="flex flex-row flex-wrap mt-2">
|
||||
{genres.map((genre) => (
|
||||
<View
|
||||
key={genre}
|
||||
className="bg-neutral-800 rounded-full px-2 py-1 mr-1"
|
||||
>
|
||||
<Text className="text-xs">{genre}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export const GenreTags: React.FC<{ genres?: string[]}> = ({ genres }) => {
|
||||
return (
|
||||
<View className="mt-2">
|
||||
<Tags tags={genres}/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,13 +13,12 @@ export const ItemCardText: React.FC<ItemCardProps> = ({ item }) => {
|
||||
<View className="mt-2 flex flex-col">
|
||||
{item.Type === "Episode" ? (
|
||||
<>
|
||||
<Text numberOfLines={1} className="">
|
||||
{item.Name}
|
||||
<Text numberOfLines={2} className="">
|
||||
{item.SeriesName}
|
||||
</Text>
|
||||
<Text numberOfLines={1} className="text-xs opacity-50">
|
||||
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
|
||||
{" - "}
|
||||
{item.SeriesName}
|
||||
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}{" "}
|
||||
{item.Name}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
||||
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
||||
import { DownloadItem } from "@/components/DownloadItem";
|
||||
import { OverviewText } from "@/components/OverviewText";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
import { PlayButton } from "@/components/PlayButton";
|
||||
@@ -10,76 +11,136 @@ import { ItemImage } from "@/components/common/ItemImage";
|
||||
import { CastAndCrew } from "@/components/series/CastAndCrew";
|
||||
import { CurrentSeries } from "@/components/series/CurrentSeries";
|
||||
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
|
||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||
import { useImageColors } from "@/hooks/useImageColors";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
||||
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { useNavigation } from "expo-router";
|
||||
import { useFocusEffect, useNavigation } from "expo-router";
|
||||
import * as ScreenOrientation from "expo-screen-orientation";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Alert, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Chromecast } from "./Chromecast";
|
||||
import { ItemHeader } from "./ItemHeader";
|
||||
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
||||
|
||||
export type SelectedOptions = {
|
||||
bitrate: Bitrate;
|
||||
mediaSource: MediaSourceInfo | undefined;
|
||||
audioIndex: number | undefined;
|
||||
subtitleIndex: number;
|
||||
};
|
||||
|
||||
export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
({ item }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const { setPlaySettings, playUrl, playSettings } = usePlaySettings();
|
||||
const [settings] = useSettings();
|
||||
const navigation = useNavigation();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const [loadingLogo, setLoadingLogo] = useState(true);
|
||||
|
||||
const [orientation, setOrientation] = useState(
|
||||
ScreenOrientation.Orientation.PORTRAIT_UP
|
||||
);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (!settings) return;
|
||||
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
|
||||
getDefaultPlaySettings(item, settings);
|
||||
|
||||
setPlaySettings({
|
||||
item,
|
||||
bitrate,
|
||||
mediaSource,
|
||||
audioIndex,
|
||||
subtitleIndex,
|
||||
});
|
||||
|
||||
if (!mediaSource) {
|
||||
Alert.alert("Error", "No media source found for this item.");
|
||||
navigation.goBack();
|
||||
}
|
||||
}, [item, settings])
|
||||
);
|
||||
|
||||
const selectedMediaSource = useMemo(() => {
|
||||
return playSettings?.mediaSource || undefined;
|
||||
}, [playSettings?.mediaSource]);
|
||||
|
||||
const setSelectedMediaSource = (mediaSource: MediaSourceInfo) => {
|
||||
setPlaySettings((prev) => ({
|
||||
...prev,
|
||||
mediaSource,
|
||||
}));
|
||||
};
|
||||
|
||||
const selectedAudioStream = useMemo(() => {
|
||||
return playSettings?.audioIndex;
|
||||
}, [playSettings?.audioIndex]);
|
||||
|
||||
const setSelectedAudioStream = (audioIndex: number) => {
|
||||
setPlaySettings((prev) => ({
|
||||
...prev,
|
||||
audioIndex,
|
||||
}));
|
||||
};
|
||||
|
||||
const selectedSubtitleStream = useMemo(() => {
|
||||
return playSettings?.subtitleIndex;
|
||||
}, [playSettings?.subtitleIndex]);
|
||||
|
||||
const setSelectedSubtitleStream = (subtitleIndex: number) => {
|
||||
setPlaySettings((prev) => ({
|
||||
...prev,
|
||||
subtitleIndex,
|
||||
}));
|
||||
};
|
||||
|
||||
const maxBitrate = useMemo(() => {
|
||||
return playSettings?.bitrate;
|
||||
}, [playSettings?.bitrate]);
|
||||
|
||||
const setMaxBitrate = (bitrate: Bitrate | undefined) => {
|
||||
console.log("setMaxBitrate", bitrate);
|
||||
setPlaySettings((prev) => ({
|
||||
...prev,
|
||||
bitrate,
|
||||
}));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = ScreenOrientation.addOrientationChangeListener(
|
||||
(event) => {
|
||||
setOrientation(event.orientationInfo.orientation);
|
||||
}
|
||||
);
|
||||
|
||||
ScreenOrientation.getOrientationAsync().then((initialOrientation) => {
|
||||
setOrientation(initialOrientation);
|
||||
});
|
||||
|
||||
return () => {
|
||||
ScreenOrientation.removeOrientationChangeListener(subscription);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const [headerHeight, setHeaderHeight] = useState(350);
|
||||
|
||||
const [selectedOptions, setSelectedOptions] = useState<
|
||||
SelectedOptions | undefined
|
||||
>(undefined);
|
||||
|
||||
const {
|
||||
defaultAudioIndex,
|
||||
defaultBitrate,
|
||||
defaultMediaSource,
|
||||
defaultSubtitleIndex,
|
||||
} = useDefaultPlaySettings(item, settings);
|
||||
|
||||
// Needs to automatically change the selected to the default values for default indexes.
|
||||
useEffect(() => {
|
||||
setSelectedOptions(() => ({
|
||||
bitrate: defaultBitrate,
|
||||
mediaSource: defaultMediaSource,
|
||||
subtitleIndex: defaultSubtitleIndex ?? -1,
|
||||
audioIndex: defaultAudioIndex,
|
||||
}));
|
||||
}, [
|
||||
defaultAudioIndex,
|
||||
defaultBitrate,
|
||||
defaultSubtitleIndex,
|
||||
defaultMediaSource,
|
||||
]);
|
||||
useImageColors({ item });
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerRight: () =>
|
||||
item && (
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
<Chromecast background="blur" width={22} height={22} />
|
||||
{item.Type !== "Program" && (
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
<DownloadItem item={item} />
|
||||
<PlayedStatus item={item} />
|
||||
</View>
|
||||
)}
|
||||
@@ -89,9 +150,15 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
}, [item]);
|
||||
|
||||
useEffect(() => {
|
||||
// If landscape
|
||||
if (orientation !== ScreenOrientation.Orientation.PORTRAIT_UP) {
|
||||
setHeaderHeight(230);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.Type === "Movie") setHeaderHeight(500);
|
||||
else setHeaderHeight(350);
|
||||
}, [item.Type]);
|
||||
}, [item.Type, orientation]);
|
||||
|
||||
const logoUrl = useMemo(() => getLogoImageUrlById({ api, item }), [item]);
|
||||
|
||||
@@ -99,37 +166,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
return Boolean(logoUrl && loadingLogo);
|
||||
}, [loadingLogo, logoUrl]);
|
||||
|
||||
const [isTranscoding, setIsTranscoding] = useState(false);
|
||||
const [previouslyChosenSubtitleIndex, setPreviouslyChosenSubtitleIndex] =
|
||||
useState<number | undefined>(selectedOptions?.subtitleIndex);
|
||||
|
||||
useEffect(() => {
|
||||
const isTranscoding = Boolean(selectedOptions?.bitrate.value);
|
||||
if (isTranscoding) {
|
||||
setPreviouslyChosenSubtitleIndex(selectedOptions?.subtitleIndex);
|
||||
const subHelper = new SubtitleHelper(
|
||||
selectedOptions?.mediaSource?.MediaStreams ?? []
|
||||
);
|
||||
|
||||
const newSubtitleIndex = subHelper.getMostCommonSubtitleByName(
|
||||
selectedOptions?.subtitleIndex
|
||||
);
|
||||
|
||||
setSelectedOptions((prev) => ({
|
||||
...prev!,
|
||||
subtitleIndex: newSubtitleIndex ?? -1,
|
||||
}));
|
||||
}
|
||||
if (!isTranscoding && previouslyChosenSubtitleIndex !== undefined) {
|
||||
setSelectedOptions((prev) => ({
|
||||
...prev!,
|
||||
subtitleIndex: previouslyChosenSubtitleIndex,
|
||||
}));
|
||||
}
|
||||
setIsTranscoding(isTranscoding);
|
||||
}, [selectedOptions?.bitrate]);
|
||||
|
||||
if (!selectedOptions) return null;
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -182,86 +219,51 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
<View className="flex flex-row items-center justify-start w-full h-16">
|
||||
<BitrateSelector
|
||||
className="mr-1"
|
||||
onChange={(val) =>
|
||||
setSelectedOptions(
|
||||
(prev) => prev && { ...prev, bitrate: val }
|
||||
)
|
||||
}
|
||||
selected={selectedOptions.bitrate}
|
||||
onChange={(val) => setMaxBitrate(val)}
|
||||
selected={maxBitrate}
|
||||
/>
|
||||
<MediaSourceSelector
|
||||
className="mr-1"
|
||||
item={item}
|
||||
onChange={(val) =>
|
||||
setSelectedOptions(
|
||||
(prev) =>
|
||||
prev && {
|
||||
...prev,
|
||||
mediaSource: val,
|
||||
}
|
||||
)
|
||||
}
|
||||
selected={selectedOptions.mediaSource}
|
||||
/>
|
||||
<AudioTrackSelector
|
||||
className="mr-1"
|
||||
source={selectedOptions.mediaSource}
|
||||
onChange={(val) => {
|
||||
setSelectedOptions(
|
||||
(prev) =>
|
||||
prev && {
|
||||
...prev,
|
||||
audioIndex: val,
|
||||
}
|
||||
);
|
||||
}}
|
||||
selected={selectedOptions.audioIndex}
|
||||
/>
|
||||
<SubtitleTrackSelector
|
||||
isTranscoding={isTranscoding}
|
||||
source={selectedOptions.mediaSource}
|
||||
onChange={(val) =>
|
||||
setSelectedOptions(
|
||||
(prev) =>
|
||||
prev && {
|
||||
...prev,
|
||||
subtitleIndex: val,
|
||||
}
|
||||
)
|
||||
}
|
||||
selected={selectedOptions.subtitleIndex}
|
||||
onChange={setSelectedMediaSource}
|
||||
selected={selectedMediaSource}
|
||||
/>
|
||||
{selectedMediaSource && (
|
||||
<>
|
||||
<AudioTrackSelector
|
||||
className="mr-1"
|
||||
source={selectedMediaSource}
|
||||
onChange={setSelectedAudioStream}
|
||||
selected={selectedAudioStream}
|
||||
/>
|
||||
<SubtitleTrackSelector
|
||||
source={selectedMediaSource}
|
||||
onChange={setSelectedSubtitleStream}
|
||||
selected={selectedSubtitleStream}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<PlayButton
|
||||
className="grow"
|
||||
selectedOptions={selectedOptions}
|
||||
item={item}
|
||||
/>
|
||||
<PlayButton item={item} url={playUrl} className="grow" />
|
||||
</View>
|
||||
|
||||
{item.Type === "Episode" && (
|
||||
<SeasonEpisodesCarousel item={item} loading={loading} />
|
||||
)}
|
||||
|
||||
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
|
||||
<OverviewText text={item.Overview} className="px-4 mb-4" />
|
||||
|
||||
<OverviewText text={item.Overview} className="px-4 my-4" />
|
||||
{item.Type !== "Program" && (
|
||||
<>
|
||||
{item.Type === "Episode" && (
|
||||
<CurrentSeries item={item} className="mb-4" />
|
||||
)}
|
||||
|
||||
<CastAndCrew item={item} className="mb-4" loading={loading} />
|
||||
|
||||
{item.People && item.People.length > 0 && (
|
||||
<View className="mb-4">
|
||||
{item.People.slice(0, 3).map((person, idx) => (
|
||||
{item.People.slice(0, 3).map((person) => (
|
||||
<MoreMoviesWithActor
|
||||
currentItem={item}
|
||||
key={idx}
|
||||
key={person.Id}
|
||||
actorId={person.Id!}
|
||||
className="mb-4"
|
||||
/>
|
||||
@@ -269,9 +271,15 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
</View>
|
||||
)}
|
||||
|
||||
{item.Type === "Episode" && (
|
||||
<CurrentSeries item={item} className="mb-4" />
|
||||
)}
|
||||
|
||||
<SimilarItems itemId={item.Id} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<View className="h-16"></View>
|
||||
</View>
|
||||
</ParallaxScrollView>
|
||||
</View>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import React from "react";
|
||||
import { View, ViewProps } from "react-native";
|
||||
import { GenreTags } from "./GenreTags";
|
||||
import { MoviesTitleHeader } from "./movies/MoviesTitleHeader";
|
||||
import { Ratings } from "./Ratings";
|
||||
import { EpisodeTitleHeader } from "./series/EpisodeTitleHeader";
|
||||
import { ItemActions } from "./series/SeriesActions";
|
||||
import { GenreTags } from "./GenreTags";
|
||||
import React from "react";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
item?: BaseItemDto | null;
|
||||
@@ -28,10 +27,7 @@ export const ItemHeader: React.FC<Props> = ({ item, ...props }) => {
|
||||
return (
|
||||
<View className="flex flex-col" {...props}>
|
||||
<View className="flex flex-col" {...props}>
|
||||
<View className="flex flex-row items-center justify-between">
|
||||
<Ratings item={item} className="mb-2" />
|
||||
<ItemActions item={item} />
|
||||
</View>
|
||||
<Ratings item={item} className="mb-2" />
|
||||
{item.Type === "Episode" && (
|
||||
<>
|
||||
<EpisodeTitleHeader item={item} />
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import {
|
||||
MediaSourceInfo,
|
||||
type MediaStream,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import React, { useMemo, useRef } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { Badge } from "./Badge";
|
||||
import { Text } from "./common/Text";
|
||||
import {
|
||||
BottomSheetModal,
|
||||
BottomSheetBackdropProps,
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetView,
|
||||
BottomSheetScrollView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { Button } from "./Button";
|
||||
|
||||
interface Props {
|
||||
source?: MediaSourceInfo;
|
||||
}
|
||||
|
||||
export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
|
||||
return (
|
||||
<View className="px-4 mt-2 mb-4">
|
||||
<Text className="text-lg font-bold mb-4">Video</Text>
|
||||
<TouchableOpacity onPress={() => bottomSheetModalRef.current?.present()}>
|
||||
<View className="flex flex-row space-x-2">
|
||||
<VideoStreamInfo source={source} />
|
||||
</View>
|
||||
<Text className="text-purple-600">More details</Text>
|
||||
</TouchableOpacity>
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetModalRef}
|
||||
snapPoints={["80%"]}
|
||||
handleIndicatorStyle={{
|
||||
backgroundColor: "white",
|
||||
}}
|
||||
backgroundStyle={{
|
||||
backgroundColor: "#171717",
|
||||
}}
|
||||
backdropComponent={(props: BottomSheetBackdropProps) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<BottomSheetScrollView>
|
||||
<View className="flex flex-col space-y-2 p-4 mb-4">
|
||||
<View className="">
|
||||
<Text className="text-lg font-bold mb-4">Video</Text>
|
||||
<View className="flex flex-row space-x-2">
|
||||
<VideoStreamInfo source={source} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="">
|
||||
<Text className="text-lg font-bold mb-2">Audio</Text>
|
||||
<AudioStreamInfo
|
||||
audioStreams={
|
||||
source?.MediaStreams?.filter(
|
||||
(stream) => stream.Type === "Audio"
|
||||
) || []
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="">
|
||||
<Text className="text-lg font-bold mb-2">Subtitles</Text>
|
||||
<SubtitleStreamInfo
|
||||
subtitleStreams={
|
||||
source?.MediaStreams?.filter(
|
||||
(stream) => stream.Type === "Subtitle"
|
||||
) || []
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</BottomSheetScrollView>
|
||||
</BottomSheetModal>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const SubtitleStreamInfo = ({
|
||||
subtitleStreams,
|
||||
}: {
|
||||
subtitleStreams: MediaStream[];
|
||||
}) => {
|
||||
return (
|
||||
<View className="flex flex-col">
|
||||
{subtitleStreams.map((stream, index) => (
|
||||
<View key={stream.Index} className="flex flex-col">
|
||||
<Text className="text-xs mb-3 text-neutral-400">
|
||||
{stream.DisplayTitle}
|
||||
</Text>
|
||||
<View className="flex flex-row flex-wrap gap-2">
|
||||
<Badge
|
||||
variant="gray"
|
||||
iconLeft={
|
||||
<Ionicons name="language-outline" size={16} color="white" />
|
||||
}
|
||||
text={stream.Language}
|
||||
/>
|
||||
<Badge
|
||||
variant="gray"
|
||||
text={stream.Codec}
|
||||
iconLeft={
|
||||
<Ionicons name="layers-outline" size={16} color="white" />
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const AudioStreamInfo = ({ audioStreams }: { audioStreams: MediaStream[] }) => {
|
||||
return (
|
||||
<View className="flex flex-col">
|
||||
{audioStreams.map((audioStreams, index) => (
|
||||
<View key={index} className="flex flex-col">
|
||||
<Text className="mb-3 text-neutral-400 text-xs">
|
||||
{audioStreams.DisplayTitle}
|
||||
</Text>
|
||||
<View className="flex-row flex-wrap gap-2">
|
||||
<Badge
|
||||
variant="gray"
|
||||
iconLeft={
|
||||
<Ionicons name="language-outline" size={16} color="white" />
|
||||
}
|
||||
text={audioStreams.Language}
|
||||
/>
|
||||
<Badge
|
||||
variant="gray"
|
||||
iconLeft={
|
||||
<Ionicons
|
||||
name="musical-notes-outline"
|
||||
size={16}
|
||||
color="white"
|
||||
/>
|
||||
}
|
||||
text={audioStreams.Codec}
|
||||
/>
|
||||
<Badge
|
||||
variant="gray"
|
||||
iconLeft={<Ionicons name="mic-outline" size={16} color="white" />}
|
||||
text={audioStreams.ChannelLayout}
|
||||
/>
|
||||
<Badge
|
||||
variant="gray"
|
||||
iconLeft={
|
||||
<Ionicons name="speedometer-outline" size={16} color="white" />
|
||||
}
|
||||
text={formatBitrate(audioStreams.BitRate)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const VideoStreamInfo = ({ source }: { source?: MediaSourceInfo }) => {
|
||||
if (!source) return null;
|
||||
|
||||
const videoStream = useMemo(() => {
|
||||
return source.MediaStreams?.find(
|
||||
(stream) => stream.Type === "Video"
|
||||
) as MediaStream;
|
||||
}, [source.MediaStreams]);
|
||||
|
||||
return (
|
||||
<View className="flex-row flex-wrap gap-2">
|
||||
<Badge
|
||||
variant="gray"
|
||||
iconLeft={<Ionicons name="film-outline" size={16} color="white" />}
|
||||
text={formatFileSize(source.Size)}
|
||||
/>
|
||||
<Badge
|
||||
variant="gray"
|
||||
iconLeft={<Ionicons name="film-outline" size={16} color="white" />}
|
||||
text={`${videoStream.Width}x${videoStream.Height}`}
|
||||
/>
|
||||
<Badge
|
||||
variant="gray"
|
||||
iconLeft={
|
||||
<Ionicons name="color-palette-outline" size={16} color="white" />
|
||||
}
|
||||
text={videoStream.VideoRange}
|
||||
/>
|
||||
<Badge
|
||||
variant="gray"
|
||||
iconLeft={
|
||||
<Ionicons name="code-working-outline" size={16} color="white" />
|
||||
}
|
||||
text={videoStream.Codec}
|
||||
/>
|
||||
<Badge
|
||||
variant="gray"
|
||||
iconLeft={
|
||||
<Ionicons name="speedometer-outline" size={16} color="white" />
|
||||
}
|
||||
text={formatBitrate(videoStream.BitRate)}
|
||||
/>
|
||||
<Badge
|
||||
variant="gray"
|
||||
iconLeft={<Ionicons name="play-outline" size={16} color="white" />}
|
||||
text={`${videoStream.AverageFrameRate?.toFixed(0)} fps`}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes?: number | null) => {
|
||||
if (!bytes) return "N/A";
|
||||
|
||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
|
||||
if (bytes === 0) return "0 Byte";
|
||||
const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)).toString());
|
||||
return Math.round((bytes / Math.pow(1024, i)) * 100) / 100 + " " + sizes[i];
|
||||
};
|
||||
|
||||
const formatBitrate = (bitrate?: number | null) => {
|
||||
if (!bitrate) return "N/A";
|
||||
|
||||
const sizes = ["bps", "Kbps", "Mbps", "Gbps", "Tbps"];
|
||||
if (bitrate === 0) return "0 bps";
|
||||
const i = parseInt(Math.floor(Math.log(bitrate) / Math.log(1000)).toString());
|
||||
return Math.round((bitrate / Math.pow(1000, i)) * 100) / 100 + " " + sizes[i];
|
||||
};
|
||||
@@ -24,7 +24,7 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
||||
<View className="flex flex-col overflow-visible">
|
||||
<Text className="font-bold ">{title}</Text>
|
||||
{subTitle && (
|
||||
<Text uiTextView selectable className="text-xs text-neutral-400">
|
||||
<Text uiTextView selectable className="text-xs">
|
||||
{subTitle}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { convertBitsToMegabitsOrGigabits } from "@/utils/bToMb";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { tc } from "@/utils/textTools";
|
||||
import {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Modal, TouchableOpacity, View } from "react-native";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { Text } from "./common/Text";
|
||||
import { convertBitsToMegabitsOrGigabits } from "@/utils/bToMb";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof View> {
|
||||
item: BaseItemDto;
|
||||
@@ -20,96 +21,70 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
||||
selected,
|
||||
...props
|
||||
}) => {
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
|
||||
const selectedName = useMemo(
|
||||
() =>
|
||||
item.MediaSources?.find((x) => x.Id === selected?.Id)?.MediaStreams?.find(
|
||||
(x) => x.Type === "Video"
|
||||
)?.DisplayTitle || "",
|
||||
[item, selected]
|
||||
[item.MediaSources, selected]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected && item.MediaSources && item.MediaSources.length > 0) {
|
||||
onChange(item.MediaSources[0]);
|
||||
}
|
||||
}, [item.MediaSources, selected]);
|
||||
|
||||
const name = (name?: string | null) => {
|
||||
if (name && name.length > 40)
|
||||
return (
|
||||
name.substring(0, 20) + " [...] " + name.substring(name.length - 20)
|
||||
);
|
||||
return name;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<View
|
||||
className="flex shrink"
|
||||
style={{
|
||||
minWidth: 50,
|
||||
}}
|
||||
>
|
||||
<View className="flex flex-col" {...props}>
|
||||
<Text className="opacity-50 mb-1 text-xs">Video</Text>
|
||||
<TouchableOpacity
|
||||
className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between"
|
||||
onPress={() => setIsModalVisible(true)}
|
||||
>
|
||||
<Text numberOfLines={1}>{selectedName}</Text>
|
||||
<Ionicons
|
||||
name="chevron-down"
|
||||
size={16}
|
||||
color="white"
|
||||
style={{ opacity: 0.5 }}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Modal
|
||||
visible={isModalVisible}
|
||||
transparent
|
||||
animationType="slide"
|
||||
onRequestClose={() => setIsModalVisible(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
className="flex-1 bg-black/50"
|
||||
activeOpacity={1}
|
||||
onPress={() => setIsModalVisible(false)}
|
||||
>
|
||||
<View className="mt-auto bg-neutral-900 rounded-t-xl">
|
||||
<View className="p-4 border-b border-neutral-800">
|
||||
<Text className="text-lg font-bold text-center">
|
||||
Media Sources
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="max-h-[50%]">
|
||||
{item.MediaSources?.map((source, idx: number) => (
|
||||
<TouchableOpacity
|
||||
key={idx.toString()}
|
||||
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
|
||||
onPress={() => {
|
||||
onChange(source);
|
||||
setIsModalVisible(false);
|
||||
}}
|
||||
>
|
||||
<Text>
|
||||
{`${name(source.Name)} - ${convertBitsToMegabitsOrGigabits(
|
||||
source.Size
|
||||
)}`}
|
||||
</Text>
|
||||
{source.Id === selected?.Id && (
|
||||
<Ionicons name="checkmark" size={24} color="white" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
className="p-4 border-t border-neutral-800"
|
||||
onPress={() => setIsModalVisible(false)}
|
||||
>
|
||||
<Text className="text-center text-purple-400">Cancel</Text>
|
||||
<View
|
||||
className="flex shrink"
|
||||
style={{
|
||||
minWidth: 50,
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className="flex flex-col" {...props}>
|
||||
<Text className="opacity-50 mb-1 text-xs">Video</Text>
|
||||
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center">
|
||||
<Text numberOfLines={1}>{selectedName}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>Media sources</DropdownMenu.Label>
|
||||
{item.MediaSources?.map((source, idx: number) => (
|
||||
<DropdownMenu.Item
|
||||
key={idx.toString()}
|
||||
onSelect={() => {
|
||||
onChange(source);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{`${name(source.Name)} - ${convertBitsToMegabitsOrGigabits(
|
||||
source.Size
|
||||
)}`}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const name = (name?: string | null) => {
|
||||
if (name && name.length > 40)
|
||||
return name.substring(0, 20) + " [...] " + name.substring(name.length - 20);
|
||||
return name;
|
||||
};
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Linking, TouchableOpacity, View } from "react-native";
|
||||
import CastContext, {
|
||||
PlayServicesState,
|
||||
useMediaStatus,
|
||||
useRemoteMediaClient,
|
||||
} from "react-native-google-cast";
|
||||
import Animated, {
|
||||
Easing,
|
||||
interpolate,
|
||||
@@ -20,87 +25,180 @@ import Animated, {
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { Button } from "./Button";
|
||||
import { SelectedOptions } from "./ItemContent";
|
||||
import { Text } from "./common/Text";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof Button> {
|
||||
item: BaseItemDto;
|
||||
selectedOptions: SelectedOptions;
|
||||
item?: BaseItemDto | null;
|
||||
url?: string | null;
|
||||
}
|
||||
|
||||
const ANIMATION_DURATION = 500;
|
||||
const MIN_PLAYBACK_WIDTH = 15;
|
||||
|
||||
export const PlayButton: React.FC<Props> = ({
|
||||
item,
|
||||
selectedOptions,
|
||||
...props
|
||||
}: Props) => {
|
||||
export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
const client = useRemoteMediaClient();
|
||||
const mediaStatus = useMediaStatus();
|
||||
|
||||
const [colorAtom] = useAtom(itemThemeColorAtom);
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const [api] = useAtom(apiAtom);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const memoizedItem = useMemo(() => item, [item?.Id]); // Memoize the item
|
||||
const memoizedColor = useMemo(() => colorAtom, [colorAtom]); // Memoize the color
|
||||
|
||||
const startWidth = useSharedValue(0);
|
||||
const targetWidth = useSharedValue(0);
|
||||
const endColor = useSharedValue(colorAtom);
|
||||
const startColor = useSharedValue(colorAtom);
|
||||
const endColor = useSharedValue(memoizedColor);
|
||||
const startColor = useSharedValue(memoizedColor);
|
||||
const widthProgress = useSharedValue(0);
|
||||
const colorChangeProgress = useSharedValue(0);
|
||||
const [settings] = useSettings();
|
||||
|
||||
const goToPlayer = useCallback(
|
||||
(q: string, bitrateValue: number | undefined) => {
|
||||
if (!bitrateValue) {
|
||||
router.push(`/player/direct-player?${q}`);
|
||||
const directStream = useMemo(() => {
|
||||
return !url?.includes("m3u8");
|
||||
}, [url]);
|
||||
|
||||
const onPress = async () => {
|
||||
if (!url || !item) {
|
||||
console.warn(
|
||||
"No URL or item provided to PlayButton",
|
||||
url?.slice(0, 100),
|
||||
item?.Id
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!client) {
|
||||
const vlcLink = "vlc://" + url;
|
||||
if (vlcLink && settings?.openInVLC) {
|
||||
Linking.openURL(vlcLink);
|
||||
return;
|
||||
}
|
||||
router.push(`/player/transcoding-player?${q}`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const onPress = useCallback(async () => {
|
||||
if (!item) return;
|
||||
router.push("/play-video");
|
||||
return;
|
||||
}
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: item.Id!,
|
||||
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
|
||||
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
|
||||
mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
|
||||
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
|
||||
});
|
||||
const options = ["Chromecast", "Device", "Cancel"];
|
||||
const cancelButtonIndex = 2;
|
||||
showActionSheetWithOptions(
|
||||
{
|
||||
options,
|
||||
cancelButtonIndex,
|
||||
},
|
||||
async (selectedIndex: number | undefined) => {
|
||||
if (!api) return;
|
||||
const currentTitle = mediaStatus?.mediaInfo?.metadata?.title;
|
||||
const isOpeningCurrentlyPlayingMedia =
|
||||
currentTitle && currentTitle === item?.Name;
|
||||
|
||||
const queryString = queryParams.toString();
|
||||
|
||||
goToPlayer(queryString, selectedOptions.bitrate?.value);
|
||||
|
||||
return;
|
||||
}, [
|
||||
item,
|
||||
settings,
|
||||
api,
|
||||
user,
|
||||
router,
|
||||
showActionSheetWithOptions,
|
||||
selectedOptions,
|
||||
]);
|
||||
switch (selectedIndex) {
|
||||
case 0:
|
||||
await CastContext.getPlayServicesState().then((state) => {
|
||||
if (state && state !== PlayServicesState.SUCCESS)
|
||||
CastContext.showPlayServicesErrorDialog(state);
|
||||
else {
|
||||
// If we're opening a currently playing item, don't restart the media.
|
||||
// Instead just open controls.
|
||||
if (isOpeningCurrentlyPlayingMedia) {
|
||||
CastContext.showExpandedControls();
|
||||
return;
|
||||
}
|
||||
client
|
||||
.loadMedia({
|
||||
mediaInfo: {
|
||||
contentUrl: url,
|
||||
contentType: "video/mp4",
|
||||
metadata:
|
||||
item.Type === "Episode"
|
||||
? {
|
||||
type: "tvShow",
|
||||
title: item.Name || "",
|
||||
episodeNumber: item.IndexNumber || 0,
|
||||
seasonNumber: item.ParentIndexNumber || 0,
|
||||
seriesTitle: item.SeriesName || "",
|
||||
images: [
|
||||
{
|
||||
url: getParentBackdropImageUrl({
|
||||
api,
|
||||
item,
|
||||
quality: 90,
|
||||
width: 2000,
|
||||
})!,
|
||||
},
|
||||
],
|
||||
}
|
||||
: item.Type === "Movie"
|
||||
? {
|
||||
type: "movie",
|
||||
title: item.Name || "",
|
||||
subtitle: item.Overview || "",
|
||||
images: [
|
||||
{
|
||||
url: getPrimaryImageUrl({
|
||||
api,
|
||||
item,
|
||||
quality: 90,
|
||||
width: 2000,
|
||||
})!,
|
||||
},
|
||||
],
|
||||
}
|
||||
: {
|
||||
type: "generic",
|
||||
title: item.Name || "",
|
||||
subtitle: item.Overview || "",
|
||||
images: [
|
||||
{
|
||||
url: getPrimaryImageUrl({
|
||||
api,
|
||||
item,
|
||||
quality: 90,
|
||||
width: 2000,
|
||||
})!,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
startTime: 0,
|
||||
})
|
||||
.then(() => {
|
||||
// state is already set when reopening current media, so skip it here.
|
||||
if (isOpeningCurrentlyPlayingMedia) {
|
||||
return;
|
||||
}
|
||||
CastContext.showExpandedControls();
|
||||
});
|
||||
}
|
||||
});
|
||||
break;
|
||||
case 1:
|
||||
router.push("/play-video");
|
||||
break;
|
||||
case cancelButtonIndex:
|
||||
break;
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const derivedTargetWidth = useDerivedValue(() => {
|
||||
if (!item || !item.RunTimeTicks) return 0;
|
||||
const userData = item.UserData;
|
||||
if (!memoizedItem || !memoizedItem.RunTimeTicks) return 0;
|
||||
const userData = memoizedItem.UserData;
|
||||
if (userData && userData.PlaybackPositionTicks) {
|
||||
return userData.PlaybackPositionTicks > 0
|
||||
? Math.max(
|
||||
(userData.PlaybackPositionTicks / item.RunTimeTicks) * 100,
|
||||
(userData.PlaybackPositionTicks / memoizedItem.RunTimeTicks) * 100,
|
||||
MIN_PLAYBACK_WIDTH
|
||||
)
|
||||
: 0;
|
||||
}
|
||||
return 0;
|
||||
}, [item]);
|
||||
}, [memoizedItem]);
|
||||
|
||||
useAnimatedReaction(
|
||||
() => derivedTargetWidth.value,
|
||||
@@ -116,7 +214,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
);
|
||||
|
||||
useAnimatedReaction(
|
||||
() => colorAtom,
|
||||
() => memoizedColor,
|
||||
(newColor) => {
|
||||
endColor.value = newColor;
|
||||
colorChangeProgress.value = 0;
|
||||
@@ -125,19 +223,19 @@ export const PlayButton: React.FC<Props> = ({
|
||||
easing: Easing.bezier(0.9, 0, 0.31, 0.99),
|
||||
});
|
||||
},
|
||||
[colorAtom]
|
||||
[memoizedColor]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout_2 = setTimeout(() => {
|
||||
startColor.value = colorAtom;
|
||||
startColor.value = memoizedColor;
|
||||
startWidth.value = targetWidth.value;
|
||||
}, ANIMATION_DURATION);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout_2);
|
||||
};
|
||||
}, [colorAtom, item]);
|
||||
}, [memoizedColor, memoizedItem]);
|
||||
|
||||
/**
|
||||
* ANIMATED STYLES
|
||||
@@ -180,11 +278,10 @@ export const PlayButton: React.FC<Props> = ({
|
||||
return (
|
||||
<View>
|
||||
<TouchableOpacity
|
||||
disabled={!item}
|
||||
accessibilityLabel="Play button"
|
||||
accessibilityHint="Tap to play the media"
|
||||
onPress={onPress}
|
||||
className={`relative`}
|
||||
className="relative"
|
||||
{...props}
|
||||
>
|
||||
<View className="absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden">
|
||||
@@ -218,17 +315,24 @@ export const PlayButton: React.FC<Props> = ({
|
||||
<Animated.Text style={animatedTextStyle}>
|
||||
<Ionicons name="play-circle" size={24} />
|
||||
</Animated.Text>
|
||||
<Animated.Text style={animatedTextStyle}>
|
||||
<MaterialCommunityIcons
|
||||
name="vlc"
|
||||
size={18}
|
||||
color={animatedTextStyle.color}
|
||||
/>
|
||||
</Animated.Text>
|
||||
{client && (
|
||||
<Animated.Text style={animatedTextStyle}>
|
||||
<Feather name="cast" size={22} />
|
||||
</Animated.Text>
|
||||
)}
|
||||
{!client && settings?.openInVLC && (
|
||||
<Animated.Text style={animatedTextStyle}>
|
||||
<MaterialCommunityIcons
|
||||
name="vlc"
|
||||
size={18}
|
||||
color={animatedTextStyle.color}
|
||||
/>
|
||||
</Animated.Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
{/* <View className="mt-2 flex flex-row items-center">
|
||||
<View className="mt-2 flex flex-row items-center">
|
||||
<Ionicons
|
||||
name="information-circle"
|
||||
size={12}
|
||||
@@ -238,7 +342,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
<Text className="text-neutral-500 ml-1">
|
||||
{directStream ? "Direct stream" : "Transcoded stream"}
|
||||
</Text>
|
||||
</View> */}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { markAsNotPlayed } from "@/utils/jellyfin/playstate/markAsNotPlayed";
|
||||
import { markAsPlayed } from "@/utils/jellyfin/playstate/markAsPlayed";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useAtom } from "jotai";
|
||||
import React from "react";
|
||||
import { View, ViewProps } from "react-native";
|
||||
import { RoundButton } from "./RoundButton";
|
||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
item: BaseItemDto;
|
||||
}
|
||||
|
||||
export const PlayedStatus: React.FC<Props> = ({ item, ...props }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const invalidateQueries = () => {
|
||||
@@ -34,21 +41,52 @@ export const PlayedStatus: React.FC<Props> = ({ item, ...props }) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["seasons"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["nextUp-all"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["home"],
|
||||
});
|
||||
};
|
||||
|
||||
const markAsPlayedStatus = useMarkAsPlayed(item);
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<RoundButton
|
||||
fillColor={item.UserData?.Played ? "primary" : undefined}
|
||||
icon={item.UserData?.Played ? "checkmark" : "checkmark"}
|
||||
onPress={() => markAsPlayedStatus(item.UserData?.Played || false)}
|
||||
size="large"
|
||||
/>
|
||||
<View
|
||||
className=" bg-neutral-800/80 rounded-full h-10 w-10 flex items-center justify-center"
|
||||
{...props}
|
||||
>
|
||||
{item.UserData?.Played ? (
|
||||
<TouchableOpacity
|
||||
onPress={async () => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
await markAsNotPlayed({
|
||||
api: api,
|
||||
itemId: item?.Id,
|
||||
userId: user?.Id,
|
||||
});
|
||||
invalidateQueries();
|
||||
}}
|
||||
>
|
||||
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
||||
<Ionicons name="checkmark-circle" size={24} color="white" />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
onPress={async () => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
await markAsPlayed({
|
||||
api: api,
|
||||
item: item,
|
||||
userId: user?.Id,
|
||||
});
|
||||
invalidateQueries();
|
||||
}}
|
||||
>
|
||||
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
||||
<Ionicons name="checkmark-circle-outline" size={24} color="white" />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,10 +3,6 @@ import { View, ViewProps } from "react-native";
|
||||
import { Badge } from "./Badge";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Image } from "expo-image";
|
||||
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
item?: BaseItemDto | null;
|
||||
@@ -21,7 +17,7 @@ export const Ratings: React.FC<Props> = ({ item, ...props }) => {
|
||||
)}
|
||||
{item.CommunityRating && (
|
||||
<Badge
|
||||
text={item.CommunityRating.toFixed(1)}
|
||||
text={item.CommunityRating}
|
||||
variant="gray"
|
||||
iconLeft={<Ionicons name="star" size={14} color="gold" />}
|
||||
/>
|
||||
@@ -32,11 +28,7 @@ export const Ratings: React.FC<Props> = ({ item, ...props }) => {
|
||||
variant="gray"
|
||||
iconLeft={
|
||||
<Image
|
||||
source={
|
||||
item.CriticRating < 60
|
||||
? require("@/assets/images/rotten-tomatoes.png")
|
||||
: require("@/assets/images/not-rotten-tomatoes.svg")
|
||||
}
|
||||
source={require("@/assets/images/rotten-tomatoes.png")}
|
||||
style={{
|
||||
width: 14,
|
||||
height: 14,
|
||||
@@ -48,86 +40,3 @@ export const Ratings: React.FC<Props> = ({ item, ...props }) => {
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export const JellyserrRatings: React.FC<{ result: MovieResult | TvResult }> = ({
|
||||
result,
|
||||
}) => {
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["jellyseerr", result.id, result.mediaType, "ratings"],
|
||||
queryFn: async () => {
|
||||
return result.mediaType === MediaType.MOVIE
|
||||
? jellyseerrApi?.movieRatings(result.id)
|
||||
: jellyseerrApi?.tvRatings(result.id);
|
||||
},
|
||||
staleTime: (5).minutesToMilliseconds(),
|
||||
retry: false,
|
||||
enabled: !!jellyseerrApi,
|
||||
});
|
||||
|
||||
return (
|
||||
(isLoading ||
|
||||
!!result.voteCount ||
|
||||
(data?.criticsRating && !!data?.criticsScore) ||
|
||||
(data?.audienceRating && !!data?.audienceScore)) && (
|
||||
<View className="flex flex-row flex-wrap space-x-1">
|
||||
{data?.criticsRating && !!data?.criticsScore && (
|
||||
<Badge
|
||||
text={`${data.criticsScore}%`}
|
||||
variant="gray"
|
||||
iconLeft={
|
||||
<Image
|
||||
className="mr-1"
|
||||
source={
|
||||
data?.criticsRating === "Rotten"
|
||||
? require("@/utils/jellyseerr/src/assets/rt_rotten.svg")
|
||||
: require("@/utils/jellyseerr/src/assets/rt_fresh.svg")
|
||||
}
|
||||
style={{
|
||||
width: 14,
|
||||
height: 14,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{data?.audienceRating && !!data?.audienceScore && (
|
||||
<Badge
|
||||
text={`${data.audienceScore}%`}
|
||||
variant="gray"
|
||||
iconLeft={
|
||||
<Image
|
||||
className="mr-1"
|
||||
source={
|
||||
data?.audienceRating === "Spilled"
|
||||
? require("@/utils/jellyseerr/src/assets/rt_aud_rotten.svg")
|
||||
: require("@/utils/jellyseerr/src/assets/rt_aud_fresh.svg")
|
||||
}
|
||||
style={{
|
||||
width: 14,
|
||||
height: 14,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!!result.voteCount && (
|
||||
<Badge
|
||||
text={`${Math.round(result.voteAverage * 10)}%`}
|
||||
variant="gray"
|
||||
iconLeft={
|
||||
<Image
|
||||
className="mr-1"
|
||||
source={require("@/utils/jellyseerr/src/assets/tmdb_logo.svg")}
|
||||
style={{
|
||||
width: 14,
|
||||
height: 14,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { BlurView } from "expo-blur";
|
||||
import { PropsWithChildren } from "react";
|
||||
import {
|
||||
Platform,
|
||||
TouchableOpacity,
|
||||
TouchableOpacityProps,
|
||||
} from "react-native";
|
||||
|
||||
interface Props extends TouchableOpacityProps {
|
||||
onPress?: () => void;
|
||||
icon?: keyof typeof Ionicons.glyphMap;
|
||||
background?: boolean;
|
||||
size?: "default" | "large";
|
||||
fillColor?: "primary";
|
||||
hapticFeedback?: boolean;
|
||||
}
|
||||
|
||||
export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
|
||||
background = true,
|
||||
icon,
|
||||
onPress,
|
||||
children,
|
||||
size = "default",
|
||||
fillColor,
|
||||
hapticFeedback = true,
|
||||
...props
|
||||
}) => {
|
||||
const buttonSize = size === "large" ? "h-10 w-10" : "h-9 w-9";
|
||||
const fillColorClass = fillColor === "primary" ? "bg-purple-600" : "";
|
||||
|
||||
const handlePress = () => {
|
||||
if (hapticFeedback) {
|
||||
}
|
||||
onPress?.();
|
||||
};
|
||||
|
||||
if (fillColor)
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`}
|
||||
{...props}
|
||||
>
|
||||
{icon ? (
|
||||
<Ionicons
|
||||
name={icon}
|
||||
size={size === "large" ? 22 : 18}
|
||||
color={"white"}
|
||||
/>
|
||||
) : null}
|
||||
{children ? children : null}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
if (background === false)
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`}
|
||||
{...props}
|
||||
>
|
||||
{icon ? (
|
||||
<Ionicons
|
||||
name={icon}
|
||||
size={size === "large" ? 22 : 18}
|
||||
color={"white"}
|
||||
/>
|
||||
) : null}
|
||||
{children ? children : null}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
if (Platform.OS === "android")
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
className={`rounded-full ${buttonSize} flex items-center justify-center ${
|
||||
fillColor ? fillColorClass : "bg-neutral-800/80"
|
||||
}`}
|
||||
{...props}
|
||||
>
|
||||
{icon ? (
|
||||
<Ionicons
|
||||
name={icon}
|
||||
size={size === "large" ? 22 : 18}
|
||||
color={"white"}
|
||||
/>
|
||||
) : null}
|
||||
{children ? children : null}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
return (
|
||||
<TouchableOpacity onPress={handlePress} {...props}>
|
||||
<BlurView
|
||||
intensity={90}
|
||||
className={`rounded-full overflow-hidden ${buttonSize} flex items-center justify-center ${fillColorClass}`}
|
||||
>
|
||||
{icon ? (
|
||||
<Ionicons
|
||||
name={icon}
|
||||
size={size === "large" ? 22 : 18}
|
||||
color={"white"}
|
||||
/>
|
||||
) : null}
|
||||
{children ? children : null}
|
||||
</BlurView>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
@@ -1,36 +1,26 @@
|
||||
import { tc } from "@/utils/textTools";
|
||||
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Platform, TouchableOpacity, View, Modal } from "react-native";
|
||||
import { useMemo } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { Text } from "./common/Text";
|
||||
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof View> {
|
||||
source?: MediaSourceInfo;
|
||||
source: MediaSourceInfo;
|
||||
onChange: (value: number) => void;
|
||||
selected?: number | undefined;
|
||||
isTranscoding?: boolean;
|
||||
selected?: number | null;
|
||||
}
|
||||
|
||||
export const SubtitleTrackSelector: React.FC<Props> = ({
|
||||
source,
|
||||
onChange,
|
||||
selected,
|
||||
isTranscoding,
|
||||
...props
|
||||
}) => {
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
|
||||
const subtitleStreams = useMemo(() => {
|
||||
const subtitleHelper = new SubtitleHelper(source?.MediaStreams ?? []);
|
||||
|
||||
if (isTranscoding && Platform.OS === "ios") {
|
||||
return subtitleHelper.getUniqueSubtitles();
|
||||
}
|
||||
|
||||
return subtitleHelper.getSubtitles();
|
||||
}, [source, isTranscoding]);
|
||||
const subtitleStreams = useMemo(
|
||||
() => source.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [],
|
||||
[source]
|
||||
);
|
||||
|
||||
const selectedSubtitleSteam = useMemo(
|
||||
() => subtitleStreams.find((x) => x.Index === selected),
|
||||
@@ -40,98 +30,59 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
||||
if (subtitleStreams.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<View
|
||||
className="flex col shrink justify-start place-self-start items-start"
|
||||
style={{
|
||||
minWidth: 60,
|
||||
maxWidth: 200,
|
||||
}}
|
||||
>
|
||||
<View className="flex flex-col" {...props}>
|
||||
<Text className="opacity-50 mb-1 text-xs">Subtitle</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"
|
||||
onPress={() => setIsModalVisible(true)}
|
||||
>
|
||||
<Text>
|
||||
{selectedSubtitleSteam
|
||||
? tc(selectedSubtitleSteam?.DisplayTitle, 7)
|
||||
: "None"}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name="chevron-down"
|
||||
size={16}
|
||||
color="white"
|
||||
style={{ opacity: 0.5 }}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Modal
|
||||
visible={isModalVisible}
|
||||
transparent
|
||||
animationType="slide"
|
||||
onRequestClose={() => setIsModalVisible(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
className="flex-1 bg-black/50"
|
||||
activeOpacity={1}
|
||||
onPress={() => setIsModalVisible(false)}
|
||||
>
|
||||
<View className="mt-auto bg-neutral-900 rounded-t-xl">
|
||||
<View className="p-4 border-b border-neutral-800">
|
||||
<Text className="text-lg font-bold text-center">
|
||||
Subtitle Tracks
|
||||
<View
|
||||
className="flex col shrink justify-start place-self-start items-start"
|
||||
style={{
|
||||
minWidth: 60,
|
||||
maxWidth: 200,
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className="flex flex-col " {...props}>
|
||||
<Text className="opacity-50 mb-1 text-xs">Subtitle</Text>
|
||||
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text className=" ">
|
||||
{selectedSubtitleSteam
|
||||
? tc(selectedSubtitleSteam?.DisplayTitle, 7)
|
||||
: "None"}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="max-h-[50%]">
|
||||
<TouchableOpacity
|
||||
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
|
||||
onPress={() => {
|
||||
onChange(-1);
|
||||
setIsModalVisible(false);
|
||||
}}
|
||||
>
|
||||
<Text>None</Text>
|
||||
{selected === -1 && (
|
||||
<Ionicons name="checkmark" size={24} color="white" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
{subtitleStreams?.map((subtitle, idx: number) => (
|
||||
<TouchableOpacity
|
||||
key={idx.toString()}
|
||||
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
|
||||
onPress={() => {
|
||||
if (
|
||||
subtitle.Index !== undefined &&
|
||||
subtitle.Index !== null
|
||||
) {
|
||||
onChange(subtitle.Index);
|
||||
setIsModalVisible(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text>{subtitle.DisplayTitle}</Text>
|
||||
{subtitle.Index === selected && (
|
||||
<Ionicons name="checkmark" size={24} color="white" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
className="p-4 border-t border-neutral-800"
|
||||
onPress={() => setIsModalVisible(false)}
|
||||
>
|
||||
<Text className="text-center text-purple-400">Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>Subtitle tracks</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
key={"-1"}
|
||||
onSelect={() => {
|
||||
onChange(-1);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>None</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
{subtitleStreams?.map((subtitle, idx: number) => (
|
||||
<DropdownMenu.Item
|
||||
key={idx.toString()}
|
||||
onSelect={() => {
|
||||
if (subtitle.Index !== undefined && subtitle.Index !== null)
|
||||
onChange(subtitle.Index);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{subtitle.DisplayTitle}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -16,7 +16,6 @@ interface HorizontalScrollProps<T>
|
||||
> {
|
||||
data?: T[] | null;
|
||||
renderItem: (item: T, index: number) => React.ReactNode;
|
||||
keyExtractor?: (item: T, index: number) => string;
|
||||
containerStyle?: ViewStyle;
|
||||
contentContainerStyle?: ViewStyle;
|
||||
loadingContainerStyle?: ViewStyle;
|
||||
@@ -33,7 +32,6 @@ export const HorizontalScroll = forwardRef<
|
||||
<T,>(
|
||||
{
|
||||
data = [],
|
||||
keyExtractor,
|
||||
renderItem,
|
||||
containerStyle,
|
||||
contentContainerStyle,
|
||||
@@ -93,7 +91,6 @@ export const HorizontalScroll = forwardRef<
|
||||
paddingHorizontal: 16,
|
||||
...contentContainerStyle,
|
||||
}}
|
||||
keyExtractor={keyExtractor}
|
||||
ListEmptyComponent={() => (
|
||||
<View className="flex-1 justify-center items-center">
|
||||
<Text className="text-center text-gray-500">
|
||||
@@ -101,7 +98,6 @@ export const HorizontalScroll = forwardRef<
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useImageColors } from "@/hooks/useImageColors";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getItemImage } from "@/utils/getItemImage";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import { useRouter, useSegments } from "expo-router";
|
||||
import React, { PropsWithChildren, useCallback, useMemo } from "react";
|
||||
import { TouchableOpacity, TouchableOpacityProps } from "react-native";
|
||||
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import {
|
||||
hasPermission,
|
||||
Permission,
|
||||
} from "@/utils/jellyseerr/server/lib/permissions";
|
||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||
|
||||
interface Props extends TouchableOpacityProps {
|
||||
result: MovieResult | TvResult;
|
||||
mediaTitle: string;
|
||||
releaseYear: number;
|
||||
canRequest: boolean;
|
||||
posterSrc: string;
|
||||
}
|
||||
|
||||
export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
result,
|
||||
mediaTitle,
|
||||
releaseYear,
|
||||
canRequest,
|
||||
posterSrc,
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const segments = useSegments();
|
||||
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
|
||||
|
||||
const from = segments[2];
|
||||
|
||||
const autoApprove = useMemo(() => {
|
||||
return (
|
||||
jellyseerrUser &&
|
||||
hasPermission(Permission.AUTO_APPROVE, jellyseerrUser.permissions, {
|
||||
type: "or",
|
||||
})
|
||||
);
|
||||
}, [jellyseerrApi, jellyseerrUser]);
|
||||
|
||||
const request = useCallback(
|
||||
() =>
|
||||
requestMedia(mediaTitle, {
|
||||
mediaId: result.id,
|
||||
mediaType: result.mediaType,
|
||||
}),
|
||||
[jellyseerrApi, result]
|
||||
);
|
||||
|
||||
if (from === "(home)" || from === "(search)" || from === "(libraries)")
|
||||
return (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
// @ts-ignore
|
||||
router.push({
|
||||
pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`,
|
||||
params: {
|
||||
...result,
|
||||
mediaTitle,
|
||||
releaseYear,
|
||||
canRequest,
|
||||
posterSrc,
|
||||
},
|
||||
});
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useRouter, useSegments } from "expo-router";
|
||||
import { PropsWithChildren } from "react";
|
||||
import { TouchableOpacity, TouchableOpacityProps } from "react-native";
|
||||
import * as ContextMenu from "zeego/context-menu";
|
||||
|
||||
interface Props extends TouchableOpacityProps {
|
||||
item: BaseItemDto;
|
||||
@@ -46,10 +45,6 @@ export const itemRouter = (item: BaseItemDto, from: string) => {
|
||||
return `/(auth)/(tabs)/(libraries)/${item.Id}`;
|
||||
}
|
||||
|
||||
if (item.Type === "Playlist") {
|
||||
return `/(auth)/(tabs)/(libraries)/${item.Id}`;
|
||||
}
|
||||
|
||||
return `/(auth)/(tabs)/${from}/items/page?id=${item.Id}`;
|
||||
};
|
||||
|
||||
@@ -63,12 +58,11 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
|
||||
const from = segments[2];
|
||||
|
||||
const markAsPlayedStatus = useMarkAsPlayed(item);
|
||||
|
||||
if (from === "(home)" || from === "(search)" || from === "(libraries)")
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
const url = itemRouter(item, from);
|
||||
// @ts-ignore
|
||||
router.push(url);
|
||||
|
||||
191
components/downloads/ActiveDownloads.tsx
Normal file
191
components/downloads/ActiveDownloads.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { JobStatus } from "@/utils/optimize-server";
|
||||
import { formatTimeString } from "@/utils/time";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { checkForExistingDownloads } from "@kesha-antonov/react-native-background-downloader";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useRouter } from "expo-router";
|
||||
import { FFmpegKit } from "ffmpeg-kit-react-native";
|
||||
import { useAtom } from "jotai";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
TouchableOpacity,
|
||||
TouchableOpacityProps,
|
||||
View,
|
||||
ViewProps,
|
||||
} from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { Button } from "../Button";
|
||||
import { Image } from "expo-image";
|
||||
import { useMemo } from "react";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
|
||||
interface Props extends ViewProps {}
|
||||
|
||||
export const ActiveDownloads: React.FC<Props> = ({ ...props }) => {
|
||||
const { processes, startDownload } = useDownload();
|
||||
if (processes?.length === 0)
|
||||
return (
|
||||
<View {...props} className="bg-neutral-900 p-4 rounded-2xl">
|
||||
<Text className="text-lg font-bold">Active download</Text>
|
||||
<Text className="opacity-50">No active downloads</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View {...props} className="bg-neutral-900 p-4 rounded-2xl">
|
||||
<Text className="text-lg font-bold mb-2">Active downloads</Text>
|
||||
<View className="space-y-2">
|
||||
{processes?.map((p) => (
|
||||
<DownloadCard key={p.id} process={p} />
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
interface DownloadCardProps extends TouchableOpacityProps {
|
||||
process: JobStatus;
|
||||
}
|
||||
|
||||
const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||
const { processes, startDownload } = useDownload();
|
||||
const router = useRouter();
|
||||
const { removeProcess, setProcesses } = useDownload();
|
||||
const [settings] = useSettings();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const cancelJobMutation = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
if (!process) throw new Error("No active download");
|
||||
|
||||
if (settings?.downloadMethod === "optimized") {
|
||||
try {
|
||||
const tasks = await checkForExistingDownloads();
|
||||
for (const task of tasks) {
|
||||
if (task.id === id) {
|
||||
task.stop();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
throw e;
|
||||
} finally {
|
||||
await removeProcess(id);
|
||||
await queryClient.refetchQueries({ queryKey: ["jobs"] });
|
||||
}
|
||||
} else {
|
||||
FFmpegKit.cancel();
|
||||
setProcesses((prev) => prev.filter((p) => p.id !== id));
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Download canceled");
|
||||
},
|
||||
onError: (e) => {
|
||||
console.log(e);
|
||||
toast.error("Could not cancel download");
|
||||
},
|
||||
});
|
||||
|
||||
const eta = (p: JobStatus) => {
|
||||
if (!p.speed || !p.progress) return null;
|
||||
|
||||
const length = p?.item?.RunTimeTicks || 0;
|
||||
const timeLeft = (length - length * (p.progress / 100)) / p.speed;
|
||||
return formatTimeString(timeLeft, true);
|
||||
};
|
||||
|
||||
const base64Image = useMemo(() => {
|
||||
return storage.getString(process.item.Id!);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push(`/(auth)/items/page?id=${process.item.Id}`)}
|
||||
className="relative bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden"
|
||||
{...props}
|
||||
>
|
||||
{(process.status === "optimizing" ||
|
||||
process.status === "downloading") && (
|
||||
<View
|
||||
className={`
|
||||
bg-purple-600 h-1 absolute bottom-0 left-0
|
||||
`}
|
||||
style={{
|
||||
width: process.progress
|
||||
? `${Math.max(5, process.progress)}%`
|
||||
: "5%",
|
||||
}}
|
||||
></View>
|
||||
)}
|
||||
<View className="px-3 py-1.5 flex flex-col w-full">
|
||||
<View className="flex flex-row items-center w-full">
|
||||
{base64Image && (
|
||||
<View className="w-14 aspect-[10/15] rounded-lg overflow-hidden mr-4">
|
||||
<Image
|
||||
source={{
|
||||
uri: `data:image/jpeg;base64,${base64Image}`,
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
resizeMode: "cover",
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<View className="shrink mb-1">
|
||||
<Text className="text-xs opacity-50">{process.item.Type}</Text>
|
||||
<Text className="font-semibold shrink">{process.item.Name}</Text>
|
||||
<Text className="text-xs opacity-50">
|
||||
{process.item.ProductionYear}
|
||||
</Text>
|
||||
<View className="flex flex-row items-center space-x-2 mt-1 text-purple-600">
|
||||
{process.progress === 0 ? (
|
||||
<ActivityIndicator size={"small"} color={"white"} />
|
||||
) : (
|
||||
<Text className="text-xs">{process.progress.toFixed(0)}%</Text>
|
||||
)}
|
||||
{process.speed && (
|
||||
<Text className="text-xs">{process.speed?.toFixed(2)}x</Text>
|
||||
)}
|
||||
{eta(process) && (
|
||||
<Text className="text-xs">ETA {eta(process)}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className="flex flex-row items-center space-x-2 mt-1 text-purple-600">
|
||||
<Text className="text-xs capitalize">{process.status}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
disabled={cancelJobMutation.isPending}
|
||||
onPress={() => cancelJobMutation.mutate(process.id)}
|
||||
className="ml-auto"
|
||||
>
|
||||
{cancelJobMutation.isPending ? (
|
||||
<ActivityIndicator size="small" color="white" />
|
||||
) : (
|
||||
<Ionicons name="close" size={24} color="red" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{process.status === "completed" && (
|
||||
<View className="flex flex-row mt-4 space-x-4">
|
||||
<Button
|
||||
onPress={() => {
|
||||
startDownload(process);
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
Download now
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
116
components/downloads/EpisodeCard.tsx
Normal file
116
components/downloads/EpisodeCard.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import React, { useCallback, useMemo, useRef } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import {
|
||||
ActionSheetProvider,
|
||||
useActionSheet,
|
||||
} from "@expo/react-native-action-sheet";
|
||||
|
||||
import { useFileOpener } from "@/hooks/useDownloadedFileOpener";
|
||||
import { Text } from "../common/Text";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { Image } from "expo-image";
|
||||
import { ItemCardText } from "../ItemCardText";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
|
||||
interface EpisodeCardProps {
|
||||
item: BaseItemDto;
|
||||
}
|
||||
|
||||
/**
|
||||
* EpisodeCard component displays an episode with action sheet options.
|
||||
* @param {EpisodeCardProps} props - The component props.
|
||||
* @returns {React.ReactElement} The rendered EpisodeCard component.
|
||||
*/
|
||||
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
|
||||
const { deleteFile } = useDownload();
|
||||
const { openFile } = useFileOpener();
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
|
||||
const base64Image = useMemo(() => {
|
||||
return storage.getString(item.Id!);
|
||||
}, []);
|
||||
|
||||
const handleOpenFile = useCallback(() => {
|
||||
openFile(item);
|
||||
}, [item, openFile]);
|
||||
|
||||
/**
|
||||
* Handles deleting the file with haptic feedback.
|
||||
*/
|
||||
const handleDeleteFile = useCallback(() => {
|
||||
if (item.Id) {
|
||||
deleteFile(item.Id);
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
}
|
||||
}, [deleteFile, item.Id]);
|
||||
|
||||
const showActionSheet = useCallback(() => {
|
||||
const options = ["Delete", "Cancel"];
|
||||
const destructiveButtonIndex = 0;
|
||||
const cancelButtonIndex = 1;
|
||||
|
||||
showActionSheetWithOptions(
|
||||
{
|
||||
options,
|
||||
cancelButtonIndex,
|
||||
destructiveButtonIndex,
|
||||
},
|
||||
(selectedIndex) => {
|
||||
switch (selectedIndex) {
|
||||
case destructiveButtonIndex:
|
||||
// Delete
|
||||
handleDeleteFile();
|
||||
break;
|
||||
case cancelButtonIndex:
|
||||
// Cancelled
|
||||
break;
|
||||
}
|
||||
}
|
||||
);
|
||||
}, [showActionSheetWithOptions, handleDeleteFile]);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={handleOpenFile}
|
||||
onLongPress={showActionSheet}
|
||||
className="flex flex-col w-44 mr-2"
|
||||
>
|
||||
{base64Image ? (
|
||||
<View className="w-44 aspect-video rounded-lg overflow-hidden">
|
||||
<Image
|
||||
source={{
|
||||
uri: `data:image/jpeg;base64,${base64Image}`,
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
resizeMode: "cover",
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<View className="w-44 aspect-video rounded-lg bg-neutral-900 flex items-center justify-center">
|
||||
<Ionicons
|
||||
name="image-outline"
|
||||
size={24}
|
||||
color="gray"
|
||||
className="self-center mt-16"
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<ItemCardText item={item} />
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
// Wrap the parent component with ActionSheetProvider
|
||||
export const EpisodeCardWithActionSheet: React.FC<EpisodeCardProps> = (
|
||||
props
|
||||
) => (
|
||||
<ActionSheetProvider>
|
||||
<EpisodeCard {...props} />
|
||||
</ActionSheetProvider>
|
||||
);
|
||||
112
components/downloads/MovieCard.tsx
Normal file
112
components/downloads/MovieCard.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import {
|
||||
ActionSheetProvider,
|
||||
useActionSheet,
|
||||
} from "@expo/react-native-action-sheet";
|
||||
|
||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||
import { Text } from "../common/Text";
|
||||
|
||||
import { useFileOpener } from "@/hooks/useDownloadedFileOpener";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { Image } from "expo-image";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { ItemCardText } from "../ItemCardText";
|
||||
|
||||
interface MovieCardProps {
|
||||
item: BaseItemDto;
|
||||
}
|
||||
|
||||
/**
|
||||
* MovieCard component displays a movie with action sheet options.
|
||||
* @param {MovieCardProps} props - The component props.
|
||||
* @returns {React.ReactElement} The rendered MovieCard component.
|
||||
*/
|
||||
export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
||||
const { deleteFile } = useDownload();
|
||||
const { openFile } = useFileOpener();
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
|
||||
const handleOpenFile = useCallback(() => {
|
||||
openFile(item);
|
||||
}, [item, openFile]);
|
||||
|
||||
const base64Image = useMemo(() => {
|
||||
return storage.getString(item.Id!);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Handles deleting the file with haptic feedback.
|
||||
*/
|
||||
const handleDeleteFile = useCallback(() => {
|
||||
if (item.Id) {
|
||||
deleteFile(item.Id);
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
}
|
||||
}, [deleteFile, item.Id]);
|
||||
|
||||
const showActionSheet = useCallback(() => {
|
||||
const options = ["Delete", "Cancel"];
|
||||
const destructiveButtonIndex = 0;
|
||||
const cancelButtonIndex = 1;
|
||||
|
||||
showActionSheetWithOptions(
|
||||
{
|
||||
options,
|
||||
cancelButtonIndex,
|
||||
destructiveButtonIndex,
|
||||
},
|
||||
(selectedIndex) => {
|
||||
switch (selectedIndex) {
|
||||
case destructiveButtonIndex:
|
||||
// Delete
|
||||
handleDeleteFile();
|
||||
break;
|
||||
case cancelButtonIndex:
|
||||
// Cancelled
|
||||
break;
|
||||
}
|
||||
}
|
||||
);
|
||||
}, [showActionSheetWithOptions, handleDeleteFile]);
|
||||
|
||||
return (
|
||||
<TouchableOpacity onPress={handleOpenFile} onLongPress={showActionSheet}>
|
||||
{base64Image ? (
|
||||
<View className="w-28 aspect-[10/15] rounded-lg overflow-hidden mr-2 border border-neutral-900">
|
||||
<Image
|
||||
source={{
|
||||
uri: `data:image/jpeg;base64,${base64Image}`,
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
resizeMode: "cover",
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<View className="w-28 aspect-[10/15] rounded-lg bg-neutral-900 mr-2 flex items-center justify-center">
|
||||
<Ionicons
|
||||
name="image-outline"
|
||||
size={24}
|
||||
color="gray"
|
||||
className="self-center mt-16"
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<ItemCardText item={item} />
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
// Wrap the parent component with ActionSheetProvider
|
||||
export const MovieCardWithActionSheet: React.FC<MovieCardProps> = (props) => (
|
||||
<ActionSheetProvider>
|
||||
<MovieCard {...props} />
|
||||
</ActionSheetProvider>
|
||||
);
|
||||
55
components/downloads/SeriesCard.tsx
Normal file
55
components/downloads/SeriesCard.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { ScrollView, View } from "react-native";
|
||||
import { EpisodeCard } from "./EpisodeCard";
|
||||
import { Text } from "../common/Text";
|
||||
import { useMemo } from "react";
|
||||
import { SeasonPicker } from "../series/SeasonPicker";
|
||||
|
||||
export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
|
||||
const groupBySeason = useMemo(() => {
|
||||
const seasons: Record<string, BaseItemDto[]> = {};
|
||||
|
||||
items.forEach((item) => {
|
||||
if (!seasons[item.SeasonName!]) {
|
||||
seasons[item.SeasonName!] = [];
|
||||
}
|
||||
|
||||
seasons[item.SeasonName!].push(item);
|
||||
});
|
||||
|
||||
return Object.values(seasons).sort(
|
||||
(a, b) => a[0].IndexNumber! - b[0].IndexNumber!
|
||||
);
|
||||
}, [items]);
|
||||
|
||||
const sortByIndex = (a: BaseItemDto, b: BaseItemDto) => {
|
||||
return a.IndexNumber! > b.IndexNumber! ? 1 : -1;
|
||||
};
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View className="flex flex-row items-center justify-between px-4">
|
||||
<Text className="text-lg font-bold shrink">{items[0].SeriesName}</Text>
|
||||
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
|
||||
<Text className="text-xs font-bold">{items.length}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text className="opacity-50 mb-2 px-4">TV-Series</Text>
|
||||
{groupBySeason.map((seasonItems, seasonIndex) => (
|
||||
<View key={seasonIndex}>
|
||||
<Text className="mb-2 font-semibold px-4">
|
||||
{seasonItems[0].SeasonName}
|
||||
</Text>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View className="px-4 flex flex-row">
|
||||
{seasonItems.sort(sortByIndex)?.map((item, index) => (
|
||||
<EpisodeCard item={item} key={index} />
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { FontAwesome, Ionicons } from "@expo/vector-icons";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||
import { FilterSheet } from "./FilterSheet";
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import { itemRouter, TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||
import { Loader } from "../Loader";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import { useRouter, useSegments } from "expo-router";
|
||||
import * as Haptics from "expo-haptics";
|
||||
|
||||
interface Props extends ViewProps {}
|
||||
|
||||
@@ -146,6 +147,7 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
const handleRoute = useCallback(() => {
|
||||
if (!from) return;
|
||||
const url = itemRouter(item, from);
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
// @ts-ignore
|
||||
if (url) router.push(url);
|
||||
}, [item, from]);
|
||||
|
||||
@@ -28,15 +28,11 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
||||
queryKey,
|
||||
...props
|
||||
}) => {
|
||||
// console.log(queryKey);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: queryKey,
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !disabled,
|
||||
staleTime: 0,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
});
|
||||
|
||||
if (disabled || !title) return null;
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import {useEffect, useState} from "react";
|
||||
import {MediaStatus} from "@/utils/jellyseerr/server/constants/media";
|
||||
import {MaterialCommunityIcons} from "@expo/vector-icons";
|
||||
import {TouchableOpacity, View, ViewProps} from "react-native";
|
||||
import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search";
|
||||
|
||||
interface Props {
|
||||
mediaStatus?: MediaStatus;
|
||||
showRequestIcon: boolean;
|
||||
onPress?: () => void;
|
||||
}
|
||||
|
||||
const JellyseerrIconStatus: React.FC<Props & ViewProps> = ({
|
||||
mediaStatus,
|
||||
showRequestIcon,
|
||||
onPress,
|
||||
...props
|
||||
}) => {
|
||||
const [badgeIcon, setBadgeIcon] = useState<keyof typeof MaterialCommunityIcons.glyphMap>();
|
||||
const [badgeStyle, setBadgeStyle] = useState<string>();
|
||||
|
||||
// Match similar to what Jellyseerr is currently using
|
||||
// https://github.com/Fallenbagel/jellyseerr/blob/8a097d5195749c8d1dca9b473b8afa96a50e2fe2/src/components/Common/StatusBadgeMini/index.tsx#L33C1-L62C4
|
||||
useEffect(() => {
|
||||
switch (mediaStatus) {
|
||||
case MediaStatus.PROCESSING:
|
||||
setBadgeStyle('bg-indigo-500 border-indigo-400 ring-indigo-400 text-indigo-100');
|
||||
setBadgeIcon('clock');
|
||||
break;
|
||||
case MediaStatus.AVAILABLE:
|
||||
setBadgeStyle('bg-purple-500 border-green-400 ring-green-400 text-green-100');
|
||||
setBadgeIcon('check')
|
||||
break;
|
||||
case MediaStatus.PENDING:
|
||||
setBadgeStyle('bg-yellow-500 border-yellow-400 ring-yellow-400 text-yellow-100');
|
||||
setBadgeIcon('bell')
|
||||
break;
|
||||
case MediaStatus.BLACKLISTED:
|
||||
setBadgeStyle('bg-red-500 border-white-400 ring-white-400 text-white');
|
||||
setBadgeIcon('eye-off')
|
||||
break;
|
||||
case MediaStatus.PARTIALLY_AVAILABLE:
|
||||
setBadgeStyle('bg-green-500 border-green-400 ring-green-400 text-green-100');
|
||||
setBadgeIcon("minus");
|
||||
break;
|
||||
default:
|
||||
if (showRequestIcon) {
|
||||
setBadgeStyle('bg-green-600');
|
||||
setBadgeIcon("plus")
|
||||
}
|
||||
break;
|
||||
}
|
||||
}, [mediaStatus, showRequestIcon, setBadgeStyle, setBadgeIcon])
|
||||
|
||||
return (
|
||||
badgeIcon &&
|
||||
<TouchableOpacity onPress={onPress} disabled={onPress == undefined}>
|
||||
<View
|
||||
className={`${badgeStyle ?? 'bg-purple-600'} rounded-full h-6 w-6 flex items-center justify-center ${props.className}`}
|
||||
{...props}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name={badgeIcon}
|
||||
size={18}
|
||||
color="white"
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
}
|
||||
|
||||
export default JellyseerrIconStatus;
|
||||
@@ -1,44 +0,0 @@
|
||||
import {TouchableOpacity, View} from "react-native";
|
||||
import {Text} from "@/components/common/Text";
|
||||
|
||||
interface StepperProps {
|
||||
value: number,
|
||||
step: number,
|
||||
min: number,
|
||||
max: number,
|
||||
onUpdate: (value: number) => void,
|
||||
appendValue?: string,
|
||||
}
|
||||
|
||||
export const Stepper: React.FC<StepperProps> = ({
|
||||
value,
|
||||
step,
|
||||
min,
|
||||
max,
|
||||
onUpdate,
|
||||
appendValue
|
||||
}) => {
|
||||
return (
|
||||
<View className="flex flex-row items-center">
|
||||
<TouchableOpacity
|
||||
onPress={() => onUpdate(Math.max(min, value - step))}
|
||||
className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
|
||||
>
|
||||
<Text>-</Text>
|
||||
</TouchableOpacity>
|
||||
<Text
|
||||
className={
|
||||
"w-auto h-8 bg-neutral-800 py-2 px-1 flex items-center justify-center" + (appendValue ? "first-letter:px-2" : "")
|
||||
}
|
||||
>
|
||||
{value}{appendValue}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
className="w-8 h-8 bg-neutral-800 rounded-r-lg flex items-center justify-center"
|
||||
onPress={() => onUpdate(Math.min(max, value + step))}
|
||||
>
|
||||
<Text>+</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
import React, { useMemo } from "react";
|
||||
import DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
|
||||
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||
import {
|
||||
DiscoverEndpoint,
|
||||
Endpoints,
|
||||
useJellyseerr,
|
||||
} from "@/hooks/useJellyseerr";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
|
||||
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { View } from "react-native";
|
||||
|
||||
interface Props {
|
||||
slide: DiscoverSlider;
|
||||
}
|
||||
const DiscoverSlide: React.FC<Props> = ({ slide }) => {
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
|
||||
const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||
queryKey: ["jellyseerr", "discover", slide.id],
|
||||
queryFn: async ({ pageParam }) => {
|
||||
let endpoint: DiscoverEndpoint | undefined = undefined;
|
||||
let params: any = {
|
||||
page: Number(pageParam),
|
||||
};
|
||||
|
||||
switch (slide.type) {
|
||||
case DiscoverSliderType.TRENDING:
|
||||
endpoint = Endpoints.DISCOVER_TRENDING;
|
||||
break;
|
||||
case DiscoverSliderType.POPULAR_MOVIES:
|
||||
case DiscoverSliderType.UPCOMING_MOVIES:
|
||||
endpoint = Endpoints.DISCOVER_MOVIES;
|
||||
if (slide.type === DiscoverSliderType.UPCOMING_MOVIES)
|
||||
params = {
|
||||
...params,
|
||||
primaryReleaseDateGte: new Date().toISOString().split("T")[0],
|
||||
};
|
||||
break;
|
||||
case DiscoverSliderType.POPULAR_TV:
|
||||
case DiscoverSliderType.UPCOMING_TV:
|
||||
endpoint = Endpoints.DISCOVER_TV;
|
||||
if (slide.type === DiscoverSliderType.UPCOMING_TV)
|
||||
params = {
|
||||
...params,
|
||||
firstAirDateGte: new Date().toISOString().split("T")[0],
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
return endpoint ? jellyseerrApi?.discover(endpoint, params) : null;
|
||||
},
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage, pages) =>
|
||||
(lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
|
||||
1,
|
||||
enabled: !!jellyseerrApi,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const flatData = useMemo(
|
||||
() =>
|
||||
data?.pages?.filter((p) => p?.results.length).flatMap((p) => p?.results),
|
||||
[data]
|
||||
);
|
||||
|
||||
return (
|
||||
flatData &&
|
||||
flatData?.length > 0 && (
|
||||
<View className="mb-4">
|
||||
<Text className="font-bold text-lg mb-2">
|
||||
{DiscoverSliderType[slide.type].toString().toTitle()}
|
||||
</Text>
|
||||
<FlashList
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
keyExtractor={(item) => item!!.id.toString()}
|
||||
estimatedItemSize={250}
|
||||
data={flatData}
|
||||
onEndReachedThreshold={1}
|
||||
onEndReached={() => {
|
||||
if (hasNextPage) fetchNextPage();
|
||||
}}
|
||||
renderItem={({ item }) =>
|
||||
item ? (
|
||||
<JellyseerrPoster item={item as MovieResult | TvResult} />
|
||||
) : (
|
||||
<></>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default DiscoverSlide;
|
||||
@@ -11,9 +11,12 @@ import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useAtom } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { TouchableOpacityProps, View } from "react-native";
|
||||
import { getColors } from "react-native-image-colors";
|
||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||
import { useImageColors } from "@/hooks/useImageColors";
|
||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||
|
||||
interface Props extends TouchableOpacityProps {
|
||||
library: BaseItemDto;
|
||||
@@ -50,6 +53,10 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
||||
[library]
|
||||
);
|
||||
|
||||
// If we want to use image colors for library cards
|
||||
// const [color] = useAtom(itemThemeColorAtom)
|
||||
// useImageColors({ url });
|
||||
|
||||
const { data: itemsCount } = useQuery({
|
||||
queryKey: ["library-count", library.Id],
|
||||
queryFn: async () => {
|
||||
@@ -61,7 +68,6 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
||||
});
|
||||
return response.data.TotalRecordCount;
|
||||
},
|
||||
staleTime: 1000 * 60 * 60,
|
||||
});
|
||||
|
||||
if (!url) return null;
|
||||
|
||||
@@ -8,6 +8,11 @@ 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;
|
||||
@@ -27,14 +32,40 @@ export const SongsListItem: React.FC<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 = () => {
|
||||
play("device");
|
||||
return;
|
||||
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") => {
|
||||
@@ -72,7 +103,7 @@ export const SongsListItem: React.FC<Props> = ({
|
||||
});
|
||||
} else {
|
||||
console.log("Playing on device", data.url, item.Id);
|
||||
router.push("/music-player");
|
||||
router.push("/play-music");
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import {View, ViewProps} from "react-native";
|
||||
import {Image} from "expo-image";
|
||||
import {MaterialCommunityIcons} from "@expo/vector-icons";
|
||||
import {Text} from "@/components/common/Text";
|
||||
import {useEffect, useMemo, useState} from "react";
|
||||
import {MovieResult, Results, TvResult} from "@/utils/jellyseerr/server/models/Search";
|
||||
import {MediaStatus, MediaType} from "@/utils/jellyseerr/server/constants/media";
|
||||
import {useJellyseerr} from "@/hooks/useJellyseerr";
|
||||
import {hasPermission, Permission} from "@/utils/jellyseerr/server/lib/permissions";
|
||||
import {TouchableJellyseerrRouter} from "@/components/common/JellyseerrItemRouter";
|
||||
import JellyseerrIconStatus from "@/components/icons/JellyseerrIconStatus";
|
||||
interface Props extends ViewProps {
|
||||
item: MovieResult | TvResult;
|
||||
}
|
||||
|
||||
const JellyseerrPoster: React.FC<Props> = ({
|
||||
item,
|
||||
...props
|
||||
}) => {
|
||||
const {jellyseerrUser, jellyseerrApi} = useJellyseerr();
|
||||
// const imageSource =
|
||||
|
||||
const imageSrc = useMemo(() =>
|
||||
item.posterPath ?
|
||||
`https://image.tmdb.org/t/p/w300_and_h450_face${item.posterPath}`
|
||||
: jellyseerrApi?.axios?.defaults.baseURL + `/images/overseerr_poster_not_found_logo_top.png`,
|
||||
[item, jellyseerrApi]
|
||||
)
|
||||
const title = useMemo(() => item.mediaType === MediaType.MOVIE ? item.title : item.name, [item])
|
||||
const releaseYear = useMemo(() =>
|
||||
new Date(item.mediaType === MediaType.MOVIE ? item.releaseDate : item.firstAirDate).getFullYear(),
|
||||
[item]
|
||||
)
|
||||
|
||||
const showRequestButton = useMemo(() =>
|
||||
jellyseerrUser && hasPermission(
|
||||
[
|
||||
Permission.REQUEST,
|
||||
item.mediaType === 'movie'
|
||||
? Permission.REQUEST_MOVIE
|
||||
: Permission.REQUEST_TV,
|
||||
],
|
||||
jellyseerrUser.permissions,
|
||||
{type: 'or'}
|
||||
),
|
||||
[item, jellyseerrUser]
|
||||
)
|
||||
|
||||
const canRequest = useMemo(() => {
|
||||
const status = item?.mediaInfo?.status
|
||||
return showRequestButton && !status || status === MediaStatus.UNKNOWN
|
||||
}, [item])
|
||||
|
||||
return (
|
||||
<TouchableJellyseerrRouter
|
||||
result={item}
|
||||
mediaTitle={title}
|
||||
releaseYear={releaseYear}
|
||||
canRequest={canRequest}
|
||||
posterSrc={imageSrc}
|
||||
>
|
||||
<View className="flex flex-col w-28 mr-2">
|
||||
<View className="relative rounded-lg overflow-hidden border border-neutral-900 w-28 aspect-[10/15]">
|
||||
<Image
|
||||
key={item.id}
|
||||
id={item.id.toString()}
|
||||
source={{uri: imageSrc}}
|
||||
cachePolicy={"memory-disk"}
|
||||
contentFit="cover"
|
||||
style={{
|
||||
aspectRatio: "10/15",
|
||||
width: "100%",
|
||||
}}
|
||||
/>
|
||||
<JellyseerrIconStatus
|
||||
className="absolute bottom-1 right-1"
|
||||
showRequestIcon={canRequest}
|
||||
mediaStatus={item?.mediaInfo?.status}
|
||||
/>
|
||||
|
||||
</View>
|
||||
<View className="mt-2 flex flex-col">
|
||||
<Text numberOfLines={2}>{title}</Text>
|
||||
<Text className="text-xs opacity-50">{releaseYear}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableJellyseerrRouter>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
export default JellyseerrPoster;
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { router } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useMemo } from "react";
|
||||
import React from "react";
|
||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
||||
import { Text } from "../common/Text";
|
||||
@@ -20,37 +20,24 @@ interface Props extends ViewProps {
|
||||
export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
|
||||
const destinctPeople = useMemo(() => {
|
||||
const people: BaseItemPerson[] = [];
|
||||
item?.People?.forEach((person) => {
|
||||
const existingPerson = people.find((p) => p.Id === person.Id);
|
||||
if (existingPerson) {
|
||||
existingPerson.Role = `${existingPerson.Role}, ${person.Role}`;
|
||||
} else {
|
||||
people.push(person);
|
||||
}
|
||||
});
|
||||
return people;
|
||||
}, [item?.People]);
|
||||
|
||||
return (
|
||||
<View {...props} className="flex flex-col">
|
||||
<Text className="text-lg font-bold mb-2 px-4">Cast & Crew</Text>
|
||||
<HorizontalScroll
|
||||
loading={loading}
|
||||
keyExtractor={(i, idx) => i.Id.toString()}
|
||||
height={247}
|
||||
data={destinctPeople}
|
||||
renderItem={(i) => (
|
||||
data={item?.People || []}
|
||||
renderItem={(item, index) => (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push(`/actors/${i.Id}`);
|
||||
router.push(`/actors/${item.Id}`);
|
||||
}}
|
||||
key={item.Id}
|
||||
className="flex flex-col w-28"
|
||||
>
|
||||
<Poster item={i} url={getPrimaryImageUrl({ api, item: i })} />
|
||||
<Text className="mt-2">{i.Name}</Text>
|
||||
<Text className="text-xs opacity-50">{i.Role}</Text>
|
||||
<Poster item={item} url={getPrimaryImageUrl({ api, item })} />
|
||||
<Text className="mt-2">{item.Name}</Text>
|
||||
<Text className="text-xs opacity-50">{item.Role}</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -1,275 +0,0 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { Alert, TouchableOpacity, View } from "react-native";
|
||||
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { orderBy } from "lodash";
|
||||
import { Tags } from "@/components/GenreTags";
|
||||
import JellyseerrIconStatus from "@/components/icons/JellyseerrIconStatus";
|
||||
import Season from "@/utils/jellyseerr/server/entity/Season";
|
||||
import {
|
||||
MediaStatus,
|
||||
MediaType,
|
||||
} from "@/utils/jellyseerr/server/constants/media";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { RoundButton } from "@/components/RoundButton";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { TvResult } from "@/utils/jellyseerr/server/models/Search";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
|
||||
import { Image } from "expo-image";
|
||||
import MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
|
||||
import { Loader } from "../Loader";
|
||||
|
||||
const JellyseerrSeasonEpisodes: React.FC<{
|
||||
details: TvDetails;
|
||||
seasonNumber: number;
|
||||
}> = ({ details, seasonNumber }) => {
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
|
||||
const { data: seasonWithEpisodes, isLoading } = useQuery({
|
||||
queryKey: ["jellyseerr", details.id, "season", seasonNumber],
|
||||
queryFn: async () => jellyseerrApi?.tvSeason(details.id, seasonNumber),
|
||||
enabled: details.seasons.filter((s) => s.seasonNumber !== 0).length > 0,
|
||||
});
|
||||
|
||||
return (
|
||||
<HorizontalScroll
|
||||
horizontal
|
||||
loading={isLoading}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
estimatedItemSize={50}
|
||||
data={seasonWithEpisodes?.episodes}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={(item, index) => (
|
||||
<RenderItem key={index} item={item} index={index} />
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const RenderItem = ({ item, index }: any) => {
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
const [imageError, setImageError] = useState(false);
|
||||
|
||||
return (
|
||||
<View className="flex flex-col w-44 mt-2">
|
||||
<View className="relative aspect-video rounded-lg overflow-hidden border border-neutral-800">
|
||||
{!imageError ? (
|
||||
<Image
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
source={{
|
||||
uri: jellyseerrApi?.tvStillImageProxy(item.stillPath),
|
||||
}}
|
||||
cachePolicy={"memory-disk"}
|
||||
contentFit="cover"
|
||||
className="w-full h-full"
|
||||
onError={(e) => {
|
||||
setImageError(true);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<View className="flex flex-col w-full h-full items-center justify-center border border-neutral-800 bg-neutral-900">
|
||||
<Ionicons
|
||||
name="image-outline"
|
||||
size={24}
|
||||
color="white"
|
||||
style={{ opacity: 0.4 }}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<View className="shrink mt-1">
|
||||
<Text numberOfLines={2} className="">
|
||||
{item.name}
|
||||
</Text>
|
||||
<Text numberOfLines={1} className="text-xs text-neutral-500">
|
||||
{`S${item.seasonNumber}:E${item.episodeNumber}`}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text numberOfLines={3} className="text-xs text-neutral-500 shrink">
|
||||
{item.overview}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const JellyseerrSeasons: React.FC<{
|
||||
isLoading: boolean;
|
||||
result?: TvResult;
|
||||
details?: TvDetails;
|
||||
}> = ({ isLoading, result, details }) => {
|
||||
if (!details) return null;
|
||||
|
||||
const { jellyseerrApi, requestMedia } = useJellyseerr();
|
||||
const [seasonStates, setSeasonStates] = useState<{
|
||||
[key: number]: boolean;
|
||||
}>();
|
||||
const seasons = useMemo(() => {
|
||||
const mediaInfoSeasons = details?.mediaInfo?.seasons?.filter(
|
||||
(s: Season) => s.seasonNumber !== 0
|
||||
);
|
||||
const requestedSeasons = details?.mediaInfo?.requests?.flatMap(
|
||||
(r: MediaRequest) => r.seasons
|
||||
);
|
||||
return details.seasons?.map((season) => {
|
||||
return {
|
||||
...season,
|
||||
status:
|
||||
// What our library status is
|
||||
mediaInfoSeasons?.find(
|
||||
(mediaSeason: Season) =>
|
||||
mediaSeason.seasonNumber === season.seasonNumber
|
||||
)?.status ??
|
||||
// What our request status is
|
||||
requestedSeasons?.find(
|
||||
(s: Season) => s.seasonNumber === season.seasonNumber
|
||||
)?.status ??
|
||||
// Otherwise set it as unknown
|
||||
MediaStatus.UNKNOWN,
|
||||
};
|
||||
});
|
||||
}, [details]);
|
||||
|
||||
const allSeasonsAvailable = useMemo(
|
||||
() => seasons?.every((season) => season.status === MediaStatus.AVAILABLE),
|
||||
[seasons]
|
||||
);
|
||||
|
||||
const requestAll = useCallback(() => {
|
||||
if (details && jellyseerrApi) {
|
||||
requestMedia(result?.name!!, {
|
||||
mediaId: details.id,
|
||||
mediaType: MediaType.TV,
|
||||
tvdbId: details.externalIds?.tvdbId,
|
||||
seasons: seasons
|
||||
.filter(
|
||||
(s) => s.status === MediaStatus.UNKNOWN && s.seasonNumber !== 0
|
||||
)
|
||||
.map((s) => s.seasonNumber),
|
||||
});
|
||||
}
|
||||
}, [jellyseerrApi, seasons, details]);
|
||||
|
||||
const promptRequestAll = useCallback(
|
||||
() =>
|
||||
Alert.alert("Confirm", "Are you sure you want to request all seasons?", [
|
||||
{
|
||||
text: "Cancel",
|
||||
style: "cancel",
|
||||
},
|
||||
{
|
||||
text: "Yes",
|
||||
onPress: requestAll,
|
||||
},
|
||||
]),
|
||||
[requestAll]
|
||||
);
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<View>
|
||||
<View className="flex flex-row justify-between items-end px-4">
|
||||
<Text className="text-lg font-bold mb-2">Seasons</Text>
|
||||
{!allSeasonsAvailable && (
|
||||
<RoundButton className="mb-2 pa-2" onPress={promptRequestAll}>
|
||||
<Ionicons name="bag-add" color="white" size={26} />
|
||||
</RoundButton>
|
||||
)}
|
||||
</View>
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<FlashList
|
||||
data={orderBy(
|
||||
details.seasons.filter((s) => s.seasonNumber !== 0),
|
||||
"seasonNumber",
|
||||
"desc"
|
||||
)}
|
||||
ListHeaderComponent={() => (
|
||||
<View className="flex flex-row justify-between items-end px-4">
|
||||
<Text className="text-lg font-bold mb-2">Seasons</Text>
|
||||
{!allSeasonsAvailable && (
|
||||
<RoundButton className="mb-2 pa-2" onPress={promptRequestAll}>
|
||||
<Ionicons name="bag-add" color="white" size={26} />
|
||||
</RoundButton>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
ItemSeparatorComponent={() => <View className="h-2" />}
|
||||
estimatedItemSize={250}
|
||||
renderItem={({ item: season }) => (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
onPress={() =>
|
||||
setSeasonStates((prevState) => ({
|
||||
...prevState,
|
||||
[season.seasonNumber]: !prevState?.[season.seasonNumber],
|
||||
}))
|
||||
}
|
||||
className="px-4"
|
||||
>
|
||||
<View
|
||||
className="flex flex-row justify-between items-center bg-gray-100/10 rounded-xl z-20 h-12 w-full px-4"
|
||||
key={season.id}
|
||||
>
|
||||
<Tags
|
||||
textClass=""
|
||||
tags={[
|
||||
`Season ${season.seasonNumber}`,
|
||||
`${season.episodeCount} Episodes`,
|
||||
]}
|
||||
/>
|
||||
{[0].map(() => {
|
||||
const canRequest =
|
||||
seasons?.find((s) => s.seasonNumber === season.seasonNumber)
|
||||
?.status === MediaStatus.UNKNOWN;
|
||||
return (
|
||||
<JellyseerrIconStatus
|
||||
key={0}
|
||||
onPress={
|
||||
canRequest
|
||||
? () =>
|
||||
requestMedia(
|
||||
`${result?.name!!}, Season ${
|
||||
season.seasonNumber
|
||||
}`,
|
||||
{
|
||||
mediaId: details.id,
|
||||
mediaType: MediaType.TV,
|
||||
tvdbId: details.externalIds?.tvdbId,
|
||||
seasons: [season.seasonNumber],
|
||||
}
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
className={canRequest ? "bg-gray-700/40" : undefined}
|
||||
mediaStatus={
|
||||
seasons?.find(
|
||||
(s) => s.seasonNumber === season.seasonNumber
|
||||
)?.status
|
||||
}
|
||||
showRequestIcon={canRequest}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
{seasonStates?.[season.seasonNumber] && (
|
||||
<JellyseerrSeasonEpisodes
|
||||
key={season.seasonNumber}
|
||||
details={details}
|
||||
seasonNumber={season.seasonNumber}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default JellyseerrSeasons;
|
||||
@@ -11,7 +11,6 @@ import { Text } from "../common/Text";
|
||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||
import { ItemCardText } from "../ItemCardText";
|
||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
|
||||
export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
|
||||
const [user] = useAtom(userAtom);
|
||||
@@ -44,14 +43,10 @@ export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Text className="text-lg font-bold px-4 mb-2">Next up</Text>
|
||||
<FlashList
|
||||
contentContainerStyle={{ paddingLeft: 16 }}
|
||||
horizontal
|
||||
estimatedItemSize={172}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
<Text className="text-lg font-bold mb-2 px-4">Next up</Text>
|
||||
<HorizontalScroll
|
||||
data={items}
|
||||
renderItem={({ item, index }) => (
|
||||
renderItem={(item, index) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={index}
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { TouchableOpacity, View, Modal } from "react-native";
|
||||
import { Text } from "../common/Text";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
|
||||
type Props = {
|
||||
item: BaseItemDto;
|
||||
seasons: BaseItemDto[];
|
||||
initialSeasonIndex?: number;
|
||||
state: SeasonIndexState;
|
||||
onSelect: (season: BaseItemDto) => void;
|
||||
};
|
||||
|
||||
type SeasonKeys = {
|
||||
id: keyof BaseItemDto;
|
||||
title: keyof BaseItemDto;
|
||||
index: keyof BaseItemDto;
|
||||
};
|
||||
|
||||
export type SeasonIndexState = {
|
||||
[seriesId: string]: number | string | null | undefined;
|
||||
};
|
||||
|
||||
export const SeasonDropdown: React.FC<Props> = ({
|
||||
item,
|
||||
seasons,
|
||||
initialSeasonIndex,
|
||||
state,
|
||||
onSelect,
|
||||
}) => {
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
|
||||
const keys = useMemo<SeasonKeys>(
|
||||
() =>
|
||||
item.Type === "Episode"
|
||||
? {
|
||||
id: "ParentId",
|
||||
title: "SeasonName",
|
||||
index: "ParentIndexNumber",
|
||||
}
|
||||
: {
|
||||
id: "Id",
|
||||
title: "Name",
|
||||
index: "IndexNumber",
|
||||
},
|
||||
[item]
|
||||
);
|
||||
|
||||
const seasonIndex = useMemo(
|
||||
() => state[(item[keys.id] as string) ?? ""],
|
||||
[state]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (seasons && seasons.length > 0 && seasonIndex === undefined) {
|
||||
let initialIndex: number | undefined;
|
||||
|
||||
if (initialSeasonIndex !== undefined) {
|
||||
const seasonExists = seasons.some(
|
||||
(season: any) => season[keys.index] === initialSeasonIndex
|
||||
);
|
||||
if (seasonExists) {
|
||||
initialIndex = initialSeasonIndex;
|
||||
}
|
||||
}
|
||||
|
||||
if (initialIndex === undefined) {
|
||||
const season1 = seasons.find((season: any) => season[keys.index] === 1);
|
||||
const season0 = seasons.find((season: any) => season[keys.index] === 0);
|
||||
const firstSeason = season1 || season0 || seasons[0];
|
||||
onSelect(firstSeason);
|
||||
}
|
||||
|
||||
if (initialIndex !== undefined) {
|
||||
const initialSeason = seasons.find(
|
||||
(season: any) => season[keys.index] === initialIndex
|
||||
);
|
||||
|
||||
if (initialSeason) onSelect(initialSeason!);
|
||||
else throw Error("Initial index could not be found!");
|
||||
}
|
||||
}
|
||||
}, [seasons, seasonIndex, item[keys.id], initialSeasonIndex]);
|
||||
|
||||
const sortByIndex = (a: BaseItemDto, b: BaseItemDto) =>
|
||||
Number(a[keys.index]) - Number(b[keys.index]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
className="bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between"
|
||||
onPress={() => setIsModalVisible(true)}
|
||||
>
|
||||
<Text>Season {seasonIndex}</Text>
|
||||
<Ionicons
|
||||
name="chevron-down"
|
||||
size={16}
|
||||
color="white"
|
||||
style={{ opacity: 0.5, marginLeft: 8 }}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Modal
|
||||
visible={isModalVisible}
|
||||
transparent
|
||||
animationType="slide"
|
||||
onRequestClose={() => setIsModalVisible(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
className="flex-1 bg-black/50"
|
||||
activeOpacity={1}
|
||||
onPress={() => setIsModalVisible(false)}
|
||||
>
|
||||
<View className="mt-auto bg-neutral-900 rounded-t-xl">
|
||||
<View className="p-4 border-b border-neutral-800">
|
||||
<Text className="text-lg font-bold text-center">
|
||||
Select Season
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="max-h-[50%]">
|
||||
{seasons?.sort(sortByIndex).map((season: any) => (
|
||||
<TouchableOpacity
|
||||
key={season[keys.title]}
|
||||
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
|
||||
onPress={() => {
|
||||
onSelect(season);
|
||||
setIsModalVisible(false);
|
||||
}}
|
||||
>
|
||||
<Text>{season[keys.title]}</Text>
|
||||
{Number(season[keys.index]) === Number(seasonIndex) && (
|
||||
<Ionicons name="checkmark" size={24} color="white" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
className="p-4 border-t border-neutral-800"
|
||||
onPress={() => setIsModalVisible(false)}
|
||||
>
|
||||
<Text className="text-center text-purple-400">Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,19 +1,20 @@
|
||||
import {
|
||||
SeasonDropdown,
|
||||
SeasonIndexState,
|
||||
} from "@/components/series/SeasonDropdown";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import { runtimeTicksToSeconds } from "@/utils/time";
|
||||
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 { useRouter } from "expo-router";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||
import { DownloadItem } from "../DownloadItem";
|
||||
import { Loader } from "../Loader";
|
||||
import { Text } from "../common/Text";
|
||||
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import { Image } from "expo-image";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||
|
||||
type Props = {
|
||||
@@ -21,6 +22,10 @@ type Props = {
|
||||
initialSeasonIndex?: number;
|
||||
};
|
||||
|
||||
type SeasonIndexState = {
|
||||
[seriesId: string]: number;
|
||||
};
|
||||
|
||||
export const seasonIndexAtom = atom<SeasonIndexState>({});
|
||||
|
||||
export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
||||
@@ -28,10 +33,9 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
||||
const [user] = useAtom(userAtom);
|
||||
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
|
||||
|
||||
const seasonIndex = useMemo(
|
||||
() => seasonIndexState[item.Id ?? ""],
|
||||
[item, seasonIndexState]
|
||||
);
|
||||
const seasonIndex = seasonIndexState[item.Id ?? ""];
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const { data: seasons } = useQuery({
|
||||
queryKey: ["seasons", item.Id],
|
||||
@@ -54,28 +58,50 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
||||
|
||||
return response.data.Items;
|
||||
},
|
||||
staleTime: 60,
|
||||
enabled: !!api && !!user?.Id && !!item.Id,
|
||||
});
|
||||
|
||||
const selectedSeasonId: string | null = useMemo(() => {
|
||||
const season: BaseItemDto = seasons?.find(
|
||||
(s: BaseItemDto) =>
|
||||
s.IndexNumber === seasonIndex || s.Name === seasonIndex
|
||||
);
|
||||
useEffect(() => {
|
||||
if (seasons && seasons.length > 0 && seasonIndex === undefined) {
|
||||
let initialIndex: number | undefined;
|
||||
|
||||
if (!season?.Id) return null;
|
||||
if (initialSeasonIndex !== undefined) {
|
||||
// Use the provided initialSeasonIndex if it exists in the seasons
|
||||
const seasonExists = seasons.some(
|
||||
(season: any) => season.IndexNumber === initialSeasonIndex
|
||||
);
|
||||
if (seasonExists) {
|
||||
initialIndex = initialSeasonIndex;
|
||||
}
|
||||
}
|
||||
|
||||
return season.Id!;
|
||||
}, [seasons, seasonIndex]);
|
||||
if (initialIndex === undefined) {
|
||||
// Fall back to the previous logic if initialIndex is not set
|
||||
const season1 = seasons.find((season: any) => season.IndexNumber === 1);
|
||||
const season0 = seasons.find((season: any) => season.IndexNumber === 0);
|
||||
const firstSeason = season1 || season0 || seasons[0];
|
||||
initialIndex = firstSeason.IndexNumber;
|
||||
}
|
||||
|
||||
if (initialIndex !== undefined) {
|
||||
setSeasonIndexState((prev) => ({
|
||||
...prev,
|
||||
[item.Id ?? ""]: initialIndex,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}, [seasons, seasonIndex, setSeasonIndexState, item.Id, initialSeasonIndex]);
|
||||
|
||||
const selectedSeasonId: string | null = useMemo(
|
||||
() =>
|
||||
seasons?.find((season: any) => season.IndexNumber === seasonIndex)?.Id,
|
||||
[seasons, seasonIndex]
|
||||
);
|
||||
|
||||
const { data: episodes, isFetching } = useQuery({
|
||||
queryKey: ["episodes", item.Id, selectedSeasonId],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id || !item.Id || !selectedSeasonId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!api || !user?.Id || !item.Id || !selectedSeasonId) return [];
|
||||
const res = await getTvShowsApi(api).getEpisodes({
|
||||
seriesId: item.Id,
|
||||
userId: user.Id,
|
||||
@@ -84,12 +110,6 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
||||
fields: ["MediaSources", "MediaStreams", "Overview"],
|
||||
});
|
||||
|
||||
if (res.data.TotalRecordCount === 0)
|
||||
console.warn(
|
||||
"No episodes found for season with ID ~",
|
||||
selectedSeasonId
|
||||
);
|
||||
|
||||
return res.data.Items;
|
||||
},
|
||||
enabled: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
|
||||
@@ -128,21 +148,40 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
||||
minHeight: 144 * nrOfEpisodes,
|
||||
}}
|
||||
>
|
||||
<View className="flex flex-row justify-start items-center px-4">
|
||||
<SeasonDropdown
|
||||
item={item}
|
||||
seasons={seasons}
|
||||
state={seasonIndexState}
|
||||
onSelect={(season) => {
|
||||
if (!item.Id) return;
|
||||
setSeasonIndexState((prev) => ({
|
||||
...prev,
|
||||
[item.Id!]: season.IndexNumber ?? season.Name,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
<View className="px-4 flex flex-col mt-4">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className="flex flex-row px-4">
|
||||
<TouchableOpacity className="bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text>Season {seasonIndex}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>Seasons</DropdownMenu.Label>
|
||||
{seasons?.map((season: any) => (
|
||||
<DropdownMenu.Item
|
||||
key={season.Name}
|
||||
onSelect={() => {
|
||||
setSeasonIndexState((prev) => ({
|
||||
...prev,
|
||||
[item.Id ?? ""]: season.IndexNumber,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>{season.Name}</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
<View className="px-4 flex flex-col my-4">
|
||||
{isFetching ? (
|
||||
<View
|
||||
style={{
|
||||
@@ -178,6 +217,9 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
||||
{runtimeTicksToSeconds(e.RunTimeTicks)}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="self-start ml-auto -mt-0.5">
|
||||
<DownloadItem item={e} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text
|
||||
@@ -189,13 +231,6 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
||||
</TouchableItemRouter>
|
||||
))
|
||||
)}
|
||||
{(episodes?.length || 0) === 0 ? (
|
||||
<View className="flex flex-col">
|
||||
<Text className="text-neutral-500">
|
||||
No episodes for this season
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
item: BaseItemDto;
|
||||
}
|
||||
|
||||
export const ItemActions = ({ item, ...props }: Props) => {
|
||||
const router = useRouter();
|
||||
|
||||
const trailerLink = useMemo(() => item.RemoteTrailers?.[0]?.Url, [item]);
|
||||
|
||||
const openTrailer = useCallback(async () => {
|
||||
if (!trailerLink) return;
|
||||
|
||||
const encodedTrailerLink = encodeURIComponent(trailerLink);
|
||||
router.push(`/trailer/page?url=${encodedTrailerLink}`);
|
||||
}, [router, trailerLink]);
|
||||
|
||||
return (
|
||||
<View className="" {...props}>
|
||||
{trailerLink && (
|
||||
<TouchableOpacity onPress={openTrailer}>
|
||||
<Ionicons name="film-outline" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -1,64 +0,0 @@
|
||||
import { View } from "react-native";
|
||||
import { Text } from "../common/Text";
|
||||
import { Ratings } from "../Ratings";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useMemo } from "react";
|
||||
import { ItemActions } from "./SeriesActions";
|
||||
|
||||
interface Props {
|
||||
item: BaseItemDto;
|
||||
}
|
||||
|
||||
export const SeriesHeader = ({ item }: Props) => {
|
||||
const startYear = useMemo(() => {
|
||||
if (item?.StartDate) {
|
||||
return new Date(item.StartDate)
|
||||
.toLocaleDateString("sv-SE", {
|
||||
calendar: "gregory",
|
||||
year: "numeric",
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
}
|
||||
return item.ProductionYear?.toString().trim();
|
||||
}, [item]);
|
||||
|
||||
const endYear = useMemo(() => {
|
||||
if (item.EndDate) {
|
||||
return new Date(item.EndDate)
|
||||
.toLocaleDateString("sv-SE", {
|
||||
calendar: "gregory",
|
||||
year: "numeric",
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
}
|
||||
return "";
|
||||
}, [item]);
|
||||
|
||||
const yearString = useMemo(() => {
|
||||
if (startYear && endYear) {
|
||||
if (startYear === endYear) return startYear;
|
||||
return `${startYear} - ${endYear}`;
|
||||
}
|
||||
if (startYear) {
|
||||
return startYear;
|
||||
}
|
||||
if (endYear) {
|
||||
return endYear;
|
||||
}
|
||||
return "";
|
||||
}, [startYear, endYear]);
|
||||
|
||||
return (
|
||||
<View className="px-4 py-4">
|
||||
<Text className="text-3xl font-bold">{item?.Name}</Text>
|
||||
<Text className="">{yearString}</Text>
|
||||
<View className="flex flex-row items-center justify-between">
|
||||
<Ratings item={item} className="mb-2" />
|
||||
<ItemActions item={item} />
|
||||
</View>
|
||||
<Text className="">{item?.Overview}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -1,147 +0,0 @@
|
||||
import { TouchableOpacity, View, ViewProps, Modal } from "react-native";
|
||||
import { Text } from "../common/Text";
|
||||
import { useMedia } from "./MediaContext";
|
||||
import { Switch } from "react-native-gesture-handler";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useState } from "react";
|
||||
|
||||
interface Props extends ViewProps {}
|
||||
|
||||
export const AudioToggles: React.FC<Props> = ({ ...props }) => {
|
||||
const media = useMedia();
|
||||
const { settings, updateSettings } = media;
|
||||
const cultures = media.cultures;
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Text className="text-lg font-bold mb-2">Audio</Text>
|
||||
<View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800">
|
||||
<View className="flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4">
|
||||
<View className="flex flex-col shrink">
|
||||
<Text className="font-semibold">Audio language</Text>
|
||||
<Text className="text-xs opacity-50">
|
||||
Choose a default audio language.
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between"
|
||||
onPress={() => setIsModalVisible(true)}
|
||||
>
|
||||
<Text>{settings?.defaultAudioLanguage?.DisplayName || "None"}</Text>
|
||||
<Ionicons
|
||||
name="chevron-down"
|
||||
size={16}
|
||||
color="white"
|
||||
style={{ opacity: 0.5, marginLeft: 8 }}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View className="flex flex-col">
|
||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||
<View className="flex flex-col">
|
||||
<Text className="font-semibold">Use Default Audio</Text>
|
||||
<Text className="text-xs opacity-50">
|
||||
Play default audio track regardless of language.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={settings.playDefaultAudioTrack}
|
||||
onValueChange={(value) =>
|
||||
updateSettings({ playDefaultAudioTrack: value })
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="flex flex-col">
|
||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||
<View className="flex flex-col">
|
||||
<Text className="font-semibold">
|
||||
Set Audio Track From Previous Item
|
||||
</Text>
|
||||
<Text className="text-xs opacity-50 min max-w-[85%]">
|
||||
Try to set the audio track to the closest match to the last
|
||||
video.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={settings.rememberAudioSelections}
|
||||
onValueChange={(value) =>
|
||||
updateSettings({ rememberAudioSelections: value })
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Modal
|
||||
visible={isModalVisible}
|
||||
transparent
|
||||
animationType="slide"
|
||||
onRequestClose={() => setIsModalVisible(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
className="flex-1 bg-black/50"
|
||||
activeOpacity={1}
|
||||
onPress={() => setIsModalVisible(false)}
|
||||
>
|
||||
<View className="mt-auto bg-neutral-900 rounded-t-xl">
|
||||
<View className="p-4 border-b border-neutral-800">
|
||||
<Text className="text-lg font-bold text-center">
|
||||
Select Language
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="max-h-[50%]">
|
||||
<TouchableOpacity
|
||||
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
|
||||
onPress={() => {
|
||||
updateSettings({
|
||||
defaultAudioLanguage: null,
|
||||
});
|
||||
setIsModalVisible(false);
|
||||
}}
|
||||
>
|
||||
<Text>None</Text>
|
||||
{!settings?.defaultAudioLanguage && (
|
||||
<Ionicons name="checkmark" size={24} color="white" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
{cultures?.map((l) => (
|
||||
<TouchableOpacity
|
||||
key={l?.ThreeLetterISOLanguageName ?? "unknown"}
|
||||
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
|
||||
onPress={() => {
|
||||
updateSettings({
|
||||
defaultAudioLanguage: l,
|
||||
});
|
||||
setIsModalVisible(false);
|
||||
}}
|
||||
>
|
||||
<Text>{l.DisplayName}</Text>
|
||||
{settings?.defaultAudioLanguage
|
||||
?.ThreeLetterISOLanguageName ===
|
||||
l.ThreeLetterISOLanguageName && (
|
||||
<Ionicons name="checkmark" size={24} color="white" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
className="p-4 border-t border-neutral-800"
|
||||
onPress={() => setIsModalVisible(false)}
|
||||
>
|
||||
<Text className="text-center text-purple-400">Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -1,207 +0,0 @@
|
||||
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { View } from "react-native";
|
||||
import { Text } from "../common/Text";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { Input } from "../common/Input";
|
||||
import { ListItem } from "../ListItem";
|
||||
import { Loader } from "../Loader";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { Button } from "../Button";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useAtom } from "jotai";
|
||||
import { toast } from "sonner-native";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
|
||||
export const JellyseerrSettings = () => {
|
||||
const {
|
||||
jellyseerrApi,
|
||||
jellyseerrUser,
|
||||
setJellyseerrUser,
|
||||
clearAllJellyseerData,
|
||||
} = useJellyseerr();
|
||||
|
||||
const [user] = useAtom(userAtom);
|
||||
const [settings, updateSettings] = useSettings();
|
||||
|
||||
const [promptForJellyseerrPass, setPromptForJellyseerrPass] =
|
||||
useState<boolean>(false);
|
||||
|
||||
const [jellyseerrPassword, setJellyseerrPassword] = useState<
|
||||
string | undefined
|
||||
>(undefined);
|
||||
|
||||
const [jellyseerrServerUrl, setjellyseerrServerUrl] = useState<
|
||||
string | undefined
|
||||
>(settings?.jellyseerrServerUrl || undefined);
|
||||
|
||||
const loginToJellyseerrMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!jellyseerrServerUrl || !user?.Name || !jellyseerrPassword) {
|
||||
throw new Error("Missing required information for login");
|
||||
}
|
||||
const jellyseerrTempApi = new JellyseerrApi(jellyseerrServerUrl);
|
||||
return jellyseerrTempApi.login(user.Name, jellyseerrPassword);
|
||||
},
|
||||
onSuccess: (user) => {
|
||||
setJellyseerrUser(user);
|
||||
updateSettings({ jellyseerrServerUrl });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Failed to login");
|
||||
},
|
||||
onSettled: () => {
|
||||
setJellyseerrPassword(undefined);
|
||||
},
|
||||
});
|
||||
|
||||
const testJellyseerrServerUrlMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!jellyseerrServerUrl || jellyseerrApi) return null;
|
||||
const jellyseerrTempApi = new JellyseerrApi(jellyseerrServerUrl);
|
||||
return jellyseerrTempApi.test();
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
if (result && result.isValid) {
|
||||
if (result.requiresPass) {
|
||||
setPromptForJellyseerrPass(true);
|
||||
} else {
|
||||
updateSettings({ jellyseerrServerUrl });
|
||||
}
|
||||
} else {
|
||||
setPromptForJellyseerrPass(false);
|
||||
setjellyseerrServerUrl(undefined);
|
||||
clearAllJellyseerData();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const clearData = () => {
|
||||
clearAllJellyseerData().finally(() => {
|
||||
setjellyseerrServerUrl(undefined);
|
||||
setPromptForJellyseerrPass(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<View className="mt-4">
|
||||
<Text className="text-lg font-bold mb-2">Jellyseerr</Text>
|
||||
<View>
|
||||
{jellyseerrUser ? (
|
||||
<View className="flex flex-col rounded-xl overflow-hidden bg-neutral-900 pt-0 divide-y divide-neutral-800">
|
||||
<ListItem
|
||||
title="Total media requests"
|
||||
subTitle={jellyseerrUser?.requestCount?.toString()}
|
||||
/>
|
||||
<ListItem
|
||||
title="Movie quota limit"
|
||||
subTitle={
|
||||
jellyseerrUser?.movieQuotaLimit?.toString() ?? "Unlimited"
|
||||
}
|
||||
/>
|
||||
<ListItem
|
||||
title="Movie quota days"
|
||||
subTitle={
|
||||
jellyseerrUser?.movieQuotaDays?.toString() ?? "Unlimited"
|
||||
}
|
||||
/>
|
||||
<ListItem
|
||||
title="TV quota limit"
|
||||
subTitle={jellyseerrUser?.tvQuotaLimit?.toString() ?? "Unlimited"}
|
||||
/>
|
||||
<ListItem
|
||||
title="TV quota days"
|
||||
subTitle={jellyseerrUser?.tvQuotaDays?.toString() ?? "Unlimited"}
|
||||
/>
|
||||
<View className="p-4">
|
||||
<Button color="red" onPress={clearData}>
|
||||
Reset Jellyseerr config
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<View className="flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900">
|
||||
<Text className="text-xs text-red-600 mb-2">
|
||||
This integration is in its early stages. Expect things to change.
|
||||
</Text>
|
||||
<Text className="font-bold mb-1">Server URL</Text>
|
||||
<View className="flex flex-col shrink mb-2">
|
||||
<Text className="text-xs text-gray-600">
|
||||
Example: http(s)://your-host.url
|
||||
</Text>
|
||||
<Text className="text-xs text-gray-600">
|
||||
(add port if required)
|
||||
</Text>
|
||||
</View>
|
||||
<Input
|
||||
placeholder="Jellyseerr URL..."
|
||||
value={settings?.jellyseerrServerUrl ?? jellyseerrServerUrl}
|
||||
defaultValue={
|
||||
settings?.jellyseerrServerUrl ?? jellyseerrServerUrl
|
||||
}
|
||||
keyboardType="url"
|
||||
returnKeyType="done"
|
||||
autoCapitalize="none"
|
||||
textContentType="URL"
|
||||
onChangeText={setjellyseerrServerUrl}
|
||||
editable={!testJellyseerrServerUrlMutation.isPending}
|
||||
/>
|
||||
|
||||
<Button
|
||||
loading={testJellyseerrServerUrlMutation.isPending}
|
||||
disabled={testJellyseerrServerUrlMutation.isPending}
|
||||
color={promptForJellyseerrPass ? "red" : "purple"}
|
||||
className="h-12 mt-2"
|
||||
onPress={() => {
|
||||
if (promptForJellyseerrPass) {
|
||||
clearData();
|
||||
return;
|
||||
}
|
||||
|
||||
testJellyseerrServerUrlMutation.mutate();
|
||||
}}
|
||||
style={{
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
{promptForJellyseerrPass ? "Clear" : "Save"}
|
||||
</Button>
|
||||
|
||||
<View
|
||||
pointerEvents={promptForJellyseerrPass ? "auto" : "none"}
|
||||
style={{
|
||||
opacity: promptForJellyseerrPass ? 1 : 0.5,
|
||||
}}
|
||||
>
|
||||
<Text className="font-bold mb-2">Password</Text>
|
||||
<Input
|
||||
autoFocus={true}
|
||||
focusable={true}
|
||||
placeholder={`Enter password for Jellyfin user ${user?.Name}`}
|
||||
value={jellyseerrPassword}
|
||||
keyboardType="default"
|
||||
secureTextEntry={true}
|
||||
returnKeyType="done"
|
||||
autoCapitalize="none"
|
||||
textContentType="password"
|
||||
onChangeText={setJellyseerrPassword}
|
||||
editable={
|
||||
!loginToJellyseerrMutation.isPending &&
|
||||
promptForJellyseerrPass
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
loading={loginToJellyseerrMutation.isPending}
|
||||
disabled={loginToJellyseerrMutation.isPending}
|
||||
color="purple"
|
||||
className="h-12 mt-2"
|
||||
onPress={() => loginToJellyseerrMutation.mutate()}
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -1,150 +0,0 @@
|
||||
import { Settings, useSettings } from "@/utils/atoms/settings";
|
||||
import { useAtomValue } from "jotai";
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
ReactNode,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getLocalizationApi, getUserApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import {
|
||||
CultureDto,
|
||||
UserDto,
|
||||
UserConfiguration,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
interface MediaContextType {
|
||||
settings: Settings | null;
|
||||
updateSettings: (update: Partial<Settings>) => void;
|
||||
user: UserDto | undefined;
|
||||
cultures: CultureDto[];
|
||||
}
|
||||
|
||||
const MediaContext = createContext<MediaContextType | undefined>(undefined);
|
||||
|
||||
export const useMedia = () => {
|
||||
const context = useContext(MediaContext);
|
||||
if (!context) {
|
||||
throw new Error("useMedia must be used within a MediaProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const MediaProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [settings, updateSettings] = useSettings();
|
||||
const api = useAtomValue(apiAtom);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const updateSetingsWrapper = (update: Partial<Settings>) => {
|
||||
const updateUserConfiguration = async (
|
||||
update: Partial<UserConfiguration>
|
||||
) => {
|
||||
if (api && user) {
|
||||
try {
|
||||
await getUserApi(api).updateUserConfiguration({
|
||||
userConfiguration: {
|
||||
...user.Configuration,
|
||||
...update,
|
||||
},
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["authUser"] });
|
||||
} catch (error) {}
|
||||
}
|
||||
};
|
||||
|
||||
updateSettings(update);
|
||||
|
||||
let updatePayload = {
|
||||
SubtitleMode: update?.subtitleMode ?? settings?.subtitleMode,
|
||||
PlayDefaultAudioTrack:
|
||||
update?.playDefaultAudioTrack ?? settings?.playDefaultAudioTrack,
|
||||
RememberAudioSelections:
|
||||
update?.rememberAudioSelections ?? settings?.rememberAudioSelections,
|
||||
RememberSubtitleSelections:
|
||||
update?.rememberSubtitleSelections ??
|
||||
settings?.rememberSubtitleSelections,
|
||||
} as Partial<UserConfiguration>;
|
||||
|
||||
updatePayload.AudioLanguagePreference =
|
||||
update?.defaultAudioLanguage === null
|
||||
? ""
|
||||
: update?.defaultAudioLanguage?.ThreeLetterISOLanguageName ||
|
||||
settings?.defaultAudioLanguage?.ThreeLetterISOLanguageName ||
|
||||
"";
|
||||
|
||||
updatePayload.SubtitleLanguagePreference =
|
||||
update?.defaultSubtitleLanguage === null
|
||||
? ""
|
||||
: update?.defaultSubtitleLanguage?.ThreeLetterISOLanguageName ||
|
||||
settings?.defaultSubtitleLanguage?.ThreeLetterISOLanguageName ||
|
||||
"";
|
||||
|
||||
updateUserConfiguration(updatePayload);
|
||||
};
|
||||
|
||||
const { data: user } = useQuery({
|
||||
queryKey: ["authUser"],
|
||||
queryFn: async () => {
|
||||
if (!api) return;
|
||||
const userApi = await getUserApi(api).getCurrentUser();
|
||||
return userApi.data;
|
||||
},
|
||||
enabled: !!api,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const { data: cultures = [], isFetched: isCulturesFetched } = useQuery({
|
||||
queryKey: ["cultures"],
|
||||
queryFn: async () => {
|
||||
if (!api) return [];
|
||||
const localizationApi = await getLocalizationApi(api).getCultures();
|
||||
const cultures = localizationApi.data;
|
||||
return cultures;
|
||||
},
|
||||
enabled: !!api,
|
||||
staleTime: 43200000, // 12 hours
|
||||
});
|
||||
|
||||
// Set default settings from user configuration.s
|
||||
useEffect(() => {
|
||||
if (!user || cultures.length === 0) return;
|
||||
const userSubtitlePreference =
|
||||
user?.Configuration?.SubtitleLanguagePreference;
|
||||
const userAudioPreference = user?.Configuration?.AudioLanguagePreference;
|
||||
|
||||
const subtitlePreference = cultures.find(
|
||||
(x) => x.ThreeLetterISOLanguageName === userSubtitlePreference
|
||||
);
|
||||
const audioPreference = cultures.find(
|
||||
(x) => x.ThreeLetterISOLanguageName === userAudioPreference
|
||||
);
|
||||
|
||||
updateSettings({
|
||||
defaultSubtitleLanguage: subtitlePreference,
|
||||
defaultAudioLanguage: audioPreference,
|
||||
subtitleMode: user?.Configuration?.SubtitleMode,
|
||||
playDefaultAudioTrack: user?.Configuration?.PlayDefaultAudioTrack,
|
||||
rememberAudioSelections: user?.Configuration?.RememberAudioSelections,
|
||||
rememberSubtitleSelections:
|
||||
user?.Configuration?.RememberSubtitleSelections,
|
||||
});
|
||||
}, [user, isCulturesFetched]);
|
||||
|
||||
if (!api) return null;
|
||||
|
||||
return (
|
||||
<MediaContext.Provider
|
||||
value={{
|
||||
settings,
|
||||
updateSettings: updateSetingsWrapper,
|
||||
user,
|
||||
cultures,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</MediaContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { Text } from "../common/Text";
|
||||
import { LANGUAGES } from "@/constants/Languages";
|
||||
|
||||
interface Props extends ViewProps {}
|
||||
|
||||
@@ -13,6 +15,113 @@ export const MediaToggles: React.FC<Props> = ({ ...props }) => {
|
||||
<View>
|
||||
<Text className="text-lg font-bold mb-2">Media</Text>
|
||||
<View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800">
|
||||
<View
|
||||
className={`
|
||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||
`}
|
||||
>
|
||||
<View className="flex flex-col shrink">
|
||||
<Text className="font-semibold">Audio language</Text>
|
||||
<Text className="text-xs opacity-50">
|
||||
Choose a default audio language.
|
||||
</Text>
|
||||
</View>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text>{settings?.defaultAudioLanguage?.label || "None"}</Text>
|
||||
</TouchableOpacity>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>Languages</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
key={"none-audio"}
|
||||
onSelect={() => {
|
||||
updateSettings({
|
||||
defaultAudioLanguage: null,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>None</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
{LANGUAGES.map((l) => (
|
||||
<DropdownMenu.Item
|
||||
key={l.value}
|
||||
onSelect={() => {
|
||||
updateSettings({
|
||||
defaultAudioLanguage: l,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>{l.label}</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
<View
|
||||
className={`
|
||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||
`}
|
||||
>
|
||||
<View className="flex flex-col shrink">
|
||||
<Text className="font-semibold">Subtitle language</Text>
|
||||
<Text className="text-xs opacity-50">
|
||||
Choose a default subtitle language.
|
||||
</Text>
|
||||
</View>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text>
|
||||
{settings?.defaultSubtitleLanguage?.label || "None"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>Languages</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
key={"none-subs"}
|
||||
onSelect={() => {
|
||||
updateSettings({
|
||||
defaultSubtitleLanguage: null,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>None</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
{LANGUAGES.map((l) => (
|
||||
<DropdownMenu.Item
|
||||
key={l.value}
|
||||
onSelect={() => {
|
||||
updateSettings({
|
||||
defaultSubtitleLanguage: l,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>{l.label}</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
|
||||
<View
|
||||
className={`
|
||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||
|
||||
@@ -1,38 +1,84 @@
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import {
|
||||
apiAtom,
|
||||
getOrSetDeviceId,
|
||||
userAtom,
|
||||
} from "@/providers/JellyfinProvider";
|
||||
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
|
||||
import {
|
||||
BACKGROUND_FETCH_TASK,
|
||||
registerBackgroundFetchAsync,
|
||||
unregisterBackgroundFetchAsync,
|
||||
} from "@/utils/background-tasks";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import * as BackgroundFetch from "expo-background-fetch";
|
||||
import * as ScreenOrientation from "expo-screen-orientation";
|
||||
import * as TaskManager from "expo-task-manager";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Linking,
|
||||
Modal,
|
||||
Switch,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
ViewProps,
|
||||
} from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { Button } from "../Button";
|
||||
import { Input } from "../common/Input";
|
||||
import { Text } from "../common/Text";
|
||||
import { Loader } from "../Loader";
|
||||
import { AudioToggles } from "./AudioToggles";
|
||||
import { JellyseerrSettings } from "./Jellyseerr";
|
||||
import { MediaProvider } from "./MediaContext";
|
||||
import { MediaToggles } from "./MediaToggles";
|
||||
import { SubtitleToggles } from "./SubtitleToggles";
|
||||
import axios from "axios";
|
||||
import { getStatistics } from "@/utils/optimize-server";
|
||||
|
||||
interface Props extends ViewProps {}
|
||||
|
||||
export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
||||
const [settings, updateSettings] = useSettings();
|
||||
const { setProcesses } = useDownload();
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const [marlinUrl, setMarlinUrl] = useState<string>("");
|
||||
const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] =
|
||||
useState<string>(settings?.optimizedVersionsServerUrl || "");
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const [isSearchEngineModalVisible, setIsSearchEngineModalVisible] =
|
||||
useState(false);
|
||||
|
||||
/********************
|
||||
* Background task
|
||||
*******************/
|
||||
const checkStatusAsync = async () => {
|
||||
await BackgroundFetch.getStatusAsync();
|
||||
return await TaskManager.isTaskRegisteredAsync(BACKGROUND_FETCH_TASK);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const registered = await checkStatusAsync();
|
||||
|
||||
if (settings?.autoDownload === true && !registered) {
|
||||
registerBackgroundFetchAsync();
|
||||
toast.success("Background downlodas enabled");
|
||||
} else if (settings?.autoDownload === false && registered) {
|
||||
unregisterBackgroundFetchAsync();
|
||||
toast.info("Background downloads disabled");
|
||||
} else if (settings?.autoDownload === true && registered) {
|
||||
// Don't to anything
|
||||
} else if (settings?.autoDownload === false && !registered) {
|
||||
// Don't to anything
|
||||
} else {
|
||||
updateSettings({ autoDownload: false });
|
||||
}
|
||||
})();
|
||||
}, [settings?.autoDownload]);
|
||||
/**********************
|
||||
*********************/
|
||||
|
||||
const {
|
||||
data: mediaListCollections,
|
||||
@@ -56,13 +102,6 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
type SearchEngine = "Jellyfin" | "Marlin";
|
||||
|
||||
const searchEngines: Array<{ id: SearchEngine; name: string }> = [
|
||||
{ id: "Jellyfin", name: "Jellyfin" },
|
||||
{ id: "Marlin", name: "Marlin" },
|
||||
];
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
return (
|
||||
@@ -82,11 +121,7 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
||||
</View>
|
||||
</View> */}
|
||||
|
||||
<MediaProvider>
|
||||
<MediaToggles />
|
||||
<AudioToggles />
|
||||
<SubtitleToggles />
|
||||
</MediaProvider>
|
||||
<MediaToggles />
|
||||
|
||||
<View>
|
||||
<Text className="text-lg font-bold mb-2">Other</Text>
|
||||
@@ -106,18 +141,126 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||
<View className="shrink">
|
||||
<Text className="font-semibold">Safe area in controls</Text>
|
||||
<View
|
||||
pointerEvents={settings.autoRotate ? "none" : "auto"}
|
||||
className={`
|
||||
${
|
||||
settings.autoRotate
|
||||
? "opacity-50 pointer-events-none"
|
||||
: "opacity-100"
|
||||
}
|
||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||
`}
|
||||
>
|
||||
<View className="flex flex-col shrink">
|
||||
<Text className="font-semibold">Video orientation</Text>
|
||||
<Text className="text-xs opacity-50">
|
||||
Enable safe area in video player controls
|
||||
Set the full screen video player orientation.
|
||||
</Text>
|
||||
</View>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text>
|
||||
{ScreenOrientationEnum[settings.defaultVideoOrientation]}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>Orientation</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
key="1"
|
||||
onSelect={() => {
|
||||
updateSettings({
|
||||
defaultVideoOrientation:
|
||||
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>
|
||||
</View>
|
||||
|
||||
<View className="flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4">
|
||||
<View className="flex flex-col shrink">
|
||||
<Text className="font-semibold">Use external player (VLC)</Text>
|
||||
<Text className="text-xs opacity-50 shrink">
|
||||
Open all videos in VLC instead of the default player. This
|
||||
requries VLC to be installed on the phone.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={settings.safeAreaInControlsEnabled}
|
||||
onValueChange={(value) =>
|
||||
updateSettings({ safeAreaInControlsEnabled: value })
|
||||
}
|
||||
value={settings.openInVLC}
|
||||
onValueChange={(value) => {
|
||||
updateSettings({ openInVLC: value, forceDirectPlay: value });
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -191,28 +334,128 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className="flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4">
|
||||
<View className="flex flex-col shrink">
|
||||
<Text className="font-semibold">Force direct play</Text>
|
||||
<Text className="text-xs opacity-50 shrink">
|
||||
This will always request direct play. This is good if you want
|
||||
to try to stream movies you think the device supports.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={settings.forceDirectPlay}
|
||||
onValueChange={(value) =>
|
||||
updateSettings({ forceDirectPlay: value })
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View
|
||||
className={`
|
||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||
${settings.forceDirectPlay ? "opacity-50 select-none" : ""}
|
||||
`}
|
||||
>
|
||||
<View className="flex flex-col shrink">
|
||||
<Text className="font-semibold">Device profile</Text>
|
||||
<Text className="text-xs opacity-50">
|
||||
A profile used for deciding what audio and video codecs the
|
||||
device supports.
|
||||
</Text>
|
||||
</View>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text>{settings.deviceProfile}</Text>
|
||||
</TouchableOpacity>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>Profiles</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
key="1"
|
||||
onSelect={() => {
|
||||
updateSettings({ deviceProfile: "Expo" });
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>Expo</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
key="2"
|
||||
onSelect={() => {
|
||||
updateSettings({ deviceProfile: "Native" });
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>Native</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
key="3"
|
||||
onSelect={() => {
|
||||
updateSettings({ deviceProfile: "Old" });
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>Old</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
|
||||
<View className="flex flex-col">
|
||||
<View className="flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4">
|
||||
<View
|
||||
className={`
|
||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||
`}
|
||||
>
|
||||
<View className="flex flex-col shrink">
|
||||
<Text className="font-semibold">Search engine</Text>
|
||||
<Text className="text-xs opacity-50">
|
||||
Choose the search engine you want to use.
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between"
|
||||
onPress={() => setIsSearchEngineModalVisible(true)}
|
||||
>
|
||||
<Text>{settings.searchEngine}</Text>
|
||||
<Ionicons
|
||||
name="chevron-down"
|
||||
size={16}
|
||||
color="white"
|
||||
style={{ opacity: 0.5, marginLeft: 8 }}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text>{settings.searchEngine}</Text>
|
||||
</TouchableOpacity>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>Profiles</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
key="1"
|
||||
onSelect={() => {
|
||||
updateSettings({ searchEngine: "Jellyfin" });
|
||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>Jellyfin</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
key="2"
|
||||
onSelect={() => {
|
||||
updateSettings({ searchEngine: "Marlin" });
|
||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>Marlin</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
|
||||
{settings.searchEngine === "Marlin" && (
|
||||
<View className="flex flex-col bg-neutral-900 px-4 pb-4">
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
@@ -251,58 +494,141 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Modal
|
||||
visible={isSearchEngineModalVisible}
|
||||
transparent
|
||||
animationType="slide"
|
||||
onRequestClose={() => setIsSearchEngineModalVisible(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
className="flex-1 bg-black/50"
|
||||
activeOpacity={1}
|
||||
onPress={() => setIsSearchEngineModalVisible(false)}
|
||||
>
|
||||
<View className="mt-auto bg-neutral-900 rounded-t-xl">
|
||||
<View className="p-4 border-b border-neutral-800">
|
||||
<Text className="text-lg font-bold text-center">
|
||||
Select Search Engine
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="max-h-[50%]">
|
||||
{searchEngines.map((engine) => (
|
||||
<TouchableOpacity
|
||||
key={engine.id}
|
||||
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
|
||||
onPress={() => {
|
||||
updateSettings({
|
||||
searchEngine: engine.id,
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||
setIsSearchEngineModalVisible(false);
|
||||
}}
|
||||
>
|
||||
<Text>{engine.name}</Text>
|
||||
{settings.searchEngine === engine.id && (
|
||||
<Ionicons name="checkmark" size={24} color="white" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
className="p-4 border-t border-neutral-800"
|
||||
onPress={() => setIsSearchEngineModalVisible(false)}
|
||||
>
|
||||
<Text className="text-center text-purple-400">Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</View>
|
||||
</View>
|
||||
<JellyseerrSettings />
|
||||
|
||||
<View className="mt-4">
|
||||
<Text className="text-lg font-bold mb-2">Downloads</Text>
|
||||
<View className="flex flex-col rounded-xl overflow-hidden divide-y-2 divide-solid divide-neutral-800">
|
||||
<View
|
||||
className={`
|
||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||
`}
|
||||
>
|
||||
<View className="flex flex-col shrink">
|
||||
<Text className="font-semibold">Download method</Text>
|
||||
<Text className="text-xs opacity-50">
|
||||
Choose the download method to use. Optimized requires the
|
||||
optimized server.
|
||||
</Text>
|
||||
</View>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text>
|
||||
{settings.downloadMethod === "remux"
|
||||
? "Default"
|
||||
: "Optimized"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>Methods</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
key="1"
|
||||
onSelect={() => {
|
||||
updateSettings({ downloadMethod: "remux" });
|
||||
setProcesses([]);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>Default</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
key="2"
|
||||
onSelect={() => {
|
||||
updateSettings({ downloadMethod: "optimized" });
|
||||
setProcesses([]);
|
||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>Optimized</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
<View className="flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4">
|
||||
<View className="flex flex-col shrink">
|
||||
<Text className="font-semibold">Auto download</Text>
|
||||
<Text className="text-xs opacity-50 shrink">
|
||||
This will automatically download the media file when it's
|
||||
finished optimizing on the server.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={settings.autoDownload}
|
||||
onValueChange={(value) => updateSettings({ autoDownload: value })}
|
||||
/>
|
||||
</View>
|
||||
<View
|
||||
pointerEvents={
|
||||
settings.downloadMethod === "optimized" ? "auto" : "none"
|
||||
}
|
||||
className={`
|
||||
${
|
||||
settings.downloadMethod === "optimized"
|
||||
? "opacity-100"
|
||||
: "opacity-50"
|
||||
}`}
|
||||
>
|
||||
<View className="flex flex-col bg-neutral-900 px-4 py-4">
|
||||
<View className="flex flex-col shrink mb-2">
|
||||
<View className="flex flex-row justify-between items-center">
|
||||
<Text className="font-semibold">
|
||||
Optimized versions server
|
||||
</Text>
|
||||
</View>
|
||||
<Text className="text-xs opacity-50">
|
||||
Set the URL for the optimized versions server for downloads.
|
||||
</Text>
|
||||
</View>
|
||||
<View></View>
|
||||
<View className="flex flex-col">
|
||||
<Input
|
||||
placeholder="Optimized versions server URL..."
|
||||
value={optimizedVersionsServerUrl}
|
||||
keyboardType="url"
|
||||
returnKeyType="done"
|
||||
autoCapitalize="none"
|
||||
textContentType="URL"
|
||||
onChangeText={(text) => setOptimizedVersionsServerUrl(text)}
|
||||
/>
|
||||
<Button
|
||||
color="purple"
|
||||
className="h-12 mt-2"
|
||||
onPress={async () => {
|
||||
updateSettings({
|
||||
optimizedVersionsServerUrl:
|
||||
optimizedVersionsServerUrl.length === 0
|
||||
? null
|
||||
: optimizedVersionsServerUrl.endsWith("/")
|
||||
? optimizedVersionsServerUrl
|
||||
: optimizedVersionsServerUrl + "/",
|
||||
});
|
||||
const res = await getStatistics({
|
||||
url: settings?.optimizedVersionsServerUrl,
|
||||
authHeader: api?.accessToken,
|
||||
deviceId: await getOrSetDeviceId(),
|
||||
});
|
||||
if (res) {
|
||||
toast.success("Connected");
|
||||
} else toast.error("Could not connect");
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,252 +0,0 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useState } from "react";
|
||||
import { Modal, TouchableOpacity, View, ViewProps } from "react-native";
|
||||
import { Switch } from "react-native-gesture-handler";
|
||||
import { Text } from "../common/Text";
|
||||
import { useMedia } from "./MediaContext";
|
||||
|
||||
interface Props extends ViewProps {}
|
||||
|
||||
export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
||||
const media = useMedia();
|
||||
const { settings, updateSettings } = media;
|
||||
const cultures = media.cultures;
|
||||
const [isLanguageModalVisible, setIsLanguageModalVisible] = useState(false);
|
||||
const [isModeModalVisible, setIsModeModalVisible] = useState(false);
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
const subtitleModes = [
|
||||
SubtitlePlaybackMode.Default,
|
||||
SubtitlePlaybackMode.Smart,
|
||||
SubtitlePlaybackMode.OnlyForced,
|
||||
SubtitlePlaybackMode.Always,
|
||||
SubtitlePlaybackMode.None,
|
||||
];
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Text className="text-lg font-bold mb-2">Subtitle</Text>
|
||||
<View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800">
|
||||
<View className="flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4">
|
||||
<View className="flex flex-col shrink">
|
||||
<Text className="font-semibold">Subtitle language</Text>
|
||||
<Text className="text-xs opacity-50">
|
||||
Choose a default subtitle language.
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between"
|
||||
onPress={() => setIsLanguageModalVisible(true)}
|
||||
>
|
||||
<Text>
|
||||
{settings?.defaultSubtitleLanguage?.DisplayName || "None"}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name="chevron-down"
|
||||
size={16}
|
||||
color="white"
|
||||
style={{ opacity: 0.5, marginLeft: 8 }}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View className="flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4">
|
||||
<View className="flex flex-col shrink">
|
||||
<Text className="font-semibold">Subtitle Mode</Text>
|
||||
<Text className="text-xs opacity-50 mr-2">
|
||||
Subtitles are loaded based on the default and forced flags in the
|
||||
embedded metadata. Language preferences are considered when
|
||||
multiple options are available.
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between"
|
||||
onPress={() => setIsModeModalVisible(true)}
|
||||
>
|
||||
<Text>{settings?.subtitleMode || "Loading"}</Text>
|
||||
<Ionicons
|
||||
name="chevron-down"
|
||||
size={16}
|
||||
color="white"
|
||||
style={{ opacity: 0.5, marginLeft: 8 }}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View className="flex flex-col">
|
||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||
<View className="flex flex-col">
|
||||
<Text className="font-semibold">
|
||||
Set Subtitle Track From Previous Item
|
||||
</Text>
|
||||
<Text className="text-xs opacity-50 min max-w-[85%]">
|
||||
Try to set the subtitle track to the closest match to the last
|
||||
video.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={settings.rememberSubtitleSelections}
|
||||
onValueChange={(value) =>
|
||||
updateSettings({ rememberSubtitleSelections: value })
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View
|
||||
className={`
|
||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||
`}
|
||||
>
|
||||
<View className="flex flex-col shrink">
|
||||
<Text className="font-semibold">Subtitle Size</Text>
|
||||
<Text className="text-xs opacity-50">
|
||||
Choose a default subtitle size for direct play (only works for
|
||||
some subtitle formats).
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex flex-row items-center">
|
||||
<TouchableOpacity
|
||||
onPress={() =>
|
||||
updateSettings({
|
||||
subtitleSize: Math.max(0, settings.subtitleSize - 5),
|
||||
})
|
||||
}
|
||||
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">
|
||||
{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>
|
||||
</View>
|
||||
</View>
|
||||
<Modal
|
||||
visible={isLanguageModalVisible}
|
||||
transparent
|
||||
animationType="slide"
|
||||
onRequestClose={() => setIsLanguageModalVisible(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
className="flex-1 bg-black/50"
|
||||
activeOpacity={1}
|
||||
onPress={() => setIsLanguageModalVisible(false)}
|
||||
>
|
||||
<View className="mt-auto bg-neutral-900 rounded-t-xl">
|
||||
<View className="p-4 border-b border-neutral-800">
|
||||
<Text className="text-lg font-bold text-center">
|
||||
Select Language
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="max-h-[50%]">
|
||||
<TouchableOpacity
|
||||
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
|
||||
onPress={() => {
|
||||
updateSettings({
|
||||
defaultSubtitleLanguage: null,
|
||||
});
|
||||
setIsLanguageModalVisible(false);
|
||||
}}
|
||||
>
|
||||
<Text>None</Text>
|
||||
{!settings?.defaultSubtitleLanguage && (
|
||||
<Ionicons name="checkmark" size={24} color="white" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
{cultures?.map((l) => (
|
||||
<TouchableOpacity
|
||||
key={l?.ThreeLetterISOLanguageName ?? "unknown"}
|
||||
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
|
||||
onPress={() => {
|
||||
updateSettings({
|
||||
defaultSubtitleLanguage: l,
|
||||
});
|
||||
setIsLanguageModalVisible(false);
|
||||
}}
|
||||
>
|
||||
<Text>{l.DisplayName}</Text>
|
||||
{settings?.defaultSubtitleLanguage
|
||||
?.ThreeLetterISOLanguageName ===
|
||||
l.ThreeLetterISOLanguageName && (
|
||||
<Ionicons name="checkmark" size={24} color="white" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
className="p-4 border-t border-neutral-800"
|
||||
onPress={() => setIsLanguageModalVisible(false)}
|
||||
>
|
||||
<Text className="text-center text-purple-400">Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
|
||||
{/* Subtitle Mode Selection Modal */}
|
||||
<Modal
|
||||
visible={isModeModalVisible}
|
||||
transparent
|
||||
animationType="slide"
|
||||
onRequestClose={() => setIsModeModalVisible(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
className="flex-1 bg-black/50"
|
||||
activeOpacity={1}
|
||||
onPress={() => setIsModeModalVisible(false)}
|
||||
>
|
||||
<View className="mt-auto bg-neutral-900 rounded-t-xl">
|
||||
<View className="p-4 border-b border-neutral-800">
|
||||
<Text className="text-lg font-bold text-center">
|
||||
Select Subtitle Mode
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="max-h-[50%]">
|
||||
{subtitleModes?.map((mode) => (
|
||||
<TouchableOpacity
|
||||
key={mode}
|
||||
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
|
||||
onPress={() => {
|
||||
updateSettings({
|
||||
subtitleMode: mode,
|
||||
});
|
||||
setIsModeModalVisible(false);
|
||||
}}
|
||||
>
|
||||
<Text>{mode}</Text>
|
||||
{settings?.subtitleMode === mode && (
|
||||
<Ionicons name="checkmark" size={24} color="white" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
className="p-4 border-t border-neutral-800"
|
||||
onPress={() => setIsModeModalVisible(false)}
|
||||
>
|
||||
<Text className="text-center text-purple-400">Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -1,15 +1,6 @@
|
||||
import { NativeStackNavigationOptions } from "@react-navigation/native-stack";
|
||||
import { HeaderBackButton } from "../common/HeaderBackButton";
|
||||
import { ParamListBase, RouteProp } from "@react-navigation/native";
|
||||
|
||||
type ICommonScreenOptions =
|
||||
| NativeStackNavigationOptions
|
||||
| ((prop: {
|
||||
route: RouteProp<ParamListBase, string>;
|
||||
navigation: any;
|
||||
}) => NativeStackNavigationOptions);
|
||||
|
||||
export const commonScreenOptions: ICommonScreenOptions = {
|
||||
const commonScreenOptions = {
|
||||
title: "",
|
||||
headerShown: true,
|
||||
headerTransparent: true,
|
||||
@@ -26,5 +17,5 @@ const routes = [
|
||||
"series/[id]",
|
||||
];
|
||||
|
||||
export const nestedTabPageScreenOptions: Record<string, ICommonScreenOptions> =
|
||||
export const nestedTabPageScreenOptions: { [key: string]: any } =
|
||||
Object.fromEntries(routes.map((route) => [route, commonScreenOptions]));
|
||||
|
||||
535
components/video-player/Controls.tsx
Normal file
535
components/video-player/Controls.tsx
Normal file
@@ -0,0 +1,535 @@
|
||||
import { useAdjacentItems } from "@/hooks/useAdjacentEpisodes";
|
||||
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
|
||||
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
|
||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import { formatTimeString, ticksToSeconds } from "@/utils/time";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { Image } from "expo-image";
|
||||
import { useRouter } from "expo-router";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Dimensions,
|
||||
Platform,
|
||||
Pressable,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Slider } from "react-native-awesome-slider";
|
||||
import Animated, {
|
||||
runOnJS,
|
||||
SharedValue,
|
||||
useAnimatedReaction,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { VideoRef } from "react-native-video";
|
||||
import { Text } from "../common/Text";
|
||||
import { Loader } from "../Loader";
|
||||
|
||||
interface Props {
|
||||
item: BaseItemDto;
|
||||
videoRef: React.MutableRefObject<VideoRef | null>;
|
||||
isPlaying: boolean;
|
||||
isSeeking: SharedValue<boolean>;
|
||||
cacheProgress: SharedValue<number>;
|
||||
progress: SharedValue<number>;
|
||||
isBuffering: boolean;
|
||||
showControls: boolean;
|
||||
ignoreSafeAreas?: boolean;
|
||||
setIgnoreSafeAreas: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
enableTrickplay?: boolean;
|
||||
togglePlay: (ticks: number) => void;
|
||||
setShowControls: (shown: boolean) => void;
|
||||
}
|
||||
|
||||
export const Controls: React.FC<Props> = ({
|
||||
item,
|
||||
videoRef,
|
||||
togglePlay,
|
||||
isPlaying,
|
||||
isSeeking,
|
||||
progress,
|
||||
isBuffering,
|
||||
cacheProgress,
|
||||
showControls,
|
||||
setShowControls,
|
||||
ignoreSafeAreas,
|
||||
setIgnoreSafeAreas,
|
||||
enableTrickplay = true,
|
||||
}) => {
|
||||
const [settings] = useSettings();
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { setPlaySettings } = usePlaySettings();
|
||||
|
||||
const windowDimensions = Dimensions.get("window");
|
||||
|
||||
const op = useSharedValue<number>(1);
|
||||
const tr = useSharedValue<number>(10);
|
||||
const animatedStyles = useAnimatedStyle(() => {
|
||||
return {
|
||||
opacity: op.value,
|
||||
};
|
||||
});
|
||||
const animatedTopStyles = useAnimatedStyle(() => {
|
||||
return {
|
||||
opacity: op.value,
|
||||
transform: [
|
||||
{
|
||||
translateY: -tr.value,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
const animatedBottomStyles = useAnimatedStyle(() => {
|
||||
return {
|
||||
opacity: op.value,
|
||||
transform: [
|
||||
{
|
||||
translateY: tr.value,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (showControls || isBuffering) {
|
||||
op.value = withTiming(1, { duration: 200 });
|
||||
tr.value = withTiming(0, { duration: 200 });
|
||||
} else {
|
||||
op.value = withTiming(0, { duration: 200 });
|
||||
tr.value = withTiming(10, { duration: 200 });
|
||||
}
|
||||
}, [showControls, isBuffering]);
|
||||
|
||||
const { previousItem, nextItem } = useAdjacentItems({ item });
|
||||
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay(
|
||||
item,
|
||||
enableTrickplay
|
||||
);
|
||||
|
||||
const [currentTime, setCurrentTime] = useState(0); // Seconds
|
||||
const [remainingTime, setRemainingTime] = useState(0); // Seconds
|
||||
|
||||
const min = useSharedValue(0);
|
||||
const max = useSharedValue(item.RunTimeTicks || 0);
|
||||
|
||||
const wasPlayingRef = useRef(false);
|
||||
|
||||
const { showSkipButton, skipIntro } = useIntroSkipper(
|
||||
item.Id,
|
||||
currentTime,
|
||||
videoRef
|
||||
);
|
||||
|
||||
const { showSkipCreditButton, skipCredit } = useCreditSkipper(
|
||||
item.Id,
|
||||
currentTime,
|
||||
videoRef
|
||||
);
|
||||
|
||||
const goToPreviousItem = useCallback(() => {
|
||||
if (!previousItem || !settings) return;
|
||||
|
||||
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
|
||||
getDefaultPlaySettings(previousItem, settings);
|
||||
|
||||
setPlaySettings({
|
||||
item: previousItem,
|
||||
bitrate,
|
||||
mediaSource,
|
||||
audioIndex,
|
||||
subtitleIndex,
|
||||
});
|
||||
|
||||
router.replace("/play-video");
|
||||
}, [previousItem, settings]);
|
||||
|
||||
const goToNextItem = useCallback(() => {
|
||||
if (!nextItem || !settings) return;
|
||||
|
||||
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
|
||||
getDefaultPlaySettings(nextItem, settings);
|
||||
|
||||
setPlaySettings({
|
||||
item: nextItem,
|
||||
bitrate,
|
||||
mediaSource,
|
||||
audioIndex,
|
||||
subtitleIndex,
|
||||
});
|
||||
|
||||
router.replace("/play-video");
|
||||
}, [nextItem, settings]);
|
||||
|
||||
const updateTimes = useCallback(
|
||||
(currentProgress: number, maxValue: number) => {
|
||||
const current = ticksToSeconds(currentProgress);
|
||||
const remaining = ticksToSeconds(maxValue - currentProgress);
|
||||
|
||||
setCurrentTime(current);
|
||||
setRemainingTime(remaining);
|
||||
|
||||
if (currentProgress === maxValue) {
|
||||
setShowControls(true);
|
||||
// Automatically play the next item if it exists
|
||||
goToNextItem();
|
||||
}
|
||||
},
|
||||
[goToNextItem]
|
||||
);
|
||||
|
||||
useAnimatedReaction(
|
||||
() => ({
|
||||
progress: progress.value,
|
||||
max: max.value,
|
||||
isSeeking: isSeeking.value,
|
||||
}),
|
||||
(result) => {
|
||||
if (result.isSeeking === false) {
|
||||
runOnJS(updateTimes)(result.progress, result.max);
|
||||
}
|
||||
},
|
||||
[updateTimes]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (item) {
|
||||
progress.value = item?.UserData?.PlaybackPositionTicks || 0;
|
||||
max.value = item.RunTimeTicks || 0;
|
||||
}
|
||||
}, [item]);
|
||||
|
||||
const toggleControls = () => setShowControls(!showControls);
|
||||
|
||||
const handleSliderComplete = useCallback((value: number) => {
|
||||
progress.value = value;
|
||||
isSeeking.value = false;
|
||||
videoRef.current?.seek(Math.max(0, Math.floor(value / 10000000)));
|
||||
if (wasPlayingRef.current === true) videoRef.current?.resume();
|
||||
}, []);
|
||||
|
||||
const handleSliderChange = (value: number) => {
|
||||
calculateTrickplayUrl(value);
|
||||
};
|
||||
|
||||
const handleSliderStart = useCallback(() => {
|
||||
if (showControls === false) return;
|
||||
wasPlayingRef.current = isPlaying;
|
||||
videoRef.current?.pause();
|
||||
isSeeking.value = true;
|
||||
}, [showControls, isPlaying]);
|
||||
|
||||
const handleSkipBackward = useCallback(async () => {
|
||||
console.log("handleSkipBackward");
|
||||
if (!settings?.rewindSkipTime) return;
|
||||
wasPlayingRef.current = isPlaying;
|
||||
try {
|
||||
const curr = await videoRef.current?.getCurrentPosition();
|
||||
if (curr !== undefined) {
|
||||
videoRef.current?.seek(Math.max(0, curr - settings.rewindSkipTime));
|
||||
setTimeout(() => {
|
||||
if (wasPlayingRef.current === true) videoRef.current?.resume();
|
||||
}, 10);
|
||||
}
|
||||
} catch (error) {
|
||||
writeToLog("ERROR", "Error seeking video backwards", error);
|
||||
}
|
||||
}, [settings, isPlaying]);
|
||||
|
||||
const handleSkipForward = useCallback(async () => {
|
||||
console.log("handleSkipForward");
|
||||
if (!settings?.forwardSkipTime) return;
|
||||
wasPlayingRef.current = isPlaying;
|
||||
try {
|
||||
const curr = await videoRef.current?.getCurrentPosition();
|
||||
if (curr !== undefined) {
|
||||
videoRef.current?.seek(Math.max(0, curr + settings.forwardSkipTime));
|
||||
setTimeout(() => {
|
||||
if (wasPlayingRef.current === true) videoRef.current?.resume();
|
||||
}, 10);
|
||||
}
|
||||
} catch (error) {
|
||||
writeToLog("ERROR", "Error seeking video forwards", error);
|
||||
}
|
||||
}, [settings, isPlaying]);
|
||||
|
||||
const toggleIgnoreSafeAreas = useCallback(() => {
|
||||
setIgnoreSafeAreas((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: windowDimensions.width,
|
||||
height: windowDimensions.height,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
position: "absolute",
|
||||
bottom: insets.bottom + 97,
|
||||
right: insets.right,
|
||||
},
|
||||
]}
|
||||
className={`z-10 p-4
|
||||
${showSkipButton ? "opacity-100" : "opacity-0"}
|
||||
`}
|
||||
>
|
||||
<TouchableOpacity
|
||||
onPress={skipIntro}
|
||||
className="bg-purple-600 rounded-full px-2.5 py-2 font-semibold"
|
||||
>
|
||||
<Text className="text-white">Skip Intro</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: insets.bottom + 94,
|
||||
right: insets.right,
|
||||
height: 70,
|
||||
}}
|
||||
pointerEvents={showSkipCreditButton ? "auto" : "none"}
|
||||
className={`z-10 p-4 ${
|
||||
showSkipCreditButton ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
>
|
||||
<TouchableOpacity
|
||||
onPress={skipCredit}
|
||||
className="bg-purple-600 rounded-full px-2.5 py-2 font-semibold"
|
||||
>
|
||||
<Text className="text-white">Skip Credits</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
toggleControls();
|
||||
}}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: windowDimensions.width + 100,
|
||||
height: windowDimensions.height + 100,
|
||||
},
|
||||
animatedStyles,
|
||||
]}
|
||||
className={`bg-black/50 z-0`}
|
||||
></Animated.View>
|
||||
</Pressable>
|
||||
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: windowDimensions.width,
|
||||
height: windowDimensions.height,
|
||||
}}
|
||||
pointerEvents="none"
|
||||
className={`flex flex-col items-center justify-center
|
||||
${isBuffering ? "opacity-100" : "opacity-0"}
|
||||
`}
|
||||
>
|
||||
<Loader />
|
||||
</View>
|
||||
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
position: "absolute",
|
||||
top: insets.top,
|
||||
right: insets.right,
|
||||
},
|
||||
animatedTopStyles,
|
||||
]}
|
||||
pointerEvents={showControls ? "auto" : "none"}
|
||||
className={`flex flex-row items-center space-x-2 z-10 p-4`}
|
||||
>
|
||||
<TouchableOpacity
|
||||
onPress={toggleIgnoreSafeAreas}
|
||||
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
||||
>
|
||||
<Ionicons
|
||||
name={ignoreSafeAreas ? "contract-outline" : "expand"}
|
||||
size={24}
|
||||
color="white"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.back();
|
||||
}}
|
||||
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
||||
>
|
||||
<Ionicons name="close" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
position: "absolute",
|
||||
width: windowDimensions.width - insets.left - insets.right,
|
||||
maxHeight: windowDimensions.height,
|
||||
left: insets.left,
|
||||
bottom: Platform.OS === "ios" ? insets.bottom : insets.bottom,
|
||||
},
|
||||
animatedBottomStyles,
|
||||
]}
|
||||
pointerEvents={showControls ? "auto" : "none"}
|
||||
className={`flex flex-col p-4 `}
|
||||
>
|
||||
<View className="shrink flex flex-col justify-center h-full mb-2">
|
||||
<Text className="font-bold">{item?.Name}</Text>
|
||||
{item?.Type === "Episode" && (
|
||||
<Text className="opacity-50">{item.SeriesName}</Text>
|
||||
)}
|
||||
{item?.Type === "Movie" && (
|
||||
<Text className="text-xs opacity-50">{item?.ProductionYear}</Text>
|
||||
)}
|
||||
{item?.Type === "Audio" && (
|
||||
<Text className="text-xs opacity-50">{item?.Album}</Text>
|
||||
)}
|
||||
</View>
|
||||
<View
|
||||
className={`flex flex-col-reverse py-4 px-4 rounded-2xl items-center bg-neutral-800/90`}
|
||||
>
|
||||
<View className="flex flex-row items-center space-x-4">
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
opacity: !previousItem ? 0.5 : 1,
|
||||
}}
|
||||
onPress={goToPreviousItem}
|
||||
>
|
||||
<Ionicons name="play-skip-back" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={handleSkipBackward}>
|
||||
<Ionicons
|
||||
name="refresh-outline"
|
||||
size={26}
|
||||
color="white"
|
||||
style={{
|
||||
transform: [{ scaleY: -1 }, { rotate: "180deg" }],
|
||||
}}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
togglePlay(progress.value);
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name={isPlaying ? "pause" : "play"}
|
||||
size={30}
|
||||
color="white"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={handleSkipForward}>
|
||||
<Ionicons name="refresh-outline" size={26} color="white" />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
opacity: !nextItem ? 0.5 : 1,
|
||||
}}
|
||||
onPress={goToNextItem}
|
||||
>
|
||||
<Ionicons name="play-skip-forward" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View className={`flex flex-col w-full shrink`}>
|
||||
<Slider
|
||||
theme={{
|
||||
maximumTrackTintColor: "rgba(255,255,255,0.2)",
|
||||
minimumTrackTintColor: "#fff",
|
||||
cacheTrackTintColor: "rgba(255,255,255,0.3)",
|
||||
bubbleBackgroundColor: "#fff",
|
||||
bubbleTextColor: "#000",
|
||||
heartbeatColor: "#999",
|
||||
}}
|
||||
cache={cacheProgress}
|
||||
onSlidingStart={handleSliderStart}
|
||||
onSlidingComplete={handleSliderComplete}
|
||||
onValueChange={handleSliderChange}
|
||||
containerStyle={{
|
||||
borderRadius: 100,
|
||||
}}
|
||||
renderBubble={() => {
|
||||
if (!trickPlayUrl || !trickplayInfo) {
|
||||
return null;
|
||||
}
|
||||
const { x, y, url } = trickPlayUrl;
|
||||
|
||||
const tileWidth = 150;
|
||||
const tileHeight = 150 / trickplayInfo.aspectRatio!;
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
width: tileWidth,
|
||||
height: tileHeight,
|
||||
marginLeft: -tileWidth / 4,
|
||||
marginTop: -tileHeight / 4 - 60,
|
||||
zIndex: 10,
|
||||
}}
|
||||
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 },
|
||||
],
|
||||
}}
|
||||
source={{ uri: url }}
|
||||
contentFit="cover"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}}
|
||||
sliderHeight={10}
|
||||
thumbWidth={0}
|
||||
progress={progress}
|
||||
minimumValue={min}
|
||||
maximumValue={max}
|
||||
/>
|
||||
<View className="flex flex-row items-center justify-between mt-0.5">
|
||||
<Text className="text-[12px] text-neutral-400">
|
||||
{formatTimeString(currentTime)}
|
||||
</Text>
|
||||
<Text className="text-[12px] text-neutral-400">
|
||||
-{formatTimeString(remainingTime)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user