mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-21 10:38:06 +00:00
Compare commits
1 Commits
0.21.0
...
refactor/s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c051f6f61 |
4
.gitignore
vendored
4
.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,11 +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
|
||||||
|
|
||||||
|
|||||||
3
.idea/.gitignore
generated
vendored
3
.idea/.gitignore
generated
vendored
@@ -1,3 +0,0 @@
|
|||||||
# Default ignored files
|
|
||||||
/shelf/
|
|
||||||
/workspace.xml
|
|
||||||
329
.idea/caches/deviceStreaming.xml
generated
329
.idea/caches/deviceStreaming.xml
generated
@@ -1,329 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="DeviceStreaming">
|
|
||||||
<option name="deviceSelectionList">
|
|
||||||
<list>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="27" />
|
|
||||||
<option name="brand" value="DOCOMO" />
|
|
||||||
<option name="codename" value="F01L" />
|
|
||||||
<option name="id" value="F01L" />
|
|
||||||
<option name="manufacturer" value="FUJITSU" />
|
|
||||||
<option name="name" value="F-01L" />
|
|
||||||
<option name="screenDensity" value="360" />
|
|
||||||
<option name="screenX" value="720" />
|
|
||||||
<option name="screenY" value="1280" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="28" />
|
|
||||||
<option name="brand" value="DOCOMO" />
|
|
||||||
<option name="codename" value="SH-01L" />
|
|
||||||
<option name="id" value="SH-01L" />
|
|
||||||
<option name="manufacturer" value="SHARP" />
|
|
||||||
<option name="name" value="AQUOS sense2 SH-01L" />
|
|
||||||
<option name="screenDensity" value="480" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2160" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="Lenovo" />
|
|
||||||
<option name="codename" value="TB370FU" />
|
|
||||||
<option name="id" value="TB370FU" />
|
|
||||||
<option name="manufacturer" value="Lenovo" />
|
|
||||||
<option name="name" value="Tab P12" />
|
|
||||||
<option name="screenDensity" value="340" />
|
|
||||||
<option name="screenX" value="1840" />
|
|
||||||
<option name="screenY" value="2944" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="31" />
|
|
||||||
<option name="brand" value="samsung" />
|
|
||||||
<option name="codename" value="a51" />
|
|
||||||
<option name="id" value="a51" />
|
|
||||||
<option name="manufacturer" value="Samsung" />
|
|
||||||
<option name="name" value="Galaxy A51" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2400" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="akita" />
|
|
||||||
<option name="id" value="akita" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 8a" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2400" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="33" />
|
|
||||||
<option name="brand" value="samsung" />
|
|
||||||
<option name="codename" value="b0q" />
|
|
||||||
<option name="id" value="b0q" />
|
|
||||||
<option name="manufacturer" value="Samsung" />
|
|
||||||
<option name="name" value="Galaxy S22 Ultra" />
|
|
||||||
<option name="screenDensity" value="600" />
|
|
||||||
<option name="screenX" value="1440" />
|
|
||||||
<option name="screenY" value="3088" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="32" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="bluejay" />
|
|
||||||
<option name="id" value="bluejay" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 6a" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2400" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="caiman" />
|
|
||||||
<option name="id" value="caiman" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 9 Pro" />
|
|
||||||
<option name="screenDensity" value="360" />
|
|
||||||
<option name="screenX" value="960" />
|
|
||||||
<option name="screenY" value="2142" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="comet" />
|
|
||||||
<option name="id" value="comet" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 9 Pro Fold" />
|
|
||||||
<option name="screenDensity" value="390" />
|
|
||||||
<option name="screenX" value="2076" />
|
|
||||||
<option name="screenY" value="2152" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="29" />
|
|
||||||
<option name="brand" value="samsung" />
|
|
||||||
<option name="codename" value="crownqlteue" />
|
|
||||||
<option name="id" value="crownqlteue" />
|
|
||||||
<option name="manufacturer" value="Samsung" />
|
|
||||||
<option name="name" value="Galaxy Note9" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="2220" />
|
|
||||||
<option name="screenY" value="1080" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="samsung" />
|
|
||||||
<option name="codename" value="dm3q" />
|
|
||||||
<option name="id" value="dm3q" />
|
|
||||||
<option name="manufacturer" value="Samsung" />
|
|
||||||
<option name="name" value="Galaxy S23 Ultra" />
|
|
||||||
<option name="screenDensity" value="600" />
|
|
||||||
<option name="screenX" value="1440" />
|
|
||||||
<option name="screenY" value="3088" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="samsung" />
|
|
||||||
<option name="codename" value="e1q" />
|
|
||||||
<option name="id" value="e1q" />
|
|
||||||
<option name="manufacturer" value="Samsung" />
|
|
||||||
<option name="name" value="Galaxy S24" />
|
|
||||||
<option name="screenDensity" value="480" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2340" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="33" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="felix" />
|
|
||||||
<option name="id" value="felix" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel Fold" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="2208" />
|
|
||||||
<option name="screenY" value="1840" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="felix" />
|
|
||||||
<option name="id" value="felix" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel Fold" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="2208" />
|
|
||||||
<option name="screenY" value="1840" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="33" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="felix_camera" />
|
|
||||||
<option name="id" value="felix_camera" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel Fold (Camera-enabled)" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="2208" />
|
|
||||||
<option name="screenY" value="1840" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="33" />
|
|
||||||
<option name="brand" value="samsung" />
|
|
||||||
<option name="codename" value="gts8uwifi" />
|
|
||||||
<option name="id" value="gts8uwifi" />
|
|
||||||
<option name="manufacturer" value="Samsung" />
|
|
||||||
<option name="name" value="Galaxy Tab S8 Ultra" />
|
|
||||||
<option name="screenDensity" value="320" />
|
|
||||||
<option name="screenX" value="1848" />
|
|
||||||
<option name="screenY" value="2960" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="husky" />
|
|
||||||
<option name="id" value="husky" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 8 Pro" />
|
|
||||||
<option name="screenDensity" value="390" />
|
|
||||||
<option name="screenX" value="1008" />
|
|
||||||
<option name="screenY" value="2244" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="30" />
|
|
||||||
<option name="brand" value="motorola" />
|
|
||||||
<option name="codename" value="java" />
|
|
||||||
<option name="id" value="java" />
|
|
||||||
<option name="manufacturer" value="Motorola" />
|
|
||||||
<option name="name" value="G20" />
|
|
||||||
<option name="screenDensity" value="280" />
|
|
||||||
<option name="screenX" value="720" />
|
|
||||||
<option name="screenY" value="1600" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="komodo" />
|
|
||||||
<option name="id" value="komodo" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 9 Pro XL" />
|
|
||||||
<option name="screenDensity" value="360" />
|
|
||||||
<option name="screenX" value="1008" />
|
|
||||||
<option name="screenY" value="2244" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="33" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="lynx" />
|
|
||||||
<option name="id" value="lynx" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 7a" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2400" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="31" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="oriole" />
|
|
||||||
<option name="id" value="oriole" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 6" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2400" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="33" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="panther" />
|
|
||||||
<option name="id" value="panther" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 7" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2400" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="samsung" />
|
|
||||||
<option name="codename" value="q5q" />
|
|
||||||
<option name="id" value="q5q" />
|
|
||||||
<option name="manufacturer" value="Samsung" />
|
|
||||||
<option name="name" value="Galaxy Z Fold5" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="1812" />
|
|
||||||
<option name="screenY" value="2176" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="samsung" />
|
|
||||||
<option name="codename" value="q6q" />
|
|
||||||
<option name="id" value="q6q" />
|
|
||||||
<option name="manufacturer" value="Samsung" />
|
|
||||||
<option name="name" value="Galaxy Z Fold6" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="1856" />
|
|
||||||
<option name="screenY" value="2160" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="30" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="r11" />
|
|
||||||
<option name="id" value="r11" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel Watch" />
|
|
||||||
<option name="screenDensity" value="320" />
|
|
||||||
<option name="screenX" value="384" />
|
|
||||||
<option name="screenY" value="384" />
|
|
||||||
<option name="type" value="WEAR_OS" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="30" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="redfin" />
|
|
||||||
<option name="id" value="redfin" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 5" />
|
|
||||||
<option name="screenDensity" value="440" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2340" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="shiba" />
|
|
||||||
<option name="id" value="shiba" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 8" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2400" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="33" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="tangorpro" />
|
|
||||||
<option name="id" value="tangorpro" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel Tablet" />
|
|
||||||
<option name="screenDensity" value="320" />
|
|
||||||
<option name="screenX" value="1600" />
|
|
||||||
<option name="screenY" value="2560" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="tokay" />
|
|
||||||
<option name="id" value="tokay" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 9" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2424" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
</list>
|
|
||||||
</option>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
6
.idea/misc.xml
generated
6
.idea/misc.xml
generated
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="ProjectRootManager">
|
|
||||||
<output url="file://$PROJECT_DIR$/out" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
8
.idea/modules.xml
generated
8
.idea/modules.xml
generated
@@ -1,8 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="ProjectModuleManager">
|
|
||||||
<modules>
|
|
||||||
<module fileurl="file://$PROJECT_DIR$/.idea/streamyfin.iml" filepath="$PROJECT_DIR$/.idea/streamyfin.iml" />
|
|
||||||
</modules>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
9
.idea/streamyfin.iml
generated
9
.idea/streamyfin.iml
generated
@@ -1,9 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<module type="JAVA_MODULE" version="4">
|
|
||||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
|
||||||
<exclude-output />
|
|
||||||
<content url="file://$MODULE_DIR$" />
|
|
||||||
<orderEntry type="inheritedJdk" />
|
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
|
||||||
</component>
|
|
||||||
</module>
|
|
||||||
6
.idea/vcs.xml
generated
6
.idea/vcs.xml
generated
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="VcsDirectoryMappings">
|
|
||||||
<mapping directory="" vcs="Git" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
7
.vscode/settings.json
vendored
7
.vscode/settings.json
vendored
@@ -8,10 +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"
|
|
||||||
},
|
|
||||||
"java.configuration.updateBuildConfiguration": "interactive",
|
|
||||||
"java.compile.nullAnalysis.mode": "automatic"
|
|
||||||
}
|
}
|
||||||
|
|||||||
20
app.json
20
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.21.0",
|
"version": "0.17.0",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "streamyfin",
|
"scheme": "streamyfin",
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"jsEngine": "hermes",
|
"jsEngine": "hermes",
|
||||||
"versionCode": 46,
|
"versionCode": 43,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/images/adaptive_icon.png"
|
"foregroundImage": "./assets/images/adaptive_icon.png"
|
||||||
},
|
},
|
||||||
@@ -66,6 +66,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
"./plugins/withAndroidMainActivityAttributes",
|
||||||
|
{
|
||||||
|
"com.reactnative.googlecast.RNGCExpandedControllerActivity": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
["./plugins/withExpandedController.js"],
|
||||||
[
|
[
|
||||||
"expo-build-properties",
|
"expo-build-properties",
|
||||||
{
|
{
|
||||||
@@ -99,14 +106,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
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export default function IndexLayout() {
|
|||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.push("/(auth)/settings");
|
router.push("/(auth)/settings");
|
||||||
}}
|
}}
|
||||||
|
className="p-2 "
|
||||||
>
|
>
|
||||||
<Feather name="settings" color={"white"} size={22} />
|
<Feather name="settings" color={"white"} size={22} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -41,11 +42,17 @@ export default function IndexLayout() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="settings"
|
name="settings/index"
|
||||||
options={{
|
options={{
|
||||||
title: "Settings",
|
title: "Settings",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="settings/audio-language"
|
||||||
|
options={{
|
||||||
|
title: "Audio Language",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{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} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -2,46 +2,36 @@ import { Text } from "@/components/common/Text";
|
|||||||
import { ActiveDownloads } from "@/components/downloads/ActiveDownloads";
|
import { ActiveDownloads } from "@/components/downloads/ActiveDownloads";
|
||||||
import { MovieCard } from "@/components/downloads/MovieCard";
|
import { MovieCard } from "@/components/downloads/MovieCard";
|
||||||
import { SeriesCard } from "@/components/downloads/SeriesCard";
|
import { SeriesCard } from "@/components/downloads/SeriesCard";
|
||||||
import { DownloadedItem, useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { queueAtom } from "@/utils/atoms/queue";
|
import { queueAtom } from "@/utils/atoms/queue";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { useRouter } from "expo-router";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { router } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
|
import { ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
export default function page() {
|
const downloads: React.FC = () => {
|
||||||
const [queue, setQueue] = useAtom(queueAtom);
|
const [queue, setQueue] = useAtom(queueAtom);
|
||||||
const { removeProcess, downloadedFiles } = useDownload();
|
const { removeProcess, downloadedFiles } = useDownload();
|
||||||
const router = useRouter();
|
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
|
|
||||||
const movies = useMemo(() => {
|
const movies = useMemo(
|
||||||
try {
|
() => downloadedFiles?.filter((f) => f.Type === "Movie") || [],
|
||||||
return downloadedFiles?.filter((f) => f.item.Type === "Movie") || [];
|
[downloadedFiles]
|
||||||
} catch {
|
);
|
||||||
migration_20241124();
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}, [downloadedFiles]);
|
|
||||||
|
|
||||||
const groupedBySeries = useMemo(() => {
|
const groupedBySeries = useMemo(() => {
|
||||||
try {
|
const episodes = downloadedFiles?.filter((f) => f.Type === "Episode");
|
||||||
const episodes = downloadedFiles?.filter(
|
const series: { [key: string]: BaseItemDto[] } = {};
|
||||||
(f) => f.item.Type === "Episode"
|
episodes?.forEach((e) => {
|
||||||
);
|
if (!series[e.SeriesName!]) series[e.SeriesName!] = [];
|
||||||
const series: { [key: string]: DownloadedItem[] } = {};
|
series[e.SeriesName!].push(e);
|
||||||
episodes?.forEach((e) => {
|
});
|
||||||
if (!series[e.item.SeriesName!]) series[e.item.SeriesName!] = [];
|
return Object.values(series);
|
||||||
series[e.item.SeriesName!].push(e);
|
|
||||||
});
|
|
||||||
return Object.values(series);
|
|
||||||
} catch {
|
|
||||||
migration_20241124();
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}, [downloadedFiles]);
|
}, [downloadedFiles]);
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
@@ -108,20 +98,17 @@ export default function page() {
|
|||||||
</View>
|
</View>
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
<View className="px-4 flex flex-row">
|
<View className="px-4 flex flex-row">
|
||||||
{movies?.map((item) => (
|
{movies?.map((item: BaseItemDto) => (
|
||||||
<View className="mb-2 last:mb-0" key={item.item.Id}>
|
<View className="mb-2 last:mb-0" key={item.Id}>
|
||||||
<MovieCard item={item.item} />
|
<MovieCard item={item} />
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{groupedBySeries?.map((items, index) => (
|
{groupedBySeries?.map((items: BaseItemDto[], index: number) => (
|
||||||
<SeriesCard
|
<SeriesCard items={items} key={items[0].SeriesId} />
|
||||||
items={items.map((i) => i.item)}
|
|
||||||
key={items[0].item.SeriesId}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
{downloadedFiles?.length === 0 && (
|
{downloadedFiles?.length === 0 && (
|
||||||
<View className="flex px-4">
|
<View className="flex px-4">
|
||||||
@@ -131,24 +118,6 @@ export default function page() {
|
|||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
function migration_20241124() {
|
export default downloads;
|
||||||
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(),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionLi
|
|||||||
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 { Colors } from "@/constants/Colors";
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
import { TAB_HEIGHT } from "@/constants/Values";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
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";
|
||||||
@@ -25,10 +25,11 @@ import {
|
|||||||
import NetInfo from "@react-native-community/netinfo";
|
import NetInfo from "@react-native-community/netinfo";
|
||||||
import { QueryFunction, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { QueryFunction, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useNavigation, useRouter } from "expo-router";
|
import { useNavigation, useRouter } from "expo-router";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
|
Platform,
|
||||||
RefreshControl,
|
RefreshControl,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
@@ -53,6 +54,7 @@ type MediaListSection = {
|
|||||||
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 = useAtomValue(apiAtom);
|
||||||
@@ -67,8 +69,6 @@ export default function index() {
|
|||||||
const { downloadedFiles } = useDownload();
|
const { downloadedFiles } = useDownload();
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hasDownloads = downloadedFiles && downloadedFiles.length > 0;
|
const hasDownloads = downloadedFiles && downloadedFiles.length > 0;
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
@@ -165,13 +165,28 @@ export default function index() {
|
|||||||
);
|
);
|
||||||
}, [userViews]);
|
}, [userViews]);
|
||||||
|
|
||||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
|
||||||
|
|
||||||
const refetch = useCallback(async () => {
|
const refetch = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
await invalidateCache();
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: ["home"],
|
||||||
|
refetchType: "all",
|
||||||
|
type: "all",
|
||||||
|
exact: false,
|
||||||
|
});
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: ["home"],
|
||||||
|
refetchType: "all",
|
||||||
|
type: "all",
|
||||||
|
exact: false,
|
||||||
|
});
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: ["item"],
|
||||||
|
refetchType: "all",
|
||||||
|
type: "all",
|
||||||
|
exact: false,
|
||||||
|
});
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, []);
|
}, [queryClient]);
|
||||||
|
|
||||||
const createCollectionConfig = useCallback(
|
const createCollectionConfig = useCallback(
|
||||||
(
|
(
|
||||||
@@ -227,7 +242,7 @@ export default function index() {
|
|||||||
const ss: Section[] = [
|
const ss: Section[] = [
|
||||||
{
|
{
|
||||||
title: "Continue Watching",
|
title: "Continue Watching",
|
||||||
queryKey: ["home", "resumeItems"],
|
queryKey: ["home", "resumeItems", user.Id],
|
||||||
queryFn: async () =>
|
queryFn: async () =>
|
||||||
(
|
(
|
||||||
await getItemsApi(api).getResumeItems({
|
await getItemsApi(api).getResumeItems({
|
||||||
@@ -241,7 +256,7 @@ export default function index() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Next Up",
|
title: "Next Up",
|
||||||
queryKey: ["home", "nextUp-all"],
|
queryKey: ["home", "nextUp-all", user?.Id],
|
||||||
queryFn: async () =>
|
queryFn: async () =>
|
||||||
(
|
(
|
||||||
await getTvShowsApi(api).getNextUp({
|
await getTvShowsApi(api).getNextUp({
|
||||||
@@ -347,6 +362,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">
|
||||||
@@ -371,11 +388,15 @@ export default function index() {
|
|||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl refreshing={loading} onRefresh={refetch} />
|
<RefreshControl refreshing={loading} onRefresh={refetch} />
|
||||||
}
|
}
|
||||||
|
key={"home"}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingLeft: insets.left,
|
paddingLeft: insets.left,
|
||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
paddingBottom: 16,
|
paddingBottom: 16,
|
||||||
}}
|
}}
|
||||||
|
style={{
|
||||||
|
marginBottom: TAB_HEIGHT,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<View className="flex flex-col space-y-4">
|
<View className="flex flex-col space-y-4">
|
||||||
<LargeMovieCarousel />
|
<LargeMovieCarousel />
|
||||||
|
|||||||
61
app/(auth)/(tabs)/(home)/settings/audio-language.tsx
Normal file
61
app/(auth)/(tabs)/(home)/settings/audio-language.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { ScrollView, View, ViewProps } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { LANGUAGES } from "@/constants/Languages";
|
||||||
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
|
import { ListSection } from "@/components/list/ListSection";
|
||||||
|
import { TAB_HEIGHT } from "@/constants/Values";
|
||||||
|
import { DefaultLanguageOption, useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Colors } from "@/constants/Colors";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const [settings, updateSettings] = useSettings();
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
paddingBottom: 16,
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
marginBottom: TAB_HEIGHT,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className="py-4 px-4">
|
||||||
|
<ListSection title="LANGUAGES">
|
||||||
|
{LANGUAGES.sort(sortByName).map((l) => (
|
||||||
|
<ListItem
|
||||||
|
key={l.value}
|
||||||
|
title={l.label}
|
||||||
|
onPress={() => {
|
||||||
|
updateSettings({
|
||||||
|
...settings,
|
||||||
|
defaultAudioLanguage: l,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
iconAfter={
|
||||||
|
settings?.defaultAudioLanguage?.value === l.value ? (
|
||||||
|
<Ionicons name="checkmark" size={24} color={Colors.primary} />
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ListSection>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortByName = (a: DefaultLanguageOption, b: DefaultLanguageOption) => {
|
||||||
|
if (a.label < b.label) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (a.label > b.label) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
@@ -1,13 +1,18 @@
|
|||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ListItem } from "@/components/ListItem";
|
import { ListInputItem } from "@/components/list/ListInputItem";
|
||||||
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
|
import { ListSection } from "@/components/list/ListSection";
|
||||||
import { SettingToggles } from "@/components/settings/SettingToggles";
|
import { SettingToggles } from "@/components/settings/SettingToggles";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { clearLogs, readFromLog } from "@/utils/log";
|
import { clearLogs, readFromLog } from "@/utils/log";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import * as Haptics from "expo-haptics";
|
import * as Haptics from "expo-haptics";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { Alert, ScrollView, View } from "react-native";
|
import { Alert, ScrollView, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
@@ -16,6 +21,7 @@ import { toast } from "sonner-native";
|
|||||||
export default function settings() {
|
export default function settings() {
|
||||||
const { logout } = useJellyfin();
|
const { logout } = useJellyfin();
|
||||||
const { deleteAllFiles } = useDownload();
|
const { deleteAllFiles } = useDownload();
|
||||||
|
const [settings, updateSettings] = useSettings();
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
@@ -57,6 +63,8 @@ export default function settings() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
@@ -73,15 +81,46 @@ export default function settings() {
|
|||||||
>
|
>
|
||||||
registerBackgroundFetchAsync
|
registerBackgroundFetchAsync
|
||||||
</Button> */}
|
</Button> */}
|
||||||
<View>
|
<ListSection title="USER INFO">
|
||||||
<Text className="font-bold text-lg mb-2">User Info</Text>
|
<ListItem title="User" text={user?.Name} />
|
||||||
|
<ListItem title="Server" text={api?.basePath} />
|
||||||
|
<ListItem title="Token" text={api?.accessToken} />
|
||||||
|
</ListSection>
|
||||||
|
|
||||||
<View className="flex flex-col rounded-xl overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800 ">
|
<ListSection title="MEDIA">
|
||||||
<ListItem title="User" subTitle={user?.Name} />
|
<ListItem
|
||||||
<ListItem title="Server" subTitle={api?.basePath} />
|
title="Audio language"
|
||||||
<ListItem title="Token" subTitle={api?.accessToken} />
|
iconAfter={
|
||||||
</View>
|
<Ionicons name="chevron-forward" size={20} color="white" />
|
||||||
</View>
|
}
|
||||||
|
onPress={() => router.push("/settings/audio-language")}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
title="Subtitle language"
|
||||||
|
iconAfter={
|
||||||
|
<Ionicons name="chevron-forward" size={20} color="white" />
|
||||||
|
}
|
||||||
|
onPress={() => router.push("/settings/subtitle-language")}
|
||||||
|
/>
|
||||||
|
<ListInputItem
|
||||||
|
textInputProps={{
|
||||||
|
placeholder: "30",
|
||||||
|
clearButtonMode: "never",
|
||||||
|
returnKeyType: "done",
|
||||||
|
}}
|
||||||
|
defaultValue={(settings?.forwardSkipTime || "").toString()}
|
||||||
|
title={"Forward skip"}
|
||||||
|
onChange={(val) => {
|
||||||
|
// 1. validate positive number
|
||||||
|
// 2. save settings
|
||||||
|
if (val.length === 0) return;
|
||||||
|
if (val.match(/^\d+$/)) {
|
||||||
|
} else {
|
||||||
|
toast.error("Invalid number");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ListSection>
|
||||||
|
|
||||||
<View>
|
<View>
|
||||||
<Text className="font-bold text-lg mb-2">Quick connect</Text>
|
<Text className="font-bold text-lg mb-2">Quick connect</Text>
|
||||||
61
app/(auth)/(tabs)/(home)/settings/subtitle-language.tsx
Normal file
61
app/(auth)/(tabs)/(home)/settings/subtitle-language.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { ScrollView, View, ViewProps } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { LANGUAGES } from "@/constants/Languages";
|
||||||
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
|
import { ListSection } from "@/components/list/ListSection";
|
||||||
|
import { TAB_HEIGHT } from "@/constants/Values";
|
||||||
|
import { DefaultLanguageOption, useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Colors } from "@/constants/Colors";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const [settings, updateSettings] = useSettings();
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
paddingBottom: 16,
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
marginBottom: TAB_HEIGHT,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className="py-4 px-4">
|
||||||
|
<ListSection title="LANGUAGES">
|
||||||
|
{LANGUAGES.sort(sortByName).map((l) => (
|
||||||
|
<ListItem
|
||||||
|
key={l.value}
|
||||||
|
title={l.label}
|
||||||
|
onPress={() => {
|
||||||
|
updateSettings({
|
||||||
|
...settings,
|
||||||
|
defaultSubtitleLanguage: l,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
iconAfter={
|
||||||
|
settings?.defaultSubtitleLanguage?.value === l.value ? (
|
||||||
|
<Ionicons name="checkmark" size={24} color={Colors.primary} />
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ListSection>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortByName = (a: DefaultLanguageOption, b: DefaultLanguageOption) => {
|
||||||
|
if (a.label < b.label) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (a.label > b.label) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
@@ -2,10 +2,6 @@ import { Text } from "@/components/common/Text";
|
|||||||
import { ItemContent } from "@/components/ItemContent";
|
import { ItemContent } from "@/components/ItemContent";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
import {
|
|
||||||
getMediaInfoApi,
|
|
||||||
getUserLibraryApi,
|
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
@@ -26,18 +22,16 @@ const Page: React.FC = () => {
|
|||||||
const { data: item, isError } = useQuery({
|
const { data: item, isError } = useQuery({
|
||||||
queryKey: ["item", id],
|
queryKey: ["item", id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!api || !user || !id) return;
|
const res = await getUserItemData({
|
||||||
const res = await getUserLibraryApi(api).getItem({
|
api,
|
||||||
itemId: id,
|
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
|
itemId: id,
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.data;
|
return res;
|
||||||
},
|
},
|
||||||
staleTime: 0,
|
enabled: !!id && !!api,
|
||||||
refetchOnMount: true,
|
staleTime: 60 * 1000 * 5, // 5 minutes
|
||||||
refetchOnWindowFocus: true,
|
|
||||||
refetchOnReconnect: true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const opacity = useSharedValue(1);
|
const opacity = useSharedValue(1);
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import {
|
||||||
|
useFocusEffect,
|
||||||
|
useLocalSearchParams,
|
||||||
|
useNavigation,
|
||||||
|
} from "expo-router";
|
||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
import * 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 +16,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,6 +32,7 @@ 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,
|
||||||
@@ -56,13 +60,12 @@ 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) {
|
||||||
@@ -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]);
|
||||||
|
|
||||||
@@ -217,7 +219,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%",
|
||||||
@@ -228,10 +230,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",
|
||||||
@@ -242,7 +244,7 @@ const Page = () => {
|
|||||||
<ItemPoster item={item} />
|
<ItemPoster item={item} />
|
||||||
<ItemCardText item={item} />
|
<ItemCardText item={item} />
|
||||||
</View>
|
</View>
|
||||||
</TouchableItemRouter>
|
</MemoizedTouchableItemRouter>
|
||||||
),
|
),
|
||||||
[orientation]
|
[orientation]
|
||||||
);
|
);
|
||||||
@@ -427,7 +429,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>
|
||||||
@@ -436,10 +437,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();
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -7,6 +8,7 @@ import { Loader } from "@/components/Loader";
|
|||||||
import AlbumCover from "@/components/posters/AlbumCover";
|
import AlbumCover from "@/components/posters/AlbumCover";
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
import SeriesPoster from "@/components/posters/SeriesPoster";
|
import SeriesPoster from "@/components/posters/SeriesPoster";
|
||||||
|
import { TAB_HEIGHT } from "@/constants/Values";
|
||||||
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 { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
@@ -70,43 +72,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();
|
||||||
@@ -230,6 +226,10 @@ export default function search() {
|
|||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingLeft: insets.left,
|
paddingLeft: insets.left,
|
||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
|
paddingBottom: 16,
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
marginBottom: TAB_HEIGHT,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className="flex flex-col pt-2">
|
<View className="flex flex-col pt-2">
|
||||||
|
|||||||
@@ -1,77 +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 "react-native-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";
|
||||||
|
|
||||||
export const NativeTabs = withLayoutContext<
|
|
||||||
BottomTabNavigationOptions,
|
|
||||||
typeof Navigator,
|
|
||||||
TabNavigationState<ParamListBase>,
|
|
||||||
NativeBottomTabNavigationEventMap
|
|
||||||
>(Navigator);
|
|
||||||
|
|
||||||
export default function TabLayout() {
|
export default function TabLayout() {
|
||||||
|
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>
|
/>
|
||||||
</>
|
<Tabs.Screen
|
||||||
|
name="(search)"
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
title: "Search",
|
||||||
|
tabBarIcon: ({ color, focused }) => (
|
||||||
|
<TabBarIcon name={focused ? "search" : "search"} color={color} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="(libraries)"
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
title: "Library",
|
||||||
|
tabBarIcon: ({ color, focused }) => (
|
||||||
|
<TabBarIcon
|
||||||
|
name={focused ? "apps" : "apps-outline"}
|
||||||
|
color={color}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tabs>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
308
app/(auth)/play-music.tsx
Normal file
308
app/(auth)/play-music.tsx
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import AlbumCover from "@/components/posters/AlbumCover";
|
||||||
|
import { Controls } from "@/components/video-player/Controls";
|
||||||
|
import { useAndroidNavigationBar } from "@/hooks/useAndroidNavigationBar";
|
||||||
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
|
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
|
||||||
|
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import {
|
||||||
|
PlaybackType,
|
||||||
|
usePlaySettings,
|
||||||
|
} from "@/providers/PlaySettingsProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||||
|
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
||||||
|
import { secondsToTicks } from "@/utils/secondsToTicks";
|
||||||
|
import { Api } from "@jellyfin/sdk";
|
||||||
|
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import * as Haptics from "expo-haptics";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useFocusEffect } from "expo-router";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { debounce } from "lodash";
|
||||||
|
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||||
|
import { Dimensions, Pressable, StatusBar, View } from "react-native";
|
||||||
|
import { useSharedValue } from "react-native-reanimated";
|
||||||
|
import Video, { OnProgressData, VideoRef } from "react-native-video";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const { playSettings, playUrl, playSessionId } = usePlaySettings();
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const [settings] = useSettings();
|
||||||
|
const videoRef = useRef<VideoRef | null>(null);
|
||||||
|
const poster = usePoster(playSettings, api);
|
||||||
|
const videoSource = useVideoSource(playSettings, api, poster, playUrl);
|
||||||
|
const firstTime = useRef(true);
|
||||||
|
|
||||||
|
const screenDimensions = Dimensions.get("screen");
|
||||||
|
|
||||||
|
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
||||||
|
const [showControls, setShowControls] = useState(true);
|
||||||
|
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [isBuffering, setIsBuffering] = useState(true);
|
||||||
|
|
||||||
|
const progress = useSharedValue(0);
|
||||||
|
const isSeeking = useSharedValue(false);
|
||||||
|
const cacheProgress = useSharedValue(0);
|
||||||
|
|
||||||
|
if (!playSettings || !playUrl || !api || !videoSource || !playSettings.item)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
const togglePlay = useCallback(
|
||||||
|
async (ticks: number) => {
|
||||||
|
console.log("togglePlay");
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
if (isPlaying) {
|
||||||
|
videoRef.current?.pause();
|
||||||
|
await getPlaystateApi(api).onPlaybackProgress({
|
||||||
|
itemId: playSettings.item?.Id!,
|
||||||
|
audioStreamIndex: playSettings.audioIndex
|
||||||
|
? playSettings.audioIndex
|
||||||
|
: undefined,
|
||||||
|
subtitleStreamIndex: playSettings.subtitleIndex
|
||||||
|
? playSettings.subtitleIndex
|
||||||
|
: undefined,
|
||||||
|
mediaSourceId: playSettings.mediaSource?.Id!,
|
||||||
|
positionTicks: Math.floor(ticks),
|
||||||
|
isPaused: true,
|
||||||
|
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||||
|
playSessionId: playSessionId ? playSessionId : undefined,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
videoRef.current?.resume();
|
||||||
|
await getPlaystateApi(api).onPlaybackProgress({
|
||||||
|
itemId: playSettings.item?.Id!,
|
||||||
|
audioStreamIndex: playSettings.audioIndex
|
||||||
|
? playSettings.audioIndex
|
||||||
|
: undefined,
|
||||||
|
subtitleStreamIndex: playSettings.subtitleIndex
|
||||||
|
? playSettings.subtitleIndex
|
||||||
|
: undefined,
|
||||||
|
mediaSourceId: playSettings.mediaSource?.Id!,
|
||||||
|
positionTicks: Math.floor(ticks),
|
||||||
|
isPaused: false,
|
||||||
|
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||||
|
playSessionId: playSessionId ? playSessionId : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isPlaying, api, playSettings?.item?.Id, videoRef, settings]
|
||||||
|
);
|
||||||
|
|
||||||
|
const play = useCallback(() => {
|
||||||
|
console.log("play");
|
||||||
|
videoRef.current?.resume();
|
||||||
|
reportPlaybackStart();
|
||||||
|
}, [videoRef]);
|
||||||
|
|
||||||
|
const pause = useCallback(() => {
|
||||||
|
console.log("play");
|
||||||
|
videoRef.current?.pause();
|
||||||
|
}, [videoRef]);
|
||||||
|
|
||||||
|
const stop = useCallback(() => {
|
||||||
|
console.log("stop");
|
||||||
|
setIsPlaybackStopped(true);
|
||||||
|
videoRef.current?.pause();
|
||||||
|
reportPlaybackStopped();
|
||||||
|
}, [videoRef]);
|
||||||
|
|
||||||
|
const reportPlaybackStopped = async () => {
|
||||||
|
await getPlaystateApi(api).onPlaybackStopped({
|
||||||
|
itemId: playSettings?.item?.Id!,
|
||||||
|
mediaSourceId: playSettings.mediaSource?.Id!,
|
||||||
|
positionTicks: Math.floor(progress.value),
|
||||||
|
playSessionId: playSessionId ? playSessionId : undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const reportPlaybackStart = async () => {
|
||||||
|
await getPlaystateApi(api).onPlaybackStart({
|
||||||
|
itemId: playSettings?.item?.Id!,
|
||||||
|
audioStreamIndex: playSettings.audioIndex
|
||||||
|
? playSettings.audioIndex
|
||||||
|
: undefined,
|
||||||
|
subtitleStreamIndex: playSettings.subtitleIndex
|
||||||
|
? playSettings.subtitleIndex
|
||||||
|
: undefined,
|
||||||
|
mediaSourceId: playSettings.mediaSource?.Id!,
|
||||||
|
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||||
|
playSessionId: playSessionId ? playSessionId : undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onProgress = useCallback(
|
||||||
|
async (data: OnProgressData) => {
|
||||||
|
if (isSeeking.value === true) return;
|
||||||
|
if (isPlaybackStopped === true) return;
|
||||||
|
|
||||||
|
const ticks = data.currentTime * 10000000;
|
||||||
|
|
||||||
|
progress.value = secondsToTicks(data.currentTime);
|
||||||
|
cacheProgress.value = secondsToTicks(data.playableDuration);
|
||||||
|
setIsBuffering(data.playableDuration === 0);
|
||||||
|
|
||||||
|
if (!playSettings?.item?.Id || data.currentTime === 0) return;
|
||||||
|
|
||||||
|
await getPlaystateApi(api).onPlaybackProgress({
|
||||||
|
itemId: playSettings.item.Id,
|
||||||
|
audioStreamIndex: playSettings.audioIndex
|
||||||
|
? playSettings.audioIndex
|
||||||
|
: undefined,
|
||||||
|
subtitleStreamIndex: playSettings.subtitleIndex
|
||||||
|
? playSettings.subtitleIndex
|
||||||
|
: undefined,
|
||||||
|
mediaSourceId: playSettings.mediaSource?.Id!,
|
||||||
|
positionTicks: Math.round(ticks),
|
||||||
|
isPaused: !isPlaying,
|
||||||
|
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||||
|
playSessionId: playSessionId ? playSessionId : undefined,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[playSettings?.item.Id, isPlaying, api, isPlaybackStopped]
|
||||||
|
);
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
play();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
stop();
|
||||||
|
};
|
||||||
|
}, [play, stop])
|
||||||
|
);
|
||||||
|
|
||||||
|
const { orientation } = useOrientation();
|
||||||
|
useOrientationSettings();
|
||||||
|
useAndroidNavigationBar();
|
||||||
|
|
||||||
|
useWebSocket({
|
||||||
|
isPlaying: isPlaying,
|
||||||
|
pauseVideo: pause,
|
||||||
|
playVideo: play,
|
||||||
|
stopPlayback: stop,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: screenDimensions.width,
|
||||||
|
height: screenDimensions.height,
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
className="flex flex-col items-center justify-center"
|
||||||
|
>
|
||||||
|
<StatusBar hidden />
|
||||||
|
|
||||||
|
<View className="h-screen w-screen top-0 left-0 flex flex-col items-center justify-center p-4 absolute z-0">
|
||||||
|
<Image
|
||||||
|
source={poster}
|
||||||
|
style={{ width: "100%", height: "100%", resizeMode: "contain" }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
onPress={() => {
|
||||||
|
setShowControls(!showControls);
|
||||||
|
}}
|
||||||
|
className="absolute z-0 h-full w-full opacity-0"
|
||||||
|
>
|
||||||
|
<Video
|
||||||
|
ref={videoRef}
|
||||||
|
source={videoSource}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
resizeMode={ignoreSafeAreas ? "cover" : "contain"}
|
||||||
|
onProgress={onProgress}
|
||||||
|
onError={() => {}}
|
||||||
|
onLoad={() => {
|
||||||
|
if (firstTime.current === true) {
|
||||||
|
play();
|
||||||
|
firstTime.current = false;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
progressUpdateInterval={500}
|
||||||
|
playWhenInactive={true}
|
||||||
|
allowsExternalPlayback={true}
|
||||||
|
playInBackground={true}
|
||||||
|
pictureInPicture={true}
|
||||||
|
showNotificationControls={true}
|
||||||
|
ignoreSilentSwitch="ignore"
|
||||||
|
fullscreen={false}
|
||||||
|
onPlaybackStateChanged={(state) => {
|
||||||
|
setIsPlaying(state.isPlaying);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Controls
|
||||||
|
item={playSettings.item}
|
||||||
|
videoRef={videoRef}
|
||||||
|
togglePlay={togglePlay}
|
||||||
|
isPlaying={isPlaying}
|
||||||
|
isSeeking={isSeeking}
|
||||||
|
progress={progress}
|
||||||
|
cacheProgress={cacheProgress}
|
||||||
|
isBuffering={isBuffering}
|
||||||
|
showControls={showControls}
|
||||||
|
setShowControls={setShowControls}
|
||||||
|
setIgnoreSafeAreas={setIgnoreSafeAreas}
|
||||||
|
ignoreSafeAreas={ignoreSafeAreas}
|
||||||
|
enableTrickplay={false}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePoster(
|
||||||
|
playSettings: PlaybackType | null,
|
||||||
|
api: Api | null
|
||||||
|
): string | undefined {
|
||||||
|
const poster = useMemo(() => {
|
||||||
|
if (!playSettings?.item || !api) return undefined;
|
||||||
|
return playSettings.item.Type === "Audio"
|
||||||
|
? `${api.basePath}/Items/${playSettings.item.AlbumId}/Images/Primary?tag=${playSettings.item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
|
||||||
|
: getBackdropUrl({
|
||||||
|
api,
|
||||||
|
item: playSettings.item,
|
||||||
|
quality: 70,
|
||||||
|
width: 200,
|
||||||
|
});
|
||||||
|
}, [playSettings?.item, api]);
|
||||||
|
|
||||||
|
return poster ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useVideoSource(
|
||||||
|
playSettings: PlaybackType | null,
|
||||||
|
api: Api | null,
|
||||||
|
poster: string | undefined,
|
||||||
|
playUrl?: string | null
|
||||||
|
) {
|
||||||
|
const videoSource = useMemo(() => {
|
||||||
|
if (!playSettings || !api || !playUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startPosition = playSettings.item?.UserData?.PlaybackPositionTicks
|
||||||
|
? Math.round(playSettings.item.UserData.PlaybackPositionTicks / 10000)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
uri: playUrl,
|
||||||
|
isNetwork: true,
|
||||||
|
startPosition,
|
||||||
|
headers: getAuthHeaders(api),
|
||||||
|
metadata: {
|
||||||
|
artist: playSettings.item?.AlbumArtist ?? undefined,
|
||||||
|
title: playSettings.item?.Name || "Unknown",
|
||||||
|
description: playSettings.item?.Overview ?? undefined,
|
||||||
|
imageUri: poster,
|
||||||
|
subtitle: playSettings.item?.Album ?? undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, [playSettings, api, poster]);
|
||||||
|
|
||||||
|
return videoSource;
|
||||||
|
}
|
||||||
180
app/(auth)/play-offline-video.tsx
Normal file
180
app/(auth)/play-offline-video.tsx
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import { Controls } from "@/components/video-player/Controls";
|
||||||
|
import { useAndroidNavigationBar } from "@/hooks/useAndroidNavigationBar";
|
||||||
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
|
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import {
|
||||||
|
PlaybackType,
|
||||||
|
usePlaySettings,
|
||||||
|
} from "@/providers/PlaySettingsProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||||
|
import orientationToOrientationLock from "@/utils/OrientationLockConverter";
|
||||||
|
import { secondsToTicks } from "@/utils/secondsToTicks";
|
||||||
|
import { Api } from "@jellyfin/sdk";
|
||||||
|
import * as Haptics from "expo-haptics";
|
||||||
|
import * as NavigationBar from "expo-navigation-bar";
|
||||||
|
import { useFocusEffect } from "expo-router";
|
||||||
|
import * as ScreenOrientation from "expo-screen-orientation";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { Dimensions, Platform, Pressable, StatusBar, View } from "react-native";
|
||||||
|
import { useSharedValue } from "react-native-reanimated";
|
||||||
|
import Video, { OnProgressData, VideoRef } from "react-native-video";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const { playSettings, playUrl } = usePlaySettings();
|
||||||
|
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const videoRef = useRef<VideoRef | null>(null);
|
||||||
|
const videoSource = useVideoSource(playSettings, api, playUrl);
|
||||||
|
const firstTime = useRef(true);
|
||||||
|
|
||||||
|
const screenDimensions = Dimensions.get("screen");
|
||||||
|
|
||||||
|
const [showControls, setShowControls] = useState(true);
|
||||||
|
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [isBuffering, setIsBuffering] = useState(true);
|
||||||
|
|
||||||
|
const progress = useSharedValue(0);
|
||||||
|
const isSeeking = useSharedValue(false);
|
||||||
|
const cacheProgress = useSharedValue(0);
|
||||||
|
|
||||||
|
const togglePlay = useCallback(async () => {
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
if (isPlaying) {
|
||||||
|
videoRef.current?.pause();
|
||||||
|
} else {
|
||||||
|
videoRef.current?.resume();
|
||||||
|
}
|
||||||
|
}, [isPlaying]);
|
||||||
|
|
||||||
|
const play = useCallback(() => {
|
||||||
|
setIsPlaying(true);
|
||||||
|
videoRef.current?.resume();
|
||||||
|
}, [videoRef]);
|
||||||
|
|
||||||
|
const stop = useCallback(() => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
videoRef.current?.pause();
|
||||||
|
}, [videoRef]);
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
play();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
stop();
|
||||||
|
};
|
||||||
|
}, [play, stop])
|
||||||
|
);
|
||||||
|
|
||||||
|
const { orientation } = useOrientation();
|
||||||
|
useOrientationSettings();
|
||||||
|
useAndroidNavigationBar();
|
||||||
|
|
||||||
|
const onProgress = useCallback(async (data: OnProgressData) => {
|
||||||
|
if (isSeeking.value === true) return;
|
||||||
|
progress.value = secondsToTicks(data.currentTime);
|
||||||
|
cacheProgress.value = secondsToTicks(data.playableDuration);
|
||||||
|
setIsBuffering(data.playableDuration === 0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!playSettings || !playUrl || !api || !videoSource || !playSettings.item)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: screenDimensions.width,
|
||||||
|
height: screenDimensions.height,
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
className="flex flex-col items-center justify-center"
|
||||||
|
>
|
||||||
|
<StatusBar hidden />
|
||||||
|
<Pressable
|
||||||
|
onPress={() => {
|
||||||
|
setShowControls(!showControls);
|
||||||
|
}}
|
||||||
|
className="absolute z-0 h-full w-full"
|
||||||
|
>
|
||||||
|
<Video
|
||||||
|
ref={videoRef}
|
||||||
|
source={videoSource}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
resizeMode={ignoreSafeAreas ? "cover" : "contain"}
|
||||||
|
onProgress={onProgress}
|
||||||
|
onError={() => {}}
|
||||||
|
onLoad={() => {
|
||||||
|
if (firstTime.current === true) {
|
||||||
|
play();
|
||||||
|
firstTime.current = false;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
playWhenInactive={true}
|
||||||
|
allowsExternalPlayback={true}
|
||||||
|
playInBackground={true}
|
||||||
|
pictureInPicture={true}
|
||||||
|
showNotificationControls={true}
|
||||||
|
ignoreSilentSwitch="ignore"
|
||||||
|
fullscreen={false}
|
||||||
|
onPlaybackStateChanged={(state) => {
|
||||||
|
if (isSeeking.value === false) setIsPlaying(state.isPlaying);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Controls
|
||||||
|
item={playSettings.item}
|
||||||
|
videoRef={videoRef}
|
||||||
|
togglePlay={togglePlay}
|
||||||
|
isPlaying={isPlaying}
|
||||||
|
isSeeking={isSeeking}
|
||||||
|
progress={progress}
|
||||||
|
cacheProgress={cacheProgress}
|
||||||
|
isBuffering={isBuffering}
|
||||||
|
showControls={showControls}
|
||||||
|
setShowControls={setShowControls}
|
||||||
|
setIgnoreSafeAreas={setIgnoreSafeAreas}
|
||||||
|
ignoreSafeAreas={ignoreSafeAreas}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useVideoSource(
|
||||||
|
playSettings: PlaybackType | null,
|
||||||
|
api: Api | null,
|
||||||
|
playUrl?: string | null
|
||||||
|
) {
|
||||||
|
const videoSource = useMemo(() => {
|
||||||
|
if (!playSettings || !api || !playUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startPosition = 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
uri: playUrl,
|
||||||
|
isNetwork: false,
|
||||||
|
startPosition,
|
||||||
|
metadata: {
|
||||||
|
artist: playSettings.item?.AlbumArtist ?? undefined,
|
||||||
|
title: playSettings.item?.Name || "Unknown",
|
||||||
|
description: playSettings.item?.Overview ?? undefined,
|
||||||
|
subtitle: playSettings.item?.Album ?? undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, [playSettings, api]);
|
||||||
|
|
||||||
|
return videoSource;
|
||||||
|
}
|
||||||
332
app/(auth)/play-video.tsx
Normal file
332
app/(auth)/play-video.tsx
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
import { Controls } from "@/components/video-player/Controls";
|
||||||
|
import { useAndroidNavigationBar } from "@/hooks/useAndroidNavigationBar";
|
||||||
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
|
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
|
||||||
|
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import {
|
||||||
|
PlaybackType,
|
||||||
|
usePlaySettings,
|
||||||
|
} from "@/providers/PlaySettingsProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||||
|
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
||||||
|
import { secondsToTicks } from "@/utils/secondsToTicks";
|
||||||
|
import { Api } from "@jellyfin/sdk";
|
||||||
|
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import * as Haptics from "expo-haptics";
|
||||||
|
import { useFocusEffect } from "expo-router";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||||
|
import { Dimensions, Pressable, StatusBar, View } from "react-native";
|
||||||
|
import { useSharedValue } from "react-native-reanimated";
|
||||||
|
import Video, {
|
||||||
|
OnProgressData,
|
||||||
|
VideoRef,
|
||||||
|
SelectedTrack,
|
||||||
|
SelectedTrackType,
|
||||||
|
} from "react-native-video";
|
||||||
|
import { WithDefault } from "react-native/Libraries/Types/CodegenTypes";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const { playSettings, playUrl, playSessionId } = usePlaySettings();
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const [settings] = useSettings();
|
||||||
|
const videoRef = useRef<VideoRef | null>(null);
|
||||||
|
const poster = usePoster(playSettings, api);
|
||||||
|
const videoSource = useVideoSource(playSettings, api, poster, playUrl);
|
||||||
|
const firstTime = useRef(true);
|
||||||
|
|
||||||
|
const screenDimensions = Dimensions.get("screen");
|
||||||
|
|
||||||
|
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
||||||
|
const [showControls, setShowControls] = useState(true);
|
||||||
|
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [isBuffering, setIsBuffering] = useState(true);
|
||||||
|
|
||||||
|
const progress = useSharedValue(0);
|
||||||
|
const isSeeking = useSharedValue(false);
|
||||||
|
const cacheProgress = useSharedValue(0);
|
||||||
|
|
||||||
|
if (!playSettings || !playUrl || !api || !videoSource || !playSettings.item)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
const togglePlay = useCallback(
|
||||||
|
async (ticks: number) => {
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
if (isPlaying) {
|
||||||
|
videoRef.current?.pause();
|
||||||
|
await getPlaystateApi(api).onPlaybackProgress({
|
||||||
|
itemId: playSettings.item?.Id!,
|
||||||
|
audioStreamIndex: playSettings.audioIndex
|
||||||
|
? playSettings.audioIndex
|
||||||
|
: undefined,
|
||||||
|
subtitleStreamIndex: playSettings.subtitleIndex
|
||||||
|
? playSettings.subtitleIndex
|
||||||
|
: undefined,
|
||||||
|
mediaSourceId: playSettings.mediaSource?.Id!,
|
||||||
|
positionTicks: Math.floor(ticks),
|
||||||
|
isPaused: true,
|
||||||
|
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||||
|
playSessionId: playSessionId ? playSessionId : undefined,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
videoRef.current?.resume();
|
||||||
|
await getPlaystateApi(api).onPlaybackProgress({
|
||||||
|
itemId: playSettings.item?.Id!,
|
||||||
|
audioStreamIndex: playSettings.audioIndex
|
||||||
|
? playSettings.audioIndex
|
||||||
|
: undefined,
|
||||||
|
subtitleStreamIndex: playSettings.subtitleIndex
|
||||||
|
? playSettings.subtitleIndex
|
||||||
|
: undefined,
|
||||||
|
mediaSourceId: playSettings.mediaSource?.Id!,
|
||||||
|
positionTicks: Math.floor(ticks),
|
||||||
|
isPaused: false,
|
||||||
|
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||||
|
playSessionId: playSessionId ? playSessionId : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isPlaying, api, playSettings?.item?.Id, videoRef, settings]
|
||||||
|
);
|
||||||
|
|
||||||
|
const play = useCallback(() => {
|
||||||
|
videoRef.current?.resume();
|
||||||
|
reportPlaybackStart();
|
||||||
|
}, [videoRef]);
|
||||||
|
|
||||||
|
const pause = useCallback(() => {
|
||||||
|
videoRef.current?.pause();
|
||||||
|
}, [videoRef]);
|
||||||
|
|
||||||
|
const stop = useCallback(() => {
|
||||||
|
setIsPlaybackStopped(true);
|
||||||
|
videoRef.current?.pause();
|
||||||
|
reportPlaybackStopped();
|
||||||
|
}, [videoRef]);
|
||||||
|
|
||||||
|
const reportPlaybackStopped = async () => {
|
||||||
|
await getPlaystateApi(api).onPlaybackStopped({
|
||||||
|
itemId: playSettings?.item?.Id!,
|
||||||
|
mediaSourceId: playSettings.mediaSource?.Id!,
|
||||||
|
positionTicks: Math.floor(progress.value),
|
||||||
|
playSessionId: playSessionId ? playSessionId : undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const reportPlaybackStart = async () => {
|
||||||
|
await getPlaystateApi(api).onPlaybackStart({
|
||||||
|
itemId: playSettings?.item?.Id!,
|
||||||
|
audioStreamIndex: playSettings.audioIndex
|
||||||
|
? playSettings.audioIndex
|
||||||
|
: undefined,
|
||||||
|
subtitleStreamIndex: playSettings.subtitleIndex
|
||||||
|
? playSettings.subtitleIndex
|
||||||
|
: undefined,
|
||||||
|
mediaSourceId: playSettings.mediaSource?.Id!,
|
||||||
|
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||||
|
playSessionId: playSessionId ? playSessionId : undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onProgress = useCallback(
|
||||||
|
async (data: OnProgressData) => {
|
||||||
|
if (isSeeking.value === true) return;
|
||||||
|
if (isPlaybackStopped === true) return;
|
||||||
|
|
||||||
|
const ticks = data.currentTime * 10000000;
|
||||||
|
|
||||||
|
progress.value = secondsToTicks(data.currentTime);
|
||||||
|
cacheProgress.value = secondsToTicks(data.playableDuration);
|
||||||
|
setIsBuffering(data.playableDuration === 0);
|
||||||
|
|
||||||
|
if (!playSettings?.item?.Id || data.currentTime === 0) return;
|
||||||
|
|
||||||
|
await getPlaystateApi(api).onPlaybackProgress({
|
||||||
|
itemId: playSettings.item.Id,
|
||||||
|
audioStreamIndex: playSettings.audioIndex
|
||||||
|
? playSettings.audioIndex
|
||||||
|
: undefined,
|
||||||
|
subtitleStreamIndex: playSettings.subtitleIndex
|
||||||
|
? playSettings.subtitleIndex
|
||||||
|
: undefined,
|
||||||
|
mediaSourceId: playSettings.mediaSource?.Id!,
|
||||||
|
positionTicks: Math.round(ticks),
|
||||||
|
isPaused: !isPlaying,
|
||||||
|
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||||
|
playSessionId: playSessionId ? playSessionId : undefined,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[playSettings?.item.Id, isPlaying, api, isPlaybackStopped]
|
||||||
|
);
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
play();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
stop();
|
||||||
|
};
|
||||||
|
}, [play, stop])
|
||||||
|
);
|
||||||
|
|
||||||
|
const { orientation } = useOrientation();
|
||||||
|
useOrientationSettings();
|
||||||
|
useAndroidNavigationBar();
|
||||||
|
|
||||||
|
useWebSocket({
|
||||||
|
isPlaying: isPlaying,
|
||||||
|
pauseVideo: pause,
|
||||||
|
playVideo: play,
|
||||||
|
stopPlayback: stop,
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedSubtitleTrack = useMemo(() => {
|
||||||
|
const a = playSettings?.mediaSource?.MediaStreams?.find(
|
||||||
|
(s) => s.Index === playSettings.subtitleIndex
|
||||||
|
);
|
||||||
|
console.log(a);
|
||||||
|
return a;
|
||||||
|
}, [playSettings]);
|
||||||
|
|
||||||
|
const [hlsSubTracks, setHlsSubTracks] = useState<
|
||||||
|
{
|
||||||
|
index: number;
|
||||||
|
language?: string | undefined;
|
||||||
|
selected?: boolean | undefined;
|
||||||
|
title?: string | undefined;
|
||||||
|
type: any;
|
||||||
|
}[]
|
||||||
|
>([]);
|
||||||
|
|
||||||
|
const selectedTextTrack = useMemo(() => {
|
||||||
|
for (let st of hlsSubTracks) {
|
||||||
|
if (st.title === selectedSubtitleTrack?.DisplayTitle) {
|
||||||
|
return {
|
||||||
|
type: SelectedTrackType.TITLE,
|
||||||
|
value: selectedSubtitleTrack?.DisplayTitle ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, [hlsSubTracks]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: screenDimensions.width,
|
||||||
|
height: screenDimensions.height,
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
className="flex flex-col items-center justify-center"
|
||||||
|
>
|
||||||
|
<StatusBar hidden />
|
||||||
|
<Pressable
|
||||||
|
onPress={() => {
|
||||||
|
setShowControls(!showControls);
|
||||||
|
}}
|
||||||
|
className="absolute z-0 h-full w-full"
|
||||||
|
>
|
||||||
|
<Video
|
||||||
|
ref={videoRef}
|
||||||
|
source={videoSource}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
resizeMode={ignoreSafeAreas ? "cover" : "contain"}
|
||||||
|
onProgress={onProgress}
|
||||||
|
onError={() => {}}
|
||||||
|
onLoad={() => {
|
||||||
|
if (firstTime.current === true) {
|
||||||
|
play();
|
||||||
|
firstTime.current = false;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
progressUpdateInterval={500}
|
||||||
|
playWhenInactive={true}
|
||||||
|
allowsExternalPlayback={true}
|
||||||
|
playInBackground={true}
|
||||||
|
pictureInPicture={true}
|
||||||
|
showNotificationControls={true}
|
||||||
|
ignoreSilentSwitch="ignore"
|
||||||
|
fullscreen={false}
|
||||||
|
onPlaybackStateChanged={(state) => {
|
||||||
|
if (isSeeking.value === false) setIsPlaying(state.isPlaying);
|
||||||
|
}}
|
||||||
|
onTextTracks={(data) => {
|
||||||
|
console.log("onTextTracks ~", data);
|
||||||
|
setHlsSubTracks(data.textTracks as any);
|
||||||
|
}}
|
||||||
|
selectedTextTrack={selectedTextTrack}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Controls
|
||||||
|
item={playSettings.item}
|
||||||
|
videoRef={videoRef}
|
||||||
|
togglePlay={togglePlay}
|
||||||
|
isPlaying={isPlaying}
|
||||||
|
isSeeking={isSeeking}
|
||||||
|
progress={progress}
|
||||||
|
cacheProgress={cacheProgress}
|
||||||
|
isBuffering={isBuffering}
|
||||||
|
showControls={showControls}
|
||||||
|
setShowControls={setShowControls}
|
||||||
|
setIgnoreSafeAreas={setIgnoreSafeAreas}
|
||||||
|
ignoreSafeAreas={ignoreSafeAreas}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePoster(
|
||||||
|
playSettings: PlaybackType | null,
|
||||||
|
api: Api | null
|
||||||
|
): string | undefined {
|
||||||
|
const poster = useMemo(() => {
|
||||||
|
if (!playSettings?.item || !api) return undefined;
|
||||||
|
return playSettings.item.Type === "Audio"
|
||||||
|
? `${api.basePath}/Items/${playSettings.item.AlbumId}/Images/Primary?tag=${playSettings.item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
|
||||||
|
: getBackdropUrl({
|
||||||
|
api,
|
||||||
|
item: playSettings.item,
|
||||||
|
quality: 70,
|
||||||
|
width: 200,
|
||||||
|
});
|
||||||
|
}, [playSettings?.item, api]);
|
||||||
|
|
||||||
|
return poster ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useVideoSource(
|
||||||
|
playSettings: PlaybackType | null,
|
||||||
|
api: Api | null,
|
||||||
|
poster: string | undefined,
|
||||||
|
playUrl?: string | null
|
||||||
|
) {
|
||||||
|
const videoSource = useMemo(() => {
|
||||||
|
if (!playSettings || !api || !playUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startPosition = playSettings.item?.UserData?.PlaybackPositionTicks
|
||||||
|
? Math.round(playSettings.item.UserData.PlaybackPositionTicks / 10000)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
uri: playUrl,
|
||||||
|
isNetwork: true,
|
||||||
|
startPosition,
|
||||||
|
headers: getAuthHeaders(api),
|
||||||
|
metadata: {
|
||||||
|
artist: playSettings.item?.AlbumArtist ?? undefined,
|
||||||
|
title: playSettings.item?.Name || "Unknown",
|
||||||
|
description: playSettings.item?.Overview ?? undefined,
|
||||||
|
imageUri: poster,
|
||||||
|
subtitle: playSettings.item?.Album ?? undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, [playSettings, api, poster]);
|
||||||
|
|
||||||
|
return videoSource;
|
||||||
|
}
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { Stack } from "expo-router";
|
|
||||||
import React from "react";
|
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
|
||||||
|
|
||||||
export default function Layout() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<SystemBars hidden />
|
|
||||||
<Stack>
|
|
||||||
<Stack.Screen
|
|
||||||
name="direct-player"
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
autoHideHomeIndicator: true,
|
|
||||||
title: "",
|
|
||||||
animation: "fade",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name="transcoding-player"
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
autoHideHomeIndicator: true,
|
|
||||||
title: "",
|
|
||||||
animation: "fade",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name="music-player"
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
autoHideHomeIndicator: true,
|
|
||||||
title: "",
|
|
||||||
animation: "fade",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,525 +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 } from "react";
|
|
||||||
import { Alert, BackHandler, View } from "react-native";
|
|
||||||
import { useSharedValue } from "react-native-reanimated";
|
|
||||||
|
|
||||||
export default function page() {
|
|
||||||
const videoRef = useRef<VlcPlayerViewRef>(null);
|
|
||||||
const user = useAtomValue(userAtom);
|
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
|
|
||||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
|
||||||
const [showControls, _setShowControls] = useState(true);
|
|
||||||
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
|
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
|
||||||
const [isBuffering, setIsBuffering] = useState(true);
|
|
||||||
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
|
||||||
|
|
||||||
const progress = useSharedValue(0);
|
|
||||||
const isSeeking = useSharedValue(false);
|
|
||||||
const cacheProgress = useSharedValue(0);
|
|
||||||
|
|
||||||
const { 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 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,
|
|
||||||
audioIndex,
|
|
||||||
subtitleIndex,
|
|
||||||
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 (ms: number) => {
|
|
||||||
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(ms),
|
|
||||||
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(ms),
|
|
||||||
isPaused: false,
|
|
||||||
playMethod: stream?.url.includes("m3u8")
|
|
||||||
? "Transcode"
|
|
||||||
: "DirectStream",
|
|
||||||
playSessionId: stream.sessionId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[
|
|
||||||
isPlaying,
|
|
||||||
api,
|
|
||||||
item,
|
|
||||||
stream,
|
|
||||||
videoRef,
|
|
||||||
audioIndex,
|
|
||||||
subtitleIndex,
|
|
||||||
mediaSourceId,
|
|
||||||
offline,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
const play = useCallback(() => {
|
|
||||||
videoRef.current?.play();
|
|
||||||
reportPlaybackStart();
|
|
||||||
}, [videoRef]);
|
|
||||||
|
|
||||||
const pause = useCallback(() => {
|
|
||||||
videoRef.current?.pause();
|
|
||||||
}, [videoRef]);
|
|
||||||
|
|
||||||
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]);
|
|
||||||
|
|
||||||
const reportPlaybackStart = useCallback(async () => {
|
|
||||||
if (offline) return;
|
|
||||||
|
|
||||||
if (!stream) return;
|
|
||||||
await getPlaystateApi(api!).onPlaybackStart({
|
|
||||||
itemId: item?.Id!,
|
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
playMethod: stream.url?.includes("m3u8") ? "Transcode" : "DirectStream",
|
|
||||||
playSessionId: stream?.sessionId ? stream?.sessionId : undefined,
|
|
||||||
});
|
|
||||||
}, [api, item, mediaSourceId, stream]);
|
|
||||||
|
|
||||||
const onProgress = useCallback(
|
|
||||||
async (data: ProgressUpdatePayload) => {
|
|
||||||
if (isSeeking.value === true) return;
|
|
||||||
if (isPlaybackStopped === true) return;
|
|
||||||
|
|
||||||
const { currentTime } = data.nativeEvent;
|
|
||||||
|
|
||||||
if (isBuffering) {
|
|
||||||
setIsBuffering(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
progress.value = currentTime;
|
|
||||||
|
|
||||||
if (offline) return;
|
|
||||||
|
|
||||||
const currentTimeInTicks = msToTicks(currentTime);
|
|
||||||
|
|
||||||
if (!item?.Id || !stream) return;
|
|
||||||
|
|
||||||
await getPlaystateApi(api!).onPlaybackProgress({
|
|
||||||
itemId: item.Id,
|
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
positionTicks: Math.floor(currentTimeInTicks),
|
|
||||||
isPaused: !isPlaying,
|
|
||||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
|
||||||
playSessionId: stream.sessionId,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[item?.Id, isPlaying, api, isPlaybackStopped]
|
|
||||||
);
|
|
||||||
|
|
||||||
useOrientation();
|
|
||||||
useOrientationSettings();
|
|
||||||
|
|
||||||
useWebSocket({
|
|
||||||
isPlaying: isPlaying,
|
|
||||||
pauseVideo: pause,
|
|
||||||
playVideo: play,
|
|
||||||
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]);
|
|
||||||
|
|
||||||
const backAction = () => {
|
|
||||||
videoRef.current?.stop();
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
useFocusEffect(
|
|
||||||
React.useCallback(() => {
|
|
||||||
const onBackPress = () => {
|
|
||||||
return backAction();
|
|
||||||
};
|
|
||||||
|
|
||||||
BackHandler.addEventListener("hardwareBackPress", onBackPress);
|
|
||||||
|
|
||||||
return async () => {
|
|
||||||
videoRef.current?.stop();
|
|
||||||
BackHandler.removeEventListener("hardwareBackPress", onBackPress);
|
|
||||||
};
|
|
||||||
}, [])
|
|
||||||
);
|
|
||||||
|
|
||||||
// Preselection of audio and subtitle tracks.
|
|
||||||
|
|
||||||
let initOptions = ["--sub-text-scale=60"];
|
|
||||||
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 ? 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
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
flexDirection: "column",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
opacity: isBuffering ? 1 : 0,
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
}}
|
|
||||||
pointerEvents="none"
|
|
||||||
>
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
</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={false}
|
|
||||||
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,590 +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 { BackHandler, View } from "react-native";
|
|
||||||
import { useSharedValue } from "react-native-reanimated";
|
|
||||||
import Video, {
|
|
||||||
OnProgressData,
|
|
||||||
SelectedTrack,
|
|
||||||
SelectedTrackType,
|
|
||||||
VideoRef,
|
|
||||||
} from "react-native-video";
|
|
||||||
|
|
||||||
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 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,
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: stream,
|
|
||||||
isLoading: isLoadingStreamUrl,
|
|
||||||
isError: isErrorStreamUrl,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: [
|
|
||||||
"stream-url",
|
|
||||||
itemId,
|
|
||||||
audioIndex,
|
|
||||||
subtitleIndex,
|
|
||||||
bitrateValue,
|
|
||||||
mediaSourceId,
|
|
||||||
],
|
|
||||||
|
|
||||||
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 (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,
|
|
||||||
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);
|
|
||||||
|
|
||||||
// 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,
|
|
||||||
pauseVideo: pause,
|
|
||||||
playVideo: play,
|
|
||||||
stopPlayback: stop,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [selectedTextTrack, setSelectedTextTrack] = useState<
|
|
||||||
SelectedTrack | undefined
|
|
||||||
>();
|
|
||||||
|
|
||||||
const [embededTextTracks, setEmbededTextTracks] = useState<
|
|
||||||
{
|
|
||||||
index: number;
|
|
||||||
language?: string | undefined;
|
|
||||||
selected?: boolean | undefined;
|
|
||||||
title?: string | undefined;
|
|
||||||
type: any;
|
|
||||||
}[]
|
|
||||||
>([]);
|
|
||||||
|
|
||||||
const [audioTracks, setAudioTracks] = useState<TrackInfo[]>([]);
|
|
||||||
const [selectedAudioTrack, setSelectedAudioTrack] = useState<
|
|
||||||
SelectedTrack | undefined
|
|
||||||
>(undefined);
|
|
||||||
|
|
||||||
// Set intial Subtitle Track.
|
|
||||||
// We will only select external tracks if they are are text based. Else it should be burned in already.
|
|
||||||
const textSubs =
|
|
||||||
stream?.mediaSource.MediaStreams?.filter(
|
|
||||||
(sub) => sub.Type === "Subtitle" && sub.IsTextSubtitleStream
|
|
||||||
) || [];
|
|
||||||
|
|
||||||
const uniqueTextSubs = Array.from(
|
|
||||||
new Set(textSubs.map((sub) => sub.DisplayTitle))
|
|
||||||
).map((title) => textSubs.find((sub) => sub.DisplayTitle === title));
|
|
||||||
const chosenSubtitleTrack = textSubs.find(
|
|
||||||
(sub) => sub.Index === subtitleIndex
|
|
||||||
);
|
|
||||||
useEffect(() => {
|
|
||||||
if (chosenSubtitleTrack && selectedTextTrack === undefined) {
|
|
||||||
console.log("Setting selected text track", chosenSubtitleTrack);
|
|
||||||
setSelectedTextTrack({
|
|
||||||
type: SelectedTrackType.INDEX,
|
|
||||||
value: uniqueTextSubs.indexOf(chosenSubtitleTrack),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [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,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const backAction = () => {
|
|
||||||
videoRef.current?.pause();
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
useFocusEffect(
|
|
||||||
React.useCallback(() => {
|
|
||||||
const onBackPress = () => {
|
|
||||||
return backAction();
|
|
||||||
};
|
|
||||||
|
|
||||||
BackHandler.addEventListener("hardwareBackPress", onBackPress);
|
|
||||||
play();
|
|
||||||
|
|
||||||
return async () => {
|
|
||||||
videoRef.current?.pause();
|
|
||||||
BackHandler.removeEventListener("hardwareBackPress", onBackPress);
|
|
||||||
};
|
|
||||||
}, [])
|
|
||||||
);
|
|
||||||
|
|
||||||
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}
|
|
||||||
/>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
flexDirection: "column",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
opacity: isBuffering ? 1 : 0,
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
}}
|
|
||||||
pointerEvents="none"
|
|
||||||
>
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Text>No video source...</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{item && (
|
|
||||||
<Controls
|
|
||||||
mediaSource={stream?.mediaSource}
|
|
||||||
videoRef={videoRef}
|
|
||||||
enableTrickplay={true}
|
|
||||||
item={item}
|
|
||||||
togglePlay={togglePlay}
|
|
||||||
isPlaying={isPlaying}
|
|
||||||
isSeeking={isSeeking}
|
|
||||||
progress={progress}
|
|
||||||
cacheProgress={cacheProgress}
|
|
||||||
isBuffering={isBuffering}
|
|
||||||
showControls={showControls}
|
|
||||||
setShowControls={setShowControls}
|
|
||||||
setIgnoreSafeAreas={setIgnoreSafeAreas}
|
|
||||||
ignoreSafeAreas={ignoreSafeAreas}
|
|
||||||
seek={seek}
|
|
||||||
play={play}
|
|
||||||
pause={pause}
|
|
||||||
stop={stop}
|
|
||||||
getSubtitleTracks={getSubtitleTracks}
|
|
||||||
setSubtitleTrack={(i) => {
|
|
||||||
if (i === -1) {
|
|
||||||
setSelectedTextTrack({
|
|
||||||
type: SelectedTrackType.DISABLED,
|
|
||||||
value: undefined,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSelectedTextTrack({
|
|
||||||
type: SelectedTrackType.INDEX,
|
|
||||||
value: i,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
getAudioTracks={getAudioTracks}
|
|
||||||
setAudioTrack={(i) => {
|
|
||||||
console.log("setAudioTrack ~", i);
|
|
||||||
setSelectedAudioTrack({
|
|
||||||
type: SelectedTrackType.INDEX,
|
|
||||||
value: i,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export function usePoster(
|
|
||||||
item: BaseItemDto | null | undefined,
|
|
||||||
api: Api | null
|
|
||||||
): string | undefined {
|
|
||||||
const poster = useMemo(() => {
|
|
||||||
if (!item || !api) return undefined;
|
|
||||||
return item.Type === "Audio"
|
|
||||||
? `${api.basePath}/Items/${item.AlbumId}/Images/Primary?tag=${item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
|
|
||||||
: getBackdropUrl({
|
|
||||||
api,
|
|
||||||
item: item,
|
|
||||||
quality: 70,
|
|
||||||
width: 200,
|
|
||||||
});
|
|
||||||
}, [item, api]);
|
|
||||||
|
|
||||||
return poster ?? undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useVideoSource(
|
|
||||||
item: BaseItemDto | null | undefined,
|
|
||||||
api: Api | null,
|
|
||||||
poster: string | undefined,
|
|
||||||
url?: string | null
|
|
||||||
) {
|
|
||||||
const videoSource = useMemo(() => {
|
|
||||||
if (!item || !api || !url) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const startPosition = item?.UserData?.PlaybackPositionTicks
|
|
||||||
? Math.round(item.UserData.PlaybackPositionTicks / 10000)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
uri: url,
|
|
||||||
isNetwork: true,
|
|
||||||
startPosition,
|
|
||||||
headers: getAuthHeaders(api),
|
|
||||||
metadata: {
|
|
||||||
artist: item?.AlbumArtist ?? undefined,
|
|
||||||
title: item?.Name || "Unknown",
|
|
||||||
description: item?.Overview ?? undefined,
|
|
||||||
imageUri: poster,
|
|
||||||
subtitle: item?.Album ?? undefined,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}, [item, api, poster, url]);
|
|
||||||
|
|
||||||
return videoSource;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Player;
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { DownloadProvider } from "@/providers/DownloadProvider";
|
import { DownloadProvider } from "@/providers/DownloadProvider";
|
||||||
import {
|
import {
|
||||||
getOrSetDeviceId,
|
getOrSetDeviceId,
|
||||||
getTokenFromStorage,
|
getTokenFromStoraage,
|
||||||
JellyfinProvider,
|
JellyfinProvider,
|
||||||
} from "@/providers/JellyfinProvider";
|
} from "@/providers/JellyfinProvider";
|
||||||
import { JobQueueProvider } from "@/providers/JobQueueProvider";
|
import { JobQueueProvider } from "@/providers/JobQueueProvider";
|
||||||
@@ -10,7 +10,6 @@ import { orientationAtom } from "@/utils/atoms/orientation";
|
|||||||
import { Settings, useSettings } from "@/utils/atoms/settings";
|
import { Settings, useSettings } from "@/utils/atoms/settings";
|
||||||
import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks";
|
import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks";
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
import { storage } from "@/utils/mmkv";
|
|
||||||
import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server";
|
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";
|
||||||
@@ -20,6 +19,7 @@ import {
|
|||||||
completeHandler,
|
completeHandler,
|
||||||
download,
|
download,
|
||||||
} from "@kesha-antonov/react-native-background-downloader";
|
} from "@kesha-antonov/react-native-background-downloader";
|
||||||
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
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 BackgroundFetch from "expo-background-fetch";
|
||||||
@@ -31,11 +31,11 @@ import * as Notifications from "expo-notifications";
|
|||||||
import { router, Stack } from "expo-router";
|
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 { StatusBar } from "expo-status-bar";
|
||||||
import * as TaskManager from "expo-task-manager";
|
import * as TaskManager from "expo-task-manager";
|
||||||
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 { 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 { Toaster } from "sonner-native";
|
||||||
@@ -86,7 +86,7 @@ TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
|
|||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
const settingsData = storage.getString("settings");
|
const settingsData = await AsyncStorage.getItem("settings");
|
||||||
|
|
||||||
if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData;
|
if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||||
|
|
||||||
@@ -96,13 +96,19 @@ TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
|
|||||||
if (!settings?.autoDownload || !url)
|
if (!settings?.autoDownload || !url)
|
||||||
return BackgroundFetch.BackgroundFetchResult.NoData;
|
return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||||
|
|
||||||
const token = getTokenFromStorage();
|
const token = await getTokenFromStoraage();
|
||||||
const deviceId = getOrSetDeviceId();
|
const deviceId = await getOrSetDeviceId();
|
||||||
const baseDirectory = FileSystem.documentDirectory;
|
const baseDirectory = FileSystem.documentDirectory;
|
||||||
|
|
||||||
if (!token || !deviceId || !baseDirectory)
|
if (!token || !deviceId || !baseDirectory)
|
||||||
return BackgroundFetch.BackgroundFetchResult.NoData;
|
return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||||
|
|
||||||
|
console.log({
|
||||||
|
token,
|
||||||
|
url,
|
||||||
|
deviceId,
|
||||||
|
});
|
||||||
|
|
||||||
const jobs = await getAllJobsByDeviceId({
|
const jobs = await getAllJobsByDeviceId({
|
||||||
deviceId,
|
deviceId,
|
||||||
authHeader: token,
|
authHeader: token,
|
||||||
@@ -114,6 +120,14 @@ TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
|
|||||||
for (let job of jobs) {
|
for (let job of jobs) {
|
||||||
if (job.status === "completed") {
|
if (job.status === "completed") {
|
||||||
const downloadUrl = url + "download/" + job.id;
|
const downloadUrl = url + "download/" + job.id;
|
||||||
|
console.log({
|
||||||
|
token,
|
||||||
|
deviceId,
|
||||||
|
baseDirectory,
|
||||||
|
url,
|
||||||
|
downloadUrl,
|
||||||
|
});
|
||||||
|
|
||||||
const tasks = await checkForExistingDownloads();
|
const tasks = await checkForExistingDownloads();
|
||||||
|
|
||||||
if (tasks.find((task) => task.id === job.id)) {
|
if (tasks.find((task) => task.id === job.id)) {
|
||||||
@@ -123,7 +137,7 @@ TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
|
|||||||
|
|
||||||
download({
|
download({
|
||||||
id: job.id,
|
id: job.id,
|
||||||
url: downloadUrl,
|
url: url + "download/" + job.id,
|
||||||
destination: `${baseDirectory}${job.item.Id}.mp4`,
|
destination: `${baseDirectory}${job.item.Id}.mp4`,
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: token,
|
Authorization: token,
|
||||||
@@ -177,7 +191,7 @@ TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
|
|||||||
|
|
||||||
const checkAndRequestPermissions = async () => {
|
const checkAndRequestPermissions = async () => {
|
||||||
try {
|
try {
|
||||||
const hasAskedBefore = storage.getString(
|
const hasAskedBefore = await AsyncStorage.getItem(
|
||||||
"hasAskedForNotificationPermission"
|
"hasAskedForNotificationPermission"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -192,7 +206,7 @@ const checkAndRequestPermissions = async () => {
|
|||||||
console.log("Notification permissions denied.");
|
console.log("Notification permissions denied.");
|
||||||
}
|
}
|
||||||
|
|
||||||
storage.set("hasAskedForNotificationPermission", "true");
|
await AsyncStorage.setItem("hasAskedForNotificationPermission", "true");
|
||||||
} else {
|
} else {
|
||||||
console.log("Already asked for notification permissions before.");
|
console.log("Already asked for notification permissions before.");
|
||||||
}
|
}
|
||||||
@@ -217,8 +231,6 @@ export default function RootLayout() {
|
|||||||
}
|
}
|
||||||
}, [loaded]);
|
}, [loaded]);
|
||||||
|
|
||||||
Appearance.setColorScheme("dark");
|
|
||||||
|
|
||||||
if (!loaded) {
|
if (!loaded) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -230,18 +242,6 @@ 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);
|
||||||
@@ -249,6 +249,20 @@ function Layout() {
|
|||||||
useKeepAwake();
|
useKeepAwake();
|
||||||
useNotificationObserver();
|
useNotificationObserver();
|
||||||
|
|
||||||
|
const queryClientRef = useRef<QueryClient>(
|
||||||
|
new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 0,
|
||||||
|
refetchOnMount: true,
|
||||||
|
refetchOnReconnect: true,
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
|
retryOnMount: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
checkAndRequestPermissions();
|
checkAndRequestPermissions();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -305,14 +319,14 @@ function Layout() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClientRef.current}>
|
||||||
<ActionSheetProvider>
|
<ActionSheetProvider>
|
||||||
<JobQueueProvider>
|
<JobQueueProvider>
|
||||||
<JellyfinProvider>
|
<JellyfinProvider>
|
||||||
<PlaySettingsProvider>
|
<PlaySettingsProvider>
|
||||||
<DownloadProvider>
|
<DownloadProvider>
|
||||||
<BottomSheetModalProvider>
|
<BottomSheetModalProvider>
|
||||||
<SystemBars style="light" hidden={false} />
|
<StatusBar style="light" backgroundColor="#000" />
|
||||||
<ThemeProvider value={DarkTheme}>
|
<ThemeProvider value={DarkTheme}>
|
||||||
<Stack initialRouteName="/home">
|
<Stack initialRouteName="/home">
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -320,15 +334,33 @@ function Layout() {
|
|||||||
options={{
|
options={{
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
title: "",
|
title: "",
|
||||||
header: () => null,
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="(auth)/player"
|
name="(auth)/play-video"
|
||||||
options={{
|
options={{
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
|
autoHideHomeIndicator: true,
|
||||||
title: "",
|
title: "",
|
||||||
header: () => null,
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="(auth)/play-offline-video"
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
autoHideHomeIndicator: true,
|
||||||
|
title: "",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="(auth)/play-music"
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
autoHideHomeIndicator: true,
|
||||||
|
title: "",
|
||||||
|
animation: "fade",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -363,9 +395,9 @@ function Layout() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveDownloadedItemInfo(item: BaseItemDto) {
|
async function saveDownloadedItemInfo(item: BaseItemDto) {
|
||||||
try {
|
try {
|
||||||
const downloadedItems = storage.getString("downloadedItems");
|
const downloadedItems = await AsyncStorage.getItem("downloadedItems");
|
||||||
let items: BaseItemDto[] = downloadedItems
|
let items: BaseItemDto[] = downloadedItems
|
||||||
? JSON.parse(downloadedItems)
|
? JSON.parse(downloadedItems)
|
||||||
: [];
|
: [];
|
||||||
@@ -377,7 +409,7 @@ function saveDownloadedItemInfo(item: BaseItemDto) {
|
|||||||
items.push(item);
|
items.push(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
storage.set("downloadedItems", JSON.stringify(items));
|
await AsyncStorage.setItem("downloadedItems", JSON.stringify(items));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
writeToLog("ERROR", "Failed to save downloaded item information:", error);
|
writeToLog("ERROR", "Failed to save downloaded item information:", error);
|
||||||
console.error("Failed to save downloaded item information:", error);
|
console.error("Failed to save downloaded item information:", error);
|
||||||
|
|||||||
@@ -128,9 +128,9 @@ const Login: React.FC = () => {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
const error = e as Error;
|
const error = e as Error;
|
||||||
if (error.name === "AbortError") {
|
if (error.name === "AbortError") {
|
||||||
console.error(`Request to ${protocol}${url} timed out`);
|
console.log(`Request to ${protocol}${url} timed out`);
|
||||||
} else {
|
} else {
|
||||||
console.error(`Error checking ${protocol}${url}:`, error);
|
console.log(`Error checking ${protocol}${url}:`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -286,7 +286,7 @@ 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 h-full relative items-center justify-center w-full">
|
||||||
<View className="flex flex-col gap-y-2 px-4 w-full -mt-36">
|
<View className="flex flex-col gap-y-2 px-4 w-full -mt-36">
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 112 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 20 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB |
@@ -5,9 +5,9 @@ import * as DropdownMenu from "zeego/dropdown-menu";
|
|||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
|
||||||
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 | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AudioTrackSelector: React.FC<Props> = ({
|
export const AudioTrackSelector: React.FC<Props> = ({
|
||||||
@@ -17,7 +17,7 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
|||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const audioStreams = useMemo(
|
const audioStreams = useMemo(
|
||||||
() => source?.MediaStreams?.filter((x) => x.Type === "Audio"),
|
() => source.MediaStreams?.filter((x) => x.Type === "Audio"),
|
||||||
[source]
|
[source]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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[] = [
|
export const BITRATES: Bitrate[] = [
|
||||||
@@ -26,14 +27,17 @@ 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));
|
].sort((a, b) => (b.value || Infinity) - (a.value || Infinity));
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
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,
|
||||||
@@ -40,32 +39,18 @@ 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 (
|
||||||
<>
|
<TouchableOpacity
|
||||||
<TouchableOpacity
|
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"
|
||||||
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"} />
|
</TouchableOpacity>
|
||||||
</TouchableOpacity>
|
|
||||||
<AndroidCastButton />
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (Platform.OS === "android")
|
if (Platform.OS === "android")
|
||||||
@@ -97,7 +82,6 @@ export const Chromecast: React.FC<Props> = ({
|
|||||||
>
|
>
|
||||||
<Feather name="cast" size={22} color={"white"} />
|
<Feather name="cast" size={22} color={"white"} />
|
||||||
</BlurView>
|
</BlurView>
|
||||||
<AndroidCastButton />
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,11 +3,9 @@ import { useDownload } from "@/providers/DownloadProvider";
|
|||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { queueActions, queueAtom } from "@/utils/atoms/queue";
|
import { queueActions, queueAtom } from "@/utils/atoms/queue";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { 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 { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server";
|
|
||||||
import native from "@/utils/profiles/native";
|
import native from "@/utils/profiles/native";
|
||||||
import download from "@/utils/profiles/download";
|
import old from "@/utils/profiles/old";
|
||||||
import Ionicons from "@expo/vector-icons/Ionicons";
|
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||||
import {
|
import {
|
||||||
BottomSheetBackdrop,
|
BottomSheetBackdrop,
|
||||||
@@ -23,15 +21,17 @@ import { router, useFocusEffect } from "expo-router";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { Alert, TouchableOpacity, 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, BITRATES, BitrateSelector } from "./BitrateSelector";
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
import { Text } from "./common/Text";
|
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 { SubtitleTrackSelector } from "./SubtitleTrackSelector";
|
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
import iosFmp4 from "@/utils/profiles/iosFmp4";
|
||||||
|
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
|
|
||||||
interface DownloadProps extends ViewProps {
|
interface DownloadProps extends ViewProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -43,10 +43,10 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
|||||||
const [queue, setQueue] = useAtom(queueAtom);
|
const [queue, setQueue] = useAtom(queueAtom);
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
const { processes, startBackgroundDownload } = useDownload();
|
const { processes, startBackgroundDownload } = useDownload();
|
||||||
const { startRemuxing } = useRemuxHlsToMp4();
|
const { startRemuxing } = useRemuxHlsToMp4(item);
|
||||||
|
|
||||||
const [selectedMediaSource, setSelectedMediaSource] = useState<
|
const [selectedMediaSource, setSelectedMediaSource] = useState<
|
||||||
MediaSourceInfo | undefined | null
|
MediaSourceInfo | undefined
|
||||||
>(undefined);
|
>(undefined);
|
||||||
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
||||||
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
||||||
@@ -63,7 +63,7 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
|||||||
getDefaultPlaySettings(item, settings);
|
getDefaultPlaySettings(item, settings);
|
||||||
|
|
||||||
// 4. Set states
|
// 4. Set states
|
||||||
setSelectedMediaSource(mediaSource ?? undefined);
|
setSelectedMediaSource(mediaSource);
|
||||||
setSelectedAudioStream(audioIndex ?? 0);
|
setSelectedAudioStream(audioIndex ?? 0);
|
||||||
setSelectedSubtitleStream(subtitleIndex ?? -1);
|
setSelectedSubtitleStream(subtitleIndex ?? -1);
|
||||||
setMaxBitrate(bitrate);
|
setMaxBitrate(bitrate);
|
||||||
@@ -99,36 +99,81 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await getStreamUrl({
|
let deviceProfile: any = iosFmp4;
|
||||||
api,
|
|
||||||
item,
|
|
||||||
startTimeTicks: 0,
|
|
||||||
userId: user?.Id,
|
|
||||||
audioStreamIndex: selectedAudioStream,
|
|
||||||
maxStreamingBitrate: maxBitrate.value,
|
|
||||||
mediaSourceId: selectedMediaSource.Id,
|
|
||||||
subtitleStreamIndex: selectedSubtitleStream,
|
|
||||||
deviceProfile: download,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res) {
|
if (settings?.deviceProfile === "Native") {
|
||||||
Alert.alert(
|
deviceProfile = native;
|
||||||
"Something went wrong",
|
} else if (settings?.deviceProfile === "Old") {
|
||||||
"Could not get stream url from Jellyfin"
|
deviceProfile = old;
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { mediaSource, url } = res;
|
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}"`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
if (!url || !mediaSource) throw new Error("No url");
|
let url: string | undefined = undefined;
|
||||||
|
let fileExtension: string | undefined | null = "mp4";
|
||||||
|
|
||||||
saveDownloadItemInfoToDiskTmp(item, mediaSource, url);
|
const mediaSource: MediaSourceInfo = response.data.MediaSources.find(
|
||||||
|
(source: MediaSourceInfo) => source.Id === selectedMediaSource?.Id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mediaSource) {
|
||||||
|
throw new Error("No media source");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mediaSource.SupportsDirectPlay) {
|
||||||
|
if (item.MediaType === "Video") {
|
||||||
|
url = `${api.basePath}/Videos/${item.Id}/stream.mp4?mediaSourceId=${item.Id}&static=true&mediaSourceId=${mediaSource.Id}&deviceId=${api.deviceInfo.id}&api_key=${api.accessToken}`;
|
||||||
|
} else if (item.MediaType === "Audio") {
|
||||||
|
console.log("Using direct stream for audio!");
|
||||||
|
const searchParams = new URLSearchParams({
|
||||||
|
UserId: user.Id,
|
||||||
|
DeviceId: api.deviceInfo.id,
|
||||||
|
MaxStreamingBitrate: "140000000",
|
||||||
|
Container:
|
||||||
|
"opus,webm|opus,mp3,aac,m4a|aac,m4b|aac,flac,webma,webm|webma,wav,ogg",
|
||||||
|
TranscodingContainer: "mp4",
|
||||||
|
TranscodingProtocol: "hls",
|
||||||
|
AudioCodec: "aac",
|
||||||
|
api_key: api.accessToken,
|
||||||
|
StartTimeTicks: "0",
|
||||||
|
EnableRedirection: "true",
|
||||||
|
EnableRemoteMedia: "false",
|
||||||
|
});
|
||||||
|
url = `${api.basePath}/Audio/${
|
||||||
|
item.Id
|
||||||
|
}/universal?${searchParams.toString()}`;
|
||||||
|
}
|
||||||
|
} else if (mediaSource.TranscodingUrl) {
|
||||||
|
url = `${api.basePath}${mediaSource.TranscodingUrl}`;
|
||||||
|
fileExtension = mediaSource.TranscodingContainer;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!url) throw new Error("No url");
|
||||||
|
if (!fileExtension) throw new Error("No file extension");
|
||||||
|
|
||||||
if (settings?.downloadMethod === "optimized") {
|
if (settings?.downloadMethod === "optimized") {
|
||||||
return await startBackgroundDownload(url, item, mediaSource);
|
return await startBackgroundDownload(url, item, fileExtension);
|
||||||
} else {
|
} else {
|
||||||
return await startRemuxing(item, url, mediaSource);
|
return await startRemuxing(url);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
api,
|
api,
|
||||||
@@ -150,7 +195,7 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
|||||||
const isDownloaded = useMemo(() => {
|
const isDownloaded = useMemo(() => {
|
||||||
if (!downloadedFiles) return false;
|
if (!downloadedFiles) return false;
|
||||||
|
|
||||||
return downloadedFiles.some((file) => file.item.Id === item.Id);
|
return downloadedFiles.some((file) => file.Id === item.Id);
|
||||||
}, [downloadedFiles, item.Id]);
|
}, [downloadedFiles, item.Id]);
|
||||||
|
|
||||||
const renderBackdrop = useCallback(
|
const renderBackdrop = useCallback(
|
||||||
|
|||||||
@@ -12,8 +12,11 @@ export const GenreTags: React.FC<GenreTagsProps> = ({ genres }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex flex-row flex-wrap mt-2">
|
<View className="flex flex-row flex-wrap mt-2">
|
||||||
{genres.map((genre, idx) => (
|
{genres.map((genre) => (
|
||||||
<View key={idx} className="bg-neutral-800 rounded-full px-2 py-1 mr-1">
|
<View
|
||||||
|
key={genre}
|
||||||
|
className="bg-neutral-800 rounded-full px-2 py-1 mr-1"
|
||||||
|
>
|
||||||
<Text className="text-xs">{genre}</Text>
|
<Text className="text-xs">{genre}</Text>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -11,21 +11,21 @@ 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 } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
import {
|
import {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useNavigation } from "expo-router";
|
import { useFocusEffect, 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, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Chromecast } from "./Chromecast";
|
import { Chromecast } from "./Chromecast";
|
||||||
@@ -33,51 +33,99 @@ import { ItemHeader } from "./ItemHeader";
|
|||||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||||
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
||||||
|
|
||||||
export type SelectedOptions = {
|
|
||||||
bitrate: Bitrate;
|
|
||||||
mediaSource: MediaSourceInfo | undefined;
|
|
||||||
audioIndex: number | undefined;
|
|
||||||
subtitleIndex: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||||
({ item }) => {
|
({ item }) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
|
const { setPlaySettings, playUrl, playSettings } = usePlaySettings();
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
const { orientation } = useOrientation();
|
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
useImageColors({ item });
|
|
||||||
|
|
||||||
const [loadingLogo, setLoadingLogo] = useState(true);
|
const [loadingLogo, setLoadingLogo] = useState(true);
|
||||||
|
|
||||||
|
const [orientation, setOrientation] = useState(
|
||||||
|
ScreenOrientation.Orientation.PORTRAIT_UP
|
||||||
|
);
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
if (!settings) return;
|
||||||
|
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
|
||||||
|
getDefaultPlaySettings(item, settings);
|
||||||
|
|
||||||
|
setPlaySettings({
|
||||||
|
item,
|
||||||
|
bitrate,
|
||||||
|
mediaSource,
|
||||||
|
audioIndex,
|
||||||
|
subtitleIndex,
|
||||||
|
});
|
||||||
|
}, [item, settings])
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedMediaSource = useMemo(() => {
|
||||||
|
return playSettings?.mediaSource || undefined;
|
||||||
|
}, [playSettings?.mediaSource]);
|
||||||
|
|
||||||
|
const setSelectedMediaSource = (mediaSource: MediaSourceInfo) => {
|
||||||
|
setPlaySettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
mediaSource,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedAudioStream = useMemo(() => {
|
||||||
|
return playSettings?.audioIndex;
|
||||||
|
}, [playSettings?.audioIndex]);
|
||||||
|
|
||||||
|
const setSelectedAudioStream = (audioIndex: number) => {
|
||||||
|
setPlaySettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
audioIndex,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedSubtitleStream = useMemo(() => {
|
||||||
|
return playSettings?.subtitleIndex;
|
||||||
|
}, [playSettings?.subtitleIndex]);
|
||||||
|
|
||||||
|
const setSelectedSubtitleStream = (subtitleIndex: number) => {
|
||||||
|
setPlaySettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
subtitleIndex,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const maxBitrate = useMemo(() => {
|
||||||
|
return playSettings?.bitrate;
|
||||||
|
}, [playSettings?.bitrate]);
|
||||||
|
|
||||||
|
const setMaxBitrate = (bitrate: Bitrate | undefined) => {
|
||||||
|
console.log("setMaxBitrate", bitrate);
|
||||||
|
setPlaySettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
bitrate,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const subscription = ScreenOrientation.addOrientationChangeListener(
|
||||||
|
(event) => {
|
||||||
|
setOrientation(event.orientationInfo.orientation);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
ScreenOrientation.getOrientationAsync().then((initialOrientation) => {
|
||||||
|
setOrientation(initialOrientation);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
ScreenOrientation.removeOrientationChangeListener(subscription);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [headerHeight, setHeaderHeight] = useState(350);
|
const [headerHeight, setHeaderHeight] = useState(350);
|
||||||
|
|
||||||
const [selectedOptions, setSelectedOptions] = useState<
|
useImageColors({ item });
|
||||||
SelectedOptions | undefined
|
|
||||||
>(undefined);
|
|
||||||
|
|
||||||
const {
|
|
||||||
defaultAudioIndex,
|
|
||||||
defaultBitrate,
|
|
||||||
defaultMediaSource,
|
|
||||||
defaultSubtitleIndex,
|
|
||||||
} = useDefaultPlaySettings(item, settings);
|
|
||||||
|
|
||||||
// Needs to automatically change the selected to the default values for default indexes.
|
|
||||||
useEffect(() => {
|
|
||||||
console.log(defaultAudioIndex, defaultSubtitleIndex);
|
|
||||||
setSelectedOptions(() => ({
|
|
||||||
bitrate: defaultBitrate,
|
|
||||||
mediaSource: defaultMediaSource,
|
|
||||||
subtitleIndex: defaultSubtitleIndex ?? -1,
|
|
||||||
audioIndex: defaultAudioIndex,
|
|
||||||
}));
|
|
||||||
}, [
|
|
||||||
defaultAudioIndex,
|
|
||||||
defaultBitrate,
|
|
||||||
defaultSubtitleIndex,
|
|
||||||
defaultMediaSource,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
@@ -97,9 +145,13 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
}, [item]);
|
}, [item]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP)
|
// If landscape
|
||||||
|
if (orientation !== ScreenOrientation.Orientation.PORTRAIT_UP) {
|
||||||
setHeaderHeight(230);
|
setHeaderHeight(230);
|
||||||
else if (item.Type === "Movie") setHeaderHeight(500);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.Type === "Movie") setHeaderHeight(500);
|
||||||
else setHeaderHeight(350);
|
else setHeaderHeight(350);
|
||||||
}, [item.Type, orientation]);
|
}, [item.Type, orientation]);
|
||||||
|
|
||||||
@@ -109,7 +161,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
return Boolean(logoUrl && loadingLogo);
|
return Boolean(logoUrl && loadingLogo);
|
||||||
}, [loadingLogo, logoUrl]);
|
}, [loadingLogo, logoUrl]);
|
||||||
|
|
||||||
if (!selectedOptions) return null;
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
@@ -162,63 +214,34 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
<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={item}
|
||||||
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
|
|
||||||
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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<PlayButton
|
<PlayButton item={item} url={playUrl} className="grow" />
|
||||||
className="grow"
|
|
||||||
selectedOptions={selectedOptions}
|
|
||||||
item={item}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{item.Type === "Episode" && (
|
{item.Type === "Episode" && (
|
||||||
@@ -232,10 +255,10 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
|
|
||||||
{item.People && item.People.length > 0 && (
|
{item.People && item.People.length > 0 && (
|
||||||
<View className="mb-4">
|
<View className="mb-4">
|
||||||
{item.People.slice(0, 3).map((person, idx) => (
|
{item.People.slice(0, 3).map((person) => (
|
||||||
<MoreMoviesWithActor
|
<MoreMoviesWithActor
|
||||||
currentItem={item}
|
currentItem={item}
|
||||||
key={idx}
|
key={person.Id}
|
||||||
actorId={person.Id!}
|
actorId={person.Id!}
|
||||||
className="mb-4"
|
className="mb-4"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
import { PropsWithChildren, ReactNode } from "react";
|
|
||||||
import { View, ViewProps } from "react-native";
|
|
||||||
import { Text } from "./common/Text";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
|
||||||
title?: string | null | undefined;
|
|
||||||
subTitle?: string | null | undefined;
|
|
||||||
children?: ReactNode;
|
|
||||||
iconAfter?: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
|
||||||
title,
|
|
||||||
subTitle,
|
|
||||||
iconAfter,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
className="flex flex-row items-center justify-between bg-neutral-900 p-4"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col overflow-visible">
|
|
||||||
<Text className="font-bold ">{title}</Text>
|
|
||||||
{subTitle && (
|
|
||||||
<Text uiTextView selectable className="text-xs">
|
|
||||||
{subTitle}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
{iconAfter}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -26,9 +26,23 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
|||||||
item.MediaSources?.find((x) => x.Id === selected?.Id)?.MediaStreams?.find(
|
item.MediaSources?.find((x) => x.Id === selected?.Id)?.MediaStreams?.find(
|
||||||
(x) => x.Type === "Video"
|
(x) => x.Type === "Video"
|
||||||
)?.DisplayTitle || "",
|
)?.DisplayTitle || "",
|
||||||
[item, selected]
|
[item.MediaSources, selected]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selected && item.MediaSources && item.MediaSources.length > 0) {
|
||||||
|
onChange(item.MediaSources[0]);
|
||||||
|
}
|
||||||
|
}, [item.MediaSources, selected]);
|
||||||
|
|
||||||
|
const name = (name?: string | null) => {
|
||||||
|
if (name && name.length > 40)
|
||||||
|
return (
|
||||||
|
name.substring(0, 20) + " [...] " + name.substring(name.length - 20)
|
||||||
|
);
|
||||||
|
return name;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className="flex shrink"
|
className="flex shrink"
|
||||||
@@ -74,9 +88,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,20 +1,15 @@
|
|||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
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, MaterialCommunityIcons } 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 { Linking, TouchableOpacity, View } from "react-native";
|
||||||
import { Alert, TouchableOpacity, View } from "react-native";
|
|
||||||
import CastContext, {
|
import CastContext, {
|
||||||
CastButton,
|
|
||||||
PlayServicesState,
|
PlayServicesState,
|
||||||
useMediaStatus,
|
useMediaStatus,
|
||||||
useRemoteMediaClient,
|
useRemoteMediaClient,
|
||||||
@@ -30,65 +25,61 @@ 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 { useRouter } from "expo-router";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
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 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 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 [settings] = useSettings();
|
||||||
|
|
||||||
const goToPlayer = useCallback(
|
const directStream = useMemo(() => {
|
||||||
(q: string, bitrateValue: number | undefined) => {
|
return !url?.includes("m3u8");
|
||||||
if (!bitrateValue) {
|
}, [url]);
|
||||||
router.push(`/player/direct-player?${q}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
router.push(`/player/transcoding-player?${q}`);
|
|
||||||
},
|
|
||||||
[router]
|
|
||||||
);
|
|
||||||
|
|
||||||
const onPress = useCallback(async () => {
|
const onPress = async () => {
|
||||||
if (!item) return;
|
if (!url || !item) {
|
||||||
|
console.warn(
|
||||||
const queryParams = new URLSearchParams({
|
"No URL or item provided to PlayButton",
|
||||||
itemId: item.Id!,
|
url?.slice(0, 100),
|
||||||
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
|
item?.Id
|
||||||
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
|
);
|
||||||
mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
|
return;
|
||||||
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
|
}
|
||||||
});
|
|
||||||
|
|
||||||
const queryString = queryParams.toString();
|
|
||||||
|
|
||||||
if (!client) {
|
if (!client) {
|
||||||
goToPlayer(queryString, selectedOptions.bitrate?.value);
|
const vlcLink = "vlc://" + url;
|
||||||
|
if (vlcLink && settings?.openInVLC) {
|
||||||
|
Linking.openURL(vlcLink);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push("/play-video");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,7 +98,7 @@ 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 {
|
||||||
@@ -117,33 +108,10 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
CastContext.showExpandedControls();
|
CastContext.showExpandedControls();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get a new URL with the Chromecast device profile:
|
|
||||||
const data = await getStreamUrl({
|
|
||||||
api,
|
|
||||||
item,
|
|
||||||
deviceProfile: ios,
|
|
||||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
|
||||||
userId: user?.Id,
|
|
||||||
audioStreamIndex: selectedOptions.audioIndex,
|
|
||||||
maxStreamingBitrate: selectedOptions.bitrate?.value,
|
|
||||||
mediaSourceId: selectedOptions.mediaSource?.Id,
|
|
||||||
subtitleStreamIndex: selectedOptions.subtitleIndex,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!data?.url) {
|
|
||||||
console.warn("No URL returned from getStreamUrl", data);
|
|
||||||
Alert.alert(
|
|
||||||
"Client error",
|
|
||||||
"Could not create stream for Chromecast"
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
client
|
client
|
||||||
.loadMedia({
|
.loadMedia({
|
||||||
mediaInfo: {
|
mediaInfo: {
|
||||||
contentUrl: data?.url,
|
contentUrl: url,
|
||||||
contentType: "video/mp4",
|
contentType: "video/mp4",
|
||||||
metadata:
|
metadata:
|
||||||
item.Type === "Episode"
|
item.Type === "Episode"
|
||||||
@@ -209,38 +177,28 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case 1:
|
case 1:
|
||||||
goToPlayer(queryString, selectedOptions.bitrate?.value);
|
router.push("/play-video");
|
||||||
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,
|
||||||
@@ -256,7 +214,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;
|
||||||
@@ -265,19 +223,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
|
||||||
@@ -320,11 +278,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">
|
||||||
@@ -361,7 +318,6 @@ 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>
|
</Animated.Text>
|
||||||
)}
|
)}
|
||||||
{!client && settings?.openInVLC && (
|
{!client && settings?.openInVLC && (
|
||||||
@@ -376,7 +332,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
</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}
|
||||||
@@ -386,7 +342,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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ export const PlayedStatus: React.FC<Props> = ({ item, ...props }) => {
|
|||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["seasons"],
|
queryKey: ["seasons"],
|
||||||
});
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["nextUp-all"],
|
||||||
|
});
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["home"],
|
queryKey: ["home"],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import * as DropdownMenu from "zeego/dropdown-menu";
|
|||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
|
||||||
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 | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SubtitleTrackSelector: React.FC<Props> = ({
|
export const SubtitleTrackSelector: React.FC<Props> = ({
|
||||||
@@ -18,7 +18,7 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
|||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const subtitleStreams = useMemo(
|
const subtitleStreams = useMemo(
|
||||||
() => source?.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [],
|
() => source.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [],
|
||||||
[source]
|
[source]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
22
components/_page_template.tsx
Normal file
22
components/_page_template.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { ScrollView, View, ViewProps } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { TAB_HEIGHT } from "@/constants/Values";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
paddingBottom: 16,
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
marginBottom: TAB_HEIGHT,
|
||||||
|
}}
|
||||||
|
></ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,7 +16,6 @@ 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;
|
||||||
@@ -33,7 +32,6 @@ export const HorizontalScroll = forwardRef<
|
|||||||
<T,>(
|
<T,>(
|
||||||
{
|
{
|
||||||
data = [],
|
data = [],
|
||||||
keyExtractor,
|
|
||||||
renderItem,
|
renderItem,
|
||||||
containerStyle,
|
containerStyle,
|
||||||
contentContainerStyle,
|
contentContainerStyle,
|
||||||
@@ -93,7 +91,6 @@ 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">
|
||||||
@@ -101,7 +98,6 @@ export const HorizontalScroll = forwardRef<
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{...props}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import { storage } from "@/utils/mmkv";
|
|||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
export const ActiveDownloads: React.FC<Props> = ({ ...props }) => {
|
export const ActiveDownloads: React.FC<Props> = ({ ...props }) => {
|
||||||
const { processes } = useDownload();
|
const { processes, startDownload } = useDownload();
|
||||||
if (processes?.length === 0)
|
if (processes?.length === 0)
|
||||||
return (
|
return (
|
||||||
<View {...props} className="bg-neutral-900 p-4 rounded-2xl">
|
<View {...props} className="bg-neutral-900 p-4 rounded-2xl">
|
||||||
@@ -85,7 +85,7 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|||||||
toast.success("Download canceled");
|
toast.success("Download canceled");
|
||||||
},
|
},
|
||||||
onError: (e) => {
|
onError: (e) => {
|
||||||
console.error(e);
|
console.log(e);
|
||||||
toast.error("Could not cancel download");
|
toast.error("Could not cancel download");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -95,7 +95,7 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|||||||
|
|
||||||
const length = p?.item?.RunTimeTicks || 0;
|
const length = p?.item?.RunTimeTicks || 0;
|
||||||
const timeLeft = (length - length * (p.progress / 100)) / p.speed;
|
const timeLeft = (length - length * (p.progress / 100)) / p.speed;
|
||||||
return formatTimeString(timeLeft, "tick");
|
return formatTimeString(timeLeft, true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const base64Image = useMemo(() => {
|
const base64Image = useMemo(() => {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
useActionSheet,
|
useActionSheet,
|
||||||
} from "@expo/react-native-action-sheet";
|
} from "@expo/react-native-action-sheet";
|
||||||
|
|
||||||
import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener";
|
import { useFileOpener } from "@/hooks/useDownloadedFileOpener";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
@@ -26,7 +26,7 @@ interface EpisodeCardProps {
|
|||||||
*/
|
*/
|
||||||
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
|
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
|
||||||
const { deleteFile } = useDownload();
|
const { deleteFile } = useDownload();
|
||||||
const { openFile } = useDownloadedFileOpener();
|
const { openFile } = useFileOpener();
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
|
|
||||||
const base64Image = useMemo(() => {
|
const base64Image = useMemo(() => {
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
import {
|
|
||||||
ActionSheetProvider,
|
|
||||||
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 * as Haptics from "expo-haptics";
|
import * as Haptics from "expo-haptics";
|
||||||
import React, { useCallback, useMemo } from "react";
|
import React, { useCallback, useMemo } from "react";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
|
import {
|
||||||
|
ActionSheetProvider,
|
||||||
|
useActionSheet,
|
||||||
|
} from "@expo/react-native-action-sheet";
|
||||||
|
|
||||||
import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener";
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
|
import { Text } from "../common/Text";
|
||||||
|
|
||||||
|
import { useFileOpener } from "@/hooks/useDownloadedFileOpener";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { ItemCardText } from "../ItemCardText";
|
import { ItemCardText } from "../ItemCardText";
|
||||||
|
|
||||||
interface MovieCardProps {
|
interface MovieCardProps {
|
||||||
@@ -25,7 +28,7 @@ interface MovieCardProps {
|
|||||||
*/
|
*/
|
||||||
export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
||||||
const { deleteFile } = useDownload();
|
const { deleteFile } = useDownload();
|
||||||
const { openFile } = useDownloadedFileOpener();
|
const { openFile } = useFileOpener();
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
|
|
||||||
const handleOpenFile = useCallback(() => {
|
const handleOpenFile = useCallback(() => {
|
||||||
|
|||||||
@@ -28,15 +28,11 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
|||||||
queryKey,
|
queryKey,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
// console.log(queryKey);
|
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: queryKey,
|
queryKey,
|
||||||
queryFn,
|
queryFn,
|
||||||
|
enabled: !disabled,
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
refetchOnMount: true,
|
|
||||||
refetchOnWindowFocus: true,
|
|
||||||
refetchOnReconnect: true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (disabled || !title) return null;
|
if (disabled || !title) return null;
|
||||||
|
|||||||
@@ -11,9 +11,12 @@ 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";
|
||||||
|
import { useImageColors } from "@/hooks/useImageColors";
|
||||||
|
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||||
|
|
||||||
interface Props extends TouchableOpacityProps {
|
interface Props extends TouchableOpacityProps {
|
||||||
library: BaseItemDto;
|
library: BaseItemDto;
|
||||||
@@ -50,6 +53,10 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
|||||||
[library]
|
[library]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// If we want to use image colors for library cards
|
||||||
|
// const [color] = useAtom(itemThemeColorAtom)
|
||||||
|
// useImageColors({ url });
|
||||||
|
|
||||||
const { data: itemsCount } = useQuery({
|
const { data: itemsCount } = useQuery({
|
||||||
queryKey: ["library-count", library.Id],
|
queryKey: ["library-count", library.Id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -61,7 +68,6 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
|||||||
});
|
});
|
||||||
return response.data.TotalRecordCount;
|
return response.data.TotalRecordCount;
|
||||||
},
|
},
|
||||||
staleTime: 1000 * 60 * 60,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
|
|||||||
64
components/list/ListInputItem.tsx
Normal file
64
components/list/ListInputItem.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { PropsWithChildren, ReactNode, useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Pressable,
|
||||||
|
TextInput,
|
||||||
|
TextInputProps,
|
||||||
|
TouchableOpacity,
|
||||||
|
TouchableOpacityProps,
|
||||||
|
View,
|
||||||
|
ViewProps,
|
||||||
|
} from "react-native";
|
||||||
|
import { Text } from "../common/Text";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {
|
||||||
|
title?: string | null | undefined;
|
||||||
|
text?: string | null | undefined;
|
||||||
|
children?: ReactNode;
|
||||||
|
iconAfter?: ReactNode;
|
||||||
|
iconBefore?: ReactNode;
|
||||||
|
textInputProps?: TextInputProps;
|
||||||
|
defaultValue?: string;
|
||||||
|
onChange: (text: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ListInputItem: React.FC<PropsWithChildren<Props>> = ({
|
||||||
|
title,
|
||||||
|
text,
|
||||||
|
iconAfter,
|
||||||
|
iconBefore,
|
||||||
|
children,
|
||||||
|
onChange,
|
||||||
|
textInputProps,
|
||||||
|
defaultValue,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const [value, setValue] = useState<string>(defaultValue || "");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onChange(value);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className={`flex flex-row items-center justify-between px-4 h-12 bg-neutral-900`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{iconBefore && <View className="mr-2">{iconBefore}</View>}
|
||||||
|
<View>
|
||||||
|
<Text className="">{title}</Text>
|
||||||
|
</View>
|
||||||
|
<View className="ml-auto">
|
||||||
|
<TextInput
|
||||||
|
inputMode="numeric"
|
||||||
|
keyboardType="decimal-pad"
|
||||||
|
style={{ color: "white" }}
|
||||||
|
value={value}
|
||||||
|
onChangeText={setValue}
|
||||||
|
className=""
|
||||||
|
{...textInputProps}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
{iconAfter && <View className="ml-2">{iconAfter}</View>}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
44
components/list/ListItem.tsx
Normal file
44
components/list/ListItem.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { PropsWithChildren, ReactNode, useState } from "react";
|
||||||
|
import {
|
||||||
|
Pressable,
|
||||||
|
TouchableOpacity,
|
||||||
|
TouchableOpacityProps,
|
||||||
|
View,
|
||||||
|
ViewProps,
|
||||||
|
} from "react-native";
|
||||||
|
import { Text } from "../common/Text";
|
||||||
|
|
||||||
|
interface Props extends TouchableOpacityProps {
|
||||||
|
title?: string | null | undefined;
|
||||||
|
text?: string | null | undefined;
|
||||||
|
children?: ReactNode;
|
||||||
|
iconAfter?: ReactNode;
|
||||||
|
iconBefore?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
||||||
|
title,
|
||||||
|
text,
|
||||||
|
iconAfter,
|
||||||
|
iconBefore,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
className={`flex flex-row items-center justify-between px-4 h-12 bg-neutral-900`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{iconBefore && <View className="mr-2">{iconBefore}</View>}
|
||||||
|
<View>
|
||||||
|
<Text className="">{title}</Text>
|
||||||
|
</View>
|
||||||
|
<View className="ml-auto">
|
||||||
|
<Text selectable className="">
|
||||||
|
{text}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{iconAfter && <View className="ml-2">{iconAfter}</View>}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
24
components/list/ListSection.tsx
Normal file
24
components/list/ListSection.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { View, ViewProps } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Children, PropsWithChildren } from "react";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ListSection: React.FC<PropsWithChildren<Props>> = ({
|
||||||
|
children,
|
||||||
|
title,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<View {...props}>
|
||||||
|
<Text className="ml-4 mb-1 text-xs text-neutral-500 uppercase">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<View className="flex flex-col rounded-xl overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800">
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -103,7 +103,7 @@ export const SongsListItem: React.FC<Props> = ({
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
console.log("Playing on device", data.url, item.Id);
|
console.log("Playing on device", data.url, item.Id);
|
||||||
router.push("/music-player");
|
router.push("/play-music");
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useMemo } from "react";
|
import React from "react";
|
||||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||||
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
@@ -20,37 +20,24 @@ interface Props extends ViewProps {
|
|||||||
export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
|
export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
const destinctPeople = useMemo(() => {
|
|
||||||
const people: BaseItemPerson[] = [];
|
|
||||||
item?.People?.forEach((person) => {
|
|
||||||
const existingPerson = people.find((p) => p.Id === person.Id);
|
|
||||||
if (existingPerson) {
|
|
||||||
existingPerson.Role = `${existingPerson.Role}, ${person.Role}`;
|
|
||||||
} else {
|
|
||||||
people.push(person);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return people;
|
|
||||||
}, [item?.People]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props} className="flex flex-col">
|
<View {...props} className="flex flex-col">
|
||||||
<Text className="text-lg font-bold mb-2 px-4">Cast & Crew</Text>
|
<Text className="text-lg font-bold mb-2 px-4">Cast & Crew</Text>
|
||||||
<HorizontalScroll
|
<HorizontalScroll
|
||||||
loading={loading}
|
loading={loading}
|
||||||
keyExtractor={(i, idx) => i.Id.toString()}
|
|
||||||
height={247}
|
height={247}
|
||||||
data={destinctPeople}
|
data={item?.People || []}
|
||||||
renderItem={(i) => (
|
renderItem={(item, index) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.push(`/actors/${i.Id}`);
|
router.push(`/actors/${item.Id}`);
|
||||||
}}
|
}}
|
||||||
|
key={item.Id}
|
||||||
className="flex flex-col w-28"
|
className="flex flex-col w-28"
|
||||||
>
|
>
|
||||||
<Poster item={i} url={getPrimaryImageUrl({ api, item: i })} />
|
<Poster item={item} url={getPrimaryImageUrl({ api, item })} />
|
||||||
<Text className="mt-2">{i.Name}</Text>
|
<Text className="mt-2">{item.Name}</Text>
|
||||||
<Text className="text-xs opacity-50">{i.Role}</Text>
|
<Text className="text-xs opacity-50">{item.Role}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
|
|
||||||
if (settings?.autoDownload === true && !registered) {
|
if (settings?.autoDownload === true && !registered) {
|
||||||
registerBackgroundFetchAsync();
|
registerBackgroundFetchAsync();
|
||||||
toast.success("Background downloads enabled");
|
toast.success("Background downlodas enabled");
|
||||||
} else if (settings?.autoDownload === false && registered) {
|
} else if (settings?.autoDownload === false && registered) {
|
||||||
unregisterBackgroundFetchAsync();
|
unregisterBackgroundFetchAsync();
|
||||||
toast.info("Background downloads disabled");
|
toast.info("Background downloads disabled");
|
||||||
@@ -246,6 +246,22 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
</DropdownMenu.Root>
|
</DropdownMenu.Root>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<View className="flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4">
|
||||||
|
<View className="flex flex-col shrink">
|
||||||
|
<Text className="font-semibold">Use external player (VLC)</Text>
|
||||||
|
<Text className="text-xs opacity-50 shrink">
|
||||||
|
Open all videos in VLC instead of the default player. This
|
||||||
|
requries VLC to be installed on the phone.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={settings.openInVLC}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
updateSettings({ openInVLC: value, forceDirectPlay: value });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
<View className="flex flex-col">
|
<View className="flex flex-col">
|
||||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||||
<View className="flex flex-col">
|
<View className="flex flex-col">
|
||||||
@@ -316,6 +332,79 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<View className="flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4">
|
||||||
|
<View className="flex flex-col shrink">
|
||||||
|
<Text className="font-semibold">Force direct play</Text>
|
||||||
|
<Text className="text-xs opacity-50 shrink">
|
||||||
|
This will always request direct play. This is good if you want
|
||||||
|
to try to stream movies you think the device supports.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={settings.forceDirectPlay}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateSettings({ forceDirectPlay: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View
|
||||||
|
className={`
|
||||||
|
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||||
|
${settings.forceDirectPlay ? "opacity-50 select-none" : ""}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<View className="flex flex-col shrink">
|
||||||
|
<Text className="font-semibold">Device profile</Text>
|
||||||
|
<Text className="text-xs opacity-50">
|
||||||
|
A profile used for deciding what audio and video codecs the
|
||||||
|
device supports.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger>
|
||||||
|
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||||
|
<Text>{settings.deviceProfile}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Content
|
||||||
|
loop={true}
|
||||||
|
side="bottom"
|
||||||
|
align="start"
|
||||||
|
alignOffset={0}
|
||||||
|
avoidCollisions={true}
|
||||||
|
collisionPadding={8}
|
||||||
|
sideOffset={8}
|
||||||
|
>
|
||||||
|
<DropdownMenu.Label>Profiles</DropdownMenu.Label>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key="1"
|
||||||
|
onSelect={() => {
|
||||||
|
updateSettings({ deviceProfile: "Expo" });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemTitle>Expo</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key="2"
|
||||||
|
onSelect={() => {
|
||||||
|
updateSettings({ deviceProfile: "Native" });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemTitle>Native</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key="3"
|
||||||
|
onSelect={() => {
|
||||||
|
updateSettings({ deviceProfile: "Old" });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemTitle>Old</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
</View>
|
||||||
|
|
||||||
<View className="flex flex-col">
|
<View className="flex flex-col">
|
||||||
<View
|
<View
|
||||||
className={`
|
className={`
|
||||||
|
|||||||
529
components/video-player/Controls.tsx
Normal file
529
components/video-player/Controls.tsx
Normal file
@@ -0,0 +1,529 @@
|
|||||||
|
import { useAdjacentItems } from "@/hooks/useAdjacentEpisodes";
|
||||||
|
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
|
||||||
|
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
|
||||||
|
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||||
|
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
|
import { writeToLog } from "@/utils/log";
|
||||||
|
import { formatTimeString, ticksToSeconds } from "@/utils/time";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
Dimensions,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { Slider } from "react-native-awesome-slider";
|
||||||
|
import Animated, {
|
||||||
|
runOnJS,
|
||||||
|
SharedValue,
|
||||||
|
useAnimatedReaction,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withTiming,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { VideoRef } from "react-native-video";
|
||||||
|
import { Text } from "../common/Text";
|
||||||
|
import { Loader } from "../Loader";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
item: BaseItemDto;
|
||||||
|
videoRef: React.MutableRefObject<VideoRef | null>;
|
||||||
|
isPlaying: boolean;
|
||||||
|
isSeeking: SharedValue<boolean>;
|
||||||
|
cacheProgress: SharedValue<number>;
|
||||||
|
progress: SharedValue<number>;
|
||||||
|
isBuffering: boolean;
|
||||||
|
showControls: boolean;
|
||||||
|
ignoreSafeAreas?: boolean;
|
||||||
|
setIgnoreSafeAreas: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
enableTrickplay?: boolean;
|
||||||
|
togglePlay: (ticks: number) => void;
|
||||||
|
setShowControls: (shown: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Controls: React.FC<Props> = ({
|
||||||
|
item,
|
||||||
|
videoRef,
|
||||||
|
togglePlay,
|
||||||
|
isPlaying,
|
||||||
|
isSeeking,
|
||||||
|
progress,
|
||||||
|
isBuffering,
|
||||||
|
cacheProgress,
|
||||||
|
showControls,
|
||||||
|
setShowControls,
|
||||||
|
ignoreSafeAreas,
|
||||||
|
setIgnoreSafeAreas,
|
||||||
|
enableTrickplay = true,
|
||||||
|
}) => {
|
||||||
|
const [settings] = useSettings();
|
||||||
|
const router = useRouter();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { setPlaySettings } = usePlaySettings();
|
||||||
|
|
||||||
|
const windowDimensions = Dimensions.get("window");
|
||||||
|
|
||||||
|
const op = useSharedValue<number>(1);
|
||||||
|
const tr = useSharedValue<number>(10);
|
||||||
|
const animatedStyles = useAnimatedStyle(() => {
|
||||||
|
return {
|
||||||
|
opacity: op.value,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const animatedTopStyles = useAnimatedStyle(() => {
|
||||||
|
return {
|
||||||
|
opacity: op.value,
|
||||||
|
transform: [
|
||||||
|
{
|
||||||
|
translateY: -tr.value,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const animatedBottomStyles = useAnimatedStyle(() => {
|
||||||
|
return {
|
||||||
|
opacity: op.value,
|
||||||
|
transform: [
|
||||||
|
{
|
||||||
|
translateY: tr.value,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showControls || isBuffering) {
|
||||||
|
op.value = withTiming(1, { duration: 200 });
|
||||||
|
tr.value = withTiming(0, { duration: 200 });
|
||||||
|
} else {
|
||||||
|
op.value = withTiming(0, { duration: 200 });
|
||||||
|
tr.value = withTiming(10, { duration: 200 });
|
||||||
|
}
|
||||||
|
}, [showControls, isBuffering]);
|
||||||
|
|
||||||
|
const { previousItem, nextItem } = useAdjacentItems({ item });
|
||||||
|
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay(
|
||||||
|
item,
|
||||||
|
enableTrickplay
|
||||||
|
);
|
||||||
|
|
||||||
|
const [currentTime, setCurrentTime] = useState(0); // Seconds
|
||||||
|
const [remainingTime, setRemainingTime] = useState(0); // Seconds
|
||||||
|
|
||||||
|
const min = useSharedValue(0);
|
||||||
|
const max = useSharedValue(item.RunTimeTicks || 0);
|
||||||
|
|
||||||
|
const wasPlayingRef = useRef(false);
|
||||||
|
|
||||||
|
const updateTimes = useCallback(
|
||||||
|
(currentProgress: number, maxValue: number) => {
|
||||||
|
const current = ticksToSeconds(currentProgress);
|
||||||
|
const remaining = ticksToSeconds(maxValue - currentProgress);
|
||||||
|
|
||||||
|
setCurrentTime(current);
|
||||||
|
setRemainingTime(remaining);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { showSkipButton, skipIntro } = useIntroSkipper(
|
||||||
|
item.Id,
|
||||||
|
currentTime,
|
||||||
|
videoRef
|
||||||
|
);
|
||||||
|
|
||||||
|
const { showSkipCreditButton, skipCredit } = useCreditSkipper(
|
||||||
|
item.Id,
|
||||||
|
currentTime,
|
||||||
|
videoRef
|
||||||
|
);
|
||||||
|
|
||||||
|
const goToPreviousItem = useCallback(() => {
|
||||||
|
if (!previousItem || !settings) return;
|
||||||
|
|
||||||
|
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
|
||||||
|
getDefaultPlaySettings(previousItem, settings);
|
||||||
|
|
||||||
|
setPlaySettings({
|
||||||
|
item: previousItem,
|
||||||
|
bitrate,
|
||||||
|
mediaSource,
|
||||||
|
audioIndex,
|
||||||
|
subtitleIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
router.replace("/play-video");
|
||||||
|
}, [previousItem, settings]);
|
||||||
|
|
||||||
|
const goToNextItem = useCallback(() => {
|
||||||
|
if (!nextItem || !settings) return;
|
||||||
|
|
||||||
|
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
|
||||||
|
getDefaultPlaySettings(nextItem, settings);
|
||||||
|
|
||||||
|
setPlaySettings({
|
||||||
|
item: nextItem,
|
||||||
|
bitrate,
|
||||||
|
mediaSource,
|
||||||
|
audioIndex,
|
||||||
|
subtitleIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
router.replace("/play-video");
|
||||||
|
}, [nextItem, settings]);
|
||||||
|
|
||||||
|
useAnimatedReaction(
|
||||||
|
() => ({
|
||||||
|
progress: progress.value,
|
||||||
|
max: max.value,
|
||||||
|
isSeeking: isSeeking.value,
|
||||||
|
}),
|
||||||
|
(result) => {
|
||||||
|
if (result.isSeeking === false) {
|
||||||
|
runOnJS(updateTimes)(result.progress, result.max);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[updateTimes]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (item) {
|
||||||
|
progress.value = item?.UserData?.PlaybackPositionTicks || 0;
|
||||||
|
max.value = item.RunTimeTicks || 0;
|
||||||
|
}
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
const toggleControls = () => setShowControls(!showControls);
|
||||||
|
|
||||||
|
const handleSliderComplete = useCallback((value: number) => {
|
||||||
|
progress.value = value;
|
||||||
|
isSeeking.value = false;
|
||||||
|
videoRef.current?.seek(Math.max(0, Math.floor(value / 10000000)));
|
||||||
|
if (wasPlayingRef.current === true) videoRef.current?.resume();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSliderChange = (value: number) => {
|
||||||
|
calculateTrickplayUrl(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSliderStart = useCallback(() => {
|
||||||
|
if (showControls === false) return;
|
||||||
|
wasPlayingRef.current = isPlaying;
|
||||||
|
videoRef.current?.pause();
|
||||||
|
isSeeking.value = true;
|
||||||
|
}, [showControls, isPlaying]);
|
||||||
|
|
||||||
|
const handleSkipBackward = useCallback(async () => {
|
||||||
|
console.log("handleSkipBackward");
|
||||||
|
if (!settings?.rewindSkipTime) return;
|
||||||
|
wasPlayingRef.current = isPlaying;
|
||||||
|
try {
|
||||||
|
const curr = await videoRef.current?.getCurrentPosition();
|
||||||
|
if (curr !== undefined) {
|
||||||
|
videoRef.current?.seek(Math.max(0, curr - settings.rewindSkipTime));
|
||||||
|
setTimeout(() => {
|
||||||
|
if (wasPlayingRef.current === true) videoRef.current?.resume();
|
||||||
|
}, 10);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
writeToLog("ERROR", "Error seeking video backwards", error);
|
||||||
|
}
|
||||||
|
}, [settings, isPlaying]);
|
||||||
|
|
||||||
|
const handleSkipForward = useCallback(async () => {
|
||||||
|
console.log("handleSkipForward");
|
||||||
|
if (!settings?.forwardSkipTime) return;
|
||||||
|
wasPlayingRef.current = isPlaying;
|
||||||
|
try {
|
||||||
|
const curr = await videoRef.current?.getCurrentPosition();
|
||||||
|
if (curr !== undefined) {
|
||||||
|
videoRef.current?.seek(Math.max(0, curr + settings.forwardSkipTime));
|
||||||
|
setTimeout(() => {
|
||||||
|
if (wasPlayingRef.current === true) videoRef.current?.resume();
|
||||||
|
}, 10);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
writeToLog("ERROR", "Error seeking video forwards", error);
|
||||||
|
}
|
||||||
|
}, [settings, isPlaying]);
|
||||||
|
|
||||||
|
const toggleIgnoreSafeAreas = useCallback(() => {
|
||||||
|
setIgnoreSafeAreas((prev) => !prev);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: windowDimensions.width,
|
||||||
|
height: windowDimensions.height,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: insets.bottom + 97,
|
||||||
|
right: insets.right,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
className={`z-10 p-4
|
||||||
|
${showSkipButton ? "opacity-100" : "opacity-0"}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={skipIntro}
|
||||||
|
className="bg-purple-600 rounded-full px-2.5 py-2 font-semibold"
|
||||||
|
>
|
||||||
|
<Text className="text-white">Skip Intro</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: insets.bottom + 94,
|
||||||
|
right: insets.right,
|
||||||
|
height: 70,
|
||||||
|
}}
|
||||||
|
pointerEvents={showSkipCreditButton ? "auto" : "none"}
|
||||||
|
className={`z-10 p-4 ${
|
||||||
|
showSkipCreditButton ? "opacity-100" : "opacity-0"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={skipCredit}
|
||||||
|
className="bg-purple-600 rounded-full px-2.5 py-2 font-semibold"
|
||||||
|
>
|
||||||
|
<Text className="text-white">Skip Credits</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
onPress={() => {
|
||||||
|
toggleControls();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: windowDimensions.width + 100,
|
||||||
|
height: windowDimensions.height + 100,
|
||||||
|
},
|
||||||
|
animatedStyles,
|
||||||
|
]}
|
||||||
|
className={`bg-black/50 z-0`}
|
||||||
|
></Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: windowDimensions.width,
|
||||||
|
height: windowDimensions.height,
|
||||||
|
}}
|
||||||
|
pointerEvents="none"
|
||||||
|
className={`flex flex-col items-center justify-center
|
||||||
|
${isBuffering ? "opacity-100" : "opacity-0"}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
position: "absolute",
|
||||||
|
top: insets.top,
|
||||||
|
right: insets.right,
|
||||||
|
},
|
||||||
|
animatedTopStyles,
|
||||||
|
]}
|
||||||
|
pointerEvents={showControls ? "auto" : "none"}
|
||||||
|
className={`flex flex-row items-center space-x-2 z-10 p-4`}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={toggleIgnoreSafeAreas}
|
||||||
|
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={ignoreSafeAreas ? "contract-outline" : "expand"}
|
||||||
|
size={24}
|
||||||
|
color="white"
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
router.back();
|
||||||
|
}}
|
||||||
|
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
||||||
|
>
|
||||||
|
<Ionicons name="close" size={24} color="white" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
position: "absolute",
|
||||||
|
width: windowDimensions.width - insets.left - insets.right,
|
||||||
|
maxHeight: windowDimensions.height,
|
||||||
|
left: insets.left,
|
||||||
|
bottom: Platform.OS === "ios" ? insets.bottom : insets.bottom,
|
||||||
|
},
|
||||||
|
animatedBottomStyles,
|
||||||
|
]}
|
||||||
|
pointerEvents={showControls ? "auto" : "none"}
|
||||||
|
className={`flex flex-col p-4 `}
|
||||||
|
>
|
||||||
|
<View className="shrink flex flex-col justify-center h-full mb-2">
|
||||||
|
<Text className="font-bold">{item?.Name}</Text>
|
||||||
|
{item?.Type === "Episode" && (
|
||||||
|
<Text className="opacity-50">{item.SeriesName}</Text>
|
||||||
|
)}
|
||||||
|
{item?.Type === "Movie" && (
|
||||||
|
<Text className="text-xs opacity-50">{item?.ProductionYear}</Text>
|
||||||
|
)}
|
||||||
|
{item?.Type === "Audio" && (
|
||||||
|
<Text className="text-xs opacity-50">{item?.Album}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
className={`flex flex-col-reverse py-4 px-4 rounded-2xl items-center bg-neutral-800/90`}
|
||||||
|
>
|
||||||
|
<View className="flex flex-row items-center space-x-4">
|
||||||
|
<TouchableOpacity
|
||||||
|
style={{
|
||||||
|
opacity: !previousItem ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
onPress={goToPreviousItem}
|
||||||
|
>
|
||||||
|
<Ionicons name="play-skip-back" size={24} color="white" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity onPress={handleSkipBackward}>
|
||||||
|
<Ionicons
|
||||||
|
name="refresh-outline"
|
||||||
|
size={26}
|
||||||
|
color="white"
|
||||||
|
style={{
|
||||||
|
transform: [{ scaleY: -1 }, { rotate: "180deg" }],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
togglePlay(progress.value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={isPlaying ? "pause" : "play"}
|
||||||
|
size={30}
|
||||||
|
color="white"
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity onPress={handleSkipForward}>
|
||||||
|
<Ionicons name="refresh-outline" size={26} color="white" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={{
|
||||||
|
opacity: !nextItem ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
onPress={goToNextItem}
|
||||||
|
>
|
||||||
|
<Ionicons name="play-skip-forward" size={24} color="white" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<View className={`flex flex-col w-full shrink`}>
|
||||||
|
<Slider
|
||||||
|
theme={{
|
||||||
|
maximumTrackTintColor: "rgba(255,255,255,0.2)",
|
||||||
|
minimumTrackTintColor: "#fff",
|
||||||
|
cacheTrackTintColor: "rgba(255,255,255,0.3)",
|
||||||
|
bubbleBackgroundColor: "#fff",
|
||||||
|
bubbleTextColor: "#000",
|
||||||
|
heartbeatColor: "#999",
|
||||||
|
}}
|
||||||
|
cache={cacheProgress}
|
||||||
|
onSlidingStart={handleSliderStart}
|
||||||
|
onSlidingComplete={handleSliderComplete}
|
||||||
|
onValueChange={handleSliderChange}
|
||||||
|
containerStyle={{
|
||||||
|
borderRadius: 100,
|
||||||
|
}}
|
||||||
|
renderBubble={() => {
|
||||||
|
if (!trickPlayUrl || !trickplayInfo) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const { x, y, url } = trickPlayUrl;
|
||||||
|
|
||||||
|
const tileWidth = 150;
|
||||||
|
const tileHeight = 150 / trickplayInfo.aspectRatio!;
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
width: tileWidth,
|
||||||
|
height: tileHeight,
|
||||||
|
marginLeft: -tileWidth / 4,
|
||||||
|
marginTop: -tileHeight / 4 - 60,
|
||||||
|
zIndex: 10,
|
||||||
|
}}
|
||||||
|
className=" bg-neutral-800 overflow-hidden"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
cachePolicy={"memory-disk"}
|
||||||
|
style={{
|
||||||
|
width: 150 * trickplayInfo?.data.TileWidth!,
|
||||||
|
height:
|
||||||
|
(150 / trickplayInfo.aspectRatio!) *
|
||||||
|
trickplayInfo?.data.TileHeight!,
|
||||||
|
transform: [
|
||||||
|
{ translateX: -x * tileWidth },
|
||||||
|
{ translateY: -y * tileHeight },
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
source={{ uri: url }}
|
||||||
|
contentFit="cover"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
sliderHeight={10}
|
||||||
|
thumbWidth={0}
|
||||||
|
progress={progress}
|
||||||
|
minimumValue={min}
|
||||||
|
maximumValue={max}
|
||||||
|
/>
|
||||||
|
<View className="flex flex-row items-center justify-between mt-0.5">
|
||||||
|
<Text className="text-[12px] text-neutral-400">
|
||||||
|
{formatTimeString(currentTime)}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[12px] text-neutral-400">
|
||||||
|
-{formatTimeString(remainingTime)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,599 +0,0 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { useAdjacentItems } from "@/hooks/useAdjacentEpisodes";
|
|
||||||
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
|
|
||||||
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
|
|
||||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
|
||||||
import {
|
|
||||||
TrackInfo,
|
|
||||||
VlcPlayerViewRef,
|
|
||||||
} from "@/modules/vlc-player/src/VlcPlayer.types";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
|
||||||
import { writeToLog } from "@/utils/log";
|
|
||||||
import {
|
|
||||||
formatTimeString,
|
|
||||||
msToTicks,
|
|
||||||
secondsToMs,
|
|
||||||
ticksToMs,
|
|
||||||
ticksToSeconds,
|
|
||||||
} from "@/utils/time";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import {
|
|
||||||
BaseItemDto,
|
|
||||||
MediaSourceInfo,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { useRouter } from "expo-router";
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import {
|
|
||||||
Dimensions,
|
|
||||||
Platform,
|
|
||||||
Pressable,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import { Slider } from "react-native-awesome-slider";
|
|
||||||
import {
|
|
||||||
runOnJS,
|
|
||||||
SharedValue,
|
|
||||||
useAnimatedReaction,
|
|
||||||
useSharedValue,
|
|
||||||
} from "react-native-reanimated";
|
|
||||||
import {
|
|
||||||
SafeAreaView,
|
|
||||||
useSafeAreaInsets,
|
|
||||||
} from "react-native-safe-area-context";
|
|
||||||
import { VideoRef } from "react-native-video";
|
|
||||||
import { ControlProvider } from "./contexts/ControlContext";
|
|
||||||
import { VideoProvider } from "./contexts/VideoContext";
|
|
||||||
import * as Haptics from "expo-haptics";
|
|
||||||
import DropdownViewDirect from "./dropdown/DropdownViewDirect";
|
|
||||||
import DropdownViewTranscoding from "./dropdown/DropdownViewTranscoding";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
item: BaseItemDto;
|
|
||||||
videoRef: React.MutableRefObject<VlcPlayerViewRef | VideoRef | null>;
|
|
||||||
isPlaying: boolean;
|
|
||||||
isSeeking: SharedValue<boolean>;
|
|
||||||
cacheProgress: SharedValue<number>;
|
|
||||||
progress: SharedValue<number>;
|
|
||||||
isBuffering: boolean;
|
|
||||||
showControls: boolean;
|
|
||||||
ignoreSafeAreas?: boolean;
|
|
||||||
setIgnoreSafeAreas: React.Dispatch<React.SetStateAction<boolean>>;
|
|
||||||
enableTrickplay?: boolean;
|
|
||||||
togglePlay: (ticks: number) => void;
|
|
||||||
setShowControls: (shown: boolean) => void;
|
|
||||||
offline?: boolean;
|
|
||||||
isVideoLoaded?: boolean;
|
|
||||||
mediaSource?: MediaSourceInfo | null;
|
|
||||||
seek: (ticks: number) => void;
|
|
||||||
play: (() => Promise<void>) | (() => void);
|
|
||||||
pause: () => void;
|
|
||||||
getAudioTracks?: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]);
|
|
||||||
getSubtitleTracks?: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]);
|
|
||||||
setSubtitleURL?: (url: string, customName: string) => void;
|
|
||||||
setSubtitleTrack?: (index: number) => void;
|
|
||||||
setAudioTrack?: (index: number) => void;
|
|
||||||
stop?: (() => Promise<void>) | (() => void);
|
|
||||||
isVlc?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Controls: React.FC<Props> = ({
|
|
||||||
item,
|
|
||||||
seek,
|
|
||||||
play,
|
|
||||||
pause,
|
|
||||||
togglePlay,
|
|
||||||
isPlaying,
|
|
||||||
isSeeking,
|
|
||||||
progress,
|
|
||||||
isBuffering,
|
|
||||||
cacheProgress,
|
|
||||||
showControls,
|
|
||||||
setShowControls,
|
|
||||||
ignoreSafeAreas,
|
|
||||||
setIgnoreSafeAreas,
|
|
||||||
mediaSource,
|
|
||||||
isVideoLoaded,
|
|
||||||
getAudioTracks,
|
|
||||||
getSubtitleTracks,
|
|
||||||
setSubtitleURL,
|
|
||||||
setSubtitleTrack,
|
|
||||||
setAudioTrack,
|
|
||||||
stop,
|
|
||||||
offline = false,
|
|
||||||
enableTrickplay = true,
|
|
||||||
isVlc = false,
|
|
||||||
}) => {
|
|
||||||
const [settings] = useSettings();
|
|
||||||
const router = useRouter();
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
const { previousItem, nextItem } = useAdjacentItems({ item });
|
|
||||||
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay(
|
|
||||||
item,
|
|
||||||
!offline && enableTrickplay
|
|
||||||
);
|
|
||||||
|
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
|
||||||
const [remainingTime, setRemainingTime] = useState(0);
|
|
||||||
|
|
||||||
const min = useSharedValue(0);
|
|
||||||
const max = useSharedValue(item.RunTimeTicks || 0);
|
|
||||||
|
|
||||||
const wasPlayingRef = useRef(false);
|
|
||||||
const lastProgressRef = useRef<number>(0);
|
|
||||||
|
|
||||||
const { showSkipButton, skipIntro } = useIntroSkipper(
|
|
||||||
offline ? undefined : item.Id,
|
|
||||||
currentTime,
|
|
||||||
seek,
|
|
||||||
play,
|
|
||||||
isVlc
|
|
||||||
);
|
|
||||||
|
|
||||||
const { showSkipCreditButton, skipCredit } = useCreditSkipper(
|
|
||||||
offline ? undefined : item.Id,
|
|
||||||
currentTime,
|
|
||||||
seek,
|
|
||||||
play,
|
|
||||||
isVlc
|
|
||||||
);
|
|
||||||
|
|
||||||
const goToPreviousItem = useCallback(() => {
|
|
||||||
if (!previousItem || !settings) return;
|
|
||||||
|
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
|
|
||||||
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
|
|
||||||
getDefaultPlaySettings(previousItem, settings);
|
|
||||||
|
|
||||||
const queryParams = new URLSearchParams({
|
|
||||||
itemId: previousItem.Id ?? "", // Ensure itemId is a string
|
|
||||||
audioIndex: audioIndex?.toString() ?? "",
|
|
||||||
subtitleIndex: subtitleIndex?.toString() ?? "",
|
|
||||||
mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string
|
|
||||||
bitrateValue: bitrate.toString(),
|
|
||||||
}).toString();
|
|
||||||
|
|
||||||
if (!bitrate.value) {
|
|
||||||
// @ts-expect-error
|
|
||||||
router.replace(`player/direct-player?${queryParams}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// @ts-expect-error
|
|
||||||
router.replace(`player/transcoding-player?${queryParams}`);
|
|
||||||
}, [previousItem, settings]);
|
|
||||||
|
|
||||||
const goToNextItem = useCallback(() => {
|
|
||||||
if (!nextItem || !settings) return;
|
|
||||||
|
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
|
|
||||||
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
|
|
||||||
getDefaultPlaySettings(nextItem, settings);
|
|
||||||
|
|
||||||
const queryParams = new URLSearchParams({
|
|
||||||
itemId: nextItem.Id ?? "", // Ensure itemId is a string
|
|
||||||
audioIndex: audioIndex?.toString() ?? "",
|
|
||||||
subtitleIndex: subtitleIndex?.toString() ?? "",
|
|
||||||
mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string
|
|
||||||
bitrateValue: bitrate.toString(),
|
|
||||||
}).toString();
|
|
||||||
|
|
||||||
if (!bitrate.value) {
|
|
||||||
// @ts-expect-error
|
|
||||||
router.replace(`player/direct-player?${queryParams}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// @ts-expect-error
|
|
||||||
router.replace(`player/transcoding-player?${queryParams}`);
|
|
||||||
}, [nextItem, settings]);
|
|
||||||
|
|
||||||
const updateTimes = useCallback(
|
|
||||||
(currentProgress: number, maxValue: number) => {
|
|
||||||
const current = isVlc ? currentProgress : ticksToSeconds(currentProgress);
|
|
||||||
const remaining = isVlc
|
|
||||||
? maxValue - currentProgress
|
|
||||||
: ticksToSeconds(maxValue - currentProgress);
|
|
||||||
|
|
||||||
setCurrentTime(current);
|
|
||||||
setRemainingTime(remaining);
|
|
||||||
|
|
||||||
// Currently doesm't work in VLC because of some corrupted timestamps, will need to find a workaround.
|
|
||||||
if (currentProgress === maxValue) {
|
|
||||||
setShowControls(true);
|
|
||||||
// Automatically play the next item if it exists
|
|
||||||
goToNextItem();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[goToNextItem, isVlc]
|
|
||||||
);
|
|
||||||
|
|
||||||
useAnimatedReaction(
|
|
||||||
() => ({
|
|
||||||
progress: progress.value,
|
|
||||||
max: max.value,
|
|
||||||
isSeeking: isSeeking.value,
|
|
||||||
}),
|
|
||||||
(result) => {
|
|
||||||
// console.log("Progress changed", result);
|
|
||||||
if (result.isSeeking === false) {
|
|
||||||
runOnJS(updateTimes)(result.progress, result.max);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[updateTimes]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (item) {
|
|
||||||
progress.value = isVlc
|
|
||||||
? ticksToMs(item?.UserData?.PlaybackPositionTicks)
|
|
||||||
: item?.UserData?.PlaybackPositionTicks || 0;
|
|
||||||
max.value = isVlc
|
|
||||||
? ticksToMs(item.RunTimeTicks || 0)
|
|
||||||
: item.RunTimeTicks || 0;
|
|
||||||
}
|
|
||||||
}, [item, isVlc]);
|
|
||||||
|
|
||||||
const toggleControls = () => setShowControls(!showControls);
|
|
||||||
|
|
||||||
const handleSliderComplete = useCallback(
|
|
||||||
async (value: number) => {
|
|
||||||
isSeeking.value = false;
|
|
||||||
progress.value = value;
|
|
||||||
|
|
||||||
await seek(
|
|
||||||
Math.max(0, Math.floor(isVlc ? value : ticksToSeconds(value)))
|
|
||||||
);
|
|
||||||
if (wasPlayingRef.current === true) play();
|
|
||||||
},
|
|
||||||
[isVlc]
|
|
||||||
);
|
|
||||||
|
|
||||||
const [time, setTime] = useState({ hours: 0, minutes: 0, seconds: 0 });
|
|
||||||
|
|
||||||
const handleSliderChange = (value: number) => {
|
|
||||||
const progressInTicks = isVlc ? msToTicks(value) : value;
|
|
||||||
calculateTrickplayUrl(progressInTicks);
|
|
||||||
|
|
||||||
const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks));
|
|
||||||
const hours = Math.floor(progressInSeconds / 3600);
|
|
||||||
const minutes = Math.floor((progressInSeconds % 3600) / 60);
|
|
||||||
const seconds = progressInSeconds % 60;
|
|
||||||
setTime({ hours, minutes, seconds });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSliderStart = useCallback(() => {
|
|
||||||
if (showControls === false) return;
|
|
||||||
|
|
||||||
wasPlayingRef.current = isPlaying;
|
|
||||||
lastProgressRef.current = progress.value;
|
|
||||||
|
|
||||||
pause();
|
|
||||||
isSeeking.value = true;
|
|
||||||
}, [showControls, isPlaying]);
|
|
||||||
|
|
||||||
const handleSkipBackward = useCallback(async () => {
|
|
||||||
if (!settings?.rewindSkipTime) return;
|
|
||||||
wasPlayingRef.current = isPlaying;
|
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
try {
|
|
||||||
const curr = progress.value;
|
|
||||||
if (curr !== undefined) {
|
|
||||||
const newTime = isVlc
|
|
||||||
? Math.max(0, curr - secondsToMs(settings.rewindSkipTime))
|
|
||||||
: Math.max(0, ticksToSeconds(curr) - settings.rewindSkipTime);
|
|
||||||
await seek(newTime);
|
|
||||||
if (wasPlayingRef.current === true) play();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
writeToLog("ERROR", "Error seeking video backwards", error);
|
|
||||||
}
|
|
||||||
}, [settings, isPlaying, isVlc]);
|
|
||||||
|
|
||||||
const handleSkipForward = useCallback(async () => {
|
|
||||||
if (!settings?.forwardSkipTime) return;
|
|
||||||
wasPlayingRef.current = isPlaying;
|
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
try {
|
|
||||||
const curr = progress.value;
|
|
||||||
console.log(curr);
|
|
||||||
if (curr !== undefined) {
|
|
||||||
const newTime = isVlc
|
|
||||||
? curr + secondsToMs(settings.forwardSkipTime)
|
|
||||||
: ticksToSeconds(curr) + settings.forwardSkipTime;
|
|
||||||
await seek(Math.max(0, newTime));
|
|
||||||
if (wasPlayingRef.current === true) play();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
writeToLog("ERROR", "Error seeking video forwards", error);
|
|
||||||
}
|
|
||||||
}, [settings, isPlaying, isVlc]);
|
|
||||||
|
|
||||||
const toggleIgnoreSafeAreas = useCallback(() => {
|
|
||||||
setIgnoreSafeAreas((prev) => !prev);
|
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ControlProvider
|
|
||||||
item={item}
|
|
||||||
mediaSource={mediaSource}
|
|
||||||
isVideoLoaded={isVideoLoaded}
|
|
||||||
>
|
|
||||||
<SafeAreaView
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
position: "absolute",
|
|
||||||
top: insets.top,
|
|
||||||
left: insets.left,
|
|
||||||
right: insets.right,
|
|
||||||
bottom: insets.bottom,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<VideoProvider
|
|
||||||
getAudioTracks={getAudioTracks}
|
|
||||||
getSubtitleTracks={getSubtitleTracks}
|
|
||||||
setAudioTrack={setAudioTrack}
|
|
||||||
setSubtitleTrack={setSubtitleTrack}
|
|
||||||
setSubtitleURL={setSubtitleURL}
|
|
||||||
>
|
|
||||||
{!mediaSource?.TranscodingUrl ? (
|
|
||||||
<DropdownViewDirect showControls={showControls} />
|
|
||||||
) : (
|
|
||||||
<DropdownViewTranscoding showControls={showControls} />
|
|
||||||
)}
|
|
||||||
</VideoProvider>
|
|
||||||
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: 97,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
className={`z-10 p-4
|
|
||||||
${showSkipButton ? "opacity-100" : "opacity-0"}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={skipIntro}
|
|
||||||
className="bg-purple-600 rounded-full px-2.5 py-2 font-semibold"
|
|
||||||
>
|
|
||||||
<Text className="text-white">Skip Intro</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: 94,
|
|
||||||
height: 70,
|
|
||||||
}}
|
|
||||||
pointerEvents={showSkipCreditButton ? "auto" : "none"}
|
|
||||||
className={`z-10 p-4 ${
|
|
||||||
showSkipCreditButton ? "opacity-100" : "opacity-0"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={skipCredit}
|
|
||||||
className="bg-purple-600 rounded-full px-2.5 py-2 font-semibold"
|
|
||||||
>
|
|
||||||
<Text className="text-white">Skip Credits</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Pressable
|
|
||||||
onPressIn={() => {
|
|
||||||
toggleControls();
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
width: Dimensions.get("window").width,
|
|
||||||
height: Dimensions.get("window").height,
|
|
||||||
}}
|
|
||||||
></Pressable>
|
|
||||||
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
right: 0,
|
|
||||||
opacity: showControls ? 1 : 0,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
pointerEvents={showControls ? "auto" : "none"}
|
|
||||||
className={`flex flex-row items-center space-x-2 z-10 p-4 `}
|
|
||||||
>
|
|
||||||
{Platform.OS !== "ios" && (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={toggleIgnoreSafeAreas}
|
|
||||||
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name={ignoreSafeAreas ? "contract-outline" : "expand"}
|
|
||||||
size={24}
|
|
||||||
color="white"
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={async () => {
|
|
||||||
if (stop) await stop();
|
|
||||||
router.back();
|
|
||||||
}}
|
|
||||||
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
|
||||||
>
|
|
||||||
<Ionicons name="close" size={24} color="white" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
{
|
|
||||||
position: "absolute",
|
|
||||||
right: 0,
|
|
||||||
left: 0,
|
|
||||||
bottom: 0,
|
|
||||||
opacity: showControls ? 1 : 0,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
pointerEvents={showControls ? "auto" : "none"}
|
|
||||||
className={`flex flex-col p-4`}
|
|
||||||
>
|
|
||||||
<View className="shrink flex flex-col justify-center h-full mb-2">
|
|
||||||
<Text className="font-bold">{item?.Name}</Text>
|
|
||||||
{item?.Type === "Episode" && (
|
|
||||||
<Text className="opacity-50">{item.SeriesName}</Text>
|
|
||||||
)}
|
|
||||||
{item?.Type === "Movie" && (
|
|
||||||
<Text className="text-xs opacity-50">{item?.ProductionYear}</Text>
|
|
||||||
)}
|
|
||||||
{item?.Type === "Audio" && (
|
|
||||||
<Text className="text-xs opacity-50">{item?.Album}</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
className={`flex flex-col-reverse py-4 px-4 rounded-2xl items-center bg-neutral-800`}
|
|
||||||
>
|
|
||||||
<View className="flex flex-row items-center space-x-4">
|
|
||||||
<TouchableOpacity
|
|
||||||
style={{
|
|
||||||
opacity: !previousItem ? 0.5 : 1,
|
|
||||||
}}
|
|
||||||
onPress={goToPreviousItem}
|
|
||||||
>
|
|
||||||
<Ionicons name="play-skip-back" size={24} color="white" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity onPress={handleSkipBackward}>
|
|
||||||
<Ionicons
|
|
||||||
name="refresh-outline"
|
|
||||||
size={26}
|
|
||||||
color="white"
|
|
||||||
style={{
|
|
||||||
transform: [{ scaleY: -1 }, { rotate: "180deg" }],
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
togglePlay(progress.value);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name={isPlaying ? "pause" : "play"}
|
|
||||||
size={30}
|
|
||||||
color="white"
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity onPress={handleSkipForward}>
|
|
||||||
<Ionicons name="refresh-outline" size={26} color="white" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={{
|
|
||||||
opacity: !nextItem ? 0.5 : 1,
|
|
||||||
}}
|
|
||||||
onPress={goToNextItem}
|
|
||||||
>
|
|
||||||
<Ionicons name="play-skip-forward" size={24} color="white" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
<View className={`flex flex-col w-full shrink`}>
|
|
||||||
<Slider
|
|
||||||
theme={{
|
|
||||||
maximumTrackTintColor: "rgba(255,255,255,0.2)",
|
|
||||||
minimumTrackTintColor: "#fff",
|
|
||||||
cacheTrackTintColor: "rgba(255,255,255,0.3)",
|
|
||||||
bubbleBackgroundColor: "#fff",
|
|
||||||
bubbleTextColor: "#000",
|
|
||||||
heartbeatColor: "#999",
|
|
||||||
}}
|
|
||||||
cache={cacheProgress}
|
|
||||||
onSlidingStart={handleSliderStart}
|
|
||||||
onSlidingComplete={handleSliderComplete}
|
|
||||||
onValueChange={handleSliderChange}
|
|
||||||
containerStyle={{
|
|
||||||
borderRadius: 100,
|
|
||||||
}}
|
|
||||||
renderBubble={() => {
|
|
||||||
if (!trickPlayUrl || !trickplayInfo) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const { x, y, url } = trickPlayUrl;
|
|
||||||
|
|
||||||
const tileWidth = 150;
|
|
||||||
const tileHeight = 150 / trickplayInfo.aspectRatio!;
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
width: tileWidth,
|
|
||||||
height: tileHeight,
|
|
||||||
marginLeft: -tileWidth / 4,
|
|
||||||
marginTop: -tileHeight / 4 - 60,
|
|
||||||
zIndex: 10,
|
|
||||||
}}
|
|
||||||
className=" bg-neutral-800 overflow-hidden"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
cachePolicy={"memory-disk"}
|
|
||||||
style={{
|
|
||||||
width: 150 * trickplayInfo?.data.TileWidth!,
|
|
||||||
height:
|
|
||||||
(150 / trickplayInfo.aspectRatio!) *
|
|
||||||
trickplayInfo?.data.TileHeight!,
|
|
||||||
transform: [
|
|
||||||
{ translateX: -x * tileWidth },
|
|
||||||
{ translateY: -y * tileHeight },
|
|
||||||
],
|
|
||||||
}}
|
|
||||||
source={{ uri: url }}
|
|
||||||
contentFit="cover"
|
|
||||||
/>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: 5,
|
|
||||||
left: 5,
|
|
||||||
color: "white",
|
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
|
||||||
padding: 5,
|
|
||||||
borderRadius: 5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{`${time.hours > 0 ? `${time.hours}:` : ""}${
|
|
||||||
time.minutes < 10 ? `0${time.minutes}` : time.minutes
|
|
||||||
}:${
|
|
||||||
time.seconds < 10 ? `0${time.seconds}` : time.seconds
|
|
||||||
}`}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
sliderHeight={10}
|
|
||||||
thumbWidth={0}
|
|
||||||
progress={progress}
|
|
||||||
minimumValue={min}
|
|
||||||
maximumValue={max}
|
|
||||||
/>
|
|
||||||
<View className="flex flex-row items-center justify-between mt-0.5">
|
|
||||||
<Text className="text-[12px] text-neutral-400">
|
|
||||||
{formatTimeString(currentTime, isVlc ? "ms" : "s")}
|
|
||||||
</Text>
|
|
||||||
<Text className="text-[12px] text-neutral-400">
|
|
||||||
-{formatTimeString(remainingTime, isVlc ? "ms" : "s")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</SafeAreaView>
|
|
||||||
</ControlProvider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
import { useTrickplay } from '@/hooks/useTrickplay';
|
|
||||||
import { formatTimeString, msToTicks, ticksToSeconds } from '@/utils/time';
|
|
||||||
import React, { useRef, useState } from 'react';
|
|
||||||
import { View, Text } from 'react-native';
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { Slider } from "react-native-awesome-slider";
|
|
||||||
import { SharedValue, useSharedValue } from 'react-native-reanimated';
|
|
||||||
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client';
|
|
||||||
|
|
||||||
interface SliderScrubberProps {
|
|
||||||
cacheProgress: SharedValue<number>;
|
|
||||||
handleSliderStart: () => void;
|
|
||||||
handleSliderComplete: (value: number) => void;
|
|
||||||
progress: SharedValue<number>;
|
|
||||||
min: SharedValue<number>;
|
|
||||||
max: SharedValue<number>;
|
|
||||||
currentTime: number;
|
|
||||||
remainingTime: number;
|
|
||||||
item: BaseItemDto;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SliderScrubber: React.FC<SliderScrubberProps> = ({
|
|
||||||
cacheProgress,
|
|
||||||
handleSliderStart,
|
|
||||||
handleSliderComplete,
|
|
||||||
progress,
|
|
||||||
min,
|
|
||||||
max,
|
|
||||||
currentTime,
|
|
||||||
remainingTime,
|
|
||||||
item,
|
|
||||||
}) => {
|
|
||||||
|
|
||||||
|
|
||||||
const [time, setTime] = useState({ hours: 0, minutes: 0, seconds: 0 });
|
|
||||||
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay(
|
|
||||||
item,
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSliderChange = (value: number) => {
|
|
||||||
const progressInTicks = msToTicks(value);
|
|
||||||
calculateTrickplayUrl(progressInTicks);
|
|
||||||
|
|
||||||
const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks));
|
|
||||||
const hours = Math.floor(progressInSeconds / 3600);
|
|
||||||
const minutes = Math.floor((progressInSeconds % 3600) / 60);
|
|
||||||
const seconds = progressInSeconds % 60;
|
|
||||||
setTime({ hours, minutes, seconds });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className={`flex flex-col w-full shrink`}>
|
|
||||||
<Slider
|
|
||||||
theme={{
|
|
||||||
maximumTrackTintColor: "rgba(255,255,255,0.2)",
|
|
||||||
minimumTrackTintColor: "#fff",
|
|
||||||
cacheTrackTintColor: "rgba(255,255,255,0.3)",
|
|
||||||
bubbleBackgroundColor: "#fff",
|
|
||||||
bubbleTextColor: "#000",
|
|
||||||
heartbeatColor: "#999",
|
|
||||||
}}
|
|
||||||
cache={cacheProgress}
|
|
||||||
onSlidingStart={handleSliderStart}
|
|
||||||
onSlidingComplete={handleSliderComplete}
|
|
||||||
onValueChange={handleSliderChange}
|
|
||||||
containerStyle={{
|
|
||||||
borderRadius: 100,
|
|
||||||
}}
|
|
||||||
renderBubble={() => {
|
|
||||||
if (!trickPlayUrl || !trickplayInfo) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const { x, y, url } = trickPlayUrl;
|
|
||||||
|
|
||||||
const tileWidth = 150;
|
|
||||||
const tileHeight = 150 / trickplayInfo.aspectRatio!;
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
width: tileWidth,
|
|
||||||
height: tileHeight,
|
|
||||||
marginLeft: -tileWidth / 4,
|
|
||||||
marginTop: -tileHeight / 4 - 60,
|
|
||||||
zIndex: 10,
|
|
||||||
}}
|
|
||||||
className=" bg-neutral-800 overflow-hidden"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
cachePolicy={"memory-disk"}
|
|
||||||
style={{
|
|
||||||
width: 150 * trickplayInfo?.data.TileWidth!,
|
|
||||||
height:
|
|
||||||
(150 / trickplayInfo.aspectRatio!) *
|
|
||||||
trickplayInfo?.data.TileHeight!,
|
|
||||||
transform: [
|
|
||||||
{ translateX: -x * tileWidth },
|
|
||||||
{ translateY: -y * tileHeight },
|
|
||||||
],
|
|
||||||
}}
|
|
||||||
source={{ uri: url }}
|
|
||||||
contentFit="cover"
|
|
||||||
/>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: 5,
|
|
||||||
left: 5,
|
|
||||||
color: "white",
|
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
|
||||||
padding: 5,
|
|
||||||
borderRadius: 5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{`${time.hours > 0 ? `${time.hours}:` : ""}${
|
|
||||||
time.minutes < 10 ? `0${time.minutes}` : time.minutes
|
|
||||||
}:${
|
|
||||||
time.seconds < 10 ? `0${time.seconds}` : time.seconds
|
|
||||||
}`}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
sliderHeight={10}
|
|
||||||
thumbWidth={0}
|
|
||||||
progress={progress}
|
|
||||||
minimumValue={min}
|
|
||||||
maximumValue={max}
|
|
||||||
/>
|
|
||||||
<View className="flex flex-row items-center justify-between mt-0.5">
|
|
||||||
<Text className="text-[12px] text-neutral-400">
|
|
||||||
{formatTimeString(currentTime, "ms")}
|
|
||||||
</Text>
|
|
||||||
<Text className="text-[12px] text-neutral-400">
|
|
||||||
-{formatTimeString(remainingTime, "ms")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SliderScrubber;
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { TrackInfo } from '@/modules/vlc-player';
|
|
||||||
import { BaseItemDto, MediaSourceInfo } from '@jellyfin/sdk/lib/generated-client';
|
|
||||||
import React, { createContext, useContext, useState, ReactNode } from 'react';
|
|
||||||
|
|
||||||
interface ControlContextProps {
|
|
||||||
item: BaseItemDto;
|
|
||||||
mediaSource: MediaSourceInfo | null | undefined;
|
|
||||||
isVideoLoaded: boolean | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ControlContext = createContext<ControlContextProps | undefined>(undefined);
|
|
||||||
|
|
||||||
interface ControlProviderProps {
|
|
||||||
children: ReactNode;
|
|
||||||
item: BaseItemDto;
|
|
||||||
mediaSource: MediaSourceInfo | null | undefined;
|
|
||||||
isVideoLoaded: boolean | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ControlProvider: React.FC<ControlProviderProps> = ({ children, item, mediaSource, isVideoLoaded }) => {
|
|
||||||
return (
|
|
||||||
<ControlContext.Provider value={{ item, mediaSource, isVideoLoaded }}>
|
|
||||||
{children}
|
|
||||||
</ControlContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useControlContext = () => {
|
|
||||||
const context = useContext(ControlContext);
|
|
||||||
if (context === undefined) {
|
|
||||||
throw new Error('useControlContext must be used within a ControlProvider');
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
import { TrackInfo } from "@/modules/vlc-player";
|
|
||||||
import {
|
|
||||||
BaseItemDto,
|
|
||||||
MediaSourceInfo,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import React, {
|
|
||||||
createContext,
|
|
||||||
useContext,
|
|
||||||
useState,
|
|
||||||
ReactNode,
|
|
||||||
useEffect,
|
|
||||||
} from "react";
|
|
||||||
import { useControlContext } from "./ControlContext";
|
|
||||||
|
|
||||||
interface VideoContextProps {
|
|
||||||
audioTracks: TrackInfo[] | null;
|
|
||||||
subtitleTracks: TrackInfo[] | null;
|
|
||||||
setAudioTrack: ((index: number) => void) | undefined;
|
|
||||||
setSubtitleTrack: ((index: number) => void) | undefined;
|
|
||||||
setSubtitleURL: ((url: string, customName: string) => void) | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const VideoContext = createContext<VideoContextProps | undefined>(undefined);
|
|
||||||
|
|
||||||
interface VideoProviderProps {
|
|
||||||
children: ReactNode;
|
|
||||||
getAudioTracks:
|
|
||||||
| (() => Promise<TrackInfo[] | null>)
|
|
||||||
| (() => TrackInfo[])
|
|
||||||
| undefined;
|
|
||||||
getSubtitleTracks:
|
|
||||||
| (() => Promise<TrackInfo[] | null>)
|
|
||||||
| (() => TrackInfo[])
|
|
||||||
| undefined;
|
|
||||||
setAudioTrack: ((index: number) => void) | undefined;
|
|
||||||
setSubtitleTrack: ((index: number) => void) | undefined;
|
|
||||||
setSubtitleURL: ((url: string, customName: string) => void) | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const VideoProvider: React.FC<VideoProviderProps> = ({
|
|
||||||
children,
|
|
||||||
getSubtitleTracks,
|
|
||||||
getAudioTracks,
|
|
||||||
setSubtitleTrack,
|
|
||||||
setSubtitleURL,
|
|
||||||
setAudioTrack,
|
|
||||||
}) => {
|
|
||||||
const [audioTracks, setAudioTracks] = useState<TrackInfo[] | null>(null);
|
|
||||||
const [subtitleTracks, setSubtitleTracks] = useState<TrackInfo[] | null>(
|
|
||||||
null
|
|
||||||
);
|
|
||||||
|
|
||||||
const ControlContext = useControlContext();
|
|
||||||
const isVideoLoaded = ControlContext?.isVideoLoaded;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchTracks = async () => {
|
|
||||||
if (
|
|
||||||
getSubtitleTracks &&
|
|
||||||
(subtitleTracks === null || subtitleTracks.length === 0)
|
|
||||||
) {
|
|
||||||
const subtitles = await getSubtitleTracks();
|
|
||||||
console.log("Getting embeded subtitles...", subtitles);
|
|
||||||
setSubtitleTracks(subtitles);
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
getAudioTracks &&
|
|
||||||
(audioTracks === null || audioTracks.length === 0)
|
|
||||||
) {
|
|
||||||
const audio = await getAudioTracks();
|
|
||||||
setAudioTracks(audio);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
fetchTracks();
|
|
||||||
}, [isVideoLoaded, getAudioTracks, getSubtitleTracks]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<VideoContext.Provider
|
|
||||||
value={{
|
|
||||||
audioTracks,
|
|
||||||
subtitleTracks,
|
|
||||||
setSubtitleTrack,
|
|
||||||
setSubtitleURL,
|
|
||||||
setAudioTrack,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</VideoContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useVideoContext = () => {
|
|
||||||
const context = useContext(VideoContext);
|
|
||||||
if (context === undefined) {
|
|
||||||
throw new Error("useVideoContext must be used within a VideoProvider");
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
import React, { useMemo, useState } from "react";
|
|
||||||
import { View, TouchableOpacity } from "react-native";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
|
||||||
import { useControlContext } from "../contexts/ControlContext";
|
|
||||||
import { useVideoContext } from "../contexts/VideoContext";
|
|
||||||
import { EmbeddedSubtitle, ExternalSubtitle } from "../types";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useLocalSearchParams } from "expo-router";
|
|
||||||
|
|
||||||
interface DropdownViewDirectProps {
|
|
||||||
showControls: boolean;
|
|
||||||
offline?: boolean; // used to disable external subs for downloads
|
|
||||||
}
|
|
||||||
|
|
||||||
const DropdownViewDirect: React.FC<DropdownViewDirectProps> = ({
|
|
||||||
showControls,
|
|
||||||
offline = false,
|
|
||||||
}) => {
|
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const ControlContext = useControlContext();
|
|
||||||
const mediaSource = ControlContext?.mediaSource;
|
|
||||||
const item = ControlContext?.item;
|
|
||||||
const isVideoLoaded = ControlContext?.isVideoLoaded;
|
|
||||||
|
|
||||||
const videoContext = useVideoContext();
|
|
||||||
const {
|
|
||||||
subtitleTracks,
|
|
||||||
audioTracks,
|
|
||||||
setSubtitleURL,
|
|
||||||
setSubtitleTrack,
|
|
||||||
setAudioTrack,
|
|
||||||
} = videoContext;
|
|
||||||
|
|
||||||
const allSubtitleTracksForDirectPlay = useMemo(() => {
|
|
||||||
if (mediaSource?.TranscodingUrl) return null;
|
|
||||||
const embeddedSubs =
|
|
||||||
subtitleTracks
|
|
||||||
?.map((s) => ({
|
|
||||||
name: s.name,
|
|
||||||
index: s.index,
|
|
||||||
deliveryUrl: undefined,
|
|
||||||
}))
|
|
||||||
.filter((sub) => !sub.name.endsWith("[External]")) || [];
|
|
||||||
|
|
||||||
const externalSubs =
|
|
||||||
mediaSource?.MediaStreams?.filter(
|
|
||||||
(stream) => stream.Type === "Subtitle" && !!stream.DeliveryUrl
|
|
||||||
).map((s) => ({
|
|
||||||
name: s.DisplayTitle! + " [External]",
|
|
||||||
index: s.Index!,
|
|
||||||
deliveryUrl: s.DeliveryUrl,
|
|
||||||
})) || [];
|
|
||||||
|
|
||||||
// Combine embedded subs with external subs only if not offline
|
|
||||||
if (!offline) {
|
|
||||||
return [...embeddedSubs, ...externalSubs] as (
|
|
||||||
| EmbeddedSubtitle
|
|
||||||
| ExternalSubtitle
|
|
||||||
)[];
|
|
||||||
}
|
|
||||||
return embeddedSubs as EmbeddedSubtitle[];
|
|
||||||
}, [item, isVideoLoaded, subtitleTracks, mediaSource?.MediaStreams, offline]);
|
|
||||||
|
|
||||||
const { subtitleIndex, audioIndex } = useLocalSearchParams<{
|
|
||||||
itemId: string;
|
|
||||||
audioIndex: string;
|
|
||||||
subtitleIndex: string;
|
|
||||||
mediaSourceId: string;
|
|
||||||
bitrateValue: string;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const [selectedSubtitleIndex, setSelectedSubtitleIndex] = useState<Number>(
|
|
||||||
parseInt(subtitleIndex)
|
|
||||||
);
|
|
||||||
const [selectedAudioIndex, setSelectedAudioIndex] = useState<Number>(
|
|
||||||
parseInt(audioIndex)
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
zIndex: 1000,
|
|
||||||
opacity: showControls ? 1 : 0,
|
|
||||||
}}
|
|
||||||
className="p-4"
|
|
||||||
>
|
|
||||||
<DropdownMenu.Root>
|
|
||||||
<DropdownMenu.Trigger>
|
|
||||||
<TouchableOpacity className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2">
|
|
||||||
<Ionicons name="ellipsis-horizontal" size={24} color={"white"} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
loop={true}
|
|
||||||
side="bottom"
|
|
||||||
align="start"
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={8}
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Sub>
|
|
||||||
<DropdownMenu.SubTrigger key="subtitle-trigger">
|
|
||||||
Subtitle
|
|
||||||
</DropdownMenu.SubTrigger>
|
|
||||||
<DropdownMenu.SubContent
|
|
||||||
alignOffset={-10}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={0}
|
|
||||||
loop={true}
|
|
||||||
sideOffset={10}
|
|
||||||
>
|
|
||||||
{allSubtitleTracksForDirectPlay?.map((sub, idx: number) => (
|
|
||||||
<DropdownMenu.CheckboxItem
|
|
||||||
key={`subtitle-item-${idx}`}
|
|
||||||
value={selectedSubtitleIndex === sub.index}
|
|
||||||
onValueChange={() => {
|
|
||||||
if ("deliveryUrl" in sub && sub.deliveryUrl) {
|
|
||||||
setSubtitleURL &&
|
|
||||||
setSubtitleURL(
|
|
||||||
api?.basePath + sub.deliveryUrl,
|
|
||||||
sub.name
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
"Set external subtitle: ",
|
|
||||||
api?.basePath + sub.deliveryUrl
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
console.log("Set sub index: ", sub.index);
|
|
||||||
setSubtitleTrack && setSubtitleTrack(sub.index);
|
|
||||||
}
|
|
||||||
|
|
||||||
setSelectedSubtitleIndex(sub.index);
|
|
||||||
console.log("Subtitle: ", sub);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle key={`subtitle-item-title-${idx}`}>
|
|
||||||
{sub.name}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.CheckboxItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenu.SubContent>
|
|
||||||
</DropdownMenu.Sub>
|
|
||||||
<DropdownMenu.Sub>
|
|
||||||
<DropdownMenu.SubTrigger key="audio-trigger">
|
|
||||||
Audio
|
|
||||||
</DropdownMenu.SubTrigger>
|
|
||||||
<DropdownMenu.SubContent
|
|
||||||
alignOffset={-10}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={0}
|
|
||||||
loop={true}
|
|
||||||
sideOffset={10}
|
|
||||||
>
|
|
||||||
{audioTracks?.map((track, idx: number) => (
|
|
||||||
<DropdownMenu.CheckboxItem
|
|
||||||
key={`audio-item-${idx}`}
|
|
||||||
value={selectedAudioIndex === track.index}
|
|
||||||
onValueChange={() => {
|
|
||||||
setSelectedAudioIndex(track.index);
|
|
||||||
setAudioTrack && setAudioTrack(track.index);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>
|
|
||||||
{track.name}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.CheckboxItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenu.SubContent>
|
|
||||||
</DropdownMenu.Sub>
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DropdownViewDirect;
|
|
||||||
@@ -1,272 +0,0 @@
|
|||||||
import React, { useCallback, useMemo, useState } from "react";
|
|
||||||
import { View, TouchableOpacity } from "react-native";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
|
||||||
import { useControlContext } from "../contexts/ControlContext";
|
|
||||||
import { useVideoContext } from "../contexts/VideoContext";
|
|
||||||
import { TranscodedSubtitle } from "../types";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
|
||||||
|
|
||||||
interface DropdownViewProps {
|
|
||||||
showControls: boolean;
|
|
||||||
offline?: boolean; // used to disable external subs for downloads
|
|
||||||
}
|
|
||||||
|
|
||||||
const DropdownView: React.FC<DropdownViewProps> = ({
|
|
||||||
showControls,
|
|
||||||
offline = false,
|
|
||||||
}) => {
|
|
||||||
const router = useRouter();
|
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const ControlContext = useControlContext();
|
|
||||||
const mediaSource = ControlContext?.mediaSource;
|
|
||||||
const item = ControlContext?.item;
|
|
||||||
const isVideoLoaded = ControlContext?.isVideoLoaded;
|
|
||||||
|
|
||||||
const videoContext = useVideoContext();
|
|
||||||
const { subtitleTracks, setSubtitleTrack } = videoContext;
|
|
||||||
|
|
||||||
const { subtitleIndex, audioIndex, bitrateValue } = useLocalSearchParams<{
|
|
||||||
itemId: string;
|
|
||||||
audioIndex: string;
|
|
||||||
subtitleIndex: string;
|
|
||||||
mediaSourceId: string;
|
|
||||||
bitrateValue: string;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
// Either its on a text subtitle or its on not on any subtitle therefore it should show all the embedded HLS subtitles.
|
|
||||||
const isOnTextSubtitle =
|
|
||||||
mediaSource?.MediaStreams?.find(
|
|
||||||
(x) => x.Index === parseInt(subtitleIndex) && x.IsTextSubtitleStream
|
|
||||||
) || subtitleIndex === "-1";
|
|
||||||
|
|
||||||
const allSubs =
|
|
||||||
mediaSource?.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [];
|
|
||||||
const textBasedSubs = allSubs.filter((x) => x.IsTextSubtitleStream);
|
|
||||||
|
|
||||||
// This is used in the case where it is transcoding stream.
|
|
||||||
const chosenSubtitle = textBasedSubs.find(
|
|
||||||
(x) => x.Index === parseInt(subtitleIndex)
|
|
||||||
);
|
|
||||||
|
|
||||||
let initialSubtitleIndex = -1;
|
|
||||||
if (!isOnTextSubtitle) {
|
|
||||||
initialSubtitleIndex = parseInt(subtitleIndex);
|
|
||||||
} else if (chosenSubtitle) {
|
|
||||||
initialSubtitleIndex = textBasedSubs.indexOf(chosenSubtitle);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [selectedSubtitleIndex, setSelectedSubtitleIndex] =
|
|
||||||
useState<number>(initialSubtitleIndex);
|
|
||||||
const [selectedAudioIndex, setSelectedAudioIndex] = useState<number>(
|
|
||||||
parseInt(audioIndex)
|
|
||||||
);
|
|
||||||
|
|
||||||
const allSubtitleTracksForTranscodingStream = useMemo(() => {
|
|
||||||
const disableSubtitle = {
|
|
||||||
name: "Disable",
|
|
||||||
index: -1,
|
|
||||||
IsTextSubtitleStream: true,
|
|
||||||
} as TranscodedSubtitle;
|
|
||||||
if (isOnTextSubtitle) {
|
|
||||||
const textSubtitles =
|
|
||||||
subtitleTracks?.map((s) => ({
|
|
||||||
name: s.name,
|
|
||||||
index: s.index,
|
|
||||||
IsTextSubtitleStream: true,
|
|
||||||
})) || [];
|
|
||||||
|
|
||||||
const imageSubtitles = allSubs
|
|
||||||
.filter((x) => !x.IsTextSubtitleStream)
|
|
||||||
.map(
|
|
||||||
(x) =>
|
|
||||||
({
|
|
||||||
name: x.DisplayTitle!,
|
|
||||||
index: x.Index!,
|
|
||||||
IsTextSubtitleStream: x.IsTextSubtitleStream,
|
|
||||||
} as TranscodedSubtitle)
|
|
||||||
);
|
|
||||||
|
|
||||||
const textSubtitlesMap = new Map(textSubtitles.map((s) => [s.name, s]));
|
|
||||||
const imageSubtitlesMap = new Map(imageSubtitles.map((s) => [s.name, s]));
|
|
||||||
|
|
||||||
const sortedSubtitles = Array.from(
|
|
||||||
new Set(
|
|
||||||
allSubs
|
|
||||||
.map((sub) => {
|
|
||||||
const displayTitle = sub.DisplayTitle ?? "";
|
|
||||||
if (textSubtitlesMap.has(displayTitle)) {
|
|
||||||
return textSubtitlesMap.get(displayTitle);
|
|
||||||
}
|
|
||||||
if (imageSubtitlesMap.has(displayTitle)) {
|
|
||||||
return imageSubtitlesMap.get(displayTitle);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})
|
|
||||||
.filter(
|
|
||||||
(subtitle): subtitle is TranscodedSubtitle => subtitle !== null
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
return [disableSubtitle, ...sortedSubtitles];
|
|
||||||
}
|
|
||||||
|
|
||||||
const transcodedSubtitle: TranscodedSubtitle[] = allSubs.map((x) => ({
|
|
||||||
name: x.DisplayTitle!,
|
|
||||||
index: x.Index!,
|
|
||||||
IsTextSubtitleStream: x.IsTextSubtitleStream!,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return [disableSubtitle, ...transcodedSubtitle];
|
|
||||||
}, [item, isVideoLoaded, subtitleTracks, mediaSource?.MediaStreams]);
|
|
||||||
|
|
||||||
const ChangeTranscodingSubtitle = useCallback(
|
|
||||||
(subtitleIndex: number) => {
|
|
||||||
const queryParams = new URLSearchParams({
|
|
||||||
itemId: item.Id ?? "", // Ensure itemId is a string
|
|
||||||
audioIndex: audioIndex?.toString() ?? "",
|
|
||||||
subtitleIndex: subtitleIndex?.toString() ?? "",
|
|
||||||
mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string
|
|
||||||
bitrateValue: bitrateValue,
|
|
||||||
}).toString();
|
|
||||||
|
|
||||||
// @ts-expect-error
|
|
||||||
router.replace(`player/transcoding-player?${queryParams}`);
|
|
||||||
},
|
|
||||||
[mediaSource]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Audio tracks for transcoding streams.
|
|
||||||
const allAudio =
|
|
||||||
mediaSource?.MediaStreams?.filter((x) => x.Type === "Audio").map((x) => ({
|
|
||||||
name: x.DisplayTitle!,
|
|
||||||
index: x.Index!,
|
|
||||||
})) || [];
|
|
||||||
const ChangeTranscodingAudio = useCallback(
|
|
||||||
(audioIndex: number, currentSelectedSubtitleIndex: number) => {
|
|
||||||
let newSubtitleIndex: number;
|
|
||||||
|
|
||||||
if (!isOnTextSubtitle) {
|
|
||||||
newSubtitleIndex = parseInt(subtitleIndex);
|
|
||||||
} else if (
|
|
||||||
currentSelectedSubtitleIndex >= 0 &&
|
|
||||||
currentSelectedSubtitleIndex < textBasedSubs.length
|
|
||||||
) {
|
|
||||||
console.log("setHere SubtitleIndex", currentSelectedSubtitleIndex);
|
|
||||||
newSubtitleIndex = textBasedSubs[currentSelectedSubtitleIndex].Index!;
|
|
||||||
console.log("newSubtitleIndex", newSubtitleIndex);
|
|
||||||
} else {
|
|
||||||
newSubtitleIndex = -1;
|
|
||||||
}
|
|
||||||
const queryParams = new URLSearchParams({
|
|
||||||
itemId: item.Id ?? "", // Ensure itemId is a string
|
|
||||||
audioIndex: audioIndex?.toString() ?? "",
|
|
||||||
subtitleIndex: newSubtitleIndex?.toString() ?? "",
|
|
||||||
mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string
|
|
||||||
bitrateValue: bitrateValue,
|
|
||||||
}).toString();
|
|
||||||
|
|
||||||
// @ts-expect-error
|
|
||||||
router.replace(`player/transcoding-player?${queryParams}`);
|
|
||||||
},
|
|
||||||
[mediaSource]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
zIndex: 1000,
|
|
||||||
opacity: showControls ? 1 : 0,
|
|
||||||
}}
|
|
||||||
className="p-4"
|
|
||||||
>
|
|
||||||
<DropdownMenu.Root>
|
|
||||||
<DropdownMenu.Trigger>
|
|
||||||
<TouchableOpacity className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2">
|
|
||||||
<Ionicons name="ellipsis-horizontal" size={24} color={"white"} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
loop={true}
|
|
||||||
side="bottom"
|
|
||||||
align="start"
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={8}
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Sub>
|
|
||||||
<DropdownMenu.SubTrigger key="subtitle-trigger">
|
|
||||||
Subtitle
|
|
||||||
</DropdownMenu.SubTrigger>
|
|
||||||
<DropdownMenu.SubContent
|
|
||||||
alignOffset={-10}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={0}
|
|
||||||
loop={true}
|
|
||||||
sideOffset={10}
|
|
||||||
>
|
|
||||||
{allSubtitleTracksForTranscodingStream?.map(
|
|
||||||
(sub, idx: number) => (
|
|
||||||
<DropdownMenu.CheckboxItem
|
|
||||||
value={selectedSubtitleIndex === sub.index}
|
|
||||||
key={`subtitle-item-${idx}`}
|
|
||||||
onValueChange={() => {
|
|
||||||
console.log("sub", sub);
|
|
||||||
if (selectedSubtitleIndex === sub?.index) return;
|
|
||||||
setSelectedSubtitleIndex(sub.index);
|
|
||||||
if (sub.IsTextSubtitleStream && isOnTextSubtitle) {
|
|
||||||
setSubtitleTrack && setSubtitleTrack(sub.index);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ChangeTranscodingSubtitle(sub.index);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle key={`subtitle-item-title-${idx}`}>
|
|
||||||
{sub.name}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.CheckboxItem>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</DropdownMenu.SubContent>
|
|
||||||
</DropdownMenu.Sub>
|
|
||||||
<DropdownMenu.Sub>
|
|
||||||
<DropdownMenu.SubTrigger key="audio-trigger">
|
|
||||||
Audio
|
|
||||||
</DropdownMenu.SubTrigger>
|
|
||||||
<DropdownMenu.SubContent
|
|
||||||
alignOffset={-10}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={0}
|
|
||||||
loop={true}
|
|
||||||
sideOffset={10}
|
|
||||||
>
|
|
||||||
{allAudio?.map((track, idx: number) => (
|
|
||||||
<DropdownMenu.CheckboxItem
|
|
||||||
key={`audio-item-${idx}`}
|
|
||||||
value={selectedAudioIndex === track.index}
|
|
||||||
onValueChange={() => {
|
|
||||||
if (selectedAudioIndex === track.index) return;
|
|
||||||
setSelectedAudioIndex(track.index);
|
|
||||||
ChangeTranscodingAudio(track.index, selectedSubtitleIndex);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>
|
|
||||||
{track.name}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.CheckboxItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenu.SubContent>
|
|
||||||
</DropdownMenu.Sub>
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DropdownView;
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
type EmbeddedSubtitle = {
|
|
||||||
name: string;
|
|
||||||
index: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ExternalSubtitle = {
|
|
||||||
name: string;
|
|
||||||
index: number;
|
|
||||||
isExternal: boolean;
|
|
||||||
deliveryUrl: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type TranscodedSubtitle = {
|
|
||||||
name: string;
|
|
||||||
index: number;
|
|
||||||
IsTextSubtitleStream: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export { EmbeddedSubtitle, ExternalSubtitle, TranscodedSubtitle };
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
import {
|
|
||||||
TrackInfo,
|
|
||||||
VlcPlayerViewRef,
|
|
||||||
} from "@/modules/vlc-player/src/VlcPlayer.types";
|
|
||||||
import React, { useEffect, useState } from "react";
|
|
||||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { Text } from "../common/Text";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
|
||||||
playerRef: React.RefObject<VlcPlayerViewRef>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const VideoDebugInfo: React.FC<Props> = ({ playerRef, ...props }) => {
|
|
||||||
const [audioTracks, setAudioTracks] = useState<TrackInfo[] | null>(null);
|
|
||||||
const [subtitleTracks, setSubtitleTracks] = useState<TrackInfo[] | null>(
|
|
||||||
null
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchTracks = async () => {
|
|
||||||
if (playerRef.current) {
|
|
||||||
const audio = await playerRef.current.getAudioTracks();
|
|
||||||
const subtitles = await playerRef.current.getSubtitleTracks();
|
|
||||||
setAudioTracks(audio);
|
|
||||||
setSubtitleTracks(subtitles);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchTracks();
|
|
||||||
}, [playerRef]);
|
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: insets.top,
|
|
||||||
left: insets.left + 8,
|
|
||||||
zIndex: 100,
|
|
||||||
}}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<Text className="font-bold">Playback State:</Text>
|
|
||||||
<Text className="font-bold mt-2.5">Audio Tracks:</Text>
|
|
||||||
{audioTracks &&
|
|
||||||
audioTracks.map((track, index) => (
|
|
||||||
<Text key={index}>
|
|
||||||
{track.name} (Index: {track.index})
|
|
||||||
</Text>
|
|
||||||
))}
|
|
||||||
<Text className="font-bold mt-2.5">Subtitle Tracks:</Text>
|
|
||||||
{subtitleTracks &&
|
|
||||||
subtitleTracks.map((track, index) => (
|
|
||||||
<Text key={index}>
|
|
||||||
{track.name} (Index: {track.index})
|
|
||||||
</Text>
|
|
||||||
))}
|
|
||||||
<TouchableOpacity
|
|
||||||
className="mt-2.5 bg-blue-500 p-2 rounded"
|
|
||||||
onPress={() => {
|
|
||||||
if (playerRef.current) {
|
|
||||||
playerRef.current.getAudioTracks().then(setAudioTracks);
|
|
||||||
playerRef.current.getSubtitleTracks().then(setSubtitleTracks);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text className="text-white text-center">Refresh Tracks</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
4
eas.json
4
eas.json
@@ -22,13 +22,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"channel": "0.21.0",
|
"channel": "0.17.0",
|
||||||
"android": {
|
"android": {
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production-apk": {
|
"production-apk": {
|
||||||
"channel": "0.21.0",
|
"channel": "0.17.0",
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
--- expo.js.original 2024-11-10 09:08:19
|
|
||||||
+++ node_modules/react-native-edge-to-edge/dist/commonjs/expo.js 2024-11-10 09:08:23
|
|
||||||
@@ -19,10 +19,8 @@
|
|
||||||
const {
|
|
||||||
barStyle
|
|
||||||
} = androidStatusBar;
|
|
||||||
+ const android = props?.android || {};
|
|
||||||
const {
|
|
||||||
- android = {}
|
|
||||||
- } = props;
|
|
||||||
- const {
|
|
||||||
parentTheme = "Default"
|
|
||||||
} = android;
|
|
||||||
config.modResults.resources.style = config.modResults.resources.style?.map(style => {
|
|
||||||
\ No newline at end of file
|
|
||||||
@@ -18,6 +18,7 @@ export const useAdjacentItems = ({ item }: AdjacentEpisodesProps) => {
|
|||||||
const parentId = item?.AlbumId || item?.ParentId;
|
const parentId = item?.AlbumId || item?.ParentId;
|
||||||
const indexNumber = item?.IndexNumber;
|
const indexNumber = item?.IndexNumber;
|
||||||
|
|
||||||
|
console.log("Getting previous item for " + indexNumber);
|
||||||
if (
|
if (
|
||||||
!api ||
|
!api ||
|
||||||
!parentId ||
|
!parentId ||
|
||||||
@@ -25,6 +26,12 @@ export const useAdjacentItems = ({ item }: AdjacentEpisodesProps) => {
|
|||||||
indexNumber === null ||
|
indexNumber === null ||
|
||||||
indexNumber - 1 < 1
|
indexNumber - 1 < 1
|
||||||
) {
|
) {
|
||||||
|
console.log("No previous item", {
|
||||||
|
itemIndex: indexNumber,
|
||||||
|
itemId: item?.Id,
|
||||||
|
parentId: parentId,
|
||||||
|
indexNumber: indexNumber,
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
17
hooks/useAndroidNavigationBar.ts
Normal file
17
hooks/useAndroidNavigationBar.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import * as NavigationBar from "expo-navigation-bar";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
|
export const useAndroidNavigationBar = () => {
|
||||||
|
useEffect(() => {
|
||||||
|
if (Platform.OS === "android") {
|
||||||
|
NavigationBar.setVisibilityAsync("hidden");
|
||||||
|
NavigationBar.setBehaviorAsync("overlay-swipe");
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
NavigationBar.setVisibilityAsync("visible");
|
||||||
|
NavigationBar.setBehaviorAsync("inset-swipe");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
};
|
||||||
@@ -4,7 +4,6 @@ import { useAtom } from "jotai";
|
|||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
import { msToSeconds, secondsToMs } from "@/utils/time";
|
|
||||||
|
|
||||||
interface CreditTimestamps {
|
interface CreditTimestamps {
|
||||||
Introduction: {
|
Introduction: {
|
||||||
@@ -22,29 +21,16 @@ interface CreditTimestamps {
|
|||||||
export const useCreditSkipper = (
|
export const useCreditSkipper = (
|
||||||
itemId: string | undefined,
|
itemId: string | undefined,
|
||||||
currentTime: number,
|
currentTime: number,
|
||||||
seek: (time: number) => void,
|
videoRef: React.RefObject<any>
|
||||||
play: () => void,
|
|
||||||
isVlc: boolean = false
|
|
||||||
) => {
|
) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [showSkipCreditButton, setShowSkipCreditButton] = useState(false);
|
const [showSkipCreditButton, setShowSkipCreditButton] = useState(false);
|
||||||
|
|
||||||
if (isVlc) {
|
|
||||||
currentTime = msToSeconds(currentTime);
|
|
||||||
}
|
|
||||||
|
|
||||||
const wrappedSeek = (seconds: number) => {
|
|
||||||
if (isVlc) {
|
|
||||||
seek(secondsToMs(seconds));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
seek(seconds);
|
|
||||||
};
|
|
||||||
|
|
||||||
const { data: creditTimestamps } = useQuery<CreditTimestamps | null>({
|
const { data: creditTimestamps } = useQuery<CreditTimestamps | null>({
|
||||||
queryKey: ["creditTimestamps", itemId],
|
queryKey: ["creditTimestamps", itemId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!itemId) {
|
if (!itemId) {
|
||||||
|
console.log("No item id");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,17 +61,17 @@ export const useCreditSkipper = (
|
|||||||
}, [creditTimestamps, currentTime]);
|
}, [creditTimestamps, currentTime]);
|
||||||
|
|
||||||
const skipCredit = useCallback(() => {
|
const skipCredit = useCallback(() => {
|
||||||
if (!creditTimestamps) return;
|
console.log("skipCredits");
|
||||||
console.log(`Skipping credits to ${creditTimestamps.Credits.End}`);
|
if (!creditTimestamps || !videoRef.current) return;
|
||||||
try {
|
try {
|
||||||
wrappedSeek(creditTimestamps.Credits.End);
|
videoRef.current.seek(creditTimestamps.Credits.End);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
play();
|
videoRef.current?.resume();
|
||||||
}, 200);
|
}, 200);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
writeToLog("ERROR", "Error skipping intro", error);
|
writeToLog("ERROR", "Error skipping intro", error);
|
||||||
}
|
}
|
||||||
}, [creditTimestamps]);
|
}, [creditTimestamps, videoRef]);
|
||||||
|
|
||||||
return { showSkipCreditButton, skipCredit };
|
return { showSkipCreditButton, skipCredit };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
import { Bitrate, BITRATES } from "@/components/BitrateSelector";
|
|
||||||
import { Settings } from "@/utils/atoms/settings";
|
|
||||||
import {
|
|
||||||
BaseItemDto,
|
|
||||||
MediaSourceInfo,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
|
|
||||||
const useDefaultPlaySettings = (
|
|
||||||
item: BaseItemDto,
|
|
||||||
settings: Settings | null
|
|
||||||
) => {
|
|
||||||
const playSettings = useMemo(() => {
|
|
||||||
// 1. Get first media source
|
|
||||||
const mediaSource = item.MediaSources?.[0];
|
|
||||||
|
|
||||||
// 2. Get default or preferred audio
|
|
||||||
const defaultAudioIndex = mediaSource?.DefaultAudioStreamIndex;
|
|
||||||
const preferedAudioIndex = mediaSource?.MediaStreams?.find(
|
|
||||||
(x) => x.Language === settings?.defaultAudioLanguage
|
|
||||||
)?.Index;
|
|
||||||
const firstAudioIndex = mediaSource?.MediaStreams?.find(
|
|
||||||
(x) => x.Type === "Audio"
|
|
||||||
)?.Index;
|
|
||||||
|
|
||||||
// 3. Get default or preferred subtitle
|
|
||||||
const preferedSubtitleIndex = mediaSource?.MediaStreams?.find(
|
|
||||||
(x) => x.Language === settings?.defaultSubtitleLanguage?.value
|
|
||||||
)?.Index;
|
|
||||||
const defaultSubtitleIndex = mediaSource?.MediaStreams?.find(
|
|
||||||
(stream) => stream.Type === "Subtitle" && stream.IsDefault
|
|
||||||
)?.Index;
|
|
||||||
|
|
||||||
// 4. Get default bitrate
|
|
||||||
const bitrate = BITRATES[0];
|
|
||||||
|
|
||||||
return {
|
|
||||||
defaultAudioIndex:
|
|
||||||
preferedAudioIndex || defaultAudioIndex || firstAudioIndex || undefined,
|
|
||||||
defaultSubtitleIndex:
|
|
||||||
preferedSubtitleIndex || defaultSubtitleIndex || undefined,
|
|
||||||
defaultMediaSource: mediaSource || undefined,
|
|
||||||
defaultBitrate: bitrate || undefined,
|
|
||||||
};
|
|
||||||
}, [
|
|
||||||
item.MediaSources,
|
|
||||||
settings?.defaultAudioLanguage,
|
|
||||||
settings?.defaultSubtitleLanguage,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return playSettings;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default useDefaultPlaySettings;
|
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// hooks/useFileOpener.ts
|
||||||
|
|
||||||
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
|
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
@@ -5,44 +7,46 @@ import * as FileSystem from "expo-file-system";
|
|||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
|
|
||||||
export const getDownloadedFileUrl = async (itemId: string): Promise<string> => {
|
export const useFileOpener = () => {
|
||||||
const directory = FileSystem.documentDirectory;
|
|
||||||
|
|
||||||
if (!directory) {
|
|
||||||
throw new Error("Document directory is not available");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!itemId) {
|
|
||||||
throw new Error("Item ID is not available");
|
|
||||||
}
|
|
||||||
|
|
||||||
const files = await FileSystem.readDirectoryAsync(directory);
|
|
||||||
const path = itemId!;
|
|
||||||
const matchingFile = files.find((file) => file.startsWith(path));
|
|
||||||
|
|
||||||
if (!matchingFile) {
|
|
||||||
throw new Error(`No file found for item ${path}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${directory}${matchingFile}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useDownloadedFileOpener = () => {
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { setPlayUrl, setOfflineSettings } = usePlaySettings();
|
const { setPlayUrl, setOfflineSettings } = usePlaySettings();
|
||||||
|
|
||||||
const openFile = useCallback(
|
const openFile = useCallback(async (item: BaseItemDto) => {
|
||||||
async (item: BaseItemDto) => {
|
const directory = FileSystem.documentDirectory;
|
||||||
try {
|
|
||||||
// @ts-expect-error
|
if (!directory) {
|
||||||
router.push("/player/direct-player?offline=true&itemId=" + item.Id);
|
throw new Error("Document directory is not available");
|
||||||
} catch (error) {
|
}
|
||||||
writeToLog("ERROR", "Error opening file", error);
|
|
||||||
console.error("Error opening file:", error);
|
if (!item.Id) {
|
||||||
|
throw new Error("Item ID is not available");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const files = await FileSystem.readDirectoryAsync(directory);
|
||||||
|
for (let f of files) {
|
||||||
|
console.log(f);
|
||||||
}
|
}
|
||||||
},
|
const path = item.Id!;
|
||||||
[setOfflineSettings, setPlayUrl, router]
|
const matchingFile = files.find((file) => file.startsWith(path));
|
||||||
);
|
|
||||||
|
if (!matchingFile) {
|
||||||
|
throw new Error(`No file found for item ${path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${directory}${matchingFile}`;
|
||||||
|
|
||||||
|
setOfflineSettings({
|
||||||
|
item,
|
||||||
|
});
|
||||||
|
setPlayUrl(url);
|
||||||
|
|
||||||
|
router.push("/play-offline-video");
|
||||||
|
} catch (error) {
|
||||||
|
writeToLog("ERROR", "Error opening file", error);
|
||||||
|
console.error("Error opening file:", error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
return { openFile };
|
return { openFile };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export const useImageColors = ({
|
|||||||
|
|
||||||
// If colors are cached, use them and exit
|
// If colors are cached, use them and exit
|
||||||
if (_primary && _text) {
|
if (_primary && _text) {
|
||||||
|
console.info("useImageColors ~ Using cached colors for performance.");
|
||||||
setPrimaryColor({
|
setPrimaryColor({
|
||||||
primary: _primary,
|
primary: _primary,
|
||||||
text: _text,
|
text: _text,
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
|
import * as FileSystem from "expo-file-system";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
import { useCallback } from "react";
|
|
||||||
|
|
||||||
const useImageStorage = () => {
|
const useImageStorage = () => {
|
||||||
const saveBase64Image = useCallback(async (base64: string, key: string) => {
|
const saveBase64Image = useCallback(async (base64: string, key: string) => {
|
||||||
try {
|
try {
|
||||||
// Save the base64 string to storage
|
// Save the base64 string to AsyncStorage
|
||||||
storage.set(key, base64);
|
storage.set(key, base64);
|
||||||
|
console.log("Image saved successfully");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error saving image:", error);
|
console.error("Error saving image:", error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -67,7 +70,7 @@ const useImageStorage = () => {
|
|||||||
|
|
||||||
const loadImage = useCallback(async (key: string) => {
|
const loadImage = useCallback(async (key: string) => {
|
||||||
try {
|
try {
|
||||||
// Retrieve the base64 string from storage
|
// Retrieve the base64 string from AsyncStorage
|
||||||
const base64Image = storage.getString(key);
|
const base64Image = storage.getString(key);
|
||||||
if (base64Image !== null) {
|
if (base64Image !== null) {
|
||||||
// Set the loaded image state
|
// Set the loaded image state
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { useAtom } from "jotai";
|
|||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
import { msToSeconds, secondsToMs } from "@/utils/time";
|
|
||||||
|
|
||||||
interface IntroTimestamps {
|
interface IntroTimestamps {
|
||||||
EpisodeId: string;
|
EpisodeId: string;
|
||||||
@@ -15,36 +14,19 @@ interface IntroTimestamps {
|
|||||||
Valid: boolean;
|
Valid: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom hook to handle skipping intros in a media player.
|
|
||||||
*
|
|
||||||
* @param {number} currentTime - The current playback time in seconds.
|
|
||||||
*/
|
|
||||||
export const useIntroSkipper = (
|
export const useIntroSkipper = (
|
||||||
itemId: string | undefined,
|
itemId: string | undefined,
|
||||||
currentTime: number,
|
currentTime: number,
|
||||||
seek: (ticks: number) => void,
|
videoRef: React.RefObject<any>
|
||||||
play: () => void,
|
|
||||||
isVlc: boolean = false
|
|
||||||
) => {
|
) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [showSkipButton, setShowSkipButton] = useState(false);
|
const [showSkipButton, setShowSkipButton] = useState(false);
|
||||||
if (isVlc) {
|
|
||||||
currentTime = msToSeconds(currentTime);
|
|
||||||
}
|
|
||||||
|
|
||||||
const wrappedSeek = (seconds: number) => {
|
|
||||||
if (isVlc) {
|
|
||||||
seek(secondsToMs(seconds));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
seek(seconds);
|
|
||||||
};
|
|
||||||
|
|
||||||
const { data: introTimestamps } = useQuery<IntroTimestamps | null>({
|
const { data: introTimestamps } = useQuery<IntroTimestamps | null>({
|
||||||
queryKey: ["introTimestamps", itemId],
|
queryKey: ["introTimestamps", itemId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!itemId) {
|
if (!itemId) {
|
||||||
|
console.log("No item id");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,16 +58,16 @@ export const useIntroSkipper = (
|
|||||||
|
|
||||||
const skipIntro = useCallback(() => {
|
const skipIntro = useCallback(() => {
|
||||||
console.log("skipIntro");
|
console.log("skipIntro");
|
||||||
if (!introTimestamps) return;
|
if (!introTimestamps || !videoRef.current) return;
|
||||||
try {
|
try {
|
||||||
wrappedSeek(introTimestamps.IntroEnd);
|
videoRef.current.seek(introTimestamps.IntroEnd);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
play();
|
videoRef.current?.resume();
|
||||||
}, 200);
|
}, 200);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
writeToLog("ERROR", "Error skipping intro", error);
|
writeToLog("ERROR", "Error skipping intro", error);
|
||||||
}
|
}
|
||||||
}, [introTimestamps]);
|
}, [introTimestamps, videoRef]);
|
||||||
|
|
||||||
return { showSkipButton, skipIntro };
|
return { showSkipButton, skipIntro };
|
||||||
};
|
};
|
||||||
|
|||||||
27
hooks/useNavigationBarVisibility.ts
Normal file
27
hooks/useNavigationBarVisibility.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// hooks/useNavigationBarVisibility.ts
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
import * as NavigationBar from "expo-navigation-bar";
|
||||||
|
|
||||||
|
export const useNavigationBarVisibility = (isPlaying: boolean | null) => {
|
||||||
|
useEffect(() => {
|
||||||
|
const handleVisibility = async () => {
|
||||||
|
if (Platform.OS === "android") {
|
||||||
|
if (isPlaying) {
|
||||||
|
await NavigationBar.setVisibilityAsync("hidden");
|
||||||
|
} else {
|
||||||
|
await NavigationBar.setVisibilityAsync("visible");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleVisibility();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (Platform.OS === "android") {
|
||||||
|
NavigationBar.setVisibilityAsync("visible");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [isPlaying]);
|
||||||
|
};
|
||||||
@@ -24,5 +24,5 @@ export const useOrientation = () => {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { orientation, setOrientation };
|
return { orientation };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
import { useDownload } from "@/providers/DownloadProvider";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { getItemImage } from "@/utils/getItemImage";
|
|
||||||
import { writeToLog } from "@/utils/log";
|
|
||||||
import {
|
|
||||||
BaseItemDto,
|
|
||||||
MediaSourceInfo,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import * as FileSystem from "expo-file-system";
|
|
||||||
import { useRouter } from "expo-router";
|
|
||||||
import { FFmpegKit, FFmpegKitConfig } from "ffmpeg-kit-react-native";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
|
import * as FileSystem from "expo-file-system";
|
||||||
|
import { FFmpegKit, FFmpegKitConfig } from "ffmpeg-kit-react-native";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { writeToLog } from "@/utils/log";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { JobStatus } from "@/utils/optimize-server";
|
||||||
import useImageStorage from "./useImageStorage";
|
import useImageStorage from "./useImageStorage";
|
||||||
|
import { getItemImage } from "@/utils/getItemImage";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom hook for remuxing HLS to MP4 using FFmpeg.
|
* Custom hook for remuxing HLS to MP4 using FFmpeg.
|
||||||
@@ -22,16 +21,23 @@ import useImageStorage from "./useImageStorage";
|
|||||||
* @param item - The BaseItemDto object representing the media item
|
* @param item - The BaseItemDto object representing the media item
|
||||||
* @returns An object with remuxing-related functions
|
* @returns An object with remuxing-related functions
|
||||||
*/
|
*/
|
||||||
export const useRemuxHlsToMp4 = () => {
|
export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { saveDownloadedItemInfo, setProcesses } = useDownload();
|
const { saveDownloadedItemInfo, setProcesses } = useDownload();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { saveImage } = useImageStorage();
|
const { loadImage, saveImage, image2Base64, saveBase64Image } =
|
||||||
|
useImageStorage();
|
||||||
|
|
||||||
|
if (!item.Id || !item.Name) {
|
||||||
|
writeToLog("ERROR", "useRemuxHlsToMp4 ~ missing arguments");
|
||||||
|
throw new Error("Item must have an Id and Name");
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = `${FileSystem.documentDirectory}${item.Id}.mp4`;
|
||||||
|
|
||||||
const startRemuxing = useCallback(
|
const startRemuxing = useCallback(
|
||||||
async (item: BaseItemDto, url: string, mediaSource: MediaSourceInfo) => {
|
async (url: string) => {
|
||||||
const output = `${FileSystem.documentDirectory}${item.Id}.mp4`;
|
|
||||||
if (!api) throw new Error("API is not defined");
|
if (!api) throw new Error("API is not defined");
|
||||||
if (!item.Id) throw new Error("Item must have an Id");
|
if (!item.Id) throw new Error("Item must have an Id");
|
||||||
|
|
||||||
@@ -55,7 +61,7 @@ export const useRemuxHlsToMp4 = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const command = `-y -thread_queue_size 512 -protocol_whitelist file,http,https,tcp,tls,crypto -multiple_requests 1 -tcp_nodelay 1 -fflags +genpts -i ${url} -map 0:v -map 0:a -c copy -bufsize 50M -max_muxing_queue_size 4096 ${output}`;
|
const command = `-y -loglevel quiet -thread_queue_size 512 -protocol_whitelist file,http,https,tcp,tls,crypto -multiple_requests 1 -tcp_nodelay 1 -fflags +genpts -i ${url} -c copy -bufsize 50M -max_muxing_queue_size 4096 ${output}`;
|
||||||
|
|
||||||
writeToLog(
|
writeToLog(
|
||||||
"INFO",
|
"INFO",
|
||||||
@@ -69,13 +75,13 @@ export const useRemuxHlsToMp4 = () => {
|
|||||||
id: "",
|
id: "",
|
||||||
deviceId: "",
|
deviceId: "",
|
||||||
inputUrl: "",
|
inputUrl: "",
|
||||||
item: item,
|
item,
|
||||||
itemId: item.Id!,
|
itemId: item.Id,
|
||||||
outputPath: "",
|
outputPath: "",
|
||||||
progress: 0,
|
progress: 0,
|
||||||
status: "downloading",
|
status: "downloading",
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
},
|
} as JobStatus,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
FFmpegKitConfig.enableStatisticsCallback((statistics) => {
|
FFmpegKitConfig.enableStatisticsCallback((statistics) => {
|
||||||
@@ -111,47 +117,29 @@ export const useRemuxHlsToMp4 = () => {
|
|||||||
FFmpegKit.executeAsync(command, async (session) => {
|
FFmpegKit.executeAsync(command, async (session) => {
|
||||||
try {
|
try {
|
||||||
const returnCode = await session.getReturnCode();
|
const returnCode = await session.getReturnCode();
|
||||||
const startTime = new Date();
|
|
||||||
|
|
||||||
let endTime;
|
|
||||||
if (returnCode.isValueSuccess()) {
|
if (returnCode.isValueSuccess()) {
|
||||||
endTime = new Date();
|
|
||||||
writeToLog(
|
|
||||||
"INFO",
|
|
||||||
`useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${
|
|
||||||
item.Name
|
|
||||||
}, start time: ${startTime.toISOString()}, end time: ${endTime.toISOString()}, duration: ${
|
|
||||||
(endTime.getTime() - startTime.getTime()) / 1000
|
|
||||||
}s`
|
|
||||||
);
|
|
||||||
if (!item) throw new Error("Item is undefined");
|
if (!item) throw new Error("Item is undefined");
|
||||||
await saveDownloadedItemInfo(item);
|
await saveDownloadedItemInfo(item);
|
||||||
toast.success("Download completed");
|
toast.success("Download completed");
|
||||||
|
writeToLog(
|
||||||
|
"INFO",
|
||||||
|
`useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${item.Name}`
|
||||||
|
);
|
||||||
await queryClient.invalidateQueries({
|
await queryClient.invalidateQueries({
|
||||||
queryKey: ["downloadedItems"],
|
queryKey: ["downloadedItems"],
|
||||||
});
|
});
|
||||||
resolve();
|
resolve();
|
||||||
} else if (returnCode.isValueError()) {
|
} else if (returnCode.isValueError()) {
|
||||||
endTime = new Date();
|
|
||||||
const allLogs = session.getAllLogsAsString();
|
|
||||||
writeToLog(
|
writeToLog(
|
||||||
"ERROR",
|
"ERROR",
|
||||||
`useRemuxHlsToMp4 ~ remuxing failed for item: ${
|
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`
|
||||||
item.Name
|
|
||||||
}, start time: ${startTime.toISOString()}, end time: ${endTime.toISOString()}, duration: ${
|
|
||||||
(endTime.getTime() - startTime.getTime()) / 1000
|
|
||||||
}s. All logs: ${allLogs}`
|
|
||||||
);
|
);
|
||||||
reject(new Error("Remuxing failed"));
|
reject(new Error("Remuxing failed")); // Reject the promise on error
|
||||||
} else if (returnCode.isValueCancel()) {
|
} else if (returnCode.isValueCancel()) {
|
||||||
endTime = new Date();
|
|
||||||
writeToLog(
|
writeToLog(
|
||||||
"INFO",
|
"INFO",
|
||||||
`useRemuxHlsToMp4 ~ remuxing was canceled for item: ${
|
`useRemuxHlsToMp4 ~ remuxing was canceled for item: ${item.Name}`
|
||||||
item.Name
|
|
||||||
}, start time: ${startTime.toISOString()}, end time: ${endTime.toISOString()}, duration: ${
|
|
||||||
(endTime.getTime() - startTime.getTime()) / 1000
|
|
||||||
}s`
|
|
||||||
);
|
);
|
||||||
resolve();
|
resolve();
|
||||||
}
|
}
|
||||||
@@ -159,24 +147,16 @@ export const useRemuxHlsToMp4 = () => {
|
|||||||
setProcesses((prev) => {
|
setProcesses((prev) => {
|
||||||
return prev.filter((process) => process.itemId !== item.Id);
|
return prev.filter((process) => process.itemId !== item.Id);
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (error) {
|
||||||
const error = e as Error;
|
|
||||||
const errorLog = `Error: ${error.message}, Stack: ${error.stack}`;
|
|
||||||
writeToLog(
|
|
||||||
"ERROR",
|
|
||||||
`useRemuxHlsToMp4 ~ Exception during remuxing for item: ${item.Name}, ${errorLog}`
|
|
||||||
);
|
|
||||||
reject(error);
|
reject(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (error) {
|
||||||
const error = e as Error;
|
|
||||||
const errorLog = `Error: ${error.message}, Stack: ${error.stack}`;
|
|
||||||
console.error("Failed to remux:", error);
|
console.error("Failed to remux:", error);
|
||||||
writeToLog(
|
writeToLog(
|
||||||
"ERROR",
|
"ERROR",
|
||||||
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}, ${errorLog}`
|
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`
|
||||||
);
|
);
|
||||||
setProcesses((prev) => {
|
setProcesses((prev) => {
|
||||||
return prev.filter((process) => process.itemId !== item.Id);
|
return prev.filter((process) => process.itemId !== item.Id);
|
||||||
@@ -184,13 +164,15 @@ export const useRemuxHlsToMp4 = () => {
|
|||||||
throw error; // Re-throw the error to propagate it to the caller
|
throw error; // Re-throw the error to propagate it to the caller
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[]
|
[output, item]
|
||||||
);
|
);
|
||||||
|
|
||||||
const cancelRemuxing = useCallback(() => {
|
const cancelRemuxing = useCallback(() => {
|
||||||
FFmpegKit.cancel();
|
FFmpegKit.cancel();
|
||||||
setProcesses([]);
|
setProcesses((prev) => {
|
||||||
}, []);
|
return prev.filter((process) => process.itemId !== item.Id);
|
||||||
|
});
|
||||||
|
}, [item.Name]);
|
||||||
|
|
||||||
return { startRemuxing, cancelRemuxing };
|
return { startRemuxing, cancelRemuxing };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* useRevalidatePlaybackProgressCache invalidates queries related to playback progress.
|
|
||||||
*/
|
|
||||||
export function useInvalidatePlaybackProgressCache() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const revalidate = async () => {
|
|
||||||
// List of all the queries to invalidate
|
|
||||||
const queriesToInvalidate = [
|
|
||||||
["item"],
|
|
||||||
["resumeItems"],
|
|
||||||
["continueWatching"],
|
|
||||||
["nextUp-all"],
|
|
||||||
["nextUp"],
|
|
||||||
["episodes"],
|
|
||||||
["seasons"],
|
|
||||||
["home"],
|
|
||||||
];
|
|
||||||
|
|
||||||
// Invalidate each query
|
|
||||||
for (const queryKey of queriesToInvalidate) {
|
|
||||||
await queryClient.invalidateQueries({ queryKey });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return revalidate;
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
// hooks/useTrickplay.ts
|
// hooks/useTrickplay.ts
|
||||||
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { ticksToMs } from "@/utils/time";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
@@ -58,7 +57,6 @@ export const useTrickplay = (item: BaseItemDto, enabled = true) => {
|
|||||||
: null;
|
: null;
|
||||||
}, [item, enabled]);
|
}, [item, enabled]);
|
||||||
|
|
||||||
// Takes in ticks.
|
|
||||||
const calculateTrickplayUrl = useCallback(
|
const calculateTrickplayUrl = useCallback(
|
||||||
(progress: number) => {
|
(progress: number) => {
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
@@ -76,33 +74,28 @@ export const useTrickplay = (item: BaseItemDto, enabled = true) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { data, resolution } = trickplayInfo;
|
const { data, resolution } = trickplayInfo;
|
||||||
const { Interval, TileWidth, TileHeight, Width, Height } = data;
|
const { Interval, TileWidth, TileHeight } = data;
|
||||||
|
|
||||||
if (
|
if (!Interval || !TileWidth || !TileHeight || !resolution) {
|
||||||
!Interval ||
|
|
||||||
!TileWidth ||
|
|
||||||
!TileHeight ||
|
|
||||||
!resolution ||
|
|
||||||
!Width ||
|
|
||||||
!Height
|
|
||||||
) {
|
|
||||||
throw new Error("Invalid trickplay data");
|
throw new Error("Invalid trickplay data");
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentTimeMs = Math.max(0, ticksToMs(progress));
|
const currentSecond = Math.max(0, Math.floor(progress / 10000000));
|
||||||
const currentTile = Math.floor(currentTimeMs / Interval);
|
|
||||||
|
|
||||||
const tileSize = TileWidth * TileHeight;
|
const cols = TileWidth;
|
||||||
const tileOffset = currentTile % tileSize;
|
const rows = TileHeight;
|
||||||
const index = Math.floor(currentTile / tileSize);
|
const imagesPerTile = cols * rows;
|
||||||
|
const imageIndex = Math.floor(currentSecond / (Interval / 1000));
|
||||||
|
const tileIndex = Math.floor(imageIndex / imagesPerTile);
|
||||||
|
|
||||||
const tileOffsetX = tileOffset % TileWidth;
|
const positionInTile = imageIndex % imagesPerTile;
|
||||||
const tileOffsetY = Math.floor(tileOffset / TileWidth);
|
const rowInTile = Math.floor(positionInTile / cols);
|
||||||
|
const colInTile = positionInTile % cols;
|
||||||
|
|
||||||
const newTrickPlayUrl = {
|
const newTrickPlayUrl = {
|
||||||
x: tileOffsetX,
|
x: rowInTile,
|
||||||
y: tileOffsetY,
|
y: colInTile,
|
||||||
url: `${api.basePath}/Videos/${item.Id}/Trickplay/${resolution}/${index}.jpg?api_key=${api.accessToken}`,
|
url: `${api.basePath}/Videos/${item.Id}/Trickplay/${resolution}/${tileIndex}.jpg?api_key=${api.accessToken}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
setTrickPlayUrl(newTrickPlayUrl);
|
setTrickPlayUrl(newTrickPlayUrl);
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ interface UseWebSocketProps {
|
|||||||
pauseVideo: () => void;
|
pauseVideo: () => void;
|
||||||
playVideo: () => void;
|
playVideo: () => void;
|
||||||
stopPlayback: () => void;
|
stopPlayback: () => void;
|
||||||
offline?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useWebSocket = ({
|
export const useWebSocket = ({
|
||||||
@@ -23,7 +22,6 @@ export const useWebSocket = ({
|
|||||||
pauseVideo,
|
pauseVideo,
|
||||||
playVideo,
|
playVideo,
|
||||||
stopPlayback,
|
stopPlayback,
|
||||||
offline = false,
|
|
||||||
}: UseWebSocketProps) => {
|
}: UseWebSocketProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const user = useAtomValue(userAtom);
|
const user = useAtomValue(userAtom);
|
||||||
@@ -40,7 +38,7 @@ export const useWebSocket = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (offline || !deviceId || !api?.accessToken) return;
|
if (!deviceId || !api?.accessToken) return;
|
||||||
|
|
||||||
const protocol = api?.basePath.includes("https") ? "wss" : "ws";
|
const protocol = api?.basePath.includes("https") ? "wss" : "ws";
|
||||||
|
|
||||||
@@ -82,10 +80,10 @@ export const useWebSocket = ({
|
|||||||
}
|
}
|
||||||
newWebSocket.close();
|
newWebSocket.close();
|
||||||
};
|
};
|
||||||
}, [api, deviceId, user, offline]);
|
}, [api, deviceId, user]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (offline || !ws) return;
|
if (!ws) return;
|
||||||
|
|
||||||
ws.onmessage = (e) => {
|
ws.onmessage = (e) => {
|
||||||
const json = JSON.parse(e.data);
|
const json = JSON.parse(e.data);
|
||||||
@@ -108,7 +106,7 @@ export const useWebSocket = ({
|
|||||||
Alert.alert("Message from server: " + title, body);
|
Alert.alert("Message from server: " + title, body);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [ws, stopPlayback, playVideo, pauseVideo, isPlaying, router, offline]);
|
}, [ws, stopPlayback, playVideo, pauseVideo, isPlaying, router]);
|
||||||
|
|
||||||
return { isConnected };
|
return { isConnected };
|
||||||
};
|
};
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,2 +0,0 @@
|
|||||||
#Sun Nov 17 18:25:45 AEDT 2024
|
|
||||||
gradle.version=8.9
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
apply plugin: 'com.android.library'
|
|
||||||
apply plugin: 'kotlin-android'
|
|
||||||
apply plugin: 'kotlin-kapt'
|
|
||||||
|
|
||||||
group = 'expo.modules.vlcplayer'
|
|
||||||
version = '0.6.0'
|
|
||||||
|
|
||||||
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
|
|
||||||
apply from: expoModulesCorePlugin
|
|
||||||
applyKotlinExpoModulesCorePlugin()
|
|
||||||
useCoreDependencies()
|
|
||||||
useExpoPublishing()
|
|
||||||
|
|
||||||
// If you want to use the managed Android SDK versions from expo-modules-core, set this to true.
|
|
||||||
// The Android SDK versions will be bumped from time to time in SDK releases and may introduce breaking changes in your module code.
|
|
||||||
// Most of the time, you may like to manage the Android SDK versions yourself.
|
|
||||||
def useManagedAndroidSdkVersions = false
|
|
||||||
if (useManagedAndroidSdkVersions) {
|
|
||||||
useDefaultAndroidSdkVersions()
|
|
||||||
} else {
|
|
||||||
buildscript {
|
|
||||||
repositories {
|
|
||||||
google()
|
|
||||||
mavenCentral()
|
|
||||||
}
|
|
||||||
dependencies {
|
|
||||||
classpath "com.android.tools.build:gradle:7.1.3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
project.android {
|
|
||||||
compileSdkVersion safeExtGet("compileSdkVersion", 34)
|
|
||||||
defaultConfig {
|
|
||||||
minSdkVersion safeExtGet("minSdkVersion", 21)
|
|
||||||
targetSdkVersion safeExtGet("targetSdkVersion", 34)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
implementation 'org.videolan.android:libvlc-all:3.6.0-eap12'
|
|
||||||
implementation "org.jetbrains.kotlin:kotlin-stdlib:1.5.31"
|
|
||||||
}
|
|
||||||
|
|
||||||
android {
|
|
||||||
namespace "expo.modules.vlcplayer"
|
|
||||||
compileSdkVersion 34
|
|
||||||
defaultConfig {
|
|
||||||
minSdkVersion 21
|
|
||||||
targetSdkVersion 34
|
|
||||||
versionCode 1
|
|
||||||
versionName "0.6.0"
|
|
||||||
}
|
|
||||||
compileOptions {
|
|
||||||
sourceCompatibility JavaVersion.VERSION_17
|
|
||||||
targetCompatibility JavaVersion.VERSION_17
|
|
||||||
}
|
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = "17"
|
|
||||||
}
|
|
||||||
lintOptions {
|
|
||||||
abortOnError false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
|
|
||||||
kotlinOptions {
|
|
||||||
freeCompilerArgs += ["-Xshow-kotlin-compiler-errors"]
|
|
||||||
jvmTarget = "17"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
<manifest>
|
|
||||||
</manifest>
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
package expo.modules.vlcplayer
|
|
||||||
|
|
||||||
import expo.modules.kotlin.modules.Module
|
|
||||||
import expo.modules.kotlin.modules.ModuleDefinition
|
|
||||||
|
|
||||||
class VlcPlayerModule : Module() {
|
|
||||||
override fun definition() = ModuleDefinition {
|
|
||||||
Name("VlcPlayer")
|
|
||||||
|
|
||||||
View(VlcPlayerView::class) {
|
|
||||||
Prop("source") { view: VlcPlayerView, source: Map<String, Any> ->
|
|
||||||
view.setSource(source)
|
|
||||||
}
|
|
||||||
|
|
||||||
Prop("paused") { view: VlcPlayerView, paused: Boolean ->
|
|
||||||
if (paused) {
|
|
||||||
view.pause()
|
|
||||||
} else {
|
|
||||||
view.play()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Events(
|
|
||||||
"onPlaybackStateChanged",
|
|
||||||
"onVideoStateChange",
|
|
||||||
"onVideoLoadStart",
|
|
||||||
"onVideoLoadEnd",
|
|
||||||
"onVideoProgress",
|
|
||||||
"onVideoError"
|
|
||||||
)
|
|
||||||
|
|
||||||
AsyncFunction("play") { view: VlcPlayerView ->
|
|
||||||
view.play()
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("pause") { view: VlcPlayerView ->
|
|
||||||
view.pause()
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("stop") { view: VlcPlayerView ->
|
|
||||||
view.stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("seekTo") { view: VlcPlayerView, time: Int ->
|
|
||||||
view.seekTo(time)
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("setAudioTrack") { view: VlcPlayerView, trackIndex: Int ->
|
|
||||||
view.setAudioTrack(trackIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("getAudioTracks") { view: VlcPlayerView ->
|
|
||||||
view.getAudioTracks()
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("setSubtitleTrack") { view: VlcPlayerView, trackIndex: Int ->
|
|
||||||
view.setSubtitleTrack(trackIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("getSubtitleTracks") { view: VlcPlayerView ->
|
|
||||||
view.getSubtitleTracks()
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("setSubtitleURL") { view: VlcPlayerView, url: String, name: String ->
|
|
||||||
view.setSubtitleURL(url, name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,263 +0,0 @@
|
|||||||
package expo.modules.vlcplayer
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.util.Log
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import android.widget.FrameLayout
|
|
||||||
import androidx.lifecycle.LifecycleObserver
|
|
||||||
import android.net.Uri
|
|
||||||
import expo.modules.kotlin.AppContext
|
|
||||||
import expo.modules.kotlin.views.ExpoView
|
|
||||||
import expo.modules.kotlin.viewevent.EventDispatcher
|
|
||||||
import org.videolan.libvlc.LibVLC
|
|
||||||
import org.videolan.libvlc.Media
|
|
||||||
import org.videolan.libvlc.interfaces.IMedia
|
|
||||||
import org.videolan.libvlc.MediaPlayer
|
|
||||||
import org.videolan.libvlc.util.VLCVideoLayout
|
|
||||||
|
|
||||||
class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext), LifecycleObserver, MediaPlayer.EventListener {
|
|
||||||
|
|
||||||
private var libVLC: LibVLC? = null
|
|
||||||
private var mediaPlayer: MediaPlayer? = null
|
|
||||||
private lateinit var videoLayout: VLCVideoLayout
|
|
||||||
private var isPaused: Boolean = false
|
|
||||||
private var lastReportedState: Int? = null
|
|
||||||
private var lastReportedIsPlaying: Boolean? = null
|
|
||||||
private var media : Media? = null
|
|
||||||
|
|
||||||
private val onVideoProgress by EventDispatcher()
|
|
||||||
private val onVideoStateChange by EventDispatcher()
|
|
||||||
private val onVideoLoadEnd by EventDispatcher()
|
|
||||||
|
|
||||||
private var startPosition: Int? = 0
|
|
||||||
private var isMediaReady: Boolean = false
|
|
||||||
private var externalTrack: Map<String, String>? = null
|
|
||||||
|
|
||||||
init {
|
|
||||||
setupView()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setupView() {
|
|
||||||
Log.d("VlcPlayerView", "Setting up view")
|
|
||||||
setBackgroundColor(android.graphics.Color.WHITE)
|
|
||||||
videoLayout = VLCVideoLayout(context).apply {
|
|
||||||
layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
|
|
||||||
}
|
|
||||||
addView(videoLayout)
|
|
||||||
Log.d("VlcPlayerView", "View setup complete")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setSource(source: Map<String, Any>) {
|
|
||||||
val mediaOptions = source["mediaOptions"] as? Map<String, Any> ?: emptyMap()
|
|
||||||
val autoplay = source["autoplay"] as? Boolean ?: false
|
|
||||||
val isNetwork = source["isNetwork"] as? Boolean ?: false
|
|
||||||
externalTrack = source["externalTrack"] as? Map<String, String>
|
|
||||||
startPosition = (source["startPosition"] as? Double)?.toInt() ?: 0
|
|
||||||
|
|
||||||
val initOptions = source["initOptions"] as? MutableList<String> ?: mutableListOf()
|
|
||||||
initOptions.add("--start-time=$startPosition")
|
|
||||||
|
|
||||||
|
|
||||||
val uri = source["uri"] as? String
|
|
||||||
|
|
||||||
// Handle video load start event
|
|
||||||
// onVideoLoadStart?.invoke(mapOf("target" to reactTag ?: "null"))
|
|
||||||
|
|
||||||
libVLC = LibVLC(context, initOptions)
|
|
||||||
mediaPlayer = MediaPlayer(libVLC)
|
|
||||||
mediaPlayer?.attachViews(videoLayout, null, false, false)
|
|
||||||
mediaPlayer?.setEventListener(this)
|
|
||||||
|
|
||||||
Log.d("VlcPlayerView", "Loading network file: $uri")
|
|
||||||
media = Media(libVLC, Uri.parse(uri))
|
|
||||||
mediaPlayer?.media = media
|
|
||||||
|
|
||||||
|
|
||||||
Log.d("VlcPlayerView", "Debug: Media options: $mediaOptions")
|
|
||||||
// media.addOptions(mediaOptions)
|
|
||||||
|
|
||||||
// Apply subtitle options
|
|
||||||
// val subtitleTrackIndex = source["subtitleTrackIndex"] as? Int ?: -1
|
|
||||||
// Log.d("VlcPlayerView", "Debug: Subtitle track index from source: $subtitleTrackIndex")
|
|
||||||
|
|
||||||
// if (subtitleTrackIndex >= -1) {
|
|
||||||
// setSubtitleTrack(subtitleTrackIndex)
|
|
||||||
// Log.d("VlcPlayerView", "Debug: Set subtitle track to index: $subtitleTrackIndex")
|
|
||||||
// } else {
|
|
||||||
// Log.d("VlcPlayerView", "Debug: Subtitle track index is less than -1, not setting")
|
|
||||||
// }
|
|
||||||
|
|
||||||
|
|
||||||
if (autoplay) {
|
|
||||||
Log.d("VlcPlayerView", "Playing...")
|
|
||||||
play()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun play() {
|
|
||||||
mediaPlayer?.play()
|
|
||||||
isPaused = false
|
|
||||||
}
|
|
||||||
|
|
||||||
fun pause() {
|
|
||||||
mediaPlayer?.pause()
|
|
||||||
isPaused = true
|
|
||||||
}
|
|
||||||
|
|
||||||
fun stop() {
|
|
||||||
mediaPlayer?.stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun seekTo(time: Int) {
|
|
||||||
mediaPlayer?.let { player ->
|
|
||||||
val wasPlaying = player.isPlaying
|
|
||||||
if (wasPlaying) {
|
|
||||||
player.pause()
|
|
||||||
}
|
|
||||||
|
|
||||||
val duration = player.length.toInt()
|
|
||||||
val seekTime = if (time > duration) duration - 1000 else time
|
|
||||||
player.time = seekTime.toLong()
|
|
||||||
|
|
||||||
if (wasPlaying) {
|
|
||||||
player.play()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setAudioTrack(trackIndex: Int) {
|
|
||||||
mediaPlayer?.setAudioTrack(trackIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getAudioTracks(): List<Map<String, Any>>? {
|
|
||||||
|
|
||||||
println("getAudioTracks")
|
|
||||||
println(mediaPlayer?.getAudioTracks())
|
|
||||||
val trackDescriptions = mediaPlayer?.audioTracks ?: return null
|
|
||||||
|
|
||||||
return trackDescriptions.map { trackDescription ->
|
|
||||||
mapOf("name" to trackDescription.name, "index" to trackDescription.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setSubtitleTrack(trackIndex: Int) {
|
|
||||||
mediaPlayer?.setSpuTrack(trackIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
// fun getSubtitleTracks(): List<Map<String, Any>>? {
|
|
||||||
// return mediaPlayer?.getSpuTracks()?.map { trackDescription ->
|
|
||||||
// mapOf("name" to trackDescription.name, "index" to trackDescription.id)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
fun getSubtitleTracks(): List<Map<String, Any>>? {
|
|
||||||
val subtitleTracks = mediaPlayer?.spuTracks?.map { trackDescription ->
|
|
||||||
mapOf("name" to trackDescription.name, "index" to trackDescription.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Debug statement to print the result
|
|
||||||
Log.d("VlcPlayerView", "Subtitle Tracks: $subtitleTracks")
|
|
||||||
|
|
||||||
return subtitleTracks
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setSubtitleURL(subtitleURL: String, name: String) {
|
|
||||||
println("Setting subtitle URL: $subtitleURL, name: $name")
|
|
||||||
mediaPlayer?.addSlave(IMedia.Slave.Type.Subtitle, Uri.parse(subtitleURL), true)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDetachedFromWindow() {
|
|
||||||
println("onDetachedFromWindow")
|
|
||||||
super.onDetachedFromWindow()
|
|
||||||
mediaPlayer?.stop()
|
|
||||||
|
|
||||||
media?.release()
|
|
||||||
mediaPlayer?.release()
|
|
||||||
libVLC?.release()
|
|
||||||
mediaPlayer = null
|
|
||||||
media = null
|
|
||||||
libVLC = null
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onEvent(event: MediaPlayer.Event) {
|
|
||||||
when (event.type) {
|
|
||||||
MediaPlayer.Event.Playing,
|
|
||||||
MediaPlayer.Event.Paused,
|
|
||||||
MediaPlayer.Event.Stopped,
|
|
||||||
MediaPlayer.Event.Buffering,
|
|
||||||
MediaPlayer.Event.EndReached,
|
|
||||||
MediaPlayer.Event.EncounteredError -> updatePlayerState(event)
|
|
||||||
MediaPlayer.Event.TimeChanged -> updateVideoProgress()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updatePlayerState(event: MediaPlayer.Event) {
|
|
||||||
val player = mediaPlayer ?: return
|
|
||||||
val currentState = event.type
|
|
||||||
|
|
||||||
val stateInfo = mutableMapOf<String, Any>(
|
|
||||||
"target" to "null", // Replace with actual target if needed
|
|
||||||
"currentTime" to player.time.toInt(),
|
|
||||||
"duration" to (player.media?.duration?.toInt() ?: 0),
|
|
||||||
"error" to false
|
|
||||||
)
|
|
||||||
|
|
||||||
when (currentState) {
|
|
||||||
MediaPlayer.Event.Playing -> {
|
|
||||||
stateInfo["isPlaying"] = true
|
|
||||||
stateInfo["isBuffering"] = false
|
|
||||||
stateInfo["state"] = "Playing"
|
|
||||||
}
|
|
||||||
MediaPlayer.Event.Paused -> {
|
|
||||||
stateInfo["isPlaying"] = false
|
|
||||||
stateInfo["state"] = "Paused"
|
|
||||||
}
|
|
||||||
MediaPlayer.Event.Buffering -> {
|
|
||||||
stateInfo["isBuffering"] = true
|
|
||||||
stateInfo["state"] = "Buffering"
|
|
||||||
}
|
|
||||||
MediaPlayer.Event.EncounteredError -> {
|
|
||||||
Log.e("VlcPlayerView", "player.state ~ error")
|
|
||||||
stateInfo["state"] = "Error"
|
|
||||||
onVideoLoadEnd(stateInfo);
|
|
||||||
}
|
|
||||||
MediaPlayer.Event.Opening -> {
|
|
||||||
Log.d("VlcPlayerView", "player.state ~ opening")
|
|
||||||
stateInfo["state"] = "Opening"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
if (lastReportedState != currentState || lastReportedIsPlaying != player.isPlaying) {
|
|
||||||
lastReportedState = currentState
|
|
||||||
lastReportedIsPlaying = player.isPlaying
|
|
||||||
onVideoStateChange(stateInfo)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private fun updateVideoProgress() {
|
|
||||||
val player = mediaPlayer ?: return
|
|
||||||
|
|
||||||
val currentTimeMs = player.time.toInt()
|
|
||||||
val durationMs = player.media?.duration?.toInt() ?: 0
|
|
||||||
|
|
||||||
if (currentTimeMs >= 0 && currentTimeMs < durationMs) {
|
|
||||||
// Set subtitle URL if available
|
|
||||||
if (player.isPlaying && !isMediaReady) {
|
|
||||||
isMediaReady = true
|
|
||||||
externalTrack?.let {
|
|
||||||
val name = it["name"]
|
|
||||||
val deliveryUrl = it["DeliveryUrl"] ?: ""
|
|
||||||
if (!name.isNullOrEmpty() && !deliveryUrl.isNullOrEmpty()) {
|
|
||||||
setSubtitleURL(deliveryUrl, name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onVideoProgress(mapOf(
|
|
||||||
"currentTime" to currentTimeMs,
|
|
||||||
"duration" to durationMs
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"platforms": ["ios", "tvos", "android", "web"],
|
|
||||||
"ios": {
|
|
||||||
"modules": ["VlcPlayerModule"]
|
|
||||||
},
|
|
||||||
"android": {
|
|
||||||
"modules": ["expo.modules.vlcplayer.VlcPlayerModule"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import {
|
|
||||||
NativeModulesProxy,
|
|
||||||
EventEmitter,
|
|
||||||
Subscription,
|
|
||||||
} from "expo-modules-core";
|
|
||||||
|
|
||||||
import VlcPlayerModule from "./src/VlcPlayerModule";
|
|
||||||
import VlcPlayerView from "./src/VlcPlayerView";
|
|
||||||
import {
|
|
||||||
PlaybackStatePayload,
|
|
||||||
ProgressUpdatePayload,
|
|
||||||
VideoLoadStartPayload,
|
|
||||||
VideoStateChangePayload,
|
|
||||||
VideoProgressPayload,
|
|
||||||
VlcPlayerSource,
|
|
||||||
TrackInfo,
|
|
||||||
ChapterInfo,
|
|
||||||
VlcPlayerViewProps,
|
|
||||||
VlcPlayerViewRef,
|
|
||||||
} from "./src/VlcPlayer.types";
|
|
||||||
|
|
||||||
const emitter = new EventEmitter(
|
|
||||||
VlcPlayerModule ?? NativeModulesProxy.VlcPlayer
|
|
||||||
);
|
|
||||||
|
|
||||||
export function addPlaybackStateListener(
|
|
||||||
listener: (event: PlaybackStatePayload) => void
|
|
||||||
): Subscription {
|
|
||||||
return emitter.addListener<PlaybackStatePayload>(
|
|
||||||
"onPlaybackStateChanged",
|
|
||||||
listener
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function addVideoLoadStartListener(
|
|
||||||
listener: (event: VideoLoadStartPayload) => void
|
|
||||||
): Subscription {
|
|
||||||
return emitter.addListener<VideoLoadStartPayload>(
|
|
||||||
"onVideoLoadStart",
|
|
||||||
listener
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function addVideoStateChangeListener(
|
|
||||||
listener: (event: VideoStateChangePayload) => void
|
|
||||||
): Subscription {
|
|
||||||
return emitter.addListener<VideoStateChangePayload>(
|
|
||||||
"onVideoStateChange",
|
|
||||||
listener
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function addVideoProgressListener(
|
|
||||||
listener: (event: VideoProgressPayload) => void
|
|
||||||
): Subscription {
|
|
||||||
return emitter.addListener<VideoProgressPayload>("onVideoProgress", listener);
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
VlcPlayerView,
|
|
||||||
VlcPlayerViewProps,
|
|
||||||
VlcPlayerViewRef,
|
|
||||||
PlaybackStatePayload,
|
|
||||||
ProgressUpdatePayload,
|
|
||||||
VideoLoadStartPayload,
|
|
||||||
VideoStateChangePayload,
|
|
||||||
VideoProgressPayload,
|
|
||||||
VlcPlayerSource,
|
|
||||||
TrackInfo,
|
|
||||||
ChapterInfo,
|
|
||||||
};
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
Pod::Spec.new do |s|
|
|
||||||
s.name = 'VlcPlayer'
|
|
||||||
s.version = '1.0.0'
|
|
||||||
s.summary = 'A sample project summary'
|
|
||||||
s.description = 'A sample project description'
|
|
||||||
s.author = ''
|
|
||||||
s.homepage = 'https://docs.expo.dev/modules/'
|
|
||||||
s.platforms = { :ios => '13.4', :tvos => '13.4' }
|
|
||||||
s.source = { git: '' }
|
|
||||||
s.static_framework = true
|
|
||||||
|
|
||||||
s.dependency 'ExpoModulesCore'
|
|
||||||
s.dependency 'MobileVLCKit', '~> 3.6.1b1'
|
|
||||||
|
|
||||||
# Swift/Objective-C compatibility
|
|
||||||
s.pod_target_xcconfig = {
|
|
||||||
'DEFINES_MODULE' => 'YES',
|
|
||||||
'SWIFT_COMPILATION_MODE' => 'wholemodule'
|
|
||||||
}
|
|
||||||
|
|
||||||
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
|
|
||||||
end
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
import ExpoModulesCore
|
|
||||||
|
|
||||||
public class VlcPlayerModule: Module {
|
|
||||||
public func definition() -> ModuleDefinition {
|
|
||||||
Name("VlcPlayer")
|
|
||||||
View(VlcPlayerView.self) {
|
|
||||||
Prop("source") { (view: VlcPlayerView, source: [String: Any]) in
|
|
||||||
view.setSource(source)
|
|
||||||
}
|
|
||||||
|
|
||||||
Prop("paused") { (view: VlcPlayerView, paused: Bool) in
|
|
||||||
if paused {
|
|
||||||
view.pause()
|
|
||||||
} else {
|
|
||||||
view.play()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prop("muted") { (view: VlcPlayerView, muted: Bool) in
|
|
||||||
// view.setMuted(muted)
|
|
||||||
// }
|
|
||||||
|
|
||||||
// Prop("volume") { (view: VlcPlayerView, volume: Int) in
|
|
||||||
// view.setVolume(volume)
|
|
||||||
// }
|
|
||||||
|
|
||||||
// Prop("videoAspectRatio") { (view: VlcPlayerView, ratio: String) in
|
|
||||||
// view.setVideoAspectRatio(ratio)
|
|
||||||
// }
|
|
||||||
|
|
||||||
Events(
|
|
||||||
"onPlaybackStateChanged",
|
|
||||||
"onVideoStateChange",
|
|
||||||
"onVideoLoadStart",
|
|
||||||
"onVideoLoadEnd",
|
|
||||||
"onVideoProgress",
|
|
||||||
"onVideoError"
|
|
||||||
)
|
|
||||||
|
|
||||||
AsyncFunction("play") { (view: VlcPlayerView) in
|
|
||||||
view.play()
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("pause") { (view: VlcPlayerView) in
|
|
||||||
view.pause()
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("stop") { (view: VlcPlayerView) in
|
|
||||||
view.stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("seekTo") { (view: VlcPlayerView, time: Int32) in
|
|
||||||
view.seekTo(time)
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("setAudioTrack") { (view: VlcPlayerView, trackIndex: Int) in
|
|
||||||
view.setAudioTrack(trackIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("getAudioTracks") { (view: VlcPlayerView) -> [[String: Any]]? in
|
|
||||||
return view.getAudioTracks()
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("setSubtitleTrack") { (view: VlcPlayerView, trackIndex: Int) in
|
|
||||||
view.setSubtitleTrack(trackIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncFunction("getSubtitleTracks") { (view: VlcPlayerView) -> [[String: Any]]? in
|
|
||||||
return view.getSubtitleTracks()
|
|
||||||
}
|
|
||||||
|
|
||||||
// AsyncFunction("setVideoCropGeometry") { (view: VlcPlayerView, geometry: String?) in
|
|
||||||
// view.setVideoCropGeometry(geometry)
|
|
||||||
// }
|
|
||||||
|
|
||||||
// AsyncFunction("getVideoCropGeometry") { (view: VlcPlayerView) -> String? in
|
|
||||||
// return view.getVideoCropGeometry()
|
|
||||||
// }
|
|
||||||
|
|
||||||
AsyncFunction("setSubtitleURL") {
|
|
||||||
(view: VlcPlayerView, url: String, name: String) in
|
|
||||||
view.setSubtitleURL(url, name: name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,691 +0,0 @@
|
|||||||
import ExpoModulesCore
|
|
||||||
import MobileVLCKit
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
class VlcPlayerView: ExpoView {
|
|
||||||
private var mediaPlayer: VLCMediaPlayer?
|
|
||||||
private var videoView: UIView?
|
|
||||||
private var progressUpdateInterval: TimeInterval = 0.5
|
|
||||||
private var isPaused: Bool = false
|
|
||||||
private var currentGeometryCString: [CChar]?
|
|
||||||
private var lastReportedState: VLCMediaPlayerState?
|
|
||||||
private var lastReportedIsPlaying: Bool?
|
|
||||||
private var customSubtitles: [(internalName: String, originalName: String)] = []
|
|
||||||
private var startPosition: Int32 = 0
|
|
||||||
private var isMediaReady: Bool = false
|
|
||||||
private var externalTrack: [String: String]?
|
|
||||||
|
|
||||||
// MARK: - Initialization
|
|
||||||
|
|
||||||
required init(appContext: AppContext? = nil) {
|
|
||||||
super.init(appContext: appContext)
|
|
||||||
setupView()
|
|
||||||
// setupNotifications()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Setup
|
|
||||||
|
|
||||||
private func setupView() {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.backgroundColor = .black
|
|
||||||
self.videoView = UIView()
|
|
||||||
self.videoView?.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
|
|
||||||
if let videoView = self.videoView {
|
|
||||||
self.addSubview(videoView)
|
|
||||||
NSLayoutConstraint.activate([
|
|
||||||
videoView.leadingAnchor.constraint(equalTo: self.leadingAnchor),
|
|
||||||
videoView.trailingAnchor.constraint(equalTo: self.trailingAnchor),
|
|
||||||
videoView.topAnchor.constraint(equalTo: self.topAnchor),
|
|
||||||
videoView.bottomAnchor.constraint(equalTo: self.bottomAnchor),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setupNotifications() {
|
|
||||||
NotificationCenter.default.addObserver(
|
|
||||||
self, selector: #selector(applicationWillResignActive),
|
|
||||||
name: UIApplication.willResignActiveNotification, object: nil)
|
|
||||||
NotificationCenter.default.addObserver(
|
|
||||||
self, selector: #selector(applicationWillEnterForeground),
|
|
||||||
name: UIApplication.willEnterForegroundNotification, object: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Public Methods
|
|
||||||
|
|
||||||
@objc func play() {
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
|
||||||
guard let self = self else { return }
|
|
||||||
self.mediaPlayer?.play()
|
|
||||||
self.isPaused = false
|
|
||||||
print("Play")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func pause() {
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
|
||||||
guard let self = self else { return }
|
|
||||||
self.mediaPlayer?.pause()
|
|
||||||
self.isPaused = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func seekTo(_ time: Int32) {
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
|
||||||
guard let self = self, let player = self.mediaPlayer else { return }
|
|
||||||
|
|
||||||
let wasPlaying = player.isPlaying
|
|
||||||
if wasPlaying {
|
|
||||||
player.pause()
|
|
||||||
}
|
|
||||||
|
|
||||||
if let duration = player.media?.length.intValue {
|
|
||||||
print("Seeking to time: \(time) Video Duration \(duration)")
|
|
||||||
|
|
||||||
// If the specified time is greater than the duration, seek to the end
|
|
||||||
let seekTime = time > duration ? duration - 1000 : time
|
|
||||||
player.time = VLCTime(int: seekTime)
|
|
||||||
|
|
||||||
// Wait for a short moment to ensure the seek has been processed
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
|
||||||
if wasPlaying {
|
|
||||||
player.play()
|
|
||||||
}
|
|
||||||
self.updatePlayerState()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
print("Error: Unable to retrieve video duration")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func setSource(_ source: [String: Any]) {
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
|
||||||
guard let self = self else { return }
|
|
||||||
|
|
||||||
let mediaOptions = source["mediaOptions"] as? [String: Any] ?? [:]
|
|
||||||
self.externalTrack = source["externalTrack"] as? [String: String]
|
|
||||||
var initOptions = source["initOptions"] as? [Any] ?? []
|
|
||||||
startPosition = source["startPosition"] as? Int32 ?? 0
|
|
||||||
initOptions.append("--start-time=\(startPosition)")
|
|
||||||
|
|
||||||
let uri = source["uri"] as? String
|
|
||||||
|
|
||||||
let autoplay = source["autoplay"] as? Bool ?? false
|
|
||||||
let isNetwork = source["isNetwork"] as? Bool ?? false
|
|
||||||
|
|
||||||
guard let uri = uri, !uri.isEmpty else {
|
|
||||||
print("Error: Invalid or empty URI")
|
|
||||||
self.onVideoError?(["error": "Invalid or empty URI"])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
self.onVideoLoadStart?(["target": self.reactTag ?? NSNull()])
|
|
||||||
self.mediaPlayer = VLCMediaPlayer(options: initOptions)
|
|
||||||
|
|
||||||
self.mediaPlayer?.delegate = self
|
|
||||||
self.mediaPlayer?.drawable = self.videoView
|
|
||||||
self.mediaPlayer?.scaleFactor = 0
|
|
||||||
|
|
||||||
let media: VLCMedia
|
|
||||||
if isNetwork {
|
|
||||||
print("Loading network file: \(uri)")
|
|
||||||
media = VLCMedia(url: URL(string: uri)!)
|
|
||||||
} else {
|
|
||||||
print("Loading local file: \(uri)")
|
|
||||||
if uri.starts(with: "file://") {
|
|
||||||
if let url = URL(string: uri) {
|
|
||||||
media = VLCMedia(url: url)
|
|
||||||
} else {
|
|
||||||
print("Error: Invalid local file URL")
|
|
||||||
self.onVideoError?(["error": "Invalid local file URL"])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
media = VLCMedia(path: uri)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
print("Debug: Media options: \(mediaOptions)")
|
|
||||||
media.addOptions(mediaOptions)
|
|
||||||
|
|
||||||
// Apply subtitle options
|
|
||||||
let subtitleTrackIndex = source["subtitleTrackIndex"] as? Int ?? -1
|
|
||||||
print("Debug: Subtitle track index from source: \(subtitleTrackIndex)")
|
|
||||||
|
|
||||||
if subtitleTrackIndex >= -1 {
|
|
||||||
self.setSubtitleTrack(subtitleTrackIndex)
|
|
||||||
print("Debug: Set subtitle track to index: \(subtitleTrackIndex)")
|
|
||||||
} else {
|
|
||||||
print("Debug: Subtitle track index is less than -1, not setting")
|
|
||||||
}
|
|
||||||
|
|
||||||
self.mediaPlayer?.media = media
|
|
||||||
|
|
||||||
if autoplay {
|
|
||||||
print("Playing...")
|
|
||||||
self.play()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO
|
|
||||||
// @objc func setMuted(_ muted: Bool) {
|
|
||||||
// DispatchQueue.main.async {
|
|
||||||
// self.mediaPlayer?.audio?.isMuted = muted
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// TODO
|
|
||||||
// @objc func setVolume(_ volume: Int) {
|
|
||||||
// DispatchQueue.main.async {
|
|
||||||
// self.mediaPlayer?.audio?.volume = Int32(volume)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// TODO
|
|
||||||
// @objc func setVideoAspectRatio(_ ratio: String) {
|
|
||||||
// DispatchQueue.main.async {
|
|
||||||
// ratio.withCString { cString in
|
|
||||||
// self.mediaPlayer?.videoAspectRatio = UnsafeMutablePointer(mutating: cString)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
@objc func setAudioTrack(_ trackIndex: Int) {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.mediaPlayer?.currentAudioTrackIndex = Int32(trackIndex)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func getAudioTracks() -> [[String: Any]]? {
|
|
||||||
guard let trackNames = mediaPlayer?.audioTrackNames,
|
|
||||||
let trackIndexes = mediaPlayer?.audioTrackIndexes
|
|
||||||
else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return zip(trackNames, trackIndexes).map { name, index in
|
|
||||||
return ["name": name, "index": index]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// @objc func getAudioTracks(
|
|
||||||
// _ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock
|
|
||||||
// ) {
|
|
||||||
// DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
|
||||||
// guard let self = self, let mediaPlayer = self.mediaPlayer else {
|
|
||||||
// DispatchQueue.main.async {
|
|
||||||
// reject("ERROR", "Media player not available", nil)
|
|
||||||
// }
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
|
|
||||||
// guard let trackNames = mediaPlayer.audioTrackNames,
|
|
||||||
// let trackIndexes = mediaPlayer.audioTrackIndexes
|
|
||||||
// else {
|
|
||||||
// DispatchQueue.main.async {
|
|
||||||
// reject("ERROR", "No audio tracks available", nil)
|
|
||||||
// }
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
|
|
||||||
// let tracks = zip(trackNames, trackIndexes).map { name, index in
|
|
||||||
// return ["name": name, "index": index]
|
|
||||||
// }
|
|
||||||
|
|
||||||
// DispatchQueue.main.async {
|
|
||||||
// resolve(tracks)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
@objc func setSubtitleTrack(_ trackIndex: Int) {
|
|
||||||
print("Debug: Attempting to set subtitle track to index: \(trackIndex)")
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
if trackIndex == -1 {
|
|
||||||
print("Debug: Disabling subtitles")
|
|
||||||
// Disable subtitles
|
|
||||||
self.mediaPlayer?.currentVideoSubTitleIndex = -1
|
|
||||||
} else {
|
|
||||||
print("Debug: Setting subtitle track to index: \(trackIndex)")
|
|
||||||
// Set the subtitle track
|
|
||||||
self.mediaPlayer?.currentVideoSubTitleIndex = Int32(trackIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Print the result
|
|
||||||
if let currentIndex = self.mediaPlayer?.currentVideoSubTitleIndex {
|
|
||||||
print("Debug: Current subtitle track index after setting: \(currentIndex)")
|
|
||||||
} else {
|
|
||||||
print("Debug: Unable to retrieve current subtitle track index")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func setSubtitleURL(_ subtitleURL: String, name: String) {
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
|
||||||
guard let self = self, let url = URL(string: subtitleURL) else {
|
|
||||||
print("Error: Invalid subtitle URL")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = self.mediaPlayer?.addPlaybackSlave(url, type: .subtitle, enforce: true)
|
|
||||||
if let result = result {
|
|
||||||
let internalName = "Track \(self.customSubtitles.count + 1)"
|
|
||||||
print("Subtitle added with result: \(result) \(internalName)")
|
|
||||||
self.customSubtitles.append((internalName: internalName, originalName: name))
|
|
||||||
} else {
|
|
||||||
print("Failed to add subtitle")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func getSubtitleTracks() -> [[String: Any]]? {
|
|
||||||
guard let mediaPlayer = self.mediaPlayer else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let count = mediaPlayer.numberOfSubtitlesTracks
|
|
||||||
print("Debug: Number of subtitle tracks: \(count)")
|
|
||||||
|
|
||||||
guard count > 0 else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var tracks: [[String: Any]] = []
|
|
||||||
|
|
||||||
if let names = mediaPlayer.videoSubTitlesNames as? [String],
|
|
||||||
let indexes = mediaPlayer.videoSubTitlesIndexes as? [NSNumber]
|
|
||||||
{
|
|
||||||
for (index, name) in zip(indexes, names) {
|
|
||||||
if let customSubtitle = customSubtitles.first(where: { $0.internalName == name }) {
|
|
||||||
tracks.append(["name": customSubtitle.originalName, "index": index.intValue])
|
|
||||||
} else {
|
|
||||||
tracks.append(["name": name, "index": index.intValue])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
print("Debug: Subtitle tracks: \(tracks)")
|
|
||||||
return tracks
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setSubtitleTrackByName(_ trackName: String) {
|
|
||||||
guard let mediaPlayer = self.mediaPlayer else { return }
|
|
||||||
|
|
||||||
// Get the subtitle tracks and their indexes
|
|
||||||
if let names = mediaPlayer.videoSubTitlesNames as? [String],
|
|
||||||
let indexes = mediaPlayer.videoSubTitlesIndexes as? [NSNumber]
|
|
||||||
{
|
|
||||||
for (index, name) in zip(indexes, names) {
|
|
||||||
if name.starts(with: trackName) {
|
|
||||||
let trackIndex = index.intValue
|
|
||||||
print("Track Index setting to: \(trackIndex)")
|
|
||||||
setSubtitleTrack(trackIndex)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
print("Track not found for name: \(trackName)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// @objc func getSubtitleTracks(
|
|
||||||
// _ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock
|
|
||||||
// ) {
|
|
||||||
// DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
|
||||||
// guard let self = self, let mediaPlayer = self.mediaPlayer else {
|
|
||||||
// DispatchQueue.main.async {
|
|
||||||
// reject("ERROR", "Media player not available", nil)
|
|
||||||
// }
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
|
|
||||||
// let count = mediaPlayer.numberOfSubtitlesTracks
|
|
||||||
// guard count > 0 else {
|
|
||||||
// DispatchQueue.main.async {
|
|
||||||
// reject("ERROR", "No subtitle tracks available", nil)
|
|
||||||
// }
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
|
|
||||||
// var tracks: [[String: Any]] = [["name": "Disabled", "index": -1]]
|
|
||||||
|
|
||||||
// if let names = mediaPlayer.videoSubTitlesNames as? [String],
|
|
||||||
// let indexes = mediaPlayer.videoSubTitlesIndexes as? [NSNumber]
|
|
||||||
// {
|
|
||||||
// for (index, name) in zip(indexes, names) {
|
|
||||||
// tracks.append(["name": name, "index": index.intValue])
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// DispatchQueue.main.async {
|
|
||||||
// resolve(tracks)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// TODO
|
|
||||||
// @objc func setSubtitleDelay(_ delay: Int) {
|
|
||||||
// DispatchQueue.main.async {
|
|
||||||
// self.mediaPlayer?.currentVideoSubTitleDelay = NSInteger(delay)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// TODO
|
|
||||||
// @objc func setAudioDelay(_ delay: Int) {
|
|
||||||
// DispatchQueue.main.async {
|
|
||||||
// self.mediaPlayer?.currentAudioPlaybackDelay = NSInteger(delay)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// TODO
|
|
||||||
// @objc func takeSnapshot(_ path: String, width: Int, height: Int) {
|
|
||||||
// DispatchQueue.main.async { [weak self] in
|
|
||||||
// guard let self = self else { return }
|
|
||||||
// self.mediaPlayer?.saveVideoSnapshot(
|
|
||||||
// at: path, withWidth: Int32(width), andHeight: Int32(height))
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// TODO
|
|
||||||
// @objc func setVideoCropGeometry(_ geometry: String?) {
|
|
||||||
// DispatchQueue.main.async {
|
|
||||||
// if let geometry = geometry, !geometry.isEmpty {
|
|
||||||
// self.currentGeometryCString = geometry.cString(using: .utf8)
|
|
||||||
// self.currentGeometryCString?.withUnsafeMutableBufferPointer { buffer in
|
|
||||||
// self.mediaPlayer?.videoCropGeometry = buffer.baseAddress
|
|
||||||
// }
|
|
||||||
// } else {
|
|
||||||
// self.currentGeometryCString = nil
|
|
||||||
// self.mediaPlayer?.videoCropGeometry = nil
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// TODO
|
|
||||||
// @objc func getVideoCropGeometry() -> String? {
|
|
||||||
// guard let cString = mediaPlayer?.videoCropGeometry else {
|
|
||||||
// return nil
|
|
||||||
// }
|
|
||||||
// return String(cString: cString)
|
|
||||||
// }
|
|
||||||
|
|
||||||
// TODO
|
|
||||||
// @objc func setRate(_ rate: Float) {
|
|
||||||
// DispatchQueue.main.async {
|
|
||||||
// self.mediaPlayer?.rate = rate
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// TODO
|
|
||||||
// @objc func nextChapter() {
|
|
||||||
// DispatchQueue.main.async {
|
|
||||||
// self.mediaPlayer?.nextChapter()
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// TODO
|
|
||||||
// @objc func previousChapter() {
|
|
||||||
// DispatchQueue.main.async {
|
|
||||||
// self.mediaPlayer?.previousChapter()
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// TODO
|
|
||||||
// @objc func getChapters() -> [[String: Any]]? {
|
|
||||||
// guard let currentTitleIndex = mediaPlayer?.currentTitleIndex,
|
|
||||||
// let chapters = mediaPlayer?.chapterDescriptions(ofTitle: currentTitleIndex)
|
|
||||||
// as? [[String: Any]]
|
|
||||||
// else {
|
|
||||||
// return nil
|
|
||||||
// }
|
|
||||||
|
|
||||||
// return chapters.compactMap { chapter in
|
|
||||||
// guard let name = chapter[VLCChapterDescriptionName] as? String,
|
|
||||||
// let timeOffset = chapter[VLCChapterDescriptionTimeOffset] as? NSNumber,
|
|
||||||
// let duration = chapter[VLCChapterDescriptionDuration] as? NSNumber
|
|
||||||
// else {
|
|
||||||
// return nil
|
|
||||||
// }
|
|
||||||
|
|
||||||
// return [
|
|
||||||
// "name": name,
|
|
||||||
// "timeOffset": timeOffset.doubleValue,
|
|
||||||
// "duration": duration.doubleValue,
|
|
||||||
// ]
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
private var isStopping: Bool = false
|
|
||||||
|
|
||||||
@objc func stop(completion: (() -> Void)? = nil) {
|
|
||||||
guard !isStopping else {
|
|
||||||
completion?()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
isStopping = true
|
|
||||||
|
|
||||||
// If we're not on the main thread, dispatch to main thread
|
|
||||||
if !Thread.isMainThread {
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
|
||||||
self?.performStop(completion: completion)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
performStop(completion: completion)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private Methods
|
|
||||||
|
|
||||||
@objc private func applicationWillResignActive() {
|
|
||||||
if !isPaused {
|
|
||||||
pause()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func applicationWillEnterForeground() {
|
|
||||||
if !isPaused {
|
|
||||||
play()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func performStop(completion: (() -> Void)? = nil) {
|
|
||||||
// Stop the media player
|
|
||||||
mediaPlayer?.stop()
|
|
||||||
|
|
||||||
// Remove observer
|
|
||||||
NotificationCenter.default.removeObserver(self)
|
|
||||||
|
|
||||||
// Clear the video view
|
|
||||||
videoView?.removeFromSuperview()
|
|
||||||
videoView = nil
|
|
||||||
|
|
||||||
// Release the media player
|
|
||||||
mediaPlayer?.delegate = nil
|
|
||||||
mediaPlayer = nil
|
|
||||||
|
|
||||||
isStopping = false
|
|
||||||
completion?()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func getSubtitleOptions() -> [String: Any] {
|
|
||||||
return [
|
|
||||||
// Text scaling (100 is default, increase for larger text)
|
|
||||||
"sub-text-scale": "105",
|
|
||||||
|
|
||||||
// Text color (RRGGBB format, 16777215 is white)
|
|
||||||
"freetype-color": "16777215",
|
|
||||||
|
|
||||||
// Outline thickness (reduced from 2 to 1 for less border)
|
|
||||||
"freetype-outline-thickness": "1",
|
|
||||||
|
|
||||||
// Outline color (RRGGBB format, 0 is black)
|
|
||||||
"freetype-outline-color": "0",
|
|
||||||
|
|
||||||
// Text opacity (0-255, 255 is fully opaque)
|
|
||||||
"freetype-opacity": "255",
|
|
||||||
|
|
||||||
// Shadow opacity (increased from 128 to 180 for more shadow)
|
|
||||||
"freetype-shadow-opacity": "180",
|
|
||||||
|
|
||||||
// Shadow offset (increased from 2 to 3 for more pronounced shadow)
|
|
||||||
"freetype-shadow-offset": "3",
|
|
||||||
|
|
||||||
// Text alignment (0: center, 1: left, 2: right)
|
|
||||||
"sub-text-alignment": "0",
|
|
||||||
|
|
||||||
// Vertical margin (from bottom of the screen, in pixels)
|
|
||||||
"sub-margin-bottom": "50",
|
|
||||||
|
|
||||||
// Background opacity (0-255, 0 for no background)
|
|
||||||
"freetype-background-opacity": "64",
|
|
||||||
|
|
||||||
// Background color (RRGGBB format)
|
|
||||||
"freetype-background-color": "0",
|
|
||||||
]
|
|
||||||
}
|
|
||||||
// MARK: - Expo Events
|
|
||||||
|
|
||||||
@objc var onPlaybackStateChanged: RCTDirectEventBlock?
|
|
||||||
@objc var onVideoLoadStart: RCTDirectEventBlock?
|
|
||||||
@objc var onVideoStateChange: RCTDirectEventBlock?
|
|
||||||
@objc var onVideoProgress: RCTDirectEventBlock?
|
|
||||||
@objc var onVideoLoadEnd: RCTDirectEventBlock?
|
|
||||||
@objc var onVideoError: RCTDirectEventBlock?
|
|
||||||
|
|
||||||
// MARK: - Deinitialization
|
|
||||||
|
|
||||||
deinit {
|
|
||||||
performStop()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension VlcPlayerView: VLCMediaPlayerDelegate {
|
|
||||||
func mediaPlayerStateChanged(_ aNotification: Notification) {
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
|
||||||
guard let self = self else { return }
|
|
||||||
self.updatePlayerState()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updatePlayerState() {
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
|
||||||
guard let self = self, let player = self.mediaPlayer else { return }
|
|
||||||
let currentState = player.state
|
|
||||||
|
|
||||||
var stateInfo: [String: Any] = [
|
|
||||||
"target": self.reactTag ?? NSNull(),
|
|
||||||
"currentTime": player.time.intValue,
|
|
||||||
"duration": player.media?.length.intValue ?? 0,
|
|
||||||
"error": false,
|
|
||||||
]
|
|
||||||
|
|
||||||
if player.isPlaying {
|
|
||||||
stateInfo["isPlaying"] = true
|
|
||||||
stateInfo["isBuffering"] = false
|
|
||||||
stateInfo["state"] = "Playing"
|
|
||||||
} else {
|
|
||||||
stateInfo["isPlaying"] = false
|
|
||||||
stateInfo["state"] = "Paused"
|
|
||||||
}
|
|
||||||
|
|
||||||
if player.state == VLCMediaPlayerState.buffering {
|
|
||||||
stateInfo["isBuffering"] = true
|
|
||||||
stateInfo["state"] = "Buffering"
|
|
||||||
} else if player.state == VLCMediaPlayerState.error {
|
|
||||||
print("player.state ~ error")
|
|
||||||
stateInfo["state"] = "Error"
|
|
||||||
self.onVideoLoadEnd?(stateInfo)
|
|
||||||
} else if player.state == VLCMediaPlayerState.opening {
|
|
||||||
print("player.state ~ opening")
|
|
||||||
stateInfo["state"] = "Opening"
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.lastReportedState != currentState
|
|
||||||
|| self.lastReportedIsPlaying != player.isPlaying
|
|
||||||
{
|
|
||||||
self.lastReportedState = currentState
|
|
||||||
self.lastReportedIsPlaying = player.isPlaying
|
|
||||||
self.onVideoStateChange?(stateInfo)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// func seekToStartTime() {
|
|
||||||
// DispatchQueue.main.async { [weak self] in
|
|
||||||
// guard let self = self, let player = self.mediaPlayer else { return }
|
|
||||||
|
|
||||||
// if let startPosition = self.startPosition, startPosition > 0 {
|
|
||||||
// print("Debug: Seeking to start position: \(startPosition)")
|
|
||||||
// player.time = VLCTime(int: Int32(startPosition))
|
|
||||||
|
|
||||||
// // Ensure the player continues playing after seeking
|
|
||||||
// if !player.isPlaying {
|
|
||||||
// player.play()
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
func mediaPlayerTimeChanged(_ aNotification: Notification) {
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
|
||||||
self?.updateVideoProgress()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateVideoProgress() {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
guard let player = self.mediaPlayer else { return }
|
|
||||||
|
|
||||||
let currentTimeMs = player.time.intValue
|
|
||||||
let durationMs = player.media?.length.intValue ?? 0
|
|
||||||
|
|
||||||
if currentTimeMs >= 0 && currentTimeMs < durationMs {
|
|
||||||
if player.isPlaying && !self.isMediaReady {
|
|
||||||
self.isMediaReady = true
|
|
||||||
// Set external track subtitle when starting.
|
|
||||||
if let externalTrack = self.externalTrack {
|
|
||||||
if let name = externalTrack["name"] as? String, !name.isEmpty {
|
|
||||||
let deliveryUrl = externalTrack["DeliveryUrl"] as? String ?? ""
|
|
||||||
self.setSubtitleURL(deliveryUrl, name: name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.onVideoProgress?([
|
|
||||||
"currentTime": currentTimeMs,
|
|
||||||
"duration": durationMs,
|
|
||||||
])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension VlcPlayerView: VLCMediaDelegate {
|
|
||||||
// func mediaMetaDataDidChange(_ aMedia: VLCMedia) {
|
|
||||||
// // Implement if needed
|
|
||||||
// }
|
|
||||||
|
|
||||||
// func mediaDidFinishParsing(_ aMedia: VLCMedia) {
|
|
||||||
// DispatchQueue.main.async {
|
|
||||||
// let duration = aMedia.length.intValue
|
|
||||||
// self.onVideoStateChange?(["type": "MediaParsed", "duration": duration])
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
|
|
||||||
extension VLCMediaPlayerState {
|
|
||||||
var description: String {
|
|
||||||
switch self {
|
|
||||||
case .opening: return "Opening"
|
|
||||||
case .buffering: return "Buffering"
|
|
||||||
case .playing: return "Playing"
|
|
||||||
case .paused: return "Paused"
|
|
||||||
case .stopped: return "Stopped"
|
|
||||||
case .ended: return "Ended"
|
|
||||||
case .error: return "Error"
|
|
||||||
case .esAdded: return "ESAdded"
|
|
||||||
@unknown default: return "Unknown"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
export type PlaybackStatePayload = {
|
|
||||||
nativeEvent: {
|
|
||||||
target: number;
|
|
||||||
state: "Opening" | "Buffering" | "Playing" | "Paused" | "Error";
|
|
||||||
currentTime: number;
|
|
||||||
duration: number;
|
|
||||||
isBuffering: boolean;
|
|
||||||
isPlaying: boolean;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ProgressUpdatePayload = {
|
|
||||||
nativeEvent: {
|
|
||||||
currentTime: number;
|
|
||||||
duration: number;
|
|
||||||
isPlaying: boolean;
|
|
||||||
isBuffering: boolean;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type VideoLoadStartPayload = {
|
|
||||||
nativeEvent: {
|
|
||||||
target: number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type VideoStateChangePayload = PlaybackStatePayload;
|
|
||||||
|
|
||||||
export type VideoProgressPayload = ProgressUpdatePayload;
|
|
||||||
|
|
||||||
export type VlcPlayerSource = {
|
|
||||||
uri: string;
|
|
||||||
type?: string;
|
|
||||||
isNetwork?: boolean;
|
|
||||||
autoplay?: boolean;
|
|
||||||
externalTrack?: { name: string, DeliveryUrl: string };
|
|
||||||
initOptions?: any[];
|
|
||||||
mediaOptions?: { [key: string]: any };
|
|
||||||
startPosition?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TrackInfo = {
|
|
||||||
name: string;
|
|
||||||
index: number;
|
|
||||||
language?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ChapterInfo = {
|
|
||||||
name: string;
|
|
||||||
timeOffset: number;
|
|
||||||
duration: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type VlcPlayerViewProps = {
|
|
||||||
source: VlcPlayerSource;
|
|
||||||
style?: Object;
|
|
||||||
progressUpdateInterval?: number;
|
|
||||||
paused?: boolean;
|
|
||||||
muted?: boolean;
|
|
||||||
volume?: number;
|
|
||||||
videoAspectRatio?: string;
|
|
||||||
onVideoProgress?: (event: ProgressUpdatePayload) => void;
|
|
||||||
onVideoStateChange?: (event: PlaybackStatePayload) => void;
|
|
||||||
onVideoLoadStart?: (event: VideoLoadStartPayload) => void;
|
|
||||||
onVideoLoadEnd?: (event: VideoLoadStartPayload) => void;
|
|
||||||
onVideoError?: (event: PlaybackStatePayload) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface VlcPlayerViewRef {
|
|
||||||
play: () => Promise<void>;
|
|
||||||
pause: () => Promise<void>;
|
|
||||||
stop: () => Promise<void>;
|
|
||||||
seekTo: (time: number) => Promise<void>;
|
|
||||||
setAudioTrack: (trackIndex: number) => Promise<void>;
|
|
||||||
getAudioTracks: () => Promise<TrackInfo[] | null>;
|
|
||||||
setSubtitleTrack: (trackIndex: number) => Promise<void>;
|
|
||||||
getSubtitleTracks: () => Promise<TrackInfo[] | null>;
|
|
||||||
setSubtitleDelay: (delay: number) => Promise<void>;
|
|
||||||
setAudioDelay: (delay: number) => Promise<void>;
|
|
||||||
takeSnapshot: (path: string, width: number, height: number) => Promise<void>;
|
|
||||||
setRate: (rate: number) => Promise<void>;
|
|
||||||
nextChapter: () => Promise<void>;
|
|
||||||
previousChapter: () => Promise<void>;
|
|
||||||
getChapters: () => Promise<ChapterInfo[] | null>;
|
|
||||||
setVideoCropGeometry: (geometry: string | null) => Promise<void>;
|
|
||||||
getVideoCropGeometry: () => Promise<string | null>;
|
|
||||||
setSubtitleURL: (url: string, name: string) => Promise<void>;
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { requireNativeModule } from 'expo-modules-core';
|
|
||||||
|
|
||||||
// It loads the native module object from the JSI or falls back to
|
|
||||||
// the bridge module (from NativeModulesProxy) if the remote debugger is on.
|
|
||||||
export default requireNativeModule('VlcPlayer');
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user