Compare commits
3 Commits
v0.22.0
...
feat/syncp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4eaabce7a | ||
|
|
788b4bcbd2 | ||
|
|
acbc650ccf |
26
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
name: Bug report
|
||||||
|
about: Create a report to help us improve
|
||||||
|
title: ''
|
||||||
|
labels: ''
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Describe the bug**
|
||||||
|
A clear and concise description of what the bug is.
|
||||||
|
|
||||||
|
**To Reproduce**
|
||||||
|
Steps to reproduce the behavior:
|
||||||
|
1. Go to '...'
|
||||||
|
2. Click on '....'
|
||||||
|
3. Scroll down to '....'
|
||||||
|
4. See error
|
||||||
|
|
||||||
|
**Screenshots**
|
||||||
|
If applicable, add screenshots to help explain your problem.
|
||||||
|
|
||||||
|
**Smartphone (please complete the following information):**
|
||||||
|
- Device: [e.g. iPhone15Pro]
|
||||||
|
- OS: [e.g. iOS18]
|
||||||
|
- Version [e.g. 0.3.1]
|
||||||
59
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,59 +0,0 @@
|
|||||||
name: Bug report
|
|
||||||
description: Create a report to help us improve
|
|
||||||
title: '[Bug]: '
|
|
||||||
labels:
|
|
||||||
- ['❌ bug']
|
|
||||||
projects:
|
|
||||||
- ['fredrikburmester/5']
|
|
||||||
assignees:
|
|
||||||
- fredrikburmester
|
|
||||||
|
|
||||||
body:
|
|
||||||
- type: textarea
|
|
||||||
id: what-happened
|
|
||||||
attributes:
|
|
||||||
label: What happened?
|
|
||||||
description: Also tell us, what did you expect to happen?
|
|
||||||
placeholder: A clear and concise description of what the bug is.
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: repro
|
|
||||||
attributes:
|
|
||||||
label: Reproduction steps
|
|
||||||
description: "How do you trigger this bug? Please walk us through it step by step."
|
|
||||||
placeholder: |
|
|
||||||
1.
|
|
||||||
2.
|
|
||||||
3.
|
|
||||||
...
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: device
|
|
||||||
attributes:
|
|
||||||
label: Which device and operating system are you using?
|
|
||||||
description: e.g. iPhone 15, iOS 18.1.1
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: dropdown
|
|
||||||
id: version
|
|
||||||
attributes:
|
|
||||||
label: Version
|
|
||||||
description: What version of Streamyfin are you running?
|
|
||||||
options:
|
|
||||||
- 0.22.0
|
|
||||||
- 0.21.0
|
|
||||||
- older
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: screenshots
|
|
||||||
attributes:
|
|
||||||
label:
|
|
||||||
If applicable, please add screenshots to help explain your problem.
|
|
||||||
You can drag and drop images here or paste them directly into the comment box.
|
|
||||||
2
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -2,7 +2,7 @@
|
|||||||
name: Feature request
|
name: Feature request
|
||||||
about: Suggest an idea for this project
|
about: Suggest an idea for this project
|
||||||
title: ''
|
title: ''
|
||||||
labels: '✨ enhancement'
|
labels: ''
|
||||||
assignees: ''
|
assignees: ''
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
5
.gitignore
vendored
@@ -9,7 +9,6 @@ npm-debug.*
|
|||||||
*.mobileprovision
|
*.mobileprovision
|
||||||
*.orig.*
|
*.orig.*
|
||||||
web-build/
|
web-build/
|
||||||
modules/vlc-player/android/build
|
|
||||||
|
|
||||||
# macOS
|
# macOS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -27,12 +26,8 @@ package-lock.json
|
|||||||
/ios
|
/ios
|
||||||
/android
|
/android
|
||||||
|
|
||||||
modules/player/android
|
|
||||||
|
|
||||||
pc-api-7079014811501811218-719-3b9f15aeccf8.json
|
pc-api-7079014811501811218-719-3b9f15aeccf8.json
|
||||||
credentials.json
|
credentials.json
|
||||||
*.apk
|
*.apk
|
||||||
*.ipa
|
*.ipa
|
||||||
.continuerc.json
|
.continuerc.json
|
||||||
|
|
||||||
.vscode/
|
|
||||||
3
.idea/.gitignore
generated
vendored
@@ -1,3 +0,0 @@
|
|||||||
# Default ignored files
|
|
||||||
/shelf/
|
|
||||||
/workspace.xml
|
|
||||||
329
.idea/caches/deviceStreaming.xml
generated
@@ -1,329 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="DeviceStreaming">
|
|
||||||
<option name="deviceSelectionList">
|
|
||||||
<list>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="27" />
|
|
||||||
<option name="brand" value="DOCOMO" />
|
|
||||||
<option name="codename" value="F01L" />
|
|
||||||
<option name="id" value="F01L" />
|
|
||||||
<option name="manufacturer" value="FUJITSU" />
|
|
||||||
<option name="name" value="F-01L" />
|
|
||||||
<option name="screenDensity" value="360" />
|
|
||||||
<option name="screenX" value="720" />
|
|
||||||
<option name="screenY" value="1280" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="28" />
|
|
||||||
<option name="brand" value="DOCOMO" />
|
|
||||||
<option name="codename" value="SH-01L" />
|
|
||||||
<option name="id" value="SH-01L" />
|
|
||||||
<option name="manufacturer" value="SHARP" />
|
|
||||||
<option name="name" value="AQUOS sense2 SH-01L" />
|
|
||||||
<option name="screenDensity" value="480" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2160" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="Lenovo" />
|
|
||||||
<option name="codename" value="TB370FU" />
|
|
||||||
<option name="id" value="TB370FU" />
|
|
||||||
<option name="manufacturer" value="Lenovo" />
|
|
||||||
<option name="name" value="Tab P12" />
|
|
||||||
<option name="screenDensity" value="340" />
|
|
||||||
<option name="screenX" value="1840" />
|
|
||||||
<option name="screenY" value="2944" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="31" />
|
|
||||||
<option name="brand" value="samsung" />
|
|
||||||
<option name="codename" value="a51" />
|
|
||||||
<option name="id" value="a51" />
|
|
||||||
<option name="manufacturer" value="Samsung" />
|
|
||||||
<option name="name" value="Galaxy A51" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2400" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="akita" />
|
|
||||||
<option name="id" value="akita" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 8a" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2400" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="33" />
|
|
||||||
<option name="brand" value="samsung" />
|
|
||||||
<option name="codename" value="b0q" />
|
|
||||||
<option name="id" value="b0q" />
|
|
||||||
<option name="manufacturer" value="Samsung" />
|
|
||||||
<option name="name" value="Galaxy S22 Ultra" />
|
|
||||||
<option name="screenDensity" value="600" />
|
|
||||||
<option name="screenX" value="1440" />
|
|
||||||
<option name="screenY" value="3088" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="32" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="bluejay" />
|
|
||||||
<option name="id" value="bluejay" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 6a" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2400" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="caiman" />
|
|
||||||
<option name="id" value="caiman" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 9 Pro" />
|
|
||||||
<option name="screenDensity" value="360" />
|
|
||||||
<option name="screenX" value="960" />
|
|
||||||
<option name="screenY" value="2142" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="comet" />
|
|
||||||
<option name="id" value="comet" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 9 Pro Fold" />
|
|
||||||
<option name="screenDensity" value="390" />
|
|
||||||
<option name="screenX" value="2076" />
|
|
||||||
<option name="screenY" value="2152" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="29" />
|
|
||||||
<option name="brand" value="samsung" />
|
|
||||||
<option name="codename" value="crownqlteue" />
|
|
||||||
<option name="id" value="crownqlteue" />
|
|
||||||
<option name="manufacturer" value="Samsung" />
|
|
||||||
<option name="name" value="Galaxy Note9" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="2220" />
|
|
||||||
<option name="screenY" value="1080" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="samsung" />
|
|
||||||
<option name="codename" value="dm3q" />
|
|
||||||
<option name="id" value="dm3q" />
|
|
||||||
<option name="manufacturer" value="Samsung" />
|
|
||||||
<option name="name" value="Galaxy S23 Ultra" />
|
|
||||||
<option name="screenDensity" value="600" />
|
|
||||||
<option name="screenX" value="1440" />
|
|
||||||
<option name="screenY" value="3088" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="samsung" />
|
|
||||||
<option name="codename" value="e1q" />
|
|
||||||
<option name="id" value="e1q" />
|
|
||||||
<option name="manufacturer" value="Samsung" />
|
|
||||||
<option name="name" value="Galaxy S24" />
|
|
||||||
<option name="screenDensity" value="480" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2340" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="33" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="felix" />
|
|
||||||
<option name="id" value="felix" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel Fold" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="2208" />
|
|
||||||
<option name="screenY" value="1840" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="felix" />
|
|
||||||
<option name="id" value="felix" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel Fold" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="2208" />
|
|
||||||
<option name="screenY" value="1840" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="33" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="felix_camera" />
|
|
||||||
<option name="id" value="felix_camera" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel Fold (Camera-enabled)" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="2208" />
|
|
||||||
<option name="screenY" value="1840" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="33" />
|
|
||||||
<option name="brand" value="samsung" />
|
|
||||||
<option name="codename" value="gts8uwifi" />
|
|
||||||
<option name="id" value="gts8uwifi" />
|
|
||||||
<option name="manufacturer" value="Samsung" />
|
|
||||||
<option name="name" value="Galaxy Tab S8 Ultra" />
|
|
||||||
<option name="screenDensity" value="320" />
|
|
||||||
<option name="screenX" value="1848" />
|
|
||||||
<option name="screenY" value="2960" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="husky" />
|
|
||||||
<option name="id" value="husky" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 8 Pro" />
|
|
||||||
<option name="screenDensity" value="390" />
|
|
||||||
<option name="screenX" value="1008" />
|
|
||||||
<option name="screenY" value="2244" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="30" />
|
|
||||||
<option name="brand" value="motorola" />
|
|
||||||
<option name="codename" value="java" />
|
|
||||||
<option name="id" value="java" />
|
|
||||||
<option name="manufacturer" value="Motorola" />
|
|
||||||
<option name="name" value="G20" />
|
|
||||||
<option name="screenDensity" value="280" />
|
|
||||||
<option name="screenX" value="720" />
|
|
||||||
<option name="screenY" value="1600" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="komodo" />
|
|
||||||
<option name="id" value="komodo" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 9 Pro XL" />
|
|
||||||
<option name="screenDensity" value="360" />
|
|
||||||
<option name="screenX" value="1008" />
|
|
||||||
<option name="screenY" value="2244" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="33" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="lynx" />
|
|
||||||
<option name="id" value="lynx" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 7a" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2400" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="31" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="oriole" />
|
|
||||||
<option name="id" value="oriole" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 6" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2400" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="33" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="panther" />
|
|
||||||
<option name="id" value="panther" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 7" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2400" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="samsung" />
|
|
||||||
<option name="codename" value="q5q" />
|
|
||||||
<option name="id" value="q5q" />
|
|
||||||
<option name="manufacturer" value="Samsung" />
|
|
||||||
<option name="name" value="Galaxy Z Fold5" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="1812" />
|
|
||||||
<option name="screenY" value="2176" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="samsung" />
|
|
||||||
<option name="codename" value="q6q" />
|
|
||||||
<option name="id" value="q6q" />
|
|
||||||
<option name="manufacturer" value="Samsung" />
|
|
||||||
<option name="name" value="Galaxy Z Fold6" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="1856" />
|
|
||||||
<option name="screenY" value="2160" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="30" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="r11" />
|
|
||||||
<option name="id" value="r11" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel Watch" />
|
|
||||||
<option name="screenDensity" value="320" />
|
|
||||||
<option name="screenX" value="384" />
|
|
||||||
<option name="screenY" value="384" />
|
|
||||||
<option name="type" value="WEAR_OS" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="30" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="redfin" />
|
|
||||||
<option name="id" value="redfin" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 5" />
|
|
||||||
<option name="screenDensity" value="440" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2340" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="shiba" />
|
|
||||||
<option name="id" value="shiba" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 8" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2400" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="33" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="tangorpro" />
|
|
||||||
<option name="id" value="tangorpro" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel Tablet" />
|
|
||||||
<option name="screenDensity" value="320" />
|
|
||||||
<option name="screenX" value="1600" />
|
|
||||||
<option name="screenY" value="2560" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="tokay" />
|
|
||||||
<option name="id" value="tokay" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 9" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2424" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
</list>
|
|
||||||
</option>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
6
.idea/misc.xml
generated
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="ProjectRootManager">
|
|
||||||
<output url="file://$PROJECT_DIR$/out" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
8
.idea/modules.xml
generated
@@ -1,8 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="ProjectModuleManager">
|
|
||||||
<modules>
|
|
||||||
<module fileurl="file://$PROJECT_DIR$/.idea/streamyfin.iml" filepath="$PROJECT_DIR$/.idea/streamyfin.iml" />
|
|
||||||
</modules>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
9
.idea/streamyfin.iml
generated
@@ -1,9 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<module type="JAVA_MODULE" version="4">
|
|
||||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
|
||||||
<exclude-output />
|
|
||||||
<content url="file://$MODULE_DIR$" />
|
|
||||||
<orderEntry type="inheritedJdk" />
|
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
|
||||||
</component>
|
|
||||||
</module>
|
|
||||||
6
.idea/vcs.xml
generated
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="VcsDirectoryMappings">
|
|
||||||
<mapping directory="" vcs="Git" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
3
.vscode/settings.json
vendored
@@ -8,8 +8,5 @@
|
|||||||
"[typescriptreact]": {
|
"[typescriptreact]": {
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
"editor.formatOnSave": true
|
"editor.formatOnSave": true
|
||||||
},
|
|
||||||
"[swift]": {
|
|
||||||
"editor.defaultFormatter": "sswg.swift-lang"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
11
README.md
@@ -13,8 +13,7 @@ Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Exp
|
|||||||
|
|
||||||
## 🌟 Features
|
## 🌟 Features
|
||||||
|
|
||||||
- 🚀 **Skp intro / credits support**
|
- 📱 **Native video player**: Playback with the platform native video player. With support for subtitles, playback speed control, and more.
|
||||||
- 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking.
|
|
||||||
- 📺 **Picture in Picture** (iPhone only): Watch movies in PiP mode on your iPhone.
|
- 📺 **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.
|
- 🔊 **Background audio**: Stream music in the background, even when locking the phone.
|
||||||
- 📥 **Download media** (Experimental): Save your media locally and watch it offline.
|
- 📥 **Download media** (Experimental): Save your media locally and watch it offline.
|
||||||
@@ -62,7 +61,7 @@ Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) to
|
|||||||
## Get it now
|
## Get it now
|
||||||
|
|
||||||
<div style="display: flex; gap: 5px;">
|
<div style="display: flex; gap: 5px;">
|
||||||
<a href="https://apps.apple.com/app/streamyfin/id6593660679?l=en-GB"><img height=50 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/></a>
|
<a href="https://apps.apple.com/se/app/streamyfin/id6593660679?l=en-GB"><img height=50 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/></a>
|
||||||
<a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin"><img height=50 alt="Get the beta on Google Play" src="./assets/Google_Play_Store_badge_EN.svg"/></a>
|
<a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin"><img height=50 alt="Get the beta on Google Play" src="./assets/Google_Play_Store_badge_EN.svg"/></a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -136,7 +135,7 @@ Key points of the MPL-2.0:
|
|||||||
|
|
||||||
## 🌐 Connect with Us
|
## 🌐 Connect with Us
|
||||||
|
|
||||||
Join our Discord: [https://discord.gg/BuGG9ZNhaE](https://discord.gg/BuGG9ZNhaE)
|
Join our Discord: [https://discord.gg/zyGKHJZvv4](https://discord.gg/aJvAYeycyY)
|
||||||
|
|
||||||
If you have questions or need support, feel free to reach out:
|
If you have questions or need support, feel free to reach out:
|
||||||
|
|
||||||
@@ -154,7 +153,3 @@ 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.
|
- [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.
|
- [Jellyfin TS SDK](https://github.com/jellyfin/jellyfin-sdk-typescript) for the TypeScript SDK.
|
||||||
- The Jellyfin devs for always being helpful in the Discord.
|
- The Jellyfin devs for always being helpful in the Discord.
|
||||||
|
|
||||||
## Star History
|
|
||||||
|
|
||||||
[](https://star-history.com/#fredrikburmester/streamyfin&Date)
|
|
||||||
|
|||||||
42
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.22.0",
|
"version": "0.12.0",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "streamyfin",
|
"scheme": "streamyfin",
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
"splash": {
|
"splash": {
|
||||||
"image": "./assets/images/splash.png",
|
"image": "./assets/images/splash.png",
|
||||||
"resizeMode": "contain",
|
"resizeMode": "contain",
|
||||||
"backgroundColor": "#2E2E2E"
|
"backgroundColor": "#29164B"
|
||||||
},
|
},
|
||||||
"jsEngine": "hermes",
|
"jsEngine": "hermes",
|
||||||
"assetBundlePatterns": ["**/*"],
|
"assetBundlePatterns": ["**/*"],
|
||||||
@@ -19,14 +19,11 @@
|
|||||||
"infoPlist": {
|
"infoPlist": {
|
||||||
"NSCameraUsageDescription": "The app needs access to your camera to scan barcodes.",
|
"NSCameraUsageDescription": "The app needs access to your camera to scan barcodes.",
|
||||||
"NSMicrophoneUsageDescription": "The app needs access to your microphone.",
|
"NSMicrophoneUsageDescription": "The app needs access to your microphone.",
|
||||||
"UIBackgroundModes": ["audio", "fetch"],
|
"UIBackgroundModes": ["audio"],
|
||||||
"NSLocalNetworkUsageDescription": "The app needs access to your local network to connect to your Jellyfin server.",
|
"NSLocalNetworkUsageDescription": "The app needs access to your local network to connect to your Jellyfin server.",
|
||||||
"NSAppTransportSecurity": {
|
"NSAppTransportSecurity": {
|
||||||
"NSAllowsArbitraryLoads": true
|
"NSAllowsArbitraryLoads": true
|
||||||
},
|
}
|
||||||
"UISupportsTrueScreenSizeOnMac": true,
|
|
||||||
"UIFileSharingEnabled": true,
|
|
||||||
"LSSupportsOpeningDocumentsInPlace": true
|
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"usesNonExemptEncryption": false
|
"usesNonExemptEncryption": false
|
||||||
@@ -36,17 +33,21 @@
|
|||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"jsEngine": "hermes",
|
"jsEngine": "hermes",
|
||||||
"versionCode": 47,
|
"versionCode": 36,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/images/adaptive_icon.png"
|
"foregroundImage": "./assets/images/icon.png"
|
||||||
},
|
},
|
||||||
"package": "com.fredrikburmester.streamyfin",
|
"package": "com.fredrikburmester.streamyfin",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"android.permission.FOREGROUND_SERVICE",
|
"android.permission.FOREGROUND_SERVICE",
|
||||||
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
|
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"
|
||||||
"android.permission.WRITE_SETTINGS"
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"web": {
|
||||||
|
"bundler": "metro",
|
||||||
|
"output": "static",
|
||||||
|
"favicon": "./assets/images/favicon.png"
|
||||||
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"expo-router",
|
"expo-router",
|
||||||
"expo-font",
|
"expo-font",
|
||||||
@@ -70,12 +71,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
"./plugins/withAndroidMainActivityAttributes",
|
||||||
|
{
|
||||||
|
"com.reactnative.googlecast.RNGCExpandedControllerActivity": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
["./plugins/withExpandedController.js"],
|
||||||
[
|
[
|
||||||
"expo-build-properties",
|
"expo-build-properties",
|
||||||
{
|
{
|
||||||
"ios": {
|
"ios": {
|
||||||
"deploymentTarget": "15.6",
|
"deploymentTarget": "14.0"
|
||||||
"useFrameworks": "static"
|
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"android": {
|
"android": {
|
||||||
@@ -104,14 +111,7 @@
|
|||||||
{
|
{
|
||||||
"motionPermission": "Allow Streamyfin to access your device motion for landscape video watching."
|
"motionPermission": "Allow Streamyfin to access your device motion for landscape video watching."
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"expo-asset",
|
|
||||||
[
|
|
||||||
"react-native-edge-to-edge",
|
|
||||||
{ "android": { "parentTheme": "Material3" } }
|
|
||||||
],
|
|
||||||
["react-native-bottom-tabs"],
|
|
||||||
["./plugins/withChangeNativeAndroidTextToWhite.js"]
|
|
||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
"typedRoutes": true
|
"typedRoutes": true
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
import {Stack} from "expo-router";
|
|
||||||
import { Platform } from "react-native";
|
|
||||||
|
|
||||||
export default function CustomMenuLayout() {
|
|
||||||
return (
|
|
||||||
<Stack>
|
|
||||||
<Stack.Screen
|
|
||||||
name="index"
|
|
||||||
options={{
|
|
||||||
headerShown: true,
|
|
||||||
headerLargeTitle: true,
|
|
||||||
headerTitle: "Custom Links",
|
|
||||||
headerBlurEffect: "prominent",
|
|
||||||
headerTransparent: Platform.OS === "ios",
|
|
||||||
headerShadowVisible: false,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
import {FlatList, TouchableOpacity, View} from "react-native";
|
|
||||||
import {useSafeAreaInsets} from "react-native-safe-area-context";
|
|
||||||
import React, {useCallback, useEffect, useState} from "react";
|
|
||||||
import {useAtom} from "jotai/index";
|
|
||||||
import {apiAtom} from "@/providers/JellyfinProvider";
|
|
||||||
import {ListItem} from "@/components/ListItem";
|
|
||||||
import * as WebBrowser from 'expo-web-browser';
|
|
||||||
import Ionicons from '@expo/vector-icons/Ionicons';
|
|
||||||
import {Text} from "@/components/common/Text";
|
|
||||||
|
|
||||||
export interface MenuLink {
|
|
||||||
name: string,
|
|
||||||
url: string,
|
|
||||||
icon: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function menuLinks() {
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const insets = useSafeAreaInsets()
|
|
||||||
const [menuLinks, setMenuLinks] = useState<MenuLink[]>([])
|
|
||||||
|
|
||||||
const getMenuLinks = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const response = await api?.axiosInstance.get(api?.basePath + "/web/config.json")
|
|
||||||
const config = response?.data;
|
|
||||||
|
|
||||||
if (!config && !config.hasOwnProperty("menuLinks")) {
|
|
||||||
console.error("Menu links not found");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setMenuLinks(config?.menuLinks as MenuLink[])
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to retrieve config:", error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[api]
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => { getMenuLinks() }, []);
|
|
||||||
return (
|
|
||||||
<FlatList
|
|
||||||
contentInsetAdjustmentBehavior="automatic"
|
|
||||||
contentContainerStyle={{
|
|
||||||
paddingTop: 10,
|
|
||||||
paddingLeft: insets.left,
|
|
||||||
paddingRight: insets.right,
|
|
||||||
}}
|
|
||||||
data={menuLinks}
|
|
||||||
renderItem={({item}) => (
|
|
||||||
<TouchableOpacity onPress={() => WebBrowser.openBrowserAsync(item.url) }>
|
|
||||||
<ListItem
|
|
||||||
title={item.name}
|
|
||||||
iconAfter={<Ionicons name="link" size={24} color="white"/>}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
ItemSeparatorComponent={() => (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: 10,
|
|
||||||
height: 10,
|
|
||||||
}}/>
|
|
||||||
)}
|
|
||||||
ListEmptyComponent={
|
|
||||||
<View className="flex flex-col items-center justify-center h-full">
|
|
||||||
<Text className="font-bold text-xl text-neutral-500">No links</Text>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Chromecast } from "@/components/Chromecast";
|
import { Chromecast } from "@/components/Chromecast";
|
||||||
|
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
|
||||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||||
import { Feather } from "@expo/vector-icons";
|
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||||
import { Stack, useRouter } from "expo-router";
|
import { Stack, useRouter } from "expo-router";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
|
||||||
@@ -17,51 +18,66 @@ export default function IndexLayout() {
|
|||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
|
headerLeft: () => (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={{
|
||||||
|
marginRight: Platform.OS === "android" ? 17 : 0,
|
||||||
|
}}
|
||||||
|
onPress={() => {
|
||||||
|
router.push("/(auth)/downloads");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Feather name="download" color={"white"} size={22} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<View className="flex flex-row items-center space-x-2">
|
<View className="flex flex-row items-center space-x-2">
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
router.push("/(auth)/syncplay");
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
marginRight: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="people" color={"white"} size={22} />
|
||||||
|
</TouchableOpacity>
|
||||||
<Chromecast />
|
<Chromecast />
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.push("/(auth)/settings");
|
router.push("/(auth)/settings");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Feather name="settings" color={"white"} size={22} />
|
<View className="h-10 aspect-square flex items-center justify-center rounded">
|
||||||
|
<Feather name="settings" color={"white"} size={22} />
|
||||||
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="downloads/index"
|
name="downloads"
|
||||||
options={{
|
options={{
|
||||||
title: "Downloads",
|
title: "Downloads",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
|
||||||
name="downloads/[seriesId]"
|
|
||||||
options={{
|
|
||||||
title: "TV-Series",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="settings"
|
name="settings"
|
||||||
options={{
|
options={{
|
||||||
title: "Settings",
|
title: "Settings",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="syncplay"
|
||||||
|
options={{
|
||||||
|
title: "Syncplay",
|
||||||
|
presentation: "modal",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||||
<Stack.Screen key={name} name={name} options={options} />
|
<Stack.Screen key={name} name={name} options={options} />
|
||||||
))}
|
))}
|
||||||
<Stack.Screen
|
|
||||||
name="collections/[collectionId]"
|
|
||||||
options={{
|
|
||||||
title: "",
|
|
||||||
headerShown: true,
|
|
||||||
headerBlurEffect: "prominent",
|
|
||||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
|
||||||
headerShadowVisible: false,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
192
app/(auth)/(tabs)/(home)/downloads.tsx
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { MovieCard } from "@/components/downloads/MovieCard";
|
||||||
|
import { SeriesCard } from "@/components/downloads/SeriesCard";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { runningProcesses } from "@/utils/atoms/downloads";
|
||||||
|
import { queueAtom } from "@/utils/atoms/queue";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { router } from "expo-router";
|
||||||
|
import { FFmpegKit } from "ffmpeg-kit-react-native";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
|
const downloads: React.FC = () => {
|
||||||
|
const [process, setProcess] = useAtom(runningProcesses);
|
||||||
|
const [queue, setQueue] = useAtom(queueAtom);
|
||||||
|
|
||||||
|
const { data: downloadedFiles, isLoading } = useQuery({
|
||||||
|
queryKey: ["downloaded_files", process?.item.Id],
|
||||||
|
queryFn: async () =>
|
||||||
|
JSON.parse(
|
||||||
|
(await AsyncStorage.getItem("downloaded_files")) || "[]"
|
||||||
|
) as BaseItemDto[],
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const movies = useMemo(
|
||||||
|
() => downloadedFiles?.filter((f) => f.Type === "Movie") || [],
|
||||||
|
[downloadedFiles]
|
||||||
|
);
|
||||||
|
|
||||||
|
const groupedBySeries = useMemo(() => {
|
||||||
|
const episodes = downloadedFiles?.filter((f) => f.Type === "Episode");
|
||||||
|
const series: { [key: string]: BaseItemDto[] } = {};
|
||||||
|
episodes?.forEach((e) => {
|
||||||
|
if (!series[e.SeriesName!]) series[e.SeriesName!] = [];
|
||||||
|
series[e.SeriesName!].push(e);
|
||||||
|
});
|
||||||
|
return Object.values(series);
|
||||||
|
}, [downloadedFiles]);
|
||||||
|
|
||||||
|
const eta = useMemo(() => {
|
||||||
|
const length = process?.item?.RunTimeTicks || 0;
|
||||||
|
|
||||||
|
if (!process?.speed || !process?.progress) return "";
|
||||||
|
|
||||||
|
const timeLeft =
|
||||||
|
(length - length * (process.progress / 100)) / process.speed;
|
||||||
|
|
||||||
|
return formatNumber(timeLeft / 10000);
|
||||||
|
}, [process]);
|
||||||
|
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View className="h-full flex flex-col items-center justify-center -mt-6">
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
paddingBottom: 100,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className="px-4 py-4">
|
||||||
|
<View className="mb-4 flex flex-col space-y-4">
|
||||||
|
<View>
|
||||||
|
<Text className="text-2xl font-bold mb-2">Queue</Text>
|
||||||
|
<View className="flex flex-col space-y-2">
|
||||||
|
{queue.map((q) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() =>
|
||||||
|
router.push(`/(auth)/items/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={() => {
|
||||||
|
setQueue((prev) => prev.filter((i) => i.id !== q.id));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="close" size={24} color="red" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{queue.length === 0 && (
|
||||||
|
<Text className="opacity-50">No items in queue</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View>
|
||||||
|
<Text className="text-2xl font-bold mb-2">Active download</Text>
|
||||||
|
{process?.item ? (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() =>
|
||||||
|
router.push(`/(auth)/items/page?id=${process.item.Id}`)
|
||||||
|
}
|
||||||
|
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
|
||||||
|
>
|
||||||
|
<View>
|
||||||
|
<Text className="font-semibold">{process.item.Name}</Text>
|
||||||
|
<Text className="text-xs opacity-50">
|
||||||
|
{process.item.Type}
|
||||||
|
</Text>
|
||||||
|
<View className="flex flex-row items-center space-x-2 mt-1 text-purple-600">
|
||||||
|
<Text className="text-xs">
|
||||||
|
{process.progress.toFixed(0)}%
|
||||||
|
</Text>
|
||||||
|
<Text className="text-xs">
|
||||||
|
{process.speed?.toFixed(2)}x
|
||||||
|
</Text>
|
||||||
|
<View>
|
||||||
|
<Text className="text-xs">ETA {eta}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
FFmpegKit.cancel();
|
||||||
|
setProcess(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="close" size={24} color="red" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<View
|
||||||
|
className={`
|
||||||
|
absolute bottom-0 left-0 h-1 bg-purple-600
|
||||||
|
`}
|
||||||
|
style={{
|
||||||
|
width: process.progress
|
||||||
|
? `${Math.max(5, process.progress)}%`
|
||||||
|
: "5%",
|
||||||
|
}}
|
||||||
|
></View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : (
|
||||||
|
<Text className="opacity-50">No active downloads</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{movies.length > 0 && (
|
||||||
|
<View className="mb-4">
|
||||||
|
<View className="flex flex-row items-center justify-between mb-2">
|
||||||
|
<Text className="text-2xl font-bold">Movies</Text>
|
||||||
|
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
|
||||||
|
<Text className="text-xs font-bold">{movies?.length}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{movies?.map((item: BaseItemDto) => (
|
||||||
|
<View className="mb-2 last:mb-0" key={item.Id}>
|
||||||
|
<MovieCard item={item} />
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{groupedBySeries?.map((items: BaseItemDto[], index: number) => (
|
||||||
|
<SeriesCard items={items} key={items[0].SeriesId} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default downloads;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Format a number (Date.getTime) to a human readable string ex. 2m 34s
|
||||||
|
* @param {number} num - The number to format
|
||||||
|
*
|
||||||
|
* @returns {string} - The formatted string
|
||||||
|
*/
|
||||||
|
const formatNumber = (num: number) => {
|
||||||
|
const minutes = Math.floor(num / 60000);
|
||||||
|
const seconds = ((num % 60000) / 1000).toFixed(0);
|
||||||
|
return `${minutes}m ${seconds}s`;
|
||||||
|
};
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
|
||||||
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import { ScrollView, TouchableOpacity, View, Alert } from "react-native";
|
|
||||||
import { EpisodeCard } from "@/components/downloads/EpisodeCard";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import {
|
|
||||||
SeasonDropdown,
|
|
||||||
SeasonIndexState,
|
|
||||||
} from "@/components/series/SeasonDropdown";
|
|
||||||
import { storage } from "@/utils/mmkv";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
|
|
||||||
export default function page() {
|
|
||||||
const navigation = useNavigation();
|
|
||||||
const local = useLocalSearchParams();
|
|
||||||
const { seriesId, episodeSeasonIndex } = local as {
|
|
||||||
seriesId: string;
|
|
||||||
episodeSeasonIndex: number | string | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>(
|
|
||||||
{}
|
|
||||||
);
|
|
||||||
const { downloadedFiles, deleteItems } = useDownload();
|
|
||||||
|
|
||||||
const series = useMemo(() => {
|
|
||||||
try {
|
|
||||||
return (
|
|
||||||
downloadedFiles
|
|
||||||
?.filter((f) => f.item.SeriesId == seriesId)
|
|
||||||
?.sort(
|
|
||||||
(a, b) => a?.item.ParentIndexNumber! - b.item.ParentIndexNumber!
|
|
||||||
) || []
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}, [downloadedFiles]);
|
|
||||||
|
|
||||||
const seasonIndex =
|
|
||||||
seasonIndexState[series?.[0]?.item?.ParentId ?? ""] ||
|
|
||||||
episodeSeasonIndex ||
|
|
||||||
"";
|
|
||||||
|
|
||||||
const groupBySeason = useMemo<BaseItemDto[]>(() => {
|
|
||||||
const seasons: Record<string, BaseItemDto[]> = {};
|
|
||||||
|
|
||||||
series?.forEach((episode) => {
|
|
||||||
if (!seasons[episode.item.ParentIndexNumber!]) {
|
|
||||||
seasons[episode.item.ParentIndexNumber!] = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
seasons[episode.item.ParentIndexNumber!].push(episode.item);
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
seasons[seasonIndex]?.sort((a, b) => a.IndexNumber! - b.IndexNumber!) ??
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
}, [series, seasonIndex]);
|
|
||||||
|
|
||||||
const initialSeasonIndex = useMemo(
|
|
||||||
() =>
|
|
||||||
Object.values(groupBySeason)?.[0]?.ParentIndexNumber ??
|
|
||||||
series?.[0]?.item?.ParentIndexNumber,
|
|
||||||
[groupBySeason]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (series.length > 0) {
|
|
||||||
navigation.setOptions({
|
|
||||||
title: series[0].item.SeriesName,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
storage.delete(seriesId);
|
|
||||||
router.back();
|
|
||||||
}
|
|
||||||
}, [series]);
|
|
||||||
|
|
||||||
const deleteSeries = useCallback(() => {
|
|
||||||
Alert.alert(
|
|
||||||
"Delete season",
|
|
||||||
"Are you sure you want to delete the entire season?",
|
|
||||||
[
|
|
||||||
{
|
|
||||||
text: "Cancel",
|
|
||||||
style: "cancel",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: "Delete",
|
|
||||||
onPress: () => deleteItems(groupBySeason),
|
|
||||||
style: "destructive",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}, [groupBySeason]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className="flex-1">
|
|
||||||
{series.length > 0 && (
|
|
||||||
<View className="flex flex-row items-center justify-start my-2 px-4">
|
|
||||||
<SeasonDropdown
|
|
||||||
item={series[0].item}
|
|
||||||
seasons={series.map((s) => s.item)}
|
|
||||||
state={seasonIndexState}
|
|
||||||
initialSeasonIndex={initialSeasonIndex!}
|
|
||||||
onSelect={(season) => {
|
|
||||||
setSeasonIndexState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[series[0].item.ParentId ?? ""]: season.ParentIndexNumber,
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center ml-2">
|
|
||||||
<Text className="text-xs font-bold">{groupBySeason.length}</Text>
|
|
||||||
</View>
|
|
||||||
<View className="bg-neutral-800/80 rounded-full h-9 w-9 flex items-center justify-center ml-auto">
|
|
||||||
<TouchableOpacity onPress={deleteSeries}>
|
|
||||||
<Ionicons name="trash" size={20} color="white" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
<ScrollView key={seasonIndex} className="px-4">
|
|
||||||
{groupBySeason.map((episode, index) => (
|
|
||||||
<EpisodeCard key={index} item={episode} />
|
|
||||||
))}
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,231 +0,0 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { ActiveDownloads } from "@/components/downloads/ActiveDownloads";
|
|
||||||
import { MovieCard } from "@/components/downloads/MovieCard";
|
|
||||||
import { SeriesCard } from "@/components/downloads/SeriesCard";
|
|
||||||
import { DownloadedItem, useDownload } from "@/providers/DownloadProvider";
|
|
||||||
import { queueAtom } from "@/utils/atoms/queue";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import {useNavigation, useRouter} from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import React, {useEffect, useMemo, useRef} from "react";
|
|
||||||
import {Alert, ScrollView, TouchableOpacity, View} from "react-native";
|
|
||||||
import { Button } from "@/components/Button";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import {DownloadSize} from "@/components/downloads/DownloadSize";
|
|
||||||
import {BottomSheetBackdrop, BottomSheetBackdropProps, BottomSheetModal, BottomSheetView} from "@gorhom/bottom-sheet";
|
|
||||||
import {toast} from "sonner-native";
|
|
||||||
import {writeToLog} from "@/utils/log";
|
|
||||||
|
|
||||||
export default function page() {
|
|
||||||
const navigation = useNavigation();
|
|
||||||
const [queue, setQueue] = useAtom(queueAtom);
|
|
||||||
const { removeProcess, downloadedFiles, deleteFileByType } = useDownload();
|
|
||||||
const router = useRouter();
|
|
||||||
const [settings] = useSettings();
|
|
||||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
|
||||||
|
|
||||||
const movies = useMemo(() => {
|
|
||||||
try {
|
|
||||||
return downloadedFiles?.filter((f) => f.item.Type === "Movie") || [];
|
|
||||||
} catch {
|
|
||||||
migration_20241124();
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}, [downloadedFiles]);
|
|
||||||
|
|
||||||
const groupedBySeries = useMemo(() => {
|
|
||||||
try {
|
|
||||||
const episodes = downloadedFiles?.filter(
|
|
||||||
(f) => f.item.Type === "Episode"
|
|
||||||
);
|
|
||||||
const series: { [key: string]: DownloadedItem[] } = {};
|
|
||||||
episodes?.forEach((e) => {
|
|
||||||
if (!series[e.item.SeriesName!]) series[e.item.SeriesName!] = [];
|
|
||||||
series[e.item.SeriesName!].push(e);
|
|
||||||
});
|
|
||||||
return Object.values(series);
|
|
||||||
} catch {
|
|
||||||
migration_20241124();
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}, [downloadedFiles]);
|
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
navigation.setOptions({
|
|
||||||
headerRight: () => (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={bottomSheetModalRef.current?.present}
|
|
||||||
>
|
|
||||||
<DownloadSize items={downloadedFiles?.map(f => f.item) || []}/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}, [downloadedFiles]);
|
|
||||||
|
|
||||||
const deleteMovies = () => deleteFileByType("Movie")
|
|
||||||
.then(() => toast.success("Deleted all movies successfully!"))
|
|
||||||
.catch((reason) => {
|
|
||||||
writeToLog("ERROR", reason);
|
|
||||||
toast.error("Failed to delete all movies");
|
|
||||||
});
|
|
||||||
const deleteShows = () => deleteFileByType("Episode")
|
|
||||||
.then(() => toast.success("Deleted all TV-Series successfully!"))
|
|
||||||
.catch((reason) => {
|
|
||||||
writeToLog("ERROR", reason);
|
|
||||||
toast.error("Failed to delete all TV-Series");
|
|
||||||
});
|
|
||||||
const deleteAllMedia = async () => await Promise.all([deleteMovies(), deleteShows()])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<ScrollView
|
|
||||||
contentContainerStyle={{
|
|
||||||
paddingLeft: insets.left,
|
|
||||||
paddingRight: insets.right,
|
|
||||||
paddingBottom: 100,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View className="py-4">
|
|
||||||
<View className="mb-4 flex flex-col space-y-4 px-4">
|
|
||||||
{settings?.downloadMethod === "remux" && (
|
|
||||||
<View className="bg-neutral-900 p-4 rounded-2xl">
|
|
||||||
<Text className="text-lg font-bold">Queue</Text>
|
|
||||||
<Text className="text-xs opacity-70 text-red-600">
|
|
||||||
Queue and downloads will be lost on app restart
|
|
||||||
</Text>
|
|
||||||
<View className="flex flex-col space-y-2 mt-2">
|
|
||||||
{queue.map((q, index) => (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() =>
|
|
||||||
router.push(`/(auth)/items/page?id=${q.item.Id}`)
|
|
||||||
}
|
|
||||||
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
|
|
||||||
key={index}
|
|
||||||
>
|
|
||||||
<View>
|
|
||||||
<Text className="font-semibold">{q.item.Name}</Text>
|
|
||||||
<Text className="text-xs opacity-50">{q.item.Type}</Text>
|
|
||||||
</View>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
removeProcess(q.id);
|
|
||||||
setQueue((prev) => {
|
|
||||||
if (!prev) return [];
|
|
||||||
return [...prev.filter((i) => i.id !== q.id)];
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name="close" size={24} color="red"/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</TouchableOpacity>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{queue.length === 0 && (
|
|
||||||
<Text className="opacity-50">No items in queue</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ActiveDownloads/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{movies.length > 0 && (
|
|
||||||
<View className="mb-4">
|
|
||||||
<View className="flex flex-row items-center justify-between mb-2 px-4">
|
|
||||||
<Text className="text-lg font-bold">Movies</Text>
|
|
||||||
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
|
|
||||||
<Text className="text-xs font-bold">{movies?.length}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
|
||||||
<View className="px-4 flex flex-row">
|
|
||||||
{movies?.map((item) => (
|
|
||||||
<View className="mb-2 last:mb-0" key={item.item.Id}>
|
|
||||||
<MovieCard item={item.item}/>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{groupedBySeries.length > 0 && (
|
|
||||||
<View className="mb-4">
|
|
||||||
<View className="flex flex-row items-center justify-between mb-2 px-4">
|
|
||||||
<Text className="text-lg font-bold">TV-Series</Text>
|
|
||||||
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
|
|
||||||
<Text className="text-xs font-bold">{groupedBySeries?.length}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
|
||||||
<View className="px-4 flex flex-row">
|
|
||||||
{groupedBySeries?.map((items) => (
|
|
||||||
<View className="mb-2 last:mb-0" key={items[0].item.SeriesId}>
|
|
||||||
<SeriesCard
|
|
||||||
items={items.map((i) => i.item)}
|
|
||||||
key={items[0].item.SeriesId}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{downloadedFiles?.length === 0 && (
|
|
||||||
<View className="flex px-4">
|
|
||||||
<Text className="opacity-50">No downloaded items</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
<BottomSheetModal
|
|
||||||
ref={bottomSheetModalRef}
|
|
||||||
enableDynamicSizing
|
|
||||||
handleIndicatorStyle={{
|
|
||||||
backgroundColor: "white",
|
|
||||||
}}
|
|
||||||
backgroundStyle={{
|
|
||||||
backgroundColor: "#171717",
|
|
||||||
}}
|
|
||||||
backdropComponent={(props: BottomSheetBackdropProps) => (
|
|
||||||
<BottomSheetBackdrop
|
|
||||||
{...props}
|
|
||||||
disappearsOnIndex={-1}
|
|
||||||
appearsOnIndex={0}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<BottomSheetView>
|
|
||||||
<View className="p-4 space-y-4 mb-4">
|
|
||||||
<Button color="purple" onPress={deleteMovies}>Delete all Movies</Button>
|
|
||||||
<Button color="purple" onPress={deleteShows}>Delete all TV-Series</Button>
|
|
||||||
<Button color="red" onPress={deleteAllMedia}>Delete all</Button>
|
|
||||||
</View>
|
|
||||||
</BottomSheetView>
|
|
||||||
</BottomSheetModal>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function migration_20241124() {
|
|
||||||
const router = useRouter();
|
|
||||||
const { deleteAllFiles } = useDownload();
|
|
||||||
Alert.alert(
|
|
||||||
"New app version requires re-download",
|
|
||||||
"The new update reqires content to be downloaded again. Please remove all downloaded content and try again.",
|
|
||||||
[
|
|
||||||
{
|
|
||||||
text: "Back",
|
|
||||||
onPress: () => router.back(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: "Delete",
|
|
||||||
style: "destructive",
|
|
||||||
onPress: async () => await deleteAllFiles(),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -4,17 +4,11 @@ import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel";
|
|||||||
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
||||||
import { Colors } from "@/constants/Colors";
|
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { Api } from "@jellyfin/sdk";
|
import { Api } from "@jellyfin/sdk";
|
||||||
import {
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
BaseItemDto,
|
|
||||||
BaseItemKind,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import {
|
import {
|
||||||
getItemsApi,
|
getItemsApi,
|
||||||
getSuggestionsApi,
|
getSuggestionsApi,
|
||||||
@@ -23,40 +17,43 @@ import {
|
|||||||
getUserViewsApi,
|
getUserViewsApi,
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
import NetInfo from "@react-native-community/netinfo";
|
import NetInfo from "@react-native-community/netinfo";
|
||||||
import { QueryFunction, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useNavigation, useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
RefreshControl,
|
RefreshControl,
|
||||||
|
SafeAreaView,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
type ScrollingCollectionListSection = {
|
type BaseSection = {
|
||||||
|
title: string;
|
||||||
|
queryKey: (string | undefined)[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ScrollingCollectionListSection = BaseSection & {
|
||||||
type: "ScrollingCollectionList";
|
type: "ScrollingCollectionList";
|
||||||
title?: string;
|
queryFn: () => Promise<BaseItemDto[]>;
|
||||||
queryKey: (string | undefined | null)[];
|
|
||||||
queryFn: QueryFunction<BaseItemDto[]>;
|
|
||||||
orientation?: "horizontal" | "vertical";
|
orientation?: "horizontal" | "vertical";
|
||||||
};
|
};
|
||||||
|
|
||||||
type MediaListSection = {
|
type MediaListSection = BaseSection & {
|
||||||
type: "MediaListSection";
|
type: "MediaListSection";
|
||||||
queryKey: (string | undefined)[];
|
queryFn: () => Promise<BaseItemDto>;
|
||||||
queryFn: QueryFunction<BaseItemDto>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type Section = ScrollingCollectionListSection | MediaListSection;
|
type Section = ScrollingCollectionListSection | MediaListSection;
|
||||||
|
|
||||||
export default function index() {
|
export default function index() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const api = useAtomValue(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const user = useAtomValue(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [settings, _] = useSettings();
|
const [settings, _] = useSettings();
|
||||||
@@ -64,31 +61,6 @@ export default function index() {
|
|||||||
const [isConnected, setIsConnected] = useState<boolean | null>(null);
|
const [isConnected, setIsConnected] = useState<boolean | null>(null);
|
||||||
const [loadingRetry, setLoadingRetry] = useState(false);
|
const [loadingRetry, setLoadingRetry] = useState(false);
|
||||||
|
|
||||||
const { downloadedFiles, cleanCacheDirectory } = useDownload();
|
|
||||||
const navigation = useNavigation();
|
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const hasDownloads = downloadedFiles && downloadedFiles.length > 0;
|
|
||||||
navigation.setOptions({
|
|
||||||
headerLeft: () => (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
router.push("/(auth)/downloads");
|
|
||||||
}}
|
|
||||||
className="p-2"
|
|
||||||
>
|
|
||||||
<Feather
|
|
||||||
name="download"
|
|
||||||
color={hasDownloads ? Colors.primary : "white"}
|
|
||||||
size={22}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}, [downloadedFiles, navigation, router]);
|
|
||||||
|
|
||||||
const checkConnection = useCallback(async () => {
|
const checkConnection = useCallback(async () => {
|
||||||
setLoadingRetry(true);
|
setLoadingRetry(true);
|
||||||
const state = await NetInfo.fetch();
|
const state = await NetInfo.fetch();
|
||||||
@@ -107,9 +79,6 @@ export default function index() {
|
|||||||
setIsConnected(state.isConnected);
|
setIsConnected(state.isConnected);
|
||||||
});
|
});
|
||||||
|
|
||||||
cleanCacheDirectory()
|
|
||||||
.then(r => console.log("Cache directory cleaned"))
|
|
||||||
.catch(e => console.error("Something went wrong cleaning cache directory"))
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
};
|
};
|
||||||
@@ -120,7 +89,7 @@ export default function index() {
|
|||||||
isError: e1,
|
isError: e1,
|
||||||
isLoading: l1,
|
isLoading: l1,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: ["home", "userViews", user?.Id],
|
queryKey: ["userViews", user?.Id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!api || !user?.Id) {
|
if (!api || !user?.Id) {
|
||||||
return null;
|
return null;
|
||||||
@@ -141,7 +110,7 @@ export default function index() {
|
|||||||
isError: e2,
|
isError: e2,
|
||||||
isLoading: l2,
|
isLoading: l2,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: ["home", "sf_promoted", user?.Id, settings?.usePopularPlugin],
|
queryKey: ["sf_promoted", user?.Id, settings?.usePopularPlugin],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!api || !user?.Id) return [];
|
if (!api || !user?.Id) return [];
|
||||||
|
|
||||||
@@ -159,84 +128,43 @@ export default function index() {
|
|||||||
staleTime: 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const collections = useMemo(() => {
|
const movieCollectionId = useMemo(() => {
|
||||||
const allow = ["movies", "tvshows"];
|
return userViews?.find((c) => c.CollectionType === "movies")?.Id;
|
||||||
return (
|
|
||||||
userViews?.filter(
|
|
||||||
(c) => c.CollectionType && allow.includes(c.CollectionType)
|
|
||||||
) || []
|
|
||||||
);
|
|
||||||
}, [userViews]);
|
}, [userViews]);
|
||||||
|
|
||||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
const tvShowCollectionId = useMemo(() => {
|
||||||
|
return userViews?.find((c) => c.CollectionType === "tvshows")?.Id;
|
||||||
|
}, [userViews]);
|
||||||
|
|
||||||
const refetch = useCallback(async () => {
|
const refetch = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
await invalidateCache();
|
await queryClient.refetchQueries({ queryKey: ["userViews"] });
|
||||||
|
await queryClient.refetchQueries({ queryKey: ["resumeItems"] });
|
||||||
|
await queryClient.refetchQueries({ queryKey: ["nextUp-all"] });
|
||||||
|
await queryClient.refetchQueries({ queryKey: ["recentlyAddedInMovies"] });
|
||||||
|
await queryClient.refetchQueries({ queryKey: ["recentlyAddedInTVShows"] });
|
||||||
|
await queryClient.refetchQueries({ queryKey: ["suggestions"] });
|
||||||
|
await queryClient.refetchQueries({
|
||||||
|
queryKey: ["sf_promoted"],
|
||||||
|
});
|
||||||
|
await queryClient.refetchQueries({
|
||||||
|
queryKey: ["sf_carousel"],
|
||||||
|
});
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, []);
|
}, [queryClient, user?.Id]);
|
||||||
|
|
||||||
const createCollectionConfig = useCallback(
|
|
||||||
(
|
|
||||||
title: string,
|
|
||||||
queryKey: string[],
|
|
||||||
includeItemTypes: BaseItemKind[],
|
|
||||||
parentId: string | undefined
|
|
||||||
): ScrollingCollectionListSection => ({
|
|
||||||
title,
|
|
||||||
queryKey,
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api) return [];
|
|
||||||
return (
|
|
||||||
(
|
|
||||||
await getUserLibraryApi(api).getLatestMedia({
|
|
||||||
userId: user?.Id,
|
|
||||||
limit: 20,
|
|
||||||
fields: ["PrimaryImageAspectRatio", "Path"],
|
|
||||||
imageTypeLimit: 1,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
|
||||||
includeItemTypes,
|
|
||||||
parentId,
|
|
||||||
})
|
|
||||||
).data || []
|
|
||||||
);
|
|
||||||
},
|
|
||||||
type: "ScrollingCollectionList",
|
|
||||||
}),
|
|
||||||
[api, user?.Id]
|
|
||||||
);
|
|
||||||
|
|
||||||
const sections = useMemo(() => {
|
const sections = useMemo(() => {
|
||||||
if (!api || !user?.Id) return [];
|
if (!api || !user?.Id) return [];
|
||||||
|
|
||||||
const latestMediaViews = collections.map((c) => {
|
|
||||||
const includeItemTypes: BaseItemKind[] =
|
|
||||||
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"];
|
|
||||||
const title = "Recently Added in " + c.Name;
|
|
||||||
const queryKey = [
|
|
||||||
"home",
|
|
||||||
"recentlyAddedIn" + c.CollectionType,
|
|
||||||
user?.Id!,
|
|
||||||
c.Id!,
|
|
||||||
];
|
|
||||||
return createCollectionConfig(
|
|
||||||
title || "",
|
|
||||||
queryKey,
|
|
||||||
includeItemTypes,
|
|
||||||
c.Id
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const ss: Section[] = [
|
const ss: Section[] = [
|
||||||
{
|
{
|
||||||
title: "Continue Watching",
|
title: "Continue Watching",
|
||||||
queryKey: ["home", "resumeItems"],
|
queryKey: ["resumeItems", user.Id],
|
||||||
queryFn: async () =>
|
queryFn: async () =>
|
||||||
(
|
(
|
||||||
await getItemsApi(api).getResumeItems({
|
await getItemsApi(api).getResumeItems({
|
||||||
userId: user.Id,
|
userId: user.Id,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
|
||||||
})
|
})
|
||||||
).data.Items || [],
|
).data.Items || [],
|
||||||
type: "ScrollingCollectionList",
|
type: "ScrollingCollectionList",
|
||||||
@@ -244,7 +172,7 @@ export default function index() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Next Up",
|
title: "Next Up",
|
||||||
queryKey: ["home", "nextUp-all"],
|
queryKey: ["nextUp-all", user?.Id],
|
||||||
queryFn: async () =>
|
queryFn: async () =>
|
||||||
(
|
(
|
||||||
await getTvShowsApi(api).getNextUp({
|
await getTvShowsApi(api).getNextUp({
|
||||||
@@ -252,26 +180,57 @@ export default function index() {
|
|||||||
fields: ["MediaSourceCount"],
|
fields: ["MediaSourceCount"],
|
||||||
limit: 20,
|
limit: 20,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
enableResumable: false,
|
|
||||||
})
|
})
|
||||||
).data.Items || [],
|
).data.Items || [],
|
||||||
type: "ScrollingCollectionList",
|
type: "ScrollingCollectionList",
|
||||||
orientation: "horizontal",
|
orientation: "horizontal",
|
||||||
},
|
},
|
||||||
...latestMediaViews,
|
|
||||||
...(mediaListCollections?.map(
|
...(mediaListCollections?.map(
|
||||||
(ml) =>
|
(ml) =>
|
||||||
({
|
({
|
||||||
title: ml.Name,
|
title: ml.Name || "",
|
||||||
queryKey: ["home", "mediaList", ml.Id!],
|
queryKey: ["mediaList", ml.Id],
|
||||||
queryFn: async () => ml,
|
queryFn: async () => ml,
|
||||||
type: "MediaListSection",
|
type: "MediaListSection",
|
||||||
orientation: "vertical",
|
} as MediaListSection)
|
||||||
} as Section)
|
|
||||||
) || []),
|
) || []),
|
||||||
|
{
|
||||||
|
title: "Recently Added in Movies",
|
||||||
|
queryKey: ["recentlyAddedInMovies", user?.Id, movieCollectionId],
|
||||||
|
queryFn: async () =>
|
||||||
|
(
|
||||||
|
await getUserLibraryApi(api).getLatestMedia({
|
||||||
|
userId: user?.Id,
|
||||||
|
limit: 50,
|
||||||
|
fields: ["PrimaryImageAspectRatio", "Path"],
|
||||||
|
imageTypeLimit: 1,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
|
includeItemTypes: ["Movie"],
|
||||||
|
parentId: movieCollectionId,
|
||||||
|
})
|
||||||
|
).data || [],
|
||||||
|
type: "ScrollingCollectionList",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Recently Added in TV-Shows",
|
||||||
|
queryKey: ["recentlyAddedInTVShows", user?.Id, tvShowCollectionId],
|
||||||
|
queryFn: async () =>
|
||||||
|
(
|
||||||
|
await getUserLibraryApi(api).getLatestMedia({
|
||||||
|
userId: user?.Id,
|
||||||
|
limit: 50,
|
||||||
|
fields: ["PrimaryImageAspectRatio", "Path"],
|
||||||
|
imageTypeLimit: 1,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
|
includeItemTypes: ["Series"],
|
||||||
|
parentId: tvShowCollectionId,
|
||||||
|
})
|
||||||
|
).data || [],
|
||||||
|
type: "ScrollingCollectionList",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Suggested Movies",
|
title: "Suggested Movies",
|
||||||
queryKey: ["home", "suggestedMovies", user?.Id],
|
queryKey: ["suggestedMovies", user?.Id],
|
||||||
queryFn: async () =>
|
queryFn: async () =>
|
||||||
(
|
(
|
||||||
await getSuggestionsApi(api).getSuggestions({
|
await getSuggestionsApi(api).getSuggestions({
|
||||||
@@ -286,7 +245,7 @@ export default function index() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Suggested Episodes",
|
title: "Suggested Episodes",
|
||||||
queryKey: ["home", "suggestedEpisodes", user?.Id],
|
queryKey: ["suggestedEpisodes", user?.Id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
try {
|
try {
|
||||||
const suggestions = await getSuggestions(api, user.Id);
|
const suggestions = await getSuggestions(api, user.Id);
|
||||||
@@ -306,7 +265,13 @@ export default function index() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
return ss;
|
return ss;
|
||||||
}, [api, user?.Id, collections, mediaListCollections]);
|
}, [
|
||||||
|
api,
|
||||||
|
user?.Id,
|
||||||
|
movieCollectionId,
|
||||||
|
tvShowCollectionId,
|
||||||
|
mediaListCollections,
|
||||||
|
]);
|
||||||
|
|
||||||
if (isConnected === false) {
|
if (isConnected === false) {
|
||||||
return (
|
return (
|
||||||
@@ -350,6 +315,8 @@ export default function index() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
if (e1 || e2)
|
if (e1 || e2)
|
||||||
return (
|
return (
|
||||||
<View className="flex flex-col items-center justify-center h-full -mt-6">
|
<View className="flex flex-col items-center justify-center h-full -mt-6">
|
||||||
@@ -374,13 +341,14 @@ export default function index() {
|
|||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl refreshing={loading} onRefresh={refetch} />
|
<RefreshControl refreshing={loading} onRefresh={refetch} />
|
||||||
}
|
}
|
||||||
contentContainerStyle={{
|
|
||||||
paddingLeft: insets.left,
|
|
||||||
paddingRight: insets.right,
|
|
||||||
paddingBottom: 16,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<View className="flex flex-col space-y-4">
|
<View
|
||||||
|
style={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}}
|
||||||
|
className="flex flex-col pt-4 pb-24 gap-y-4"
|
||||||
|
>
|
||||||
<LargeMovieCarousel />
|
<LargeMovieCarousel />
|
||||||
|
|
||||||
{sections.map((section, index) => {
|
{sections.map((section, index) => {
|
||||||
|
|||||||
@@ -2,84 +2,29 @@ import { Button } from "@/components/Button";
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ListItem } from "@/components/ListItem";
|
import { ListItem } from "@/components/ListItem";
|
||||||
import { SettingToggles } from "@/components/settings/SettingToggles";
|
import { SettingToggles } from "@/components/settings/SettingToggles";
|
||||||
import { bytesToReadable, useDownload } from "@/providers/DownloadProvider";
|
import { useFiles } from "@/hooks/useFiles";
|
||||||
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
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 { useQuery } from "@tanstack/react-query";
|
||||||
import * as FileSystem from "expo-file-system";
|
|
||||||
import * as Haptics from "expo-haptics";
|
import * as Haptics from "expo-haptics";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { Alert, ScrollView, View } from "react-native";
|
import { ScrollView, View } from "react-native";
|
||||||
import * as Progress from "react-native-progress";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { toast } from "sonner-native";
|
|
||||||
|
|
||||||
export default function settings() {
|
export default function settings() {
|
||||||
const { logout } = useJellyfin();
|
const { logout } = useJellyfin();
|
||||||
const { deleteAllFiles, appSizeUsage } = useDownload();
|
const { deleteAllFiles } = useFiles();
|
||||||
const { logs } = useLog();
|
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
const { data: logs } = useQuery({
|
||||||
|
queryKey: ["logs"],
|
||||||
const { data: size, isLoading: appSizeLoading } = useQuery({
|
queryFn: async () => readFromLog(),
|
||||||
queryKey: ["appSize", appSizeUsage],
|
refetchInterval: 1000,
|
||||||
queryFn: async () => {
|
|
||||||
const app = await appSizeUsage;
|
|
||||||
|
|
||||||
const remaining = await FileSystem.getFreeDiskStorageAsync();
|
|
||||||
const total = await FileSystem.getTotalDiskCapacityAsync();
|
|
||||||
|
|
||||||
return { app, remaining, total, used: (total - remaining) / total };
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const openQuickConnectAuthCodeInput = () => {
|
const insets = useSafeAreaInsets();
|
||||||
Alert.prompt(
|
|
||||||
"Quick connect",
|
|
||||||
"Enter the quick connect code",
|
|
||||||
async (text) => {
|
|
||||||
if (text) {
|
|
||||||
try {
|
|
||||||
const res = await getQuickConnectApi(api!).authorizeQuickConnect({
|
|
||||||
code: text,
|
|
||||||
userId: user?.Id,
|
|
||||||
});
|
|
||||||
if (res.status === 200) {
|
|
||||||
Haptics.notificationAsync(
|
|
||||||
Haptics.NotificationFeedbackType.Success
|
|
||||||
);
|
|
||||||
Alert.alert("Success", "Quick connect authorized");
|
|
||||||
} else {
|
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
|
||||||
Alert.alert("Error", "Invalid code");
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
|
||||||
Alert.alert("Error", "Invalid code");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDeleteClicked = async () => {
|
|
||||||
try {
|
|
||||||
await deleteAllFiles();
|
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
|
||||||
} catch (e) {
|
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
|
||||||
toast.error("Error deleting files");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onClearLogsClicked = async () => {
|
|
||||||
clearLogs();
|
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@@ -90,60 +35,41 @@ export default function settings() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className="p-4 flex flex-col gap-y-4">
|
<View className="p-4 flex flex-col gap-y-4">
|
||||||
{/* <Button
|
|
||||||
onPress={() => {
|
|
||||||
registerBackgroundFetchAsync();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
registerBackgroundFetchAsync
|
|
||||||
</Button> */}
|
|
||||||
<View>
|
<View>
|
||||||
<Text className="font-bold text-lg mb-2">User Info</Text>
|
<Text className="font-bold text-lg mb-2">Information</Text>
|
||||||
|
|
||||||
<View className="flex flex-col rounded-xl overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800 ">
|
<View className="flex flex-col rounded-xl mb-4 overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800 ">
|
||||||
<ListItem title="User" subTitle={user?.Name} />
|
<ListItem title="User" subTitle={user?.Name} />
|
||||||
<ListItem title="Server" subTitle={api?.basePath} />
|
<ListItem title="Server" subTitle={api?.basePath} />
|
||||||
<ListItem title="Token" subTitle={api?.accessToken} />
|
|
||||||
</View>
|
</View>
|
||||||
<Button className="my-2.5" color="black" onPress={logout}>
|
|
||||||
Log out
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View>
|
|
||||||
<Text className="font-bold text-lg mb-2">Quick connect</Text>
|
|
||||||
<Button onPress={openQuickConnectAuthCodeInput} color="black">
|
|
||||||
Authorize
|
|
||||||
</Button>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<SettingToggles />
|
<SettingToggles />
|
||||||
|
|
||||||
<View className="flex flex-col space-y-2">
|
<View className="flex flex-col space-y-2">
|
||||||
<Text className="font-bold text-lg mb-2">Storage</Text>
|
<Button color="black" onPress={logout}>
|
||||||
<View className="mb-4 space-y-2">
|
Log out
|
||||||
{size && <Text>App usage: {bytesToReadable(size.app)}</Text>}
|
</Button>
|
||||||
<Progress.Bar
|
<Button
|
||||||
className="bg-gray-100/10"
|
color="red"
|
||||||
indeterminate={appSizeLoading}
|
onPress={async () => {
|
||||||
color="#9333ea"
|
await deleteAllFiles();
|
||||||
width={null}
|
Haptics.notificationAsync(
|
||||||
height={10}
|
Haptics.NotificationFeedbackType.Success
|
||||||
borderRadius={6}
|
);
|
||||||
borderWidth={0}
|
}}
|
||||||
progress={size?.used}
|
>
|
||||||
/>
|
|
||||||
{size && (
|
|
||||||
<Text>
|
|
||||||
Available: {bytesToReadable(size.remaining)}, Total:{" "}
|
|
||||||
{bytesToReadable(size.total)}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
<Button color="red" onPress={onDeleteClicked}>
|
|
||||||
Delete all downloaded files
|
Delete all downloaded files
|
||||||
</Button>
|
</Button>
|
||||||
<Button color="red" onPress={onClearLogsClicked}>
|
<Button
|
||||||
|
color="red"
|
||||||
|
onPress={async () => {
|
||||||
|
await clearLogs();
|
||||||
|
Haptics.notificationAsync(
|
||||||
|
Haptics.NotificationFeedbackType.Success
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
Delete all logs
|
Delete all logs
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
@@ -161,9 +87,7 @@ export default function settings() {
|
|||||||
>
|
>
|
||||||
{log.level}
|
{log.level}
|
||||||
</Text>
|
</Text>
|
||||||
<Text uiTextView selectable className="text-xs">
|
<Text className="text-xs">{log.message}</Text>
|
||||||
{log.message}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
{logs?.length === 0 && (
|
{logs?.length === 0 && (
|
||||||
|
|||||||
145
app/(auth)/(tabs)/(home)/syncplay.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { List } from "@/components/List";
|
||||||
|
import { ListItem } from "@/components/ListItem";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { ActivityIndicator, Alert, ScrollView, View } from "react-native";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const name = useMemo(() => user?.Name || "", [user]);
|
||||||
|
|
||||||
|
const { data: activeGroups } = useQuery({
|
||||||
|
queryKey: ["syncplay", "activeGroups"],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api) return [];
|
||||||
|
const res = await getSyncPlayApi(api).syncPlayGetGroups();
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
refetchInterval: 1000,
|
||||||
|
refetchIntervalInBackground: true,
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
|
refetchOnReconnect: true,
|
||||||
|
refetchOnMount: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createGroupMutation = useMutation({
|
||||||
|
mutationFn: async (GroupName: string) => {
|
||||||
|
if (!api) return;
|
||||||
|
const res = await getSyncPlayApi(api).syncPlayCreateGroup({
|
||||||
|
newGroupRequestDto: {
|
||||||
|
GroupName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (res.status !== 204) {
|
||||||
|
Alert.alert("Error", "Failed to create group");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["syncplay", "activeGroups"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createGroup = () => {
|
||||||
|
Alert.prompt("Create Group", "Enter a name for the group", (text) => {
|
||||||
|
if (text) {
|
||||||
|
createGroupMutation.mutate(text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const joinGroupMutation = useMutation({
|
||||||
|
mutationFn: async (groupId: string) => {
|
||||||
|
if (!api) return;
|
||||||
|
const res = await getSyncPlayApi(api).syncPlayJoinGroup({
|
||||||
|
joinGroupRequestDto: {
|
||||||
|
GroupId: groupId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (res.status !== 204) {
|
||||||
|
Alert.alert("Error", "Failed to join group");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["syncplay", "activeGroups"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const leaveGroupMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!api) return;
|
||||||
|
const res = await getSyncPlayApi(api).syncPlayLeaveGroup();
|
||||||
|
if (res.status !== 204) {
|
||||||
|
Alert.alert("Error", "Failed to exit group");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["syncplay", "activeGroups"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView>
|
||||||
|
<View className="px-4 py-4">
|
||||||
|
<View>
|
||||||
|
<Text className="text-lg font-bold mb-4">Join group</Text>
|
||||||
|
{!activeGroups?.length && (
|
||||||
|
<Text className="text-neutral-500 mb-4">No active groups</Text>
|
||||||
|
)}
|
||||||
|
<List>
|
||||||
|
{activeGroups?.map((group) => (
|
||||||
|
<ListItem
|
||||||
|
key={group.GroupId}
|
||||||
|
title={group.GroupName}
|
||||||
|
onPress={async () => {
|
||||||
|
if (!group.GroupId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (group.Participants?.includes(name)) {
|
||||||
|
leaveGroupMutation.mutate();
|
||||||
|
} else {
|
||||||
|
joinGroupMutation.mutate(group.GroupId);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
iconAfter={
|
||||||
|
group.Participants?.includes(name) ? (
|
||||||
|
<Ionicons name="exit-outline" size={20} color="white" />
|
||||||
|
) : (
|
||||||
|
<Ionicons name="add" size={20} color="white" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
subTitle={group.Participants?.join(", ")}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<ListItem
|
||||||
|
onPress={() => createGroup()}
|
||||||
|
key={"create"}
|
||||||
|
title={"Create group"}
|
||||||
|
iconAfter={
|
||||||
|
createGroupMutation.isPending ? (
|
||||||
|
<ActivityIndicator size={20} color={"white"} />
|
||||||
|
) : (
|
||||||
|
<Ionicons name="add" size={20} color="white" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</List>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -50,7 +50,7 @@ const page: React.FC = () => {
|
|||||||
userId: user.Id,
|
userId: user.Id,
|
||||||
personIds: [actorId],
|
personIds: [actorId],
|
||||||
startIndex: pageParam,
|
startIndex: pageParam,
|
||||||
limit: 16,
|
limit: 8,
|
||||||
sortOrder: ["Descending", "Descending", "Ascending"],
|
sortOrder: ["Descending", "Descending", "Ascending"],
|
||||||
includeItemTypes: ["Movie", "Series"],
|
includeItemTypes: ["Movie", "Series"],
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ import { useAtom } from "jotai";
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { FlatList, View } from "react-native";
|
import { FlatList, View } from "react-native";
|
||||||
|
|
||||||
|
const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter);
|
||||||
|
|
||||||
const page: React.FC = () => {
|
const page: React.FC = () => {
|
||||||
const searchParams = useLocalSearchParams();
|
const searchParams = useLocalSearchParams();
|
||||||
const { collectionId } = searchParams as { collectionId: string };
|
const { collectionId } = searchParams as { collectionId: string };
|
||||||
@@ -167,7 +169,7 @@ const page: React.FC = () => {
|
|||||||
|
|
||||||
const renderItem = useCallback(
|
const renderItem = useCallback(
|
||||||
({ item, index }: { item: BaseItemDto; index: number }) => (
|
({ item, index }: { item: BaseItemDto; index: number }) => (
|
||||||
<TouchableItemRouter
|
<MemoizedTouchableItemRouter
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
@@ -191,7 +193,7 @@ const page: React.FC = () => {
|
|||||||
{/* <MoviePoster item={item} /> */}
|
{/* <MoviePoster item={item} /> */}
|
||||||
<ItemCardText item={item} />
|
<ItemCardText item={item} />
|
||||||
</View>
|
</View>
|
||||||
</TouchableItemRouter>
|
</MemoizedTouchableItemRouter>
|
||||||
),
|
),
|
||||||
[orientation]
|
[orientation]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,112 +1,13 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { ItemContent } from "@/components/ItemContent";
|
import { ItemContent } from "@/components/ItemContent";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import React, { useMemo } from "react";
|
||||||
import React, { useEffect } from "react";
|
|
||||||
import { View } from "react-native";
|
|
||||||
import Animated, {
|
|
||||||
runOnJS,
|
|
||||||
useAnimatedStyle,
|
|
||||||
useSharedValue,
|
|
||||||
withTiming,
|
|
||||||
} from "react-native-reanimated";
|
|
||||||
|
|
||||||
const Page: React.FC = () => {
|
const Page: React.FC = () => {
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
const { id } = useLocalSearchParams() as { id: string };
|
const { id } = useLocalSearchParams() as { id: string };
|
||||||
|
|
||||||
const { data: item, isError } = useQuery({
|
const memoizedContent = useMemo(() => <ItemContent id={id} />, [id]);
|
||||||
queryKey: ["item", id],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api || !user || !id) return;
|
|
||||||
const res = await getUserLibraryApi(api).getItem({
|
|
||||||
itemId: id,
|
|
||||||
userId: user?.Id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.data;
|
return memoizedContent;
|
||||||
},
|
|
||||||
staleTime: 0,
|
|
||||||
refetchOnMount: true,
|
|
||||||
refetchOnWindowFocus: true,
|
|
||||||
refetchOnReconnect: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const opacity = useSharedValue(1);
|
|
||||||
const animatedStyle = useAnimatedStyle(() => {
|
|
||||||
return {
|
|
||||||
opacity: opacity.value,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const fadeOut = (callback: any) => {
|
|
||||||
setTimeout(() => {
|
|
||||||
opacity.value = withTiming(0, { duration: 500 }, (finished) => {
|
|
||||||
if (finished) {
|
|
||||||
runOnJS(callback)();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, 100);
|
|
||||||
};
|
|
||||||
|
|
||||||
const fadeIn = (callback: any) => {
|
|
||||||
setTimeout(() => {
|
|
||||||
opacity.value = withTiming(1, { duration: 500 }, (finished) => {
|
|
||||||
if (finished) {
|
|
||||||
runOnJS(callback)();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, 100);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (item) {
|
|
||||||
fadeOut(() => {});
|
|
||||||
} else {
|
|
||||||
fadeIn(() => {});
|
|
||||||
}
|
|
||||||
}, [item]);
|
|
||||||
|
|
||||||
if (isError)
|
|
||||||
return (
|
|
||||||
<View className="flex flex-col items-center justify-center h-screen w-screen">
|
|
||||||
<Text>Could not load item</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className="flex flex-1 relative">
|
|
||||||
<Animated.View
|
|
||||||
pointerEvents={"none"}
|
|
||||||
style={[animatedStyle]}
|
|
||||||
className="absolute top-0 left-0 flex flex-col items-start h-screen w-screen px-4 z-50 bg-black"
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: item?.Type === "Episode" ? 300 : 450,
|
|
||||||
}}
|
|
||||||
className="bg-transparent rounded-lg mb-4 w-full"
|
|
||||||
></View>
|
|
||||||
<View className="h-6 bg-neutral-900 rounded mb-4 w-14"></View>
|
|
||||||
<View className="h-10 bg-neutral-900 rounded-lg mb-2 w-1/2"></View>
|
|
||||||
<View className="h-3 bg-neutral-900 rounded mb-3 w-8"></View>
|
|
||||||
<View className="flex flex-row space-x-1 mb-8">
|
|
||||||
<View className="h-6 bg-neutral-900 rounded mb-3 w-14"></View>
|
|
||||||
<View className="h-6 bg-neutral-900 rounded mb-3 w-14"></View>
|
|
||||||
<View className="h-6 bg-neutral-900 rounded mb-3 w-14"></View>
|
|
||||||
</View>
|
|
||||||
<View className="h-3 bg-neutral-900 rounded w-2/3 mb-1"></View>
|
|
||||||
<View className="h-10 bg-neutral-900 rounded-lg w-full mb-2"></View>
|
|
||||||
<View className="h-12 bg-neutral-900 rounded-lg w-full mb-2"></View>
|
|
||||||
<View className="h-24 bg-neutral-900 rounded-lg mb-1 w-full"></View>
|
|
||||||
</Animated.View>
|
|
||||||
{item && <ItemContent item={item} />}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Page;
|
export default React.memo(Page);
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
import type {
|
|
||||||
MaterialTopTabNavigationEventMap,
|
|
||||||
MaterialTopTabNavigationOptions,
|
|
||||||
} from "@react-navigation/material-top-tabs";
|
|
||||||
import { createMaterialTopTabNavigator } from "@react-navigation/material-top-tabs";
|
|
||||||
import { ParamListBase, TabNavigationState } from "@react-navigation/native";
|
|
||||||
import { Stack, withLayoutContext } from "expo-router";
|
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
const { Navigator } = createMaterialTopTabNavigator();
|
|
||||||
|
|
||||||
export const Tab = withLayoutContext<
|
|
||||||
MaterialTopTabNavigationOptions,
|
|
||||||
typeof Navigator,
|
|
||||||
TabNavigationState<ParamListBase>,
|
|
||||||
MaterialTopTabNavigationEventMap
|
|
||||||
>(Navigator);
|
|
||||||
|
|
||||||
const Layout = () => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Stack.Screen options={{ title: "Live TV" }} />
|
|
||||||
<Tab
|
|
||||||
initialRouteName="programs"
|
|
||||||
keyboardDismissMode="none"
|
|
||||||
screenOptions={{
|
|
||||||
tabBarBounces: true,
|
|
||||||
tabBarLabelStyle: { fontSize: 10 },
|
|
||||||
tabBarItemStyle: {
|
|
||||||
width: 100,
|
|
||||||
},
|
|
||||||
tabBarStyle: { backgroundColor: "black" },
|
|
||||||
animationEnabled: true,
|
|
||||||
lazy: true,
|
|
||||||
swipeEnabled: true,
|
|
||||||
tabBarIndicatorStyle: { backgroundColor: "#9334E9" },
|
|
||||||
tabBarScrollEnabled: true,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Tab.Screen name="programs" />
|
|
||||||
<Tab.Screen name="guide" />
|
|
||||||
<Tab.Screen name="channels" />
|
|
||||||
<Tab.Screen name="recordings" />
|
|
||||||
</Tab>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Layout;
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import { ItemImage } from "@/components/common/ItemImage";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { FlashList } from "@shopify/flash-list";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import React from "react";
|
|
||||||
import { View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
|
|
||||||
export default function page() {
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
const { data: channels } = useQuery({
|
|
||||||
queryKey: ["livetv", "channels"],
|
|
||||||
queryFn: async () => {
|
|
||||||
const res = await getLiveTvApi(api!).getLiveTvChannels({
|
|
||||||
startIndex: 0,
|
|
||||||
limit: 500,
|
|
||||||
enableFavoriteSorting: true,
|
|
||||||
userId: user?.Id,
|
|
||||||
addCurrentProgram: false,
|
|
||||||
enableUserData: false,
|
|
||||||
enableImageTypes: ["Primary"],
|
|
||||||
});
|
|
||||||
return res.data;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className="flex flex-1">
|
|
||||||
<FlashList
|
|
||||||
data={channels?.Items}
|
|
||||||
estimatedItemSize={76}
|
|
||||||
renderItem={({ item }) => (
|
|
||||||
<View className="flex flex-row items-center px-4 mb-2">
|
|
||||||
<View className="w-22 mr-4 rounded-lg overflow-hidden">
|
|
||||||
<ItemImage
|
|
||||||
style={{
|
|
||||||
aspectRatio: "1/1",
|
|
||||||
width: 60,
|
|
||||||
borderRadius: 8,
|
|
||||||
}}
|
|
||||||
item={item}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<Text className="font-bold">{item.Name}</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
import { ItemImage } from "@/components/common/ItemImage";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { HourHeader } from "@/components/livetv/HourHeader";
|
|
||||||
import { LiveTVGuideRow } from "@/components/livetv/LiveTVGuideRow";
|
|
||||||
import { TAB_HEIGHT } from "@/constants/Values";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import React, { useCallback, useMemo, useState } from "react";
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Dimensions,
|
|
||||||
ScrollView,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
|
|
||||||
const HOUR_HEIGHT = 30;
|
|
||||||
const ITEMS_PER_PAGE = 20;
|
|
||||||
|
|
||||||
const MemoizedLiveTVGuideRow = React.memo(LiveTVGuideRow);
|
|
||||||
|
|
||||||
export default function page() {
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const [date, setDate] = useState<Date>(new Date());
|
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
|
||||||
|
|
||||||
const { data: guideInfo } = useQuery({
|
|
||||||
queryKey: ["livetv", "guideInfo"],
|
|
||||||
queryFn: async () => {
|
|
||||||
const res = await getLiveTvApi(api!).getGuideInfo();
|
|
||||||
return res.data;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: channels } = useQuery({
|
|
||||||
queryKey: ["livetv", "channels", currentPage],
|
|
||||||
queryFn: async () => {
|
|
||||||
const res = await getLiveTvApi(api!).getLiveTvChannels({
|
|
||||||
startIndex: (currentPage - 1) * ITEMS_PER_PAGE,
|
|
||||||
limit: ITEMS_PER_PAGE,
|
|
||||||
enableFavoriteSorting: true,
|
|
||||||
userId: user?.Id,
|
|
||||||
addCurrentProgram: false,
|
|
||||||
enableUserData: false,
|
|
||||||
enableImageTypes: ["Primary"],
|
|
||||||
});
|
|
||||||
return res.data;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: programs } = useQuery({
|
|
||||||
queryKey: ["livetv", "programs", date, currentPage],
|
|
||||||
queryFn: async () => {
|
|
||||||
const startOfDay = new Date(date);
|
|
||||||
startOfDay.setHours(0, 0, 0, 0);
|
|
||||||
const endOfDay = new Date(date);
|
|
||||||
endOfDay.setHours(23, 59, 59, 999);
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const isToday = startOfDay.toDateString() === now.toDateString();
|
|
||||||
|
|
||||||
const res = await getLiveTvApi(api!).getPrograms({
|
|
||||||
getProgramsDto: {
|
|
||||||
MaxStartDate: endOfDay.toISOString(),
|
|
||||||
MinEndDate: isToday ? now.toISOString() : startOfDay.toISOString(),
|
|
||||||
ChannelIds: channels?.Items?.map((c) => c.Id).filter(
|
|
||||||
Boolean
|
|
||||||
) as string[],
|
|
||||||
ImageTypeLimit: 1,
|
|
||||||
EnableImages: false,
|
|
||||||
SortBy: ["StartDate"],
|
|
||||||
EnableTotalRecordCount: false,
|
|
||||||
EnableUserData: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return res.data;
|
|
||||||
},
|
|
||||||
enabled: !!channels,
|
|
||||||
});
|
|
||||||
|
|
||||||
const screenWidth = Dimensions.get("window").width;
|
|
||||||
|
|
||||||
const [scrollX, setScrollX] = useState(0);
|
|
||||||
|
|
||||||
const handleNextPage = useCallback(() => {
|
|
||||||
setCurrentPage((prev) => prev + 1);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handlePrevPage = useCallback(() => {
|
|
||||||
setCurrentPage((prev) => Math.max(1, prev - 1));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView
|
|
||||||
nestedScrollEnabled
|
|
||||||
contentInsetAdjustmentBehavior="automatic"
|
|
||||||
key={"home"}
|
|
||||||
contentContainerStyle={{
|
|
||||||
paddingLeft: insets.left,
|
|
||||||
paddingRight: insets.right,
|
|
||||||
paddingBottom: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PageButtons
|
|
||||||
currentPage={currentPage}
|
|
||||||
onPrevPage={handlePrevPage}
|
|
||||||
onNextPage={handleNextPage}
|
|
||||||
isNextDisabled={
|
|
||||||
!channels || (channels?.Items?.length || 0) < ITEMS_PER_PAGE
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<View className="flex flex-row">
|
|
||||||
<View className="flex flex-col w-[64px]">
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: HOUR_HEIGHT,
|
|
||||||
}}
|
|
||||||
className="bg-neutral-800"
|
|
||||||
></View>
|
|
||||||
{channels?.Items?.map((c, i) => (
|
|
||||||
<View className="h-16 w-16 mr-4 rounded-lg overflow-hidden" key={i}>
|
|
||||||
<ItemImage
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
resizeMode: "contain",
|
|
||||||
}}
|
|
||||||
item={c}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
<ScrollView
|
|
||||||
style={{
|
|
||||||
width: screenWidth - 64,
|
|
||||||
}}
|
|
||||||
horizontal
|
|
||||||
scrollEnabled
|
|
||||||
onScroll={(e) => {
|
|
||||||
setScrollX(e.nativeEvent.contentOffset.x);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col">
|
|
||||||
<HourHeader height={HOUR_HEIGHT} />
|
|
||||||
{channels?.Items?.map((c, i) => (
|
|
||||||
<MemoizedLiveTVGuideRow
|
|
||||||
channel={c}
|
|
||||||
programs={programs?.Items}
|
|
||||||
key={c.Id}
|
|
||||||
scrollX={scrollX}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PageButtonsProps {
|
|
||||||
currentPage: number;
|
|
||||||
onPrevPage: () => void;
|
|
||||||
onNextPage: () => void;
|
|
||||||
isNextDisabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PageButtons: React.FC<PageButtonsProps> = ({
|
|
||||||
currentPage,
|
|
||||||
onPrevPage,
|
|
||||||
onNextPage,
|
|
||||||
isNextDisabled,
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<View className="flex flex-row justify-between items-center bg-neutral-800 w-full px-4 py-2">
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={onPrevPage}
|
|
||||||
disabled={currentPage === 1}
|
|
||||||
className="flex flex-row items-center"
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name="chevron-back"
|
|
||||||
size={24}
|
|
||||||
color={currentPage === 1 ? "gray" : "white"}
|
|
||||||
/>
|
|
||||||
<Text
|
|
||||||
className={`ml-1 ${
|
|
||||||
currentPage === 1 ? "text-gray-500" : "text-white"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<Text className="text-white">Page {currentPage}</Text>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={onNextPage}
|
|
||||||
disabled={isNextDisabled}
|
|
||||||
className="flex flex-row items-center"
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
className={`mr-1 ${isNextDisabled ? "text-gray-500" : "text-white"}`}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</Text>
|
|
||||||
<Ionicons
|
|
||||||
name="chevron-forward"
|
|
||||||
size={24}
|
|
||||||
color={isNextDisabled ? "gray" : "white"}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
|
||||||
import { TAB_HEIGHT } from "@/constants/Values";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import React from "react";
|
|
||||||
import { ScrollView, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
|
|
||||||
export default function page() {
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView
|
|
||||||
nestedScrollEnabled
|
|
||||||
contentInsetAdjustmentBehavior="automatic"
|
|
||||||
key={"home"}
|
|
||||||
contentContainerStyle={{
|
|
||||||
paddingLeft: insets.left,
|
|
||||||
paddingRight: insets.right,
|
|
||||||
paddingBottom: 16,
|
|
||||||
paddingTop: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col space-y-2">
|
|
||||||
<ScrollingCollectionList
|
|
||||||
queryKey={["livetv", "recommended"]}
|
|
||||||
title={"On now"}
|
|
||||||
queryFn={async () => {
|
|
||||||
if (!api) return [] as BaseItemDto[];
|
|
||||||
const res = await getLiveTvApi(api).getRecommendedPrograms({
|
|
||||||
userId: user?.Id,
|
|
||||||
isAiring: true,
|
|
||||||
limit: 24,
|
|
||||||
imageTypeLimit: 1,
|
|
||||||
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
|
|
||||||
enableTotalRecordCount: false,
|
|
||||||
fields: ["ChannelInfo", "PrimaryImageAspectRatio"],
|
|
||||||
});
|
|
||||||
return res.data.Items || [];
|
|
||||||
}}
|
|
||||||
orientation="horizontal"
|
|
||||||
/>
|
|
||||||
<ScrollingCollectionList
|
|
||||||
queryKey={["livetv", "shows"]}
|
|
||||||
title={"Shows"}
|
|
||||||
queryFn={async () => {
|
|
||||||
if (!api) return [] as BaseItemDto[];
|
|
||||||
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
|
||||||
userId: user?.Id,
|
|
||||||
hasAired: false,
|
|
||||||
limit: 9,
|
|
||||||
isMovie: false,
|
|
||||||
isSeries: true,
|
|
||||||
isSports: false,
|
|
||||||
isNews: false,
|
|
||||||
isKids: false,
|
|
||||||
enableTotalRecordCount: false,
|
|
||||||
fields: ["ChannelInfo", "PrimaryImageAspectRatio"],
|
|
||||||
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
|
|
||||||
});
|
|
||||||
return res.data.Items || [];
|
|
||||||
}}
|
|
||||||
orientation="horizontal"
|
|
||||||
/>
|
|
||||||
<ScrollingCollectionList
|
|
||||||
queryKey={["livetv", "movies"]}
|
|
||||||
title={"Movies"}
|
|
||||||
queryFn={async () => {
|
|
||||||
if (!api) return [] as BaseItemDto[];
|
|
||||||
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
|
||||||
userId: user?.Id,
|
|
||||||
hasAired: false,
|
|
||||||
limit: 9,
|
|
||||||
isMovie: true,
|
|
||||||
enableTotalRecordCount: false,
|
|
||||||
fields: ["ChannelInfo"],
|
|
||||||
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
|
|
||||||
});
|
|
||||||
return res.data.Items || [];
|
|
||||||
}}
|
|
||||||
orientation="horizontal"
|
|
||||||
/>
|
|
||||||
<ScrollingCollectionList
|
|
||||||
queryKey={["livetv", "sports"]}
|
|
||||||
title={"Sports"}
|
|
||||||
queryFn={async () => {
|
|
||||||
if (!api) return [] as BaseItemDto[];
|
|
||||||
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
|
||||||
userId: user?.Id,
|
|
||||||
hasAired: false,
|
|
||||||
limit: 9,
|
|
||||||
isSports: true,
|
|
||||||
enableTotalRecordCount: false,
|
|
||||||
fields: ["ChannelInfo"],
|
|
||||||
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
|
|
||||||
});
|
|
||||||
return res.data.Items || [];
|
|
||||||
}}
|
|
||||||
orientation="horizontal"
|
|
||||||
/>
|
|
||||||
<ScrollingCollectionList
|
|
||||||
queryKey={["livetv", "kids"]}
|
|
||||||
title={"For Kids"}
|
|
||||||
queryFn={async () => {
|
|
||||||
if (!api) return [] as BaseItemDto[];
|
|
||||||
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
|
||||||
userId: user?.Id,
|
|
||||||
hasAired: false,
|
|
||||||
limit: 9,
|
|
||||||
isKids: true,
|
|
||||||
enableTotalRecordCount: false,
|
|
||||||
fields: ["ChannelInfo"],
|
|
||||||
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
|
|
||||||
});
|
|
||||||
return res.data.Items || [];
|
|
||||||
}}
|
|
||||||
orientation="horizontal"
|
|
||||||
/>
|
|
||||||
<ScrollingCollectionList
|
|
||||||
queryKey={["livetv", "news"]}
|
|
||||||
title={"News"}
|
|
||||||
queryFn={async () => {
|
|
||||||
if (!api) return [] as BaseItemDto[];
|
|
||||||
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
|
||||||
userId: user?.Id,
|
|
||||||
hasAired: false,
|
|
||||||
limit: 9,
|
|
||||||
isNews: true,
|
|
||||||
enableTotalRecordCount: false,
|
|
||||||
fields: ["ChannelInfo"],
|
|
||||||
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
|
|
||||||
});
|
|
||||||
return res.data.Items || [];
|
|
||||||
}}
|
|
||||||
orientation="horizontal"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import React from "react";
|
|
||||||
import { View } from "react-native";
|
|
||||||
|
|
||||||
export default function page() {
|
|
||||||
return (
|
|
||||||
<View className="flex items-center justify-center h-full -mt-12">
|
|
||||||
<Text>Coming soon</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { DownloadItems } from "@/components/DownloadItem";
|
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
import { NextUp } from "@/components/series/NextUp";
|
import { NextUp } from "@/components/series/NextUp";
|
||||||
import { SeasonPicker } from "@/components/series/SeasonPicker";
|
import { SeasonPicker } from "@/components/series/SeasonPicker";
|
||||||
@@ -7,23 +6,22 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|||||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
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 { useQuery } from "@tanstack/react-query";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useEffect, useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
|
|
||||||
const page: React.FC = () => {
|
const page: React.FC = () => {
|
||||||
const navigation = useNavigation();
|
|
||||||
const params = useLocalSearchParams();
|
const params = useLocalSearchParams();
|
||||||
const { id: seriesId, seasonIndex } = params as {
|
const { id: seriesId, seasonIndex } = params as {
|
||||||
id: string;
|
id: string;
|
||||||
seasonIndex: string;
|
seasonIndex: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
console.log("seasonIndex", seasonIndex);
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
@@ -59,46 +57,6 @@ const page: React.FC = () => {
|
|||||||
[item]
|
[item]
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: allEpisodes, isLoading } = useQuery({
|
|
||||||
queryKey: ["AllEpisodes", item?.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
const res = await getTvShowsApi(api!).getEpisodes({
|
|
||||||
seriesId: item?.Id!,
|
|
||||||
userId: user?.Id!,
|
|
||||||
enableUserData: true,
|
|
||||||
fields: ["MediaSources", "MediaStreams", "Overview"],
|
|
||||||
});
|
|
||||||
return res?.data.Items || [];
|
|
||||||
},
|
|
||||||
enabled: !!api && !!user?.Id && !!item?.Id,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
navigation.setOptions({
|
|
||||||
headerRight: () =>
|
|
||||||
!isLoading &&
|
|
||||||
allEpisodes &&
|
|
||||||
allEpisodes.length > 0 && (
|
|
||||||
<View className="flex flex-row items-center space-x-2">
|
|
||||||
<DownloadItems
|
|
||||||
title="Download Series"
|
|
||||||
items={allEpisodes || []}
|
|
||||||
MissingDownloadIconComponent={() => (
|
|
||||||
<Ionicons name="download" size={22} color="white" />
|
|
||||||
)}
|
|
||||||
DownloadedIconComponent={() => (
|
|
||||||
<Ionicons
|
|
||||||
name="checkmark-done-outline"
|
|
||||||
size={24}
|
|
||||||
color="#9333ea"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}, [allEpisodes, isLoading]);
|
|
||||||
|
|
||||||
if (!item || !backdropUrl) return null;
|
if (!item || !backdropUrl) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import { useFocusEffect, useLocalSearchParams } from "expo-router";
|
||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
import * as ScreenOrientation from "expo-screen-orientation";
|
||||||
import { useAtom } from "jotai";
|
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 { FlatList, useWindowDimensions, View } from "react-native";
|
||||||
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
@@ -12,7 +12,6 @@ import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
|||||||
import { ItemCardText } from "@/components/ItemCardText";
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { ItemPoster } from "@/components/posters/ItemPoster";
|
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import {
|
import {
|
||||||
genreFilterAtom,
|
genreFilterAtom,
|
||||||
@@ -29,10 +28,10 @@ import {
|
|||||||
tagsFilterAtom,
|
tagsFilterAtom,
|
||||||
yearFilterAtom,
|
yearFilterAtom,
|
||||||
} from "@/utils/atoms/filters";
|
} from "@/utils/atoms/filters";
|
||||||
|
import { orientationAtom } from "@/utils/atoms/orientation";
|
||||||
import {
|
import {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
BaseItemDtoQueryResult,
|
BaseItemDtoQueryResult,
|
||||||
BaseItemKind,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import {
|
import {
|
||||||
getFilterApi,
|
getFilterApi,
|
||||||
@@ -41,7 +40,8 @@ import {
|
|||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { colletionTypeToItemType } from "@/utils/collectionTypeToItemType";
|
|
||||||
|
const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter);
|
||||||
|
|
||||||
const Page = () => {
|
const Page = () => {
|
||||||
const searchParams = useLocalSearchParams();
|
const searchParams = useLocalSearchParams();
|
||||||
@@ -56,21 +56,22 @@ const Page = () => {
|
|||||||
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
|
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
|
||||||
const [sortBy, _setSortBy] = useAtom(sortByAtom);
|
const [sortBy, _setSortBy] = useAtom(sortByAtom);
|
||||||
const [sortOrder, _setSortOrder] = useAtom(sortOrderAtom);
|
const [sortOrder, _setSortOrder] = useAtom(sortOrderAtom);
|
||||||
|
const [orientation] = useAtom(orientationAtom);
|
||||||
const [sortByPreference, setSortByPreference] = useAtom(sortByPreferenceAtom);
|
const [sortByPreference, setSortByPreference] = useAtom(sortByPreferenceAtom);
|
||||||
const [sortOrderPreference, setOderByPreference] = useAtom(
|
const [sortOrderPreference, setOderByPreference] = useAtom(
|
||||||
sortOrderPreferenceAtom
|
sortOrderPreferenceAtom
|
||||||
);
|
);
|
||||||
|
|
||||||
const { orientation } = useOrientation();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
|
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
|
||||||
if (sop) {
|
if (sop) {
|
||||||
|
console.log("getSortOrderPreference ~", sop, libraryId);
|
||||||
_setSortOrder([sop]);
|
_setSortOrder([sop]);
|
||||||
} else {
|
} else {
|
||||||
_setSortOrder([SortOrderOption.Ascending]);
|
_setSortOrder([SortOrderOption.Ascending]);
|
||||||
}
|
}
|
||||||
const obp = getSortByPreference(libraryId, sortByPreference);
|
const obp = getSortByPreference(libraryId, sortByPreference);
|
||||||
|
console.log("getSortByPreference ~", obp, libraryId);
|
||||||
if (obp) {
|
if (obp) {
|
||||||
_setSortBy([obp]);
|
_setSortBy([obp]);
|
||||||
} else {
|
} else {
|
||||||
@@ -82,6 +83,7 @@ const Page = () => {
|
|||||||
(sortBy: SortByOption[]) => {
|
(sortBy: SortByOption[]) => {
|
||||||
const sop = getSortByPreference(libraryId, sortByPreference);
|
const sop = getSortByPreference(libraryId, sortByPreference);
|
||||||
if (sortBy[0] !== sop) {
|
if (sortBy[0] !== sop) {
|
||||||
|
console.log("setSortByPreference ~", sortBy[0], libraryId);
|
||||||
setSortByPreference({ ...sortByPreference, [libraryId]: sortBy[0] });
|
setSortByPreference({ ...sortByPreference, [libraryId]: sortBy[0] });
|
||||||
}
|
}
|
||||||
_setSortBy(sortBy);
|
_setSortBy(sortBy);
|
||||||
@@ -93,6 +95,7 @@ const Page = () => {
|
|||||||
(sortOrder: SortOrderOption[]) => {
|
(sortOrder: SortOrderOption[]) => {
|
||||||
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
|
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
|
||||||
if (sortOrder[0] !== sop) {
|
if (sortOrder[0] !== sop) {
|
||||||
|
console.log("setSortOrderPreference ~", sortOrder[0], libraryId);
|
||||||
setOderByPreference({
|
setOderByPreference({
|
||||||
...sortOrderPreference,
|
...sortOrderPreference,
|
||||||
[libraryId]: sortOrder[0],
|
[libraryId]: sortOrder[0],
|
||||||
@@ -103,12 +106,11 @@ const Page = () => {
|
|||||||
[libraryId, sortOrderPreference]
|
[libraryId, sortOrderPreference]
|
||||||
);
|
);
|
||||||
|
|
||||||
const nrOfCols = useMemo(() => {
|
const getNumberOfColumns = useCallback(() => {
|
||||||
if (screenWidth < 300) return 2;
|
if (orientation === ScreenOrientation.Orientation.PORTRAIT_UP) return 3;
|
||||||
if (screenWidth < 500) return 3;
|
if (screenWidth < 600) return 5;
|
||||||
if (screenWidth < 800) return 5;
|
if (screenWidth < 960) return 6;
|
||||||
if (screenWidth < 1000) return 6;
|
if (screenWidth < 1280) return 7;
|
||||||
if (screenWidth < 1500) return 7;
|
|
||||||
return 6;
|
return 6;
|
||||||
}, [screenWidth, orientation]);
|
}, [screenWidth, orientation]);
|
||||||
|
|
||||||
@@ -126,13 +128,6 @@ const Page = () => {
|
|||||||
staleTime: 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const navigation = useNavigation();
|
|
||||||
useEffect(() => {
|
|
||||||
navigation.setOptions({
|
|
||||||
title: library?.Name || "",
|
|
||||||
});
|
|
||||||
}, [library]);
|
|
||||||
|
|
||||||
const fetchItems = useCallback(
|
const fetchItems = useCallback(
|
||||||
async ({
|
async ({
|
||||||
pageParam,
|
pageParam,
|
||||||
@@ -141,18 +136,6 @@ const Page = () => {
|
|||||||
}): Promise<BaseItemDtoQueryResult | null> => {
|
}): Promise<BaseItemDtoQueryResult | null> => {
|
||||||
if (!api || !library) return null;
|
if (!api || !library) return null;
|
||||||
|
|
||||||
console.log("[libraryId] ~", library);
|
|
||||||
|
|
||||||
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";
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await getItemsApi(api).getItems({
|
const response = await getItemsApi(api).getItems({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
parentId: libraryId,
|
parentId: libraryId,
|
||||||
@@ -167,7 +150,6 @@ const Page = () => {
|
|||||||
genres: selectedGenres,
|
genres: selectedGenres,
|
||||||
tags: selectedTags,
|
tags: selectedTags,
|
||||||
years: selectedYears.map((year) => parseInt(year)),
|
years: selectedYears.map((year) => parseInt(year)),
|
||||||
includeItemTypes: itemType ? [itemType] : undefined,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return response.data || null;
|
return response.data || null;
|
||||||
@@ -230,7 +212,7 @@ const Page = () => {
|
|||||||
|
|
||||||
const renderItem = useCallback(
|
const renderItem = useCallback(
|
||||||
({ item, index }: { item: BaseItemDto; index: number }) => (
|
({ item, index }: { item: BaseItemDto; index: number }) => (
|
||||||
<TouchableItemRouter
|
<MemoizedTouchableItemRouter
|
||||||
key={item.Id}
|
key={item.Id}
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
@@ -241,10 +223,10 @@ const Page = () => {
|
|||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
alignSelf:
|
alignSelf:
|
||||||
orientation === ScreenOrientation.OrientationLock.PORTRAIT_UP
|
orientation === ScreenOrientation.Orientation.PORTRAIT_UP
|
||||||
? index % nrOfCols === 0
|
? index % 3 === 0
|
||||||
? "flex-end"
|
? "flex-end"
|
||||||
: (index + 1) % nrOfCols === 0
|
: (index + 1) % 3 === 0
|
||||||
? "flex-start"
|
? "flex-start"
|
||||||
: "center"
|
: "center"
|
||||||
: "center",
|
: "center",
|
||||||
@@ -255,7 +237,7 @@ const Page = () => {
|
|||||||
<ItemPoster item={item} />
|
<ItemPoster item={item} />
|
||||||
<ItemCardText item={item} />
|
<ItemCardText item={item} />
|
||||||
</View>
|
</View>
|
||||||
</TouchableItemRouter>
|
</MemoizedTouchableItemRouter>
|
||||||
),
|
),
|
||||||
[orientation]
|
[orientation]
|
||||||
);
|
);
|
||||||
@@ -440,7 +422,6 @@ const Page = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<FlashList
|
<FlashList
|
||||||
key={orientation}
|
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
<View className="flex flex-col items-center justify-center h-full">
|
<View className="flex flex-col items-center justify-center h-full">
|
||||||
<Text className="font-bold text-xl text-neutral-500">No results</Text>
|
<Text className="font-bold text-xl text-neutral-500">No results</Text>
|
||||||
@@ -449,10 +430,10 @@ const Page = () => {
|
|||||||
contentInsetAdjustmentBehavior="automatic"
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
data={flatData}
|
data={flatData}
|
||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
extraData={[orientation, nrOfCols]}
|
extraData={orientation}
|
||||||
keyExtractor={keyExtractor}
|
keyExtractor={keyExtractor}
|
||||||
estimatedItemSize={244}
|
estimatedItemSize={244}
|
||||||
numColumns={nrOfCols}
|
numColumns={getNumberOfColumns()}
|
||||||
onEndReached={() => {
|
onEndReached={() => {
|
||||||
if (hasNextPage) {
|
if (hasNextPage) {
|
||||||
fetchNextPage();
|
fetchNextPage();
|
||||||
|
|||||||
@@ -195,16 +195,6 @@ export default function IndexLayout() {
|
|||||||
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||||
<Stack.Screen key={name} name={name} options={options} />
|
<Stack.Screen key={name} name={name} options={options} />
|
||||||
))}
|
))}
|
||||||
<Stack.Screen
|
|
||||||
name="collections/[collectionId]"
|
|
||||||
options={{
|
|
||||||
title: "",
|
|
||||||
headerShown: true,
|
|
||||||
headerBlurEffect: "prominent",
|
|
||||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
|
||||||
headerShadowVisible: false,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,16 +19,6 @@ export default function SearchLayout() {
|
|||||||
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||||
<Stack.Screen key={name} name={name} options={options} />
|
<Stack.Screen key={name} name={name} options={options} />
|
||||||
))}
|
))}
|
||||||
<Stack.Screen
|
|
||||||
name="collections/[collectionId]"
|
|
||||||
options={{
|
|
||||||
title: "",
|
|
||||||
headerShown: true,
|
|
||||||
headerBlurEffect: "prominent",
|
|
||||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
|
||||||
headerShadowVisible: false,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
|
||||||
import { Input } from "@/components/common/Input";
|
import { Input } from "@/components/common/Input";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
@@ -70,43 +71,37 @@ export default function search() {
|
|||||||
types: BaseItemKind[];
|
types: BaseItemKind[];
|
||||||
query: string;
|
query: string;
|
||||||
}): Promise<BaseItemDto[]> => {
|
}): Promise<BaseItemDto[]> => {
|
||||||
if (!api || !query) return [];
|
if (!api) return [];
|
||||||
|
|
||||||
try {
|
if (searchEngine === "Jellyfin") {
|
||||||
if (searchEngine === "Jellyfin") {
|
const searchApi = await getSearchApi(api).getSearchHints({
|
||||||
const searchApi = await getSearchApi(api).getSearchHints({
|
searchTerm: query,
|
||||||
searchTerm: query,
|
limit: 10,
|
||||||
limit: 10,
|
includeItemTypes: types,
|
||||||
includeItemTypes: types,
|
});
|
||||||
});
|
|
||||||
|
|
||||||
return (searchApi.data.SearchHints as BaseItemDto[]) || [];
|
return searchApi.data.SearchHints as BaseItemDto[];
|
||||||
} else {
|
} else {
|
||||||
if (!settings?.marlinServerUrl) return [];
|
const url = `${settings?.marlinServerUrl}/search?q=${encodeURIComponent(
|
||||||
const url = `${
|
query
|
||||||
settings.marlinServerUrl
|
)}&includeItemTypes=${types
|
||||||
}/search?q=${encodeURIComponent(query)}&includeItemTypes=${types
|
.map((type) => encodeURIComponent(type))
|
||||||
.map((type) => encodeURIComponent(type))
|
.join("&includeItemTypes=")}`;
|
||||||
.join("&includeItemTypes=")}`;
|
|
||||||
|
|
||||||
const response1 = await axios.get(url);
|
const response1 = await axios.get(url);
|
||||||
const ids = response1.data.ids;
|
const ids = response1.data.ids;
|
||||||
|
|
||||||
if (!ids || !ids.length) return [];
|
if (!ids || !ids.length) return [];
|
||||||
|
|
||||||
const response2 = await getItemsApi(api).getItems({
|
const response2 = await getItemsApi(api).getItems({
|
||||||
ids,
|
ids,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
});
|
});
|
||||||
|
|
||||||
return (response2.data.Items as BaseItemDto[]) || [];
|
return response2.data.Items as BaseItemDto[];
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error during search:", error);
|
|
||||||
return []; // Ensure an empty array is returned in case of an error
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[api, searchEngine, settings]
|
[api, settings]
|
||||||
);
|
);
|
||||||
|
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
@@ -232,7 +227,7 @@ export default function search() {
|
|||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className="flex flex-col pt-2">
|
<View className="flex flex-col pt-4 pb-32">
|
||||||
{Platform.OS === "android" && (
|
{Platform.OS === "android" && (
|
||||||
<View className="mb-4 px-4">
|
<View className="mb-4 px-4">
|
||||||
<Input
|
<Input
|
||||||
@@ -255,125 +250,165 @@ export default function search() {
|
|||||||
<SearchItemWrapper
|
<SearchItemWrapper
|
||||||
header="Movies"
|
header="Movies"
|
||||||
ids={movies?.map((m) => m.Id!)}
|
ids={movies?.map((m) => m.Id!)}
|
||||||
renderItem={(item) => (
|
renderItem={(data) => (
|
||||||
<TouchableItemRouter
|
<HorizontalScroll
|
||||||
key={item.Id}
|
data={data}
|
||||||
className="flex flex-col w-28 mr-2"
|
renderItem={(item) => (
|
||||||
item={item}
|
<TouchableItemRouter
|
||||||
>
|
key={item.Id}
|
||||||
<MoviePoster item={item} key={item.Id} />
|
className="flex flex-col w-28"
|
||||||
<Text numberOfLines={2} className="mt-2">
|
item={item}
|
||||||
{item.Name}
|
>
|
||||||
</Text>
|
<MoviePoster item={item} key={item.Id} />
|
||||||
<Text className="opacity-50 text-xs">
|
<Text numberOfLines={2} className="mt-2">
|
||||||
{item.ProductionYear}
|
{item.Name}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableItemRouter>
|
<Text className="opacity-50 text-xs">
|
||||||
|
{item.ProductionYear}
|
||||||
|
</Text>
|
||||||
|
</TouchableItemRouter>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<SearchItemWrapper
|
<SearchItemWrapper
|
||||||
ids={series?.map((m) => m.Id!)}
|
ids={series?.map((m) => m.Id!)}
|
||||||
header="Series"
|
header="Series"
|
||||||
renderItem={(item) => (
|
renderItem={(data) => (
|
||||||
<TouchableItemRouter
|
<HorizontalScroll
|
||||||
key={item.Id}
|
data={data}
|
||||||
item={item}
|
renderItem={(item) => (
|
||||||
className="flex flex-col w-28 mr-2"
|
<TouchableOpacity
|
||||||
>
|
key={item.Id}
|
||||||
<SeriesPoster item={item} key={item.Id} />
|
onPress={() => router.push(`/series/${item.Id}`)}
|
||||||
<Text numberOfLines={2} className="mt-2">
|
className="flex flex-col w-28"
|
||||||
{item.Name}
|
>
|
||||||
</Text>
|
<SeriesPoster item={item} key={item.Id} />
|
||||||
<Text className="opacity-50 text-xs">
|
<Text numberOfLines={2} className="mt-2">
|
||||||
{item.ProductionYear}
|
{item.Name}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableItemRouter>
|
<Text className="opacity-50 text-xs">
|
||||||
|
{item.ProductionYear}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<SearchItemWrapper
|
<SearchItemWrapper
|
||||||
ids={episodes?.map((m) => m.Id!)}
|
ids={episodes?.map((m) => m.Id!)}
|
||||||
header="Episodes"
|
header="Episodes"
|
||||||
renderItem={(item) => (
|
renderItem={(data) => (
|
||||||
<TouchableItemRouter
|
<HorizontalScroll
|
||||||
item={item}
|
data={data}
|
||||||
key={item.Id}
|
renderItem={(item) => (
|
||||||
className="flex flex-col w-44 mr-2"
|
<TouchableOpacity
|
||||||
>
|
key={item.Id}
|
||||||
<ContinueWatchingPoster item={item} />
|
onPress={() => router.push(`/items/page?id=${item.Id}`)}
|
||||||
<ItemCardText item={item} />
|
className="flex flex-col w-44"
|
||||||
</TouchableItemRouter>
|
>
|
||||||
|
<ContinueWatchingPoster item={item} />
|
||||||
|
<ItemCardText item={item} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<SearchItemWrapper
|
<SearchItemWrapper
|
||||||
ids={collections?.map((m) => m.Id!)}
|
ids={collections?.map((m) => m.Id!)}
|
||||||
header="Collections"
|
header="Collections"
|
||||||
renderItem={(item) => (
|
renderItem={(data) => (
|
||||||
<TouchableItemRouter
|
<HorizontalScroll
|
||||||
key={item.Id}
|
data={data}
|
||||||
item={item}
|
renderItem={(item) => (
|
||||||
className="flex flex-col w-28 mr-2"
|
<TouchableOpacity
|
||||||
>
|
key={item.Id}
|
||||||
<MoviePoster item={item} key={item.Id} />
|
className="flex flex-col w-28"
|
||||||
<Text numberOfLines={2} className="mt-2">
|
onPress={() => router.push(`/collections/${item.Id}`)}
|
||||||
{item.Name}
|
>
|
||||||
</Text>
|
<MoviePoster item={item} key={item.Id} />
|
||||||
</TouchableItemRouter>
|
<Text numberOfLines={2} className="mt-2">
|
||||||
|
{item.Name}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<SearchItemWrapper
|
<SearchItemWrapper
|
||||||
ids={actors?.map((m) => m.Id!)}
|
ids={actors?.map((m) => m.Id!)}
|
||||||
header="Actors"
|
header="Actors"
|
||||||
renderItem={(item) => (
|
renderItem={(data) => (
|
||||||
<TouchableItemRouter
|
<HorizontalScroll
|
||||||
item={item}
|
data={data}
|
||||||
key={item.Id}
|
renderItem={(item) => (
|
||||||
className="flex flex-col w-28 mr-2"
|
<TouchableItemRouter
|
||||||
>
|
item={item}
|
||||||
<MoviePoster item={item} />
|
key={item.Id}
|
||||||
<ItemCardText item={item} />
|
className="flex flex-col w-28"
|
||||||
</TouchableItemRouter>
|
>
|
||||||
|
<MoviePoster item={item} />
|
||||||
|
<ItemCardText item={item} />
|
||||||
|
</TouchableItemRouter>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<SearchItemWrapper
|
<SearchItemWrapper
|
||||||
ids={artists?.map((m) => m.Id!)}
|
ids={artists?.map((m) => m.Id!)}
|
||||||
header="Artists"
|
header="Artists"
|
||||||
renderItem={(item) => (
|
renderItem={(data) => (
|
||||||
<TouchableItemRouter
|
<HorizontalScroll
|
||||||
item={item}
|
data={data}
|
||||||
key={item.Id}
|
renderItem={(item) => (
|
||||||
className="flex flex-col w-28 mr-2"
|
<TouchableItemRouter
|
||||||
>
|
item={item}
|
||||||
<AlbumCover id={item.Id} />
|
key={item.Id}
|
||||||
<ItemCardText item={item} />
|
className="flex flex-col w-28"
|
||||||
</TouchableItemRouter>
|
>
|
||||||
|
<AlbumCover id={item.Id} />
|
||||||
|
<ItemCardText item={item} />
|
||||||
|
</TouchableItemRouter>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<SearchItemWrapper
|
<SearchItemWrapper
|
||||||
ids={albums?.map((m) => m.Id!)}
|
ids={albums?.map((m) => m.Id!)}
|
||||||
header="Albums"
|
header="Albums"
|
||||||
renderItem={(item) => (
|
renderItem={(data) => (
|
||||||
<TouchableItemRouter
|
<HorizontalScroll
|
||||||
item={item}
|
data={data}
|
||||||
key={item.Id}
|
renderItem={(item) => (
|
||||||
className="flex flex-col w-28 mr-2"
|
<TouchableItemRouter
|
||||||
>
|
item={item}
|
||||||
<AlbumCover id={item.Id} />
|
key={item.Id}
|
||||||
<ItemCardText item={item} />
|
className="flex flex-col w-28"
|
||||||
</TouchableItemRouter>
|
>
|
||||||
|
<AlbumCover id={item.Id} />
|
||||||
|
<ItemCardText item={item} />
|
||||||
|
</TouchableItemRouter>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<SearchItemWrapper
|
<SearchItemWrapper
|
||||||
ids={songs?.map((m) => m.Id!)}
|
ids={songs?.map((m) => m.Id!)}
|
||||||
header="Songs"
|
header="Songs"
|
||||||
renderItem={(item) => (
|
renderItem={(data) => (
|
||||||
<TouchableItemRouter
|
<HorizontalScroll
|
||||||
item={item}
|
data={data}
|
||||||
key={item.Id}
|
renderItem={(item) => (
|
||||||
className="flex flex-col w-28 mr-2"
|
<TouchableItemRouter
|
||||||
>
|
item={item}
|
||||||
<AlbumCover id={item.AlbumId} />
|
key={item.Id}
|
||||||
<ItemCardText item={item} />
|
className="flex flex-col w-28"
|
||||||
</TouchableItemRouter>
|
>
|
||||||
|
<AlbumCover id={item.AlbumId} />
|
||||||
|
<ItemCardText item={item} />
|
||||||
|
</TouchableItemRouter>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -410,7 +445,7 @@ export default function search() {
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
ids?: string[] | null;
|
ids?: string[] | null;
|
||||||
renderItem: (item: BaseItemDto) => React.ReactNode;
|
renderItem: (data: BaseItemDto[]) => React.ReactNode;
|
||||||
header?: string;
|
header?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -448,14 +483,8 @@ const SearchItemWrapper: React.FC<Props> = ({ ids, renderItem, header }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Text className="font-bold text-lg px-4 mb-2">{header}</Text>
|
<Text className="font-bold text-2xl px-4 my-2">{header}</Text>
|
||||||
<ScrollView
|
{renderItem(data)}
|
||||||
horizontal
|
|
||||||
className="px-4 mb-2"
|
|
||||||
showsHorizontalScrollIndicator={false}
|
|
||||||
>
|
|
||||||
{data.map((item) => renderItem(item))}
|
|
||||||
</ScrollView>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,91 +1,87 @@
|
|||||||
import React from "react";
|
import { TabBarIcon } from "@/components/navigation/TabBarIcon";
|
||||||
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 { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
import type {
|
import { BlurView } from "expo-blur";
|
||||||
ParamListBase,
|
import * as NavigationBar from "expo-navigation-bar";
|
||||||
TabNavigationState,
|
import { Tabs } from "expo-router";
|
||||||
} from "@react-navigation/native";
|
import React, { useEffect } from "react";
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
import { Platform, StyleSheet } from "react-native";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
|
|
||||||
export const NativeTabs = withLayoutContext<
|
|
||||||
BottomTabNavigationOptions,
|
|
||||||
typeof Navigator,
|
|
||||||
TabNavigationState<ParamListBase>,
|
|
||||||
NativeBottomTabNavigationEventMap
|
|
||||||
>(Navigator);
|
|
||||||
|
|
||||||
export default function TabLayout() {
|
export default function TabLayout() {
|
||||||
const [settings] = useSettings();
|
useEffect(() => {
|
||||||
|
if (Platform.OS === "android") {
|
||||||
|
NavigationBar.setBackgroundColorAsync("#121212");
|
||||||
|
NavigationBar.setBorderColorAsync("#121212");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Tabs
|
||||||
<SystemBars hidden={false} style="light" />
|
initialRouteName="home"
|
||||||
<NativeTabs
|
screenOptions={{
|
||||||
sidebarAdaptable
|
tabBarActiveTintColor: Colors.tabIconSelected,
|
||||||
ignoresTopSafeArea
|
headerShown: false,
|
||||||
barTintColor={Platform.OS === "android" ? "#121212" : undefined}
|
tabBarStyle: {
|
||||||
tabBarActiveTintColor={Colors.primary}
|
position: "absolute",
|
||||||
scrollEdgeAppearance="default"
|
borderTopLeftRadius: 0,
|
||||||
>
|
borderTopRightRadius: 0,
|
||||||
<NativeTabs.Screen redirect name="index" />
|
borderTopWidth: 0,
|
||||||
<NativeTabs.Screen
|
paddingTop: 8,
|
||||||
name="(home)"
|
paddingBottom: Platform.OS === "android" ? 8 : 26,
|
||||||
options={{
|
height: Platform.OS === "android" ? 58 : 74,
|
||||||
title: "Home",
|
},
|
||||||
tabBarIcon:
|
tabBarBackground: () =>
|
||||||
Platform.OS == "android"
|
Platform.OS === "ios" ? (
|
||||||
? ({ color, focused, size }) =>
|
<BlurView
|
||||||
require("@/assets/icons/house.fill.png")
|
experimentalBlurMethod="dimezisBlurView"
|
||||||
: () => ({ sfSymbol: "house" }),
|
intensity={95}
|
||||||
}}
|
style={{
|
||||||
/>
|
...StyleSheet.absoluteFillObject,
|
||||||
<NativeTabs.Screen
|
overflow: "hidden",
|
||||||
name="(search)"
|
borderTopLeftRadius: 0,
|
||||||
options={{
|
borderTopRightRadius: 0,
|
||||||
title: "Search",
|
backgroundColor: "black",
|
||||||
tabBarIcon:
|
}}
|
||||||
Platform.OS == "android"
|
/>
|
||||||
? ({ color, focused, size }) =>
|
) : undefined,
|
||||||
require("@/assets/icons/magnifyingglass.png")
|
}}
|
||||||
: () => ({ sfSymbol: "magnifyingglass" }),
|
>
|
||||||
}}
|
<Tabs.Screen redirect name="index" />
|
||||||
/>
|
<Tabs.Screen
|
||||||
<NativeTabs.Screen
|
name="(home)"
|
||||||
name="(libraries)"
|
options={{
|
||||||
options={{
|
headerShown: false,
|
||||||
title: "Library",
|
title: "Home",
|
||||||
tabBarIcon:
|
tabBarIcon: ({ color, focused }) => (
|
||||||
Platform.OS == "android"
|
<TabBarIcon
|
||||||
? ({ color, focused, size }) =>
|
name={focused ? "home" : "home-outline"}
|
||||||
require("@/assets/icons/server.rack.png")
|
color={color}
|
||||||
: () => ({ sfSymbol: "rectangle.stack" }),
|
/>
|
||||||
}}
|
),
|
||||||
/>
|
}}
|
||||||
<NativeTabs.Screen
|
/>
|
||||||
name="(custom-links)"
|
<Tabs.Screen
|
||||||
options={{
|
name="(search)"
|
||||||
title: "Custom Links",
|
options={{
|
||||||
// @ts-expect-error
|
headerShown: false,
|
||||||
tabBarItemHidden: settings?.showCustomMenuLinks ? false : true,
|
title: "Search",
|
||||||
tabBarIcon:
|
tabBarIcon: ({ color, focused }) => (
|
||||||
Platform.OS == "android"
|
<TabBarIcon name={focused ? "search" : "search"} color={color} />
|
||||||
? () => require("@/assets/icons/list.png")
|
),
|
||||||
: () => ({ sfSymbol: "list.dash" }),
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
<Tabs.Screen
|
||||||
</NativeTabs>
|
name="(libraries)"
|
||||||
</>
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
title: "Library",
|
||||||
|
tabBarIcon: ({ color, focused }) => (
|
||||||
|
<TabBarIcon
|
||||||
|
name={focused ? "apps" : "apps-outline"}
|
||||||
|
color={color}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tabs>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,534 +0,0 @@
|
|||||||
import { BITRATES } from "@/components/BitrateSelector";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { Controls } from "@/components/video-player/controls/Controls";
|
|
||||||
import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener";
|
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
|
||||||
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
|
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
|
||||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
|
||||||
import { VlcPlayerView } from "@/modules/vlc-player";
|
|
||||||
import {
|
|
||||||
PlaybackStatePayload,
|
|
||||||
ProgressUpdatePayload,
|
|
||||||
VlcPlayerViewRef,
|
|
||||||
} from "@/modules/vlc-player/src/VlcPlayer.types";
|
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
|
||||||
import { writeToLog } from "@/utils/log";
|
|
||||||
import native from "@/utils/profiles/native";
|
|
||||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
|
||||||
import { Api } from "@jellyfin/sdk";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import {
|
|
||||||
getPlaystateApi,
|
|
||||||
getUserLibraryApi,
|
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import * as Haptics from "expo-haptics";
|
|
||||||
import { useFocusEffect, useGlobalSearchParams } from "expo-router";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import React, {
|
|
||||||
useCallback,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
useEffect,
|
|
||||||
} from "react";
|
|
||||||
import {
|
|
||||||
Alert,
|
|
||||||
BackHandler,
|
|
||||||
View,
|
|
||||||
AppState,
|
|
||||||
AppStateStatus,
|
|
||||||
Platform,
|
|
||||||
} from "react-native";
|
|
||||||
import { useSharedValue } from "react-native-reanimated";
|
|
||||||
import settings from "../(tabs)/(home)/settings";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
|
|
||||||
export default function page() {
|
|
||||||
const videoRef = useRef<VlcPlayerViewRef>(null);
|
|
||||||
const user = useAtomValue(userAtom);
|
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
|
|
||||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
|
||||||
const [showControls, _setShowControls] = useState(true);
|
|
||||||
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
|
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
|
||||||
const [isBuffering, setIsBuffering] = useState(true);
|
|
||||||
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
|
||||||
|
|
||||||
const progress = useSharedValue(0);
|
|
||||||
const isSeeking = useSharedValue(false);
|
|
||||||
const cacheProgress = useSharedValue(0);
|
|
||||||
|
|
||||||
const { getDownloadedItem } = useDownload();
|
|
||||||
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
|
||||||
|
|
||||||
const setShowControls = useCallback((show: boolean) => {
|
|
||||||
_setShowControls(show);
|
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const {
|
|
||||||
itemId,
|
|
||||||
audioIndex: audioIndexStr,
|
|
||||||
subtitleIndex: subtitleIndexStr,
|
|
||||||
mediaSourceId,
|
|
||||||
bitrateValue: bitrateValueStr,
|
|
||||||
offline: offlineStr,
|
|
||||||
} = useGlobalSearchParams<{
|
|
||||||
itemId: string;
|
|
||||||
audioIndex: string;
|
|
||||||
subtitleIndex: string;
|
|
||||||
mediaSourceId: string;
|
|
||||||
bitrateValue: string;
|
|
||||||
offline: string;
|
|
||||||
}>();
|
|
||||||
const [settings] = useSettings();
|
|
||||||
const offline = offlineStr === "true";
|
|
||||||
|
|
||||||
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
|
|
||||||
const subtitleIndex = subtitleIndexStr ? parseInt(subtitleIndexStr, 10) : -1;
|
|
||||||
const bitrateValue = bitrateValueStr
|
|
||||||
? parseInt(bitrateValueStr, 10)
|
|
||||||
: BITRATES[0].value;
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: item,
|
|
||||||
isLoading: isLoadingItem,
|
|
||||||
isError: isErrorItem,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["item", itemId],
|
|
||||||
queryFn: async () => {
|
|
||||||
console.log("Offline:", offline);
|
|
||||||
if (offline) {
|
|
||||||
const item = await getDownloadedItem(itemId);
|
|
||||||
if (item) return item.item;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await getUserLibraryApi(api!).getItem({
|
|
||||||
itemId,
|
|
||||||
userId: user?.Id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.data;
|
|
||||||
},
|
|
||||||
enabled: !!itemId,
|
|
||||||
staleTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: stream,
|
|
||||||
isLoading: isLoadingStreamUrl,
|
|
||||||
isError: isErrorStreamUrl,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["stream-url", itemId, mediaSourceId, bitrateValue],
|
|
||||||
queryFn: async () => {
|
|
||||||
console.log("Offline:", offline);
|
|
||||||
if (offline) {
|
|
||||||
const data = await getDownloadedItem(itemId);
|
|
||||||
if (!data?.mediaSource) return null;
|
|
||||||
|
|
||||||
const url = await getDownloadedFileUrl(data.item.Id!);
|
|
||||||
|
|
||||||
if (item)
|
|
||||||
return {
|
|
||||||
mediaSource: data.mediaSource,
|
|
||||||
url,
|
|
||||||
sessionId: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await getStreamUrl({
|
|
||||||
api,
|
|
||||||
item,
|
|
||||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
|
||||||
userId: user?.Id,
|
|
||||||
audioStreamIndex: audioIndex,
|
|
||||||
maxStreamingBitrate: bitrateValue,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
subtitleStreamIndex: subtitleIndex,
|
|
||||||
deviceProfile: native,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res) return null;
|
|
||||||
|
|
||||||
const { mediaSource, sessionId, url } = res;
|
|
||||||
|
|
||||||
if (!sessionId || !mediaSource || !url) {
|
|
||||||
Alert.alert("Error", "Failed to get stream url");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
mediaSource,
|
|
||||||
sessionId,
|
|
||||||
url,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
enabled: !!itemId && !!item,
|
|
||||||
staleTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const togglePlay = useCallback(async () => {
|
|
||||||
if (!api) return;
|
|
||||||
|
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
if (isPlaying) {
|
|
||||||
await videoRef.current?.pause();
|
|
||||||
|
|
||||||
if (!offline && stream) {
|
|
||||||
await getPlaystateApi(api).onPlaybackProgress({
|
|
||||||
itemId: item?.Id!,
|
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
positionTicks: msToTicks(progress.value),
|
|
||||||
isPaused: true,
|
|
||||||
playMethod: stream.url?.includes("m3u8")
|
|
||||||
? "Transcode"
|
|
||||||
: "DirectStream",
|
|
||||||
playSessionId: stream.sessionId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("Actually marked as paused");
|
|
||||||
} else {
|
|
||||||
videoRef.current?.play();
|
|
||||||
if (!offline && stream) {
|
|
||||||
await getPlaystateApi(api).onPlaybackProgress({
|
|
||||||
itemId: item?.Id!,
|
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
positionTicks: msToTicks(progress.value),
|
|
||||||
isPaused: false,
|
|
||||||
playMethod: stream?.url.includes("m3u8")
|
|
||||||
? "Transcode"
|
|
||||||
: "DirectStream",
|
|
||||||
playSessionId: stream.sessionId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
isPlaying,
|
|
||||||
api,
|
|
||||||
item,
|
|
||||||
stream,
|
|
||||||
videoRef,
|
|
||||||
audioIndex,
|
|
||||||
subtitleIndex,
|
|
||||||
mediaSourceId,
|
|
||||||
offline,
|
|
||||||
progress.value,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const reportPlaybackStopped = useCallback(async () => {
|
|
||||||
if (offline) return;
|
|
||||||
|
|
||||||
const currentTimeInTicks = msToTicks(progress.value);
|
|
||||||
|
|
||||||
await getPlaystateApi(api!).onPlaybackStopped({
|
|
||||||
itemId: item?.Id!,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
positionTicks: currentTimeInTicks,
|
|
||||||
playSessionId: stream?.sessionId!,
|
|
||||||
});
|
|
||||||
|
|
||||||
revalidateProgressCache();
|
|
||||||
}, [api, item, mediaSourceId, stream]);
|
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
|
||||||
reportPlaybackStopped();
|
|
||||||
setIsPlaybackStopped(true);
|
|
||||||
videoRef.current?.stop();
|
|
||||||
}, [videoRef, reportPlaybackStopped]);
|
|
||||||
|
|
||||||
// TODO: unused should remove.
|
|
||||||
const reportPlaybackStart = useCallback(async () => {
|
|
||||||
if (offline) return;
|
|
||||||
|
|
||||||
if (!stream) return;
|
|
||||||
await getPlaystateApi(api!).onPlaybackStart({
|
|
||||||
itemId: item?.Id!,
|
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
playMethod: stream.url?.includes("m3u8") ? "Transcode" : "DirectStream",
|
|
||||||
playSessionId: stream?.sessionId ? stream?.sessionId : undefined,
|
|
||||||
});
|
|
||||||
}, [api, item, mediaSourceId, stream]);
|
|
||||||
|
|
||||||
const onProgress = useCallback(
|
|
||||||
async (data: ProgressUpdatePayload) => {
|
|
||||||
if (isSeeking.value === true) return;
|
|
||||||
if (isPlaybackStopped === true) return;
|
|
||||||
|
|
||||||
const { currentTime } = data.nativeEvent;
|
|
||||||
|
|
||||||
if (isBuffering) {
|
|
||||||
setIsBuffering(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
progress.value = currentTime;
|
|
||||||
|
|
||||||
if (offline) return;
|
|
||||||
|
|
||||||
const currentTimeInTicks = msToTicks(currentTime);
|
|
||||||
|
|
||||||
if (!item?.Id || !stream) return;
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
"onProgress ~",
|
|
||||||
currentTimeInTicks,
|
|
||||||
isPlaying,
|
|
||||||
`AUDIO index: ${audioIndex} SUB index" ${subtitleIndex}`
|
|
||||||
);
|
|
||||||
|
|
||||||
await getPlaystateApi(api!).onPlaybackProgress({
|
|
||||||
itemId: item.Id,
|
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
positionTicks: Math.floor(currentTimeInTicks),
|
|
||||||
isPaused: !isPlaying,
|
|
||||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
|
||||||
playSessionId: stream.sessionId,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[item?.Id, isPlaying, api, isPlaybackStopped, audioIndex, subtitleIndex]
|
|
||||||
);
|
|
||||||
|
|
||||||
useOrientation();
|
|
||||||
useOrientationSettings();
|
|
||||||
|
|
||||||
useWebSocket({
|
|
||||||
isPlaying: isPlaying,
|
|
||||||
togglePlay: togglePlay,
|
|
||||||
stopPlayback: stop,
|
|
||||||
offline,
|
|
||||||
});
|
|
||||||
|
|
||||||
const onPlaybackStateChanged = useCallback((e: PlaybackStatePayload) => {
|
|
||||||
const { state, isBuffering, isPlaying } = e.nativeEvent;
|
|
||||||
|
|
||||||
if (state === "Playing") {
|
|
||||||
setIsPlaying(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state === "Paused") {
|
|
||||||
setIsPlaying(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isPlaying) {
|
|
||||||
setIsPlaying(true);
|
|
||||||
setIsBuffering(false);
|
|
||||||
} else if (isBuffering) {
|
|
||||||
setIsBuffering(true);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const startPosition = useMemo(() => {
|
|
||||||
if (offline) return 0;
|
|
||||||
|
|
||||||
return item?.UserData?.PlaybackPositionTicks
|
|
||||||
? ticksToSeconds(item.UserData.PlaybackPositionTicks)
|
|
||||||
: 0;
|
|
||||||
}, [item]);
|
|
||||||
|
|
||||||
useFocusEffect(
|
|
||||||
React.useCallback(() => {
|
|
||||||
return async () => {
|
|
||||||
stop();
|
|
||||||
console.log("Unmounted");
|
|
||||||
};
|
|
||||||
}, [])
|
|
||||||
);
|
|
||||||
|
|
||||||
const [appState, setAppState] = useState(AppState.currentState);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleAppStateChange = (nextAppState: AppStateStatus) => {
|
|
||||||
if (appState.match(/inactive|background/) && nextAppState === "active") {
|
|
||||||
console.log("App has come to the foreground!");
|
|
||||||
// Handle app coming to the foreground
|
|
||||||
} else if (nextAppState.match(/inactive|background/)) {
|
|
||||||
console.log("App has gone to the background!");
|
|
||||||
// Handle app going to the background
|
|
||||||
if (videoRef.current && videoRef.current.pause) {
|
|
||||||
videoRef.current.pause();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setAppState(nextAppState);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Use AppState.addEventListener and return a cleanup function
|
|
||||||
const subscription = AppState.addEventListener(
|
|
||||||
"change",
|
|
||||||
handleAppStateChange
|
|
||||||
);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
// Cleanup the event listener when the component is unmounted
|
|
||||||
subscription.remove();
|
|
||||||
};
|
|
||||||
}, [appState]);
|
|
||||||
|
|
||||||
// Preselection of audio and subtitle tracks.
|
|
||||||
|
|
||||||
if (!settings) return null;
|
|
||||||
|
|
||||||
let initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
|
|
||||||
let externalTrack = { name: "", DeliveryUrl: "" };
|
|
||||||
|
|
||||||
const allSubs =
|
|
||||||
stream?.mediaSource.MediaStreams?.filter(
|
|
||||||
(sub) => sub.Type === "Subtitle"
|
|
||||||
) || [];
|
|
||||||
const chosenSubtitleTrack = allSubs.find(
|
|
||||||
(sub) => sub.Index === subtitleIndex
|
|
||||||
);
|
|
||||||
const allAudio =
|
|
||||||
stream?.mediaSource.MediaStreams?.filter(
|
|
||||||
(audio) => audio.Type === "Audio"
|
|
||||||
) || [];
|
|
||||||
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
|
|
||||||
|
|
||||||
// Direct playback CASE
|
|
||||||
if (!bitrateValue) {
|
|
||||||
// If Subtitle is embedded we can use the position to select it straight away.
|
|
||||||
if (chosenSubtitleTrack && !chosenSubtitleTrack.DeliveryUrl) {
|
|
||||||
initOptions.push(`--sub-track=${allSubs.indexOf(chosenSubtitleTrack)}`);
|
|
||||||
} else if (chosenSubtitleTrack && chosenSubtitleTrack.DeliveryUrl) {
|
|
||||||
// If Subtitle is external we need to pass the URL to the player.
|
|
||||||
externalTrack = {
|
|
||||||
name: chosenSubtitleTrack.DisplayTitle || "",
|
|
||||||
DeliveryUrl: `${api?.basePath || ""}${chosenSubtitleTrack.DeliveryUrl}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chosenAudioTrack)
|
|
||||||
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
|
|
||||||
} else {
|
|
||||||
// Transcoded playback CASE
|
|
||||||
if (chosenSubtitleTrack?.DeliveryMethod === "Hls") {
|
|
||||||
externalTrack = {
|
|
||||||
name: `subs ${chosenSubtitleTrack.DisplayTitle}`,
|
|
||||||
DeliveryUrl: "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!item || isLoadingItem || isLoadingStreamUrl || !stream)
|
|
||||||
return (
|
|
||||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isErrorItem || isErrorStreamUrl)
|
|
||||||
return (
|
|
||||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
|
||||||
<Text className="text-white">Error</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={{ flex: 1, backgroundColor: "black" }}>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
position: "relative",
|
|
||||||
flexDirection: "column",
|
|
||||||
justifyContent: "center",
|
|
||||||
opacity: showControls ? (Platform.OS === "android" ? 0.7 : 0.5) : 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<VlcPlayerView
|
|
||||||
ref={videoRef}
|
|
||||||
source={{
|
|
||||||
uri: stream.url,
|
|
||||||
autoplay: true,
|
|
||||||
isNetwork: true,
|
|
||||||
startPosition,
|
|
||||||
externalTrack,
|
|
||||||
initOptions,
|
|
||||||
}}
|
|
||||||
style={{ width: "100%", height: "100%" }}
|
|
||||||
onVideoProgress={onProgress}
|
|
||||||
progressUpdateInterval={1000}
|
|
||||||
onVideoStateChange={onPlaybackStateChanged}
|
|
||||||
onVideoLoadStart={() => {}}
|
|
||||||
onVideoLoadEnd={() => {
|
|
||||||
setIsVideoLoaded(true);
|
|
||||||
}}
|
|
||||||
onVideoError={(e) => {
|
|
||||||
console.error("Video Error:", e.nativeEvent);
|
|
||||||
Alert.alert(
|
|
||||||
"Error",
|
|
||||||
"An error occurred while playing the video. Check logs in settings."
|
|
||||||
);
|
|
||||||
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
{videoRef.current && (
|
|
||||||
<Controls
|
|
||||||
mediaSource={stream?.mediaSource}
|
|
||||||
item={item}
|
|
||||||
videoRef={videoRef}
|
|
||||||
togglePlay={togglePlay}
|
|
||||||
isPlaying={isPlaying}
|
|
||||||
isSeeking={isSeeking}
|
|
||||||
progress={progress}
|
|
||||||
cacheProgress={cacheProgress}
|
|
||||||
isBuffering={isBuffering}
|
|
||||||
showControls={showControls}
|
|
||||||
setShowControls={setShowControls}
|
|
||||||
setIgnoreSafeAreas={setIgnoreSafeAreas}
|
|
||||||
ignoreSafeAreas={ignoreSafeAreas}
|
|
||||||
isVideoLoaded={isVideoLoaded}
|
|
||||||
play={videoRef.current?.play}
|
|
||||||
pause={videoRef.current?.pause}
|
|
||||||
seek={videoRef.current?.seekTo}
|
|
||||||
enableTrickplay={true}
|
|
||||||
getAudioTracks={videoRef.current?.getAudioTracks}
|
|
||||||
getSubtitleTracks={videoRef.current?.getSubtitleTracks}
|
|
||||||
offline={offline}
|
|
||||||
setSubtitleTrack={videoRef.current.setSubtitleTrack}
|
|
||||||
setSubtitleURL={videoRef.current.setSubtitleURL}
|
|
||||||
setAudioTrack={videoRef.current.setAudioTrack}
|
|
||||||
stop={stop}
|
|
||||||
isVlc
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function usePoster(
|
|
||||||
item: BaseItemDto,
|
|
||||||
api: Api | null
|
|
||||||
): string | undefined {
|
|
||||||
const poster = useMemo(() => {
|
|
||||||
if (!item || !api) return undefined;
|
|
||||||
return item.Type === "Audio"
|
|
||||||
? `${api.basePath}/Items/${item.AlbumId}/Images/Primary?tag=${item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
|
|
||||||
: getBackdropUrl({
|
|
||||||
api,
|
|
||||||
item: item,
|
|
||||||
quality: 70,
|
|
||||||
width: 200,
|
|
||||||
});
|
|
||||||
}, [item, api]);
|
|
||||||
|
|
||||||
return poster ?? undefined;
|
|
||||||
}
|
|
||||||
@@ -1,420 +0,0 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { Controls } from "@/components/video-player/controls/Controls";
|
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
|
||||||
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
|
|
||||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
|
||||||
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
|
||||||
import { secondsToTicks } from "@/utils/secondsToTicks";
|
|
||||||
import { Api } from "@jellyfin/sdk";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import {
|
|
||||||
getPlaystateApi,
|
|
||||||
getUserLibraryApi,
|
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import * as Haptics from "expo-haptics";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { useFocusEffect, useLocalSearchParams } from "expo-router";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
|
||||||
import { Pressable, useWindowDimensions, View } from "react-native";
|
|
||||||
import { useSharedValue } from "react-native-reanimated";
|
|
||||||
import Video, { OnProgressData, VideoRef } from "react-native-video";
|
|
||||||
|
|
||||||
export default function page() {
|
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const user = useAtomValue(userAtom);
|
|
||||||
const [settings] = useSettings();
|
|
||||||
const videoRef = useRef<VideoRef | null>(null);
|
|
||||||
const windowDimensions = useWindowDimensions();
|
|
||||||
|
|
||||||
const firstTime = useRef(true);
|
|
||||||
|
|
||||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
|
||||||
const [showControls, setShowControls] = useState(true);
|
|
||||||
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
|
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
|
||||||
const [isBuffering, setIsBuffering] = useState(true);
|
|
||||||
|
|
||||||
const progress = useSharedValue(0);
|
|
||||||
const isSeeking = useSharedValue(false);
|
|
||||||
const cacheProgress = useSharedValue(0);
|
|
||||||
|
|
||||||
const {
|
|
||||||
itemId,
|
|
||||||
audioIndex: audioIndexStr,
|
|
||||||
subtitleIndex: subtitleIndexStr,
|
|
||||||
mediaSourceId,
|
|
||||||
bitrateValue: bitrateValueStr,
|
|
||||||
} = useLocalSearchParams<{
|
|
||||||
itemId: string;
|
|
||||||
audioIndex: string;
|
|
||||||
subtitleIndex: string;
|
|
||||||
mediaSourceId: string;
|
|
||||||
bitrateValue: string;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
|
|
||||||
const subtitleIndex = subtitleIndexStr
|
|
||||||
? parseInt(subtitleIndexStr, 10)
|
|
||||||
: undefined;
|
|
||||||
const bitrateValue = bitrateValueStr
|
|
||||||
? parseInt(bitrateValueStr, 10)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: item,
|
|
||||||
isLoading: isLoadingItem,
|
|
||||||
isError: isErrorItem,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["item", itemId],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api) return;
|
|
||||||
const res = await getUserLibraryApi(api).getItem({
|
|
||||||
itemId,
|
|
||||||
userId: user?.Id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.data;
|
|
||||||
},
|
|
||||||
enabled: !!itemId && !!api,
|
|
||||||
staleTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: stream,
|
|
||||||
isLoading: isLoadingStreamUrl,
|
|
||||||
isError: isErrorStreamUrl,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["stream-url"],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api) return;
|
|
||||||
const res = await getStreamUrl({
|
|
||||||
api,
|
|
||||||
item,
|
|
||||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
|
||||||
userId: user?.Id,
|
|
||||||
audioStreamIndex: audioIndex,
|
|
||||||
maxStreamingBitrate: bitrateValue,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
subtitleStreamIndex: subtitleIndex,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res) return null;
|
|
||||||
|
|
||||||
const { mediaSource, sessionId, url } = res;
|
|
||||||
|
|
||||||
if (!sessionId || !mediaSource || !url) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
mediaSource,
|
|
||||||
sessionId,
|
|
||||||
url,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const poster = usePoster(item, api);
|
|
||||||
const videoSource = useVideoSource(item, api, poster, stream?.url);
|
|
||||||
|
|
||||||
const togglePlay = useCallback(
|
|
||||||
async (ticks: number) => {
|
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
if (isPlaying) {
|
|
||||||
videoRef.current?.pause();
|
|
||||||
await getPlaystateApi(api!).onPlaybackProgress({
|
|
||||||
itemId: item?.Id!,
|
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
positionTicks: Math.floor(ticks),
|
|
||||||
isPaused: true,
|
|
||||||
playMethod: stream?.url.includes("m3u8")
|
|
||||||
? "Transcode"
|
|
||||||
: "DirectStream",
|
|
||||||
playSessionId: stream?.sessionId,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
videoRef.current?.resume();
|
|
||||||
await getPlaystateApi(api!).onPlaybackProgress({
|
|
||||||
itemId: item?.Id!,
|
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
positionTicks: Math.floor(ticks),
|
|
||||||
isPaused: false,
|
|
||||||
playMethod: stream?.url.includes("m3u8")
|
|
||||||
? "Transcode"
|
|
||||||
: "DirectStream",
|
|
||||||
playSessionId: stream?.sessionId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[
|
|
||||||
isPlaying,
|
|
||||||
api,
|
|
||||||
item,
|
|
||||||
videoRef,
|
|
||||||
settings,
|
|
||||||
audioIndex,
|
|
||||||
subtitleIndex,
|
|
||||||
mediaSourceId,
|
|
||||||
stream,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
const play = useCallback(() => {
|
|
||||||
console.log("play");
|
|
||||||
videoRef.current?.resume();
|
|
||||||
reportPlaybackStart();
|
|
||||||
}, [videoRef]);
|
|
||||||
|
|
||||||
const pause = useCallback(() => {
|
|
||||||
console.log("play");
|
|
||||||
videoRef.current?.pause();
|
|
||||||
}, [videoRef]);
|
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
|
||||||
console.log("stop");
|
|
||||||
setIsPlaybackStopped(true);
|
|
||||||
videoRef.current?.pause();
|
|
||||||
reportPlaybackStopped();
|
|
||||||
}, [videoRef]);
|
|
||||||
|
|
||||||
const seek = useCallback(
|
|
||||||
(seconds: number) => {
|
|
||||||
videoRef.current?.seek(seconds);
|
|
||||||
},
|
|
||||||
[videoRef]
|
|
||||||
);
|
|
||||||
|
|
||||||
const reportPlaybackStopped = async () => {
|
|
||||||
if (!item?.Id) return;
|
|
||||||
await getPlaystateApi(api!).onPlaybackStopped({
|
|
||||||
itemId: item.Id,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
positionTicks: Math.floor(progress.value),
|
|
||||||
playSessionId: stream?.sessionId,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const reportPlaybackStart = async () => {
|
|
||||||
if (!item?.Id) return;
|
|
||||||
await getPlaystateApi(api!).onPlaybackStart({
|
|
||||||
itemId: item?.Id,
|
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
|
||||||
playSessionId: stream?.sessionId,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const onProgress = useCallback(
|
|
||||||
async (data: OnProgressData) => {
|
|
||||||
if (isSeeking.value === true) return;
|
|
||||||
if (isPlaybackStopped === true) return;
|
|
||||||
|
|
||||||
const ticks = data.currentTime * 10000000;
|
|
||||||
|
|
||||||
progress.value = secondsToTicks(data.currentTime);
|
|
||||||
cacheProgress.value = secondsToTicks(data.playableDuration);
|
|
||||||
setIsBuffering(data.playableDuration === 0);
|
|
||||||
|
|
||||||
if (!item?.Id || data.currentTime === 0) return;
|
|
||||||
|
|
||||||
await getPlaystateApi(api!).onPlaybackProgress({
|
|
||||||
itemId: item.Id!,
|
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
positionTicks: Math.round(ticks),
|
|
||||||
isPaused: !isPlaying,
|
|
||||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
|
||||||
playSessionId: stream?.sessionId,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[
|
|
||||||
item,
|
|
||||||
isPlaying,
|
|
||||||
api,
|
|
||||||
isPlaybackStopped,
|
|
||||||
audioIndex,
|
|
||||||
subtitleIndex,
|
|
||||||
mediaSourceId,
|
|
||||||
stream,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
useFocusEffect(
|
|
||||||
useCallback(() => {
|
|
||||||
play();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
stop();
|
|
||||||
};
|
|
||||||
}, [play, stop])
|
|
||||||
);
|
|
||||||
|
|
||||||
useOrientation();
|
|
||||||
useOrientationSettings();
|
|
||||||
|
|
||||||
useWebSocket({
|
|
||||||
isPlaying: isPlaying,
|
|
||||||
pauseVideo: pause,
|
|
||||||
playVideo: play,
|
|
||||||
stopPlayback: stop,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isLoadingItem || isLoadingStreamUrl)
|
|
||||||
return (
|
|
||||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isErrorItem || isErrorStreamUrl)
|
|
||||||
return (
|
|
||||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
|
||||||
<Text className="text-white">Error</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!item || !stream)
|
|
||||||
return (
|
|
||||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
|
||||||
<Text className="text-white">Error</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: windowDimensions.width,
|
|
||||||
height: windowDimensions.height,
|
|
||||||
position: "relative",
|
|
||||||
}}
|
|
||||||
className="flex flex-col items-center justify-center"
|
|
||||||
>
|
|
||||||
<View className="h-screen w-screen top-0 left-0 flex flex-col items-center justify-center p-4 absolute z-0">
|
|
||||||
<Image
|
|
||||||
source={poster}
|
|
||||||
style={{ width: "100%", height: "100%", resizeMode: "contain" }}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Pressable
|
|
||||||
onPress={() => {
|
|
||||||
setShowControls(!showControls);
|
|
||||||
}}
|
|
||||||
className="absolute z-0 h-full w-full opacity-0"
|
|
||||||
>
|
|
||||||
{videoSource && (
|
|
||||||
<Video
|
|
||||||
ref={videoRef}
|
|
||||||
source={videoSource}
|
|
||||||
style={{ width: "100%", height: "100%" }}
|
|
||||||
resizeMode={ignoreSafeAreas ? "cover" : "contain"}
|
|
||||||
onProgress={onProgress}
|
|
||||||
onError={() => {}}
|
|
||||||
onLoad={() => {
|
|
||||||
if (firstTime.current === true) {
|
|
||||||
play();
|
|
||||||
firstTime.current = false;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
progressUpdateInterval={500}
|
|
||||||
playWhenInactive={true}
|
|
||||||
allowsExternalPlayback={true}
|
|
||||||
playInBackground={true}
|
|
||||||
pictureInPicture={true}
|
|
||||||
showNotificationControls={true}
|
|
||||||
ignoreSilentSwitch="ignore"
|
|
||||||
fullscreen={false}
|
|
||||||
onPlaybackStateChanged={(state) => {
|
|
||||||
setIsPlaying(state.isPlaying);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
<Controls
|
|
||||||
item={item}
|
|
||||||
videoRef={videoRef}
|
|
||||||
togglePlay={togglePlay}
|
|
||||||
isPlaying={isPlaying}
|
|
||||||
isSeeking={isSeeking}
|
|
||||||
progress={progress}
|
|
||||||
cacheProgress={cacheProgress}
|
|
||||||
isBuffering={isBuffering}
|
|
||||||
showControls={showControls}
|
|
||||||
setShowControls={setShowControls}
|
|
||||||
setIgnoreSafeAreas={setIgnoreSafeAreas}
|
|
||||||
ignoreSafeAreas={ignoreSafeAreas}
|
|
||||||
enableTrickplay={false}
|
|
||||||
pause={pause}
|
|
||||||
play={play}
|
|
||||||
seek={seek}
|
|
||||||
isVlc={false}
|
|
||||||
stop={stop}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function usePoster(
|
|
||||||
item: BaseItemDto | null | undefined,
|
|
||||||
api: Api | null
|
|
||||||
): string | undefined {
|
|
||||||
const poster = useMemo(() => {
|
|
||||||
if (!item || !api) return undefined;
|
|
||||||
return item.Type === "Audio"
|
|
||||||
? `${api.basePath}/Items/${item.AlbumId}/Images/Primary?tag=${item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
|
|
||||||
: getBackdropUrl({
|
|
||||||
api,
|
|
||||||
item: item,
|
|
||||||
quality: 70,
|
|
||||||
width: 200,
|
|
||||||
});
|
|
||||||
}, [item, api]);
|
|
||||||
|
|
||||||
return poster ?? undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useVideoSource(
|
|
||||||
item: BaseItemDto | null | undefined,
|
|
||||||
api: Api | null,
|
|
||||||
poster: string | undefined,
|
|
||||||
url?: string | null
|
|
||||||
) {
|
|
||||||
const videoSource = useMemo(() => {
|
|
||||||
if (!item || !api || !url) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const startPosition = item?.UserData?.PlaybackPositionTicks
|
|
||||||
? Math.round(item.UserData.PlaybackPositionTicks / 10000)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
uri: url,
|
|
||||||
isNetwork: true,
|
|
||||||
startPosition,
|
|
||||||
headers: getAuthHeaders(api),
|
|
||||||
metadata: {
|
|
||||||
artist: item?.AlbumArtist ?? undefined,
|
|
||||||
title: item?.Name || "Unknown",
|
|
||||||
description: item?.Overview ?? undefined,
|
|
||||||
imageUri: poster,
|
|
||||||
subtitle: item?.Album ?? undefined,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}, [item, api, poster]);
|
|
||||||
|
|
||||||
return videoSource;
|
|
||||||
}
|
|
||||||
@@ -1,560 +0,0 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { Controls } from "@/components/video-player/controls/Controls";
|
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
|
||||||
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
|
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
|
||||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
|
||||||
import { TrackInfo } from "@/modules/vlc-player";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
|
||||||
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
|
||||||
import transcoding from "@/utils/profiles/transcoding";
|
|
||||||
import { secondsToTicks } from "@/utils/secondsToTicks";
|
|
||||||
import { Api } from "@jellyfin/sdk";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import {
|
|
||||||
getPlaystateApi,
|
|
||||||
getUserLibraryApi,
|
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import * as Haptics from "expo-haptics";
|
|
||||||
import { useFocusEffect, useLocalSearchParams } from "expo-router";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import React, {
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { View } from "react-native";
|
|
||||||
import { useSharedValue } from "react-native-reanimated";
|
|
||||||
import Video, {
|
|
||||||
OnProgressData,
|
|
||||||
SelectedTrack,
|
|
||||||
SelectedTrackType,
|
|
||||||
VideoRef,
|
|
||||||
} from "react-native-video";
|
|
||||||
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
|
||||||
|
|
||||||
const Player = () => {
|
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const user = useAtomValue(userAtom);
|
|
||||||
const [settings] = useSettings();
|
|
||||||
const videoRef = useRef<VideoRef | null>(null);
|
|
||||||
|
|
||||||
const firstTime = useRef(true);
|
|
||||||
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
|
||||||
|
|
||||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
|
||||||
const [showControls, _setShowControls] = useState(true);
|
|
||||||
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
|
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
|
||||||
const [isBuffering, setIsBuffering] = useState(true);
|
|
||||||
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
|
||||||
|
|
||||||
const setShowControls = useCallback((show: boolean) => {
|
|
||||||
_setShowControls(show);
|
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const progress = useSharedValue(0);
|
|
||||||
const isSeeking = useSharedValue(false);
|
|
||||||
const cacheProgress = useSharedValue(0);
|
|
||||||
|
|
||||||
const {
|
|
||||||
itemId,
|
|
||||||
audioIndex: audioIndexStr,
|
|
||||||
subtitleIndex: subtitleIndexStr,
|
|
||||||
mediaSourceId,
|
|
||||||
bitrateValue: bitrateValueStr,
|
|
||||||
} = useLocalSearchParams<{
|
|
||||||
itemId: string;
|
|
||||||
audioIndex: string;
|
|
||||||
subtitleIndex: string;
|
|
||||||
mediaSourceId: string;
|
|
||||||
bitrateValue: string;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
|
|
||||||
const subtitleIndex = subtitleIndexStr
|
|
||||||
? parseInt(subtitleIndexStr, 10)
|
|
||||||
: undefined;
|
|
||||||
const bitrateValue = bitrateValueStr
|
|
||||||
? parseInt(bitrateValueStr, 10)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: item,
|
|
||||||
isLoading: isLoadingItem,
|
|
||||||
isError: isErrorItem,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["item", itemId],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api) {
|
|
||||||
throw new Error("No api");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!itemId) {
|
|
||||||
console.warn("No itemId");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await getUserLibraryApi(api).getItem({
|
|
||||||
itemId,
|
|
||||||
userId: user?.Id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.data;
|
|
||||||
},
|
|
||||||
staleTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
// TODO: NEED TO FIND A WAY TO FROM SWITCHING TO IMAGE BASED TO TEXT BASED SUBTITLES, THERE IS A BUG.
|
|
||||||
// MOST LIKELY LIKELY NEED A MASSIVE REFACTOR.
|
|
||||||
const {
|
|
||||||
data: stream,
|
|
||||||
isLoading: isLoadingStreamUrl,
|
|
||||||
isError: isErrorStreamUrl,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["stream-url", itemId, bitrateValue, mediaSourceId, audioIndex],
|
|
||||||
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api) {
|
|
||||||
throw new Error("No api");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!item) {
|
|
||||||
console.warn("No item", itemId, item);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await getStreamUrl({
|
|
||||||
api,
|
|
||||||
item,
|
|
||||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
|
||||||
userId: user?.Id,
|
|
||||||
audioStreamIndex: audioIndex,
|
|
||||||
maxStreamingBitrate: bitrateValue,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
subtitleStreamIndex: subtitleIndex,
|
|
||||||
deviceProfile: transcoding,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res) return null;
|
|
||||||
|
|
||||||
const { mediaSource, sessionId, url } = res;
|
|
||||||
|
|
||||||
if (!sessionId || !mediaSource || !url) {
|
|
||||||
console.warn("No sessionId or mediaSource or url", url);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
mediaSource,
|
|
||||||
sessionId,
|
|
||||||
url,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
enabled: !!item,
|
|
||||||
staleTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const poster = usePoster(item, api);
|
|
||||||
const videoSource = useVideoSource(item, api, poster, stream?.url);
|
|
||||||
|
|
||||||
const togglePlay = useCallback(async () => {
|
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
if (isPlaying) {
|
|
||||||
videoRef.current?.pause();
|
|
||||||
await getPlaystateApi(api!).onPlaybackProgress({
|
|
||||||
itemId: item?.Id!,
|
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
positionTicks: Math.floor(progress.value),
|
|
||||||
isPaused: true,
|
|
||||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
|
||||||
playSessionId: stream?.sessionId,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
videoRef.current?.resume();
|
|
||||||
await getPlaystateApi(api!).onPlaybackProgress({
|
|
||||||
itemId: item?.Id!,
|
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
positionTicks: Math.floor(progress.value),
|
|
||||||
isPaused: false,
|
|
||||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
|
||||||
playSessionId: stream?.sessionId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
isPlaying,
|
|
||||||
api,
|
|
||||||
item,
|
|
||||||
videoRef,
|
|
||||||
settings,
|
|
||||||
stream,
|
|
||||||
audioIndex,
|
|
||||||
subtitleIndex,
|
|
||||||
mediaSourceId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const play = useCallback(() => {
|
|
||||||
videoRef.current?.resume();
|
|
||||||
reportPlaybackStart();
|
|
||||||
}, [videoRef]);
|
|
||||||
|
|
||||||
const pause = useCallback(() => {
|
|
||||||
videoRef.current?.pause();
|
|
||||||
}, [videoRef]);
|
|
||||||
|
|
||||||
const seek = useCallback(
|
|
||||||
(seconds: number) => {
|
|
||||||
videoRef.current?.seek(seconds);
|
|
||||||
},
|
|
||||||
[videoRef]
|
|
||||||
);
|
|
||||||
|
|
||||||
const reportPlaybackStopped = async () => {
|
|
||||||
if (!item?.Id) return;
|
|
||||||
await getPlaystateApi(api!).onPlaybackStopped({
|
|
||||||
itemId: item.Id,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
positionTicks: Math.floor(progress.value),
|
|
||||||
playSessionId: stream?.sessionId,
|
|
||||||
});
|
|
||||||
revalidateProgressCache();
|
|
||||||
};
|
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
|
||||||
reportPlaybackStopped();
|
|
||||||
videoRef.current?.pause();
|
|
||||||
setIsPlaybackStopped(true);
|
|
||||||
}, [videoRef, reportPlaybackStopped]);
|
|
||||||
|
|
||||||
const reportPlaybackStart = async () => {
|
|
||||||
if (!item?.Id) return;
|
|
||||||
await getPlaystateApi(api!).onPlaybackStart({
|
|
||||||
itemId: item.Id,
|
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
|
||||||
playSessionId: stream?.sessionId,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const onProgress = useCallback(
|
|
||||||
async (data: OnProgressData) => {
|
|
||||||
if (isSeeking.value === true) return;
|
|
||||||
if (isPlaybackStopped === true) return;
|
|
||||||
|
|
||||||
const ticks = secondsToTicks(data.currentTime);
|
|
||||||
|
|
||||||
progress.value = ticks;
|
|
||||||
cacheProgress.value = secondsToTicks(data.playableDuration);
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
"onProgress ~",
|
|
||||||
ticks,
|
|
||||||
isPlaying,
|
|
||||||
`AUDIO index: ${audioIndex} SUB index" ${subtitleIndex}`
|
|
||||||
);
|
|
||||||
|
|
||||||
// TODO: Use this when streaming with HLS url, but NOT when direct playing
|
|
||||||
// TODO: since playable duration is always 0 then.
|
|
||||||
setIsBuffering(data.playableDuration === 0);
|
|
||||||
|
|
||||||
if (!item?.Id || data.currentTime === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await getPlaystateApi(api!).onPlaybackProgress({
|
|
||||||
itemId: item.Id,
|
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
positionTicks: Math.round(ticks),
|
|
||||||
isPaused: !isPlaying,
|
|
||||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
|
||||||
playSessionId: stream?.sessionId,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[
|
|
||||||
item,
|
|
||||||
isPlaying,
|
|
||||||
api,
|
|
||||||
isPlaybackStopped,
|
|
||||||
isSeeking,
|
|
||||||
stream,
|
|
||||||
mediaSourceId,
|
|
||||||
audioIndex,
|
|
||||||
subtitleIndex,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
useOrientation();
|
|
||||||
useOrientationSettings();
|
|
||||||
|
|
||||||
useWebSocket({
|
|
||||||
isPlaying: isPlaying,
|
|
||||||
togglePlay: togglePlay,
|
|
||||||
stopPlayback: stop,
|
|
||||||
offline: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [selectedTextTrack, setSelectedTextTrack] = useState<
|
|
||||||
SelectedTrack | undefined
|
|
||||||
>();
|
|
||||||
|
|
||||||
const [embededTextTracks, setEmbededTextTracks] = useState<
|
|
||||||
{
|
|
||||||
index: number;
|
|
||||||
language?: string | undefined;
|
|
||||||
selected?: boolean | undefined;
|
|
||||||
title?: string | undefined;
|
|
||||||
type: any;
|
|
||||||
}[]
|
|
||||||
>([]);
|
|
||||||
|
|
||||||
const [audioTracks, setAudioTracks] = useState<TrackInfo[]>([]);
|
|
||||||
const [selectedAudioTrack, setSelectedAudioTrack] = useState<
|
|
||||||
SelectedTrack | undefined
|
|
||||||
>(undefined);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedTextTrack === undefined) {
|
|
||||||
const subtitleHelper = new SubtitleHelper(
|
|
||||||
stream?.mediaSource.MediaStreams ?? []
|
|
||||||
);
|
|
||||||
const embeddedTrackIndex = subtitleHelper.getEmbeddedTrackIndex(
|
|
||||||
subtitleIndex!
|
|
||||||
);
|
|
||||||
|
|
||||||
// Most likely the subtitle is burned in.
|
|
||||||
if (embeddedTrackIndex === -1) return;
|
|
||||||
console.log(
|
|
||||||
"Setting selected text track",
|
|
||||||
subtitleIndex,
|
|
||||||
embeddedTrackIndex
|
|
||||||
);
|
|
||||||
setSelectedTextTrack({
|
|
||||||
type: SelectedTrackType.INDEX,
|
|
||||||
value: embeddedTrackIndex,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [embededTextTracks]);
|
|
||||||
|
|
||||||
const getAudioTracks = (): TrackInfo[] => {
|
|
||||||
return audioTracks.map((t) => ({
|
|
||||||
name: t.name,
|
|
||||||
index: t.index,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSubtitleTracks = (): TrackInfo[] => {
|
|
||||||
return embededTextTracks.map((t) => ({
|
|
||||||
name: t.title ?? "",
|
|
||||||
index: t.index,
|
|
||||||
language: t.language,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
useFocusEffect(
|
|
||||||
React.useCallback(() => {
|
|
||||||
return async () => {
|
|
||||||
stop();
|
|
||||||
};
|
|
||||||
}, [])
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isLoadingItem || isLoadingStreamUrl)
|
|
||||||
return (
|
|
||||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isErrorItem || isErrorStreamUrl)
|
|
||||||
return (
|
|
||||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
|
||||||
<Text className="text-white">Error</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={{ flex: 1, backgroundColor: "black" }}>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
position: "relative",
|
|
||||||
flexDirection: "column",
|
|
||||||
justifyContent: "center",
|
|
||||||
opacity: showControls ? 0.5 : 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{videoSource ? (
|
|
||||||
<>
|
|
||||||
<Video
|
|
||||||
ref={videoRef}
|
|
||||||
source={videoSource}
|
|
||||||
style={{
|
|
||||||
height: "100%",
|
|
||||||
width: "100%",
|
|
||||||
}}
|
|
||||||
resizeMode={ignoreSafeAreas ? "cover" : "contain"}
|
|
||||||
onProgress={onProgress}
|
|
||||||
onError={(e) => {
|
|
||||||
console.error("Error playing video", e);
|
|
||||||
}}
|
|
||||||
onLoad={() => {
|
|
||||||
if (firstTime.current === true) {
|
|
||||||
play();
|
|
||||||
firstTime.current = false;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
progressUpdateInterval={500}
|
|
||||||
playWhenInactive={true}
|
|
||||||
allowsExternalPlayback={true}
|
|
||||||
playInBackground={true}
|
|
||||||
pictureInPicture={true}
|
|
||||||
showNotificationControls={true}
|
|
||||||
ignoreSilentSwitch="ignore"
|
|
||||||
fullscreen={false}
|
|
||||||
onPlaybackStateChanged={(state) => {
|
|
||||||
if (isSeeking.value === false) setIsPlaying(state.isPlaying);
|
|
||||||
}}
|
|
||||||
onTextTracks={(data) => {
|
|
||||||
setEmbededTextTracks(data.textTracks as any);
|
|
||||||
}}
|
|
||||||
onBuffer={(e) => {
|
|
||||||
setIsBuffering(e.isBuffering);
|
|
||||||
}}
|
|
||||||
onAudioTracks={(e) => {
|
|
||||||
console.log("onAudioTracks: ", e.audioTracks);
|
|
||||||
setAudioTracks(
|
|
||||||
e.audioTracks.map((t) => ({
|
|
||||||
index: t.index,
|
|
||||||
name: t.title ?? "",
|
|
||||||
language: t.language,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
selectedTextTrack={selectedTextTrack}
|
|
||||||
selectedAudioTrack={selectedAudioTrack}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Text>No video source...</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{item && (
|
|
||||||
<Controls
|
|
||||||
mediaSource={stream?.mediaSource}
|
|
||||||
videoRef={videoRef}
|
|
||||||
enableTrickplay={true}
|
|
||||||
item={item}
|
|
||||||
togglePlay={togglePlay}
|
|
||||||
isPlaying={isPlaying}
|
|
||||||
isSeeking={isSeeking}
|
|
||||||
progress={progress}
|
|
||||||
cacheProgress={cacheProgress}
|
|
||||||
isBuffering={isBuffering}
|
|
||||||
showControls={showControls}
|
|
||||||
setShowControls={setShowControls}
|
|
||||||
setIgnoreSafeAreas={setIgnoreSafeAreas}
|
|
||||||
ignoreSafeAreas={ignoreSafeAreas}
|
|
||||||
seek={seek}
|
|
||||||
play={play}
|
|
||||||
pause={pause}
|
|
||||||
stop={stop}
|
|
||||||
getSubtitleTracks={getSubtitleTracks}
|
|
||||||
setSubtitleTrack={(i) => {
|
|
||||||
if (i === -1) {
|
|
||||||
setSelectedTextTrack({
|
|
||||||
type: SelectedTrackType.DISABLED,
|
|
||||||
value: undefined,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSelectedTextTrack({
|
|
||||||
type: SelectedTrackType.INDEX,
|
|
||||||
value: i,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
getAudioTracks={getAudioTracks}
|
|
||||||
setAudioTrack={(i) => {
|
|
||||||
console.log("setAudioTrack ~", i);
|
|
||||||
setSelectedAudioTrack({
|
|
||||||
type: SelectedTrackType.INDEX,
|
|
||||||
value: i,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export function usePoster(
|
|
||||||
item: BaseItemDto | null | undefined,
|
|
||||||
api: Api | null
|
|
||||||
): string | undefined {
|
|
||||||
const poster = useMemo(() => {
|
|
||||||
if (!item || !api) return undefined;
|
|
||||||
return item.Type === "Audio"
|
|
||||||
? `${api.basePath}/Items/${item.AlbumId}/Images/Primary?tag=${item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
|
|
||||||
: getBackdropUrl({
|
|
||||||
api,
|
|
||||||
item: item,
|
|
||||||
quality: 70,
|
|
||||||
width: 200,
|
|
||||||
});
|
|
||||||
}, [item, api]);
|
|
||||||
|
|
||||||
return poster ?? undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useVideoSource(
|
|
||||||
item: BaseItemDto | null | undefined,
|
|
||||||
api: Api | null,
|
|
||||||
poster: string | undefined,
|
|
||||||
url?: string | null
|
|
||||||
) {
|
|
||||||
const videoSource = useMemo(() => {
|
|
||||||
if (!item || !api || !url) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const startPosition = item?.UserData?.PlaybackPositionTicks
|
|
||||||
? Math.round(item.UserData.PlaybackPositionTicks / 10000)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
uri: url,
|
|
||||||
isNetwork: true,
|
|
||||||
startPosition,
|
|
||||||
headers: getAuthHeaders(api),
|
|
||||||
metadata: {
|
|
||||||
artist: item?.AlbumArtist ?? undefined,
|
|
||||||
title: item?.Name || "Unknown",
|
|
||||||
description: item?.Overview ?? undefined,
|
|
||||||
imageUri: poster,
|
|
||||||
subtitle: item?.Album ?? undefined,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}, [item, api, poster, url]);
|
|
||||||
|
|
||||||
return videoSource;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Player;
|
|
||||||
363
app/_layout.tsx
@@ -1,212 +1,27 @@
|
|||||||
import { DownloadProvider } from "@/providers/DownloadProvider";
|
import { CurrentlyPlayingBar } from "@/components/CurrentlyPlayingBar";
|
||||||
import {
|
import { JellyfinProvider } from "@/providers/JellyfinProvider";
|
||||||
getOrSetDeviceId,
|
|
||||||
getTokenFromStorage,
|
|
||||||
JellyfinProvider,
|
|
||||||
} from "@/providers/JellyfinProvider";
|
|
||||||
import { JobQueueProvider } from "@/providers/JobQueueProvider";
|
import { JobQueueProvider } from "@/providers/JobQueueProvider";
|
||||||
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
import { PlaybackProvider } from "@/providers/PlaybackProvider";
|
||||||
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { orientationAtom } from "@/utils/atoms/orientation";
|
|
||||||
import { Settings, useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks";
|
|
||||||
import { LogProvider, writeToLog } from "@/utils/log";
|
|
||||||
import { storage } from "@/utils/mmkv";
|
|
||||||
import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server";
|
|
||||||
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
||||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import {
|
|
||||||
checkForExistingDownloads,
|
|
||||||
completeHandler,
|
|
||||||
download,
|
|
||||||
} from "@kesha-antonov/react-native-background-downloader";
|
|
||||||
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
|
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
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 { useFonts } from "expo-font";
|
||||||
import { useKeepAwake } from "expo-keep-awake";
|
import { useKeepAwake } from "expo-keep-awake";
|
||||||
import * as Linking from "expo-linking";
|
import { Stack, useRouter } from "expo-router";
|
||||||
import * as Notifications from "expo-notifications";
|
|
||||||
import { router, Stack } from "expo-router";
|
|
||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
import * as ScreenOrientation from "expo-screen-orientation";
|
||||||
import * as SplashScreen from "expo-splash-screen";
|
import * as SplashScreen from "expo-splash-screen";
|
||||||
import * as TaskManager from "expo-task-manager";
|
import { StatusBar } from "expo-status-bar";
|
||||||
import { Provider as JotaiProvider, useAtom } from "jotai";
|
import { Provider as JotaiProvider, useAtom } from "jotai";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { Appearance, AppState } from "react-native";
|
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
|
||||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||||
import "react-native-reanimated";
|
import "react-native-reanimated";
|
||||||
import { Toaster } from "sonner-native";
|
import * as Linking from "expo-linking";
|
||||||
|
import { orientationAtom } from "@/utils/atoms/orientation";
|
||||||
|
|
||||||
SplashScreen.preventAutoHideAsync();
|
SplashScreen.preventAutoHideAsync();
|
||||||
|
|
||||||
Notifications.setNotificationHandler({
|
|
||||||
handleNotification: async () => ({
|
|
||||||
shouldShowAlert: true,
|
|
||||||
shouldPlaySound: true,
|
|
||||||
shouldSetBadge: false,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
function useNotificationObserver() {
|
|
||||||
useEffect(() => {
|
|
||||||
let isMounted = true;
|
|
||||||
|
|
||||||
function redirect(notification: Notifications.Notification) {
|
|
||||||
const url = notification.request.content.data?.url;
|
|
||||||
if (url) {
|
|
||||||
router.push(url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Notifications.getLastNotificationResponseAsync().then((response) => {
|
|
||||||
if (!isMounted || !response?.notification) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
redirect(response?.notification);
|
|
||||||
});
|
|
||||||
|
|
||||||
const subscription = Notifications.addNotificationResponseReceivedListener(
|
|
||||||
(response) => {
|
|
||||||
redirect(response.notification);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isMounted = false;
|
|
||||||
subscription.remove();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
}
|
|
||||||
|
|
||||||
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
|
|
||||||
console.log("TaskManager ~ trigger");
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
const settingsData = storage.getString("settings");
|
|
||||||
|
|
||||||
if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData;
|
|
||||||
|
|
||||||
const settings: Partial<Settings> = JSON.parse(settingsData);
|
|
||||||
const url = settings?.optimizedVersionsServerUrl;
|
|
||||||
|
|
||||||
if (!settings?.autoDownload || !url)
|
|
||||||
return BackgroundFetch.BackgroundFetchResult.NoData;
|
|
||||||
|
|
||||||
const token = getTokenFromStorage();
|
|
||||||
const deviceId = getOrSetDeviceId();
|
|
||||||
const baseDirectory = FileSystem.documentDirectory;
|
|
||||||
|
|
||||||
if (!token || !deviceId || !baseDirectory)
|
|
||||||
return BackgroundFetch.BackgroundFetchResult.NoData;
|
|
||||||
|
|
||||||
const jobs = await getAllJobsByDeviceId({
|
|
||||||
deviceId,
|
|
||||||
authHeader: token,
|
|
||||||
url,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("TaskManager ~ Active jobs: ", jobs.length);
|
|
||||||
|
|
||||||
for (let job of jobs) {
|
|
||||||
if (job.status === "completed") {
|
|
||||||
const downloadUrl = url + "download/" + job.id;
|
|
||||||
const tasks = await checkForExistingDownloads();
|
|
||||||
|
|
||||||
if (tasks.find((task) => task.id === job.id)) {
|
|
||||||
console.log("TaskManager ~ Download already in progress: ", job.id);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
download({
|
|
||||||
id: job.id,
|
|
||||||
url: downloadUrl,
|
|
||||||
destination: `${baseDirectory}${job.item.Id}.mp4`,
|
|
||||||
headers: {
|
|
||||||
Authorization: token,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.begin(() => {
|
|
||||||
console.log("TaskManager ~ Download started: ", job.id);
|
|
||||||
})
|
|
||||||
.done(() => {
|
|
||||||
console.log("TaskManager ~ Download completed: ", job.id);
|
|
||||||
saveDownloadedItemInfo(job.item);
|
|
||||||
completeHandler(job.id);
|
|
||||||
cancelJobById({
|
|
||||||
authHeader: token,
|
|
||||||
id: job.id,
|
|
||||||
url: url,
|
|
||||||
});
|
|
||||||
Notifications.scheduleNotificationAsync({
|
|
||||||
content: {
|
|
||||||
title: job.item.Name,
|
|
||||||
body: "Download completed",
|
|
||||||
data: {
|
|
||||||
url: `/downloads`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
trigger: null,
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.error((error) => {
|
|
||||||
console.log("TaskManager ~ Download error: ", job.id, error);
|
|
||||||
completeHandler(job.id);
|
|
||||||
Notifications.scheduleNotificationAsync({
|
|
||||||
content: {
|
|
||||||
title: job.item.Name,
|
|
||||||
body: "Download failed",
|
|
||||||
data: {
|
|
||||||
url: `/downloads`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
trigger: null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Auto download started: ${new Date(now).toISOString()}`);
|
|
||||||
|
|
||||||
// Be sure to return the successful result type!
|
|
||||||
return BackgroundFetch.BackgroundFetchResult.NewData;
|
|
||||||
});
|
|
||||||
|
|
||||||
const checkAndRequestPermissions = async () => {
|
|
||||||
try {
|
|
||||||
const hasAskedBefore = storage.getString(
|
|
||||||
"hasAskedForNotificationPermission"
|
|
||||||
);
|
|
||||||
|
|
||||||
if (hasAskedBefore !== "true") {
|
|
||||||
const { status } = await Notifications.requestPermissionsAsync();
|
|
||||||
|
|
||||||
if (status === "granted") {
|
|
||||||
writeToLog("INFO", "Notification permissions granted.");
|
|
||||||
console.log("Notification permissions granted.");
|
|
||||||
} else {
|
|
||||||
writeToLog("ERROR", "Notification permissions denied.");
|
|
||||||
console.log("Notification permissions denied.");
|
|
||||||
}
|
|
||||||
|
|
||||||
storage.set("hasAskedForNotificationPermission", "true");
|
|
||||||
} else {
|
|
||||||
console.log("Already asked for notification permissions before.");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
writeToLog(
|
|
||||||
"ERROR",
|
|
||||||
"Error checking/requesting notification permissions:",
|
|
||||||
error
|
|
||||||
);
|
|
||||||
console.error("Error checking/requesting notification permissions:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
const [loaded] = useFonts({
|
const [loaded] = useFonts({
|
||||||
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
|
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
|
||||||
@@ -218,8 +33,6 @@ export default function RootLayout() {
|
|||||||
}
|
}
|
||||||
}, [loaded]);
|
}, [loaded]);
|
||||||
|
|
||||||
Appearance.setColorScheme("dark");
|
|
||||||
|
|
||||||
if (!loaded) {
|
if (!loaded) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -231,28 +44,25 @@ export default function RootLayout() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
|
||||||
defaultOptions: {
|
|
||||||
queries: {
|
|
||||||
staleTime: 0,
|
|
||||||
refetchOnMount: true,
|
|
||||||
refetchOnReconnect: true,
|
|
||||||
refetchOnWindowFocus: true,
|
|
||||||
retryOnMount: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function Layout() {
|
function Layout() {
|
||||||
const [settings, updateSettings] = useSettings();
|
const [settings, updateSettings] = useSettings();
|
||||||
const [orientation, setOrientation] = useAtom(orientationAtom);
|
const [orientation, setOrientation] = useAtom(orientationAtom);
|
||||||
|
|
||||||
useKeepAwake();
|
useKeepAwake();
|
||||||
useNotificationObserver();
|
|
||||||
|
|
||||||
useEffect(() => {
|
const queryClientRef = useRef<QueryClient>(
|
||||||
checkAndRequestPermissions();
|
new QueryClient({
|
||||||
}, []);
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
refetchOnMount: true,
|
||||||
|
refetchOnReconnect: true,
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
|
retryOnMount: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (settings?.autoRotate === true)
|
if (settings?.autoRotate === true)
|
||||||
@@ -263,28 +73,10 @@ function Layout() {
|
|||||||
);
|
);
|
||||||
}, [settings]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const subscription = ScreenOrientation.addOrientationChangeListener(
|
const subscription = ScreenOrientation.addOrientationChangeListener(
|
||||||
(event) => {
|
(event) => {
|
||||||
|
console.log(event.orientationInfo.orientation);
|
||||||
setOrientation(event.orientationInfo.orientation);
|
setOrientation(event.orientationInfo.orientation);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -306,89 +98,36 @@ function Layout() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClientRef.current}>
|
||||||
<ActionSheetProvider>
|
<JobQueueProvider>
|
||||||
<JobQueueProvider>
|
<ActionSheetProvider>
|
||||||
<JellyfinProvider>
|
<BottomSheetModalProvider>
|
||||||
<PlaySettingsProvider>
|
<JellyfinProvider>
|
||||||
<LogProvider>
|
<PlaybackProvider>
|
||||||
<WebSocketProvider>
|
<StatusBar style="light" backgroundColor="#000" />
|
||||||
<DownloadProvider>
|
<ThemeProvider value={DarkTheme}>
|
||||||
<BottomSheetModalProvider>
|
<Stack initialRouteName="/home">
|
||||||
<SystemBars style="light" hidden={false} />
|
<Stack.Screen
|
||||||
<ThemeProvider value={DarkTheme}>
|
name="(auth)/(tabs)"
|
||||||
<Stack initialRouteName="/home">
|
options={{
|
||||||
<Stack.Screen
|
headerShown: false,
|
||||||
name="(auth)/(tabs)"
|
title: "",
|
||||||
options={{
|
}}
|
||||||
headerShown: false,
|
/>
|
||||||
title: "",
|
<Stack.Screen
|
||||||
header: () => null,
|
name="login"
|
||||||
}}
|
options={{ headerShown: false, title: "Login" }}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen name="+not-found" />
|
||||||
name="(auth)/player"
|
</Stack>
|
||||||
options={{
|
<CurrentlyPlayingBar />
|
||||||
headerShown: false,
|
</ThemeProvider>
|
||||||
title: "",
|
</PlaybackProvider>
|
||||||
header: () => null,
|
</JellyfinProvider>
|
||||||
}}
|
</BottomSheetModalProvider>
|
||||||
/>
|
</ActionSheetProvider>
|
||||||
<Stack.Screen
|
</JobQueueProvider>
|
||||||
name="login"
|
|
||||||
options={{
|
|
||||||
headerShown: true,
|
|
||||||
title: "",
|
|
||||||
headerTransparent: true,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen name="+not-found" />
|
|
||||||
</Stack>
|
|
||||||
<Toaster
|
|
||||||
duration={4000}
|
|
||||||
toastOptions={{
|
|
||||||
style: {
|
|
||||||
backgroundColor: "#262626",
|
|
||||||
borderColor: "#363639",
|
|
||||||
borderWidth: 1,
|
|
||||||
},
|
|
||||||
titleStyle: {
|
|
||||||
color: "white",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
closeButton
|
|
||||||
/>
|
|
||||||
</ThemeProvider>
|
|
||||||
</BottomSheetModalProvider>
|
|
||||||
</DownloadProvider>
|
|
||||||
</WebSocketProvider>
|
|
||||||
</LogProvider>
|
|
||||||
</PlaySettingsProvider>
|
|
||||||
</JellyfinProvider>
|
|
||||||
</JobQueueProvider>
|
|
||||||
</ActionSheetProvider>
|
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</GestureHandlerRootView>
|
</GestureHandlerRootView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveDownloadedItemInfo(item: BaseItemDto) {
|
|
||||||
try {
|
|
||||||
const downloadedItems = storage.getString("downloadedItems");
|
|
||||||
let items: BaseItemDto[] = downloadedItems
|
|
||||||
? JSON.parse(downloadedItems)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const existingItemIndex = items.findIndex((i) => i.Id === item.Id);
|
|
||||||
if (existingItemIndex !== -1) {
|
|
||||||
items[existingItemIndex] = item;
|
|
||||||
} else {
|
|
||||||
items.push(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
storage.set("downloadedItems", JSON.stringify(items));
|
|
||||||
} catch (error) {
|
|
||||||
writeToLog("ERROR", "Failed to save downloaded item information:", error);
|
|
||||||
console.error("Failed to save downloaded item information:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
179
app/login.tsx
@@ -3,10 +3,7 @@ import { Input } from "@/components/common/Input";
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import { getSystemApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
@@ -14,7 +11,6 @@ import {
|
|||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
Platform,
|
Platform,
|
||||||
SafeAreaView,
|
SafeAreaView,
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
|
|
||||||
@@ -37,7 +33,6 @@ const Login: React.FC = () => {
|
|||||||
} = params as { apiUrl: string; username: string; password: string };
|
} = params as { apiUrl: string; username: string; password: string };
|
||||||
|
|
||||||
const [serverURL, setServerURL] = useState<string>(_apiUrl);
|
const [serverURL, setServerURL] = useState<string>(_apiUrl);
|
||||||
const [serverName, setServerName] = useState<string>("");
|
|
||||||
const [error, setError] = useState<string>("");
|
const [error, setError] = useState<string>("");
|
||||||
const [credentials, setCredentials] = useState<{
|
const [credentials, setCredentials] = useState<{
|
||||||
username: string;
|
username: string;
|
||||||
@@ -49,8 +44,6 @@ const Login: React.FC = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
// we might re-use the checkUrl function here to check the url as well
|
|
||||||
// however, I don't think it should be necessary for now
|
|
||||||
if (_apiUrl) {
|
if (_apiUrl) {
|
||||||
setServer({
|
setServer({
|
||||||
address: _apiUrl,
|
address: _apiUrl,
|
||||||
@@ -66,23 +59,6 @@ const Login: React.FC = () => {
|
|||||||
})();
|
})();
|
||||||
}, [_apiUrl, _username, _password]);
|
}, [_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 [loading, setLoading] = useState<boolean>(false);
|
||||||
|
|
||||||
const handleLogin = async () => {
|
const handleLogin = async () => {
|
||||||
@@ -103,73 +79,12 @@ const Login: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const [loadingServerCheck, setLoadingServerCheck] = useState<boolean>(false);
|
const handleConnect = (url: string) => {
|
||||||
|
if (!url.startsWith("http")) {
|
||||||
/**
|
Alert.alert("Error", "URL needs to start with http or https.");
|
||||||
* Checks the availability and validity of a Jellyfin server URL.
|
|
||||||
*
|
|
||||||
* This function attempts to connect to a Jellyfin server using the provided URL.
|
|
||||||
* It tries both HTTPS and HTTP protocols, with a timeout to handle long 404 responses.
|
|
||||||
*
|
|
||||||
* @param {string} url - The base URL of the Jellyfin server to check.
|
|
||||||
* @returns {Promise<string | undefined>} A Promise that resolves to:
|
|
||||||
* - The full URL (including protocol) if a valid Jellyfin server is found.
|
|
||||||
* - undefined if no valid server is found at the given URL.
|
|
||||||
*
|
|
||||||
* Side effects:
|
|
||||||
* - Sets loadingServerCheck state to true at the beginning and false at the end.
|
|
||||||
* - Logs errors and timeout information to the console.
|
|
||||||
*/
|
|
||||||
async function checkUrl(url: string) {
|
|
||||||
setLoadingServerCheck(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${url}/System/Info/Public`, {
|
|
||||||
mode: "cors",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const data = (await response.json()) as PublicSystemInfo;
|
|
||||||
setServerName(data.ServerName || "");
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
} finally {
|
|
||||||
setLoadingServerCheck(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles the connection attempt to a Jellyfin server.
|
|
||||||
*
|
|
||||||
* This function trims the input URL, checks its validity using the `checkUrl` function,
|
|
||||||
* and sets the server address if a valid connection is established.
|
|
||||||
*
|
|
||||||
* @param {string} url - The URL of the Jellyfin server to connect to.
|
|
||||||
*
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*
|
|
||||||
* Side effects:
|
|
||||||
* - Calls `checkUrl` to validate the server URL.
|
|
||||||
* - Shows an alert if the connection fails.
|
|
||||||
* - Sets the server address using `setServer` if the connection is successful.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
const handleConnect = async (url: string) => {
|
|
||||||
url = url.trim();
|
|
||||||
|
|
||||||
const result = await checkUrl(url);
|
|
||||||
|
|
||||||
if (result === undefined) {
|
|
||||||
Alert.alert(
|
|
||||||
"Connection failed",
|
|
||||||
"Could not connect to the server. Please check the URL and your network connection."
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setServer({ address: url.trim() });
|
||||||
setServer({ address: url });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleQuickConnect = async () => {
|
const handleQuickConnect = async () => {
|
||||||
@@ -194,21 +109,38 @@ const Login: React.FC = () => {
|
|||||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||||
style={{ flex: 1, height: "100%" }}
|
style={{ flex: 1, height: "100%" }}
|
||||||
>
|
>
|
||||||
<View className="flex flex-col h-full relative items-center justify-center">
|
<View className="flex flex-col justify-between px-4 h-full gap-y-2">
|
||||||
<View className="px-4 -mt-20 w-full">
|
<View></View>
|
||||||
<View className="flex flex-col space-y-2">
|
<View>
|
||||||
<Text className="text-2xl font-bold -mb-2">
|
<View className="mb-4">
|
||||||
Log in
|
<Text className="text-3xl font-bold mb-2">Streamyfin</Text>
|
||||||
<>
|
<Text className="text-neutral-500 mb-2">
|
||||||
{serverName ? (
|
Server: {api.basePath}
|
||||||
<>
|
</Text>
|
||||||
{" to "}
|
<Button
|
||||||
<Text className="text-purple-600">{serverName}</Text>
|
color="black"
|
||||||
</>
|
onPress={() => {
|
||||||
) : null}
|
removeServer();
|
||||||
</>
|
setServerURL("");
|
||||||
|
}}
|
||||||
|
justify="between"
|
||||||
|
iconLeft={
|
||||||
|
<Ionicons
|
||||||
|
name="arrow-back-outline"
|
||||||
|
size={18}
|
||||||
|
color={"white"}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Change server
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex flex-col space-y-2">
|
||||||
|
<Text className="text-2xl font-bold">Log in</Text>
|
||||||
|
<Text className="text-neutral-500">
|
||||||
|
Log in to any user account
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-xs text-neutral-400">{serverURL}</Text>
|
|
||||||
<Input
|
<Input
|
||||||
placeholder="Username"
|
placeholder="Username"
|
||||||
onChangeText={(text) =>
|
onChangeText={(text) =>
|
||||||
@@ -245,11 +177,11 @@ const Login: React.FC = () => {
|
|||||||
<Text className="text-red-600 mb-2">{error}</Text>
|
<Text className="text-red-600 mb-2">{error}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="absolute bottom-0 left-0 w-full px-4 mb-2">
|
<View className="mt-auto mb-2">
|
||||||
<Button
|
<Button
|
||||||
color="black"
|
color="black"
|
||||||
onPress={handleQuickConnect}
|
onPress={handleQuickConnect}
|
||||||
className="w-full mb-2"
|
className="mb-2"
|
||||||
>
|
>
|
||||||
Use Quick Connect
|
Use Quick Connect
|
||||||
</Button>
|
</Button>
|
||||||
@@ -267,22 +199,14 @@ const Login: React.FC = () => {
|
|||||||
<SafeAreaView style={{ flex: 1 }}>
|
<SafeAreaView style={{ flex: 1 }}>
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
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 px-4 justify-between h-full">
|
||||||
<View className="flex flex-col gap-y-2 px-4 w-full -mt-36">
|
<View></View>
|
||||||
<Image
|
<View className="flex flex-col gap-y-2">
|
||||||
style={{
|
|
||||||
width: 100,
|
|
||||||
height: 100,
|
|
||||||
marginLeft: -23,
|
|
||||||
marginBottom: -20,
|
|
||||||
}}
|
|
||||||
source={require("@/assets/images/StreamyFinFinal.png")}
|
|
||||||
/>
|
|
||||||
<Text className="text-3xl font-bold">Streamyfin</Text>
|
<Text className="text-3xl font-bold">Streamyfin</Text>
|
||||||
<Text className="text-neutral-500">
|
<Text className="text-neutral-500">
|
||||||
Enter the URL to your Jellyfin server
|
Connect to your Jellyfin server
|
||||||
</Text>
|
</Text>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Server URL"
|
placeholder="Server URL"
|
||||||
@@ -294,20 +218,13 @@ const Login: React.FC = () => {
|
|||||||
textContentType="URL"
|
textContentType="URL"
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
/>
|
/>
|
||||||
<Text className="text-xs text-neutral-500">
|
<Text className="opacity-30">
|
||||||
Make sure to include http or https
|
Server URL requires http or https
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className="mb-2 absolute bottom-0 left-0 w-full px-4">
|
<Button onPress={() => handleConnect(serverURL)} className="mb-2">
|
||||||
<Button
|
Connect
|
||||||
loading={loadingServerCheck}
|
</Button>
|
||||||
disabled={loadingServerCheck}
|
|
||||||
onPress={async () => await handleConnect(serverURL)}
|
|
||||||
className="w-full grow"
|
|
||||||
>
|
|
||||||
Connect
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 231 KiB |
|
Before Width: | Height: | Size: 91 KiB |
BIN
assets/images/icon.jpg
Normal file
|
After Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 1.1 MiB |
BIN
assets/images/icon_512x512.jpg
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
assets/images/splash.jpg
Normal file
|
After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 158 KiB |
@@ -1,13 +1,20 @@
|
|||||||
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
import { atom, useAtom } from "jotai";
|
||||||
|
import {
|
||||||
|
BaseItemDto,
|
||||||
|
MediaSourceInfo,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { useEffect, useMemo } from "react";
|
||||||
|
import { MediaStream } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { tc } from "@/utils/textTools";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof View> {
|
interface Props extends React.ComponentProps<typeof View> {
|
||||||
source?: MediaSourceInfo;
|
source: MediaSourceInfo;
|
||||||
onChange: (value: number) => void;
|
onChange: (value: number) => void;
|
||||||
selected?: number | undefined;
|
selected: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AudioTrackSelector: React.FC<Props> = ({
|
export const AudioTrackSelector: React.FC<Props> = ({
|
||||||
@@ -16,8 +23,10 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
|||||||
selected,
|
selected,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
|
const [settings] = useSettings();
|
||||||
|
|
||||||
const audioStreams = useMemo(
|
const audioStreams = useMemo(
|
||||||
() => source?.MediaStreams?.filter((x) => x.Type === "Audio"),
|
() => source.MediaStreams?.filter((x) => x.Type === "Audio"),
|
||||||
[source]
|
[source]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -26,6 +35,24 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
|||||||
[audioStreams, selected]
|
[audioStreams, selected]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const defaultAudioIndex = audioStreams?.find(
|
||||||
|
(x) => x.Language === settings?.defaultAudioLanguage
|
||||||
|
)?.Index;
|
||||||
|
if (defaultAudioIndex !== undefined && defaultAudioIndex !== null) {
|
||||||
|
onChange(defaultAudioIndex);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const index = source.DefaultAudioStreamIndex;
|
||||||
|
if (index !== undefined && index !== null) {
|
||||||
|
console.log("DefaultAudioStreamIndex", index);
|
||||||
|
onChange(index);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange(0);
|
||||||
|
}, [audioStreams, settings]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className="flex shrink"
|
className="flex shrink"
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ import { useMemo } from "react";
|
|||||||
export type Bitrate = {
|
export type Bitrate = {
|
||||||
key: string;
|
key: string;
|
||||||
value: number | undefined;
|
value: number | undefined;
|
||||||
|
height?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const BITRATES: Bitrate[] = [
|
const BITRATES: Bitrate[] = [
|
||||||
{
|
{
|
||||||
key: "Max",
|
key: "Max",
|
||||||
value: undefined,
|
value: undefined,
|
||||||
@@ -26,21 +27,24 @@ export const BITRATES: Bitrate[] = [
|
|||||||
{
|
{
|
||||||
key: "2 Mb/s",
|
key: "2 Mb/s",
|
||||||
value: 2000000,
|
value: 2000000,
|
||||||
|
height: 720,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "500 Kb/s",
|
key: "500 Kb/s",
|
||||||
value: 500000,
|
value: 500000,
|
||||||
|
height: 480,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "250 Kb/s",
|
key: "250 Kb/s",
|
||||||
value: 250000,
|
value: 250000,
|
||||||
|
height: 480,
|
||||||
},
|
},
|
||||||
].sort((a, b) => (b.value || Infinity) - (a.value || Infinity));
|
];
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof View> {
|
interface Props extends React.ComponentProps<typeof View> {
|
||||||
onChange: (value: Bitrate) => void;
|
onChange: (value: Bitrate) => void;
|
||||||
selected?: Bitrate | null;
|
selected: Bitrate;
|
||||||
inverted?: boolean | null;
|
inverted?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BitrateSelector: React.FC<Props> = ({
|
export const BitrateSelector: React.FC<Props> = ({
|
||||||
@@ -73,7 +77,7 @@ export const BitrateSelector: React.FC<Props> = ({
|
|||||||
<Text className="opacity-50 mb-1 text-xs">Quality</Text>
|
<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">
|
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
|
||||||
<Text style={{}} className="" numberOfLines={1}>
|
<Text style={{}} className="" numberOfLines={1}>
|
||||||
{BITRATES.find((b) => b.value === selected?.value)?.key}
|
{BITRATES.find((b) => b.value === selected.value)?.key}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ import React, { PropsWithChildren, ReactNode, useMemo } from "react";
|
|||||||
import { Text, TouchableOpacity, View } from "react-native";
|
import { Text, TouchableOpacity, View } from "react-native";
|
||||||
import { Loader } from "./Loader";
|
import { Loader } from "./Loader";
|
||||||
|
|
||||||
export interface ButtonProps
|
interface ButtonProps extends React.ComponentProps<typeof TouchableOpacity> {
|
||||||
extends React.ComponentProps<typeof TouchableOpacity> {
|
|
||||||
onPress?: () => void;
|
onPress?: () => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
textClassName?: string;
|
textClassName?: string;
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
import { Feather } from "@expo/vector-icons";
|
import { Feather } from "@expo/vector-icons";
|
||||||
import { BlurView } from "expo-blur";
|
import { BlurView } from "expo-blur";
|
||||||
import React, { useCallback, useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
import { Platform, TouchableOpacity, ViewProps } from "react-native";
|
import { Platform, TouchableOpacity, ViewProps } from "react-native";
|
||||||
import GoogleCast, {
|
import GoogleCast, {
|
||||||
CastButton,
|
|
||||||
CastContext,
|
CastContext,
|
||||||
useCastDevice,
|
useCastDevice,
|
||||||
useDevices,
|
useDevices,
|
||||||
useMediaStatus,
|
useMediaStatus,
|
||||||
useRemoteMediaClient,
|
useRemoteMediaClient,
|
||||||
} from "react-native-google-cast";
|
} from "react-native-google-cast";
|
||||||
import { RoundButton } from "./RoundButton";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
width?: number;
|
width?: number;
|
||||||
@@ -41,43 +39,49 @@ export const Chromecast: React.FC<Props> = ({
|
|||||||
})();
|
})();
|
||||||
}, [client, devices, castDevice, sessionManager, discoveryManager]);
|
}, [client, devices, castDevice, sessionManager, discoveryManager]);
|
||||||
|
|
||||||
// Android requires the cast button to be present for startDiscovery to work
|
|
||||||
const AndroidCastButton = useCallback(
|
|
||||||
() =>
|
|
||||||
Platform.OS === "android" ? (
|
|
||||||
<CastButton tintColor="transparent" />
|
|
||||||
) : (
|
|
||||||
<></>
|
|
||||||
),
|
|
||||||
[Platform.OS]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (background === "transparent")
|
if (background === "transparent")
|
||||||
return (
|
return (
|
||||||
<RoundButton
|
<TouchableOpacity
|
||||||
size="large"
|
|
||||||
className="mr-2"
|
|
||||||
background={false}
|
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||||
else CastContext.showCastDialog();
|
else CastContext.showCastDialog();
|
||||||
}}
|
}}
|
||||||
|
className="rounded-full h-10 w-10 flex items-center justify-center b"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<Feather name="cast" size={22} color={"white"} />
|
<Feather name="cast" size={22} color={"white"} />
|
||||||
</RoundButton>
|
</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 (
|
return (
|
||||||
<RoundButton
|
<TouchableOpacity
|
||||||
size="large"
|
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||||
else CastContext.showCastDialog();
|
else CastContext.showCastDialog();
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<Feather name="cast" size={22} color={"white"} />
|
<BlurView
|
||||||
</RoundButton>
|
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,26 @@
|
|||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { WatchedIndicator } from "./WatchedIndicator";
|
import { WatchedIndicator } from "./WatchedIndicator";
|
||||||
import React from "react";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
|
|
||||||
type ContinueWatchingPosterProps = {
|
type ContinueWatchingPosterProps = {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
|
width?: number;
|
||||||
useEpisodePoster?: boolean;
|
useEpisodePoster?: boolean;
|
||||||
size?: "small" | "normal";
|
|
||||||
showPlayButton?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
||||||
item,
|
item,
|
||||||
|
width = 176,
|
||||||
useEpisodePoster = false,
|
useEpisodePoster = false,
|
||||||
size = "normal",
|
|
||||||
showPlayButton = false,
|
|
||||||
}) => {
|
}) => {
|
||||||
const api = useAtomValue(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get horizontal poster for movie and episode, with failover to primary.
|
* Get horrizontal poster for movie and episode, with failover to primary.
|
||||||
*/
|
*/
|
||||||
const url = useMemo(() => {
|
const url = useMemo(() => {
|
||||||
if (!api) return;
|
if (!api) return;
|
||||||
@@ -43,61 +39,47 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
|||||||
else
|
else
|
||||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
||||||
}
|
}
|
||||||
if (item.Type === "Program") {
|
|
||||||
if (item.ImageTags?.["Thumb"])
|
|
||||||
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ImageTags?.["Thumb"]}`;
|
|
||||||
else
|
|
||||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
|
||||||
}
|
|
||||||
}, [item]);
|
}, [item]);
|
||||||
|
|
||||||
const progress = useMemo(() => {
|
const [progress, setProgress] = useState(
|
||||||
if (item.Type === "Program") {
|
item.UserData?.PlayedPercentage || 0
|
||||||
const startDate = new Date(item.StartDate || "");
|
);
|
||||||
const endDate = new Date(item.EndDate || "");
|
|
||||||
const now = new Date();
|
|
||||||
const total = endDate.getTime() - startDate.getTime();
|
|
||||||
const elapsed = now.getTime() - startDate.getTime();
|
|
||||||
return (elapsed / total) * 100;
|
|
||||||
} else {
|
|
||||||
return item.UserData?.PlayedPercentage || 0;
|
|
||||||
}
|
|
||||||
}, [item]);
|
|
||||||
|
|
||||||
if (!url)
|
if (!url)
|
||||||
return (
|
return (
|
||||||
<View className="aspect-video border border-neutral-800 w-44"></View>
|
<View
|
||||||
|
className="aspect-video border border-neutral-800"
|
||||||
|
style={{
|
||||||
|
width,
|
||||||
|
}}
|
||||||
|
></View>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className={`
|
style={{
|
||||||
relative w-44 aspect-video rounded-lg overflow-hidden border border-neutral-800
|
width,
|
||||||
${size === "small" ? "w-32" : "w-44"}
|
}}
|
||||||
`}
|
className="relative aspect-video rounded-lg overflow-hidden border border-neutral-800"
|
||||||
>
|
>
|
||||||
<View className="w-full h-full flex items-center justify-center">
|
<Image
|
||||||
<Image
|
key={item.Id}
|
||||||
key={item.Id}
|
id={item.Id}
|
||||||
id={item.Id}
|
source={{
|
||||||
source={{
|
uri: url,
|
||||||
uri: url,
|
}}
|
||||||
}}
|
cachePolicy={"memory-disk"}
|
||||||
cachePolicy={"memory-disk"}
|
contentFit="cover"
|
||||||
contentFit="cover"
|
className="w-full h-full"
|
||||||
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>
|
|
||||||
{!progress && <WatchedIndicator item={item} />}
|
{!progress && <WatchedIndicator item={item} />}
|
||||||
{progress > 0 && (
|
{progress > 0 && (
|
||||||
<>
|
<>
|
||||||
<View
|
<View
|
||||||
className={`absolute w-100 bottom-0 left-0 h-1 bg-neutral-700 opacity-80 w-full`}
|
style={{
|
||||||
|
width: `100%`,
|
||||||
|
}}
|
||||||
|
className={`absolute bottom-0 left-0 h-1 bg-neutral-700 opacity-80 w-full`}
|
||||||
></View>
|
></View>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
313
components/CurrentlyPlayingBar.tsx
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { usePlayback } from "@/providers/PlaybackProvider";
|
||||||
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||||
|
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
||||||
|
import { writeToLog } from "@/utils/log";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { BlurView } from "expo-blur";
|
||||||
|
import { useRouter, useSegments } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useEffect, useMemo } from "react";
|
||||||
|
import { Alert, Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
import Animated, {
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withTiming,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
import Video from "react-native-video";
|
||||||
|
import { Text } from "./common/Text";
|
||||||
|
import { Loader } from "./Loader";
|
||||||
|
|
||||||
|
export const CurrentlyPlayingBar: React.FC = () => {
|
||||||
|
const segments = useSegments();
|
||||||
|
const {
|
||||||
|
currentlyPlaying,
|
||||||
|
pauseVideo,
|
||||||
|
playVideo,
|
||||||
|
stopPlayback,
|
||||||
|
setVolume,
|
||||||
|
setIsPlaying,
|
||||||
|
isPlaying,
|
||||||
|
videoRef,
|
||||||
|
presentFullscreenPlayer,
|
||||||
|
onProgress,
|
||||||
|
onBuffer,
|
||||||
|
} = usePlayback();
|
||||||
|
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
|
const aBottom = useSharedValue(0);
|
||||||
|
const aPadding = useSharedValue(0);
|
||||||
|
const aHeight = useSharedValue(100);
|
||||||
|
const router = useRouter();
|
||||||
|
const animatedOuterStyle = useAnimatedStyle(() => {
|
||||||
|
return {
|
||||||
|
bottom: withTiming(aBottom.value, { duration: 500 }),
|
||||||
|
height: withTiming(aHeight.value, { duration: 500 }),
|
||||||
|
padding: withTiming(aPadding.value, { duration: 500 }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const aPaddingBottom = useSharedValue(30);
|
||||||
|
const aPaddingInner = useSharedValue(12);
|
||||||
|
const aBorderRadiusBottom = useSharedValue(12);
|
||||||
|
const animatedInnerStyle = useAnimatedStyle(() => {
|
||||||
|
return {
|
||||||
|
padding: withTiming(aPaddingInner.value, { duration: 500 }),
|
||||||
|
paddingBottom: withTiming(aPaddingBottom.value, { duration: 500 }),
|
||||||
|
borderBottomLeftRadius: withTiming(aBorderRadiusBottom.value, {
|
||||||
|
duration: 500,
|
||||||
|
}),
|
||||||
|
borderBottomRightRadius: withTiming(aBorderRadiusBottom.value, {
|
||||||
|
duration: 500,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const from = useMemo(() => segments[2], [segments]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (segments.find((s) => s.includes("tabs"))) {
|
||||||
|
// Tab screen - i.e. home
|
||||||
|
aBottom.value = Platform.OS === "ios" ? 78 : 50;
|
||||||
|
aHeight.value = 80;
|
||||||
|
aPadding.value = 8;
|
||||||
|
aPaddingBottom.value = 8;
|
||||||
|
aPaddingInner.value = 8;
|
||||||
|
} else {
|
||||||
|
// Inside a normal screen
|
||||||
|
aBottom.value = Platform.OS === "ios" ? 0 : 0;
|
||||||
|
aHeight.value = Platform.OS === "ios" ? 110 : 80;
|
||||||
|
aPadding.value = Platform.OS === "ios" ? 0 : 8;
|
||||||
|
aPaddingInner.value = Platform.OS === "ios" ? 12 : 8;
|
||||||
|
aPaddingBottom.value = Platform.OS === "ios" ? 40 : 12;
|
||||||
|
}
|
||||||
|
}, [segments]);
|
||||||
|
|
||||||
|
const startPosition = useMemo(
|
||||||
|
() =>
|
||||||
|
currentlyPlaying?.item?.UserData?.PlaybackPositionTicks
|
||||||
|
? Math.round(
|
||||||
|
currentlyPlaying?.item.UserData.PlaybackPositionTicks / 10000
|
||||||
|
)
|
||||||
|
: 0,
|
||||||
|
[currentlyPlaying?.item]
|
||||||
|
);
|
||||||
|
|
||||||
|
const poster = useMemo(() => {
|
||||||
|
if (currentlyPlaying?.item.Type === "Audio")
|
||||||
|
return `${api?.basePath}/Items/${currentlyPlaying.item.AlbumId}/Images/Primary?tag=${currentlyPlaying.item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`;
|
||||||
|
else
|
||||||
|
return getBackdropUrl({
|
||||||
|
api,
|
||||||
|
item: currentlyPlaying?.item,
|
||||||
|
quality: 70,
|
||||||
|
width: 200,
|
||||||
|
});
|
||||||
|
}, [currentlyPlaying?.item.Id, api]);
|
||||||
|
|
||||||
|
const videoSource = useMemo(() => {
|
||||||
|
if (!api || !currentlyPlaying || !poster) return null;
|
||||||
|
return {
|
||||||
|
uri: currentlyPlaying.url,
|
||||||
|
isNetwork: true,
|
||||||
|
startPosition,
|
||||||
|
headers: getAuthHeaders(api),
|
||||||
|
metadata: {
|
||||||
|
artist: currentlyPlaying.item?.AlbumArtist
|
||||||
|
? currentlyPlaying.item?.AlbumArtist
|
||||||
|
: undefined,
|
||||||
|
title: currentlyPlaying.item?.Name || "Unknown",
|
||||||
|
description: currentlyPlaying.item?.Overview
|
||||||
|
? currentlyPlaying.item?.Overview
|
||||||
|
: undefined,
|
||||||
|
imageUri: poster,
|
||||||
|
subtitle: currentlyPlaying.item?.Album
|
||||||
|
? currentlyPlaying.item?.Album
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, [currentlyPlaying, startPosition, api, poster]);
|
||||||
|
|
||||||
|
if (!api || !currentlyPlaying) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
style={[animatedOuterStyle]}
|
||||||
|
className="absolute left-0 w-screen"
|
||||||
|
>
|
||||||
|
<BlurView
|
||||||
|
intensity={Platform.OS === "android" ? 60 : 100}
|
||||||
|
experimentalBlurMethod={Platform.OS === "android" ? "none" : undefined}
|
||||||
|
className={`h-full w-full rounded-xl overflow-hidden ${
|
||||||
|
Platform.OS === "android" && "bg-black"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
{ padding: 8, borderTopLeftRadius: 12, borderTopEndRadius: 12 },
|
||||||
|
animatedInnerStyle,
|
||||||
|
]}
|
||||||
|
className="h-full w-full flex flex-row items-center justify-between overflow-hidden"
|
||||||
|
>
|
||||||
|
<View className="flex flex-row items-center space-x-4 shrink">
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
videoRef.current?.presentFullscreenPlayer();
|
||||||
|
}}
|
||||||
|
className={`relative h-full bg-neutral-800 rounded-md overflow-hidden
|
||||||
|
${
|
||||||
|
currentlyPlaying.item?.Type === "Audio"
|
||||||
|
? "aspect-square"
|
||||||
|
: "aspect-video"
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{videoSource && (
|
||||||
|
<Video
|
||||||
|
ref={videoRef}
|
||||||
|
allowsExternalPlayback
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
playWhenInactive={true}
|
||||||
|
playInBackground={true}
|
||||||
|
showNotificationControls={true}
|
||||||
|
ignoreSilentSwitch="ignore"
|
||||||
|
controls={false}
|
||||||
|
pictureInPicture={true}
|
||||||
|
poster={
|
||||||
|
poster && currentlyPlaying.item?.Type === "Audio"
|
||||||
|
? poster
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
debug={{
|
||||||
|
enable: true,
|
||||||
|
thread: true,
|
||||||
|
}}
|
||||||
|
onIdle={() => {
|
||||||
|
console.log("IDLE");
|
||||||
|
}}
|
||||||
|
fullscreenAutorotate={true}
|
||||||
|
onReadyForDisplay={() => {
|
||||||
|
console.log("READY FOR DISPLAY");
|
||||||
|
}}
|
||||||
|
onProgress={(e) => onProgress(e)}
|
||||||
|
subtitleStyle={{
|
||||||
|
fontSize: 16,
|
||||||
|
}}
|
||||||
|
onBuffer={(e) => onBuffer(e.isBuffering)}
|
||||||
|
source={videoSource}
|
||||||
|
onRestoreUserInterfaceForPictureInPictureStop={() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
presentFullscreenPlayer();
|
||||||
|
}, 300);
|
||||||
|
}}
|
||||||
|
onPlaybackStateChanged={(e) => {
|
||||||
|
if (e.isPlaying === true) {
|
||||||
|
playVideo(false);
|
||||||
|
} else if (e.isPlaying === false) {
|
||||||
|
pauseVideo(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onVolumeChange={(e) => {
|
||||||
|
setVolume(e.volume);
|
||||||
|
}}
|
||||||
|
progressUpdateInterval={4000}
|
||||||
|
onError={(e) => {
|
||||||
|
console.log(e);
|
||||||
|
writeToLog(
|
||||||
|
"ERROR",
|
||||||
|
"Video playback error: " + JSON.stringify(e)
|
||||||
|
);
|
||||||
|
Alert.alert("Error", "Cannot play this video file.");
|
||||||
|
setIsPlaying(false);
|
||||||
|
// setCurrentlyPlaying(null);
|
||||||
|
}}
|
||||||
|
renderLoader={
|
||||||
|
currentlyPlaying.item?.Type !== "Audio" && (
|
||||||
|
<View className="flex flex-col items-center justify-center h-full">
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
<View className="shrink text-xs">
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
if (currentlyPlaying.item?.Type === "Audio") {
|
||||||
|
router.push(
|
||||||
|
// @ts-ignore
|
||||||
|
`/(auth)/(tabs)/${from}/albums/${currentlyPlaying.item.AlbumId}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
router.push(
|
||||||
|
// @ts-ignore
|
||||||
|
`/(auth)/(tabs)/${from}/items/page?id=${currentlyPlaying.item?.Id}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text>{currentlyPlaying.item?.Name}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
{currentlyPlaying.item?.Type === "Episode" && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
router.push(
|
||||||
|
// @ts-ignore
|
||||||
|
`/(auth)/(tabs)/${from}/series/${currentlyPlaying.item.SeriesId}`
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className="text-xs opacity-50"
|
||||||
|
>
|
||||||
|
<Text>{currentlyPlaying.item.SeriesName}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
{currentlyPlaying.item?.Type === "Movie" && (
|
||||||
|
<View>
|
||||||
|
<Text className="text-xs opacity-50">
|
||||||
|
{currentlyPlaying.item?.ProductionYear}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{currentlyPlaying.item?.Type === "Audio" && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
router.push(`/albums/${currentlyPlaying.item?.AlbumId}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className="text-xs opacity-50">
|
||||||
|
{currentlyPlaying.item?.Album}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View className="flex flex-row items-center space-x-2">
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
if (isPlaying) pauseVideo();
|
||||||
|
else playVideo();
|
||||||
|
}}
|
||||||
|
className="aspect-square rounded flex flex-col items-center justify-center p-2"
|
||||||
|
>
|
||||||
|
{isPlaying ? (
|
||||||
|
<Ionicons name="pause" size={24} color="white" />
|
||||||
|
) : (
|
||||||
|
<Ionicons name="play" size={24} color="white" />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
stopPlayback();
|
||||||
|
}}
|
||||||
|
className="aspect-square rounded flex flex-col items-center justify-center p-2"
|
||||||
|
>
|
||||||
|
<Ionicons name="close" size={24} color="white" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
</BlurView>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
|
import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { runningProcesses } from "@/utils/atoms/downloads";
|
||||||
import { queueActions, queueAtom } from "@/utils/atoms/queue";
|
import { queueActions, queueAtom } from "@/utils/atoms/queue";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
import ios from "@/utils/profiles/ios";
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import native from "@/utils/profiles/native";
|
||||||
import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server";
|
import old from "@/utils/profiles/old";
|
||||||
import download from "@/utils/profiles/download";
|
|
||||||
import Ionicons from "@expo/vector-icons/Ionicons";
|
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||||
import {
|
import {
|
||||||
BottomSheetBackdrop,
|
BottomSheetBackdrop,
|
||||||
@@ -18,11 +17,12 @@ import {
|
|||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { Href, router, useFocusEffect } from "expo-router";
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { router } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { Alert, View, ViewProps } from "react-native";
|
import { Alert, TouchableOpacity, View, ViewProps } from "react-native";
|
||||||
import { toast } from "sonner-native";
|
|
||||||
import { AudioTrackSelector } from "./AudioTrackSelector";
|
import { AudioTrackSelector } from "./AudioTrackSelector";
|
||||||
import { Bitrate, BitrateSelector } from "./BitrateSelector";
|
import { Bitrate, BitrateSelector } from "./BitrateSelector";
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
@@ -30,37 +30,22 @@ import { Text } from "./common/Text";
|
|||||||
import { Loader } from "./Loader";
|
import { Loader } from "./Loader";
|
||||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||||
import ProgressCircle from "./ProgressCircle";
|
import ProgressCircle from "./ProgressCircle";
|
||||||
import { RoundButton } from "./RoundButton";
|
|
||||||
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
|
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
|
||||||
|
|
||||||
interface DownloadProps extends ViewProps {
|
interface DownloadProps extends ViewProps {
|
||||||
items: BaseItemDto[];
|
item: BaseItemDto;
|
||||||
MissingDownloadIconComponent: () => React.ReactElement;
|
|
||||||
DownloadedIconComponent: () => React.ReactElement;
|
|
||||||
title?: string;
|
|
||||||
subtitle?: string;
|
|
||||||
size?: "default" | "large";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DownloadItems: React.FC<DownloadProps> = ({
|
export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
||||||
items,
|
|
||||||
MissingDownloadIconComponent,
|
|
||||||
DownloadedIconComponent,
|
|
||||||
title = "Download",
|
|
||||||
subtitle = "",
|
|
||||||
size = "default",
|
|
||||||
...props
|
|
||||||
}) => {
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
|
const [process] = useAtom(runningProcesses);
|
||||||
const [queue, setQueue] = useAtom(queueAtom);
|
const [queue, setQueue] = useAtom(queueAtom);
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
const { processes, startBackgroundDownload, downloadedFiles } = useDownload();
|
const { startRemuxing } = useRemuxHlsToMp4(item);
|
||||||
const { startRemuxing } = useRemuxHlsToMp4();
|
|
||||||
|
|
||||||
const [selectedMediaSource, setSelectedMediaSource] = useState<
|
const [selectedMediaSource, setSelectedMediaSource] =
|
||||||
MediaSourceInfo | undefined | null
|
useState<MediaSourceInfo | null>(null);
|
||||||
>(undefined);
|
|
||||||
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
||||||
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
||||||
useState<number>(0);
|
useState<number>(0);
|
||||||
@@ -69,182 +54,135 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
value: undefined,
|
value: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const userCanDownload = useMemo(
|
const userCanDownload = useMemo(() => {
|
||||||
() => user?.Policy?.EnableContentDownloading,
|
return user?.Policy?.EnableContentDownloading;
|
||||||
[user]
|
}, [user]);
|
||||||
);
|
|
||||||
const usingOptimizedServer = useMemo(
|
|
||||||
() => settings?.downloadMethod === "optimized",
|
|
||||||
[settings]
|
|
||||||
);
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bottom sheet
|
||||||
|
*/
|
||||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
|
|
||||||
const handlePresentModalPress = useCallback(() => {
|
const handlePresentModalPress = useCallback(() => {
|
||||||
bottomSheetModalRef.current?.present();
|
bottomSheetModalRef.current?.present();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSheetChanges = useCallback((index: number) => {}, []);
|
const handleSheetChanges = useCallback((index: number) => {
|
||||||
|
console.log("handleSheetChanges", index);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const closeModal = useCallback(() => {
|
const closeModal = useCallback(() => {
|
||||||
bottomSheetModalRef.current?.dismiss();
|
bottomSheetModalRef.current?.dismiss();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const itemIds = useMemo(() => items.map((i) => i.Id), [items]);
|
/**
|
||||||
|
* Start download
|
||||||
const itemsNotDownloaded = useMemo(
|
*/
|
||||||
() =>
|
const initiateDownload = useCallback(async () => {
|
||||||
items.filter((i) => !downloadedFiles?.some((f) => f.item.Id === i.Id)),
|
if (!api || !user?.Id || !item.Id || !selectedMediaSource?.Id) {
|
||||||
[items, downloadedFiles]
|
throw new Error(
|
||||||
);
|
"DownloadItem ~ initiateDownload: No api or user or item"
|
||||||
|
);
|
||||||
const allItemsDownloaded = useMemo(() => {
|
|
||||||
if (items.length === 0) return false;
|
|
||||||
return itemsNotDownloaded.length === 0;
|
|
||||||
}, [items, itemsNotDownloaded]);
|
|
||||||
const itemsProcesses = useMemo(
|
|
||||||
() => processes?.filter((p) => itemIds.includes(p.item.Id)),
|
|
||||||
[processes, itemIds]
|
|
||||||
);
|
|
||||||
|
|
||||||
const progress = useMemo(() => {
|
|
||||||
if (itemIds.length == 1)
|
|
||||||
return itemsProcesses.reduce((acc, p) => acc + p.progress, 0);
|
|
||||||
return (
|
|
||||||
((itemIds.length -
|
|
||||||
queue.filter((q) => itemIds.includes(q.item.Id)).length) /
|
|
||||||
itemIds.length) *
|
|
||||||
100
|
|
||||||
);
|
|
||||||
}, [queue, itemsProcesses, itemIds]);
|
|
||||||
|
|
||||||
const itemsQueued = useMemo(() => {
|
|
||||||
return (
|
|
||||||
itemsNotDownloaded.length > 0 &&
|
|
||||||
itemsNotDownloaded.every((p) => queue.some((q) => p.Id == q.item.Id))
|
|
||||||
);
|
|
||||||
}, [queue, itemsNotDownloaded]);
|
|
||||||
const navigateToDownloads = () => router.push("/downloads");
|
|
||||||
|
|
||||||
const onDownloadedPress = () => {
|
|
||||||
const firstItem = items?.[0];
|
|
||||||
router.push(
|
|
||||||
firstItem.Type !== "Episode"
|
|
||||||
? "/downloads"
|
|
||||||
: ({
|
|
||||||
pathname: `/downloads/${firstItem.SeriesId}`,
|
|
||||||
params: {
|
|
||||||
episodeSeasonIndex: firstItem.ParentIndexNumber,
|
|
||||||
},
|
|
||||||
} as Href)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const acceptDownloadOptions = useCallback(() => {
|
|
||||||
if (userCanDownload === true) {
|
|
||||||
if (itemsNotDownloaded.some((i) => !i.Id)) {
|
|
||||||
throw new Error("No item id");
|
|
||||||
}
|
|
||||||
closeModal();
|
|
||||||
|
|
||||||
if (usingOptimizedServer) initiateDownload(...itemsNotDownloaded);
|
|
||||||
else {
|
|
||||||
queueActions.enqueue(
|
|
||||||
queue,
|
|
||||||
setQueue,
|
|
||||||
...itemsNotDownloaded.map((item) => ({
|
|
||||||
id: item.Id!,
|
|
||||||
execute: async () => await initiateDownload(item),
|
|
||||||
item,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
toast.error("You are not allowed to download files.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let deviceProfile: any = ios;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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") {
|
||||||
|
console.log("Using direct stream for 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) {
|
||||||
|
console.log("Using transcoded stream!");
|
||||||
|
url = `${api.basePath}${mediaSource.TranscodingUrl}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!url) throw new Error("No url");
|
||||||
|
|
||||||
|
return await startRemuxing(url);
|
||||||
}, [
|
}, [
|
||||||
queue,
|
api,
|
||||||
setQueue,
|
item,
|
||||||
itemsNotDownloaded,
|
startRemuxing,
|
||||||
usingOptimizedServer,
|
user?.Id,
|
||||||
userCanDownload,
|
|
||||||
maxBitrate,
|
|
||||||
selectedMediaSource,
|
selectedMediaSource,
|
||||||
selectedAudioStream,
|
selectedAudioStream,
|
||||||
selectedSubtitleStream,
|
selectedSubtitleStream,
|
||||||
|
maxBitrate,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const initiateDownload = useCallback(
|
/**
|
||||||
async (...items: BaseItemDto[]) => {
|
* Check if item is downloaded
|
||||||
if (
|
*/
|
||||||
!api ||
|
const { data: downloaded, isFetching } = useQuery({
|
||||||
!user?.Id ||
|
queryKey: ["downloaded", item.Id],
|
||||||
items.some((p) => !p.Id) ||
|
queryFn: async () => {
|
||||||
(itemsNotDownloaded.length === 1 && !selectedMediaSource?.Id)
|
if (!item.Id) return false;
|
||||||
) {
|
|
||||||
throw new Error(
|
|
||||||
"DownloadItem ~ initiateDownload: No api or user or item"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let mediaSource = selectedMediaSource;
|
|
||||||
let audioIndex: number | undefined = selectedAudioStream;
|
|
||||||
let subtitleIndex: number | undefined = selectedSubtitleStream;
|
|
||||||
|
|
||||||
for (const item of items) {
|
const data: BaseItemDto[] = JSON.parse(
|
||||||
if (itemsNotDownloaded.length > 1) {
|
(await AsyncStorage.getItem("downloaded_files")) || "[]"
|
||||||
({ mediaSource, audioIndex, subtitleIndex } = getDefaultPlaySettings(
|
);
|
||||||
item,
|
|
||||||
settings!
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await getStreamUrl({
|
return data.some((d) => d.Id === item.Id);
|
||||||
api,
|
|
||||||
item,
|
|
||||||
startTimeTicks: 0,
|
|
||||||
userId: user?.Id,
|
|
||||||
audioStreamIndex: audioIndex,
|
|
||||||
maxStreamingBitrate: maxBitrate.value,
|
|
||||||
mediaSourceId: mediaSource?.Id,
|
|
||||||
subtitleStreamIndex: subtitleIndex,
|
|
||||||
deviceProfile: download,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res) {
|
|
||||||
Alert.alert(
|
|
||||||
"Something went wrong",
|
|
||||||
"Could not get stream url from Jellyfin"
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { mediaSource: source, url } = res;
|
|
||||||
|
|
||||||
if (!url || !source) throw new Error("No url");
|
|
||||||
|
|
||||||
saveDownloadItemInfoToDiskTmp(item, source, url);
|
|
||||||
|
|
||||||
if (usingOptimizedServer) {
|
|
||||||
await startBackgroundDownload(url, item, source);
|
|
||||||
} else {
|
|
||||||
await startRemuxing(item, url, source);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[
|
enabled: !!item.Id,
|
||||||
api,
|
});
|
||||||
user?.Id,
|
|
||||||
itemsNotDownloaded,
|
|
||||||
selectedMediaSource,
|
|
||||||
selectedAudioStream,
|
|
||||||
selectedSubtitleStream,
|
|
||||||
settings,
|
|
||||||
maxBitrate,
|
|
||||||
usingOptimizedServer,
|
|
||||||
startBackgroundDownload,
|
|
||||||
startRemuxing,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderBackdrop = useCallback(
|
const renderBackdrop = useCallback(
|
||||||
(props: BottomSheetBackdropProps) => (
|
(props: BottomSheetBackdropProps) => (
|
||||||
@@ -256,61 +194,55 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
),
|
),
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
useFocusEffect(
|
|
||||||
useCallback(() => {
|
|
||||||
if (!settings) return;
|
|
||||||
if (itemsNotDownloaded.length !== 1) return;
|
|
||||||
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
|
|
||||||
getDefaultPlaySettings(items[0], settings);
|
|
||||||
|
|
||||||
setSelectedMediaSource(mediaSource ?? undefined);
|
|
||||||
setSelectedAudioStream(audioIndex ?? 0);
|
|
||||||
setSelectedSubtitleStream(subtitleIndex ?? -1);
|
|
||||||
setMaxBitrate(bitrate);
|
|
||||||
}, [items, itemsNotDownloaded, settings])
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderButtonContent = () => {
|
|
||||||
if (processes && itemsProcesses.length > 0) {
|
|
||||||
return progress === 0 ? (
|
|
||||||
<Loader />
|
|
||||||
) : (
|
|
||||||
<View className="-rotate-45">
|
|
||||||
<ProgressCircle
|
|
||||||
size={24}
|
|
||||||
fill={progress}
|
|
||||||
width={4}
|
|
||||||
tintColor="#9334E9"
|
|
||||||
backgroundColor="#bdc3c7"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
} else if (itemsQueued) {
|
|
||||||
return <Ionicons name="hourglass" size={24} color="white" />;
|
|
||||||
} else if (allItemsDownloaded) {
|
|
||||||
return <DownloadedIconComponent />;
|
|
||||||
} else {
|
|
||||||
return <MissingDownloadIconComponent />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onButtonPress = () => {
|
|
||||||
if (processes && itemsProcesses.length > 0) {
|
|
||||||
navigateToDownloads();
|
|
||||||
} else if (itemsQueued) {
|
|
||||||
navigateToDownloads();
|
|
||||||
} else if (allItemsDownloaded) {
|
|
||||||
onDownloadedPress();
|
|
||||||
} else {
|
|
||||||
handlePresentModalPress();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View
|
||||||
<RoundButton size={size} onPress={onButtonPress}>
|
className="bg-neutral-800/80 rounded-full h-10 w-10 flex items-center justify-center"
|
||||||
{renderButtonContent()}
|
{...props}
|
||||||
</RoundButton>
|
>
|
||||||
|
{isFetching ? (
|
||||||
|
<Loader />
|
||||||
|
) : 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>
|
||||||
|
) : downloaded ? (
|
||||||
|
<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
|
<BottomSheetModal
|
||||||
ref={bottomSheetModalRef}
|
ref={bottomSheetModalRef}
|
||||||
enableDynamicSizing
|
enableDynamicSizing
|
||||||
@@ -325,81 +257,61 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
>
|
>
|
||||||
<BottomSheetView>
|
<BottomSheetView>
|
||||||
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
|
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
|
||||||
<View>
|
<Text className="font-bold text-2xl text-neutral-10">
|
||||||
<Text className="font-bold text-2xl text-neutral-100">
|
Download options
|
||||||
{title}
|
</Text>
|
||||||
</Text>
|
|
||||||
<Text className="text-neutral-300">
|
|
||||||
{subtitle || `Download ${itemsNotDownloaded.length} items`}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View className="flex flex-col space-y-2 w-full items-start">
|
<View className="flex flex-col space-y-2 w-full items-start">
|
||||||
<BitrateSelector
|
<BitrateSelector
|
||||||
inverted
|
inverted
|
||||||
onChange={setMaxBitrate}
|
onChange={(val) => setMaxBitrate(val)}
|
||||||
selected={maxBitrate}
|
selected={maxBitrate}
|
||||||
/>
|
/>
|
||||||
{itemsNotDownloaded.length === 1 && (
|
<MediaSourceSelector
|
||||||
<>
|
item={item}
|
||||||
<MediaSourceSelector
|
onChange={setSelectedMediaSource}
|
||||||
item={items[0]}
|
selected={selectedMediaSource}
|
||||||
onChange={setSelectedMediaSource}
|
/>
|
||||||
selected={selectedMediaSource}
|
{selectedMediaSource && (
|
||||||
|
<View className="flex flex-col space-y-2">
|
||||||
|
<AudioTrackSelector
|
||||||
|
source={selectedMediaSource}
|
||||||
|
onChange={setSelectedAudioStream}
|
||||||
|
selected={selectedAudioStream}
|
||||||
/>
|
/>
|
||||||
{selectedMediaSource && (
|
<SubtitleTrackSelector
|
||||||
<View className="flex flex-col space-y-2">
|
source={selectedMediaSource}
|
||||||
<AudioTrackSelector
|
onChange={setSelectedSubtitleStream}
|
||||||
source={selectedMediaSource}
|
selected={selectedSubtitleStream}
|
||||||
onChange={setSelectedAudioStream}
|
/>
|
||||||
selected={selectedAudioStream}
|
</View>
|
||||||
/>
|
|
||||||
<SubtitleTrackSelector
|
|
||||||
source={selectedMediaSource}
|
|
||||||
onChange={setSelectedSubtitleStream}
|
|
||||||
selected={selectedSubtitleStream}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
<Button
|
<Button
|
||||||
className="mt-auto"
|
className="mt-auto"
|
||||||
onPress={acceptDownloadOptions}
|
onPress={() => {
|
||||||
|
if (userCanDownload === true) {
|
||||||
|
closeModal();
|
||||||
|
queueActions.enqueue(queue, setQueue, {
|
||||||
|
id: item.Id!,
|
||||||
|
execute: async () => {
|
||||||
|
await initiateDownload();
|
||||||
|
},
|
||||||
|
item,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
Alert.alert(
|
||||||
|
"Disabled",
|
||||||
|
"This user is not allowed to download files."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
color="purple"
|
color="purple"
|
||||||
>
|
>
|
||||||
Download
|
Download
|
||||||
</Button>
|
</Button>
|
||||||
<View className="opacity-70 text-center w-full flex items-center">
|
|
||||||
<Text className="text-xs">
|
|
||||||
{usingOptimizedServer
|
|
||||||
? "Using optimized server"
|
|
||||||
: "Using default method"}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
</BottomSheetView>
|
</BottomSheetView>
|
||||||
</BottomSheetModal>
|
</BottomSheetModal>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DownloadSingleItem: React.FC<{
|
|
||||||
size?: "default" | "large";
|
|
||||||
item: BaseItemDto;
|
|
||||||
}> = ({ item, size = "default" }) => {
|
|
||||||
return (
|
|
||||||
<DownloadItems
|
|
||||||
size={size}
|
|
||||||
title="Download Episode"
|
|
||||||
subtitle={item.Name!}
|
|
||||||
items={[item]}
|
|
||||||
MissingDownloadIconComponent={() => (
|
|
||||||
<Ionicons name="cloud-download-outline" size={24} color="white" />
|
|
||||||
)}
|
|
||||||
DownloadedIconComponent={() => (
|
|
||||||
<Ionicons name="cloud-download" size={26} color="#9333ea" />
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
// GenreTags.tsx
|
|
||||||
import React from "react";
|
|
||||||
import { View } from "react-native";
|
|
||||||
import { Text } from "./common/Text";
|
|
||||||
|
|
||||||
interface GenreTagsProps {
|
|
||||||
genres?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const GenreTags: React.FC<GenreTagsProps> = ({ genres }) => {
|
|
||||||
if (!genres || genres.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className="flex flex-row flex-wrap mt-2">
|
|
||||||
{genres.map((genre, idx) => (
|
|
||||||
<View key={idx} className="bg-neutral-800 rounded-full px-2 py-1 mr-1">
|
|
||||||
<Text className="text-xs">{genre}</Text>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -10,16 +10,15 @@ type ItemCardProps = {
|
|||||||
|
|
||||||
export const ItemCardText: React.FC<ItemCardProps> = ({ item }) => {
|
export const ItemCardText: React.FC<ItemCardProps> = ({ item }) => {
|
||||||
return (
|
return (
|
||||||
<View className="mt-2 flex flex-col">
|
<View className="mt-2 flex flex-col h-12">
|
||||||
{item.Type === "Episode" ? (
|
{item.Type === "Episode" ? (
|
||||||
<>
|
<>
|
||||||
<Text numberOfLines={1} className="">
|
<Text numberOfLines={2} className="">
|
||||||
{item.Name}
|
{item.SeriesName}
|
||||||
</Text>
|
</Text>
|
||||||
<Text numberOfLines={1} className="text-xs opacity-50">
|
<Text numberOfLines={1} className="text-xs opacity-50">
|
||||||
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
|
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}{" "}
|
||||||
{" - "}
|
{item.Name}
|
||||||
{item.SeriesName}
|
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
||||||
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
||||||
import { DownloadSingleItem } from "@/components/DownloadItem";
|
import { DownloadItem } from "@/components/DownloadItem";
|
||||||
import { OverviewText } from "@/components/OverviewText";
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
import { PlayButton } from "@/components/PlayButton";
|
import { PlayButton } from "@/components/PlayButton";
|
||||||
@@ -11,284 +11,363 @@ import { ItemImage } from "@/components/common/ItemImage";
|
|||||||
import { CastAndCrew } from "@/components/series/CastAndCrew";
|
import { CastAndCrew } from "@/components/series/CastAndCrew";
|
||||||
import { CurrentSeries } from "@/components/series/CurrentSeries";
|
import { CurrentSeries } from "@/components/series/CurrentSeries";
|
||||||
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
|
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
|
||||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
|
||||||
import { useImageColors } from "@/hooks/useImageColors";
|
import { useImageColors } from "@/hooks/useImageColors";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { getItemImage } from "@/utils/getItemImage";
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
import {
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
BaseItemDto,
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
MediaSourceInfo,
|
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
||||||
MediaStream,
|
import ios from "@/utils/profiles/ios";
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
import native from "@/utils/profiles/native";
|
||||||
|
import old from "@/utils/profiles/old";
|
||||||
|
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useNavigation } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
import * as ScreenOrientation from "expo-screen-orientation";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
|
import { useCastDevice } from "react-native-google-cast";
|
||||||
|
import Animated, {
|
||||||
|
runOnJS,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withTiming,
|
||||||
|
} from "react-native-reanimated";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Chromecast } from "./Chromecast";
|
import { Chromecast } from "./Chromecast";
|
||||||
import { ItemHeader } from "./ItemHeader";
|
import { ItemHeader } from "./ItemHeader";
|
||||||
|
import { Loader } from "./Loader";
|
||||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||||
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
|
||||||
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
|
||||||
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
|
||||||
|
|
||||||
export type SelectedOptions = {
|
export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
|
||||||
bitrate: Bitrate;
|
const [api] = useAtom(apiAtom);
|
||||||
mediaSource: MediaSourceInfo | undefined;
|
const [user] = useAtom(userAtom);
|
||||||
audioIndex: number | undefined;
|
|
||||||
subtitleIndex: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
const opacity = useSharedValue(0);
|
||||||
({ item }) => {
|
const castDevice = useCastDevice();
|
||||||
const [api] = useAtom(apiAtom);
|
const navigation = useNavigation();
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
const { orientation } = useOrientation();
|
const [selectedMediaSource, setSelectedMediaSource] =
|
||||||
const navigation = useNavigation();
|
useState<MediaSourceInfo | null>(null);
|
||||||
const insets = useSafeAreaInsets();
|
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
||||||
useImageColors({ item });
|
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
||||||
|
useState<number>(0);
|
||||||
|
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
|
||||||
|
key: "Max",
|
||||||
|
value: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
const [loadingLogo, setLoadingLogo] = useState(true);
|
const [loadingLogo, setLoadingLogo] = useState(true);
|
||||||
const [headerHeight, setHeaderHeight] = useState(350);
|
|
||||||
|
|
||||||
const [selectedOptions, setSelectedOptions] = useState<
|
const [orientation, setOrientation] = useState(
|
||||||
SelectedOptions | undefined
|
ScreenOrientation.Orientation.PORTRAIT_UP
|
||||||
>(undefined);
|
);
|
||||||
|
|
||||||
const {
|
useEffect(() => {
|
||||||
defaultAudioIndex,
|
const subscription = ScreenOrientation.addOrientationChangeListener(
|
||||||
defaultBitrate,
|
(event) => {
|
||||||
defaultMediaSource,
|
setOrientation(event.orientationInfo.orientation);
|
||||||
defaultSubtitleIndex,
|
}
|
||||||
} = useDefaultPlaySettings(item, settings);
|
);
|
||||||
|
|
||||||
// Needs to automatically change the selected to the default values for default indexes.
|
ScreenOrientation.getOrientationAsync().then((initialOrientation) => {
|
||||||
useEffect(() => {
|
setOrientation(initialOrientation);
|
||||||
console.log(defaultAudioIndex, defaultSubtitleIndex);
|
});
|
||||||
setSelectedOptions(() => ({
|
|
||||||
bitrate: defaultBitrate,
|
|
||||||
mediaSource: defaultMediaSource,
|
|
||||||
subtitleIndex: defaultSubtitleIndex ?? -1,
|
|
||||||
audioIndex: defaultAudioIndex,
|
|
||||||
}));
|
|
||||||
}, [
|
|
||||||
defaultAudioIndex,
|
|
||||||
defaultBitrate,
|
|
||||||
defaultSubtitleIndex,
|
|
||||||
defaultMediaSource,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
return () => {
|
||||||
navigation.setOptions({
|
ScreenOrientation.removeOrientationChangeListener(subscription);
|
||||||
headerRight: () =>
|
};
|
||||||
item && (
|
}, []);
|
||||||
<View className="flex flex-row items-center space-x-2">
|
|
||||||
<Chromecast background="blur" width={22} height={22} />
|
const animatedStyle = useAnimatedStyle(() => {
|
||||||
{item.Type !== "Program" && (
|
return {
|
||||||
<View className="flex flex-row items-center space-x-2">
|
opacity: opacity.value,
|
||||||
<DownloadSingleItem item={item} size="large" />
|
};
|
||||||
<PlayedStatus item={item} />
|
});
|
||||||
</View>
|
|
||||||
)}
|
const fadeIn = () => {
|
||||||
</View>
|
opacity.value = withTiming(1, { duration: 300 });
|
||||||
),
|
};
|
||||||
|
|
||||||
|
const fadeOut = (callback: any) => {
|
||||||
|
opacity.value = withTiming(0, { duration: 300 }, (finished) => {
|
||||||
|
if (finished) {
|
||||||
|
runOnJS(callback)();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const headerHeightRef = useRef(400);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: item,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["item", id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await getUserItemData({
|
||||||
|
api,
|
||||||
|
userId: user?.Id,
|
||||||
|
itemId: id,
|
||||||
});
|
});
|
||||||
}, [item]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
return res;
|
||||||
if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP)
|
},
|
||||||
setHeaderHeight(230);
|
enabled: !!id && !!api,
|
||||||
else if (item.Type === "Movie") setHeaderHeight(500);
|
staleTime: 60 * 1000 * 5,
|
||||||
else setHeaderHeight(350);
|
});
|
||||||
}, [item.Type, orientation]);
|
|
||||||
|
|
||||||
const logoUrl = useMemo(() => getLogoImageUrlById({ api, item }), [item]);
|
const [localItem, setLocalItem] = useState(item);
|
||||||
|
|
||||||
const loading = useMemo(() => {
|
useEffect(() => {
|
||||||
return Boolean(logoUrl && loadingLogo);
|
if (item) {
|
||||||
}, [loadingLogo, logoUrl]);
|
if (localItem) {
|
||||||
|
// Fade out current item
|
||||||
const [isTranscoding, setIsTranscoding] = useState(false);
|
fadeOut(() => {
|
||||||
const [previouslyChosenSubtitleIndex, setPreviouslyChosenSubtitleIndex] =
|
// Update local item after fade out
|
||||||
useState<number | undefined>(selectedOptions?.subtitleIndex);
|
setLocalItem(item);
|
||||||
|
// Then fade in
|
||||||
useEffect(() => {
|
fadeIn();
|
||||||
const isTranscoding = Boolean(selectedOptions?.bitrate.value);
|
});
|
||||||
if (isTranscoding) {
|
} else {
|
||||||
setPreviouslyChosenSubtitleIndex(selectedOptions?.subtitleIndex);
|
// If there's no current item, just set and fade in
|
||||||
const subHelper = new SubtitleHelper(
|
setLocalItem(item);
|
||||||
selectedOptions?.mediaSource?.MediaStreams ?? []
|
fadeIn();
|
||||||
);
|
|
||||||
|
|
||||||
const newSubtitleIndex = subHelper.getMostCommonSubtitleByName(
|
|
||||||
selectedOptions?.subtitleIndex
|
|
||||||
);
|
|
||||||
|
|
||||||
setSelectedOptions((prev) => ({
|
|
||||||
...prev!,
|
|
||||||
subtitleIndex: newSubtitleIndex ?? -1,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
if (!isTranscoding && previouslyChosenSubtitleIndex !== undefined) {
|
} else {
|
||||||
setSelectedOptions((prev) => ({
|
// If item is null, fade out and clear local item
|
||||||
...prev!,
|
fadeOut(() => setLocalItem(null));
|
||||||
subtitleIndex: previouslyChosenSubtitleIndex,
|
}
|
||||||
}));
|
}, [item]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
navigation.setOptions({
|
||||||
|
headerRight: () =>
|
||||||
|
item && (
|
||||||
|
<View className="flex flex-row items-center space-x-2">
|
||||||
|
<Chromecast background="blur" width={22} height={22} />
|
||||||
|
<DownloadItem item={item} />
|
||||||
|
<PlayedStatus item={item} />
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (orientation !== ScreenOrientation.Orientation.PORTRAIT_UP) {
|
||||||
|
headerHeightRef.current = 230;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (item?.Type === "Episode") headerHeightRef.current = 400;
|
||||||
|
else if (item?.Type === "Movie") headerHeightRef.current = 500;
|
||||||
|
else headerHeightRef.current = 400;
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
const { data: sessionData } = useQuery({
|
||||||
|
queryKey: ["sessionData", item?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api || !user?.Id || !item?.Id) return null;
|
||||||
|
const playbackData = await getMediaInfoApi(api!).getPlaybackInfo({
|
||||||
|
itemId: item?.Id,
|
||||||
|
userId: user?.Id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return playbackData.data;
|
||||||
|
},
|
||||||
|
enabled: !!item?.Id && !!api && !!user?.Id,
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: playbackUrl } = useQuery({
|
||||||
|
queryKey: [
|
||||||
|
"playbackUrl",
|
||||||
|
item?.Id,
|
||||||
|
maxBitrate,
|
||||||
|
castDevice,
|
||||||
|
selectedMediaSource,
|
||||||
|
selectedAudioStream,
|
||||||
|
selectedSubtitleStream,
|
||||||
|
settings,
|
||||||
|
],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api || !user?.Id || !sessionData || !selectedMediaSource?.Id)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
let deviceProfile: any = ios;
|
||||||
|
|
||||||
|
if (castDevice?.deviceId) {
|
||||||
|
deviceProfile = chromecastProfile;
|
||||||
|
} else if (settings?.deviceProfile === "Native") {
|
||||||
|
deviceProfile = native;
|
||||||
|
} else if (settings?.deviceProfile === "Old") {
|
||||||
|
deviceProfile = old;
|
||||||
}
|
}
|
||||||
setIsTranscoding(isTranscoding);
|
|
||||||
}, [selectedOptions?.bitrate]);
|
|
||||||
|
|
||||||
if (!selectedOptions) return null;
|
const url = await getStreamUrl({
|
||||||
|
api,
|
||||||
|
userId: user.Id,
|
||||||
|
item,
|
||||||
|
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
|
||||||
|
maxStreamingBitrate: maxBitrate.value,
|
||||||
|
sessionData,
|
||||||
|
deviceProfile,
|
||||||
|
audioStreamIndex: selectedAudioStream,
|
||||||
|
subtitleStreamIndex: selectedSubtitleStream,
|
||||||
|
forceDirectPlay: settings?.forceDirectPlay,
|
||||||
|
height: maxBitrate.height,
|
||||||
|
mediaSourceId: selectedMediaSource.Id,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
console.info("Stream URL:", url);
|
||||||
<View
|
|
||||||
className="flex-1 relative"
|
return url;
|
||||||
style={{
|
},
|
||||||
paddingLeft: insets.left,
|
enabled: !!sessionData && !!api && !!user?.Id && !!item?.Id,
|
||||||
paddingRight: insets.right,
|
staleTime: 0,
|
||||||
}}
|
});
|
||||||
>
|
|
||||||
<ParallaxScrollView
|
const logoUrl = useMemo(() => getLogoImageUrlById({ api, item }), [item]);
|
||||||
className={`flex-1 ${loading ? "opacity-0" : "opacity-100"}`}
|
const themeImageColorSource = useMemo(() => {
|
||||||
headerHeight={headerHeight}
|
if (!api || !item) return;
|
||||||
headerImage={
|
return getItemImage({
|
||||||
<View style={[{ flex: 1 }]}>
|
item,
|
||||||
<ItemImage
|
api,
|
||||||
variant={
|
variant: "Primary",
|
||||||
item.Type === "Movie" && logoUrl ? "Backdrop" : "Primary"
|
quality: 80,
|
||||||
}
|
width: 300,
|
||||||
item={item}
|
});
|
||||||
style={{
|
}, [api, item]);
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
useImageColors(themeImageColorSource?.uri);
|
||||||
}}
|
|
||||||
/>
|
const loading = useMemo(() => {
|
||||||
</View>
|
return Boolean(isLoading || isFetching || (logoUrl && loadingLogo));
|
||||||
}
|
}, [isLoading, isFetching, loadingLogo, logoUrl]);
|
||||||
logo={
|
|
||||||
<>
|
const insets = useSafeAreaInsets();
|
||||||
{logoUrl ? (
|
|
||||||
<Image
|
return (
|
||||||
source={{
|
<View
|
||||||
uri: logoUrl,
|
className="flex-1 relative"
|
||||||
}}
|
style={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading && (
|
||||||
|
<View className="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex flex-col justify-center items-center z-50">
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<ParallaxScrollView
|
||||||
|
className={`flex-1 ${loading ? "opacity-0" : "opacity-100"}`}
|
||||||
|
headerHeight={headerHeightRef.current}
|
||||||
|
headerImage={
|
||||||
|
<>
|
||||||
|
<Animated.View style={[animatedStyle, { flex: 1 }]}>
|
||||||
|
{localItem && (
|
||||||
|
<ItemImage
|
||||||
|
useThemeColor
|
||||||
|
variant={
|
||||||
|
localItem.Type === "Movie" && logoUrl
|
||||||
|
? "Backdrop"
|
||||||
|
: "Primary"
|
||||||
|
}
|
||||||
|
item={localItem}
|
||||||
style={{
|
style={{
|
||||||
height: 130,
|
|
||||||
width: "100%",
|
width: "100%",
|
||||||
resizeMode: "contain",
|
height: "100%",
|
||||||
}}
|
}}
|
||||||
onLoad={() => setLoadingLogo(false)}
|
|
||||||
onError={() => setLoadingLogo(false)}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
)}
|
||||||
</>
|
</Animated.View>
|
||||||
}
|
</>
|
||||||
>
|
}
|
||||||
<View className="flex flex-col bg-transparent shrink">
|
logo={
|
||||||
<View className="flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink">
|
<>
|
||||||
<ItemHeader item={item} className="mb-4" />
|
{logoUrl ? (
|
||||||
{item.Type !== "Program" && (
|
<Image
|
||||||
|
source={{
|
||||||
|
uri: logoUrl,
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
height: 130,
|
||||||
|
width: "100%",
|
||||||
|
resizeMode: "contain",
|
||||||
|
}}
|
||||||
|
onLoad={() => setLoadingLogo(false)}
|
||||||
|
onError={() => setLoadingLogo(false)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<View className="flex flex-col bg-transparent shrink">
|
||||||
|
<View className="flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink">
|
||||||
|
<Animated.View style={[animatedStyle, { flex: 1 }]}>
|
||||||
|
<ItemHeader item={localItem} className="mb-4" />
|
||||||
|
{localItem ? (
|
||||||
<View className="flex flex-row items-center justify-start w-full h-16">
|
<View className="flex flex-row items-center justify-start w-full h-16">
|
||||||
<BitrateSelector
|
<BitrateSelector
|
||||||
className="mr-1"
|
className="mr-1"
|
||||||
onChange={(val) =>
|
onChange={(val) => setMaxBitrate(val)}
|
||||||
setSelectedOptions(
|
selected={maxBitrate}
|
||||||
(prev) => prev && { ...prev, bitrate: val }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
selected={selectedOptions.bitrate}
|
|
||||||
/>
|
/>
|
||||||
<MediaSourceSelector
|
<MediaSourceSelector
|
||||||
className="mr-1"
|
className="mr-1"
|
||||||
item={item}
|
item={localItem}
|
||||||
onChange={(val) =>
|
onChange={setSelectedMediaSource}
|
||||||
setSelectedOptions(
|
selected={selectedMediaSource}
|
||||||
(prev) =>
|
|
||||||
prev && {
|
|
||||||
...prev,
|
|
||||||
mediaSource: val,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
selected={selectedOptions.mediaSource}
|
|
||||||
/>
|
|
||||||
<AudioTrackSelector
|
|
||||||
className="mr-1"
|
|
||||||
source={selectedOptions.mediaSource}
|
|
||||||
onChange={(val) => {
|
|
||||||
console.log(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}
|
|
||||||
/>
|
/>
|
||||||
|
{selectedMediaSource && (
|
||||||
|
<>
|
||||||
|
<AudioTrackSelector
|
||||||
|
className="mr-1"
|
||||||
|
source={selectedMediaSource}
|
||||||
|
onChange={setSelectedAudioStream}
|
||||||
|
selected={selectedAudioStream}
|
||||||
|
/>
|
||||||
|
<SubtitleTrackSelector
|
||||||
|
source={selectedMediaSource}
|
||||||
|
onChange={setSelectedSubtitleStream}
|
||||||
|
selected={selectedSubtitleStream}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View className="h-16">
|
||||||
|
<View className="bg-neutral-900 h-4 w-2/4 rounded-md mb-1"></View>
|
||||||
|
<View className="bg-neutral-900 h-10 w-3/4 rounded-lg"></View>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
<PlayButton
|
<PlayButton item={item} url={playbackUrl} className="grow" />
|
||||||
className="grow"
|
|
||||||
selectedOptions={selectedOptions}
|
|
||||||
item={item}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{item.Type === "Episode" && (
|
|
||||||
<SeasonEpisodesCarousel item={item} loading={loading} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
|
|
||||||
<OverviewText text={item.Overview} className="px-4 mb-4" />
|
|
||||||
|
|
||||||
{item.Type !== "Program" && (
|
|
||||||
<>
|
|
||||||
{item.Type === "Episode" && (
|
|
||||||
<CurrentSeries item={item} className="mb-4" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<CastAndCrew item={item} className="mb-4" loading={loading} />
|
|
||||||
|
|
||||||
{item.People && item.People.length > 0 && (
|
|
||||||
<View className="mb-4">
|
|
||||||
{item.People.slice(0, 3).map((person, idx) => (
|
|
||||||
<MoreMoviesWithActor
|
|
||||||
currentItem={item}
|
|
||||||
key={idx}
|
|
||||||
actorId={person.Id!}
|
|
||||||
className="mb-4"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<SimilarItems itemId={item.Id} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</ParallaxScrollView>
|
|
||||||
</View>
|
{item?.Type === "Episode" && (
|
||||||
);
|
<SeasonEpisodesCarousel item={item} loading={loading} />
|
||||||
}
|
)}
|
||||||
);
|
|
||||||
|
<OverviewText text={item?.Overview} className="px-4 mb-4" />
|
||||||
|
|
||||||
|
<CastAndCrew item={item} className="mb-4" loading={loading} />
|
||||||
|
|
||||||
|
{item?.Type === "Episode" && (
|
||||||
|
<CurrentSeries item={item} className="mb-4" />
|
||||||
|
)}
|
||||||
|
<SimilarItems itemId={item?.Id} />
|
||||||
|
|
||||||
|
<View className="h-16"></View>
|
||||||
|
</View>
|
||||||
|
</ParallaxScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import { View, ViewProps } from "react-native";
|
|||||||
import { MoviesTitleHeader } from "./movies/MoviesTitleHeader";
|
import { MoviesTitleHeader } from "./movies/MoviesTitleHeader";
|
||||||
import { Ratings } from "./Ratings";
|
import { Ratings } from "./Ratings";
|
||||||
import { EpisodeTitleHeader } from "./series/EpisodeTitleHeader";
|
import { EpisodeTitleHeader } from "./series/EpisodeTitleHeader";
|
||||||
import { GenreTags } from "./GenreTags";
|
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
item?: BaseItemDto | null;
|
item?: BaseItemDto | null;
|
||||||
@@ -14,7 +12,7 @@ export const ItemHeader: React.FC<Props> = ({ item, ...props }) => {
|
|||||||
if (!item)
|
if (!item)
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className="flex flex-col space-y-1.5 w-full items-start h-32"
|
className="flex flex-col space-y-1.5 w-full items-start h-24"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<View className="w-1/3 h-6 bg-neutral-900 rounded" />
|
<View className="w-1/3 h-6 bg-neutral-900 rounded" />
|
||||||
@@ -25,22 +23,16 @@ export const ItemHeader: React.FC<Props> = ({ item, ...props }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex flex-col" {...props}>
|
<View
|
||||||
<View className="flex flex-col" {...props}>
|
style={{
|
||||||
<Ratings item={item} className="mb-2" />
|
minHeight: 96,
|
||||||
{item.Type === "Episode" && (
|
}}
|
||||||
<>
|
className="flex flex-col"
|
||||||
<EpisodeTitleHeader item={item} />
|
{...props}
|
||||||
<GenreTags genres={item.Genres!} />
|
>
|
||||||
</>
|
<Ratings item={item} className="mb-2" />
|
||||||
)}
|
{item.Type === "Episode" && <EpisodeTitleHeader item={item} />}
|
||||||
{item.Type === "Movie" && (
|
{item.Type === "Movie" && <MoviesTitleHeader item={item} />}
|
||||||
<>
|
|
||||||
<MoviesTitleHeader item={item} />
|
|
||||||
<GenreTags genres={item.Genres!} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,236 +0,0 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import {
|
|
||||||
MediaSourceInfo,
|
|
||||||
type MediaStream,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import React, { useMemo, useRef } from "react";
|
|
||||||
import { TouchableOpacity, View } from "react-native";
|
|
||||||
import { Badge } from "./Badge";
|
|
||||||
import { Text } from "./common/Text";
|
|
||||||
import {
|
|
||||||
BottomSheetModal,
|
|
||||||
BottomSheetBackdropProps,
|
|
||||||
BottomSheetBackdrop,
|
|
||||||
BottomSheetView,
|
|
||||||
BottomSheetScrollView,
|
|
||||||
} from "@gorhom/bottom-sheet";
|
|
||||||
import { Button } from "./Button";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
source?: MediaSourceInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
|
|
||||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className="px-4 mt-2 mb-4">
|
|
||||||
<Text className="text-lg font-bold mb-4">Video</Text>
|
|
||||||
<TouchableOpacity onPress={() => bottomSheetModalRef.current?.present()}>
|
|
||||||
<View className="flex flex-row space-x-2">
|
|
||||||
<VideoStreamInfo source={source} />
|
|
||||||
</View>
|
|
||||||
<Text className="text-purple-600">More details</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<BottomSheetModal
|
|
||||||
ref={bottomSheetModalRef}
|
|
||||||
snapPoints={["80%"]}
|
|
||||||
handleIndicatorStyle={{
|
|
||||||
backgroundColor: "white",
|
|
||||||
}}
|
|
||||||
backgroundStyle={{
|
|
||||||
backgroundColor: "#171717",
|
|
||||||
}}
|
|
||||||
backdropComponent={(props: BottomSheetBackdropProps) => (
|
|
||||||
<BottomSheetBackdrop
|
|
||||||
{...props}
|
|
||||||
disappearsOnIndex={-1}
|
|
||||||
appearsOnIndex={0}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<BottomSheetScrollView>
|
|
||||||
<View className="flex flex-col space-y-2 p-4 mb-4">
|
|
||||||
<View className="">
|
|
||||||
<Text className="text-lg font-bold mb-4">Video</Text>
|
|
||||||
<View className="flex flex-row space-x-2">
|
|
||||||
<VideoStreamInfo source={source} />
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className="">
|
|
||||||
<Text className="text-lg font-bold mb-2">Audio</Text>
|
|
||||||
<AudioStreamInfo
|
|
||||||
audioStreams={
|
|
||||||
source?.MediaStreams?.filter(
|
|
||||||
(stream) => stream.Type === "Audio"
|
|
||||||
) || []
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className="">
|
|
||||||
<Text className="text-lg font-bold mb-2">Subtitles</Text>
|
|
||||||
<SubtitleStreamInfo
|
|
||||||
subtitleStreams={
|
|
||||||
source?.MediaStreams?.filter(
|
|
||||||
(stream) => stream.Type === "Subtitle"
|
|
||||||
) || []
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</BottomSheetScrollView>
|
|
||||||
</BottomSheetModal>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const SubtitleStreamInfo = ({
|
|
||||||
subtitleStreams,
|
|
||||||
}: {
|
|
||||||
subtitleStreams: MediaStream[];
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<View className="flex flex-col">
|
|
||||||
{subtitleStreams.map((stream, index) => (
|
|
||||||
<View key={stream.Index} className="flex flex-col">
|
|
||||||
<Text className="text-xs mb-3 text-neutral-400">
|
|
||||||
{stream.DisplayTitle}
|
|
||||||
</Text>
|
|
||||||
<View className="flex flex-row flex-wrap gap-2">
|
|
||||||
<Badge
|
|
||||||
variant="gray"
|
|
||||||
iconLeft={
|
|
||||||
<Ionicons name="language-outline" size={16} color="white" />
|
|
||||||
}
|
|
||||||
text={stream.Language}
|
|
||||||
/>
|
|
||||||
<Badge
|
|
||||||
variant="gray"
|
|
||||||
text={stream.Codec}
|
|
||||||
iconLeft={
|
|
||||||
<Ionicons name="layers-outline" size={16} color="white" />
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const AudioStreamInfo = ({ audioStreams }: { audioStreams: MediaStream[] }) => {
|
|
||||||
return (
|
|
||||||
<View className="flex flex-col">
|
|
||||||
{audioStreams.map((audioStreams, index) => (
|
|
||||||
<View key={index} className="flex flex-col">
|
|
||||||
<Text className="mb-3 text-neutral-400 text-xs">
|
|
||||||
{audioStreams.DisplayTitle}
|
|
||||||
</Text>
|
|
||||||
<View className="flex-row flex-wrap gap-2">
|
|
||||||
<Badge
|
|
||||||
variant="gray"
|
|
||||||
iconLeft={
|
|
||||||
<Ionicons name="language-outline" size={16} color="white" />
|
|
||||||
}
|
|
||||||
text={audioStreams.Language}
|
|
||||||
/>
|
|
||||||
<Badge
|
|
||||||
variant="gray"
|
|
||||||
iconLeft={
|
|
||||||
<Ionicons
|
|
||||||
name="musical-notes-outline"
|
|
||||||
size={16}
|
|
||||||
color="white"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
text={audioStreams.Codec}
|
|
||||||
/>
|
|
||||||
<Badge
|
|
||||||
variant="gray"
|
|
||||||
iconLeft={<Ionicons name="mic-outline" size={16} color="white" />}
|
|
||||||
text={audioStreams.ChannelLayout}
|
|
||||||
/>
|
|
||||||
<Badge
|
|
||||||
variant="gray"
|
|
||||||
iconLeft={
|
|
||||||
<Ionicons name="speedometer-outline" size={16} color="white" />
|
|
||||||
}
|
|
||||||
text={formatBitrate(audioStreams.BitRate)}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const VideoStreamInfo = ({ source }: { source?: MediaSourceInfo }) => {
|
|
||||||
if (!source) return null;
|
|
||||||
|
|
||||||
const videoStream = useMemo(() => {
|
|
||||||
return source.MediaStreams?.find(
|
|
||||||
(stream) => stream.Type === "Video"
|
|
||||||
) as MediaStream;
|
|
||||||
}, [source.MediaStreams]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className="flex-row flex-wrap gap-2">
|
|
||||||
<Badge
|
|
||||||
variant="gray"
|
|
||||||
iconLeft={<Ionicons name="film-outline" size={16} color="white" />}
|
|
||||||
text={formatFileSize(source.Size)}
|
|
||||||
/>
|
|
||||||
<Badge
|
|
||||||
variant="gray"
|
|
||||||
iconLeft={<Ionicons name="film-outline" size={16} color="white" />}
|
|
||||||
text={`${videoStream.Width}x${videoStream.Height}`}
|
|
||||||
/>
|
|
||||||
<Badge
|
|
||||||
variant="gray"
|
|
||||||
iconLeft={
|
|
||||||
<Ionicons name="color-palette-outline" size={16} color="white" />
|
|
||||||
}
|
|
||||||
text={videoStream.VideoRange}
|
|
||||||
/>
|
|
||||||
<Badge
|
|
||||||
variant="gray"
|
|
||||||
iconLeft={
|
|
||||||
<Ionicons name="code-working-outline" size={16} color="white" />
|
|
||||||
}
|
|
||||||
text={videoStream.Codec}
|
|
||||||
/>
|
|
||||||
<Badge
|
|
||||||
variant="gray"
|
|
||||||
iconLeft={
|
|
||||||
<Ionicons name="speedometer-outline" size={16} color="white" />
|
|
||||||
}
|
|
||||||
text={formatBitrate(videoStream.BitRate)}
|
|
||||||
/>
|
|
||||||
<Badge
|
|
||||||
variant="gray"
|
|
||||||
iconLeft={<Ionicons name="play-outline" size={16} color="white" />}
|
|
||||||
text={`${videoStream.AverageFrameRate?.toFixed(0)} fps`}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatFileSize = (bytes?: number | null) => {
|
|
||||||
if (!bytes) return "N/A";
|
|
||||||
|
|
||||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
|
|
||||||
if (bytes === 0) return "0 Byte";
|
|
||||||
const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)).toString());
|
|
||||||
return Math.round((bytes / Math.pow(1024, i)) * 100) / 100 + " " + sizes[i];
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatBitrate = (bitrate?: number | null) => {
|
|
||||||
if (!bitrate) return "N/A";
|
|
||||||
|
|
||||||
const sizes = ["bps", "Kbps", "Mbps", "Gbps", "Tbps"];
|
|
||||||
if (bitrate === 0) return "0 bps";
|
|
||||||
const i = parseInt(Math.floor(Math.log(bitrate) / Math.log(1000)).toString());
|
|
||||||
return Math.round((bitrate / Math.pow(1000, i)) * 100) / 100 + " " + sizes[i];
|
|
||||||
};
|
|
||||||
19
components/List.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { View, ViewProps } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { PropsWithChildren } from "react";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
|
export const List: React.FC<PropsWithChildren<Props>> = ({
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className="flex flex-col rounded-xl overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
import { PropsWithChildren, ReactNode } from "react";
|
import { PropsWithChildren, ReactNode } from "react";
|
||||||
import { View, ViewProps } from "react-native";
|
import {
|
||||||
|
TouchableOpacity,
|
||||||
|
TouchableOpacityProps,
|
||||||
|
View,
|
||||||
|
ViewProps,
|
||||||
|
} from "react-native";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends TouchableOpacityProps {
|
||||||
title?: string | null | undefined;
|
title?: string | null | undefined;
|
||||||
subTitle?: string | null | undefined;
|
subTitle?: string | null | undefined;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
@@ -17,19 +22,15 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<View
|
<TouchableOpacity
|
||||||
className="flex flex-row items-center justify-between bg-neutral-900 p-4"
|
className="flex flex-row items-center justify-between bg-neutral-900 p-4"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<View className="flex flex-col overflow-visible">
|
<View className="flex flex-col">
|
||||||
<Text className="font-bold ">{title}</Text>
|
<Text className="font-bold ">{title}</Text>
|
||||||
{subTitle && (
|
{subTitle && <Text className="text-xs">{subTitle}</Text>}
|
||||||
<Text uiTextView selectable className="text-xs">
|
|
||||||
{subTitle}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
{iconAfter}
|
{iconAfter}
|
||||||
</View>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,12 +7,11 @@ import { useEffect, useMemo } from "react";
|
|||||||
import { TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
import { convertBitsToMegabitsOrGigabits } from "@/utils/bToMb";
|
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof View> {
|
interface Props extends React.ComponentProps<typeof View> {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
onChange: (value: MediaSourceInfo) => void;
|
onChange: (value: MediaSourceInfo) => void;
|
||||||
selected?: MediaSourceInfo | null;
|
selected: MediaSourceInfo | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MediaSourceSelector: React.FC<Props> = ({
|
export const MediaSourceSelector: React.FC<Props> = ({
|
||||||
@@ -21,14 +20,30 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
|||||||
selected,
|
selected,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const selectedName = useMemo(
|
const mediaSources = useMemo(() => {
|
||||||
|
return item.MediaSources;
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
const selectedMediaSource = useMemo(
|
||||||
() =>
|
() =>
|
||||||
item.MediaSources?.find((x) => x.Id === selected?.Id)?.MediaStreams?.find(
|
mediaSources
|
||||||
(x) => x.Type === "Video"
|
?.find((x) => x.Id === selected?.Id)
|
||||||
)?.DisplayTitle || "",
|
?.MediaStreams?.find((x) => x.Type === "Video")?.DisplayTitle || "",
|
||||||
[item, selected]
|
[mediaSources, selected]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mediaSources?.length) onChange(mediaSources[0]);
|
||||||
|
}, [mediaSources]);
|
||||||
|
|
||||||
|
const name = (name?: string | null) => {
|
||||||
|
if (name && name.length > 40)
|
||||||
|
return (
|
||||||
|
name.substring(0, 20) + " [...] " + name.substring(name.length - 20)
|
||||||
|
);
|
||||||
|
return name;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className="flex shrink"
|
className="flex shrink"
|
||||||
@@ -40,8 +55,8 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
|||||||
<DropdownMenu.Trigger>
|
<DropdownMenu.Trigger>
|
||||||
<View className="flex flex-col" {...props}>
|
<View className="flex flex-col" {...props}>
|
||||||
<Text className="opacity-50 mb-1 text-xs">Video</Text>
|
<Text className="opacity-50 mb-1 text-xs">Video</Text>
|
||||||
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center">
|
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center ">
|
||||||
<Text numberOfLines={1}>{selectedName}</Text>
|
<Text numberOfLines={1}>{selectedMediaSource}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
@@ -55,7 +70,7 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
|||||||
sideOffset={8}
|
sideOffset={8}
|
||||||
>
|
>
|
||||||
<DropdownMenu.Label>Media sources</DropdownMenu.Label>
|
<DropdownMenu.Label>Media sources</DropdownMenu.Label>
|
||||||
{item.MediaSources?.map((source, idx: number) => (
|
{mediaSources?.map((source, idx: number) => (
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
key={idx.toString()}
|
key={idx.toString()}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
@@ -63,9 +78,7 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemTitle>
|
<DropdownMenu.ItemTitle>
|
||||||
{`${name(source.Name)} - ${convertBitsToMegabitsOrGigabits(
|
{name(source.Name)}
|
||||||
source.Size
|
|
||||||
)}`}
|
|
||||||
</DropdownMenu.ItemTitle>
|
</DropdownMenu.ItemTitle>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
))}
|
))}
|
||||||
@@ -74,9 +87,3 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const name = (name?: string | null) => {
|
|
||||||
if (name && name.length > 40)
|
|
||||||
return name.substring(0, 20) + " [...] " + name.substring(name.length - 20);
|
|
||||||
return name;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,100 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { View, ViewProps } from "react-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
|
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
|
||||||
actorId: string;
|
|
||||||
currentItem: BaseItemDto;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MoreMoviesWithActor: React.FC<Props> = ({
|
|
||||||
actorId,
|
|
||||||
currentItem,
|
|
||||||
...props
|
|
||||||
}) => {
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
|
|
||||||
const { data: actor } = useQuery({
|
|
||||||
queryKey: ["actor", actorId],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api || !user?.Id) return null;
|
|
||||||
return await getUserItemData({
|
|
||||||
api,
|
|
||||||
userId: user.Id,
|
|
||||||
itemId: actorId,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
enabled: !!api && !!user?.Id && !!actorId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: items, isLoading } = useQuery({
|
|
||||||
queryKey: ["actor", "movies", actorId, currentItem.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api || !user?.Id) return [];
|
|
||||||
const response = await getItemsApi(api).getItems({
|
|
||||||
userId: user.Id,
|
|
||||||
personIds: [actorId],
|
|
||||||
limit: 20,
|
|
||||||
sortOrder: ["Descending"],
|
|
||||||
includeItemTypes: ["Movie", "Series"],
|
|
||||||
recursive: true,
|
|
||||||
fields: ["ParentId", "PrimaryImageAspectRatio"],
|
|
||||||
sortBy: ["PremiereDate"],
|
|
||||||
collapseBoxSetItems: false,
|
|
||||||
excludeItemIds: [currentItem.SeriesId || "", currentItem.Id || ""],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Remove duplicates based on item ID
|
|
||||||
const uniqueItems =
|
|
||||||
response.data.Items?.reduce((acc, current) => {
|
|
||||||
const x = acc.find((item) => item.Id === current.Id);
|
|
||||||
if (!x) {
|
|
||||||
return acc.concat([current]);
|
|
||||||
} else {
|
|
||||||
return acc;
|
|
||||||
}
|
|
||||||
}, [] as BaseItemDto[]) || [];
|
|
||||||
|
|
||||||
return uniqueItems;
|
|
||||||
},
|
|
||||||
enabled: !!api && !!user?.Id && !!actorId,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (items?.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View {...props}>
|
|
||||||
<Text className="text-lg font-bold mb-2 px-4">
|
|
||||||
More with {actor?.Name}
|
|
||||||
</Text>
|
|
||||||
<HorizontalScroll
|
|
||||||
data={items}
|
|
||||||
loading={isLoading}
|
|
||||||
height={247}
|
|
||||||
renderItem={(item: BaseItemDto, idx: number) => (
|
|
||||||
<TouchableItemRouter
|
|
||||||
key={idx}
|
|
||||||
item={item}
|
|
||||||
className="flex flex-col w-28"
|
|
||||||
>
|
|
||||||
<View>
|
|
||||||
<MoviePoster item={item} />
|
|
||||||
<ItemCardText item={item} />
|
|
||||||
</View>
|
|
||||||
</TouchableItemRouter>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
37
components/OfflineVideoPlayer.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import React, { useEffect, useRef } from "react";
|
||||||
|
import Video, { VideoRef } from "react-native-video";
|
||||||
|
|
||||||
|
type VideoPlayerProps = {
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OfflineVideoPlayer: React.FC<VideoPlayerProps> = ({ url }) => {
|
||||||
|
const videoRef = useRef<VideoRef | null>(null);
|
||||||
|
|
||||||
|
const onError = (error: any) => {
|
||||||
|
console.error("Video Error: ", error);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.resume();
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.presentFullscreenPlayer();
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Video
|
||||||
|
source={{
|
||||||
|
uri: url,
|
||||||
|
isNetwork: false,
|
||||||
|
}}
|
||||||
|
ref={videoRef}
|
||||||
|
onError={onError}
|
||||||
|
ignoreSilentSwitch="ignore"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -19,7 +19,7 @@ export const OverviewText: React.FC<Props> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex flex-col" {...props}>
|
<View className="flex flex-col" {...props}>
|
||||||
<Text className="text-lg font-bold mb-2">Overview</Text>
|
<Text className="text-xl font-bold mb-2">Overview</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
setLimit((prev) =>
|
setLimit((prev) =>
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
import { BlurView } from "expo-blur";
|
|
||||||
import React from "react";
|
|
||||||
import { Platform, View, ViewProps } from "react-native";
|
|
||||||
interface Props extends ViewProps {
|
|
||||||
blurAmount?: number;
|
|
||||||
blurType?: "light" | "dark" | "xlight";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* BlurView for iOS and simple View for Android
|
|
||||||
*/
|
|
||||||
export const PlatformBlurView: React.FC<Props> = ({
|
|
||||||
blurAmount = 100,
|
|
||||||
blurType = "light",
|
|
||||||
style,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}) => {
|
|
||||||
if (Platform.OS === "ios") {
|
|
||||||
return (
|
|
||||||
<BlurView style={style} intensity={blurAmount} {...props}>
|
|
||||||
{children}
|
|
||||||
</BlurView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={[{ backgroundColor: "rgba(50, 50, 50, 0.9)" }, style]}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,20 +1,16 @@
|
|||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { usePlayback } from "@/providers/PlaybackProvider";
|
||||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
|
||||||
import ios from "@/utils/profiles/ios";
|
|
||||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||||
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useRouter } from "expo-router";
|
import { useAtom } from "jotai";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useEffect, useMemo } from "react";
|
||||||
import { useCallback, useEffect } from "react";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
import { Alert, TouchableOpacity, View } from "react-native";
|
|
||||||
import CastContext, {
|
import CastContext, {
|
||||||
CastButton,
|
|
||||||
PlayServicesState,
|
PlayServicesState,
|
||||||
useMediaStatus,
|
useMediaStatus,
|
||||||
useRemoteMediaClient,
|
useRemoteMediaClient,
|
||||||
@@ -30,72 +26,45 @@ import Animated, {
|
|||||||
withTiming,
|
withTiming,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
import { SelectedOptions } from "./ItemContent";
|
import { Text } from "./common/Text";
|
||||||
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
|
||||||
import * as Haptics from "expo-haptics";
|
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof Button> {
|
interface Props extends React.ComponentProps<typeof Button> {
|
||||||
item: BaseItemDto;
|
item?: BaseItemDto | null;
|
||||||
selectedOptions: SelectedOptions;
|
url?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ANIMATION_DURATION = 500;
|
const ANIMATION_DURATION = 500;
|
||||||
const MIN_PLAYBACK_WIDTH = 15;
|
const MIN_PLAYBACK_WIDTH = 15;
|
||||||
|
|
||||||
export const PlayButton: React.FC<Props> = ({
|
export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
|
||||||
item,
|
|
||||||
selectedOptions,
|
|
||||||
...props
|
|
||||||
}: Props) => {
|
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
const client = useRemoteMediaClient();
|
const client = useRemoteMediaClient();
|
||||||
|
const { setCurrentlyPlayingState } = usePlayback();
|
||||||
const mediaStatus = useMediaStatus();
|
const mediaStatus = useMediaStatus();
|
||||||
|
|
||||||
const [colorAtom] = useAtom(itemThemeColorAtom);
|
const [colorAtom] = useAtom(itemThemeColorAtom);
|
||||||
const api = useAtomValue(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const user = useAtomValue(userAtom);
|
|
||||||
|
|
||||||
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 startWidth = useSharedValue(0);
|
||||||
const targetWidth = useSharedValue(0);
|
const targetWidth = useSharedValue(0);
|
||||||
const endColor = useSharedValue(colorAtom);
|
const endColor = useSharedValue(memoizedColor);
|
||||||
const startColor = useSharedValue(colorAtom);
|
const startColor = useSharedValue(memoizedColor);
|
||||||
const widthProgress = useSharedValue(0);
|
const widthProgress = useSharedValue(0);
|
||||||
const colorChangeProgress = useSharedValue(0);
|
const colorChangeProgress = useSharedValue(0);
|
||||||
const [settings] = useSettings();
|
|
||||||
|
|
||||||
const goToPlayer = useCallback(
|
const directStream = useMemo(() => {
|
||||||
(q: string, bitrateValue: number | undefined) => {
|
return !url?.includes("m3u8");
|
||||||
if (!bitrateValue) {
|
}, []);
|
||||||
router.push(`/player/direct-player?${q}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
router.push(`/player/transcoding-player?${q}`);
|
|
||||||
},
|
|
||||||
[router]
|
|
||||||
);
|
|
||||||
|
|
||||||
const onPress = useCallback(async () => {
|
|
||||||
if (!item) return;
|
|
||||||
|
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
|
|
||||||
const queryParams = new URLSearchParams({
|
|
||||||
itemId: item.Id!,
|
|
||||||
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
|
|
||||||
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
|
|
||||||
mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
|
|
||||||
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
|
|
||||||
});
|
|
||||||
|
|
||||||
const queryString = queryParams.toString();
|
|
||||||
|
|
||||||
|
const onPress = async () => {
|
||||||
|
if (!url || !item) return;
|
||||||
if (!client) {
|
if (!client) {
|
||||||
goToPlayer(queryString, selectedOptions.bitrate?.value);
|
setCurrentlyPlayingState({ item, url });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const options = ["Chromecast", "Device", "Cancel"];
|
const options = ["Chromecast", "Device", "Cancel"];
|
||||||
const cancelButtonIndex = 2;
|
const cancelButtonIndex = 2;
|
||||||
showActionSheetWithOptions(
|
showActionSheetWithOptions(
|
||||||
@@ -111,36 +80,20 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
|
|
||||||
switch (selectedIndex) {
|
switch (selectedIndex) {
|
||||||
case 0:
|
case 0:
|
||||||
await CastContext.getPlayServicesState().then(async (state) => {
|
await CastContext.getPlayServicesState().then((state) => {
|
||||||
if (state && state !== PlayServicesState.SUCCESS)
|
if (state && state !== PlayServicesState.SUCCESS)
|
||||||
CastContext.showPlayServicesErrorDialog(state);
|
CastContext.showPlayServicesErrorDialog(state);
|
||||||
else {
|
else {
|
||||||
// Get a new URL with the Chromecast device profile:
|
// If we're opening a currently playing item, don't restart the media.
|
||||||
const data = await getStreamUrl({
|
// Instead just open controls.
|
||||||
api,
|
if (isOpeningCurrentlyPlayingMedia) {
|
||||||
item,
|
CastContext.showExpandedControls();
|
||||||
deviceProfile: chromecastProfile,
|
|
||||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
|
||||||
userId: user?.Id,
|
|
||||||
audioStreamIndex: selectedOptions.audioIndex,
|
|
||||||
maxStreamingBitrate: selectedOptions.bitrate?.value,
|
|
||||||
mediaSourceId: selectedOptions.mediaSource?.Id,
|
|
||||||
subtitleStreamIndex: selectedOptions.subtitleIndex,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!data?.url) {
|
|
||||||
console.warn("No URL returned from getStreamUrl", data);
|
|
||||||
Alert.alert(
|
|
||||||
"Client error",
|
|
||||||
"Could not create stream for Chromecast"
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
client
|
client
|
||||||
.loadMedia({
|
.loadMedia({
|
||||||
mediaInfo: {
|
mediaInfo: {
|
||||||
contentUrl: data?.url,
|
contentUrl: url,
|
||||||
contentType: "video/mp4",
|
contentType: "video/mp4",
|
||||||
metadata:
|
metadata:
|
||||||
item.Type === "Episode"
|
item.Type === "Episode"
|
||||||
@@ -206,38 +159,28 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case 1:
|
case 1:
|
||||||
goToPlayer(queryString, selectedOptions.bitrate?.value);
|
setCurrentlyPlayingState({ item, url });
|
||||||
break;
|
break;
|
||||||
case cancelButtonIndex:
|
case cancelButtonIndex:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}, [
|
};
|
||||||
item,
|
|
||||||
client,
|
|
||||||
settings,
|
|
||||||
api,
|
|
||||||
user,
|
|
||||||
router,
|
|
||||||
showActionSheetWithOptions,
|
|
||||||
mediaStatus,
|
|
||||||
selectedOptions,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const derivedTargetWidth = useDerivedValue(() => {
|
const derivedTargetWidth = useDerivedValue(() => {
|
||||||
if (!item || !item.RunTimeTicks) return 0;
|
if (!memoizedItem || !memoizedItem.RunTimeTicks) return 0;
|
||||||
const userData = item.UserData;
|
const userData = memoizedItem.UserData;
|
||||||
if (userData && userData.PlaybackPositionTicks) {
|
if (userData && userData.PlaybackPositionTicks) {
|
||||||
return userData.PlaybackPositionTicks > 0
|
return userData.PlaybackPositionTicks > 0
|
||||||
? Math.max(
|
? Math.max(
|
||||||
(userData.PlaybackPositionTicks / item.RunTimeTicks) * 100,
|
(userData.PlaybackPositionTicks / memoizedItem.RunTimeTicks) * 100,
|
||||||
MIN_PLAYBACK_WIDTH
|
MIN_PLAYBACK_WIDTH
|
||||||
)
|
)
|
||||||
: 0;
|
: 0;
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
}, [item]);
|
}, [memoizedItem]);
|
||||||
|
|
||||||
useAnimatedReaction(
|
useAnimatedReaction(
|
||||||
() => derivedTargetWidth.value,
|
() => derivedTargetWidth.value,
|
||||||
@@ -253,7 +196,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
useAnimatedReaction(
|
useAnimatedReaction(
|
||||||
() => colorAtom,
|
() => memoizedColor,
|
||||||
(newColor) => {
|
(newColor) => {
|
||||||
endColor.value = newColor;
|
endColor.value = newColor;
|
||||||
colorChangeProgress.value = 0;
|
colorChangeProgress.value = 0;
|
||||||
@@ -262,19 +205,19 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
easing: Easing.bezier(0.9, 0, 0.31, 0.99),
|
easing: Easing.bezier(0.9, 0, 0.31, 0.99),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[colorAtom]
|
[memoizedColor]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeout_2 = setTimeout(() => {
|
const timeout_2 = setTimeout(() => {
|
||||||
startColor.value = colorAtom;
|
startColor.value = memoizedColor;
|
||||||
startWidth.value = targetWidth.value;
|
startWidth.value = targetWidth.value;
|
||||||
}, ANIMATION_DURATION);
|
}, ANIMATION_DURATION);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(timeout_2);
|
clearTimeout(timeout_2);
|
||||||
};
|
};
|
||||||
}, [colorAtom, item]);
|
}, [memoizedColor, memoizedItem]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ANIMATED STYLES
|
* ANIMATED STYLES
|
||||||
@@ -283,7 +226,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
backgroundColor: interpolateColor(
|
backgroundColor: interpolateColor(
|
||||||
colorChangeProgress.value,
|
colorChangeProgress.value,
|
||||||
[0, 1],
|
[0, 1],
|
||||||
[startColor.value.primary, endColor.value.primary]
|
[startColor.value.average, endColor.value.average]
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -317,11 +260,10 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
disabled={!item}
|
|
||||||
accessibilityLabel="Play button"
|
accessibilityLabel="Play button"
|
||||||
accessibilityHint="Tap to play the media"
|
accessibilityHint="Tap to play the media"
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
className={`relative`}
|
className="relative"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<View className="absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden">
|
<View className="absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden">
|
||||||
@@ -337,7 +279,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[animatedAverageStyle, { opacity: 0.5 }]}
|
style={[animatedAverageStyle]}
|
||||||
className="absolute w-full h-full top-0 left-0 rounded-xl"
|
className="absolute w-full h-full top-0 left-0 rounded-xl"
|
||||||
/>
|
/>
|
||||||
<View
|
<View
|
||||||
@@ -358,22 +300,12 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
{client && (
|
{client && (
|
||||||
<Animated.Text style={animatedTextStyle}>
|
<Animated.Text style={animatedTextStyle}>
|
||||||
<Feather name="cast" size={22} />
|
<Feather name="cast" size={22} />
|
||||||
<CastButton tintColor="transparent" />
|
|
||||||
</Animated.Text>
|
|
||||||
)}
|
|
||||||
{!client && settings?.openInVLC && (
|
|
||||||
<Animated.Text style={animatedTextStyle}>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name="vlc"
|
|
||||||
size={18}
|
|
||||||
color={animatedTextStyle.color}
|
|
||||||
/>
|
|
||||||
</Animated.Text>
|
</Animated.Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
{/* <View className="mt-2 flex flex-row items-center">
|
<View className="mt-2 flex flex-row items-center">
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name="information-circle"
|
name="information-circle"
|
||||||
size={12}
|
size={12}
|
||||||
@@ -383,7 +315,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
<Text className="text-neutral-500 ml-1">
|
<Text className="text-neutral-500 ml-1">
|
||||||
{directStream ? "Direct stream" : "Transcoded stream"}
|
{directStream ? "Direct stream" : "Transcoded stream"}
|
||||||
</Text>
|
</Text>
|
||||||
</View> */}
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,30 +1,31 @@
|
|||||||
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 { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import * as Haptics from "expo-haptics";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { View, ViewProps } from "react-native";
|
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||||
import { RoundButton } from "./RoundButton";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PlayedStatus: React.FC<Props> = ({ item, ...props }) => {
|
export const PlayedStatus: React.FC<Props> = ({ item, ...props }) => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const invalidateQueries = () => {
|
const invalidateQueries = () => {
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["item", item.Id],
|
queryKey: ["item"],
|
||||||
});
|
});
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["resumeItems"],
|
queryKey: ["resumeItems"],
|
||||||
});
|
});
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["continueWatching"],
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["nextUp-all"],
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["nextUp"],
|
queryKey: ["nextUp"],
|
||||||
});
|
});
|
||||||
@@ -35,20 +36,48 @@ export const PlayedStatus: React.FC<Props> = ({ item, ...props }) => {
|
|||||||
queryKey: ["seasons"],
|
queryKey: ["seasons"],
|
||||||
});
|
});
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["home"],
|
queryKey: ["nextUp-all"],
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const markAsPlayedStatus = useMarkAsPlayed(item);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View
|
||||||
<RoundButton
|
className=" bg-neutral-800/80 rounded-full h-10 w-10 flex items-center justify-center"
|
||||||
fillColor={item.UserData?.Played ? "primary" : undefined}
|
{...props}
|
||||||
icon={item.UserData?.Played ? "checkmark" : "checkmark"}
|
>
|
||||||
onPress={() => markAsPlayedStatus(item.UserData?.Played || false)}
|
{item.UserData?.Played ? (
|
||||||
size="large"
|
<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>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { BlurView } from "expo-blur";
|
|
||||||
import { PropsWithChildren } from "react";
|
|
||||||
import {
|
|
||||||
Platform,
|
|
||||||
TouchableOpacity,
|
|
||||||
TouchableOpacityProps,
|
|
||||||
} from "react-native";
|
|
||||||
import * as Haptics from "expo-haptics";
|
|
||||||
|
|
||||||
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) {
|
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
}
|
|
||||||
onPress();
|
|
||||||
};
|
|
||||||
|
|
||||||
if (fillColor)
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handlePress}
|
|
||||||
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{icon ? (
|
|
||||||
<Ionicons
|
|
||||||
name={icon}
|
|
||||||
size={size === "large" ? 22 : 18}
|
|
||||||
color={"white"}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{children ? children : null}
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (background === false)
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handlePress}
|
|
||||||
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{icon ? (
|
|
||||||
<Ionicons
|
|
||||||
name={icon}
|
|
||||||
size={size === "large" ? 22 : 18}
|
|
||||||
color={"white"}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{children ? children : null}
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (Platform.OS === "android")
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handlePress}
|
|
||||||
className={`rounded-full ${buttonSize} flex items-center justify-center ${
|
|
||||||
fillColor ? fillColorClass : "bg-neutral-800/80"
|
|
||||||
}`}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{icon ? (
|
|
||||||
<Ionicons
|
|
||||||
name={icon}
|
|
||||||
size={size === "large" ? 22 : 18}
|
|
||||||
color={"white"}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{children ? children : null}
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TouchableOpacity onPress={handlePress} {...props}>
|
|
||||||
<BlurView
|
|
||||||
intensity={90}
|
|
||||||
className={`rounded-full overflow-hidden ${buttonSize} flex items-center justify-center ${fillColorClass}`}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{icon ? (
|
|
||||||
<Ionicons
|
|
||||||
name={icon}
|
|
||||||
size={size === "large" ? 22 : 18}
|
|
||||||
color={"white"}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{children ? children : null}
|
|
||||||
</BlurView>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -10,8 +10,6 @@ import { ScrollView, TouchableOpacity, View, ViewProps } from "react-native";
|
|||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
import { ItemCardText } from "./ItemCardText";
|
import { ItemCardText } from "./ItemCardText";
|
||||||
import { Loader } from "./Loader";
|
import { Loader } from "./Loader";
|
||||||
import { HorizontalScroll } from "./common/HorrizontalScroll";
|
|
||||||
import { TouchableItemRouter } from "./common/TouchableItemRouter";
|
|
||||||
|
|
||||||
interface SimilarItemsProps extends ViewProps {
|
interface SimilarItemsProps extends ViewProps {
|
||||||
itemId?: string | null;
|
itemId?: string | null;
|
||||||
@@ -48,24 +46,29 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({
|
|||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<Text className="px-4 text-lg font-bold mb-2">Similar items</Text>
|
<Text className="px-4 text-lg font-bold mb-2">Similar items</Text>
|
||||||
<HorizontalScroll
|
{isLoading ? (
|
||||||
data={movies}
|
<View className="my-12">
|
||||||
loading={isLoading}
|
<Loader />
|
||||||
height={247}
|
</View>
|
||||||
noItemsText="No similar items found"
|
) : (
|
||||||
renderItem={(item: BaseItemDto, idx: number) => (
|
<ScrollView horizontal>
|
||||||
<TouchableItemRouter
|
<View className="px-4 flex flex-row gap-x-2">
|
||||||
key={idx}
|
{movies.map((item) => (
|
||||||
item={item}
|
<TouchableOpacity
|
||||||
className="flex flex-col w-28"
|
key={item.Id}
|
||||||
>
|
onPress={() => router.push(`/items/page?id=${item.Id}`)}
|
||||||
<View>
|
className="flex flex-col w-32"
|
||||||
<MoviePoster item={item} />
|
>
|
||||||
<ItemCardText item={item} />
|
<MoviePoster item={item} />
|
||||||
</View>
|
<ItemCardText item={item} />
|
||||||
</TouchableItemRouter>
|
</TouchableOpacity>
|
||||||
)}
|
))}
|
||||||
/>
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
{movies.length === 0 && (
|
||||||
|
<Text className="px-4 text-neutral-500">No similar items</Text>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,40 +1,57 @@
|
|||||||
import { tc } from "@/utils/textTools";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
import { atom, useAtom } from "jotai";
|
||||||
|
import {
|
||||||
|
BaseItemDto,
|
||||||
|
MediaSourceInfo,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { useEffect, useMemo } from "react";
|
||||||
|
import { MediaStream } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { tc } from "@/utils/textTools";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof View> {
|
interface Props extends React.ComponentProps<typeof View> {
|
||||||
source?: MediaSourceInfo;
|
source: MediaSourceInfo;
|
||||||
onChange: (value: number) => void;
|
onChange: (value: number) => void;
|
||||||
selected?: number | undefined;
|
selected: number;
|
||||||
isTranscoding?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SubtitleTrackSelector: React.FC<Props> = ({
|
export const SubtitleTrackSelector: React.FC<Props> = ({
|
||||||
source,
|
source,
|
||||||
onChange,
|
onChange,
|
||||||
selected,
|
selected,
|
||||||
isTranscoding,
|
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const subtitleStreams = useMemo(() => {
|
const [settings] = useSettings();
|
||||||
const subtitleHelper = new SubtitleHelper(source?.MediaStreams ?? []);
|
|
||||||
|
|
||||||
if (isTranscoding && Platform.OS === "ios") {
|
const subtitleStreams = useMemo(
|
||||||
return subtitleHelper.getUniqueSubtitles();
|
() => source.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [],
|
||||||
}
|
[source]
|
||||||
|
);
|
||||||
return subtitleHelper.getSubtitles();
|
|
||||||
}, [source, isTranscoding]);
|
|
||||||
|
|
||||||
const selectedSubtitleSteam = useMemo(
|
const selectedSubtitleSteam = useMemo(
|
||||||
() => subtitleStreams.find((x) => x.Index === selected),
|
() => subtitleStreams.find((x) => x.Index === selected),
|
||||||
[subtitleStreams, selected]
|
[subtitleStreams, selected]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// const index = source.DefaultAudioStreamIndex;
|
||||||
|
// if (index !== undefined && index !== null) {
|
||||||
|
// onChange(index);
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
const defaultSubIndex = subtitleStreams?.find(
|
||||||
|
(x) => x.Language === settings?.defaultSubtitleLanguage?.value
|
||||||
|
)?.Index;
|
||||||
|
if (defaultSubIndex !== undefined && defaultSubIndex !== null) {
|
||||||
|
onChange(defaultSubIndex);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange(-1);
|
||||||
|
}, [subtitleStreams, settings]);
|
||||||
|
|
||||||
if (subtitleStreams.length === 0) return null;
|
if (subtitleStreams.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import React from "react";
|
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
|
|
||||||
export const WatchedIndicator: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
export const WatchedIndicator: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
Platform,
|
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
TouchableOpacityProps,
|
TouchableOpacityProps,
|
||||||
View,
|
View,
|
||||||
@@ -22,16 +21,16 @@ export const HeaderBackButton: React.FC<Props> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
if (background === "transparent" && Platform.OS !== "android")
|
if (background === "transparent")
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<BlurView
|
||||||
onPress={() => router.back()}
|
{...props}
|
||||||
{...touchableOpacityProps}
|
intensity={100}
|
||||||
|
className="overflow-hidden rounded-full p-2"
|
||||||
>
|
>
|
||||||
<BlurView
|
<TouchableOpacity
|
||||||
{...props}
|
onPress={() => router.back()}
|
||||||
intensity={100}
|
{...touchableOpacityProps}
|
||||||
className="overflow-hidden rounded-full p-2"
|
|
||||||
>
|
>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
className="drop-shadow-2xl"
|
className="drop-shadow-2xl"
|
||||||
@@ -39,8 +38,8 @@ export const HeaderBackButton: React.FC<Props> = ({
|
|||||||
size={24}
|
size={24}
|
||||||
color="white"
|
color="white"
|
||||||
/>
|
/>
|
||||||
</BlurView>
|
</TouchableOpacity>
|
||||||
</TouchableOpacity>
|
</BlurView>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -53,7 +52,7 @@ export const HeaderBackButton: React.FC<Props> = ({
|
|||||||
className="drop-shadow-2xl"
|
className="drop-shadow-2xl"
|
||||||
name="arrow-back"
|
name="arrow-back"
|
||||||
size={24}
|
size={24}
|
||||||
color="white"
|
color="#077DF2"
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,14 +16,12 @@ interface HorizontalScrollProps<T>
|
|||||||
> {
|
> {
|
||||||
data?: T[] | null;
|
data?: T[] | null;
|
||||||
renderItem: (item: T, index: number) => React.ReactNode;
|
renderItem: (item: T, index: number) => React.ReactNode;
|
||||||
keyExtractor?: (item: T, index: number) => string;
|
|
||||||
containerStyle?: ViewStyle;
|
containerStyle?: ViewStyle;
|
||||||
contentContainerStyle?: ViewStyle;
|
contentContainerStyle?: ViewStyle;
|
||||||
loadingContainerStyle?: ViewStyle;
|
loadingContainerStyle?: ViewStyle;
|
||||||
height?: number;
|
height?: number;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
extraData?: any;
|
extraData?: any;
|
||||||
noItemsText?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HorizontalScroll = forwardRef<
|
export const HorizontalScroll = forwardRef<
|
||||||
@@ -33,7 +31,6 @@ export const HorizontalScroll = forwardRef<
|
|||||||
<T,>(
|
<T,>(
|
||||||
{
|
{
|
||||||
data = [],
|
data = [],
|
||||||
keyExtractor,
|
|
||||||
renderItem,
|
renderItem,
|
||||||
containerStyle,
|
containerStyle,
|
||||||
contentContainerStyle,
|
contentContainerStyle,
|
||||||
@@ -41,7 +38,6 @@ export const HorizontalScroll = forwardRef<
|
|||||||
loading = false,
|
loading = false,
|
||||||
height = 164,
|
height = 164,
|
||||||
extraData,
|
extraData,
|
||||||
noItemsText,
|
|
||||||
...props
|
...props
|
||||||
}: HorizontalScrollProps<T>,
|
}: HorizontalScrollProps<T>,
|
||||||
ref: React.ForwardedRef<HorizontalScrollRef>
|
ref: React.ForwardedRef<HorizontalScrollRef>
|
||||||
@@ -93,12 +89,9 @@ export const HorizontalScroll = forwardRef<
|
|||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
...contentContainerStyle,
|
...contentContainerStyle,
|
||||||
}}
|
}}
|
||||||
keyExtractor={keyExtractor}
|
|
||||||
ListEmptyComponent={() => (
|
ListEmptyComponent={() => (
|
||||||
<View className="flex-1 justify-center items-center">
|
<View className="flex-1 justify-center items-center">
|
||||||
<Text className="text-center text-gray-500">
|
<Text className="text-center text-gray-500">No data available</Text>
|
||||||
{noItemsText || "No data available"}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ export function Input(props: TextInputProps) {
|
|||||||
className="p-4 border border-neutral-800 rounded-xl bg-neutral-900"
|
className="p-4 border border-neutral-800 rounded-xl bg-neutral-900"
|
||||||
allowFontScaling={false}
|
allowFontScaling={false}
|
||||||
style={[{ color: "white" }, style]}
|
style={[{ color: "white" }, style]}
|
||||||
|
{...otherProps}
|
||||||
placeholderTextColor={"#9CA3AF"}
|
placeholderTextColor={"#9CA3AF"}
|
||||||
clearButtonMode="while-editing"
|
clearButtonMode="while-editing"
|
||||||
{...otherProps}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ interface Props extends ImageProps {
|
|||||||
| "Thumb";
|
| "Thumb";
|
||||||
quality?: number;
|
quality?: number;
|
||||||
width?: number;
|
width?: number;
|
||||||
|
useThemeColor?: boolean;
|
||||||
onError?: () => void;
|
onError?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ export const ItemImage: React.FC<Props> = ({
|
|||||||
variant = "Primary",
|
variant = "Primary",
|
||||||
quality = 90,
|
quality = 90,
|
||||||
width = 1000,
|
width = 1000,
|
||||||
|
useThemeColor = false,
|
||||||
onError,
|
onError,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -1,16 +1,11 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { TextProps } from "react-native";
|
import { TextProps } from "react-native";
|
||||||
import { UITextView } from "react-native-uitextview";
|
import { Text as DefaultText } from "react-native";
|
||||||
|
export function Text(props: TextProps) {
|
||||||
export function Text(
|
|
||||||
props: TextProps & {
|
|
||||||
uiTextView?: boolean;
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
const { style, ...otherProps } = props;
|
const { style, ...otherProps } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UITextView
|
<DefaultText
|
||||||
allowFontScaling={false}
|
allowFontScaling={false}
|
||||||
style={[{ color: "white" }, style]}
|
style={[{ color: "white" }, style]}
|
||||||
{...otherProps}
|
{...otherProps}
|
||||||
|
|||||||
@@ -1,59 +1,13 @@
|
|||||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import * as Haptics from "expo-haptics";
|
import * as Haptics from "expo-haptics";
|
||||||
import { useRouter, useSegments } from "expo-router";
|
import { useRouter, useSegments } from "expo-router";
|
||||||
import { PropsWithChildren } from "react";
|
import { PropsWithChildren } from "react";
|
||||||
import { TouchableOpacity, TouchableOpacityProps } from "react-native";
|
import { TouchableOpacity, TouchableOpacityProps } from "react-native";
|
||||||
import * as ContextMenu from "zeego/context-menu";
|
|
||||||
|
|
||||||
interface Props extends TouchableOpacityProps {
|
interface Props extends TouchableOpacityProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const itemRouter = (item: BaseItemDto, from: string) => {
|
|
||||||
if (item.CollectionType === "livetv") {
|
|
||||||
return `/(auth)/(tabs)/${from}/livetv`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.Type === "Series") {
|
|
||||||
return `/(auth)/(tabs)/${from}/series/${item.Id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.Type === "MusicAlbum") {
|
|
||||||
return `/(auth)/(tabs)/${from}/albums/${item.Id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.Type === "Audio") {
|
|
||||||
return `/(auth)/(tabs)/${from}/albums/${item.AlbumId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.Type === "MusicArtist") {
|
|
||||||
return `/(auth)/(tabs)/${from}/artists/${item.Id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.Type === "Person") {
|
|
||||||
return `/(auth)/(tabs)/${from}/actors/${item.Id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.Type === "BoxSet") {
|
|
||||||
return `/(auth)/(tabs)/${from}/collections/${item.Id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.Type === "UserView") {
|
|
||||||
return `/(auth)/(tabs)/${from}/collections/${item.Id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.Type === "CollectionFolder") {
|
|
||||||
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}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||||
item,
|
item,
|
||||||
children,
|
children,
|
||||||
@@ -64,82 +18,63 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
|
|
||||||
const from = segments[2];
|
const from = segments[2];
|
||||||
|
|
||||||
const markAsPlayedStatus = useMarkAsPlayed(item);
|
|
||||||
|
|
||||||
if (from === "(home)" || from === "(search)" || from === "(libraries)")
|
if (from === "(home)" || from === "(search)" || from === "(libraries)")
|
||||||
return (
|
return (
|
||||||
<ContextMenu.Root>
|
<TouchableOpacity
|
||||||
<ContextMenu.Trigger>
|
onPress={() => {
|
||||||
<TouchableOpacity
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
onPress={() => {
|
|
||||||
const url = itemRouter(item, from);
|
if (item.Type === "Series") {
|
||||||
// @ts-ignore
|
router.push(`/(auth)/(tabs)/${from}/series/${item.Id}`);
|
||||||
router.push(url);
|
return;
|
||||||
}}
|
}
|
||||||
{...props}
|
|
||||||
>
|
if (item.Type === "MusicAlbum") {
|
||||||
{children}
|
router.push(`/(auth)/(tabs)/${from}/albums/${item.Id}`);
|
||||||
</TouchableOpacity>
|
return;
|
||||||
</ContextMenu.Trigger>
|
}
|
||||||
<ContextMenu.Content
|
|
||||||
avoidCollisions
|
if (item.Type === "Audio") {
|
||||||
alignOffset={0}
|
router.push(`/(auth)/(tabs)/${from}/albums/${item.AlbumId}`);
|
||||||
collisionPadding={0}
|
return;
|
||||||
loop={false}
|
}
|
||||||
key={"content"}
|
|
||||||
>
|
if (item.Type === "MusicArtist") {
|
||||||
<ContextMenu.Label key="label-1">Actions</ContextMenu.Label>
|
router.push(`/(auth)/(tabs)/${from}/artists/${item.Id}`);
|
||||||
<ContextMenu.Item
|
return;
|
||||||
key="item-1"
|
}
|
||||||
onSelect={() => {
|
|
||||||
markAsPlayedStatus(true);
|
if (item.Type === "Person") {
|
||||||
}}
|
router.push(`/(auth)/(tabs)/${from}/actors/${item.Id}`);
|
||||||
shouldDismissMenuOnSelect
|
return;
|
||||||
>
|
}
|
||||||
<ContextMenu.ItemTitle key="item-1-title">
|
|
||||||
Mark as watched
|
if (item.Type === "BoxSet") {
|
||||||
</ContextMenu.ItemTitle>
|
router.push(`/(auth)/(tabs)/${from}/collections/${item.Id}`);
|
||||||
<ContextMenu.ItemIcon
|
return;
|
||||||
ios={{
|
}
|
||||||
name: "checkmark.circle", // Changed to "checkmark.circle" which represents "watched"
|
|
||||||
pointSize: 18,
|
if (item.Type === "UserView") {
|
||||||
weight: "semibold",
|
router.push(`/(auth)/(tabs)/${from}/collections/${item.Id}`);
|
||||||
scale: "medium",
|
return;
|
||||||
hierarchicalColor: {
|
}
|
||||||
dark: "green", // Changed to green for "watched"
|
|
||||||
light: "green",
|
if (item.Type === "CollectionFolder") {
|
||||||
},
|
router.push(`/(auth)/(tabs)/(libraries)/${item.Id}`);
|
||||||
}}
|
return;
|
||||||
androidIconName="checkmark-circle"
|
}
|
||||||
></ContextMenu.ItemIcon>
|
|
||||||
</ContextMenu.Item>
|
// Same as default
|
||||||
<ContextMenu.Item
|
// if (item.Type === "Episode") {
|
||||||
key="item-2"
|
// router.push(`/items/${item.Id}`);
|
||||||
onSelect={() => {
|
// return;
|
||||||
markAsPlayedStatus(false);
|
// }
|
||||||
}}
|
|
||||||
shouldDismissMenuOnSelect
|
router.push(`/(auth)/(tabs)/${from}/items/page?id=${item.Id}`);
|
||||||
destructive
|
}}
|
||||||
>
|
{...props}
|
||||||
<ContextMenu.ItemTitle key="item-2-title">
|
>
|
||||||
Mark as not watched
|
{children}
|
||||||
</ContextMenu.ItemTitle>
|
</TouchableOpacity>
|
||||||
<ContextMenu.ItemIcon
|
|
||||||
ios={{
|
|
||||||
name: "eye.slash", // Changed to "eye.slash" which represents "not watched"
|
|
||||||
pointSize: 18, // Adjusted for better visibility
|
|
||||||
weight: "semibold",
|
|
||||||
scale: "medium",
|
|
||||||
hierarchicalColor: {
|
|
||||||
dark: "red", // Changed to red for "not watched"
|
|
||||||
light: "red",
|
|
||||||
},
|
|
||||||
// Removed paletteColors as it's not necessary in this case
|
|
||||||
}}
|
|
||||||
androidIconName="eye-slash"
|
|
||||||
></ContextMenu.ItemIcon>
|
|
||||||
</ContextMenu.Item>
|
|
||||||
</ContextMenu.Content>
|
|
||||||
</ContextMenu.Root>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,191 +0,0 @@
|
|||||||
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 } = 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.item.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(Number(id));
|
|
||||||
setProcesses((prev) => prev.filter((p) => p.id !== id));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success("Download canceled");
|
|
||||||
},
|
|
||||||
onError: (e) => {
|
|
||||||
console.error(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, "tick");
|
|
||||||
};
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { bytesToReadable, useDownload } from "@/providers/DownloadProvider";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
|
||||||
import { TextProps } from "react-native";
|
|
||||||
|
|
||||||
interface DownloadSizeProps extends TextProps {
|
|
||||||
items: BaseItemDto[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DownloadSize: React.FC<DownloadSizeProps> = ({
|
|
||||||
items,
|
|
||||||
...props
|
|
||||||
}) => {
|
|
||||||
const { downloadedFiles, getDownloadedItemSize } = useDownload();
|
|
||||||
const [size, setSize] = useState<string | undefined>();
|
|
||||||
|
|
||||||
const itemIds = useMemo(() => items.map((i) => i.Id), [items]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!downloadedFiles) return;
|
|
||||||
|
|
||||||
let s = 0;
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
if (!item.Id) continue;
|
|
||||||
const size = getDownloadedItemSize(item.Id);
|
|
||||||
if (size) {
|
|
||||||
s += size;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setSize(bytesToReadable(s));
|
|
||||||
}, [itemIds]);
|
|
||||||
|
|
||||||
const sizeText = useMemo(() => {
|
|
||||||
if (!size) return "...";
|
|
||||||
return size;
|
|
||||||
}, [size]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Text className="text-xs text-neutral-500" {...props}>
|
|
||||||
{sizeText}
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,39 +1,36 @@
|
|||||||
|
import React, { useCallback } from "react";
|
||||||
|
import { TouchableOpacity } from "react-native";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import * as ContextMenu from "zeego/context-menu";
|
||||||
import * as Haptics from "expo-haptics";
|
import * as Haptics from "expo-haptics";
|
||||||
import React, { useCallback, useMemo } from "react";
|
import * as FileSystem from "expo-file-system";
|
||||||
import { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
|
import { useAtom } from "jotai";
|
||||||
import {
|
|
||||||
ActionSheetProvider,
|
|
||||||
useActionSheet,
|
|
||||||
} from "@expo/react-native-action-sheet";
|
|
||||||
|
|
||||||
import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener";
|
import { Text } from "../common/Text";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useFiles } from "@/hooks/useFiles";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { Image } from "expo-image";
|
import { usePlayback } from "@/providers/PlaybackProvider";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { runtimeTicksToSeconds } from "@/utils/time";
|
|
||||||
import { DownloadSize } from "@/components/downloads/DownloadSize";
|
|
||||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
|
||||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
|
||||||
|
|
||||||
interface EpisodeCardProps extends TouchableOpacityProps {
|
interface EpisodeCardProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item, ...props }) => {
|
/**
|
||||||
const { deleteFile } = useDownload();
|
* EpisodeCard component displays an episode with context menu options.
|
||||||
const { openFile } = useDownloadedFileOpener();
|
* @param {EpisodeCardProps} props - The component props.
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
* @returns {React.ReactElement} The rendered EpisodeCard component.
|
||||||
|
*/
|
||||||
|
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
|
||||||
|
const { deleteFile } = useFiles();
|
||||||
|
|
||||||
const base64Image = useMemo(() => {
|
const { startDownloadedFilePlayback } = usePlayback();
|
||||||
return storage.getString(item.Id!);
|
|
||||||
}, [item]);
|
|
||||||
|
|
||||||
const handleOpenFile = useCallback(() => {
|
const handleOpenFile = useCallback(async () => {
|
||||||
openFile(item);
|
startDownloadedFilePlayback({
|
||||||
}, [item, openFile]);
|
item,
|
||||||
|
url: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
|
||||||
|
});
|
||||||
|
}, [item, startDownloadedFilePlayback]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles deleting the file with haptic feedback.
|
* Handles deleting the file with haptic feedback.
|
||||||
@@ -45,68 +42,43 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item, ...props }) => {
|
|||||||
}
|
}
|
||||||
}, [deleteFile, item.Id]);
|
}, [deleteFile, item.Id]);
|
||||||
|
|
||||||
const showActionSheet = useCallback(() => {
|
const contextMenuOptions = [
|
||||||
const options = ["Delete", "Cancel"];
|
{
|
||||||
const destructiveButtonIndex = 0;
|
label: "Delete",
|
||||||
const cancelButtonIndex = 1;
|
onSelect: handleDeleteFile,
|
||||||
|
destructive: true,
|
||||||
showActionSheetWithOptions(
|
},
|
||||||
{
|
];
|
||||||
options,
|
|
||||||
cancelButtonIndex,
|
|
||||||
destructiveButtonIndex,
|
|
||||||
},
|
|
||||||
(selectedIndex) => {
|
|
||||||
switch (selectedIndex) {
|
|
||||||
case destructiveButtonIndex:
|
|
||||||
// Delete
|
|
||||||
handleDeleteFile();
|
|
||||||
break;
|
|
||||||
case cancelButtonIndex:
|
|
||||||
// Cancelled
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}, [showActionSheetWithOptions, handleDeleteFile]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<ContextMenu.Root>
|
||||||
onPress={handleOpenFile}
|
<ContextMenu.Trigger>
|
||||||
onLongPress={showActionSheet}
|
<TouchableOpacity
|
||||||
key={item.Id}
|
onPress={handleOpenFile}
|
||||||
className="flex flex-col mb-4"
|
className="bg-neutral-900 border border-neutral-800 rounded-2xl p-4"
|
||||||
>
|
>
|
||||||
<View className="flex flex-row items-start mb-2">
|
<Text className="font-bold">{item.Name}</Text>
|
||||||
<View className="mr-2">
|
<Text className="text-xs opacity-50">Episode {item.IndexNumber}</Text>
|
||||||
<ContinueWatchingPoster size="small" item={item} useEpisodePoster />
|
</TouchableOpacity>
|
||||||
</View>
|
</ContextMenu.Trigger>
|
||||||
<View className="shrink">
|
<ContextMenu.Content
|
||||||
<Text numberOfLines={2} className="">
|
alignOffset={0}
|
||||||
{item.Name}
|
avoidCollisions
|
||||||
</Text>
|
collisionPadding={10}
|
||||||
<Text numberOfLines={1} className="text-xs text-neutral-500">
|
loop={false}
|
||||||
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
|
>
|
||||||
</Text>
|
{contextMenuOptions.map((option) => (
|
||||||
<Text className="text-xs text-neutral-500">
|
<ContextMenu.Item
|
||||||
{runtimeTicksToSeconds(item.RunTimeTicks)}
|
key={option.label}
|
||||||
</Text>
|
onSelect={option.onSelect}
|
||||||
<DownloadSize items={[item]} />
|
destructive={option.destructive}
|
||||||
</View>
|
>
|
||||||
</View>
|
<ContextMenu.ItemTitle style={{ color: "red" }}>
|
||||||
|
{option.label}
|
||||||
<Text numberOfLines={3} className="text-xs text-neutral-500 shrink">
|
</ContextMenu.ItemTitle>
|
||||||
{item.Overview}
|
</ContextMenu.Item>
|
||||||
</Text>
|
))}
|
||||||
</TouchableOpacity>
|
</ContextMenu.Content>
|
||||||
|
</ContextMenu.Root>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Wrap the parent component with ActionSheetProvider
|
|
||||||
export const EpisodeCardWithActionSheet: React.FC<EpisodeCardProps> = (
|
|
||||||
props
|
|
||||||
) => (
|
|
||||||
<ActionSheetProvider>
|
|
||||||
<EpisodeCard {...props} />
|
|
||||||
</ActionSheetProvider>
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -1,41 +1,39 @@
|
|||||||
import {
|
import React, { useCallback } from "react";
|
||||||
ActionSheetProvider,
|
|
||||||
useActionSheet,
|
|
||||||
} from "@expo/react-native-action-sheet";
|
|
||||||
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 { TouchableOpacity, View } from "react-native";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import * as ContextMenu from "zeego/context-menu";
|
||||||
|
import * as FileSystem from "expo-file-system";
|
||||||
|
import * as Haptics from "expo-haptics";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
|
||||||
import { DownloadSize } from "@/components/downloads/DownloadSize";
|
import { Text } from "../common/Text";
|
||||||
import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener";
|
import { useFiles } from "@/hooks/useFiles";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
import { storage } from "@/utils/mmkv";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { Image } from "expo-image";
|
import { usePlayback } from "@/providers/PlaybackProvider";
|
||||||
import { ItemCardText } from "../ItemCardText";
|
|
||||||
|
|
||||||
interface MovieCardProps {
|
interface MovieCardProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MovieCard component displays a movie with action sheet options.
|
* MovieCard component displays a movie with context menu options.
|
||||||
* @param {MovieCardProps} props - The component props.
|
* @param {MovieCardProps} props - The component props.
|
||||||
* @returns {React.ReactElement} The rendered MovieCard component.
|
* @returns {React.ReactElement} The rendered MovieCard component.
|
||||||
*/
|
*/
|
||||||
export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
||||||
const { deleteFile } = useDownload();
|
const { deleteFile } = useFiles();
|
||||||
const { openFile } = useDownloadedFileOpener();
|
const [settings] = useSettings();
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
|
||||||
|
const { startDownloadedFilePlayback } = usePlayback();
|
||||||
|
|
||||||
const handleOpenFile = useCallback(() => {
|
const handleOpenFile = useCallback(() => {
|
||||||
openFile(item);
|
startDownloadedFilePlayback({
|
||||||
}, [item, openFile]);
|
item,
|
||||||
|
url: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
|
||||||
const base64Image = useMemo(() => {
|
});
|
||||||
return storage.getString(item.Id!);
|
}, [item, startDownloadedFilePlayback]);
|
||||||
}, []);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles deleting the file with haptic feedback.
|
* Handles deleting the file with haptic feedback.
|
||||||
@@ -47,67 +45,48 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
|||||||
}
|
}
|
||||||
}, [deleteFile, item.Id]);
|
}, [deleteFile, item.Id]);
|
||||||
|
|
||||||
const showActionSheet = useCallback(() => {
|
const contextMenuOptions = [
|
||||||
const options = ["Delete", "Cancel"];
|
{
|
||||||
const destructiveButtonIndex = 0;
|
label: "Delete",
|
||||||
const cancelButtonIndex = 1;
|
onSelect: handleDeleteFile,
|
||||||
|
destructive: true,
|
||||||
showActionSheetWithOptions(
|
},
|
||||||
{
|
];
|
||||||
options,
|
|
||||||
cancelButtonIndex,
|
|
||||||
destructiveButtonIndex,
|
|
||||||
},
|
|
||||||
(selectedIndex) => {
|
|
||||||
switch (selectedIndex) {
|
|
||||||
case destructiveButtonIndex:
|
|
||||||
// Delete
|
|
||||||
handleDeleteFile();
|
|
||||||
break;
|
|
||||||
case cancelButtonIndex:
|
|
||||||
// Cancelled
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}, [showActionSheetWithOptions, handleDeleteFile]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity onPress={handleOpenFile} onLongPress={showActionSheet}>
|
<ContextMenu.Root>
|
||||||
{base64Image ? (
|
<ContextMenu.Trigger>
|
||||||
<View className="w-28 aspect-[10/15] rounded-lg overflow-hidden mr-2 border border-neutral-900">
|
<TouchableOpacity
|
||||||
<Image
|
onPress={handleOpenFile}
|
||||||
source={{
|
className="bg-neutral-900 border border-neutral-800 rounded-2xl p-4"
|
||||||
uri: `data:image/jpeg;base64,${base64Image}`,
|
>
|
||||||
}}
|
<Text className="font-bold">{item.Name}</Text>
|
||||||
style={{
|
<View className="flex flex-col">
|
||||||
width: "100%",
|
<Text className="text-xs opacity-50">{item.ProductionYear}</Text>
|
||||||
height: "100%",
|
<Text className="text-xs opacity-50">
|
||||||
resizeMode: "cover",
|
{runtimeTicksToMinutes(item.RunTimeTicks)}
|
||||||
}}
|
</Text>
|
||||||
/>
|
</View>
|
||||||
</View>
|
</TouchableOpacity>
|
||||||
) : (
|
</ContextMenu.Trigger>
|
||||||
<View className="w-28 aspect-[10/15] rounded-lg bg-neutral-900 mr-2 flex items-center justify-center">
|
<ContextMenu.Content
|
||||||
<Ionicons
|
loop={false}
|
||||||
name="image-outline"
|
alignOffset={0}
|
||||||
size={24}
|
avoidCollisions={false}
|
||||||
color="gray"
|
collisionPadding={0}
|
||||||
className="self-center mt-16"
|
>
|
||||||
/>
|
{contextMenuOptions.map((option) => (
|
||||||
</View>
|
<ContextMenu.Item
|
||||||
)}
|
key={option.label}
|
||||||
<View className="w-28">
|
onSelect={option.onSelect}
|
||||||
<ItemCardText item={item} />
|
destructive={option.destructive}
|
||||||
</View>
|
>
|
||||||
<DownloadSize items={[item]} />
|
<ContextMenu.ItemTitle style={{ color: "red" }}>
|
||||||
</TouchableOpacity>
|
{option.label}
|
||||||
|
</ContextMenu.ItemTitle>
|
||||||
|
</ContextMenu.Item>
|
||||||
|
))}
|
||||||
|
</ContextMenu.Content>
|
||||||
|
</ContextMenu.Root>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Wrap the parent component with ActionSheetProvider
|
|
||||||
export const MovieCardWithActionSheet: React.FC<MovieCardProps> = (props) => (
|
|
||||||
<ActionSheetProvider>
|
|
||||||
<MovieCard {...props} />
|
|
||||||
</ActionSheetProvider>
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -1,82 +1,49 @@
|
|||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import {TouchableOpacity, View} from "react-native";
|
import { View } from "react-native";
|
||||||
|
import { EpisodeCard } from "./EpisodeCard";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import React, {useCallback, useMemo} from "react";
|
import { useMemo } from "react";
|
||||||
import {storage} from "@/utils/mmkv";
|
import { SeasonPicker } from "../series/SeasonPicker";
|
||||||
import {Image} from "expo-image";
|
|
||||||
import {Ionicons} from "@expo/vector-icons";
|
|
||||||
import {router} from "expo-router";
|
|
||||||
import {DownloadSize} from "@/components/downloads/DownloadSize";
|
|
||||||
import {useDownload} from "@/providers/DownloadProvider";
|
|
||||||
import {useActionSheet} from "@expo/react-native-action-sheet";
|
|
||||||
|
|
||||||
export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({items}) => {
|
export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
|
||||||
const { deleteItems } = useDownload();
|
const groupBySeason = useMemo(() => {
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const seasons: Record<string, BaseItemDto[]> = {};
|
||||||
|
|
||||||
const base64Image = useMemo(() => {
|
items.forEach((item) => {
|
||||||
return storage.getString(items[0].SeriesId!);
|
if (!seasons[item.SeasonName!]) {
|
||||||
}, []);
|
seasons[item.SeasonName!] = [];
|
||||||
|
|
||||||
const deleteSeries = useCallback(
|
|
||||||
async () => deleteItems(items),
|
|
||||||
[items]
|
|
||||||
);
|
|
||||||
|
|
||||||
const showActionSheet = useCallback(() => {
|
|
||||||
const options = ["Delete", "Cancel"];
|
|
||||||
const destructiveButtonIndex = 0;
|
|
||||||
|
|
||||||
showActionSheetWithOptions({
|
|
||||||
options,
|
|
||||||
destructiveButtonIndex,
|
|
||||||
},
|
|
||||||
(selectedIndex) => {
|
|
||||||
if (selectedIndex == destructiveButtonIndex) {
|
|
||||||
deleteSeries();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
seasons[item.SeasonName!].push(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.values(seasons).sort(
|
||||||
|
(a, b) => a[0].IndexNumber! - b[0].IndexNumber!
|
||||||
);
|
);
|
||||||
}, [showActionSheetWithOptions, deleteSeries]);
|
}, [items]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<View>
|
||||||
onPress={() => router.push(`/downloads/${items[0].SeriesId}`)}
|
<View className="flex flex-row items-center justify-between">
|
||||||
onLongPress={showActionSheet}
|
<Text className="text-2xl font-bold shrink">{items[0].SeriesName}</Text>
|
||||||
>
|
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
|
||||||
{base64Image ? (
|
<Text className="text-xs font-bold">{items.length}</Text>
|
||||||
<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
|
|
||||||
className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center absolute bottom-1 right-1">
|
|
||||||
<Text className="text-xs font-bold">{items.length}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
</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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<View className="w-28 mt-2 flex flex-col">
|
|
||||||
<Text numberOfLines={2} className="">{items[0].SeriesName}</Text>
|
|
||||||
<Text className="text-xs opacity-50">{items[0].ProductionYear}</Text>
|
|
||||||
<DownloadSize items={items} />
|
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
|
||||||
|
<Text className="opacity-50 mb-2">TV-Series</Text>
|
||||||
|
{groupBySeason.map((seasonItems, seasonIndex) => (
|
||||||
|
<View key={seasonIndex}>
|
||||||
|
<Text className="mb-2 font-semibold">
|
||||||
|
{seasonItems[0].SeasonName}
|
||||||
|
</Text>
|
||||||
|
{seasonItems.map((item, index) => (
|
||||||
|
<View className="mb-2" key={index}>
|
||||||
|
<EpisodeCard item={item} />
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,29 +4,25 @@ import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
|||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useCallback, useMemo } from "react";
|
import React, { useMemo } from "react";
|
||||||
import { Dimensions, TouchableOpacity, View, ViewProps } from "react-native";
|
import { Dimensions, View, ViewProps } from "react-native";
|
||||||
import Animated, {
|
import { useSharedValue } from "react-native-reanimated";
|
||||||
runOnJS,
|
|
||||||
useSharedValue,
|
|
||||||
withTiming,
|
|
||||||
} from "react-native-reanimated";
|
|
||||||
import Carousel, {
|
import Carousel, {
|
||||||
ICarouselInstance,
|
ICarouselInstance,
|
||||||
Pagination,
|
Pagination,
|
||||||
} from "react-native-reanimated-carousel";
|
} from "react-native-reanimated-carousel";
|
||||||
import { itemRouter, TouchableItemRouter } from "../common/TouchableItemRouter";
|
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||||
import { Loader } from "../Loader";
|
import { Loader } from "../Loader";
|
||||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
|
||||||
import { useRouter, useSegments } from "expo-router";
|
|
||||||
import * as Haptics from "expo-haptics";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
|
export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
|
|
||||||
const ref = React.useRef<ICarouselInstance>(null);
|
const ref = React.useRef<ICarouselInstance>(null);
|
||||||
@@ -84,7 +80,13 @@ export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
|
|||||||
|
|
||||||
const width = Dimensions.get("screen").width;
|
const width = Dimensions.get("screen").width;
|
||||||
|
|
||||||
if (l1 || l2) return null;
|
if (l1 || l2)
|
||||||
|
return (
|
||||||
|
<View className="h-[242px] flex items-center justify-center">
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
if (!popularItems) return null;
|
if (!popularItems) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -120,7 +122,7 @@ export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
|
|||||||
|
|
||||||
const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const router = useRouter();
|
|
||||||
const screenWidth = Dimensions.get("screen").width;
|
const screenWidth = Dimensions.get("screen").width;
|
||||||
|
|
||||||
const uri = useMemo(() => {
|
const uri = useMemo(() => {
|
||||||
@@ -139,41 +141,11 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
|||||||
return getLogoImageUrlById({ api, item, height: 100 });
|
return getLogoImageUrlById({ api, item, height: 100 });
|
||||||
}, [item]);
|
}, [item]);
|
||||||
|
|
||||||
const segments = useSegments();
|
|
||||||
const from = segments[2];
|
|
||||||
|
|
||||||
const opacity = useSharedValue(1);
|
|
||||||
|
|
||||||
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]);
|
|
||||||
|
|
||||||
const tap = Gesture.Tap()
|
|
||||||
.maxDuration(2000)
|
|
||||||
.onBegin(() => {
|
|
||||||
opacity.value = withTiming(0.5, { duration: 100 });
|
|
||||||
})
|
|
||||||
.onEnd(() => {
|
|
||||||
runOnJS(handleRoute)();
|
|
||||||
})
|
|
||||||
.onFinalize(() => {
|
|
||||||
opacity.value = withTiming(1, { duration: 100 });
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!uri || !logoUri) return null;
|
if (!uri || !logoUri) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GestureDetector gesture={tap}>
|
<TouchableItemRouter item={item}>
|
||||||
<Animated.View
|
<View className="px-4">
|
||||||
style={{
|
|
||||||
opacity: opacity,
|
|
||||||
}}
|
|
||||||
className="px-4"
|
|
||||||
>
|
|
||||||
<View className="relative flex justify-center rounded-2xl overflow-hidden border border-neutral-800">
|
<View className="relative flex justify-center rounded-2xl overflow-hidden border border-neutral-800">
|
||||||
<Image
|
<Image
|
||||||
source={{
|
source={{
|
||||||
@@ -199,7 +171,7 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</View>
|
||||||
</GestureDetector>
|
</TouchableItemRouter>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,20 +1,23 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import {
|
import {
|
||||||
useQuery,
|
useQuery,
|
||||||
type QueryFunction,
|
type QueryFunction,
|
||||||
type QueryKey,
|
type QueryKey,
|
||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
import { ScrollView, View, ViewProps } from "react-native";
|
import { View, ViewProps } from "react-native";
|
||||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||||
import { ItemCardText } from "../ItemCardText";
|
import { ItemCardText } from "../ItemCardText";
|
||||||
|
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
||||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||||
import SeriesPoster from "../posters/SeriesPoster";
|
import SeriesPoster from "../posters/SeriesPoster";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
title?: string | null;
|
title?: string | null;
|
||||||
orientation?: "horizontal" | "vertical";
|
orientation?: "horizontal" | "vertical";
|
||||||
|
height?: "small" | "large";
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
queryKey: QueryKey;
|
queryKey: QueryKey;
|
||||||
queryFn: QueryFunction<BaseItemDto[]>;
|
queryFn: QueryFunction<BaseItemDto[]>;
|
||||||
@@ -23,97 +26,58 @@ interface Props extends ViewProps {
|
|||||||
export const ScrollingCollectionList: React.FC<Props> = ({
|
export const ScrollingCollectionList: React.FC<Props> = ({
|
||||||
title,
|
title,
|
||||||
orientation = "vertical",
|
orientation = "vertical",
|
||||||
|
height = "small",
|
||||||
disabled = false,
|
disabled = false,
|
||||||
queryFn,
|
queryFn,
|
||||||
queryKey,
|
queryKey,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
// console.log(queryKey);
|
const [settings] = useSettings();
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: queryKey,
|
queryKey,
|
||||||
queryFn,
|
queryFn,
|
||||||
staleTime: 0,
|
enabled: !disabled,
|
||||||
refetchOnMount: true,
|
staleTime: 60 * 1000,
|
||||||
refetchOnWindowFocus: true,
|
|
||||||
refetchOnReconnect: true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (disabled || !title) return null;
|
if (disabled || !title) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props} className="">
|
<View {...props}>
|
||||||
<Text className="px-4 text-lg font-bold mb-2 text-neutral-100">
|
<Text className="px-4 text-2xl font-bold mb-2 text-neutral-100">
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
{isLoading === false && data?.length === 0 && (
|
<HorizontalScroll
|
||||||
<View className="px-4">
|
data={data}
|
||||||
<Text className="text-neutral-500">No items</Text>
|
height={orientation === "vertical" ? 247 : 164}
|
||||||
</View>
|
loading={isLoading}
|
||||||
)}
|
renderItem={(item, index) => (
|
||||||
{isLoading ? (
|
<TouchableItemRouter
|
||||||
<View
|
key={index}
|
||||||
className={`
|
item={item}
|
||||||
flex flex-row gap-2 px-4
|
className={`flex flex-col
|
||||||
`}
|
|
||||||
>
|
|
||||||
{[1, 2, 3].map((i) => (
|
|
||||||
<View className="w-44" key={i}>
|
|
||||||
<View className="bg-neutral-900 h-24 w-full rounded-md mb-1"></View>
|
|
||||||
<View className="rounded-md overflow-hidden mb-1 self-start">
|
|
||||||
<Text
|
|
||||||
className="text-neutral-900 bg-neutral-900 rounded-md"
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
Nisi mollit voluptate amet.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View className="rounded-md overflow-hidden self-start mb-1">
|
|
||||||
<Text
|
|
||||||
className="text-neutral-900 bg-neutral-900 text-xs rounded-md "
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
Lorem ipsum
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
|
||||||
<View className="px-4 flex flex-row">
|
|
||||||
{data?.map((item, index) => (
|
|
||||||
<TouchableItemRouter
|
|
||||||
item={item}
|
|
||||||
key={index}
|
|
||||||
className={`
|
|
||||||
mr-2
|
|
||||||
|
|
||||||
${orientation === "horizontal" ? "w-44" : "w-28"}
|
${orientation === "horizontal" ? "w-44" : "w-28"}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{item.Type === "Episode" && orientation === "horizontal" && (
|
<View>
|
||||||
<ContinueWatchingPoster item={item} />
|
{item.Type === "Episode" && orientation === "horizontal" && (
|
||||||
)}
|
<ContinueWatchingPoster item={item} />
|
||||||
{item.Type === "Episode" && orientation === "vertical" && (
|
)}
|
||||||
<SeriesPoster item={item} />
|
{item.Type === "Episode" && orientation === "vertical" && (
|
||||||
)}
|
<SeriesPoster item={item} />
|
||||||
{item.Type === "Movie" && orientation === "horizontal" && (
|
)}
|
||||||
<ContinueWatchingPoster item={item} />
|
{item.Type === "Movie" && orientation === "horizontal" && (
|
||||||
)}
|
<ContinueWatchingPoster item={item} />
|
||||||
{item.Type === "Movie" && orientation === "vertical" && (
|
)}
|
||||||
<MoviePoster item={item} />
|
{item.Type === "Movie" && orientation === "vertical" && (
|
||||||
)}
|
<MoviePoster item={item} />
|
||||||
{item.Type === "Series" && <SeriesPoster item={item} />}
|
)}
|
||||||
{item.Type === "Program" && (
|
{item.Type === "Series" && <SeriesPoster item={item} />}
|
||||||
<ContinueWatchingPoster item={item} />
|
<ItemCardText item={item} />
|
||||||
)}
|
</View>
|
||||||
<ItemCardText item={item} />
|
</TouchableItemRouter>
|
||||||
</TouchableItemRouter>
|
)}
|
||||||
))}
|
/>
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -11,14 +11,21 @@ import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useMemo } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { TouchableOpacityProps, View } from "react-native";
|
import { TouchableOpacityProps, View } from "react-native";
|
||||||
|
import { getColors } from "react-native-image-colors";
|
||||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||||
|
|
||||||
interface Props extends TouchableOpacityProps {
|
interface Props extends TouchableOpacityProps {
|
||||||
library: BaseItemDto;
|
library: BaseItemDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LibraryColor = {
|
||||||
|
dominantColor: string;
|
||||||
|
averageColor: string;
|
||||||
|
secondary: string;
|
||||||
|
};
|
||||||
|
|
||||||
type IconName = React.ComponentProps<typeof Ionicons>["name"];
|
type IconName = React.ComponentProps<typeof Ionicons>["name"];
|
||||||
|
|
||||||
const icons: Record<CollectionType, IconName> = {
|
const icons: Record<CollectionType, IconName> = {
|
||||||
@@ -41,6 +48,12 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
|||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
|
|
||||||
|
const [imageInfo, setImageInfo] = useState<LibraryColor>({
|
||||||
|
dominantColor: "#fff",
|
||||||
|
averageColor: "#fff",
|
||||||
|
secondary: "#fff",
|
||||||
|
});
|
||||||
|
|
||||||
const url = useMemo(
|
const url = useMemo(
|
||||||
() =>
|
() =>
|
||||||
getPrimaryImageUrl({
|
getPrimaryImageUrl({
|
||||||
@@ -61,9 +74,42 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
|||||||
});
|
});
|
||||||
return response.data.TotalRecordCount;
|
return response.data.TotalRecordCount;
|
||||||
},
|
},
|
||||||
staleTime: 1000 * 60 * 60,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (url) {
|
||||||
|
getColors(url, {
|
||||||
|
fallback: "#fff",
|
||||||
|
cache: true,
|
||||||
|
key: url,
|
||||||
|
})
|
||||||
|
.then((colors) => {
|
||||||
|
let dominantColor: string = "#fff";
|
||||||
|
let averageColor: string = "#fff";
|
||||||
|
let secondary: string = "#fff";
|
||||||
|
|
||||||
|
if (colors.platform === "android") {
|
||||||
|
dominantColor = colors.dominant;
|
||||||
|
averageColor = colors.average;
|
||||||
|
secondary = colors.muted;
|
||||||
|
} else if (colors.platform === "ios") {
|
||||||
|
dominantColor = colors.primary;
|
||||||
|
averageColor = colors.background;
|
||||||
|
secondary = colors.detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
setImageInfo({
|
||||||
|
dominantColor,
|
||||||
|
averageColor,
|
||||||
|
secondary,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Error getting colors", error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [url]);
|
||||||
|
|
||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
|
|
||||||
if (settings?.libraryOptions?.display === "row") {
|
if (settings?.libraryOptions?.display === "row") {
|
||||||
@@ -79,7 +125,7 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
|||||||
{library.Name}
|
{library.Name}
|
||||||
</Text>
|
</Text>
|
||||||
{settings?.libraryOptions?.showStats && (
|
{settings?.libraryOptions?.showStats && (
|
||||||
<Text className="font-bold text-xs text-neutral-500 text-start ml-auto">
|
<Text className="font-bold text-xs text-neutral-500 text-start px-4 ml-auto">
|
||||||
{itemsCount} items
|
{itemsCount} items
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { View } from "react-native";
|
|
||||||
import { Text } from "../common/Text";
|
|
||||||
|
|
||||||
export const HourHeader = ({ height }: { height: number }) => {
|
|
||||||
const now = new Date();
|
|
||||||
const currentHour = now.getHours();
|
|
||||||
const hoursRemaining = 24 - currentHour;
|
|
||||||
const hours = generateHours(currentHour, hoursRemaining);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
className="flex flex-row"
|
|
||||||
style={{
|
|
||||||
height,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{hours.map((hour, index) => (
|
|
||||||
<HourCell key={index} hour={hour} />
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const HourCell = ({ hour }: { hour: Date }) => (
|
|
||||||
<View className="w-[200px] flex items-center justify-center bg-neutral-800">
|
|
||||||
<Text className="text-xs text-gray-600">
|
|
||||||
{hour.toLocaleTimeString([], {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
const generateHours = (startHour: number, count: number): Date[] => {
|
|
||||||
const now = new Date();
|
|
||||||
return Array.from({ length: count }, (_, i) => {
|
|
||||||
const hour = new Date(now);
|
|
||||||
hour.setHours(startHour + i, 0, 0, 0);
|
|
||||||
return hour;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import { useMemo, useRef } from "react";
|
|
||||||
import { Dimensions, View } from "react-native";
|
|
||||||
import { Text } from "../common/Text";
|
|
||||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
|
||||||
|
|
||||||
export const LiveTVGuideRow = ({
|
|
||||||
channel,
|
|
||||||
programs,
|
|
||||||
scrollX = 0,
|
|
||||||
isVisible = true,
|
|
||||||
}: {
|
|
||||||
channel: BaseItemDto;
|
|
||||||
programs?: BaseItemDto[] | null;
|
|
||||||
scrollX?: number;
|
|
||||||
isVisible?: boolean;
|
|
||||||
}) => {
|
|
||||||
const positionRefs = useRef<{ [key: string]: number }>({});
|
|
||||||
const screenWidth = Dimensions.get("window").width;
|
|
||||||
|
|
||||||
const calculateWidth = (s?: string | null, e?: string | null) => {
|
|
||||||
if (!s || !e) return 0;
|
|
||||||
const start = new Date(s);
|
|
||||||
const end = new Date(e);
|
|
||||||
const duration = end.getTime() - start.getTime();
|
|
||||||
const minutes = duration / 60000;
|
|
||||||
const width = (minutes / 60) * 200;
|
|
||||||
return width;
|
|
||||||
};
|
|
||||||
|
|
||||||
const programsWithPositions = useMemo(() => {
|
|
||||||
let cumulativeWidth = 0;
|
|
||||||
return programs
|
|
||||||
?.filter((p) => p.ChannelId === channel.Id)
|
|
||||||
.map((p) => {
|
|
||||||
const width = calculateWidth(p.StartDate, p.EndDate);
|
|
||||||
const position = cumulativeWidth;
|
|
||||||
cumulativeWidth += width;
|
|
||||||
return { ...p, width, position };
|
|
||||||
});
|
|
||||||
}, [programs, channel.Id]);
|
|
||||||
|
|
||||||
const isCurrentlyLive = (program: BaseItemDto) => {
|
|
||||||
if (!program.StartDate || !program.EndDate) return false;
|
|
||||||
const now = new Date();
|
|
||||||
const start = new Date(program.StartDate);
|
|
||||||
const end = new Date(program.EndDate);
|
|
||||||
return now >= start && now <= end;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isVisible) {
|
|
||||||
return <View style={{ height: 64 }} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View key={channel.ChannelNumber} className="flex flex-row h-16">
|
|
||||||
{programsWithPositions?.map((p) => (
|
|
||||||
<TouchableItemRouter item={p} key={p.Id}>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: p.width,
|
|
||||||
height: "100%",
|
|
||||||
position: "absolute",
|
|
||||||
left: p.position,
|
|
||||||
backgroundColor: isCurrentlyLive(p)
|
|
||||||
? "rgba(255, 255, 255, 0.1)"
|
|
||||||
: "transparent",
|
|
||||||
}}
|
|
||||||
className="flex flex-col items-center justify-center border border-neutral-800 overflow-hidden"
|
|
||||||
>
|
|
||||||
{(() => {
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
marginLeft:
|
|
||||||
p.width > screenWidth && scrollX > p.position
|
|
||||||
? scrollX - p.position
|
|
||||||
: 0,
|
|
||||||
}}
|
|
||||||
className="px-4 self-start"
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
numberOfLines={2}
|
|
||||||
className="text-xs text-start self-start"
|
|
||||||
>
|
|
||||||
{p.Name}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</View>
|
|
||||||
</TouchableItemRouter>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -31,10 +31,10 @@ export const MediaListSection: React.FC<Props> = ({
|
|||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
const { data: collection } = useQuery({
|
const { data: collection, isLoading } = useQuery({
|
||||||
queryKey,
|
queryKey,
|
||||||
queryFn,
|
queryFn,
|
||||||
staleTime: 0,
|
staleTime: 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const fetchItems = useCallback(
|
const fetchItems = useCallback(
|
||||||
|
|||||||
@@ -9,10 +9,8 @@ interface Props extends ViewProps {
|
|||||||
export const MoviesTitleHeader: React.FC<Props> = ({ item, ...props }) => {
|
export const MoviesTitleHeader: React.FC<Props> = ({ item, ...props }) => {
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<Text uiTextView selectable className="font-bold text-2xl mb-1">
|
<Text className=" font-bold text-2xl mb-1">{item?.Name}</Text>
|
||||||
{item?.Name}
|
<Text className=" opacity-50">{item?.ProductionYear}</Text>
|
||||||
</Text>
|
|
||||||
<Text className="opacity-50">{item?.ProductionYear}</Text>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
|
import { usePlayback } from "@/providers/PlaybackProvider";
|
||||||
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
|
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
||||||
|
import ios from "@/utils/profiles/ios";
|
||||||
import { runtimeTicksToSeconds } from "@/utils/time";
|
import { runtimeTicksToSeconds } from "@/utils/time";
|
||||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useRouter } from "expo-router";
|
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback } from "react";
|
|
||||||
import { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
|
import { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
|
||||||
import CastContext, {
|
import CastContext, {
|
||||||
PlayServicesState,
|
PlayServicesState,
|
||||||
@@ -33,11 +35,11 @@ export const SongsListItem: React.FC<Props> = ({
|
|||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const castDevice = useCastDevice();
|
const castDevice = useCastDevice();
|
||||||
const router = useRouter();
|
|
||||||
const client = useRemoteMediaClient();
|
const client = useRemoteMediaClient();
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
|
|
||||||
const { setPlaySettings } = usePlaySettings();
|
const { setCurrentlyPlayingState } = usePlayback();
|
||||||
|
|
||||||
const openSelect = () => {
|
const openSelect = () => {
|
||||||
if (!castDevice?.deviceId) {
|
if (!castDevice?.deviceId) {
|
||||||
@@ -68,18 +70,32 @@ export const SongsListItem: React.FC<Props> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const play = useCallback(async (type: "device" | "cast") => {
|
const play = async (type: "device" | "cast") => {
|
||||||
if (!user?.Id || !api || !item.Id) {
|
if (!user?.Id || !api || !item.Id) {
|
||||||
console.warn("No user, api or item", user, api, item.Id);
|
console.warn("No user, api or item", user, api, item.Id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await setPlaySettings({
|
const response = await getMediaInfoApi(api!).getPlaybackInfo({
|
||||||
item,
|
itemId: item?.Id,
|
||||||
|
userId: user?.Id,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!data?.url) {
|
const sessionData = response.data;
|
||||||
throw new Error("play-music ~ No stream url");
|
|
||||||
|
const url = await getStreamUrl({
|
||||||
|
api,
|
||||||
|
userId: user.Id,
|
||||||
|
item,
|
||||||
|
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
|
||||||
|
sessionData,
|
||||||
|
deviceProfile: castDevice?.deviceId ? chromecastProfile : ios,
|
||||||
|
mediaSourceId: item.Id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!url || !item) {
|
||||||
|
console.warn("No url or item", url, item.Id);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === "cast" && client) {
|
if (type === "cast" && client) {
|
||||||
@@ -89,7 +105,7 @@ export const SongsListItem: React.FC<Props> = ({
|
|||||||
else {
|
else {
|
||||||
client.loadMedia({
|
client.loadMedia({
|
||||||
mediaInfo: {
|
mediaInfo: {
|
||||||
contentUrl: data.url!,
|
contentUrl: url,
|
||||||
contentType: "video/mp4",
|
contentType: "video/mp4",
|
||||||
metadata: {
|
metadata: {
|
||||||
type: item.Type === "Episode" ? "tvShow" : "movie",
|
type: item.Type === "Episode" ? "tvShow" : "movie",
|
||||||
@@ -102,10 +118,13 @@ export const SongsListItem: React.FC<Props> = ({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
console.log("Playing on device", data.url, item.Id);
|
console.log("Playing on device", url, item.Id);
|
||||||
router.push("/music-player");
|
setCurrentlyPlayingState({
|
||||||
|
item,
|
||||||
|
url,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ const MoviePoster: React.FC<MoviePosterProps> = ({
|
|||||||
}, [item]);
|
}, [item]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="relative rounded-lg overflow-hidden border border-neutral-900 w-28 aspect-[10/15]">
|
<View className="relative rounded-lg overflow-hidden border border-neutral-900">
|
||||||
<Image
|
<Image
|
||||||
placeholder={{
|
placeholder={{
|
||||||
blurhash,
|
blurhash,
|
||||||
@@ -57,6 +57,7 @@ const MoviePoster: React.FC<MoviePosterProps> = ({
|
|||||||
width: "100%",
|
width: "100%",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<WatchedIndicator item={item} />
|
<WatchedIndicator item={item} />
|
||||||
{showProgress && progress > 0 && (
|
{showProgress && progress > 0 && (
|
||||||
<View className="h-1 bg-red-600 w-full"></View>
|
<View className="h-1 bg-red-600 w-full"></View>
|
||||||
|
|||||||