mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-03-24 18:26:30 +00:00
Compare commits
236 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa785b0f95 | ||
|
|
8ea38a3efc | ||
|
|
07ad905e16 | ||
|
|
3058b866c3 | ||
|
|
497a1adc26 | ||
|
|
093cd94455 | ||
|
|
349a86bcfb | ||
|
|
4b81dff0be | ||
|
|
ee6d43e3e8 | ||
|
|
f8d22bb7d6 | ||
|
|
a0391b484d | ||
|
|
681aadb121 | ||
|
|
479a1f037e | ||
|
|
ae5b88ab56 | ||
|
|
9091b9b66a | ||
|
|
cccb26c9cc | ||
|
|
28568cbb9c | ||
|
|
8344d4025b | ||
|
|
0f69448081 | ||
|
|
a936916da4 | ||
|
|
c753e33f38 | ||
|
|
48422fa93e | ||
|
|
5adf943fd9 | ||
|
|
9174a8104d | ||
|
|
56f1bd489c | ||
|
|
5e79b5a581 | ||
|
|
36a689f59d | ||
|
|
47211ba009 | ||
|
|
e86a2af9a9 | ||
|
|
c46b4cc34d | ||
|
|
ec0d9d7788 | ||
|
|
d2eda1365c | ||
|
|
b58fa86a6b | ||
|
|
400dfe3679 | ||
|
|
cf58a5e749 | ||
|
|
001eba02b4 | ||
|
|
67e767f298 | ||
|
|
5f1c5f7b34 | ||
|
|
e54cac1e09 | ||
|
|
cbce83e109 | ||
|
|
c6b58c5c28 | ||
|
|
0468756317 | ||
|
|
9f12ee027f | ||
|
|
78b7425c6b | ||
|
|
c38c1d06ad | ||
|
|
5af735065a | ||
|
|
600276cb69 | ||
|
|
ba3104f87e | ||
|
|
3aef9458e3 | ||
|
|
5bce394836 | ||
|
|
90930d478c | ||
|
|
dd09f3d4d9 | ||
|
|
8608ad02f7 | ||
|
|
030947fc38 | ||
|
|
9b18188b32 | ||
|
|
d86853dec9 | ||
|
|
0750acdc13 | ||
|
|
d8231f5b80 | ||
|
|
41d17499bb | ||
|
|
60f1217cae | ||
|
|
834de10e34 | ||
|
|
51f17f983d | ||
|
|
ba4a2c0b79 | ||
|
|
a32eb710ec | ||
|
|
cb05da782a | ||
|
|
5a680a4392 | ||
|
|
8a44d2ff15 | ||
|
|
f3f260625f | ||
|
|
6908620f4e | ||
|
|
9932266203 | ||
|
|
cb2268e39c | ||
|
|
bf9be278d3 | ||
|
|
584fcc09d6 | ||
|
|
7a26b5004b | ||
|
|
ae92692ea0 | ||
|
|
92e4b3b8cf | ||
|
|
127ec1391b | ||
|
|
0ac4f826bc | ||
|
|
6190f2e602 | ||
|
|
24fdd071af | ||
|
|
be3122caac | ||
|
|
39a220bbed | ||
|
|
e3bdbb5cbd | ||
|
|
b6ad05d980 | ||
|
|
0360b5cbd5 | ||
|
|
a9b1d9fb0a | ||
|
|
4291ef55b9 | ||
|
|
655060fb40 | ||
|
|
0e29b8b671 | ||
|
|
72f64c71dd | ||
|
|
ddfd9f6ce3 | ||
|
|
67fb339d40 | ||
|
|
9e0a7f047c | ||
|
|
aab806bbf4 | ||
|
|
4a53b20618 | ||
|
|
45299a5c5d | ||
|
|
65ad4effca | ||
|
|
35fcb5ca0c | ||
|
|
5dc0066370 | ||
|
|
3fb20a8ca2 | ||
|
|
180ed54fed | ||
|
|
72859b4ae3 | ||
|
|
bfe96edb29 | ||
|
|
46f4acdad0 | ||
|
|
da1aa9f48c | ||
|
|
1d0d99c79b | ||
|
|
33a6295b20 | ||
|
|
72cc381087 | ||
|
|
c4bfaf2d56 | ||
|
|
487ac398e5 | ||
|
|
84fd0edc49 | ||
|
|
0e1583c440 | ||
|
|
6459e5f323 | ||
|
|
319e1fd53f | ||
|
|
93bd817eaf | ||
|
|
d9f21e6824 | ||
|
|
d287f5d082 | ||
|
|
ecd2fa386e | ||
|
|
7c022bbaff | ||
|
|
5d79ee34cf | ||
|
|
b0adad8dc4 | ||
|
|
c3d3f538d7 | ||
|
|
6b6dedf303 | ||
|
|
8d22e4c075 | ||
|
|
4dff26e8c3 | ||
|
|
ee2edda507 | ||
|
|
9e6a8424db | ||
|
|
d37ecc1bef | ||
|
|
e70fd3ee45 | ||
|
|
16e93513e2 | ||
|
|
b0c506f85d | ||
|
|
b762aff6e2 | ||
|
|
75639c4424 | ||
|
|
4606ce1834 | ||
|
|
44bde8f41e | ||
|
|
828edad749 | ||
|
|
f842c8a41f | ||
|
|
4d38573973 | ||
|
|
785e3b6859 | ||
|
|
40b3304f9b | ||
|
|
abf1b343cd | ||
|
|
e427802aae | ||
|
|
684e671750 | ||
|
|
5e9b28f2eb | ||
|
|
1d4c56265f | ||
|
|
1102df8384 | ||
|
|
15073f47db | ||
|
|
15f32bca6c | ||
|
|
108c5f9bab | ||
|
|
24d781050f | ||
|
|
353ebf3b0c | ||
|
|
c8b16f947d | ||
|
|
bd24f59199 | ||
|
|
a6b49c42cf | ||
|
|
5afb677b3a | ||
|
|
65d3da155f | ||
|
|
d616574232 | ||
|
|
b8b083abe2 | ||
|
|
49a1bffcf5 | ||
|
|
cb6c716830 | ||
|
|
a725af114c | ||
|
|
5b290fd667 | ||
|
|
de4f60f564 | ||
|
|
a4cd3ea600 | ||
|
|
3db12bd76a | ||
|
|
26305c2983 | ||
|
|
9c02fa2e72 | ||
|
|
b08ec474a4 | ||
|
|
416fb24ac0 | ||
|
|
0d2b15e5af | ||
|
|
ef036cb362 | ||
|
|
006e457d23 | ||
|
|
832a717585 | ||
|
|
39f86a9eb1 | ||
|
|
38445c6959 | ||
|
|
24320541c7 | ||
|
|
ee4e9fe347 | ||
|
|
6d43b34f66 | ||
|
|
63cf7eb622 | ||
|
|
32130f1a9c | ||
|
|
7f458f2f0b | ||
|
|
6ec6c6daa0 | ||
|
|
02a48fd958 | ||
|
|
04c4dfd13a | ||
|
|
40bdb10653 | ||
|
|
f16c486bfb | ||
|
|
19fc00e314 | ||
|
|
c51965016c | ||
|
|
3bcf73f0dd | ||
|
|
1ecef4be67 | ||
|
|
387525f9c3 | ||
|
|
cf182d8473 | ||
|
|
f0e3321a16 | ||
|
|
96c76e2b08 | ||
|
|
aaa07d93cf | ||
|
|
0716bba6ec | ||
|
|
15476f3686 | ||
|
|
97cf9185d3 | ||
|
|
c11ad17ca5 | ||
|
|
b0d563bc48 | ||
|
|
909fc84ec0 | ||
|
|
0400597061 | ||
|
|
b44a5fbbba | ||
|
|
a5f6ba27b1 | ||
|
|
ece1b8f2b9 | ||
|
|
beb6702112 | ||
|
|
98c0ed4ad5 | ||
|
|
b3f471bfa6 | ||
|
|
1a10f0debf | ||
|
|
ac266c6956 | ||
|
|
b23a50914c | ||
|
|
5c4a419d22 | ||
|
|
3d034864f9 | ||
|
|
ea183c426b | ||
|
|
92be991cf7 | ||
|
|
b73c29221a | ||
|
|
880a739dd4 | ||
|
|
69ffdc2ddf | ||
|
|
d686bd8c7b | ||
|
|
c8a60e735b | ||
|
|
05f7574e60 | ||
|
|
11b880863c | ||
|
|
aec172d8f5 | ||
|
|
7b52528d72 | ||
|
|
5fd1d9080e | ||
|
|
5cc0f381fa | ||
|
|
0f547deb39 | ||
|
|
5aeb80348a | ||
|
|
1dfc0ac762 | ||
|
|
2b8aee442a | ||
|
|
3e45adfeb5 | ||
|
|
b41363d347 | ||
|
|
2d5a27c015 | ||
|
|
b5c6403e2d | ||
|
|
7eb7d17fa9 | ||
|
|
91b4e403e6 |
26
.github/ISSUE_TEMPLATE/bug_report.md
vendored
26
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -1,26 +0,0 @@
|
|||||||
---
|
|
||||||
name: Bug report
|
|
||||||
about: Create a report to help us improve
|
|
||||||
title: ''
|
|
||||||
labels: '❌ bug'
|
|
||||||
assignees: ''
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Describe the bug**
|
|
||||||
A clear and concise description of what the bug is.
|
|
||||||
|
|
||||||
**To Reproduce**
|
|
||||||
Steps to reproduce the behavior:
|
|
||||||
1. Go to '...'
|
|
||||||
2. Click on '....'
|
|
||||||
3. Scroll down to '....'
|
|
||||||
4. See error
|
|
||||||
|
|
||||||
**Screenshots**
|
|
||||||
If applicable, add screenshots to help explain your problem.
|
|
||||||
|
|
||||||
**Smartphone (please complete the following information):**
|
|
||||||
- Device: [e.g. iPhone15Pro]
|
|
||||||
- OS: [e.g. iOS18]
|
|
||||||
- Version [e.g. 0.3.1]
|
|
||||||
59
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
59
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
name: Bug report
|
||||||
|
description: Create a report to help us improve
|
||||||
|
title: "[Bug]: "
|
||||||
|
labels:
|
||||||
|
- ["❌ bug"]
|
||||||
|
projects:
|
||||||
|
- ["fredrikburmester/5"]
|
||||||
|
assignees:
|
||||||
|
- fredrikburmester
|
||||||
|
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
id: what-happened
|
||||||
|
attributes:
|
||||||
|
label: What happened?
|
||||||
|
description: Also tell us, what did you expect to happen?
|
||||||
|
placeholder: A clear and concise description of what the bug is.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: repro
|
||||||
|
attributes:
|
||||||
|
label: Reproduction steps
|
||||||
|
description: "How do you trigger this bug? Please walk us through it step by step."
|
||||||
|
placeholder: |
|
||||||
|
1.
|
||||||
|
2.
|
||||||
|
3.
|
||||||
|
...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: device
|
||||||
|
attributes:
|
||||||
|
label: Which device and operating system are you using?
|
||||||
|
description: e.g. iPhone 15, iOS 18.1.1
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: Version
|
||||||
|
description: What version of Streamyfin are you running?
|
||||||
|
options:
|
||||||
|
- 0.23.0
|
||||||
|
- 0.22.0
|
||||||
|
- 0.21.0
|
||||||
|
- older
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: screenshots
|
||||||
|
attributes:
|
||||||
|
label: If applicable, please add screenshots to help explain your problem.
|
||||||
|
You can drag and drop images here or paste them directly into the comment box.
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -35,3 +35,4 @@ credentials.json
|
|||||||
*.ipa
|
*.ipa
|
||||||
.continuerc.json
|
.continuerc.json
|
||||||
|
|
||||||
|
.vscode/
|
||||||
4
.gitmodules
vendored
Normal file
4
.gitmodules
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
[submodule "utils/jellyseerr"]
|
||||||
|
path = utils/jellyseerr
|
||||||
|
url = https://github.com/herrrta/jellyseerr
|
||||||
|
branch = models
|
||||||
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@@ -11,7 +11,5 @@
|
|||||||
},
|
},
|
||||||
"[swift]": {
|
"[swift]": {
|
||||||
"editor.defaultFormatter": "sswg.swift-lang"
|
"editor.defaultFormatter": "sswg.swift-lang"
|
||||||
},
|
}
|
||||||
"java.configuration.updateBuildConfiguration": "interactive",
|
|
||||||
"java.compile.nullAnalysis.mode": "automatic"
|
|
||||||
}
|
}
|
||||||
|
|||||||
43
README.md
43
README.md
@@ -15,10 +15,10 @@ Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Exp
|
|||||||
|
|
||||||
- 🚀 **Skp intro / credits support**
|
- 🚀 **Skp intro / credits support**
|
||||||
- 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking.
|
- 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking.
|
||||||
- 📺 **Picture in Picture** (iPhone only): Watch movies in PiP mode on your iPhone.
|
|
||||||
- 🔊 **Background audio**: Stream music in the background, even when locking the phone.
|
- 🔊 **Background audio**: Stream music in the background, even when locking the phone.
|
||||||
- 📥 **Download media** (Experimental): Save your media locally and watch it offline.
|
- 📥 **Download media** (Experimental): Save your media locally and watch it offline.
|
||||||
- 📡 **Chromecast** (Experimental): Cast your media to any Chromecast-enabled device.
|
- 📡 **Chromecast** (Experimental): Cast your media to any Chromecast-enabled device.
|
||||||
|
- 🤖 **Jellyseerr integration**: Request media directly in the app.
|
||||||
|
|
||||||
## 🧪 Experimental Features
|
## 🧪 Experimental Features
|
||||||
|
|
||||||
@@ -70,11 +70,9 @@ Or download the APKs [here on GitHub](https://github.com/fredrikburmester/stream
|
|||||||
|
|
||||||
### Beta testing
|
### Beta testing
|
||||||
|
|
||||||
Get the latest updates by using the TestFlight version of the app.
|
To access the Streamyfin beta, you need to subscribe to the Member tier (or higher) on [Patreon](https://www.patreon.com/streamyfin). This will give you immediate access to the 🧪-public-beta channel on Discord and i'll know that you have subscribed. This is where i'll post APKs and IPAs. This won't give automatic access to the TestFlight however, so you need to send me a DM with the email you use for Apple so that i can manually add you.
|
||||||
|
|
||||||
<a href="https://testflight.apple.com/join/CWBaAAK2">
|
**Note**: Everyone who is actively contributing to the source code of Streamyfin will have automatic access to the betas.
|
||||||
<img height=75 alt="Get the beta on TestFlight" src="./assets/Get_the_beta_on_Testflight.svg"/>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
## 🚀 Getting Started
|
## 🚀 Getting Started
|
||||||
|
|
||||||
@@ -89,36 +87,10 @@ We welcome any help to make Streamyfin better. If you'd like to contribute, plea
|
|||||||
|
|
||||||
### Development info
|
### Development info
|
||||||
|
|
||||||
1. Use node `20`
|
1. Use node `>20`
|
||||||
2. Install dependencies `bun i`
|
2. Install dependencies `bun i && bun run submodule-reload`
|
||||||
3. Create an expo dev build by running `npx expo run:ios` or `npx expo run:android`.
|
3. Make sure you have xcode and/or android studio installed.
|
||||||
|
4. Create an expo dev build by running `npx expo run:ios` or `npx expo run:android`. This will open a simulator on you computer and run the app.
|
||||||
## Extended chromecast controls
|
|
||||||
|
|
||||||
Add this to AppDelegate.mm:
|
|
||||||
|
|
||||||
```
|
|
||||||
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
|
|
||||||
{
|
|
||||||
// @generated begin react-native-google-cast-didFinishLaunchingWithOptions - expo prebuild (DO NOT MODIFY) sync-8901be60b982d2ae9c658b1e8c50634d61bb5091
|
|
||||||
#if __has_include(<GoogleCast/GoogleCast.h>)
|
|
||||||
...
|
|
||||||
|
|
||||||
[GCKCastContext sharedInstance].useDefaultExpandedMediaControls = true;`
|
|
||||||
#endif
|
|
||||||
```
|
|
||||||
|
|
||||||
Add this to Info.plist:
|
|
||||||
|
|
||||||
```
|
|
||||||
<key>NSBonjourServices</key>
|
|
||||||
<array>
|
|
||||||
<string>_googlecast._tcp</string>
|
|
||||||
<string>_CC1AD845._googlecast._tcp</string>
|
|
||||||
</array>
|
|
||||||
<key>NSLocalNetworkUsageDescription</key>
|
|
||||||
<string>${PRODUCT_NAME} uses the local network to discover Cast-enabled devices on your WiFi network.</string>
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📄 License
|
## 📄 License
|
||||||
|
|
||||||
@@ -153,6 +125,7 @@ I'd like to thank the following people and projects for their contributions to S
|
|||||||
|
|
||||||
- [Reiverr](https://github.com/aleksilassila/reiverr) for great help with understanding the Jellyfin API.
|
- [Reiverr](https://github.com/aleksilassila/reiverr) for great help with understanding the Jellyfin API.
|
||||||
- [Jellyfin TS SDK](https://github.com/jellyfin/jellyfin-sdk-typescript) for the TypeScript SDK.
|
- [Jellyfin TS SDK](https://github.com/jellyfin/jellyfin-sdk-typescript) for the TypeScript SDK.
|
||||||
|
- [Jellyseerr](https://github.com/Fallenbagel/jellyseerr) for enabling API integration with their project.
|
||||||
- The Jellyfin devs for always being helpful in the Discord.
|
- The Jellyfin devs for always being helpful in the Discord.
|
||||||
|
|
||||||
## Star History
|
## Star History
|
||||||
|
|||||||
58
app.json
58
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.21.0",
|
"version": "0.23.0",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "streamyfin",
|
"scheme": "streamyfin",
|
||||||
@@ -23,7 +23,10 @@
|
|||||||
"NSLocalNetworkUsageDescription": "The app needs access to your local network to connect to your Jellyfin server.",
|
"NSLocalNetworkUsageDescription": "The app needs access to your local network to connect to your Jellyfin server.",
|
||||||
"NSAppTransportSecurity": {
|
"NSAppTransportSecurity": {
|
||||||
"NSAllowsArbitraryLoads": true
|
"NSAllowsArbitraryLoads": true
|
||||||
}
|
},
|
||||||
|
"UISupportsTrueScreenSizeOnMac": true,
|
||||||
|
"UIFileSharingEnabled": true,
|
||||||
|
"LSSupportsOpeningDocumentsInPlace": true
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"usesNonExemptEncryption": false
|
"usesNonExemptEncryption": false
|
||||||
@@ -33,67 +36,33 @@
|
|||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"jsEngine": "hermes",
|
"jsEngine": "hermes",
|
||||||
"versionCode": 46,
|
"versionCode": 49,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/images/adaptive_icon.png"
|
"foregroundImage": "./assets/images/adaptive_icon.png"
|
||||||
},
|
},
|
||||||
"package": "com.fredrikburmester.streamyfin",
|
"package": "com.fredrikburmester.streamyfin",
|
||||||
"permissions": [
|
"permissions": []
|
||||||
"android.permission.FOREGROUND_SERVICE",
|
|
||||||
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"expo-router",
|
"expo-router",
|
||||||
"expo-font",
|
"expo-font",
|
||||||
"@config-plugins/ffmpeg-kit-react-native",
|
"@config-plugins/ffmpeg-kit-react-native",
|
||||||
[
|
|
||||||
"react-native-google-cast",
|
|
||||||
{
|
|
||||||
"useDefaultExpandedMediaControls": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[
|
[
|
||||||
"react-native-video",
|
"react-native-video",
|
||||||
{
|
{
|
||||||
"enableNotificationControls": true,
|
"enableNotificationControls": true,
|
||||||
"enableBackgroundAudio": true,
|
"enableBackgroundAudio": true
|
||||||
"androidExtensions": {
|
|
||||||
"useExoplayerRtsp": false,
|
|
||||||
"useExoplayerSmoothStreaming": false,
|
|
||||||
"useExoplayerHls": true,
|
|
||||||
"useExoplayerDash": false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"expo-build-properties",
|
"expo-build-properties",
|
||||||
{
|
{
|
||||||
"ios": {
|
"ios": {
|
||||||
"deploymentTarget": "15.6"
|
"deploymentTarget": "15.6",
|
||||||
},
|
"useFrameworks": "static"
|
||||||
"android": {
|
|
||||||
"android": {
|
|
||||||
"compileSdkVersion": 34,
|
|
||||||
"targetSdkVersion": 34,
|
|
||||||
"buildToolsVersion": "34.0.0"
|
|
||||||
},
|
|
||||||
"minSdkVersion": 24,
|
|
||||||
"usesCleartextTraffic": true,
|
|
||||||
"packagingOptions": {
|
|
||||||
"jniLibs": {
|
|
||||||
"useLegacyPackaging": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
[
|
|
||||||
"expo-screen-orientation",
|
|
||||||
{
|
|
||||||
"initialOrientation": "DEFAULT"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[
|
[
|
||||||
"expo-sensors",
|
"expo-sensors",
|
||||||
{
|
{
|
||||||
@@ -101,12 +70,9 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"expo-asset",
|
"expo-asset",
|
||||||
[
|
["react-native-edge-to-edge"],
|
||||||
"react-native-edge-to-edge",
|
|
||||||
{ "android": { "parentTheme": "Material3" } }
|
|
||||||
],
|
|
||||||
["react-native-bottom-tabs"],
|
["react-native-bottom-tabs"],
|
||||||
["./plugins/withChangeNativeAndroidTextToWhite.js"]
|
["@react-native-tvos/config-tv"]
|
||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
"typedRoutes": true
|
"typedRoutes": true
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
import { Chromecast } from "@/components/Chromecast";
|
|
||||||
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
|
|
||||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
|
||||||
import { Feather } from "@expo/vector-icons";
|
import { Feather } from "@expo/vector-icons";
|
||||||
import { Stack, useRouter } from "expo-router";
|
import { Stack, useRouter } from "expo-router";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
|
||||||
export default function IndexLayout() {
|
export default function IndexLayout() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -22,7 +18,6 @@ export default function IndexLayout() {
|
|||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<View className="flex flex-row items-center space-x-2">
|
<View className="flex flex-row items-center space-x-2">
|
||||||
<Chromecast />
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.push("/(auth)/settings");
|
router.push("/(auth)/settings");
|
||||||
@@ -34,12 +29,6 @@ export default function IndexLayout() {
|
|||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
|
||||||
name="downloads"
|
|
||||||
options={{
|
|
||||||
title: "Downloads",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="settings"
|
name="settings"
|
||||||
options={{
|
options={{
|
||||||
|
|||||||
@@ -1,154 +0,0 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { ActiveDownloads } from "@/components/downloads/ActiveDownloads";
|
|
||||||
import { MovieCard } from "@/components/downloads/MovieCard";
|
|
||||||
import { SeriesCard } from "@/components/downloads/SeriesCard";
|
|
||||||
import { DownloadedItem, useDownload } from "@/providers/DownloadProvider";
|
|
||||||
import { queueAtom } from "@/utils/atoms/queue";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { useRouter } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
|
|
||||||
export default function page() {
|
|
||||||
const [queue, setQueue] = useAtom(queueAtom);
|
|
||||||
const { removeProcess, downloadedFiles } = useDownload();
|
|
||||||
const router = useRouter();
|
|
||||||
const [settings] = useSettings();
|
|
||||||
|
|
||||||
const movies = useMemo(() => {
|
|
||||||
try {
|
|
||||||
return downloadedFiles?.filter((f) => f.item.Type === "Movie") || [];
|
|
||||||
} catch {
|
|
||||||
migration_20241124();
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}, [downloadedFiles]);
|
|
||||||
|
|
||||||
const groupedBySeries = useMemo(() => {
|
|
||||||
try {
|
|
||||||
const episodes = downloadedFiles?.filter(
|
|
||||||
(f) => f.item.Type === "Episode"
|
|
||||||
);
|
|
||||||
const series: { [key: string]: DownloadedItem[] } = {};
|
|
||||||
episodes?.forEach((e) => {
|
|
||||||
if (!series[e.item.SeriesName!]) series[e.item.SeriesName!] = [];
|
|
||||||
series[e.item.SeriesName!].push(e);
|
|
||||||
});
|
|
||||||
return Object.values(series);
|
|
||||||
} catch {
|
|
||||||
migration_20241124();
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}, [downloadedFiles]);
|
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView
|
|
||||||
contentContainerStyle={{
|
|
||||||
paddingLeft: insets.left,
|
|
||||||
paddingRight: insets.right,
|
|
||||||
paddingBottom: 100,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View className="py-4">
|
|
||||||
<View className="mb-4 flex flex-col space-y-4 px-4">
|
|
||||||
{settings?.downloadMethod === "remux" && (
|
|
||||||
<View className="bg-neutral-900 p-4 rounded-2xl">
|
|
||||||
<Text className="text-lg font-bold">Queue</Text>
|
|
||||||
<Text className="text-xs opacity-70 text-red-600">
|
|
||||||
Queue and downloads will be lost on app restart
|
|
||||||
</Text>
|
|
||||||
<View className="flex flex-col space-y-2 mt-2">
|
|
||||||
{queue.map((q) => (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() =>
|
|
||||||
router.push(`/(auth)/items/page?id=${q.item.Id}`)
|
|
||||||
}
|
|
||||||
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
|
|
||||||
>
|
|
||||||
<View>
|
|
||||||
<Text className="font-semibold">{q.item.Name}</Text>
|
|
||||||
<Text className="text-xs opacity-50">{q.item.Type}</Text>
|
|
||||||
</View>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
removeProcess(q.id);
|
|
||||||
setQueue((prev) => {
|
|
||||||
if (!prev) return [];
|
|
||||||
return [...prev.filter((i) => i.id !== q.id)];
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name="close" size={24} color="red" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</TouchableOpacity>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{queue.length === 0 && (
|
|
||||||
<Text className="opacity-50">No items in queue</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ActiveDownloads />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{movies.length > 0 && (
|
|
||||||
<View className="mb-4">
|
|
||||||
<View className="flex flex-row items-center justify-between mb-2 px-4">
|
|
||||||
<Text className="text-lg font-bold">Movies</Text>
|
|
||||||
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
|
|
||||||
<Text className="text-xs font-bold">{movies?.length}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
|
||||||
<View className="px-4 flex flex-row">
|
|
||||||
{movies?.map((item) => (
|
|
||||||
<View className="mb-2 last:mb-0" key={item.item.Id}>
|
|
||||||
<MovieCard item={item.item} />
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{groupedBySeries?.map((items, index) => (
|
|
||||||
<SeriesCard
|
|
||||||
items={items.map((i) => i.item)}
|
|
||||||
key={items[0].item.SeriesId}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{downloadedFiles?.length === 0 && (
|
|
||||||
<View className="flex px-4">
|
|
||||||
<Text className="opacity-50">No downloaded items</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function migration_20241124() {
|
|
||||||
const router = useRouter();
|
|
||||||
const { deleteAllFiles } = useDownload();
|
|
||||||
Alert.alert(
|
|
||||||
"New app version requires re-download",
|
|
||||||
"The new update reqires content to be downloaded again. Please remove all downloaded content and try again.",
|
|
||||||
[
|
|
||||||
{
|
|
||||||
text: "Back",
|
|
||||||
onPress: () => router.back(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: "Delete",
|
|
||||||
style: "destructive",
|
|
||||||
onPress: async () => await deleteAllFiles(),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,6 @@ 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 { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||||
@@ -64,31 +63,10 @@ export default function index() {
|
|||||||
const [isConnected, setIsConnected] = useState<boolean | null>(null);
|
const [isConnected, setIsConnected] = useState<boolean | null>(null);
|
||||||
const [loadingRetry, setLoadingRetry] = useState(false);
|
const [loadingRetry, setLoadingRetry] = useState(false);
|
||||||
|
|
||||||
const { downloadedFiles } = useDownload();
|
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const hasDownloads = downloadedFiles && downloadedFiles.length > 0;
|
|
||||||
navigation.setOptions({
|
|
||||||
headerLeft: () => (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
router.push("/(auth)/downloads");
|
|
||||||
}}
|
|
||||||
className="p-2"
|
|
||||||
>
|
|
||||||
<Feather
|
|
||||||
name="download"
|
|
||||||
color={hasDownloads ? Colors.primary : "white"}
|
|
||||||
size={22}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}, [downloadedFiles, navigation, router]);
|
|
||||||
|
|
||||||
const checkConnection = useCallback(async () => {
|
const checkConnection = useCallback(async () => {
|
||||||
setLoadingRetry(true);
|
setLoadingRetry(true);
|
||||||
const state = await NetInfo.fetch();
|
const state = await NetInfo.fetch();
|
||||||
@@ -188,7 +166,7 @@ export default function index() {
|
|||||||
(
|
(
|
||||||
await getUserLibraryApi(api).getLatestMedia({
|
await getUserLibraryApi(api).getLatestMedia({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
limit: 50,
|
limit: 20,
|
||||||
fields: ["PrimaryImageAspectRatio", "Path"],
|
fields: ["PrimaryImageAspectRatio", "Path"],
|
||||||
imageTypeLimit: 1,
|
imageTypeLimit: 1,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
@@ -305,48 +283,6 @@ export default function index() {
|
|||||||
return ss;
|
return ss;
|
||||||
}, [api, user?.Id, collections, mediaListCollections]);
|
}, [api, user?.Id, collections, mediaListCollections]);
|
||||||
|
|
||||||
if (isConnected === false) {
|
|
||||||
return (
|
|
||||||
<View className="flex flex-col items-center justify-center h-full -mt-6 px-8">
|
|
||||||
<Text className="text-3xl font-bold mb-2">No Internet</Text>
|
|
||||||
<Text className="text-center opacity-70">
|
|
||||||
No worries, you can still watch{"\n"}downloaded content.
|
|
||||||
</Text>
|
|
||||||
<View className="mt-4">
|
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
onPress={() => router.push("/(auth)/downloads")}
|
|
||||||
justify="center"
|
|
||||||
iconRight={
|
|
||||||
<Ionicons name="arrow-forward" size={20} color="white" />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Go to downloads
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color="black"
|
|
||||||
onPress={() => {
|
|
||||||
checkConnection();
|
|
||||||
}}
|
|
||||||
justify="center"
|
|
||||||
className="mt-2"
|
|
||||||
iconRight={
|
|
||||||
loadingRetry ? null : (
|
|
||||||
<Ionicons name="refresh" size={20} color="white" />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{loadingRetry ? (
|
|
||||||
<ActivityIndicator size={"small"} color={"white"} />
|
|
||||||
) : (
|
|
||||||
"Retry"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e1 || e2)
|
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">
|
||||||
|
|||||||
@@ -2,30 +2,24 @@ import { Button } from "@/components/Button";
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ListItem } from "@/components/ListItem";
|
import { ListItem } from "@/components/ListItem";
|
||||||
import { SettingToggles } from "@/components/settings/SettingToggles";
|
import { SettingToggles } from "@/components/settings/SettingToggles";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
|
||||||
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { clearLogs, readFromLog } from "@/utils/log";
|
import { clearLogs, useLog } from "@/utils/log";
|
||||||
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 FileSystem from "expo-file-system";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { Alert, ScrollView, View } from "react-native";
|
import { Alert, ScrollView, View } from "react-native";
|
||||||
|
import * as Progress from "react-native-progress";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
|
|
||||||
export default function settings() {
|
export default function settings() {
|
||||||
const { logout } = useJellyfin();
|
const { logout } = useJellyfin();
|
||||||
const { deleteAllFiles } = useDownload();
|
const { logs } = useLog();
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
const { data: logs } = useQuery({
|
|
||||||
queryKey: ["logs"],
|
|
||||||
queryFn: async () => readFromLog(),
|
|
||||||
refetchInterval: 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const openQuickConnectAuthCodeInput = () => {
|
const openQuickConnectAuthCodeInput = () => {
|
||||||
@@ -40,16 +34,11 @@ export default function settings() {
|
|||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
});
|
});
|
||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
Haptics.notificationAsync(
|
|
||||||
Haptics.NotificationFeedbackType.Success
|
|
||||||
);
|
|
||||||
Alert.alert("Success", "Quick connect authorized");
|
Alert.alert("Success", "Quick connect authorized");
|
||||||
} else {
|
} else {
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
|
||||||
Alert.alert("Error", "Invalid code");
|
Alert.alert("Error", "Invalid code");
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
|
||||||
Alert.alert("Error", "Invalid code");
|
Alert.alert("Error", "Invalid code");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -66,13 +55,6 @@ export default function settings() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className="p-4 flex flex-col gap-y-4">
|
<View className="p-4 flex flex-col gap-y-4">
|
||||||
{/* <Button
|
|
||||||
onPress={() => {
|
|
||||||
registerBackgroundFetchAsync();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
registerBackgroundFetchAsync
|
|
||||||
</Button> */}
|
|
||||||
<View>
|
<View>
|
||||||
<Text className="font-bold text-lg mb-2">User Info</Text>
|
<Text className="font-bold text-lg mb-2">User Info</Text>
|
||||||
|
|
||||||
@@ -81,6 +63,9 @@ export default function settings() {
|
|||||||
<ListItem title="Server" subTitle={api?.basePath} />
|
<ListItem title="Server" subTitle={api?.basePath} />
|
||||||
<ListItem title="Token" subTitle={api?.accessToken} />
|
<ListItem title="Token" subTitle={api?.accessToken} />
|
||||||
</View>
|
</View>
|
||||||
|
<Button className="my-2.5" color="black" onPress={logout}>
|
||||||
|
Log out
|
||||||
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View>
|
<View>
|
||||||
@@ -92,43 +77,6 @@ export default function settings() {
|
|||||||
|
|
||||||
<SettingToggles />
|
<SettingToggles />
|
||||||
|
|
||||||
<View>
|
|
||||||
<Text className="font-bold text-lg mb-2">Account and storage</Text>
|
|
||||||
<View className="flex flex-col space-y-2">
|
|
||||||
<Button color="black" onPress={logout}>
|
|
||||||
Log out
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color="red"
|
|
||||||
onPress={async () => {
|
|
||||||
try {
|
|
||||||
await deleteAllFiles();
|
|
||||||
Haptics.notificationAsync(
|
|
||||||
Haptics.NotificationFeedbackType.Success
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
Haptics.notificationAsync(
|
|
||||||
Haptics.NotificationFeedbackType.Error
|
|
||||||
);
|
|
||||||
toast.error("Error deleting files");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Delete all downloaded files
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color="red"
|
|
||||||
onPress={async () => {
|
|
||||||
await clearLogs();
|
|
||||||
Haptics.notificationAsync(
|
|
||||||
Haptics.NotificationFeedbackType.Success
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Delete all logs
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<View>
|
<View>
|
||||||
<Text className="font-bold text-lg mb-2">Logs</Text>
|
<Text className="font-bold text-lg mb-2">Logs</Text>
|
||||||
<View className="flex flex-col space-y-2">
|
<View className="flex flex-col space-y-2">
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { Chromecast } from "@/components/Chromecast";
|
|
||||||
import { ItemImage } from "@/components/common/ItemImage";
|
import { ItemImage } from "@/components/common/ItemImage";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
@@ -28,16 +27,6 @@ export default function page() {
|
|||||||
|
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
navigation.setOptions({
|
|
||||||
headerRight: () => (
|
|
||||||
<View className="">
|
|
||||||
<Chromecast />
|
|
||||||
</View>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: album } = useQuery({
|
const { data: album } = useQuery({
|
||||||
queryKey: ["album", albumId, artistId],
|
queryKey: ["album", albumId, artistId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ import {
|
|||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { FlatList, View } from "react-native";
|
import { FlatList, View } from "react-native";
|
||||||
@@ -41,10 +40,6 @@ const page: React.FC = () => {
|
|||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const [orientation, setOrientation] = useState(
|
|
||||||
ScreenOrientation.Orientation.PORTRAIT_UP
|
|
||||||
);
|
|
||||||
|
|
||||||
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
|
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
|
||||||
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
||||||
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
|
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
|
||||||
@@ -104,9 +99,12 @@ const page: React.FC = () => {
|
|||||||
"CanDelete",
|
"CanDelete",
|
||||||
"MediaSourceCount",
|
"MediaSourceCount",
|
||||||
],
|
],
|
||||||
|
// true is needed for merged versions
|
||||||
|
recursive: true,
|
||||||
genres: selectedGenres,
|
genres: selectedGenres,
|
||||||
tags: selectedTags,
|
tags: selectedTags,
|
||||||
years: selectedYears.map((year) => parseInt(year)),
|
years: selectedYears.map((year) => parseInt(year)),
|
||||||
|
includeItemTypes: ["Movie", "Series", "MusicAlbum"],
|
||||||
});
|
});
|
||||||
|
|
||||||
return response.data || null;
|
return response.data || null;
|
||||||
@@ -171,8 +169,7 @@ const page: React.FC = () => {
|
|||||||
key={item.Id}
|
key={item.Id}
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
marginBottom:
|
marginBottom: 16,
|
||||||
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 4 : 16,
|
|
||||||
}}
|
}}
|
||||||
item={item}
|
item={item}
|
||||||
>
|
>
|
||||||
@@ -386,9 +383,7 @@ const page: React.FC = () => {
|
|||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
keyExtractor={keyExtractor}
|
keyExtractor={keyExtractor}
|
||||||
estimatedItemSize={255}
|
estimatedItemSize={255}
|
||||||
numColumns={
|
numColumns={5}
|
||||||
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5
|
|
||||||
}
|
|
||||||
onEndReached={() => {
|
onEndReached={() => {
|
||||||
if (hasNextPage) {
|
if (hasNextPage) {
|
||||||
fetchNextPage();
|
fetchNextPage();
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
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 { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
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";
|
||||||
@@ -48,20 +44,25 @@ const Page: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const fadeOut = (callback: any) => {
|
const fadeOut = (callback: any) => {
|
||||||
opacity.value = withTiming(0, { duration: 300 }, (finished) => {
|
setTimeout(() => {
|
||||||
if (finished) {
|
opacity.value = withTiming(0, { duration: 500 }, (finished) => {
|
||||||
runOnJS(callback)();
|
if (finished) {
|
||||||
}
|
runOnJS(callback)();
|
||||||
});
|
}
|
||||||
|
});
|
||||||
|
}, 100);
|
||||||
};
|
};
|
||||||
|
|
||||||
const fadeIn = (callback: any) => {
|
const fadeIn = (callback: any) => {
|
||||||
opacity.value = withTiming(1, { duration: 300 }, (finished) => {
|
setTimeout(() => {
|
||||||
if (finished) {
|
opacity.value = withTiming(1, { duration: 500 }, (finished) => {
|
||||||
runOnJS(callback)();
|
if (finished) {
|
||||||
}
|
runOnJS(callback)();
|
||||||
});
|
}
|
||||||
|
});
|
||||||
|
}, 100);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (item) {
|
if (item) {
|
||||||
fadeOut(() => {});
|
fadeOut(() => {});
|
||||||
@@ -84,14 +85,24 @@ const Page: React.FC = () => {
|
|||||||
style={[animatedStyle]}
|
style={[animatedStyle]}
|
||||||
className="absolute top-0 left-0 flex flex-col items-start h-screen w-screen px-4 z-50 bg-black"
|
className="absolute top-0 left-0 flex flex-col items-start h-screen w-screen px-4 z-50 bg-black"
|
||||||
>
|
>
|
||||||
<View className="h-[350px] bg-transparent rounded-lg mb-4 w-full"></View>
|
<View
|
||||||
<View className="h-6 bg-neutral-900 rounded mb-1 w-12"></View>
|
style={{
|
||||||
<View className="h-12 bg-neutral-900 rounded-lg mb-1 w-1/2"></View>
|
height: item?.Type === "Episode" ? 300 : 450,
|
||||||
<View className="h-12 bg-neutral-900 rounded-lg w-2/3 mb-10"></View>
|
}}
|
||||||
<View className="h-4 bg-neutral-900 rounded-lg mb-1 w-full"></View>
|
className="bg-transparent rounded-lg mb-4 w-full"
|
||||||
<View className="h-12 bg-neutral-900 rounded-lg mb-1 w-full"></View>
|
></View>
|
||||||
<View className="h-12 bg-neutral-900 rounded-lg mb-1 w-full"></View>
|
<View className="h-6 bg-neutral-900 rounded mb-4 w-14"></View>
|
||||||
<View className="h-4 bg-neutral-900 rounded-lg mb-1 w-1/4"></View>
|
<View className="h-10 bg-neutral-900 rounded-lg mb-2 w-1/2"></View>
|
||||||
|
<View className="h-3 bg-neutral-900 rounded mb-3 w-8"></View>
|
||||||
|
<View className="flex flex-row space-x-1 mb-8">
|
||||||
|
<View className="h-6 bg-neutral-900 rounded mb-3 w-14"></View>
|
||||||
|
<View className="h-6 bg-neutral-900 rounded mb-3 w-14"></View>
|
||||||
|
<View className="h-6 bg-neutral-900 rounded mb-3 w-14"></View>
|
||||||
|
</View>
|
||||||
|
<View className="h-3 bg-neutral-900 rounded w-2/3 mb-1"></View>
|
||||||
|
<View className="h-10 bg-neutral-900 rounded-lg w-full mb-2"></View>
|
||||||
|
<View className="h-12 bg-neutral-900 rounded-lg w-full mb-2"></View>
|
||||||
|
<View className="h-24 bg-neutral-900 rounded-lg mb-1 w-full"></View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
{item && <ItemContent item={item} />}
|
{item && <ItemContent item={item} />}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
320
app/(auth)/(tabs)/(home,libraries,search)/jellyseerr/page.tsx
Normal file
320
app/(auth)/(tabs)/(home,libraries,search)/jellyseerr/page.tsx
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
import { Button } from "@/components/Button";
|
||||||
|
import { Input } from "@/components/common/Input";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { GenreTags } from "@/components/GenreTags";
|
||||||
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
|
import { JellyserrRatings } from "@/components/Ratings";
|
||||||
|
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
|
||||||
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
|
import {
|
||||||
|
IssueType,
|
||||||
|
IssueTypeName,
|
||||||
|
} from "@/utils/jellyseerr/server/constants/issue";
|
||||||
|
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||||
|
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
|
||||||
|
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import {
|
||||||
|
BottomSheetBackdrop,
|
||||||
|
BottomSheetBackdropProps,
|
||||||
|
BottomSheetModal,
|
||||||
|
BottomSheetView,
|
||||||
|
} from "@gorhom/bottom-sheet";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import React, { useCallback, useRef, useState } from "react";
|
||||||
|
import { Modal, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
|
const Page: React.FC = () => {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const params = useLocalSearchParams();
|
||||||
|
const {
|
||||||
|
mediaTitle,
|
||||||
|
releaseYear,
|
||||||
|
canRequest: canRequestString,
|
||||||
|
posterSrc,
|
||||||
|
...result
|
||||||
|
} = params as unknown as {
|
||||||
|
mediaTitle: string;
|
||||||
|
releaseYear: number;
|
||||||
|
canRequest: string;
|
||||||
|
posterSrc: string;
|
||||||
|
} & Partial<MovieResult | TvResult>;
|
||||||
|
|
||||||
|
const canRequest = canRequestString === "true";
|
||||||
|
const { jellyseerrApi, requestMedia } = useJellyseerr();
|
||||||
|
|
||||||
|
const [issueType, setIssueType] = useState<IssueType>();
|
||||||
|
const [issueMessage, setIssueMessage] = useState<string>();
|
||||||
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
|
const [isIssueTypeModalVisible, setIsIssueTypeModalVisible] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: details,
|
||||||
|
isFetching,
|
||||||
|
isLoading,
|
||||||
|
} = useQuery({
|
||||||
|
enabled: !!jellyseerrApi && !!result && !!result.id,
|
||||||
|
queryKey: ["jellyseerr", "detail", result.mediaType, result.id],
|
||||||
|
staleTime: 0,
|
||||||
|
refetchOnMount: true,
|
||||||
|
refetchOnReconnect: true,
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
|
retryOnMount: true,
|
||||||
|
queryFn: async () => {
|
||||||
|
return result.mediaType === MediaType.MOVIE
|
||||||
|
? jellyseerrApi?.movieDetails(result.id!!)
|
||||||
|
: jellyseerrApi?.tvDetails(result.id!!);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderBackdrop = useCallback(
|
||||||
|
(props: BottomSheetBackdropProps) => (
|
||||||
|
<BottomSheetBackdrop
|
||||||
|
{...props}
|
||||||
|
disappearsOnIndex={-1}
|
||||||
|
appearsOnIndex={0}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const submitIssue = useCallback(() => {
|
||||||
|
if (result.id && issueType && issueMessage && details) {
|
||||||
|
jellyseerrApi
|
||||||
|
?.submitIssue(details.mediaInfo.id, Number(issueType), issueMessage)
|
||||||
|
.then(() => {
|
||||||
|
setIssueType(undefined);
|
||||||
|
setIssueMessage(undefined);
|
||||||
|
bottomSheetModalRef?.current?.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [jellyseerrApi, details, result, issueType, issueMessage]);
|
||||||
|
|
||||||
|
const request = useCallback(
|
||||||
|
() =>
|
||||||
|
requestMedia(mediaTitle, {
|
||||||
|
mediaId: Number(result.id!!),
|
||||||
|
mediaType: result.mediaType!!,
|
||||||
|
tvdbId: details?.externalIds?.tvdbId,
|
||||||
|
seasons: (details as TvDetails)?.seasons
|
||||||
|
?.filter?.((s) => s.seasonNumber !== 0)
|
||||||
|
?.map?.((s) => s.seasonNumber),
|
||||||
|
}),
|
||||||
|
[details, result, requestMedia]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className="flex-1 relative"
|
||||||
|
style={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ParallaxScrollView
|
||||||
|
className="flex-1 opacity-100"
|
||||||
|
headerHeight={300}
|
||||||
|
headerImage={
|
||||||
|
<View>
|
||||||
|
{result.backdropPath ? (
|
||||||
|
<Image
|
||||||
|
cachePolicy={"memory-disk"}
|
||||||
|
transition={300}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
source={{
|
||||||
|
uri: `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${result.backdropPath}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
className="flex flex-col items-center justify-center border border-neutral-800 bg-neutral-900"
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="image-outline"
|
||||||
|
size={24}
|
||||||
|
color="white"
|
||||||
|
style={{ opacity: 0.4 }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<View className="flex flex-col">
|
||||||
|
<View className="space-y-4">
|
||||||
|
<View className="px-4">
|
||||||
|
<View className="flex flex-row justify-between w-full">
|
||||||
|
<View className="flex flex-col w-56">
|
||||||
|
<JellyserrRatings result={result as MovieResult | TvResult} />
|
||||||
|
<Text
|
||||||
|
uiTextView
|
||||||
|
selectable
|
||||||
|
className="font-bold text-2xl mb-1"
|
||||||
|
>
|
||||||
|
{mediaTitle}
|
||||||
|
</Text>
|
||||||
|
<Text className="opacity-50">{releaseYear}</Text>
|
||||||
|
</View>
|
||||||
|
<Image
|
||||||
|
className="absolute bottom-1 right-1 rounded-lg w-28 aspect-[10/15] border-2 border-neutral-800/50 drop-shadow-2xl"
|
||||||
|
cachePolicy={"memory-disk"}
|
||||||
|
transition={300}
|
||||||
|
source={{
|
||||||
|
uri: posterSrc,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View className="mb-4">
|
||||||
|
<GenreTags genres={details?.genres?.map((g) => g.name) || []} />
|
||||||
|
</View>
|
||||||
|
{canRequest ? (
|
||||||
|
<Button color="purple" onPress={request}>
|
||||||
|
Request
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
className="bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100"
|
||||||
|
color="transparent"
|
||||||
|
onPress={() => bottomSheetModalRef?.current?.present()}
|
||||||
|
iconLeft={
|
||||||
|
<Ionicons name="warning-outline" size={24} color="white" />
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Report issue
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<OverviewText text={result.overview} className="mt-4" />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{result.mediaType === MediaType.TV && (
|
||||||
|
<JellyseerrSeasons
|
||||||
|
isLoading={isLoading || isFetching}
|
||||||
|
result={result as TvResult}
|
||||||
|
details={details as TvDetails}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ParallaxScrollView>
|
||||||
|
<BottomSheetModal
|
||||||
|
ref={bottomSheetModalRef}
|
||||||
|
enableDynamicSizing
|
||||||
|
handleIndicatorStyle={{
|
||||||
|
backgroundColor: "white",
|
||||||
|
}}
|
||||||
|
backgroundStyle={{
|
||||||
|
backgroundColor: "#171717",
|
||||||
|
}}
|
||||||
|
backdropComponent={renderBackdrop}
|
||||||
|
>
|
||||||
|
<BottomSheetView>
|
||||||
|
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
|
||||||
|
<View>
|
||||||
|
<Text className="font-bold text-2xl text-neutral-100">
|
||||||
|
Whats wrong?
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="flex flex-col space-y-2 items-start">
|
||||||
|
<View className="flex flex-col">
|
||||||
|
<View className="flex flex-col">
|
||||||
|
<Text className="opacity-50 mb-1 text-xs">Issue Type</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between"
|
||||||
|
onPress={() => setIsIssueTypeModalVisible(true)}
|
||||||
|
>
|
||||||
|
<Text className="" numberOfLines={1}>
|
||||||
|
{issueType ? IssueTypeName[issueType] : "Select an issue"}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name="chevron-down"
|
||||||
|
size={16}
|
||||||
|
color="white"
|
||||||
|
style={{ opacity: 0.5 }}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
visible={isIssueTypeModalVisible}
|
||||||
|
transparent
|
||||||
|
animationType="slide"
|
||||||
|
onRequestClose={() => setIsIssueTypeModalVisible(false)}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
className="flex-1 bg-black/50"
|
||||||
|
activeOpacity={1}
|
||||||
|
onPress={() => setIsIssueTypeModalVisible(false)}
|
||||||
|
>
|
||||||
|
<View className="mt-auto bg-neutral-900 rounded-t-xl">
|
||||||
|
<View className="p-4 border-b border-neutral-800">
|
||||||
|
<Text className="text-lg font-bold text-center">
|
||||||
|
Select Issue Type
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="max-h-[50%]">
|
||||||
|
{Object.entries(IssueTypeName)
|
||||||
|
.reverse()
|
||||||
|
.map(([key, value]) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={key}
|
||||||
|
className="p-4 border-b border-neutral-800"
|
||||||
|
onPress={() => {
|
||||||
|
setIssueType(key as unknown as IssueType);
|
||||||
|
setIsIssueTypeModalVisible(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className="text-center">{value}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
className="p-4 border-t border-neutral-800"
|
||||||
|
onPress={() => setIsIssueTypeModalVisible(false)}
|
||||||
|
>
|
||||||
|
<Text className="text-center text-purple-400">
|
||||||
|
Cancel
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Modal>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
className="w-full"
|
||||||
|
placeholder="(optional) Describe the issue..."
|
||||||
|
value={issueMessage}
|
||||||
|
keyboardType="default"
|
||||||
|
returnKeyType="done"
|
||||||
|
autoCapitalize="none"
|
||||||
|
textContentType="none"
|
||||||
|
maxLength={254}
|
||||||
|
onChangeText={setIssueMessage}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Button className="mt-auto" onPress={submitIssue} color="purple">
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</BottomSheetView>
|
||||||
|
</BottomSheetModal>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Page;
|
||||||
@@ -1,13 +1,21 @@
|
|||||||
import { ItemImage } from "@/components/common/ItemImage";
|
import { ItemImage } from "@/components/common/ItemImage";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
import { HourHeader } from "@/components/livetv/HourHeader";
|
import { HourHeader } from "@/components/livetv/HourHeader";
|
||||||
import { LiveTVGuideRow } from "@/components/livetv/LiveTVGuideRow";
|
import { LiveTVGuideRow } from "@/components/livetv/LiveTVGuideRow";
|
||||||
import { TAB_HEIGHT } from "@/constants/Values";
|
import { TAB_HEIGHT } from "@/constants/Values";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useCallback, useMemo, useState } from "react";
|
import React, { useCallback, useMemo, useState } from "react";
|
||||||
import { Button, Dimensions, ScrollView, View } from "react-native";
|
import {
|
||||||
|
Button,
|
||||||
|
Dimensions,
|
||||||
|
ScrollView,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
const HOUR_HEIGHT = 30;
|
const HOUR_HEIGHT = 30;
|
||||||
@@ -78,8 +86,6 @@ export default function page() {
|
|||||||
|
|
||||||
const screenWidth = Dimensions.get("window").width;
|
const screenWidth = Dimensions.get("window").width;
|
||||||
|
|
||||||
const memoizedChannels = useMemo(() => channels?.Items || [], [channels]);
|
|
||||||
|
|
||||||
const [scrollX, setScrollX] = useState(0);
|
const [scrollX, setScrollX] = useState(0);
|
||||||
|
|
||||||
const handleNextPage = useCallback(() => {
|
const handleNextPage = useCallback(() => {
|
||||||
@@ -100,24 +106,15 @@ export default function page() {
|
|||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
paddingBottom: 16,
|
paddingBottom: 16,
|
||||||
}}
|
}}
|
||||||
style={{
|
|
||||||
marginBottom: TAB_HEIGHT,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<View className="flex flex-row bg-neutral-800 w-full items-end">
|
<PageButtons
|
||||||
<Button
|
currentPage={currentPage}
|
||||||
title="Previous"
|
onPrevPage={handlePrevPage}
|
||||||
onPress={handlePrevPage}
|
onNextPage={handleNextPage}
|
||||||
disabled={currentPage === 1}
|
isNextDisabled={
|
||||||
/>
|
!channels || (channels?.Items?.length || 0) < ITEMS_PER_PAGE
|
||||||
<Button
|
}
|
||||||
title="Next"
|
/>
|
||||||
onPress={handleNextPage}
|
|
||||||
disabled={
|
|
||||||
!channels || (channels?.Items?.length || 0) < ITEMS_PER_PAGE
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className="flex flex-row">
|
<View className="flex flex-row">
|
||||||
<View className="flex flex-col w-[64px]">
|
<View className="flex flex-col w-[64px]">
|
||||||
@@ -166,3 +163,57 @@ export default function page() {
|
|||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PageButtonsProps {
|
||||||
|
currentPage: number;
|
||||||
|
onPrevPage: () => void;
|
||||||
|
onNextPage: () => void;
|
||||||
|
isNextDisabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PageButtons: React.FC<PageButtonsProps> = ({
|
||||||
|
currentPage,
|
||||||
|
onPrevPage,
|
||||||
|
onNextPage,
|
||||||
|
isNextDisabled,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<View className="flex flex-row justify-between items-center bg-neutral-800 w-full px-4 py-2">
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onPrevPage}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className="flex flex-row items-center"
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="chevron-back"
|
||||||
|
size={24}
|
||||||
|
color={currentPage === 1 ? "gray" : "white"}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
className={`ml-1 ${
|
||||||
|
currentPage === 1 ? "text-gray-500" : "text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text className="text-white">Page {currentPage}</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onNextPage}
|
||||||
|
disabled={isNextDisabled}
|
||||||
|
className="flex flex-row items-center"
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={`mr-1 ${isNextDisabled ? "text-gray-500" : "text-white"}`}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name="chevron-forward"
|
||||||
|
size={24}
|
||||||
|
color={isNextDisabled ? "gray" : "white"}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -5,10 +5,7 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|||||||
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import {
|
import { ScrollView, View } from "react-native";
|
||||||
ScrollView,
|
|
||||||
View
|
|
||||||
} from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
@@ -27,9 +24,6 @@ export default function page() {
|
|||||||
paddingBottom: 16,
|
paddingBottom: 16,
|
||||||
paddingTop: 8,
|
paddingTop: 8,
|
||||||
}}
|
}}
|
||||||
style={{
|
|
||||||
marginBottom: TAB_HEIGHT,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<View className="flex flex-col space-y-2">
|
<View className="flex flex-col space-y-2">
|
||||||
<ScrollingCollectionList
|
<ScrollingCollectionList
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
import { NextUp } from "@/components/series/NextUp";
|
import { NextUp } from "@/components/series/NextUp";
|
||||||
import { SeasonPicker } from "@/components/series/SeasonPicker";
|
import { SeasonPicker } from "@/components/series/SeasonPicker";
|
||||||
|
import { SeriesHeader } from "@/components/series/SeriesHeader";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React from "react";
|
import React, { useEffect, useMemo } from "react";
|
||||||
import { useEffect, useMemo } from "react";
|
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
|
|
||||||
const page: React.FC = () => {
|
const page: React.FC = () => {
|
||||||
|
const navigation = useNavigation();
|
||||||
const params = useLocalSearchParams();
|
const params = useLocalSearchParams();
|
||||||
const { id: seriesId, seasonIndex } = params as {
|
const { id: seriesId, seasonIndex } = params as {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -56,6 +58,21 @@ const page: React.FC = () => {
|
|||||||
[item]
|
[item]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { data: allEpisodes, isLoading } = useQuery({
|
||||||
|
queryKey: ["AllEpisodes", item?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await getTvShowsApi(api!).getEpisodes({
|
||||||
|
seriesId: item?.Id!,
|
||||||
|
userId: user?.Id!,
|
||||||
|
enableUserData: true,
|
||||||
|
fields: ["MediaSources", "MediaStreams", "Overview"],
|
||||||
|
});
|
||||||
|
return res?.data.Items || [];
|
||||||
|
},
|
||||||
|
staleTime: 60,
|
||||||
|
enabled: !!api && !!user?.Id && !!item?.Id,
|
||||||
|
});
|
||||||
|
|
||||||
if (!item || !backdropUrl) return null;
|
if (!item || !backdropUrl) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -90,10 +107,7 @@ const page: React.FC = () => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<View className="flex flex-col pt-4">
|
<View className="flex flex-col pt-4">
|
||||||
<View className="px-4 py-4">
|
<SeriesHeader item={item} />
|
||||||
<Text className="text-3xl font-bold">{item?.Name}</Text>
|
|
||||||
<Text className="">{item?.Overview}</Text>
|
|
||||||
</View>
|
|
||||||
<View className="mb-4">
|
<View className="mb-4">
|
||||||
<NextUp seriesId={seriesId} />
|
<NextUp seriesId={seriesId} />
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
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, useMemo } from "react";
|
||||||
import { FlatList, useWindowDimensions, View } from "react-native";
|
import { FlatList, useWindowDimensions, View } from "react-native";
|
||||||
@@ -12,7 +11,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,
|
||||||
@@ -32,6 +30,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
BaseItemDtoQueryResult,
|
BaseItemDtoQueryResult,
|
||||||
|
BaseItemKind,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import {
|
import {
|
||||||
getFilterApi,
|
getFilterApi,
|
||||||
@@ -40,8 +39,7 @@ import {
|
|||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { colletionTypeToItemType } from "@/utils/collectionTypeToItemType";
|
||||||
const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter);
|
|
||||||
|
|
||||||
const Page = () => {
|
const Page = () => {
|
||||||
const searchParams = useLocalSearchParams();
|
const searchParams = useLocalSearchParams();
|
||||||
@@ -61,8 +59,6 @@ const Page = () => {
|
|||||||
sortOrderPreferenceAtom
|
sortOrderPreferenceAtom
|
||||||
);
|
);
|
||||||
|
|
||||||
const { orientation } = useOrientation();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
|
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
|
||||||
if (sop) {
|
if (sop) {
|
||||||
@@ -141,6 +137,18 @@ const Page = () => {
|
|||||||
}): Promise<BaseItemDtoQueryResult | null> => {
|
}): Promise<BaseItemDtoQueryResult | null> => {
|
||||||
if (!api || !library) return null;
|
if (!api || !library) return null;
|
||||||
|
|
||||||
|
let itemType: BaseItemKind | undefined;
|
||||||
|
|
||||||
|
// This fix makes sure to only return 1 type of items, if defined.
|
||||||
|
// This is because the underlying directory some times contains other types, and we don't want to show them.
|
||||||
|
if (library.CollectionType === "movies") {
|
||||||
|
itemType = "Movie";
|
||||||
|
} else if (library.CollectionType === "tvshows") {
|
||||||
|
itemType = "Series";
|
||||||
|
} else if (library.CollectionType === "boxsets") {
|
||||||
|
itemType = "BoxSet";
|
||||||
|
}
|
||||||
|
|
||||||
const response = await getItemsApi(api).getItems({
|
const response = await getItemsApi(api).getItems({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
parentId: libraryId,
|
parentId: libraryId,
|
||||||
@@ -149,12 +157,14 @@ const Page = () => {
|
|||||||
sortBy: [sortBy[0], "SortName", "ProductionYear"],
|
sortBy: [sortBy[0], "SortName", "ProductionYear"],
|
||||||
sortOrder: [sortOrder[0]],
|
sortOrder: [sortOrder[0]],
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
|
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
|
||||||
recursive: false,
|
// true is needed for merged versions
|
||||||
|
recursive: true,
|
||||||
imageTypeLimit: 1,
|
imageTypeLimit: 1,
|
||||||
fields: ["PrimaryImageAspectRatio", "SortName"],
|
fields: ["PrimaryImageAspectRatio", "SortName"],
|
||||||
genres: selectedGenres,
|
genres: selectedGenres,
|
||||||
tags: selectedTags,
|
tags: selectedTags,
|
||||||
years: selectedYears.map((year) => parseInt(year)),
|
years: selectedYears.map((year) => parseInt(year)),
|
||||||
|
includeItemTypes: itemType ? [itemType] : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
return response.data || null;
|
return response.data || null;
|
||||||
@@ -227,14 +237,7 @@ const Page = () => {
|
|||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
alignSelf:
|
alignSelf: "center",
|
||||||
orientation === ScreenOrientation.OrientationLock.PORTRAIT_UP
|
|
||||||
? index % nrOfCols === 0
|
|
||||||
? "flex-end"
|
|
||||||
: (index + 1) % nrOfCols === 0
|
|
||||||
? "flex-start"
|
|
||||||
: "center"
|
|
||||||
: "center",
|
|
||||||
width: "89%",
|
width: "89%",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,12 +1,44 @@
|
|||||||
|
import { Text } from "@/components/common/Text";
|
||||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||||
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 { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
import { Platform } from "react-native";
|
import { useState } from "react";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
import { Modal, Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
|
||||||
export default function IndexLayout() {
|
export default function IndexLayout() {
|
||||||
const [settings, updateSettings] = useSettings();
|
const [settings, updateSettings] = useSettings();
|
||||||
|
const [isMenuVisible, setIsMenuVisible] = useState(false);
|
||||||
|
const [activeSubmenu, setActiveSubmenu] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const MenuItem = ({
|
||||||
|
label,
|
||||||
|
selected,
|
||||||
|
onPress,
|
||||||
|
disabled = false,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
selected?: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
className={`p-4 border-b border-neutral-800 flex-row items-center justify-between ${
|
||||||
|
disabled ? "opacity-50" : ""
|
||||||
|
}`}
|
||||||
|
onPress={onPress}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<Text className="text-base">{label}</Text>
|
||||||
|
{selected && <Ionicons name="checkmark" size={24} color="white" />}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
|
||||||
|
const MenuSection = ({ title }: { title: string }) => (
|
||||||
|
<View className="p-4 border-b border-neutral-800 bg-neutral-800/30">
|
||||||
|
<Text className="text-sm opacity-50 font-medium">{title}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
if (!settings?.libraryOptions) return null;
|
if (!settings?.libraryOptions) return null;
|
||||||
|
|
||||||
@@ -22,163 +54,167 @@ export default function IndexLayout() {
|
|||||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<DropdownMenu.Root>
|
<Modal
|
||||||
<DropdownMenu.Trigger>
|
visible={isMenuVisible}
|
||||||
<Ionicons
|
transparent
|
||||||
name="ellipsis-horizontal-outline"
|
animationType="slide"
|
||||||
size={24}
|
onRequestClose={() => {
|
||||||
color="white"
|
setIsMenuVisible(false);
|
||||||
/>
|
setActiveSubmenu(null);
|
||||||
</DropdownMenu.Trigger>
|
}}
|
||||||
<DropdownMenu.Content
|
>
|
||||||
align={"end"}
|
<TouchableOpacity
|
||||||
alignOffset={-10}
|
className="flex-1 bg-black/50"
|
||||||
avoidCollisions={false}
|
activeOpacity={1}
|
||||||
collisionPadding={0}
|
onPress={() => {
|
||||||
loop={false}
|
setIsMenuVisible(false);
|
||||||
side={"bottom"}
|
setActiveSubmenu(null);
|
||||||
sideOffset={10}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.Label>Display</DropdownMenu.Label>
|
<View className="mt-auto bg-neutral-900 rounded-t-xl">
|
||||||
<DropdownMenu.Group key="display-group">
|
{!activeSubmenu ? (
|
||||||
<DropdownMenu.Sub>
|
<>
|
||||||
<DropdownMenu.SubTrigger key="image-style-trigger">
|
<MenuSection title="Display" />
|
||||||
Display
|
<MenuItem
|
||||||
</DropdownMenu.SubTrigger>
|
label="Display"
|
||||||
<DropdownMenu.SubContent
|
onPress={() => setActiveSubmenu("display")}
|
||||||
alignOffset={-10}
|
/>
|
||||||
avoidCollisions={true}
|
<MenuItem
|
||||||
collisionPadding={0}
|
label="Image style"
|
||||||
loop={true}
|
onPress={() => setActiveSubmenu("imageStyle")}
|
||||||
sideOffset={10}
|
/>
|
||||||
>
|
<MenuItem
|
||||||
<DropdownMenu.CheckboxItem
|
label="Show titles"
|
||||||
key="display-option-1"
|
selected={settings.libraryOptions.showTitles}
|
||||||
value={settings.libraryOptions.display === "row"}
|
disabled={
|
||||||
onValueChange={() =>
|
settings.libraryOptions.imageStyle === "poster"
|
||||||
|
}
|
||||||
|
onPress={() => {
|
||||||
|
if (settings.libraryOptions.imageStyle === "poster")
|
||||||
|
return;
|
||||||
|
updateSettings({
|
||||||
|
libraryOptions: {
|
||||||
|
...settings.libraryOptions,
|
||||||
|
showTitles: !settings.libraryOptions.showTitles,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<MenuItem
|
||||||
|
label="Show stats"
|
||||||
|
selected={settings.libraryOptions.showStats}
|
||||||
|
onPress={() => {
|
||||||
|
updateSettings({
|
||||||
|
libraryOptions: {
|
||||||
|
...settings.libraryOptions,
|
||||||
|
showStats: !settings.libraryOptions.showStats,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : activeSubmenu === "display" ? (
|
||||||
|
<>
|
||||||
|
<View className="p-4 border-b border-neutral-800 flex-row items-center">
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setActiveSubmenu(null)}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="chevron-back"
|
||||||
|
size={24}
|
||||||
|
color="white"
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text className="text-lg font-bold ml-2">Display</Text>
|
||||||
|
</View>
|
||||||
|
<MenuItem
|
||||||
|
label="Row"
|
||||||
|
selected={settings.libraryOptions.display === "row"}
|
||||||
|
onPress={() => {
|
||||||
updateSettings({
|
updateSettings({
|
||||||
libraryOptions: {
|
libraryOptions: {
|
||||||
...settings.libraryOptions,
|
...settings.libraryOptions,
|
||||||
display: "row",
|
display: "row",
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
setActiveSubmenu(null);
|
||||||
>
|
}}
|
||||||
<DropdownMenu.ItemIndicator />
|
/>
|
||||||
<DropdownMenu.ItemTitle key="display-title-1">
|
<MenuItem
|
||||||
Row
|
label="List"
|
||||||
</DropdownMenu.ItemTitle>
|
selected={settings.libraryOptions.display === "list"}
|
||||||
</DropdownMenu.CheckboxItem>
|
onPress={() => {
|
||||||
<DropdownMenu.CheckboxItem
|
|
||||||
key="display-option-2"
|
|
||||||
value={settings.libraryOptions.display === "list"}
|
|
||||||
onValueChange={() =>
|
|
||||||
updateSettings({
|
updateSettings({
|
||||||
libraryOptions: {
|
libraryOptions: {
|
||||||
...settings.libraryOptions,
|
...settings.libraryOptions,
|
||||||
display: "list",
|
display: "list",
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
setActiveSubmenu(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : activeSubmenu === "imageStyle" ? (
|
||||||
|
<>
|
||||||
|
<View className="p-4 border-b border-neutral-800 flex-row items-center">
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setActiveSubmenu(null)}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="chevron-back"
|
||||||
|
size={24}
|
||||||
|
color="white"
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text className="text-lg font-bold ml-2">
|
||||||
|
Image Style
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<MenuItem
|
||||||
|
label="Poster"
|
||||||
|
selected={
|
||||||
|
settings.libraryOptions.imageStyle === "poster"
|
||||||
}
|
}
|
||||||
>
|
onPress={() => {
|
||||||
<DropdownMenu.ItemIndicator />
|
|
||||||
<DropdownMenu.ItemTitle key="display-title-2">
|
|
||||||
List
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.CheckboxItem>
|
|
||||||
</DropdownMenu.SubContent>
|
|
||||||
</DropdownMenu.Sub>
|
|
||||||
<DropdownMenu.Sub>
|
|
||||||
<DropdownMenu.SubTrigger key="image-style-trigger">
|
|
||||||
Image style
|
|
||||||
</DropdownMenu.SubTrigger>
|
|
||||||
<DropdownMenu.SubContent
|
|
||||||
alignOffset={-10}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={0}
|
|
||||||
loop={true}
|
|
||||||
sideOffset={10}
|
|
||||||
>
|
|
||||||
<DropdownMenu.CheckboxItem
|
|
||||||
key="poster-option"
|
|
||||||
value={settings.libraryOptions.imageStyle === "poster"}
|
|
||||||
onValueChange={() =>
|
|
||||||
updateSettings({
|
updateSettings({
|
||||||
libraryOptions: {
|
libraryOptions: {
|
||||||
...settings.libraryOptions,
|
...settings.libraryOptions,
|
||||||
imageStyle: "poster",
|
imageStyle: "poster",
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
setActiveSubmenu(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<MenuItem
|
||||||
|
label="Cover"
|
||||||
|
selected={
|
||||||
|
settings.libraryOptions.imageStyle === "cover"
|
||||||
}
|
}
|
||||||
>
|
onPress={() => {
|
||||||
<DropdownMenu.ItemIndicator />
|
|
||||||
<DropdownMenu.ItemTitle key="poster-title">
|
|
||||||
Poster
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.CheckboxItem>
|
|
||||||
<DropdownMenu.CheckboxItem
|
|
||||||
key="cover-option"
|
|
||||||
value={settings.libraryOptions.imageStyle === "cover"}
|
|
||||||
onValueChange={() =>
|
|
||||||
updateSettings({
|
updateSettings({
|
||||||
libraryOptions: {
|
libraryOptions: {
|
||||||
...settings.libraryOptions,
|
...settings.libraryOptions,
|
||||||
imageStyle: "cover",
|
imageStyle: "cover",
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
setActiveSubmenu(null);
|
||||||
>
|
}}
|
||||||
<DropdownMenu.ItemIndicator />
|
/>
|
||||||
<DropdownMenu.ItemTitle key="cover-title">
|
</>
|
||||||
Cover
|
) : null}
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.CheckboxItem>
|
|
||||||
</DropdownMenu.SubContent>
|
|
||||||
</DropdownMenu.Sub>
|
|
||||||
</DropdownMenu.Group>
|
|
||||||
<DropdownMenu.Group key="show-titles-group">
|
|
||||||
<DropdownMenu.CheckboxItem
|
|
||||||
disabled={settings.libraryOptions.imageStyle === "poster"}
|
|
||||||
key="show-titles-option"
|
|
||||||
value={settings.libraryOptions.showTitles}
|
|
||||||
onValueChange={(newValue) => {
|
|
||||||
if (settings.libraryOptions.imageStyle === "poster")
|
|
||||||
return;
|
|
||||||
updateSettings({
|
|
||||||
libraryOptions: {
|
|
||||||
...settings.libraryOptions,
|
|
||||||
showTitles: newValue === "on" ? true : false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemIndicator />
|
|
||||||
<DropdownMenu.ItemTitle key="show-titles-title">
|
|
||||||
Show titles
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.CheckboxItem>
|
|
||||||
<DropdownMenu.CheckboxItem
|
|
||||||
key="show-stats-option"
|
|
||||||
value={settings.libraryOptions.showStats}
|
|
||||||
onValueChange={(newValue) => {
|
|
||||||
updateSettings({
|
|
||||||
libraryOptions: {
|
|
||||||
...settings.libraryOptions,
|
|
||||||
showStats: newValue === "on" ? true : false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemIndicator />
|
|
||||||
<DropdownMenu.ItemTitle key="show-stats-title">
|
|
||||||
Show stats
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.CheckboxItem>
|
|
||||||
</DropdownMenu.Group>
|
|
||||||
|
|
||||||
<DropdownMenu.Separator />
|
<TouchableOpacity
|
||||||
</DropdownMenu.Content>
|
className="p-4 border-t border-neutral-800"
|
||||||
</DropdownMenu.Root>
|
onPress={() => {
|
||||||
|
setIsMenuVisible(false);
|
||||||
|
setActiveSubmenu(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className="text-center text-purple-400">Done</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Modal>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
import {commonScreenOptions, nestedTabPageScreenOptions} from "@/components/stacks/NestedTabPageStack";
|
||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
@@ -29,6 +29,10 @@ export default function SearchLayout() {
|
|||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="jellyseerr/page"
|
||||||
|
options={commonScreenOptions}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import axios from "axios";
|
|||||||
import { Href, router, useLocalSearchParams, useNavigation } from "expo-router";
|
import { Href, router, useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, {
|
import React, {
|
||||||
|
PropsWithChildren,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
@@ -29,6 +30,15 @@ import React, {
|
|||||||
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useDebounce } from "use-debounce";
|
import { useDebounce } from "use-debounce";
|
||||||
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
|
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
|
||||||
|
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||||
|
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
||||||
|
import { Tag } from "@/components/GenreTags";
|
||||||
|
import DiscoverSlide from "@/components/jellyseerr/DiscoverSlide";
|
||||||
|
import { sortBy } from "lodash";
|
||||||
|
|
||||||
|
type SearchType = "Library" | "Discover";
|
||||||
|
|
||||||
const exampleSearches = [
|
const exampleSearches = [
|
||||||
"Lord of the rings",
|
"Lord of the rings",
|
||||||
@@ -45,6 +55,7 @@ export default function search() {
|
|||||||
|
|
||||||
const { q, prev } = params as { q: string; prev: Href<string> };
|
const { q, prev } = params as { q: string; prev: Href<string> };
|
||||||
|
|
||||||
|
const [searchType, setSearchType] = useState<SearchType>("Library");
|
||||||
const [search, setSearch] = useState<string>("");
|
const [search, setSearch] = useState<string>("");
|
||||||
|
|
||||||
const [debouncedSearch] = useDebounce(search, 500);
|
const [debouncedSearch] = useDebounce(search, 500);
|
||||||
@@ -53,6 +64,7 @@ export default function search() {
|
|||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
|
const { jellyseerrApi } = useJellyseerr();
|
||||||
|
|
||||||
const searchEngine = useMemo(() => {
|
const searchEngine = useMemo(() => {
|
||||||
return settings?.searchEngine || "Jellyfin";
|
return settings?.searchEngine || "Jellyfin";
|
||||||
@@ -132,9 +144,51 @@ export default function search() {
|
|||||||
query: debouncedSearch,
|
query: debouncedSearch,
|
||||||
types: ["Movie"],
|
types: ["Movie"],
|
||||||
}),
|
}),
|
||||||
enabled: debouncedSearch.length > 0,
|
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: jellyseerrResults, isFetching: j1 } = useQuery({
|
||||||
|
queryKey: ["search", "jellyseerrResults", debouncedSearch],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await jellyseerrApi?.search({
|
||||||
|
query: new URLSearchParams(debouncedSearch).toString(),
|
||||||
|
page: 1, // todo: maybe rework page & page-size if first results are not enough...
|
||||||
|
language: "en",
|
||||||
|
});
|
||||||
|
|
||||||
|
return response?.results;
|
||||||
|
},
|
||||||
|
enabled:
|
||||||
|
!!jellyseerrApi &&
|
||||||
|
searchType === "Discover" &&
|
||||||
|
debouncedSearch.length > 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: jellyseerrDiscoverSettings, isFetching: j2 } = useQuery({
|
||||||
|
queryKey: ["search", "jellyseerrDiscoverSettings", debouncedSearch],
|
||||||
|
queryFn: async () => jellyseerrApi?.discoverSettings(),
|
||||||
|
enabled:
|
||||||
|
!!jellyseerrApi &&
|
||||||
|
searchType === "Discover" &&
|
||||||
|
debouncedSearch.length == 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const jellyseerrMovieResults: MovieResult[] | undefined = useMemo(
|
||||||
|
() =>
|
||||||
|
jellyseerrResults?.filter(
|
||||||
|
(r) => r.mediaType === MediaType.MOVIE
|
||||||
|
) as MovieResult[],
|
||||||
|
[jellyseerrResults]
|
||||||
|
);
|
||||||
|
|
||||||
|
const jellyseerrTvResults: TvResult[] | undefined = useMemo(
|
||||||
|
() =>
|
||||||
|
jellyseerrResults?.filter(
|
||||||
|
(r) => r.mediaType === MediaType.TV
|
||||||
|
) as TvResult[],
|
||||||
|
[jellyseerrResults]
|
||||||
|
);
|
||||||
|
|
||||||
const { data: series, isFetching: l2 } = useQuery({
|
const { data: series, isFetching: l2 } = useQuery({
|
||||||
queryKey: ["search", "series", debouncedSearch],
|
queryKey: ["search", "series", debouncedSearch],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
@@ -142,7 +196,7 @@ export default function search() {
|
|||||||
query: debouncedSearch,
|
query: debouncedSearch,
|
||||||
types: ["Series"],
|
types: ["Series"],
|
||||||
}),
|
}),
|
||||||
enabled: debouncedSearch.length > 0,
|
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: episodes, isFetching: l3 } = useQuery({
|
const { data: episodes, isFetching: l3 } = useQuery({
|
||||||
@@ -152,7 +206,7 @@ export default function search() {
|
|||||||
query: debouncedSearch,
|
query: debouncedSearch,
|
||||||
types: ["Episode"],
|
types: ["Episode"],
|
||||||
}),
|
}),
|
||||||
enabled: debouncedSearch.length > 0,
|
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: collections, isFetching: l7 } = useQuery({
|
const { data: collections, isFetching: l7 } = useQuery({
|
||||||
@@ -162,7 +216,7 @@ export default function search() {
|
|||||||
query: debouncedSearch,
|
query: debouncedSearch,
|
||||||
types: ["BoxSet"],
|
types: ["BoxSet"],
|
||||||
}),
|
}),
|
||||||
enabled: debouncedSearch.length > 0,
|
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: actors, isFetching: l8 } = useQuery({
|
const { data: actors, isFetching: l8 } = useQuery({
|
||||||
@@ -172,7 +226,7 @@ export default function search() {
|
|||||||
query: debouncedSearch,
|
query: debouncedSearch,
|
||||||
types: ["Person"],
|
types: ["Person"],
|
||||||
}),
|
}),
|
||||||
enabled: debouncedSearch.length > 0,
|
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: artists, isFetching: l4 } = useQuery({
|
const { data: artists, isFetching: l4 } = useQuery({
|
||||||
@@ -182,7 +236,7 @@ export default function search() {
|
|||||||
query: debouncedSearch,
|
query: debouncedSearch,
|
||||||
types: ["MusicArtist"],
|
types: ["MusicArtist"],
|
||||||
}),
|
}),
|
||||||
enabled: debouncedSearch.length > 0,
|
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: albums, isFetching: l5 } = useQuery({
|
const { data: albums, isFetching: l5 } = useQuery({
|
||||||
@@ -192,7 +246,7 @@ export default function search() {
|
|||||||
query: debouncedSearch,
|
query: debouncedSearch,
|
||||||
types: ["MusicAlbum"],
|
types: ["MusicAlbum"],
|
||||||
}),
|
}),
|
||||||
enabled: debouncedSearch.length > 0,
|
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: songs, isFetching: l6 } = useQuery({
|
const { data: songs, isFetching: l6 } = useQuery({
|
||||||
@@ -202,7 +256,7 @@ export default function search() {
|
|||||||
query: debouncedSearch,
|
query: debouncedSearch,
|
||||||
types: ["Audio"],
|
types: ["Audio"],
|
||||||
}),
|
}),
|
||||||
enabled: debouncedSearch.length > 0,
|
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const noResults = useMemo(() => {
|
const noResults = useMemo(() => {
|
||||||
@@ -214,13 +268,25 @@ export default function search() {
|
|||||||
episodes?.length ||
|
episodes?.length ||
|
||||||
series?.length ||
|
series?.length ||
|
||||||
collections?.length ||
|
collections?.length ||
|
||||||
actors?.length
|
actors?.length ||
|
||||||
|
jellyseerrMovieResults?.length ||
|
||||||
|
jellyseerrTvResults?.length
|
||||||
);
|
);
|
||||||
}, [artists, episodes, albums, songs, movies, series, collections, actors]);
|
}, [
|
||||||
|
artists,
|
||||||
|
episodes,
|
||||||
|
albums,
|
||||||
|
songs,
|
||||||
|
movies,
|
||||||
|
series,
|
||||||
|
collections,
|
||||||
|
actors,
|
||||||
|
jellyseerrResults,
|
||||||
|
]);
|
||||||
|
|
||||||
const loading = useMemo(() => {
|
const loading = useMemo(() => {
|
||||||
return l1 || l2 || l3 || l4 || l5 || l6 || l7 || l8;
|
return l1 || l2 || l3 || l4 || l5 || l6 || l7 || l8 || j1 || j2;
|
||||||
}, [l1, l2, l3, l4, l5, l6, l7, l8]);
|
}, [l1, l2, l3, l4, l5, l6, l7, l8, j1, j2]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -245,6 +311,28 @@ export default function search() {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
{jellyseerrApi && (
|
||||||
|
<View className="flex flex-row flex-wrap space-x-2 px-4 mb-2">
|
||||||
|
<TouchableOpacity onPress={() => setSearchType("Library")}>
|
||||||
|
<Tag
|
||||||
|
text="Library"
|
||||||
|
textClass="p-1"
|
||||||
|
className={
|
||||||
|
searchType === "Library" ? "bg-neutral-600" : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity onPress={() => setSearchType("Discover")}>
|
||||||
|
<Tag
|
||||||
|
text="Discover"
|
||||||
|
textClass="p-1"
|
||||||
|
className={
|
||||||
|
searchType === "Discover" ? "bg-neutral-600" : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
{!!q && (
|
{!!q && (
|
||||||
<View className="px-4 flex flex-col space-y-2">
|
<View className="px-4 flex flex-col space-y-2">
|
||||||
<Text className="text-neutral-500 ">
|
<Text className="text-neutral-500 ">
|
||||||
@@ -252,130 +340,153 @@ export default function search() {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
<SearchItemWrapper
|
{searchType === "Library" && (
|
||||||
header="Movies"
|
<>
|
||||||
ids={movies?.map((m) => m.Id!)}
|
<SearchItemWrapper
|
||||||
renderItem={(item) => (
|
header="Movies"
|
||||||
<TouchableItemRouter
|
ids={movies?.map((m) => m.Id!)}
|
||||||
key={item.Id}
|
renderItem={(item: BaseItemDto) => (
|
||||||
className="flex flex-col w-28 mr-2"
|
<TouchableItemRouter
|
||||||
item={item}
|
key={item.Id}
|
||||||
>
|
className="flex flex-col w-28 mr-2"
|
||||||
<MoviePoster item={item} key={item.Id} />
|
item={item}
|
||||||
<Text numberOfLines={2} className="mt-2">
|
>
|
||||||
{item.Name}
|
<MoviePoster item={item} key={item.Id} />
|
||||||
</Text>
|
<Text numberOfLines={2} className="mt-2">
|
||||||
<Text className="opacity-50 text-xs">
|
{item.Name}
|
||||||
{item.ProductionYear}
|
</Text>
|
||||||
</Text>
|
<Text className="opacity-50 text-xs">
|
||||||
</TouchableItemRouter>
|
{item.ProductionYear}
|
||||||
)}
|
</Text>
|
||||||
/>
|
</TouchableItemRouter>
|
||||||
<SearchItemWrapper
|
)}
|
||||||
ids={series?.map((m) => m.Id!)}
|
/>
|
||||||
header="Series"
|
<SearchItemWrapper
|
||||||
renderItem={(item) => (
|
ids={series?.map((m) => m.Id!)}
|
||||||
<TouchableItemRouter
|
header="Series"
|
||||||
key={item.Id}
|
renderItem={(item: BaseItemDto) => (
|
||||||
item={item}
|
<TouchableItemRouter
|
||||||
className="flex flex-col w-28 mr-2"
|
key={item.Id}
|
||||||
>
|
item={item}
|
||||||
<SeriesPoster item={item} key={item.Id} />
|
className="flex flex-col w-28 mr-2"
|
||||||
<Text numberOfLines={2} className="mt-2">
|
>
|
||||||
{item.Name}
|
<SeriesPoster item={item} key={item.Id} />
|
||||||
</Text>
|
<Text numberOfLines={2} className="mt-2">
|
||||||
<Text className="opacity-50 text-xs">
|
{item.Name}
|
||||||
{item.ProductionYear}
|
</Text>
|
||||||
</Text>
|
<Text className="opacity-50 text-xs">
|
||||||
</TouchableItemRouter>
|
{item.ProductionYear}
|
||||||
)}
|
</Text>
|
||||||
/>
|
</TouchableItemRouter>
|
||||||
<SearchItemWrapper
|
)}
|
||||||
ids={episodes?.map((m) => m.Id!)}
|
/>
|
||||||
header="Episodes"
|
<SearchItemWrapper
|
||||||
renderItem={(item) => (
|
ids={episodes?.map((m) => m.Id!)}
|
||||||
<TouchableItemRouter
|
header="Episodes"
|
||||||
item={item}
|
renderItem={(item: BaseItemDto) => (
|
||||||
key={item.Id}
|
<TouchableItemRouter
|
||||||
className="flex flex-col w-44 mr-2"
|
item={item}
|
||||||
>
|
key={item.Id}
|
||||||
<ContinueWatchingPoster item={item} />
|
className="flex flex-col w-44 mr-2"
|
||||||
<ItemCardText item={item} />
|
>
|
||||||
</TouchableItemRouter>
|
<ContinueWatchingPoster item={item} />
|
||||||
)}
|
<ItemCardText item={item} />
|
||||||
/>
|
</TouchableItemRouter>
|
||||||
<SearchItemWrapper
|
)}
|
||||||
ids={collections?.map((m) => m.Id!)}
|
/>
|
||||||
header="Collections"
|
<SearchItemWrapper
|
||||||
renderItem={(item) => (
|
ids={collections?.map((m) => m.Id!)}
|
||||||
<TouchableItemRouter
|
header="Collections"
|
||||||
key={item.Id}
|
renderItem={(item: BaseItemDto) => (
|
||||||
item={item}
|
<TouchableItemRouter
|
||||||
className="flex flex-col w-28 mr-2"
|
key={item.Id}
|
||||||
>
|
item={item}
|
||||||
<MoviePoster item={item} key={item.Id} />
|
className="flex flex-col w-28 mr-2"
|
||||||
<Text numberOfLines={2} className="mt-2">
|
>
|
||||||
{item.Name}
|
<MoviePoster item={item} key={item.Id} />
|
||||||
</Text>
|
<Text numberOfLines={2} className="mt-2">
|
||||||
</TouchableItemRouter>
|
{item.Name}
|
||||||
)}
|
</Text>
|
||||||
/>
|
</TouchableItemRouter>
|
||||||
<SearchItemWrapper
|
)}
|
||||||
ids={actors?.map((m) => m.Id!)}
|
/>
|
||||||
header="Actors"
|
<SearchItemWrapper
|
||||||
renderItem={(item) => (
|
ids={actors?.map((m) => m.Id!)}
|
||||||
<TouchableItemRouter
|
header="Actors"
|
||||||
item={item}
|
renderItem={(item: BaseItemDto) => (
|
||||||
key={item.Id}
|
<TouchableItemRouter
|
||||||
className="flex flex-col w-28 mr-2"
|
item={item}
|
||||||
>
|
key={item.Id}
|
||||||
<MoviePoster item={item} />
|
className="flex flex-col w-28 mr-2"
|
||||||
<ItemCardText item={item} />
|
>
|
||||||
</TouchableItemRouter>
|
<MoviePoster item={item} />
|
||||||
)}
|
<ItemCardText item={item} />
|
||||||
/>
|
</TouchableItemRouter>
|
||||||
<SearchItemWrapper
|
)}
|
||||||
ids={artists?.map((m) => m.Id!)}
|
/>
|
||||||
header="Artists"
|
<SearchItemWrapper
|
||||||
renderItem={(item) => (
|
ids={artists?.map((m) => m.Id!)}
|
||||||
<TouchableItemRouter
|
header="Artists"
|
||||||
item={item}
|
renderItem={(item: BaseItemDto) => (
|
||||||
key={item.Id}
|
<TouchableItemRouter
|
||||||
className="flex flex-col w-28 mr-2"
|
item={item}
|
||||||
>
|
key={item.Id}
|
||||||
<AlbumCover id={item.Id} />
|
className="flex flex-col w-28 mr-2"
|
||||||
<ItemCardText item={item} />
|
>
|
||||||
</TouchableItemRouter>
|
<AlbumCover id={item.Id} />
|
||||||
)}
|
<ItemCardText item={item} />
|
||||||
/>
|
</TouchableItemRouter>
|
||||||
<SearchItemWrapper
|
)}
|
||||||
ids={albums?.map((m) => m.Id!)}
|
/>
|
||||||
header="Albums"
|
<SearchItemWrapper
|
||||||
renderItem={(item) => (
|
ids={albums?.map((m) => m.Id!)}
|
||||||
<TouchableItemRouter
|
header="Albums"
|
||||||
item={item}
|
renderItem={(item: BaseItemDto) => (
|
||||||
key={item.Id}
|
<TouchableItemRouter
|
||||||
className="flex flex-col w-28 mr-2"
|
item={item}
|
||||||
>
|
key={item.Id}
|
||||||
<AlbumCover id={item.Id} />
|
className="flex flex-col w-28 mr-2"
|
||||||
<ItemCardText item={item} />
|
>
|
||||||
</TouchableItemRouter>
|
<AlbumCover id={item.Id} />
|
||||||
)}
|
<ItemCardText item={item} />
|
||||||
/>
|
</TouchableItemRouter>
|
||||||
<SearchItemWrapper
|
)}
|
||||||
ids={songs?.map((m) => m.Id!)}
|
/>
|
||||||
header="Songs"
|
<SearchItemWrapper
|
||||||
renderItem={(item) => (
|
ids={songs?.map((m) => m.Id!)}
|
||||||
<TouchableItemRouter
|
header="Songs"
|
||||||
item={item}
|
renderItem={(item: BaseItemDto) => (
|
||||||
key={item.Id}
|
<TouchableItemRouter
|
||||||
className="flex flex-col w-28 mr-2"
|
item={item}
|
||||||
>
|
key={item.Id}
|
||||||
<AlbumCover id={item.AlbumId} />
|
className="flex flex-col w-28 mr-2"
|
||||||
<ItemCardText item={item} />
|
>
|
||||||
</TouchableItemRouter>
|
<AlbumCover id={item.AlbumId} />
|
||||||
)}
|
<ItemCardText item={item} />
|
||||||
/>
|
</TouchableItemRouter>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{searchType === "Discover" && (
|
||||||
|
<>
|
||||||
|
<SearchItemWrapper
|
||||||
|
header="Request Movies"
|
||||||
|
items={jellyseerrMovieResults}
|
||||||
|
renderItem={(item: MovieResult) => (
|
||||||
|
<JellyseerrPoster item={item} key={item.id} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<SearchItemWrapper
|
||||||
|
header="Request Series"
|
||||||
|
items={jellyseerrTvResults}
|
||||||
|
renderItem={(item: TvResult) => (
|
||||||
|
<JellyseerrPoster item={item} key={item.id} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<View className="mt-4 flex justify-center items-center">
|
<View className="mt-4 flex justify-center items-center">
|
||||||
<Loader />
|
<Loader />
|
||||||
@@ -389,7 +500,7 @@ export default function search() {
|
|||||||
"{debouncedSearch}"
|
"{debouncedSearch}"
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : debouncedSearch.length === 0 ? (
|
) : debouncedSearch.length === 0 && searchType === "Library" ? (
|
||||||
<View className="mt-4 flex flex-col items-center space-y-2">
|
<View className="mt-4 flex flex-col items-center space-y-2">
|
||||||
{exampleSearches.map((e) => (
|
{exampleSearches.map((e) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -401,6 +512,15 @@ export default function search() {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
) : debouncedSearch.length === 0 && searchType === "Discover" ? (
|
||||||
|
<View className="flex flex-col px-4">
|
||||||
|
{sortBy?.(
|
||||||
|
jellyseerrDiscoverSettings?.filter((s) => s.enabled),
|
||||||
|
"order"
|
||||||
|
).map((slide) => (
|
||||||
|
<DiscoverSlide key={slide.id} slide={slide} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
@@ -408,13 +528,19 @@ export default function search() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props<T> = {
|
||||||
ids?: string[] | null;
|
ids?: string[] | null;
|
||||||
renderItem: (item: BaseItemDto) => React.ReactNode;
|
items?: T[];
|
||||||
|
renderItem: (item: any) => React.ReactNode;
|
||||||
header?: string;
|
header?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SearchItemWrapper: React.FC<Props> = ({ ids, renderItem, header }) => {
|
const SearchItemWrapper = <T extends unknown>({
|
||||||
|
ids,
|
||||||
|
items,
|
||||||
|
renderItem,
|
||||||
|
header,
|
||||||
|
}: PropsWithChildren<Props<T>>) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
@@ -444,7 +570,7 @@ const SearchItemWrapper: React.FC<Props> = ({ ids, renderItem, header }) => {
|
|||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!data) return null;
|
if (!data && (!items || items.length === 0)) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -454,7 +580,11 @@ const SearchItemWrapper: React.FC<Props> = ({ ids, renderItem, header }) => {
|
|||||||
className="px-4 mb-2"
|
className="px-4 mb-2"
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
{data.map((item) => renderItem(item))}
|
{data && data?.length > 0
|
||||||
|
? data.map((item) => renderItem(item))
|
||||||
|
: items && items?.length > 0
|
||||||
|
? items.map((i) => renderItem(i))
|
||||||
|
: undefined}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { withLayoutContext } from "expo-router";
|
|||||||
import {
|
import {
|
||||||
createNativeBottomTabNavigator,
|
createNativeBottomTabNavigator,
|
||||||
NativeBottomTabNavigationEventMap,
|
NativeBottomTabNavigationEventMap,
|
||||||
} from "react-native-bottom-tabs/react-navigation";
|
} from "@bottom-tabs/react-navigation";
|
||||||
|
|
||||||
const { Navigator } = createNativeBottomTabNavigator();
|
const { Navigator } = createNativeBottomTabNavigator();
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@ import type {
|
|||||||
TabNavigationState,
|
TabNavigationState,
|
||||||
} from "@react-navigation/native";
|
} from "@react-navigation/native";
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export const NativeTabs = withLayoutContext<
|
export const NativeTabs = withLayoutContext<
|
||||||
BottomTabNavigationOptions,
|
BottomTabNavigationOptions,
|
||||||
@@ -27,6 +28,7 @@ export const NativeTabs = withLayoutContext<
|
|||||||
>(Navigator);
|
>(Navigator);
|
||||||
|
|
||||||
export default function TabLayout() {
|
export default function TabLayout() {
|
||||||
|
const [settings] = useSettings();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SystemBars hidden={false} style="light" />
|
<SystemBars hidden={false} style="light" />
|
||||||
|
|||||||
@@ -2,9 +2,6 @@ import { BITRATES } from "@/components/BitrateSelector";
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { Controls } from "@/components/video-player/controls/Controls";
|
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 { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||||
import { VlcPlayerView } from "@/modules/vlc-player";
|
import { VlcPlayerView } from "@/modules/vlc-player";
|
||||||
@@ -13,8 +10,8 @@ import {
|
|||||||
ProgressUpdatePayload,
|
ProgressUpdatePayload,
|
||||||
VlcPlayerViewRef,
|
VlcPlayerViewRef,
|
||||||
} from "@/modules/vlc-player/src/VlcPlayer.types";
|
} from "@/modules/vlc-player/src/VlcPlayer.types";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
@@ -27,11 +24,16 @@ import {
|
|||||||
getUserLibraryApi,
|
getUserLibraryApi,
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} 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 { useFocusEffect, useGlobalSearchParams } from "expo-router";
|
import { useFocusEffect, useGlobalSearchParams } from "expo-router";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
import React, {
|
||||||
import { Alert, BackHandler, View } from "react-native";
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { Alert, Platform, View } from "react-native";
|
||||||
import { useSharedValue } from "react-native-reanimated";
|
import { useSharedValue } from "react-native-reanimated";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
@@ -50,12 +52,10 @@ export default function page() {
|
|||||||
const isSeeking = useSharedValue(false);
|
const isSeeking = useSharedValue(false);
|
||||||
const cacheProgress = useSharedValue(0);
|
const cacheProgress = useSharedValue(0);
|
||||||
|
|
||||||
const { getDownloadedItem } = useDownload();
|
|
||||||
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
||||||
|
|
||||||
const setShowControls = useCallback((show: boolean) => {
|
const setShowControls = useCallback((show: boolean) => {
|
||||||
_setShowControls(show);
|
_setShowControls(show);
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -64,17 +64,14 @@ export default function page() {
|
|||||||
subtitleIndex: subtitleIndexStr,
|
subtitleIndex: subtitleIndexStr,
|
||||||
mediaSourceId,
|
mediaSourceId,
|
||||||
bitrateValue: bitrateValueStr,
|
bitrateValue: bitrateValueStr,
|
||||||
offline: offlineStr,
|
|
||||||
} = useGlobalSearchParams<{
|
} = useGlobalSearchParams<{
|
||||||
itemId: string;
|
itemId: string;
|
||||||
audioIndex: string;
|
audioIndex: string;
|
||||||
subtitleIndex: string;
|
subtitleIndex: string;
|
||||||
mediaSourceId: string;
|
mediaSourceId: string;
|
||||||
bitrateValue: string;
|
bitrateValue: string;
|
||||||
offline: string;
|
|
||||||
}>();
|
}>();
|
||||||
|
const [settings] = useSettings();
|
||||||
const offline = offlineStr === "true";
|
|
||||||
|
|
||||||
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
|
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
|
||||||
const subtitleIndex = subtitleIndexStr ? parseInt(subtitleIndexStr, 10) : -1;
|
const subtitleIndex = subtitleIndexStr ? parseInt(subtitleIndexStr, 10) : -1;
|
||||||
@@ -89,12 +86,6 @@ export default function page() {
|
|||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: ["item", itemId],
|
queryKey: ["item", itemId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
console.log("Offline:", offline);
|
|
||||||
if (offline) {
|
|
||||||
const item = await getDownloadedItem(itemId);
|
|
||||||
if (item) return item.item;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await getUserLibraryApi(api!).getItem({
|
const res = await getUserLibraryApi(api!).getItem({
|
||||||
itemId,
|
itemId,
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
@@ -111,30 +102,8 @@ export default function page() {
|
|||||||
isLoading: isLoadingStreamUrl,
|
isLoading: isLoadingStreamUrl,
|
||||||
isError: isErrorStreamUrl,
|
isError: isErrorStreamUrl,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: [
|
queryKey: ["stream-url", itemId, mediaSourceId, bitrateValue],
|
||||||
"stream-url",
|
|
||||||
itemId,
|
|
||||||
audioIndex,
|
|
||||||
subtitleIndex,
|
|
||||||
mediaSourceId,
|
|
||||||
bitrateValue,
|
|
||||||
],
|
|
||||||
queryFn: async () => {
|
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({
|
const res = await getStreamUrl({
|
||||||
api,
|
api,
|
||||||
item,
|
item,
|
||||||
@@ -166,73 +135,58 @@ export default function page() {
|
|||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const togglePlay = useCallback(
|
const togglePlay = useCallback(async () => {
|
||||||
async (ms: number) => {
|
if (!api) return;
|
||||||
if (!api) return;
|
|
||||||
|
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
if (isPlaying) {
|
||||||
if (isPlaying) {
|
await videoRef.current?.pause();
|
||||||
await videoRef.current?.pause();
|
|
||||||
|
|
||||||
if (!offline && stream) {
|
if (stream) {
|
||||||
await getPlaystateApi(api).onPlaybackProgress({
|
await getPlaystateApi(api).onPlaybackProgress({
|
||||||
itemId: item?.Id!,
|
itemId: item?.Id!,
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||||
mediaSourceId: mediaSourceId,
|
mediaSourceId: mediaSourceId,
|
||||||
positionTicks: msToTicks(ms),
|
positionTicks: msToTicks(progress.value),
|
||||||
isPaused: true,
|
isPaused: true,
|
||||||
playMethod: stream.url?.includes("m3u8")
|
playMethod: stream.url?.includes("m3u8")
|
||||||
? "Transcode"
|
? "Transcode"
|
||||||
: "DirectStream",
|
: "DirectStream",
|
||||||
playSessionId: stream.sessionId,
|
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(() => {
|
console.log("Actually marked as paused");
|
||||||
videoRef.current?.play();
|
} else {
|
||||||
reportPlaybackStart();
|
videoRef.current?.play();
|
||||||
}, [videoRef]);
|
if (stream) {
|
||||||
|
await getPlaystateApi(api).onPlaybackProgress({
|
||||||
const pause = useCallback(() => {
|
itemId: item?.Id!,
|
||||||
videoRef.current?.pause();
|
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||||
}, [videoRef]);
|
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||||
|
mediaSourceId: mediaSourceId,
|
||||||
|
positionTicks: msToTicks(progress.value),
|
||||||
|
isPaused: false,
|
||||||
|
playMethod: stream?.url.includes("m3u8")
|
||||||
|
? "Transcode"
|
||||||
|
: "DirectStream",
|
||||||
|
playSessionId: stream.sessionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
isPlaying,
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
stream,
|
||||||
|
videoRef,
|
||||||
|
audioIndex,
|
||||||
|
subtitleIndex,
|
||||||
|
mediaSourceId,
|
||||||
|
progress.value,
|
||||||
|
]);
|
||||||
|
|
||||||
const reportPlaybackStopped = useCallback(async () => {
|
const reportPlaybackStopped = useCallback(async () => {
|
||||||
if (offline) return;
|
|
||||||
|
|
||||||
const currentTimeInTicks = msToTicks(progress.value);
|
const currentTimeInTicks = msToTicks(progress.value);
|
||||||
|
|
||||||
await getPlaystateApi(api!).onPlaybackStopped({
|
await getPlaystateApi(api!).onPlaybackStopped({
|
||||||
@@ -251,9 +205,8 @@ export default function page() {
|
|||||||
videoRef.current?.stop();
|
videoRef.current?.stop();
|
||||||
}, [videoRef, reportPlaybackStopped]);
|
}, [videoRef, reportPlaybackStopped]);
|
||||||
|
|
||||||
|
// TODO: unused should remove.
|
||||||
const reportPlaybackStart = useCallback(async () => {
|
const reportPlaybackStart = useCallback(async () => {
|
||||||
if (offline) return;
|
|
||||||
|
|
||||||
if (!stream) return;
|
if (!stream) return;
|
||||||
await getPlaystateApi(api!).onPlaybackStart({
|
await getPlaystateApi(api!).onPlaybackStart({
|
||||||
itemId: item?.Id!,
|
itemId: item?.Id!,
|
||||||
@@ -278,8 +231,6 @@ export default function page() {
|
|||||||
|
|
||||||
progress.value = currentTime;
|
progress.value = currentTime;
|
||||||
|
|
||||||
if (offline) return;
|
|
||||||
|
|
||||||
const currentTimeInTicks = msToTicks(currentTime);
|
const currentTimeInTicks = msToTicks(currentTime);
|
||||||
|
|
||||||
if (!item?.Id || !stream) return;
|
if (!item?.Id || !stream) return;
|
||||||
@@ -295,18 +246,13 @@ export default function page() {
|
|||||||
playSessionId: stream.sessionId,
|
playSessionId: stream.sessionId,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[item?.Id, isPlaying, api, isPlaybackStopped]
|
[item?.Id, isPlaying, api, isPlaybackStopped, audioIndex, subtitleIndex]
|
||||||
);
|
);
|
||||||
|
|
||||||
useOrientation();
|
|
||||||
useOrientationSettings();
|
|
||||||
|
|
||||||
useWebSocket({
|
useWebSocket({
|
||||||
isPlaying: isPlaying,
|
isPlaying: isPlaying,
|
||||||
pauseVideo: pause,
|
togglePlay: togglePlay,
|
||||||
playVideo: play,
|
|
||||||
stopPlayback: stop,
|
stopPlayback: stop,
|
||||||
offline,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const onPlaybackStateChanged = useCallback((e: PlaybackStatePayload) => {
|
const onPlaybackStateChanged = useCallback((e: PlaybackStatePayload) => {
|
||||||
@@ -331,36 +277,23 @@ export default function page() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const startPosition = useMemo(() => {
|
const startPosition = useMemo(() => {
|
||||||
if (offline) return 0;
|
|
||||||
|
|
||||||
return item?.UserData?.PlaybackPositionTicks
|
return item?.UserData?.PlaybackPositionTicks
|
||||||
? ticksToSeconds(item.UserData.PlaybackPositionTicks)
|
? ticksToSeconds(item.UserData.PlaybackPositionTicks)
|
||||||
: 0;
|
: 0;
|
||||||
}, [item]);
|
}, [item]);
|
||||||
|
|
||||||
const backAction = () => {
|
|
||||||
videoRef.current?.stop();
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
React.useCallback(() => {
|
React.useCallback(() => {
|
||||||
const onBackPress = () => {
|
|
||||||
return backAction();
|
|
||||||
};
|
|
||||||
|
|
||||||
BackHandler.addEventListener("hardwareBackPress", onBackPress);
|
|
||||||
|
|
||||||
return async () => {
|
return async () => {
|
||||||
videoRef.current?.stop();
|
stop();
|
||||||
BackHandler.removeEventListener("hardwareBackPress", onBackPress);
|
console.log("Unmounted");
|
||||||
};
|
};
|
||||||
}, [])
|
}, [])
|
||||||
);
|
);
|
||||||
|
|
||||||
// Preselection of audio and subtitle tracks.
|
if (!settings) return null;
|
||||||
|
|
||||||
let initOptions = ["--sub-text-scale=60"];
|
let initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
|
||||||
let externalTrack = { name: "", DeliveryUrl: "" };
|
let externalTrack = { name: "", DeliveryUrl: "" };
|
||||||
|
|
||||||
const allSubs =
|
const allSubs =
|
||||||
@@ -425,7 +358,7 @@ export default function page() {
|
|||||||
position: "relative",
|
position: "relative",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
opacity: showControls ? 0.5 : 1,
|
opacity: showControls ? (Platform.OS === "android" ? 0.7 : 0.5) : 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<VlcPlayerView
|
<VlcPlayerView
|
||||||
@@ -455,21 +388,6 @@ export default function page() {
|
|||||||
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
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>
|
</View>
|
||||||
{videoRef.current && (
|
{videoRef.current && (
|
||||||
<Controls
|
<Controls
|
||||||
@@ -493,7 +411,6 @@ export default function page() {
|
|||||||
enableTrickplay={true}
|
enableTrickplay={true}
|
||||||
getAudioTracks={videoRef.current?.getAudioTracks}
|
getAudioTracks={videoRef.current?.getAudioTracks}
|
||||||
getSubtitleTracks={videoRef.current?.getSubtitleTracks}
|
getSubtitleTracks={videoRef.current?.getSubtitleTracks}
|
||||||
offline={false}
|
|
||||||
setSubtitleTrack={videoRef.current.setSubtitleTrack}
|
setSubtitleTrack={videoRef.current.setSubtitleTrack}
|
||||||
setSubtitleURL={videoRef.current.setSubtitleURL}
|
setSubtitleURL={videoRef.current.setSubtitleURL}
|
||||||
setAudioTrack={videoRef.current.setAudioTrack}
|
setAudioTrack={videoRef.current.setAudioTrack}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { Controls } from "@/components/video-player/controls/Controls";
|
import { Controls } from "@/components/video-player/controls/Controls";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
|
||||||
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
|
|
||||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
@@ -17,7 +15,6 @@ import {
|
|||||||
getUserLibraryApi,
|
getUserLibraryApi,
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} 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 { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useFocusEffect, useLocalSearchParams } from "expo-router";
|
import { useFocusEffect, useLocalSearchParams } from "expo-router";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
@@ -124,7 +121,6 @@ export default function page() {
|
|||||||
|
|
||||||
const togglePlay = useCallback(
|
const togglePlay = useCallback(
|
||||||
async (ticks: number) => {
|
async (ticks: number) => {
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
videoRef.current?.pause();
|
videoRef.current?.pause();
|
||||||
await getPlaystateApi(api!).onPlaybackProgress({
|
await getPlaystateApi(api!).onPlaybackProgress({
|
||||||
@@ -261,9 +257,6 @@ export default function page() {
|
|||||||
}, [play, stop])
|
}, [play, stop])
|
||||||
);
|
);
|
||||||
|
|
||||||
useOrientation();
|
|
||||||
useOrientationSettings();
|
|
||||||
|
|
||||||
useWebSocket({
|
useWebSocket({
|
||||||
isPlaying: isPlaying,
|
isPlaying: isPlaying,
|
||||||
pauseVideo: pause,
|
pauseVideo: pause,
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { Controls } from "@/components/video-player/controls/Controls";
|
import { Controls } from "@/components/video-player/controls/Controls";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
|
||||||
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
|
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||||
import { TrackInfo } from "@/modules/vlc-player";
|
import { TrackInfo } from "@/modules/vlc-player";
|
||||||
@@ -20,7 +18,6 @@ import {
|
|||||||
getUserLibraryApi,
|
getUserLibraryApi,
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} 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 { useFocusEffect, useLocalSearchParams } from "expo-router";
|
import { useFocusEffect, useLocalSearchParams } from "expo-router";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import React, {
|
import React, {
|
||||||
@@ -30,7 +27,7 @@ import React, {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { BackHandler, View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { useSharedValue } from "react-native-reanimated";
|
import { useSharedValue } from "react-native-reanimated";
|
||||||
import Video, {
|
import Video, {
|
||||||
OnProgressData,
|
OnProgressData,
|
||||||
@@ -38,6 +35,7 @@ import Video, {
|
|||||||
SelectedTrackType,
|
SelectedTrackType,
|
||||||
VideoRef,
|
VideoRef,
|
||||||
} from "react-native-video";
|
} from "react-native-video";
|
||||||
|
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
||||||
|
|
||||||
const Player = () => {
|
const Player = () => {
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
@@ -53,10 +51,10 @@ const Player = () => {
|
|||||||
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
|
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
const [isBuffering, setIsBuffering] = useState(true);
|
const [isBuffering, setIsBuffering] = useState(true);
|
||||||
|
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
||||||
|
|
||||||
const setShowControls = useCallback((show: boolean) => {
|
const setShowControls = useCallback((show: boolean) => {
|
||||||
_setShowControls(show);
|
_setShowControls(show);
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const progress = useSharedValue(0);
|
const progress = useSharedValue(0);
|
||||||
@@ -111,19 +109,14 @@ const Player = () => {
|
|||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// TODO: NEED TO FIND A WAY TO FROM SWITCHING TO IMAGE BASED TO TEXT BASED SUBTITLES, THERE IS A BUG.
|
||||||
|
// MOST LIKELY LIKELY NEED A MASSIVE REFACTOR.
|
||||||
const {
|
const {
|
||||||
data: stream,
|
data: stream,
|
||||||
isLoading: isLoadingStreamUrl,
|
isLoading: isLoadingStreamUrl,
|
||||||
isError: isErrorStreamUrl,
|
isError: isErrorStreamUrl,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: [
|
queryKey: ["stream-url", itemId, bitrateValue, mediaSourceId, audioIndex],
|
||||||
"stream-url",
|
|
||||||
itemId,
|
|
||||||
audioIndex,
|
|
||||||
subtitleIndex,
|
|
||||||
bitrateValue,
|
|
||||||
mediaSourceId,
|
|
||||||
],
|
|
||||||
|
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!api) {
|
if (!api) {
|
||||||
@@ -169,51 +162,43 @@ const Player = () => {
|
|||||||
const poster = usePoster(item, api);
|
const poster = usePoster(item, api);
|
||||||
const videoSource = useVideoSource(item, api, poster, stream?.url);
|
const videoSource = useVideoSource(item, api, poster, stream?.url);
|
||||||
|
|
||||||
const togglePlay = useCallback(
|
const togglePlay = useCallback(async () => {
|
||||||
async (ticks: number) => {
|
if (isPlaying) {
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
videoRef.current?.pause();
|
||||||
if (isPlaying) {
|
await getPlaystateApi(api!).onPlaybackProgress({
|
||||||
videoRef.current?.pause();
|
itemId: item?.Id!,
|
||||||
await getPlaystateApi(api!).onPlaybackProgress({
|
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||||
itemId: item?.Id!,
|
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
mediaSourceId: mediaSourceId,
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
positionTicks: Math.floor(progress.value),
|
||||||
mediaSourceId: mediaSourceId,
|
isPaused: true,
|
||||||
positionTicks: Math.floor(ticks),
|
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||||
isPaused: true,
|
playSessionId: stream?.sessionId,
|
||||||
playMethod: stream?.url.includes("m3u8")
|
});
|
||||||
? "Transcode"
|
} else {
|
||||||
: "DirectStream",
|
videoRef.current?.resume();
|
||||||
playSessionId: stream?.sessionId,
|
await getPlaystateApi(api!).onPlaybackProgress({
|
||||||
});
|
itemId: item?.Id!,
|
||||||
} else {
|
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||||
videoRef.current?.resume();
|
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||||
await getPlaystateApi(api!).onPlaybackProgress({
|
mediaSourceId: mediaSourceId,
|
||||||
itemId: item?.Id!,
|
positionTicks: Math.floor(progress.value),
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
isPaused: false,
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||||
mediaSourceId: mediaSourceId,
|
playSessionId: stream?.sessionId,
|
||||||
positionTicks: Math.floor(ticks),
|
});
|
||||||
isPaused: false,
|
}
|
||||||
playMethod: stream?.url.includes("m3u8")
|
}, [
|
||||||
? "Transcode"
|
isPlaying,
|
||||||
: "DirectStream",
|
api,
|
||||||
playSessionId: stream?.sessionId,
|
item,
|
||||||
});
|
videoRef,
|
||||||
}
|
settings,
|
||||||
},
|
stream,
|
||||||
[
|
audioIndex,
|
||||||
isPlaying,
|
subtitleIndex,
|
||||||
api,
|
mediaSourceId,
|
||||||
item,
|
]);
|
||||||
videoRef,
|
|
||||||
settings,
|
|
||||||
stream,
|
|
||||||
audioIndex,
|
|
||||||
subtitleIndex,
|
|
||||||
mediaSourceId,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
const play = useCallback(() => {
|
const play = useCallback(() => {
|
||||||
videoRef.current?.resume();
|
videoRef.current?.resume();
|
||||||
@@ -270,6 +255,13 @@ const Player = () => {
|
|||||||
progress.value = ticks;
|
progress.value = ticks;
|
||||||
cacheProgress.value = secondsToTicks(data.playableDuration);
|
cacheProgress.value = secondsToTicks(data.playableDuration);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"onProgress ~",
|
||||||
|
ticks,
|
||||||
|
isPlaying,
|
||||||
|
`AUDIO index: ${audioIndex} SUB index" ${subtitleIndex}`
|
||||||
|
);
|
||||||
|
|
||||||
// TODO: Use this when streaming with HLS url, but NOT when direct playing
|
// TODO: Use this when streaming with HLS url, but NOT when direct playing
|
||||||
// TODO: since playable duration is always 0 then.
|
// TODO: since playable duration is always 0 then.
|
||||||
setIsBuffering(data.playableDuration === 0);
|
setIsBuffering(data.playableDuration === 0);
|
||||||
@@ -302,13 +294,9 @@ const Player = () => {
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
useOrientation();
|
|
||||||
useOrientationSettings();
|
|
||||||
|
|
||||||
useWebSocket({
|
useWebSocket({
|
||||||
isPlaying: isPlaying,
|
isPlaying: isPlaying,
|
||||||
pauseVideo: pause,
|
togglePlay: togglePlay,
|
||||||
playVideo: play,
|
|
||||||
stopPlayback: stop,
|
stopPlayback: stop,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -331,25 +319,25 @@ const Player = () => {
|
|||||||
SelectedTrack | undefined
|
SelectedTrack | undefined
|
||||||
>(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(() => {
|
useEffect(() => {
|
||||||
if (chosenSubtitleTrack && selectedTextTrack === undefined) {
|
if (selectedTextTrack === undefined) {
|
||||||
console.log("Setting selected text track", chosenSubtitleTrack);
|
const subtitleHelper = new SubtitleHelper(
|
||||||
|
stream?.mediaSource.MediaStreams ?? []
|
||||||
|
);
|
||||||
|
const embeddedTrackIndex = subtitleHelper.getEmbeddedTrackIndex(
|
||||||
|
subtitleIndex!
|
||||||
|
);
|
||||||
|
|
||||||
|
// Most likely the subtitle is burned in.
|
||||||
|
if (embeddedTrackIndex === -1) return;
|
||||||
|
console.log(
|
||||||
|
"Setting selected text track",
|
||||||
|
subtitleIndex,
|
||||||
|
embeddedTrackIndex
|
||||||
|
);
|
||||||
setSelectedTextTrack({
|
setSelectedTextTrack({
|
||||||
type: SelectedTrackType.INDEX,
|
type: SelectedTrackType.INDEX,
|
||||||
value: uniqueTextSubs.indexOf(chosenSubtitleTrack),
|
value: embeddedTrackIndex,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [embededTextTracks]);
|
}, [embededTextTracks]);
|
||||||
@@ -369,23 +357,10 @@ const Player = () => {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const backAction = () => {
|
|
||||||
videoRef.current?.pause();
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
React.useCallback(() => {
|
React.useCallback(() => {
|
||||||
const onBackPress = () => {
|
|
||||||
return backAction();
|
|
||||||
};
|
|
||||||
|
|
||||||
BackHandler.addEventListener("hardwareBackPress", onBackPress);
|
|
||||||
play();
|
|
||||||
|
|
||||||
return async () => {
|
return async () => {
|
||||||
videoRef.current?.pause();
|
stop();
|
||||||
BackHandler.removeEventListener("hardwareBackPress", onBackPress);
|
|
||||||
};
|
};
|
||||||
}, [])
|
}, [])
|
||||||
);
|
);
|
||||||
@@ -467,20 +442,6 @@ const Player = () => {
|
|||||||
selectedTextTrack={selectedTextTrack}
|
selectedTextTrack={selectedTextTrack}
|
||||||
selectedAudioTrack={selectedAudioTrack}
|
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>
|
<Text>No video source...</Text>
|
||||||
|
|||||||
46
app/(auth)/trailer/page.tsx
Normal file
46
app/(auth)/trailer/page.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { useGlobalSearchParams } from "expo-router";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { Alert, Dimensions, View } from "react-native";
|
||||||
|
import YoutubePlayer, { PLAYER_STATES } from "react-native-youtube-iframe";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const searchParams = useGlobalSearchParams();
|
||||||
|
console.log(searchParams);
|
||||||
|
|
||||||
|
const { url } = searchParams as { url: string };
|
||||||
|
|
||||||
|
const videoId = useMemo(() => {
|
||||||
|
return url.split("v=")[1];
|
||||||
|
}, [url]);
|
||||||
|
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
|
|
||||||
|
const onStateChange = useCallback((state: PLAYER_STATES) => {
|
||||||
|
if (state === "ended") {
|
||||||
|
setPlaying(false);
|
||||||
|
Alert.alert("video has finished playing!");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const togglePlaying = useCallback(() => {
|
||||||
|
setPlaying((prev) => !prev);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
togglePlaying();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const screenWidth = Dimensions.get("screen").width;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex flex-col bg-black items-center justify-center h-full">
|
||||||
|
<YoutubePlayer
|
||||||
|
height={300}
|
||||||
|
play={playing}
|
||||||
|
videoId={videoId}
|
||||||
|
onChangeState={onStateChange}
|
||||||
|
width={screenWidth}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
311
app/_layout.tsx
311
app/_layout.tsx
@@ -1,40 +1,19 @@
|
|||||||
import { DownloadProvider } from "@/providers/DownloadProvider";
|
import "@/augmentations";
|
||||||
import {
|
import { JellyfinProvider } from "@/providers/JellyfinProvider";
|
||||||
getOrSetDeviceId,
|
|
||||||
getTokenFromStorage,
|
|
||||||
JellyfinProvider,
|
|
||||||
} from "@/providers/JellyfinProvider";
|
|
||||||
import { JobQueueProvider } from "@/providers/JobQueueProvider";
|
|
||||||
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
||||||
import { orientationAtom } from "@/utils/atoms/orientation";
|
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
||||||
import { Settings, useSettings } from "@/utils/atoms/settings";
|
import { LogProvider } from "@/utils/log";
|
||||||
import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks";
|
|
||||||
import { writeToLog } from "@/utils/log";
|
|
||||||
import { storage } from "@/utils/mmkv";
|
|
||||||
import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server";
|
|
||||||
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
||||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import {
|
|
||||||
checkForExistingDownloads,
|
|
||||||
completeHandler,
|
|
||||||
download,
|
|
||||||
} from "@kesha-antonov/react-native-background-downloader";
|
|
||||||
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
|
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import * as BackgroundFetch from "expo-background-fetch";
|
|
||||||
import * as FileSystem from "expo-file-system";
|
|
||||||
import { useFonts } from "expo-font";
|
import { useFonts } from "expo-font";
|
||||||
import { useKeepAwake } from "expo-keep-awake";
|
import { useKeepAwake } from "expo-keep-awake";
|
||||||
import * as Linking from "expo-linking";
|
import { Stack } from "expo-router";
|
||||||
import * as Notifications from "expo-notifications";
|
|
||||||
import { router, Stack } from "expo-router";
|
|
||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
|
||||||
import * as SplashScreen from "expo-splash-screen";
|
import * as SplashScreen from "expo-splash-screen";
|
||||||
import * as TaskManager from "expo-task-manager";
|
import { Provider as JotaiProvider } from "jotai";
|
||||||
import { Provider as JotaiProvider, useAtom } from "jotai";
|
import { useEffect } from "react";
|
||||||
import { useEffect, useRef } from "react";
|
import { Appearance } from "react-native";
|
||||||
import { Appearance, AppState } from "react-native";
|
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
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";
|
||||||
@@ -42,170 +21,6 @@ import { Toaster } from "sonner-native";
|
|||||||
|
|
||||||
SplashScreen.preventAutoHideAsync();
|
SplashScreen.preventAutoHideAsync();
|
||||||
|
|
||||||
Notifications.setNotificationHandler({
|
|
||||||
handleNotification: async () => ({
|
|
||||||
shouldShowAlert: true,
|
|
||||||
shouldPlaySound: true,
|
|
||||||
shouldSetBadge: false,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
function useNotificationObserver() {
|
|
||||||
useEffect(() => {
|
|
||||||
let isMounted = true;
|
|
||||||
|
|
||||||
function redirect(notification: Notifications.Notification) {
|
|
||||||
const url = notification.request.content.data?.url;
|
|
||||||
if (url) {
|
|
||||||
router.push(url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Notifications.getLastNotificationResponseAsync().then((response) => {
|
|
||||||
if (!isMounted || !response?.notification) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
redirect(response?.notification);
|
|
||||||
});
|
|
||||||
|
|
||||||
const subscription = Notifications.addNotificationResponseReceivedListener(
|
|
||||||
(response) => {
|
|
||||||
redirect(response.notification);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isMounted = false;
|
|
||||||
subscription.remove();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
}
|
|
||||||
|
|
||||||
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
|
|
||||||
console.log("TaskManager ~ trigger");
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
const settingsData = storage.getString("settings");
|
|
||||||
|
|
||||||
if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData;
|
|
||||||
|
|
||||||
const settings: Partial<Settings> = JSON.parse(settingsData);
|
|
||||||
const url = settings?.optimizedVersionsServerUrl;
|
|
||||||
|
|
||||||
if (!settings?.autoDownload || !url)
|
|
||||||
return BackgroundFetch.BackgroundFetchResult.NoData;
|
|
||||||
|
|
||||||
const token = getTokenFromStorage();
|
|
||||||
const deviceId = getOrSetDeviceId();
|
|
||||||
const baseDirectory = FileSystem.documentDirectory;
|
|
||||||
|
|
||||||
if (!token || !deviceId || !baseDirectory)
|
|
||||||
return BackgroundFetch.BackgroundFetchResult.NoData;
|
|
||||||
|
|
||||||
const jobs = await getAllJobsByDeviceId({
|
|
||||||
deviceId,
|
|
||||||
authHeader: token,
|
|
||||||
url,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("TaskManager ~ Active jobs: ", jobs.length);
|
|
||||||
|
|
||||||
for (let job of jobs) {
|
|
||||||
if (job.status === "completed") {
|
|
||||||
const downloadUrl = url + "download/" + job.id;
|
|
||||||
const tasks = await checkForExistingDownloads();
|
|
||||||
|
|
||||||
if (tasks.find((task) => task.id === job.id)) {
|
|
||||||
console.log("TaskManager ~ Download already in progress: ", job.id);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
download({
|
|
||||||
id: job.id,
|
|
||||||
url: downloadUrl,
|
|
||||||
destination: `${baseDirectory}${job.item.Id}.mp4`,
|
|
||||||
headers: {
|
|
||||||
Authorization: token,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.begin(() => {
|
|
||||||
console.log("TaskManager ~ Download started: ", job.id);
|
|
||||||
})
|
|
||||||
.done(() => {
|
|
||||||
console.log("TaskManager ~ Download completed: ", job.id);
|
|
||||||
saveDownloadedItemInfo(job.item);
|
|
||||||
completeHandler(job.id);
|
|
||||||
cancelJobById({
|
|
||||||
authHeader: token,
|
|
||||||
id: job.id,
|
|
||||||
url: url,
|
|
||||||
});
|
|
||||||
Notifications.scheduleNotificationAsync({
|
|
||||||
content: {
|
|
||||||
title: job.item.Name,
|
|
||||||
body: "Download completed",
|
|
||||||
data: {
|
|
||||||
url: `/downloads`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
trigger: null,
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.error((error) => {
|
|
||||||
console.log("TaskManager ~ Download error: ", job.id, error);
|
|
||||||
completeHandler(job.id);
|
|
||||||
Notifications.scheduleNotificationAsync({
|
|
||||||
content: {
|
|
||||||
title: job.item.Name,
|
|
||||||
body: "Download failed",
|
|
||||||
data: {
|
|
||||||
url: `/downloads`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
trigger: null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Auto download started: ${new Date(now).toISOString()}`);
|
|
||||||
|
|
||||||
// Be sure to return the successful result type!
|
|
||||||
return BackgroundFetch.BackgroundFetchResult.NewData;
|
|
||||||
});
|
|
||||||
|
|
||||||
const checkAndRequestPermissions = async () => {
|
|
||||||
try {
|
|
||||||
const hasAskedBefore = storage.getString(
|
|
||||||
"hasAskedForNotificationPermission"
|
|
||||||
);
|
|
||||||
|
|
||||||
if (hasAskedBefore !== "true") {
|
|
||||||
const { status } = await Notifications.requestPermissionsAsync();
|
|
||||||
|
|
||||||
if (status === "granted") {
|
|
||||||
writeToLog("INFO", "Notification permissions granted.");
|
|
||||||
console.log("Notification permissions granted.");
|
|
||||||
} else {
|
|
||||||
writeToLog("ERROR", "Notification permissions denied.");
|
|
||||||
console.log("Notification permissions denied.");
|
|
||||||
}
|
|
||||||
|
|
||||||
storage.set("hasAskedForNotificationPermission", "true");
|
|
||||||
} else {
|
|
||||||
console.log("Already asked for notification permissions before.");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
writeToLog(
|
|
||||||
"ERROR",
|
|
||||||
"Error checking/requesting notification permissions:",
|
|
||||||
error
|
|
||||||
);
|
|
||||||
console.error("Error checking/requesting notification permissions:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
const [loaded] = useFonts({
|
const [loaded] = useFonts({
|
||||||
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
|
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
|
||||||
@@ -243,74 +58,15 @@ const queryClient = new QueryClient({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function Layout() {
|
function Layout() {
|
||||||
const [settings, updateSettings] = useSettings();
|
|
||||||
const [orientation, setOrientation] = useAtom(orientationAtom);
|
|
||||||
|
|
||||||
useKeepAwake();
|
useKeepAwake();
|
||||||
useNotificationObserver();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
checkAndRequestPermissions();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (settings?.autoRotate === true)
|
|
||||||
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.DEFAULT);
|
|
||||||
else
|
|
||||||
ScreenOrientation.lockAsync(
|
|
||||||
ScreenOrientation.OrientationLock.PORTRAIT_UP
|
|
||||||
);
|
|
||||||
}, [settings]);
|
|
||||||
|
|
||||||
const appState = useRef(AppState.currentState);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const subscription = AppState.addEventListener("change", (nextAppState) => {
|
|
||||||
if (
|
|
||||||
appState.current.match(/inactive|background/) &&
|
|
||||||
nextAppState === "active"
|
|
||||||
) {
|
|
||||||
checkForExistingDownloads();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
checkForExistingDownloads();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
subscription.remove();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const subscription = ScreenOrientation.addOrientationChangeListener(
|
|
||||||
(event) => {
|
|
||||||
setOrientation(event.orientationInfo.orientation);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
ScreenOrientation.getOrientationAsync().then((initialOrientation) => {
|
|
||||||
setOrientation(initialOrientation);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
ScreenOrientation.removeOrientationChangeListener(subscription);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const url = Linking.useURL();
|
|
||||||
|
|
||||||
if (url) {
|
|
||||||
const { hostname, path, queryParams } = Linking.parse(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ActionSheetProvider>
|
<ActionSheetProvider>
|
||||||
<JobQueueProvider>
|
<JellyfinProvider>
|
||||||
<JellyfinProvider>
|
<PlaySettingsProvider>
|
||||||
<PlaySettingsProvider>
|
<LogProvider>
|
||||||
<DownloadProvider>
|
<WebSocketProvider>
|
||||||
<BottomSheetModalProvider>
|
<BottomSheetModalProvider>
|
||||||
<SystemBars style="light" hidden={false} />
|
<SystemBars style="light" hidden={false} />
|
||||||
<ThemeProvider value={DarkTheme}>
|
<ThemeProvider value={DarkTheme}>
|
||||||
@@ -331,9 +87,21 @@ function Layout() {
|
|||||||
header: () => null,
|
header: () => null,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="(auth)/trailer/page"
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: "modal",
|
||||||
|
title: "",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="login"
|
name="login"
|
||||||
options={{ headerShown: false, title: "Login" }}
|
options={{
|
||||||
|
headerShown: true,
|
||||||
|
title: "",
|
||||||
|
headerTransparent: true,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen name="+not-found" />
|
<Stack.Screen name="+not-found" />
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -353,33 +121,12 @@ function Layout() {
|
|||||||
/>
|
/>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</BottomSheetModalProvider>
|
</BottomSheetModalProvider>
|
||||||
</DownloadProvider>
|
</WebSocketProvider>
|
||||||
</PlaySettingsProvider>
|
</LogProvider>
|
||||||
</JellyfinProvider>
|
</PlaySettingsProvider>
|
||||||
</JobQueueProvider>
|
</JellyfinProvider>
|
||||||
</ActionSheetProvider>
|
</ActionSheetProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</GestureHandlerRootView>
|
</GestureHandlerRootView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveDownloadedItemInfo(item: BaseItemDto) {
|
|
||||||
try {
|
|
||||||
const downloadedItems = storage.getString("downloadedItems");
|
|
||||||
let items: BaseItemDto[] = downloadedItems
|
|
||||||
? JSON.parse(downloadedItems)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const existingItemIndex = items.findIndex((i) => i.Id === item.Id);
|
|
||||||
if (existingItemIndex !== -1) {
|
|
||||||
items[existingItemIndex] = item;
|
|
||||||
} else {
|
|
||||||
items.push(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
storage.set("downloadedItems", JSON.stringify(items));
|
|
||||||
} catch (error) {
|
|
||||||
writeToLog("ERROR", "Failed to save downloaded item information:", error);
|
|
||||||
console.error("Failed to save downloaded item information:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
111
app/login.tsx
111
app/login.tsx
@@ -4,9 +4,8 @@ import { Text } from "@/components/common/Text";
|
|||||||
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { getSystemApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
@@ -14,6 +13,7 @@ import {
|
|||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
Platform,
|
Platform,
|
||||||
SafeAreaView,
|
SafeAreaView,
|
||||||
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
|
|
||||||
@@ -65,6 +65,23 @@ const Login: React.FC = () => {
|
|||||||
})();
|
})();
|
||||||
}, [_apiUrl, _username, _password]);
|
}, [_apiUrl, _username, _password]);
|
||||||
|
|
||||||
|
const navigation = useNavigation();
|
||||||
|
useEffect(() => {
|
||||||
|
navigation.setOptions({
|
||||||
|
headerTitle: serverName,
|
||||||
|
headerLeft: () =>
|
||||||
|
api?.basePath ? (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
removeServer();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="chevron-back" size={24} color="white" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : null,
|
||||||
|
});
|
||||||
|
}, [serverName, navigation, api?.basePath]);
|
||||||
|
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
|
|
||||||
const handleLogin = async () => {
|
const handleLogin = async () => {
|
||||||
@@ -103,37 +120,19 @@ const Login: React.FC = () => {
|
|||||||
* - Logs errors and timeout information to the console.
|
* - Logs errors and timeout information to the console.
|
||||||
*/
|
*/
|
||||||
async function checkUrl(url: string) {
|
async function checkUrl(url: string) {
|
||||||
url = url.endsWith("/") ? url.slice(0, -1) : url;
|
|
||||||
setLoadingServerCheck(true);
|
setLoadingServerCheck(true);
|
||||||
|
|
||||||
const protocols = ["https://", "http://"];
|
|
||||||
const timeout = 2000; // 2 seconds timeout for long 404 responses
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (const protocol of protocols) {
|
const response = await fetch(`${url}/System/Info/Public`, {
|
||||||
const controller = new AbortController();
|
mode: "cors",
|
||||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
});
|
||||||
|
|
||||||
try {
|
if (response.ok) {
|
||||||
const response = await fetch(`${protocol}${url}/System/Info/Public`, {
|
const data = (await response.json()) as PublicSystemInfo;
|
||||||
mode: "cors",
|
setServerName(data.ServerName || "");
|
||||||
signal: controller.signal,
|
return url;
|
||||||
});
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
if (response.ok) {
|
|
||||||
const data = (await response.json()) as PublicSystemInfo;
|
|
||||||
setServerName(data.ServerName || "");
|
|
||||||
return `${protocol}${url}`;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
const error = e as Error;
|
|
||||||
if (error.name === "AbortError") {
|
|
||||||
console.error(`Request to ${protocol}${url} timed out`);
|
|
||||||
} else {
|
|
||||||
console.error(`Error checking ${protocol}${url}:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingServerCheck(false);
|
setLoadingServerCheck(false);
|
||||||
@@ -159,9 +158,7 @@ const Login: React.FC = () => {
|
|||||||
const handleConnect = async (url: string) => {
|
const handleConnect = async (url: string) => {
|
||||||
url = url.trim();
|
url = url.trim();
|
||||||
|
|
||||||
const result = await checkUrl(
|
const result = await checkUrl(url);
|
||||||
url.startsWith("http") ? new URL(url).host : url
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result === undefined) {
|
if (result === undefined) {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
@@ -171,7 +168,7 @@ const Login: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setServer({ address: result });
|
setServer({ address: url });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleQuickConnect = async () => {
|
const handleQuickConnect = async () => {
|
||||||
@@ -196,38 +193,21 @@ const Login: React.FC = () => {
|
|||||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||||
style={{ flex: 1, height: "100%" }}
|
style={{ flex: 1, height: "100%" }}
|
||||||
>
|
>
|
||||||
<View className="flex flex-col w-full h-full relative items-center justify-center">
|
<View className="flex flex-col h-full relative items-center justify-center">
|
||||||
<View className="px-4 -mt-20">
|
<View className="px-4 -mt-20 w-full">
|
||||||
<View className="mb-4">
|
|
||||||
<Text className="text-3xl font-bold mb-1">
|
|
||||||
{serverName || "Streamyfin"}
|
|
||||||
</Text>
|
|
||||||
<View className="bg-neutral-900 rounded-xl p-4 mb-2 flex flex-row items-center justify-between">
|
|
||||||
<Text className="">URL</Text>
|
|
||||||
<Text numberOfLines={1} className="shrink">
|
|
||||||
{api.basePath}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Button
|
|
||||||
color="black"
|
|
||||||
onPress={() => {
|
|
||||||
removeServer();
|
|
||||||
}}
|
|
||||||
justify="between"
|
|
||||||
iconLeft={
|
|
||||||
<Ionicons
|
|
||||||
name="arrow-back-outline"
|
|
||||||
size={18}
|
|
||||||
color={"white"}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Change server
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className="flex flex-col space-y-2">
|
<View className="flex flex-col space-y-2">
|
||||||
<Text className="text-2xl font-bold">Log in</Text>
|
<Text className="text-2xl font-bold -mb-2">
|
||||||
|
Log in
|
||||||
|
<>
|
||||||
|
{serverName ? (
|
||||||
|
<>
|
||||||
|
{" to "}
|
||||||
|
<Text className="text-purple-600">{serverName}</Text>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
</Text>
|
||||||
|
<Text className="text-xs text-neutral-400">{serverURL}</Text>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Username"
|
placeholder="Username"
|
||||||
onChangeText={(text) =>
|
onChangeText={(text) =>
|
||||||
@@ -301,7 +281,7 @@ const Login: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
<Text className="text-3xl font-bold">Streamyfin</Text>
|
<Text className="text-3xl font-bold">Streamyfin</Text>
|
||||||
<Text className="text-neutral-500">
|
<Text className="text-neutral-500">
|
||||||
Connect to your Jellyfin server
|
Enter the URL to your Jellyfin server
|
||||||
</Text>
|
</Text>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Server URL"
|
placeholder="Server URL"
|
||||||
@@ -313,6 +293,9 @@ const Login: React.FC = () => {
|
|||||||
textContentType="URL"
|
textContentType="URL"
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
/>
|
/>
|
||||||
|
<Text className="text-xs text-neutral-500">
|
||||||
|
Make sure to include http or https
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className="mb-2 absolute bottom-0 left-0 w-full px-4">
|
<View className="mb-2 absolute bottom-0 left-0 w-full px-4">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
BIN
assets/icons/list.png
Normal file
BIN
assets/icons/list.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
65
assets/images/not-rotten-tomatoes.svg
Normal file
65
assets/images/not-rotten-tomatoes.svg
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<svg
|
||||||
|
type="certified"
|
||||||
|
viewBox="0 0 80 80"
|
||||||
|
preserveAspectRatio="xMidYMid"
|
||||||
|
version="1.1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
>
|
||||||
|
<g transform="translate(2.29, 0)">
|
||||||
|
<path
|
||||||
|
d="M42.1942857,18.8022857 C44.3794286,18.608 49.1565714,18.7177143 51.4902857,21.0057143 C51.6297143,21.1451429 51.5085714,21.4605714 51.3097143,21.408 C47.8902857,20.4868571 42.5577143,25.0217143 39.1017143,22.0891429 C39.008,22.9485714 38.2331429,27.0857143 32.3314286,26.4731429 C32.192,26.4594286 32.1371429,26.304 32.24,26.2171429 C33.1542857,25.44 34.2765714,23.2891429 33.3142857,21.9154286 C30.3108571,23.9085714 28.7565714,23.9954286 23.2182857,21.5954286 C23.0377143,21.5177143 23.1451429,21.2228571 23.3577143,21.1748571 C24.5074286,20.9165714 27.2434286,19.9222857 29.696,19.4582857 C30.1645714,19.3691429 30.624,19.3165714 31.0674286,19.312 C28.528,18.7062857 27.4217143,18.1805714 25.7485714,18.1874286 C25.5657143,18.1897143 25.4742857,17.9611429 25.6068571,17.8354286 C28.224,15.3188571 32.9691429,15.1885714 35.2548571,17.0628571 L33.2068571,12.7862857 L35.696,12.4114286 C35.696,12.4114286 36.3451429,14.6925714 36.9257143,16.7428571 C39.5177143,13.904 43.5268571,14.192 44.8777143,16.672 C44.9577143,16.8182857 44.8251429,16.992 44.6605714,16.9622857 C43.3005714,16.7314286 42.3702857,17.8628571 42.1737143,18.7977143 L42.1942857,18.8022857"
|
||||||
|
id="Fill-2"
|
||||||
|
fill="#00912D"
|
||||||
|
></path>
|
||||||
|
<mask id="mask-2" fill="white">
|
||||||
|
<polygon
|
||||||
|
points="0.137142857 0.921142857 75.0534777 0.921142857 75.0534777 79.8628571 0.137142857 79.8628571"
|
||||||
|
></polygon>
|
||||||
|
</mask>
|
||||||
|
<path
|
||||||
|
d="M13.0491429,59.1817143 C9.90628571,55.3554286 7.86971429,50.576 7.51771429,44.9622857 C6.912,35.2342857 10.2354286,26.0845714 23.1794286,21.4834286 C23.1908571,21.5245714 23.1725714,21.5748571 23.2182857,21.5954286 C23.0377143,21.5177143 23.1451429,21.2228571 23.3577143,21.1748571 C24.5074286,20.9165714 27.2434286,19.92 29.696,19.4582857 C30.1645714,19.3691429 30.624,19.3165714 31.0674286,19.3097143 C28.528,18.7062857 27.4217143,18.1805714 25.7485714,18.1874286 C25.5657143,18.1897143 25.4742857,17.9611429 25.6068571,17.8331429 C28.224,15.3165714 32.9691429,15.1885714 35.2548571,17.0628571 L33.2068571,12.784 L35.696,12.4114286 C35.696,12.4114286 36.3451429,14.6902857 36.9257143,16.7428571 C39.5177143,13.904 43.5268571,14.192 44.8777143,16.672 C44.9577143,16.8182857 44.8251429,16.992 44.6605714,16.9622857 C43.3005714,16.7314286 42.3702857,17.8628571 42.1737143,18.7977143 L42.1942857,18.8022857 C44.3794286,18.608 49.1565714,18.7177143 51.4902857,21.0057143 C51.328,20.8502857 51.1337143,20.7245714 50.9508571,20.5874286 C60.2765714,23.504 66.7474286,30.1531429 67.44,41.2251429 C67.8811429,48.2948571 65.5702857,54.3885714 61.568,59.1154286 C62.784,59.2891429 63.9931429,59.4925714 65.2045714,59.6937143 C70.304,53.4537143 73.2502857,45.5428571 73.2502857,37.056 C73.2502857,17.7165714 57.5337143,2.56685714 37.472,2.56685714 C17.4102857,2.56685714 1.69371429,17.7165714 1.69371429,37.056 C1.69371429,45.5565714 4.64,53.472 9.744,59.7097143 C10.8434286,59.5268571 11.9451429,59.3462857 13.0491429,59.1817143"
|
||||||
|
fill="#FFD700"
|
||||||
|
mask="url(#mask-2)"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
d="M9.744,59.7097143 C4.64,53.472 1.69371429,45.5565714 1.69371429,37.056 C1.69371429,17.7165714 17.4102857,2.56685714 37.472,2.56685714 C57.5337143,2.56685714 73.2502857,17.7165714 73.2502857,37.056 C73.2502857,45.5428571 70.304,53.4537143 65.2045714,59.6937143 C65.8125714,59.7942857 66.4205714,59.8742857 67.0285714,59.984 C71.9497143,53.6457143 74.8937143,45.6982857 74.8937143,37.056 C74.8937143,16.3862857 58.1394286,0.921142857 37.472,0.921142857 C16.8022857,0.921142857 0.048,16.3862857 0.048,37.056 C0.048,45.7074286 2.99885714,53.6594286 7.92914286,59.9977143 C8.53257143,59.8902857 9.13828571,59.8102857 9.744,59.7097143"
|
||||||
|
fill="#FA6E0F"
|
||||||
|
mask="url(#mask-2)"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
d="M58.2857143,74.9394286 C62.3748571,75.1954286 65.7874286,77.2137143 67.8468571,79.9474286 C67.9131429,80.0182857 68.0114286,80.016 68.0411429,79.9382857 C68.7451429,77.0971429 68.9394286,74.0662857 68.5851429,71.0125714 C68.5874286,70.9805714 68.6125714,70.9577143 68.6537143,70.9485714 C70.576,70.3428571 72.7017143,70.0137143 74.9645714,70.0457143 C75.0857143,70.0594286 75.0834286,69.9405714 74.9554286,69.8194286 C72.5577143,67.4994286 69.6297143,65.6914286 66.416,64.5417143 C65.3051429,67.68 64.2217143,70.816 63.1565714,73.9634286 C63.136,74.0228571 63.0514286,74.0594286 62.9645714,74.0434286 L58.2857143,74.9394286"
|
||||||
|
fill="#0AC855"
|
||||||
|
mask="url(#mask-2)"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
d="M62.9645714,74.0434286 L58.2857143,74.9394286 C58.2857143,74.9394286 58.3451429,74.512 58.528,73.3325714 C60.9417143,73.6754286 62.9645714,74.0434286 62.9645714,74.0434286"
|
||||||
|
fill="#0B4902"
|
||||||
|
></path>
|
||||||
|
<g transform="translate(0, 20.57)">
|
||||||
|
<mask id="mask-4" fill="white">
|
||||||
|
<polygon
|
||||||
|
points="0.137142857 0.016 67.4935952 0.016 67.4935952 59.2914286 0.137142857 59.2914286"
|
||||||
|
></polygon>
|
||||||
|
</mask>
|
||||||
|
<path
|
||||||
|
d="M13.0765714,38.6057143 C29.1177143,36.2605714 45.5222857,36.2354286 61.568,38.544 C65.5702857,33.8171429 67.8811429,27.7234286 67.44,20.6537143 C66.7474286,9.58171429 60.2765714,2.93257143 50.9508571,0.016 C51.1337143,0.153142857 51.328,0.278857143 51.4902857,0.434285714 C51.6297143,0.573714286 51.5085714,0.889142857 51.3097143,0.836571429 C47.8902857,-0.0845714286 42.5577143,4.45028571 39.1017143,1.51771429 C39.008,2.37485714 38.2331429,6.51428571 32.3314286,5.90171429 C32.192,5.888 32.1371429,5.73257143 32.24,5.64571429 C33.1542857,4.86857143 34.2765714,2.71542857 33.3142857,1.344 C30.3108571,3.33714286 28.7565714,3.424 23.2182857,1.024 C23.1725714,1.00342857 23.1908571,0.953142857 23.1794286,0.912 C10.2354286,5.51314286 6.912,14.6628571 7.51771429,24.3908571 C7.86971429,30.0091429 9.93142857,34.7748571 13.0765714,38.6057143"
|
||||||
|
fill="#FA3200"
|
||||||
|
mask="url(#mask-4)"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
d="M12.0868571,53.472 C12,53.488 11.9154286,53.4514286 11.8948571,53.392 C10.8274286,50.2445714 9.73485714,47.0971429 8.62171429,43.9611429 C5.41028571,45.1108571 2.49371429,46.9302857 0.0982857143,49.248 C-0.0297142857,49.3691429 -0.032,49.488 0.0891428571,49.4742857 C2.352,49.4422857 4.47771429,49.7714286 6.4,50.3771429 C6.44114286,50.3862857 6.46628571,50.4091429 6.46857143,50.4411429 C6.11428571,53.4948571 6.30857143,56.5257143 7.01257143,59.3668571 C7.04228571,59.4445714 7.14057143,59.4468571 7.20685714,59.376 C9.26628571,56.6422857 12.6742857,54.624 16.7657143,54.368 L12.0868571,53.472"
|
||||||
|
fill="#0AC855"
|
||||||
|
mask="url(#mask-4)"
|
||||||
|
></path>
|
||||||
|
</g>
|
||||||
|
<path
|
||||||
|
d="M62.9645714,74.0434286 C46.192,71.104 28.8571429,71.104 12.0868571,74.0434286 C12,74.0594286 11.9154286,74.0228571 11.8948571,73.9634286 C10.3428571,69.3851429 8.74285714,64.8182857 7.09257143,60.2628571 C7.06971429,60.1988571 7.14057143,60.1257143 7.248,60.1074286 C27.1885714,56.464 47.8605714,56.464 67.8034286,60.1074286 C67.9108571,60.1257143 67.9817143,60.1988571 67.9565714,60.2628571 C66.3085714,64.8182857 64.7085714,69.3851429 63.1565714,73.9634286 C63.136,74.0228571 63.0514286,74.0594286 62.9645714,74.0434286"
|
||||||
|
fill="#00912D"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
d="M12.0868571,74.0434286 L16.7657143,74.9394286 C16.7657143,74.9394286 16.704,74.512 16.5211429,73.3325714 C14.1074286,73.6754286 12.0868571,74.0434286 12.0868571,74.0434286"
|
||||||
|
fill="#0B4902"
|
||||||
|
></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 7.4 KiB |
3
augmentations/index.ts
Normal file
3
augmentations/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./mmkv";
|
||||||
|
export * from "./number";
|
||||||
|
export * from "./string";
|
||||||
17
augmentations/mmkv.ts
Normal file
17
augmentations/mmkv.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import {MMKV} from "react-native-mmkv";
|
||||||
|
|
||||||
|
declare module "react-native-mmkv" {
|
||||||
|
interface MMKV {
|
||||||
|
get<T>(key: string): T | undefined
|
||||||
|
setAny(key: string, value: any | undefined): void
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MMKV.prototype.get = function <T> (key: string): T | undefined {
|
||||||
|
const serializedItem = this.getString(key);
|
||||||
|
return serializedItem ? JSON.parse(serializedItem) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
MMKV.prototype.setAny = function (key: string, value: any | undefined): void {
|
||||||
|
this.set(key, JSON.stringify(value));
|
||||||
|
}
|
||||||
37
augmentations/number.ts
Normal file
37
augmentations/number.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
declare global {
|
||||||
|
interface Number {
|
||||||
|
bytesToReadable(): string;
|
||||||
|
secondsToMilliseconds(): number
|
||||||
|
minutesToMilliseconds(): number
|
||||||
|
hoursToMilliseconds(): number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Number.prototype.bytesToReadable = function () {
|
||||||
|
const bytes = this.valueOf();
|
||||||
|
const gb = bytes / 1e9;
|
||||||
|
|
||||||
|
if (gb >= 1) return `${gb.toFixed(2)} GB`;
|
||||||
|
|
||||||
|
const mb = bytes / 1024.0 / 1024.0;
|
||||||
|
if (mb >= 1) return `${mb.toFixed(2)} MB`;
|
||||||
|
|
||||||
|
const kb = bytes / 1024.0;
|
||||||
|
if (kb >= 1) return `${kb.toFixed(2)} KB`;
|
||||||
|
|
||||||
|
return `${bytes.toFixed(2)} B`;
|
||||||
|
}
|
||||||
|
|
||||||
|
Number.prototype.secondsToMilliseconds = function () {
|
||||||
|
return this.valueOf() * 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
Number.prototype.minutesToMilliseconds = function () {
|
||||||
|
return this.valueOf() * (60).secondsToMilliseconds()
|
||||||
|
}
|
||||||
|
|
||||||
|
Number.prototype.hoursToMilliseconds = function () {
|
||||||
|
return this.valueOf() * (60).minutesToMilliseconds()
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
16
augmentations/string.ts
Normal file
16
augmentations/string.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
declare global {
|
||||||
|
interface String {
|
||||||
|
toTitle(): string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String.prototype.toTitle = function () {
|
||||||
|
return this
|
||||||
|
.replaceAll("_", " ")
|
||||||
|
.replace(
|
||||||
|
/\w\S*/g,
|
||||||
|
text => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { Modal, TouchableOpacity, View } from "react-native";
|
||||||
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> {
|
||||||
@@ -16,6 +16,8 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
|||||||
selected,
|
selected,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
|
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||||
|
|
||||||
const audioStreams = useMemo(
|
const audioStreams = useMemo(
|
||||||
() => source?.MediaStreams?.filter((x) => x.Type === "Audio"),
|
() => source?.MediaStreams?.filter((x) => x.Type === "Audio"),
|
||||||
[source]
|
[source]
|
||||||
@@ -25,50 +27,80 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
|||||||
() => audioStreams?.find((x) => x.Index === selected),
|
() => audioStreams?.find((x) => x.Index === selected),
|
||||||
[audioStreams, selected]
|
[audioStreams, selected]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<>
|
||||||
className="flex shrink"
|
<View
|
||||||
style={{
|
className="flex shrink"
|
||||||
minWidth: 50,
|
style={{
|
||||||
}}
|
minWidth: 50,
|
||||||
>
|
}}
|
||||||
<DropdownMenu.Root>
|
>
|
||||||
<DropdownMenu.Trigger>
|
<View className="flex flex-col" {...props}>
|
||||||
<View className="flex flex-col" {...props}>
|
<Text className="opacity-50 mb-1 text-xs">Audio</Text>
|
||||||
<Text className="opacity-50 mb-1 text-xs">Audio</Text>
|
<TouchableOpacity
|
||||||
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
|
className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between"
|
||||||
<Text className="" numberOfLines={1}>
|
onPress={() => setIsModalVisible(true)}
|
||||||
{selectedAudioSteam?.DisplayTitle}
|
>
|
||||||
|
<Text className="" numberOfLines={1}>
|
||||||
|
{selectedAudioSteam?.DisplayTitle}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name="chevron-down"
|
||||||
|
size={16}
|
||||||
|
color="white"
|
||||||
|
style={{ opacity: 0.5 }}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
visible={isModalVisible}
|
||||||
|
transparent
|
||||||
|
animationType="slide"
|
||||||
|
onRequestClose={() => setIsModalVisible(false)}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
className="flex-1 bg-black/50"
|
||||||
|
activeOpacity={1}
|
||||||
|
onPress={() => setIsModalVisible(false)}
|
||||||
|
>
|
||||||
|
<View className="mt-auto bg-neutral-900 rounded-t-xl">
|
||||||
|
<View className="p-4 border-b border-neutral-800">
|
||||||
|
<Text className="text-lg font-bold text-center">
|
||||||
|
Audio Streams
|
||||||
</Text>
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="max-h-[50%]">
|
||||||
|
{audioStreams?.map((audio, idx: number) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={idx.toString()}
|
||||||
|
className={`p-4 border-b border-neutral-800 flex-row items-center justify-between`}
|
||||||
|
onPress={() => {
|
||||||
|
if (audio.Index !== null && audio.Index !== undefined) {
|
||||||
|
onChange(audio.Index);
|
||||||
|
setIsModalVisible(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text>{audio.DisplayTitle}</Text>
|
||||||
|
{audio.Index === selected && (
|
||||||
|
<Ionicons name="checkmark" size={24} color="white" />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
className="p-4 border-t border-neutral-800"
|
||||||
|
onPress={() => setIsModalVisible(false)}
|
||||||
|
>
|
||||||
|
<Text className="text-center text-purple-400">Cancel</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</DropdownMenu.Trigger>
|
</TouchableOpacity>
|
||||||
<DropdownMenu.Content
|
</Modal>
|
||||||
loop={true}
|
</>
|
||||||
side="bottom"
|
|
||||||
align="start"
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={8}
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Label>Audio streams</DropdownMenu.Label>
|
|
||||||
{audioStreams?.map((audio, idx: number) => (
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={idx.toString()}
|
|
||||||
onSelect={() => {
|
|
||||||
if (audio.Index !== null && audio.Index !== undefined)
|
|
||||||
onChange(audio.Index);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>
|
|
||||||
{audio.DisplayTitle}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
))}
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View, Modal } from "react-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
import { useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
|
||||||
export type Bitrate = {
|
export type Bitrate = {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -49,6 +49,8 @@ export const BitrateSelector: React.FC<Props> = ({
|
|||||||
inverted,
|
inverted,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
|
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||||
|
|
||||||
const sorted = useMemo(() => {
|
const sorted = useMemo(() => {
|
||||||
if (inverted)
|
if (inverted)
|
||||||
return BITRATES.sort(
|
return BITRATES.sort(
|
||||||
@@ -57,49 +59,81 @@ export const BitrateSelector: React.FC<Props> = ({
|
|||||||
return BITRATES.sort(
|
return BITRATES.sort(
|
||||||
(a, b) => (b.value || Infinity) - (a.value || Infinity)
|
(a, b) => (b.value || Infinity) - (a.value || Infinity)
|
||||||
);
|
);
|
||||||
}, []);
|
}, [inverted]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<>
|
||||||
className="flex shrink"
|
<View
|
||||||
style={{
|
className="flex shrink"
|
||||||
minWidth: 60,
|
style={{
|
||||||
maxWidth: 200,
|
minWidth: 60,
|
||||||
}}
|
maxWidth: 200,
|
||||||
>
|
}}
|
||||||
<DropdownMenu.Root>
|
>
|
||||||
<DropdownMenu.Trigger>
|
<View className="flex flex-col" {...props}>
|
||||||
<View className="flex flex-col" {...props}>
|
<Text className="opacity-50 mb-1 text-xs">Quality</Text>
|
||||||
<Text className="opacity-50 mb-1 text-xs">Quality</Text>
|
<TouchableOpacity
|
||||||
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
|
className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between"
|
||||||
<Text style={{}} className="" numberOfLines={1}>
|
onPress={() => setIsModalVisible(true)}
|
||||||
{BITRATES.find((b) => b.value === selected?.value)?.key}
|
>
|
||||||
|
<Text className="" numberOfLines={1}>
|
||||||
|
{BITRATES.find((b) => b.value === selected?.value)?.key}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name="chevron-down"
|
||||||
|
size={16}
|
||||||
|
color="white"
|
||||||
|
style={{ opacity: 0.5 }}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
visible={isModalVisible}
|
||||||
|
transparent
|
||||||
|
animationType="slide"
|
||||||
|
onRequestClose={() => setIsModalVisible(false)}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
className="flex-1 bg-black/50"
|
||||||
|
activeOpacity={1}
|
||||||
|
onPress={() => setIsModalVisible(false)}
|
||||||
|
>
|
||||||
|
<View className="mt-auto bg-neutral-900 rounded-t-xl">
|
||||||
|
<View className="p-4 border-b border-neutral-800">
|
||||||
|
<Text className="text-lg font-bold text-center">
|
||||||
|
Select Quality
|
||||||
</Text>
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="max-h-[50%]">
|
||||||
|
{sorted.map((bitrate) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={bitrate.key}
|
||||||
|
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
|
||||||
|
onPress={() => {
|
||||||
|
onChange(bitrate);
|
||||||
|
setIsModalVisible(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text>{bitrate.key}</Text>
|
||||||
|
{bitrate.value === selected?.value && (
|
||||||
|
<Ionicons name="checkmark" size={24} color="white" />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
className="p-4 border-t border-neutral-800"
|
||||||
|
onPress={() => setIsModalVisible(false)}
|
||||||
|
>
|
||||||
|
<Text className="text-center text-purple-400">Cancel</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</DropdownMenu.Trigger>
|
</TouchableOpacity>
|
||||||
<DropdownMenu.Content
|
</Modal>
|
||||||
loop={false}
|
</>
|
||||||
side="bottom"
|
|
||||||
align="center"
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={0}
|
|
||||||
sideOffset={0}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Label>Bitrates</DropdownMenu.Label>
|
|
||||||
{sorted.map((b) => (
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={b.key}
|
|
||||||
onSelect={() => {
|
|
||||||
onChange(b);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>{b.key}</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
))}
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import * as Haptics from "expo-haptics";
|
|
||||||
import React, { PropsWithChildren, ReactNode, useMemo } from "react";
|
import React, { PropsWithChildren, ReactNode, useMemo } from "react";
|
||||||
import { Text, TouchableOpacity, View } from "react-native";
|
import { Text, TouchableOpacity, View } from "react-native";
|
||||||
import { Loader } from "./Loader";
|
import { Loader } from "./Loader";
|
||||||
|
|
||||||
interface ButtonProps extends React.ComponentProps<typeof TouchableOpacity> {
|
export interface ButtonProps
|
||||||
|
extends React.ComponentProps<typeof TouchableOpacity> {
|
||||||
onPress?: () => void;
|
onPress?: () => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
textClassName?: string;
|
textClassName?: string;
|
||||||
@@ -46,14 +46,13 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
className={`
|
className={`
|
||||||
p-3 rounded-xl items-center justify-center
|
p-3 rounded-xl items-center justify-center
|
||||||
${loading || (disabled && "opacity-50")}
|
${(loading || disabled) && "opacity-50"}
|
||||||
${colorClasses}
|
${colorClasses}
|
||||||
${className}
|
${className}
|
||||||
`}
|
`}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (!loading && !disabled && onPress) {
|
if (!loading && !disabled && onPress) {
|
||||||
onPress();
|
onPress();
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={disabled || loading}
|
disabled={disabled || loading}
|
||||||
|
|||||||
@@ -1,103 +0,0 @@
|
|||||||
import { Feather } from "@expo/vector-icons";
|
|
||||||
import { BlurView } from "expo-blur";
|
|
||||||
import React, { useCallback, useEffect } from "react";
|
|
||||||
import { Platform, TouchableOpacity, ViewProps } from "react-native";
|
|
||||||
import GoogleCast, {
|
|
||||||
CastButton,
|
|
||||||
CastContext,
|
|
||||||
useCastDevice,
|
|
||||||
useDevices,
|
|
||||||
useMediaStatus,
|
|
||||||
useRemoteMediaClient,
|
|
||||||
} from "react-native-google-cast";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
|
||||||
width?: number;
|
|
||||||
height?: number;
|
|
||||||
background?: "blur" | "transparent";
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Chromecast: React.FC<Props> = ({
|
|
||||||
width = 48,
|
|
||||||
height = 48,
|
|
||||||
background = "transparent",
|
|
||||||
...props
|
|
||||||
}) => {
|
|
||||||
const client = useRemoteMediaClient();
|
|
||||||
const castDevice = useCastDevice();
|
|
||||||
const devices = useDevices();
|
|
||||||
const sessionManager = GoogleCast.getSessionManager();
|
|
||||||
const discoveryManager = GoogleCast.getDiscoveryManager();
|
|
||||||
const mediaStatus = useMediaStatus();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
(async () => {
|
|
||||||
if (!discoveryManager) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await discoveryManager.startDiscovery();
|
|
||||||
})();
|
|
||||||
}, [client, devices, castDevice, sessionManager, discoveryManager]);
|
|
||||||
|
|
||||||
// Android requires the cast button to be present for startDiscovery to work
|
|
||||||
const AndroidCastButton = useCallback(
|
|
||||||
() =>
|
|
||||||
Platform.OS === "android" ? (
|
|
||||||
<CastButton tintColor="transparent" />
|
|
||||||
) : (
|
|
||||||
<></>
|
|
||||||
),
|
|
||||||
[Platform.OS]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (background === "transparent")
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
|
||||||
else CastContext.showCastDialog();
|
|
||||||
}}
|
|
||||||
className="rounded-full h-10 w-10 flex items-center justify-center b"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<Feather name="cast" size={22} color={"white"} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
<AndroidCastButton />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (Platform.OS === "android")
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
|
||||||
else CastContext.showCastDialog();
|
|
||||||
}}
|
|
||||||
className="rounded-full h-10 w-10 flex items-center justify-center bg-neutral-800/80"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<Feather name="cast" size={22} color={"white"} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
|
||||||
else CastContext.showCastDialog();
|
|
||||||
}}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<BlurView
|
|
||||||
intensity={100}
|
|
||||||
className="rounded-full overflow-hidden h-10 aspect-square flex items-center justify-center"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<Feather name="cast" size={22} color={"white"} />
|
|
||||||
</BlurView>
|
|
||||||
<AndroidCastButton />
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,27 +1,30 @@
|
|||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo } from "react";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { WatchedIndicator } from "./WatchedIndicator";
|
import { WatchedIndicator } from "./WatchedIndicator";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
|
||||||
type ContinueWatchingPosterProps = {
|
type ContinueWatchingPosterProps = {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
useEpisodePoster?: boolean;
|
useEpisodePoster?: boolean;
|
||||||
size?: "small" | "normal";
|
size?: "small" | "normal";
|
||||||
|
showPlayButton?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
||||||
item,
|
item,
|
||||||
useEpisodePoster = false,
|
useEpisodePoster = false,
|
||||||
size = "normal",
|
size = "normal",
|
||||||
|
showPlayButton = false,
|
||||||
}) => {
|
}) => {
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get horrizontal poster for movie and episode, with failover to primary.
|
* Get horizontal poster for movie and episode, with failover to primary.
|
||||||
*/
|
*/
|
||||||
const url = useMemo(() => {
|
const url = useMemo(() => {
|
||||||
if (!api) return;
|
if (!api) return;
|
||||||
@@ -73,16 +76,23 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
|||||||
${size === "small" ? "w-32" : "w-44"}
|
${size === "small" ? "w-32" : "w-44"}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<Image
|
<View className="w-full h-full flex items-center justify-center">
|
||||||
key={item.Id}
|
<Image
|
||||||
id={item.Id}
|
key={item.Id}
|
||||||
source={{
|
id={item.Id}
|
||||||
uri: url,
|
source={{
|
||||||
}}
|
uri: url,
|
||||||
cachePolicy={"memory-disk"}
|
}}
|
||||||
contentFit="cover"
|
cachePolicy={"memory-disk"}
|
||||||
className="w-full h-full"
|
contentFit="cover"
|
||||||
/>
|
className="w-full h-full"
|
||||||
|
/>
|
||||||
|
{showPlayButton && (
|
||||||
|
<View className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<Ionicons name="play-circle" size={40} color="white" />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
{!progress && <WatchedIndicator item={item} />}
|
{!progress && <WatchedIndicator item={item} />}
|
||||||
{progress > 0 && (
|
{progress > 0 && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,301 +0,0 @@
|
|||||||
import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
|
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { queueActions, queueAtom } from "@/utils/atoms/queue";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
|
||||||
import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server";
|
|
||||||
import native from "@/utils/profiles/native";
|
|
||||||
import download from "@/utils/profiles/download";
|
|
||||||
import Ionicons from "@expo/vector-icons/Ionicons";
|
|
||||||
import {
|
|
||||||
BottomSheetBackdrop,
|
|
||||||
BottomSheetBackdropProps,
|
|
||||||
BottomSheetModal,
|
|
||||||
BottomSheetView,
|
|
||||||
} from "@gorhom/bottom-sheet";
|
|
||||||
import {
|
|
||||||
BaseItemDto,
|
|
||||||
MediaSourceInfo,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { router, useFocusEffect } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
|
||||||
import { Alert, TouchableOpacity, View, ViewProps } from "react-native";
|
|
||||||
import { toast } from "sonner-native";
|
|
||||||
import { AudioTrackSelector } from "./AudioTrackSelector";
|
|
||||||
import { Bitrate, BitrateSelector } from "./BitrateSelector";
|
|
||||||
import { Button } from "./Button";
|
|
||||||
import { Text } from "./common/Text";
|
|
||||||
import { Loader } from "./Loader";
|
|
||||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
|
||||||
import ProgressCircle from "./ProgressCircle";
|
|
||||||
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
|
|
||||||
|
|
||||||
interface DownloadProps extends ViewProps {
|
|
||||||
item: BaseItemDto;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
const [queue, setQueue] = useAtom(queueAtom);
|
|
||||||
const [settings] = useSettings();
|
|
||||||
const { processes, startBackgroundDownload } = useDownload();
|
|
||||||
const { startRemuxing } = useRemuxHlsToMp4();
|
|
||||||
|
|
||||||
const [selectedMediaSource, setSelectedMediaSource] = useState<
|
|
||||||
MediaSourceInfo | undefined | null
|
|
||||||
>(undefined);
|
|
||||||
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
|
||||||
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
|
||||||
useState<number>(0);
|
|
||||||
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
|
|
||||||
key: "Max",
|
|
||||||
value: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
useFocusEffect(
|
|
||||||
useCallback(() => {
|
|
||||||
if (!settings) return;
|
|
||||||
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
|
|
||||||
getDefaultPlaySettings(item, settings);
|
|
||||||
|
|
||||||
// 4. Set states
|
|
||||||
setSelectedMediaSource(mediaSource ?? undefined);
|
|
||||||
setSelectedAudioStream(audioIndex ?? 0);
|
|
||||||
setSelectedSubtitleStream(subtitleIndex ?? -1);
|
|
||||||
setMaxBitrate(bitrate);
|
|
||||||
}, [item, settings])
|
|
||||||
);
|
|
||||||
|
|
||||||
const userCanDownload = useMemo(() => {
|
|
||||||
return user?.Policy?.EnableContentDownloading;
|
|
||||||
}, [user]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bottom sheet
|
|
||||||
*/
|
|
||||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
|
||||||
|
|
||||||
const handlePresentModalPress = useCallback(() => {
|
|
||||||
bottomSheetModalRef.current?.present();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSheetChanges = useCallback((index: number) => {}, []);
|
|
||||||
|
|
||||||
const closeModal = useCallback(() => {
|
|
||||||
bottomSheetModalRef.current?.dismiss();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start download
|
|
||||||
*/
|
|
||||||
const initiateDownload = useCallback(async () => {
|
|
||||||
if (!api || !user?.Id || !item.Id || !selectedMediaSource?.Id) {
|
|
||||||
throw new Error(
|
|
||||||
"DownloadItem ~ initiateDownload: No api or user or item"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await getStreamUrl({
|
|
||||||
api,
|
|
||||||
item,
|
|
||||||
startTimeTicks: 0,
|
|
||||||
userId: user?.Id,
|
|
||||||
audioStreamIndex: selectedAudioStream,
|
|
||||||
maxStreamingBitrate: maxBitrate.value,
|
|
||||||
mediaSourceId: selectedMediaSource.Id,
|
|
||||||
subtitleStreamIndex: selectedSubtitleStream,
|
|
||||||
deviceProfile: download,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res) {
|
|
||||||
Alert.alert(
|
|
||||||
"Something went wrong",
|
|
||||||
"Could not get stream url from Jellyfin"
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { mediaSource, url } = res;
|
|
||||||
|
|
||||||
if (!url || !mediaSource) throw new Error("No url");
|
|
||||||
|
|
||||||
saveDownloadItemInfoToDiskTmp(item, mediaSource, url);
|
|
||||||
|
|
||||||
if (settings?.downloadMethod === "optimized") {
|
|
||||||
return await startBackgroundDownload(url, item, mediaSource);
|
|
||||||
} else {
|
|
||||||
return await startRemuxing(item, url, mediaSource);
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
api,
|
|
||||||
item,
|
|
||||||
startBackgroundDownload,
|
|
||||||
user?.Id,
|
|
||||||
selectedMediaSource,
|
|
||||||
selectedAudioStream,
|
|
||||||
selectedSubtitleStream,
|
|
||||||
maxBitrate,
|
|
||||||
settings?.downloadMethod,
|
|
||||||
]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if item is downloaded
|
|
||||||
*/
|
|
||||||
const { downloadedFiles } = useDownload();
|
|
||||||
|
|
||||||
const isDownloaded = useMemo(() => {
|
|
||||||
if (!downloadedFiles) return false;
|
|
||||||
|
|
||||||
return downloadedFiles.some((file) => file.item.Id === item.Id);
|
|
||||||
}, [downloadedFiles, item.Id]);
|
|
||||||
|
|
||||||
const renderBackdrop = useCallback(
|
|
||||||
(props: BottomSheetBackdropProps) => (
|
|
||||||
<BottomSheetBackdrop
|
|
||||||
{...props}
|
|
||||||
disappearsOnIndex={-1}
|
|
||||||
appearsOnIndex={0}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const process = useMemo(() => {
|
|
||||||
if (!processes) return null;
|
|
||||||
|
|
||||||
return processes.find((process) => process?.item?.Id === item.Id);
|
|
||||||
}, [processes, item.Id]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
className="bg-neutral-800/80 rounded-full h-10 w-10 flex items-center justify-center"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{process && process?.item.Id === item.Id ? (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
router.push("/downloads");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{process.progress === 0 ? (
|
|
||||||
<Loader />
|
|
||||||
) : (
|
|
||||||
<View className="-rotate-45">
|
|
||||||
<ProgressCircle
|
|
||||||
size={24}
|
|
||||||
fill={process.progress}
|
|
||||||
width={4}
|
|
||||||
tintColor="#9334E9"
|
|
||||||
backgroundColor="#bdc3c7"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
) : queue.some((i) => i.id === item.Id) ? (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
router.push("/downloads");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name="hourglass" size={24} color="white" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
) : isDownloaded ? (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
router.push("/downloads");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name="cloud-download" size={26} color="#9333ea" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
) : (
|
|
||||||
<TouchableOpacity onPress={handlePresentModalPress}>
|
|
||||||
<Ionicons name="cloud-download-outline" size={24} color="white" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
<BottomSheetModal
|
|
||||||
ref={bottomSheetModalRef}
|
|
||||||
enableDynamicSizing
|
|
||||||
handleIndicatorStyle={{
|
|
||||||
backgroundColor: "white",
|
|
||||||
}}
|
|
||||||
backgroundStyle={{
|
|
||||||
backgroundColor: "#171717",
|
|
||||||
}}
|
|
||||||
onChange={handleSheetChanges}
|
|
||||||
backdropComponent={renderBackdrop}
|
|
||||||
>
|
|
||||||
<BottomSheetView>
|
|
||||||
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
|
|
||||||
<Text className="font-bold text-2xl text-neutral-10">
|
|
||||||
Download options
|
|
||||||
</Text>
|
|
||||||
<View className="flex flex-col space-y-2 w-full items-start">
|
|
||||||
<BitrateSelector
|
|
||||||
inverted
|
|
||||||
onChange={(val) => setMaxBitrate(val)}
|
|
||||||
selected={maxBitrate}
|
|
||||||
/>
|
|
||||||
<MediaSourceSelector
|
|
||||||
item={item}
|
|
||||||
onChange={setSelectedMediaSource}
|
|
||||||
selected={selectedMediaSource}
|
|
||||||
/>
|
|
||||||
{selectedMediaSource && (
|
|
||||||
<View className="flex flex-col space-y-2">
|
|
||||||
<AudioTrackSelector
|
|
||||||
source={selectedMediaSource}
|
|
||||||
onChange={setSelectedAudioStream}
|
|
||||||
selected={selectedAudioStream}
|
|
||||||
/>
|
|
||||||
<SubtitleTrackSelector
|
|
||||||
source={selectedMediaSource}
|
|
||||||
onChange={setSelectedSubtitleStream}
|
|
||||||
selected={selectedSubtitleStream}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
<Button
|
|
||||||
className="mt-auto"
|
|
||||||
onPress={() => {
|
|
||||||
if (userCanDownload === true) {
|
|
||||||
if (!item.Id) {
|
|
||||||
throw new Error("No item id");
|
|
||||||
}
|
|
||||||
closeModal();
|
|
||||||
if (settings?.downloadMethod === "remux") {
|
|
||||||
queueActions.enqueue(queue, setQueue, {
|
|
||||||
id: item.Id,
|
|
||||||
execute: async () => {
|
|
||||||
await initiateDownload();
|
|
||||||
},
|
|
||||||
item,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
initiateDownload();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
toast.error("You are not allowed to download files.");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
color="purple"
|
|
||||||
>
|
|
||||||
Download
|
|
||||||
</Button>
|
|
||||||
<View className="opacity-70 text-center w-full flex items-center">
|
|
||||||
{settings?.downloadMethod === "optimized" ? (
|
|
||||||
<Text className="text-xs">Using optimized server</Text>
|
|
||||||
) : (
|
|
||||||
<Text className="text-xs">Using default method</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</BottomSheetView>
|
|
||||||
</BottomSheetModal>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,22 +1,43 @@
|
|||||||
// GenreTags.tsx
|
// GenreTags.tsx
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { View } from "react-native";
|
import {View, ViewProps} from "react-native";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
|
||||||
interface GenreTagsProps {
|
interface TagProps {
|
||||||
genres?: string[];
|
tags?: string[];
|
||||||
|
textClass?: ViewProps["className"]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GenreTags: React.FC<GenreTagsProps> = ({ genres }) => {
|
export const Tag: React.FC<{ text: string, textClass?: ViewProps["className"]} & ViewProps> = ({
|
||||||
if (!genres || genres.length === 0) return null;
|
text,
|
||||||
|
textClass,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<View className="bg-neutral-800 rounded-full px-2 py-1" {...props}>
|
||||||
|
<Text className={textClass}>{text}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Tags: React.FC<TagProps & ViewProps> = ({ tags, textClass = "text-xs", ...props }) => {
|
||||||
|
if (!tags || tags.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex flex-row flex-wrap mt-2">
|
<View className={`flex flex-row flex-wrap gap-1 ${props.className}`} {...props}>
|
||||||
{genres.map((genre, idx) => (
|
{tags.map((tag, idx) => (
|
||||||
<View key={idx} className="bg-neutral-800 rounded-full px-2 py-1 mr-1">
|
<View>
|
||||||
<Text className="text-xs">{genre}</Text>
|
<Tag key={idx} textClass={textClass} text={tag}/>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const GenreTags: React.FC<{ genres?: string[]}> = ({ genres }) => {
|
||||||
|
return (
|
||||||
|
<View className="mt-2">
|
||||||
|
<Tags tags={genres}/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -13,12 +13,13 @@ export const ItemCardText: React.FC<ItemCardProps> = ({ item }) => {
|
|||||||
<View className="mt-2 flex flex-col">
|
<View className="mt-2 flex flex-col">
|
||||||
{item.Type === "Episode" ? (
|
{item.Type === "Episode" ? (
|
||||||
<>
|
<>
|
||||||
<Text numberOfLines={2} className="">
|
<Text numberOfLines={1} className="">
|
||||||
{item.SeriesName}
|
{item.Name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text numberOfLines={1} className="text-xs opacity-50">
|
<Text numberOfLines={1} className="text-xs opacity-50">
|
||||||
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}{" "}
|
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
|
||||||
{item.Name}
|
{" - "}
|
||||||
|
{item.SeriesName}
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
||||||
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
||||||
import { DownloadItem } from "@/components/DownloadItem";
|
|
||||||
import { OverviewText } from "@/components/OverviewText";
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
import { PlayButton } from "@/components/PlayButton";
|
import { PlayButton } from "@/components/PlayButton";
|
||||||
@@ -12,9 +11,8 @@ 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 useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||||
import { useImageColors } from "@/hooks/useImageColors";
|
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
import {
|
import {
|
||||||
@@ -23,13 +21,12 @@ import {
|
|||||||
} 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 { useNavigation } from "expo-router";
|
||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, 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 { ItemHeader } from "./ItemHeader";
|
import { ItemHeader } from "./ItemHeader";
|
||||||
|
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
||||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||||
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
||||||
|
|
||||||
@@ -44,10 +41,8 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
({ item }) => {
|
({ item }) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
const { orientation } = useOrientation();
|
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
useImageColors({ item });
|
|
||||||
|
|
||||||
const [loadingLogo, setLoadingLogo] = useState(true);
|
const [loadingLogo, setLoadingLogo] = useState(true);
|
||||||
const [headerHeight, setHeaderHeight] = useState(350);
|
const [headerHeight, setHeaderHeight] = useState(350);
|
||||||
@@ -65,7 +60,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
|
|
||||||
// Needs to automatically change the selected to the default values for default indexes.
|
// Needs to automatically change the selected to the default values for default indexes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log(defaultAudioIndex, defaultSubtitleIndex);
|
|
||||||
setSelectedOptions(() => ({
|
setSelectedOptions(() => ({
|
||||||
bitrate: defaultBitrate,
|
bitrate: defaultBitrate,
|
||||||
mediaSource: defaultMediaSource,
|
mediaSource: defaultMediaSource,
|
||||||
@@ -84,10 +78,8 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
headerRight: () =>
|
headerRight: () =>
|
||||||
item && (
|
item && (
|
||||||
<View className="flex flex-row items-center space-x-2">
|
<View className="flex flex-row items-center space-x-2">
|
||||||
<Chromecast background="blur" width={22} height={22} />
|
|
||||||
{item.Type !== "Program" && (
|
{item.Type !== "Program" && (
|
||||||
<View className="flex flex-row items-center space-x-2">
|
<View className="flex flex-row items-center space-x-2">
|
||||||
<DownloadItem item={item} />
|
|
||||||
<PlayedStatus item={item} />
|
<PlayedStatus item={item} />
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -97,11 +89,9 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
}, [item]);
|
}, [item]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP)
|
if (item.Type === "Movie") setHeaderHeight(500);
|
||||||
setHeaderHeight(230);
|
|
||||||
else if (item.Type === "Movie") setHeaderHeight(500);
|
|
||||||
else setHeaderHeight(350);
|
else setHeaderHeight(350);
|
||||||
}, [item.Type, orientation]);
|
}, [item.Type]);
|
||||||
|
|
||||||
const logoUrl = useMemo(() => getLogoImageUrlById({ api, item }), [item]);
|
const logoUrl = useMemo(() => getLogoImageUrlById({ api, item }), [item]);
|
||||||
|
|
||||||
@@ -109,6 +99,36 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
return Boolean(logoUrl && loadingLogo);
|
return Boolean(logoUrl && loadingLogo);
|
||||||
}, [loadingLogo, logoUrl]);
|
}, [loadingLogo, logoUrl]);
|
||||||
|
|
||||||
|
const [isTranscoding, setIsTranscoding] = useState(false);
|
||||||
|
const [previouslyChosenSubtitleIndex, setPreviouslyChosenSubtitleIndex] =
|
||||||
|
useState<number | undefined>(selectedOptions?.subtitleIndex);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const isTranscoding = Boolean(selectedOptions?.bitrate.value);
|
||||||
|
if (isTranscoding) {
|
||||||
|
setPreviouslyChosenSubtitleIndex(selectedOptions?.subtitleIndex);
|
||||||
|
const subHelper = new SubtitleHelper(
|
||||||
|
selectedOptions?.mediaSource?.MediaStreams ?? []
|
||||||
|
);
|
||||||
|
|
||||||
|
const newSubtitleIndex = subHelper.getMostCommonSubtitleByName(
|
||||||
|
selectedOptions?.subtitleIndex
|
||||||
|
);
|
||||||
|
|
||||||
|
setSelectedOptions((prev) => ({
|
||||||
|
...prev!,
|
||||||
|
subtitleIndex: newSubtitleIndex ?? -1,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (!isTranscoding && previouslyChosenSubtitleIndex !== undefined) {
|
||||||
|
setSelectedOptions((prev) => ({
|
||||||
|
...prev!,
|
||||||
|
subtitleIndex: previouslyChosenSubtitleIndex,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
setIsTranscoding(isTranscoding);
|
||||||
|
}, [selectedOptions?.bitrate]);
|
||||||
|
|
||||||
if (!selectedOptions) return null;
|
if (!selectedOptions) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -187,7 +207,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
className="mr-1"
|
className="mr-1"
|
||||||
source={selectedOptions.mediaSource}
|
source={selectedOptions.mediaSource}
|
||||||
onChange={(val) => {
|
onChange={(val) => {
|
||||||
console.log(val);
|
|
||||||
setSelectedOptions(
|
setSelectedOptions(
|
||||||
(prev) =>
|
(prev) =>
|
||||||
prev && {
|
prev && {
|
||||||
@@ -199,6 +218,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
selected={selectedOptions.audioIndex}
|
selected={selectedOptions.audioIndex}
|
||||||
/>
|
/>
|
||||||
<SubtitleTrackSelector
|
<SubtitleTrackSelector
|
||||||
|
isTranscoding={isTranscoding}
|
||||||
source={selectedOptions.mediaSource}
|
source={selectedOptions.mediaSource}
|
||||||
onChange={(val) =>
|
onChange={(val) =>
|
||||||
setSelectedOptions(
|
setSelectedOptions(
|
||||||
@@ -225,9 +245,15 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
<SeasonEpisodesCarousel item={item} loading={loading} />
|
<SeasonEpisodesCarousel item={item} loading={loading} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<OverviewText text={item.Overview} className="px-4 my-4" />
|
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
|
||||||
|
<OverviewText text={item.Overview} className="px-4 mb-4" />
|
||||||
|
|
||||||
{item.Type !== "Program" && (
|
{item.Type !== "Program" && (
|
||||||
<>
|
<>
|
||||||
|
{item.Type === "Episode" && (
|
||||||
|
<CurrentSeries item={item} className="mb-4" />
|
||||||
|
)}
|
||||||
|
|
||||||
<CastAndCrew item={item} className="mb-4" loading={loading} />
|
<CastAndCrew item={item} className="mb-4" loading={loading} />
|
||||||
|
|
||||||
{item.People && item.People.length > 0 && (
|
{item.People && item.People.length > 0 && (
|
||||||
@@ -243,15 +269,9 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{item.Type === "Episode" && (
|
|
||||||
<CurrentSeries item={item} className="mb-4" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<SimilarItems itemId={item.Id} />
|
<SimilarItems itemId={item.Id} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<View className="h-16"></View>
|
|
||||||
</View>
|
</View>
|
||||||
</ParallaxScrollView>
|
</ParallaxScrollView>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import React from "react";
|
||||||
import { View, ViewProps } from "react-native";
|
import { View, ViewProps } from "react-native";
|
||||||
|
import { GenreTags } from "./GenreTags";
|
||||||
import { MoviesTitleHeader } from "./movies/MoviesTitleHeader";
|
import { MoviesTitleHeader } from "./movies/MoviesTitleHeader";
|
||||||
import { Ratings } from "./Ratings";
|
import { Ratings } from "./Ratings";
|
||||||
import { EpisodeTitleHeader } from "./series/EpisodeTitleHeader";
|
import { EpisodeTitleHeader } from "./series/EpisodeTitleHeader";
|
||||||
import { GenreTags } from "./GenreTags";
|
import { ItemActions } from "./series/SeriesActions";
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
item?: BaseItemDto | null;
|
item?: BaseItemDto | null;
|
||||||
@@ -27,7 +28,10 @@ export const ItemHeader: React.FC<Props> = ({ item, ...props }) => {
|
|||||||
return (
|
return (
|
||||||
<View className="flex flex-col" {...props}>
|
<View className="flex flex-col" {...props}>
|
||||||
<View className="flex flex-col" {...props}>
|
<View className="flex flex-col" {...props}>
|
||||||
<Ratings item={item} className="mb-2" />
|
<View className="flex flex-row items-center justify-between">
|
||||||
|
<Ratings item={item} className="mb-2" />
|
||||||
|
<ItemActions item={item} />
|
||||||
|
</View>
|
||||||
{item.Type === "Episode" && (
|
{item.Type === "Episode" && (
|
||||||
<>
|
<>
|
||||||
<EpisodeTitleHeader item={item} />
|
<EpisodeTitleHeader item={item} />
|
||||||
|
|||||||
236
components/ItemTechnicalDetails.tsx
Normal file
236
components/ItemTechnicalDetails.tsx
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import {
|
||||||
|
MediaSourceInfo,
|
||||||
|
type MediaStream,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import React, { useMemo, useRef } from "react";
|
||||||
|
import { TouchableOpacity, View } from "react-native";
|
||||||
|
import { Badge } from "./Badge";
|
||||||
|
import { Text } from "./common/Text";
|
||||||
|
import {
|
||||||
|
BottomSheetModal,
|
||||||
|
BottomSheetBackdropProps,
|
||||||
|
BottomSheetBackdrop,
|
||||||
|
BottomSheetView,
|
||||||
|
BottomSheetScrollView,
|
||||||
|
} from "@gorhom/bottom-sheet";
|
||||||
|
import { Button } from "./Button";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
source?: MediaSourceInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
|
||||||
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="px-4 mt-2 mb-4">
|
||||||
|
<Text className="text-lg font-bold mb-4">Video</Text>
|
||||||
|
<TouchableOpacity onPress={() => bottomSheetModalRef.current?.present()}>
|
||||||
|
<View className="flex flex-row space-x-2">
|
||||||
|
<VideoStreamInfo source={source} />
|
||||||
|
</View>
|
||||||
|
<Text className="text-purple-600">More details</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<BottomSheetModal
|
||||||
|
ref={bottomSheetModalRef}
|
||||||
|
snapPoints={["80%"]}
|
||||||
|
handleIndicatorStyle={{
|
||||||
|
backgroundColor: "white",
|
||||||
|
}}
|
||||||
|
backgroundStyle={{
|
||||||
|
backgroundColor: "#171717",
|
||||||
|
}}
|
||||||
|
backdropComponent={(props: BottomSheetBackdropProps) => (
|
||||||
|
<BottomSheetBackdrop
|
||||||
|
{...props}
|
||||||
|
disappearsOnIndex={-1}
|
||||||
|
appearsOnIndex={0}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<BottomSheetScrollView>
|
||||||
|
<View className="flex flex-col space-y-2 p-4 mb-4">
|
||||||
|
<View className="">
|
||||||
|
<Text className="text-lg font-bold mb-4">Video</Text>
|
||||||
|
<View className="flex flex-row space-x-2">
|
||||||
|
<VideoStreamInfo source={source} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="">
|
||||||
|
<Text className="text-lg font-bold mb-2">Audio</Text>
|
||||||
|
<AudioStreamInfo
|
||||||
|
audioStreams={
|
||||||
|
source?.MediaStreams?.filter(
|
||||||
|
(stream) => stream.Type === "Audio"
|
||||||
|
) || []
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="">
|
||||||
|
<Text className="text-lg font-bold mb-2">Subtitles</Text>
|
||||||
|
<SubtitleStreamInfo
|
||||||
|
subtitleStreams={
|
||||||
|
source?.MediaStreams?.filter(
|
||||||
|
(stream) => stream.Type === "Subtitle"
|
||||||
|
) || []
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</BottomSheetScrollView>
|
||||||
|
</BottomSheetModal>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SubtitleStreamInfo = ({
|
||||||
|
subtitleStreams,
|
||||||
|
}: {
|
||||||
|
subtitleStreams: MediaStream[];
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<View className="flex flex-col">
|
||||||
|
{subtitleStreams.map((stream, index) => (
|
||||||
|
<View key={stream.Index} className="flex flex-col">
|
||||||
|
<Text className="text-xs mb-3 text-neutral-400">
|
||||||
|
{stream.DisplayTitle}
|
||||||
|
</Text>
|
||||||
|
<View className="flex flex-row flex-wrap gap-2">
|
||||||
|
<Badge
|
||||||
|
variant="gray"
|
||||||
|
iconLeft={
|
||||||
|
<Ionicons name="language-outline" size={16} color="white" />
|
||||||
|
}
|
||||||
|
text={stream.Language}
|
||||||
|
/>
|
||||||
|
<Badge
|
||||||
|
variant="gray"
|
||||||
|
text={stream.Codec}
|
||||||
|
iconLeft={
|
||||||
|
<Ionicons name="layers-outline" size={16} color="white" />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const AudioStreamInfo = ({ audioStreams }: { audioStreams: MediaStream[] }) => {
|
||||||
|
return (
|
||||||
|
<View className="flex flex-col">
|
||||||
|
{audioStreams.map((audioStreams, index) => (
|
||||||
|
<View key={index} className="flex flex-col">
|
||||||
|
<Text className="mb-3 text-neutral-400 text-xs">
|
||||||
|
{audioStreams.DisplayTitle}
|
||||||
|
</Text>
|
||||||
|
<View className="flex-row flex-wrap gap-2">
|
||||||
|
<Badge
|
||||||
|
variant="gray"
|
||||||
|
iconLeft={
|
||||||
|
<Ionicons name="language-outline" size={16} color="white" />
|
||||||
|
}
|
||||||
|
text={audioStreams.Language}
|
||||||
|
/>
|
||||||
|
<Badge
|
||||||
|
variant="gray"
|
||||||
|
iconLeft={
|
||||||
|
<Ionicons
|
||||||
|
name="musical-notes-outline"
|
||||||
|
size={16}
|
||||||
|
color="white"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
text={audioStreams.Codec}
|
||||||
|
/>
|
||||||
|
<Badge
|
||||||
|
variant="gray"
|
||||||
|
iconLeft={<Ionicons name="mic-outline" size={16} color="white" />}
|
||||||
|
text={audioStreams.ChannelLayout}
|
||||||
|
/>
|
||||||
|
<Badge
|
||||||
|
variant="gray"
|
||||||
|
iconLeft={
|
||||||
|
<Ionicons name="speedometer-outline" size={16} color="white" />
|
||||||
|
}
|
||||||
|
text={formatBitrate(audioStreams.BitRate)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const VideoStreamInfo = ({ source }: { source?: MediaSourceInfo }) => {
|
||||||
|
if (!source) return null;
|
||||||
|
|
||||||
|
const videoStream = useMemo(() => {
|
||||||
|
return source.MediaStreams?.find(
|
||||||
|
(stream) => stream.Type === "Video"
|
||||||
|
) as MediaStream;
|
||||||
|
}, [source.MediaStreams]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex-row flex-wrap gap-2">
|
||||||
|
<Badge
|
||||||
|
variant="gray"
|
||||||
|
iconLeft={<Ionicons name="film-outline" size={16} color="white" />}
|
||||||
|
text={formatFileSize(source.Size)}
|
||||||
|
/>
|
||||||
|
<Badge
|
||||||
|
variant="gray"
|
||||||
|
iconLeft={<Ionicons name="film-outline" size={16} color="white" />}
|
||||||
|
text={`${videoStream.Width}x${videoStream.Height}`}
|
||||||
|
/>
|
||||||
|
<Badge
|
||||||
|
variant="gray"
|
||||||
|
iconLeft={
|
||||||
|
<Ionicons name="color-palette-outline" size={16} color="white" />
|
||||||
|
}
|
||||||
|
text={videoStream.VideoRange}
|
||||||
|
/>
|
||||||
|
<Badge
|
||||||
|
variant="gray"
|
||||||
|
iconLeft={
|
||||||
|
<Ionicons name="code-working-outline" size={16} color="white" />
|
||||||
|
}
|
||||||
|
text={videoStream.Codec}
|
||||||
|
/>
|
||||||
|
<Badge
|
||||||
|
variant="gray"
|
||||||
|
iconLeft={
|
||||||
|
<Ionicons name="speedometer-outline" size={16} color="white" />
|
||||||
|
}
|
||||||
|
text={formatBitrate(videoStream.BitRate)}
|
||||||
|
/>
|
||||||
|
<Badge
|
||||||
|
variant="gray"
|
||||||
|
iconLeft={<Ionicons name="play-outline" size={16} color="white" />}
|
||||||
|
text={`${videoStream.AverageFrameRate?.toFixed(0)} fps`}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatFileSize = (bytes?: number | null) => {
|
||||||
|
if (!bytes) return "N/A";
|
||||||
|
|
||||||
|
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
|
||||||
|
if (bytes === 0) return "0 Byte";
|
||||||
|
const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)).toString());
|
||||||
|
return Math.round((bytes / Math.pow(1024, i)) * 100) / 100 + " " + sizes[i];
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatBitrate = (bitrate?: number | null) => {
|
||||||
|
if (!bitrate) return "N/A";
|
||||||
|
|
||||||
|
const sizes = ["bps", "Kbps", "Mbps", "Gbps", "Tbps"];
|
||||||
|
if (bitrate === 0) return "0 bps";
|
||||||
|
const i = parseInt(Math.floor(Math.log(bitrate) / Math.log(1000)).toString());
|
||||||
|
return Math.round((bitrate / Math.pow(1000, i)) * 100) / 100 + " " + sizes[i];
|
||||||
|
};
|
||||||
@@ -24,7 +24,7 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
<View className="flex flex-col overflow-visible">
|
<View className="flex flex-col overflow-visible">
|
||||||
<Text className="font-bold ">{title}</Text>
|
<Text className="font-bold ">{title}</Text>
|
||||||
{subTitle && (
|
{subTitle && (
|
||||||
<Text uiTextView selectable className="text-xs">
|
<Text uiTextView selectable className="text-xs text-neutral-400">
|
||||||
{subTitle}
|
{subTitle}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import { tc } from "@/utils/textTools";
|
import { convertBitsToMegabitsOrGigabits } from "@/utils/bToMb";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import {
|
import {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useEffect, useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { Modal, TouchableOpacity, View } from "react-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
import { convertBitsToMegabitsOrGigabits } from "@/utils/bToMb";
|
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof View> {
|
interface Props extends React.ComponentProps<typeof View> {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -21,6 +20,8 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
|||||||
selected,
|
selected,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
|
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||||
|
|
||||||
const selectedName = useMemo(
|
const selectedName = useMemo(
|
||||||
() =>
|
() =>
|
||||||
item.MediaSources?.find((x) => x.Id === selected?.Id)?.MediaStreams?.find(
|
item.MediaSources?.find((x) => x.Id === selected?.Id)?.MediaStreams?.find(
|
||||||
@@ -30,48 +31,80 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<>
|
||||||
className="flex shrink"
|
<View
|
||||||
style={{
|
className="flex shrink"
|
||||||
minWidth: 50,
|
style={{
|
||||||
}}
|
minWidth: 50,
|
||||||
>
|
}}
|
||||||
<DropdownMenu.Root>
|
>
|
||||||
<DropdownMenu.Trigger>
|
<View className="flex flex-col" {...props}>
|
||||||
<View className="flex flex-col" {...props}>
|
<Text className="opacity-50 mb-1 text-xs">Video</Text>
|
||||||
<Text className="opacity-50 mb-1 text-xs">Video</Text>
|
<TouchableOpacity
|
||||||
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center">
|
className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between"
|
||||||
<Text numberOfLines={1}>{selectedName}</Text>
|
onPress={() => setIsModalVisible(true)}
|
||||||
|
>
|
||||||
|
<Text numberOfLines={1}>{selectedName}</Text>
|
||||||
|
<Ionicons
|
||||||
|
name="chevron-down"
|
||||||
|
size={16}
|
||||||
|
color="white"
|
||||||
|
style={{ opacity: 0.5 }}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
visible={isModalVisible}
|
||||||
|
transparent
|
||||||
|
animationType="slide"
|
||||||
|
onRequestClose={() => setIsModalVisible(false)}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
className="flex-1 bg-black/50"
|
||||||
|
activeOpacity={1}
|
||||||
|
onPress={() => setIsModalVisible(false)}
|
||||||
|
>
|
||||||
|
<View className="mt-auto bg-neutral-900 rounded-t-xl">
|
||||||
|
<View className="p-4 border-b border-neutral-800">
|
||||||
|
<Text className="text-lg font-bold text-center">
|
||||||
|
Media Sources
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="max-h-[50%]">
|
||||||
|
{item.MediaSources?.map((source, idx: number) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={idx.toString()}
|
||||||
|
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
|
||||||
|
onPress={() => {
|
||||||
|
onChange(source);
|
||||||
|
setIsModalVisible(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text>
|
||||||
|
{`${name(source.Name)} - ${convertBitsToMegabitsOrGigabits(
|
||||||
|
source.Size
|
||||||
|
)}`}
|
||||||
|
</Text>
|
||||||
|
{source.Id === selected?.Id && (
|
||||||
|
<Ionicons name="checkmark" size={24} color="white" />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
className="p-4 border-t border-neutral-800"
|
||||||
|
onPress={() => setIsModalVisible(false)}
|
||||||
|
>
|
||||||
|
<Text className="text-center text-purple-400">Cancel</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</DropdownMenu.Trigger>
|
</TouchableOpacity>
|
||||||
<DropdownMenu.Content
|
</Modal>
|
||||||
loop={true}
|
</>
|
||||||
side="bottom"
|
|
||||||
align="start"
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={8}
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Label>Media sources</DropdownMenu.Label>
|
|
||||||
{item.MediaSources?.map((source, idx: number) => (
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={idx.toString()}
|
|
||||||
onSelect={() => {
|
|
||||||
onChange(source);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>
|
|
||||||
{`${name(source.Name)} - ${convertBitsToMegabitsOrGigabits(
|
|
||||||
source.Size
|
|
||||||
)}`}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
))}
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,14 @@
|
|||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
|
||||||
import ios from "@/utils/profiles/ios";
|
|
||||||
import { 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 { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { Alert, TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
import CastContext, {
|
|
||||||
CastButton,
|
|
||||||
PlayServicesState,
|
|
||||||
useMediaStatus,
|
|
||||||
useRemoteMediaClient,
|
|
||||||
} from "react-native-google-cast";
|
|
||||||
import Animated, {
|
import Animated, {
|
||||||
Easing,
|
Easing,
|
||||||
interpolate,
|
interpolate,
|
||||||
@@ -46,8 +36,6 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
...props
|
...props
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
const client = useRemoteMediaClient();
|
|
||||||
const mediaStatus = useMediaStatus();
|
|
||||||
|
|
||||||
const [colorAtom] = useAtom(itemThemeColorAtom);
|
const [colorAtom] = useAtom(itemThemeColorAtom);
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
@@ -87,144 +75,16 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
|
|
||||||
const queryString = queryParams.toString();
|
const queryString = queryParams.toString();
|
||||||
|
|
||||||
if (!client) {
|
goToPlayer(queryString, selectedOptions.bitrate?.value);
|
||||||
goToPlayer(queryString, selectedOptions.bitrate?.value);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const options = ["Chromecast", "Device", "Cancel"];
|
return;
|
||||||
const cancelButtonIndex = 2;
|
|
||||||
showActionSheetWithOptions(
|
|
||||||
{
|
|
||||||
options,
|
|
||||||
cancelButtonIndex,
|
|
||||||
},
|
|
||||||
async (selectedIndex: number | undefined) => {
|
|
||||||
if (!api) return;
|
|
||||||
const currentTitle = mediaStatus?.mediaInfo?.metadata?.title;
|
|
||||||
const isOpeningCurrentlyPlayingMedia =
|
|
||||||
currentTitle && currentTitle === item?.Name;
|
|
||||||
|
|
||||||
switch (selectedIndex) {
|
|
||||||
case 0:
|
|
||||||
await CastContext.getPlayServicesState().then(async (state) => {
|
|
||||||
if (state && state !== PlayServicesState.SUCCESS)
|
|
||||||
CastContext.showPlayServicesErrorDialog(state);
|
|
||||||
else {
|
|
||||||
// If we're opening a currently playing item, don't restart the media.
|
|
||||||
// Instead just open controls.
|
|
||||||
if (isOpeningCurrentlyPlayingMedia) {
|
|
||||||
CastContext.showExpandedControls();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
.loadMedia({
|
|
||||||
mediaInfo: {
|
|
||||||
contentUrl: data?.url,
|
|
||||||
contentType: "video/mp4",
|
|
||||||
metadata:
|
|
||||||
item.Type === "Episode"
|
|
||||||
? {
|
|
||||||
type: "tvShow",
|
|
||||||
title: item.Name || "",
|
|
||||||
episodeNumber: item.IndexNumber || 0,
|
|
||||||
seasonNumber: item.ParentIndexNumber || 0,
|
|
||||||
seriesTitle: item.SeriesName || "",
|
|
||||||
images: [
|
|
||||||
{
|
|
||||||
url: getParentBackdropImageUrl({
|
|
||||||
api,
|
|
||||||
item,
|
|
||||||
quality: 90,
|
|
||||||
width: 2000,
|
|
||||||
})!,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
: item.Type === "Movie"
|
|
||||||
? {
|
|
||||||
type: "movie",
|
|
||||||
title: item.Name || "",
|
|
||||||
subtitle: item.Overview || "",
|
|
||||||
images: [
|
|
||||||
{
|
|
||||||
url: getPrimaryImageUrl({
|
|
||||||
api,
|
|
||||||
item,
|
|
||||||
quality: 90,
|
|
||||||
width: 2000,
|
|
||||||
})!,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
type: "generic",
|
|
||||||
title: item.Name || "",
|
|
||||||
subtitle: item.Overview || "",
|
|
||||||
images: [
|
|
||||||
{
|
|
||||||
url: getPrimaryImageUrl({
|
|
||||||
api,
|
|
||||||
item,
|
|
||||||
quality: 90,
|
|
||||||
width: 2000,
|
|
||||||
})!,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
startTime: 0,
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
// state is already set when reopening current media, so skip it here.
|
|
||||||
if (isOpeningCurrentlyPlayingMedia) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
CastContext.showExpandedControls();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case 1:
|
|
||||||
goToPlayer(queryString, selectedOptions.bitrate?.value);
|
|
||||||
break;
|
|
||||||
case cancelButtonIndex:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}, [
|
}, [
|
||||||
item,
|
item,
|
||||||
client,
|
|
||||||
settings,
|
settings,
|
||||||
api,
|
api,
|
||||||
user,
|
user,
|
||||||
router,
|
router,
|
||||||
showActionSheetWithOptions,
|
showActionSheetWithOptions,
|
||||||
mediaStatus,
|
|
||||||
selectedOptions,
|
selectedOptions,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -358,21 +218,13 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
<Animated.Text style={animatedTextStyle}>
|
<Animated.Text style={animatedTextStyle}>
|
||||||
<Ionicons name="play-circle" size={24} />
|
<Ionicons name="play-circle" size={24} />
|
||||||
</Animated.Text>
|
</Animated.Text>
|
||||||
{client && (
|
<Animated.Text style={animatedTextStyle}>
|
||||||
<Animated.Text style={animatedTextStyle}>
|
<MaterialCommunityIcons
|
||||||
<Feather name="cast" size={22} />
|
name="vlc"
|
||||||
<CastButton tintColor="transparent" />
|
size={18}
|
||||||
</Animated.Text>
|
color={animatedTextStyle.color}
|
||||||
)}
|
/>
|
||||||
{!client && settings?.openInVLC && (
|
</Animated.Text>
|
||||||
<Animated.Text style={animatedTextStyle}>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name="vlc"
|
|
||||||
size={18}
|
|
||||||
color={animatedTextStyle.color}
|
|
||||||
/>
|
|
||||||
</Animated.Text>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@@ -1,22 +1,15 @@
|
|||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||||
import { markAsNotPlayed } from "@/utils/jellyfin/playstate/markAsNotPlayed";
|
|
||||||
import { markAsPlayed } from "@/utils/jellyfin/playstate/markAsPlayed";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import * as Haptics from "expo-haptics";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
import { View, ViewProps } from "react-native";
|
||||||
|
import { RoundButton } from "./RoundButton";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PlayedStatus: React.FC<Props> = ({ item, ...props }) => {
|
export const PlayedStatus: React.FC<Props> = ({ item, ...props }) => {
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const invalidateQueries = () => {
|
const invalidateQueries = () => {
|
||||||
@@ -46,44 +39,16 @@ export const PlayedStatus: React.FC<Props> = ({ item, ...props }) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const markAsPlayedStatus = useMarkAsPlayed(item);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View {...props}>
|
||||||
className=" bg-neutral-800/80 rounded-full h-10 w-10 flex items-center justify-center"
|
<RoundButton
|
||||||
{...props}
|
fillColor={item.UserData?.Played ? "primary" : undefined}
|
||||||
>
|
icon={item.UserData?.Played ? "checkmark" : "checkmark"}
|
||||||
{item.UserData?.Played ? (
|
onPress={() => markAsPlayedStatus(item.UserData?.Played || false)}
|
||||||
<TouchableOpacity
|
size="large"
|
||||||
onPress={async () => {
|
/>
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
await markAsNotPlayed({
|
|
||||||
api: api,
|
|
||||||
itemId: item?.Id,
|
|
||||||
userId: user?.Id,
|
|
||||||
});
|
|
||||||
invalidateQueries();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
|
||||||
<Ionicons name="checkmark-circle" size={24} color="white" />
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
) : (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={async () => {
|
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
await markAsPlayed({
|
|
||||||
api: api,
|
|
||||||
item: item,
|
|
||||||
userId: user?.Id,
|
|
||||||
});
|
|
||||||
invalidateQueries();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
|
||||||
<Ionicons name="checkmark-circle-outline" size={24} color="white" />
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import { View, ViewProps } from "react-native";
|
|||||||
import { Badge } from "./Badge";
|
import { Badge } from "./Badge";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
|
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
|
||||||
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
item?: BaseItemDto | null;
|
item?: BaseItemDto | null;
|
||||||
@@ -17,7 +21,7 @@ export const Ratings: React.FC<Props> = ({ item, ...props }) => {
|
|||||||
)}
|
)}
|
||||||
{item.CommunityRating && (
|
{item.CommunityRating && (
|
||||||
<Badge
|
<Badge
|
||||||
text={item.CommunityRating}
|
text={item.CommunityRating.toFixed(1)}
|
||||||
variant="gray"
|
variant="gray"
|
||||||
iconLeft={<Ionicons name="star" size={14} color="gold" />}
|
iconLeft={<Ionicons name="star" size={14} color="gold" />}
|
||||||
/>
|
/>
|
||||||
@@ -28,7 +32,11 @@ export const Ratings: React.FC<Props> = ({ item, ...props }) => {
|
|||||||
variant="gray"
|
variant="gray"
|
||||||
iconLeft={
|
iconLeft={
|
||||||
<Image
|
<Image
|
||||||
source={require("@/assets/images/rotten-tomatoes.png")}
|
source={
|
||||||
|
item.CriticRating < 60
|
||||||
|
? require("@/assets/images/rotten-tomatoes.png")
|
||||||
|
: require("@/assets/images/not-rotten-tomatoes.svg")
|
||||||
|
}
|
||||||
style={{
|
style={{
|
||||||
width: 14,
|
width: 14,
|
||||||
height: 14,
|
height: 14,
|
||||||
@@ -40,3 +48,86 @@ export const Ratings: React.FC<Props> = ({ item, ...props }) => {
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const JellyserrRatings: React.FC<{ result: MovieResult | TvResult }> = ({
|
||||||
|
result,
|
||||||
|
}) => {
|
||||||
|
const { jellyseerrApi } = useJellyseerr();
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ["jellyseerr", result.id, result.mediaType, "ratings"],
|
||||||
|
queryFn: async () => {
|
||||||
|
return result.mediaType === MediaType.MOVIE
|
||||||
|
? jellyseerrApi?.movieRatings(result.id)
|
||||||
|
: jellyseerrApi?.tvRatings(result.id);
|
||||||
|
},
|
||||||
|
staleTime: (5).minutesToMilliseconds(),
|
||||||
|
retry: false,
|
||||||
|
enabled: !!jellyseerrApi,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
(isLoading ||
|
||||||
|
!!result.voteCount ||
|
||||||
|
(data?.criticsRating && !!data?.criticsScore) ||
|
||||||
|
(data?.audienceRating && !!data?.audienceScore)) && (
|
||||||
|
<View className="flex flex-row flex-wrap space-x-1">
|
||||||
|
{data?.criticsRating && !!data?.criticsScore && (
|
||||||
|
<Badge
|
||||||
|
text={`${data.criticsScore}%`}
|
||||||
|
variant="gray"
|
||||||
|
iconLeft={
|
||||||
|
<Image
|
||||||
|
className="mr-1"
|
||||||
|
source={
|
||||||
|
data?.criticsRating === "Rotten"
|
||||||
|
? require("@/utils/jellyseerr/src/assets/rt_rotten.svg")
|
||||||
|
: require("@/utils/jellyseerr/src/assets/rt_fresh.svg")
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{data?.audienceRating && !!data?.audienceScore && (
|
||||||
|
<Badge
|
||||||
|
text={`${data.audienceScore}%`}
|
||||||
|
variant="gray"
|
||||||
|
iconLeft={
|
||||||
|
<Image
|
||||||
|
className="mr-1"
|
||||||
|
source={
|
||||||
|
data?.audienceRating === "Spilled"
|
||||||
|
? require("@/utils/jellyseerr/src/assets/rt_aud_rotten.svg")
|
||||||
|
: require("@/utils/jellyseerr/src/assets/rt_aud_fresh.svg")
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!!result.voteCount && (
|
||||||
|
<Badge
|
||||||
|
text={`${Math.round(result.voteAverage * 10)}%`}
|
||||||
|
variant="gray"
|
||||||
|
iconLeft={
|
||||||
|
<Image
|
||||||
|
className="mr-1"
|
||||||
|
source={require("@/utils/jellyseerr/src/assets/tmdb_logo.svg")}
|
||||||
|
style={{
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
111
components/RoundButton.tsx
Normal file
111
components/RoundButton.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { BlurView } from "expo-blur";
|
||||||
|
import { PropsWithChildren } from "react";
|
||||||
|
import {
|
||||||
|
Platform,
|
||||||
|
TouchableOpacity,
|
||||||
|
TouchableOpacityProps,
|
||||||
|
} from "react-native";
|
||||||
|
|
||||||
|
interface Props extends TouchableOpacityProps {
|
||||||
|
onPress?: () => void;
|
||||||
|
icon?: keyof typeof Ionicons.glyphMap;
|
||||||
|
background?: boolean;
|
||||||
|
size?: "default" | "large";
|
||||||
|
fillColor?: "primary";
|
||||||
|
hapticFeedback?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
|
||||||
|
background = true,
|
||||||
|
icon,
|
||||||
|
onPress,
|
||||||
|
children,
|
||||||
|
size = "default",
|
||||||
|
fillColor,
|
||||||
|
hapticFeedback = true,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const buttonSize = size === "large" ? "h-10 w-10" : "h-9 w-9";
|
||||||
|
const fillColorClass = fillColor === "primary" ? "bg-purple-600" : "";
|
||||||
|
|
||||||
|
const handlePress = () => {
|
||||||
|
if (hapticFeedback) {
|
||||||
|
}
|
||||||
|
onPress?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (fillColor)
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handlePress}
|
||||||
|
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{icon ? (
|
||||||
|
<Ionicons
|
||||||
|
name={icon}
|
||||||
|
size={size === "large" ? 22 : 18}
|
||||||
|
color={"white"}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{children ? children : null}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (background === false)
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handlePress}
|
||||||
|
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{icon ? (
|
||||||
|
<Ionicons
|
||||||
|
name={icon}
|
||||||
|
size={size === "large" ? 22 : 18}
|
||||||
|
color={"white"}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{children ? children : null}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (Platform.OS === "android")
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handlePress}
|
||||||
|
className={`rounded-full ${buttonSize} flex items-center justify-center ${
|
||||||
|
fillColor ? fillColorClass : "bg-neutral-800/80"
|
||||||
|
}`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{icon ? (
|
||||||
|
<Ionicons
|
||||||
|
name={icon}
|
||||||
|
size={size === "large" ? 22 : 18}
|
||||||
|
color={"white"}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{children ? children : null}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity onPress={handlePress} {...props}>
|
||||||
|
<BlurView
|
||||||
|
intensity={90}
|
||||||
|
className={`rounded-full overflow-hidden ${buttonSize} flex items-center justify-center ${fillColorClass}`}
|
||||||
|
>
|
||||||
|
{icon ? (
|
||||||
|
<Ionicons
|
||||||
|
name={icon}
|
||||||
|
size={size === "large" ? 22 : 18}
|
||||||
|
color={"white"}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{children ? children : null}
|
||||||
|
</BlurView>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,26 +1,36 @@
|
|||||||
import { tc } from "@/utils/textTools";
|
import { tc } from "@/utils/textTools";
|
||||||
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View, Modal } from "react-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
|
||||||
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 | undefined;
|
||||||
|
isTranscoding?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SubtitleTrackSelector: React.FC<Props> = ({
|
export const SubtitleTrackSelector: React.FC<Props> = ({
|
||||||
source,
|
source,
|
||||||
onChange,
|
onChange,
|
||||||
selected,
|
selected,
|
||||||
|
isTranscoding,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const subtitleStreams = useMemo(
|
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||||
() => source?.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [],
|
|
||||||
[source]
|
const subtitleStreams = useMemo(() => {
|
||||||
);
|
const subtitleHelper = new SubtitleHelper(source?.MediaStreams ?? []);
|
||||||
|
|
||||||
|
if (isTranscoding && Platform.OS === "ios") {
|
||||||
|
return subtitleHelper.getUniqueSubtitles();
|
||||||
|
}
|
||||||
|
|
||||||
|
return subtitleHelper.getSubtitles();
|
||||||
|
}, [source, isTranscoding]);
|
||||||
|
|
||||||
const selectedSubtitleSteam = useMemo(
|
const selectedSubtitleSteam = useMemo(
|
||||||
() => subtitleStreams.find((x) => x.Index === selected),
|
() => subtitleStreams.find((x) => x.Index === selected),
|
||||||
@@ -30,59 +40,98 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
|||||||
if (subtitleStreams.length === 0) return null;
|
if (subtitleStreams.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<>
|
||||||
className="flex col shrink justify-start place-self-start items-start"
|
<View
|
||||||
style={{
|
className="flex col shrink justify-start place-self-start items-start"
|
||||||
minWidth: 60,
|
style={{
|
||||||
maxWidth: 200,
|
minWidth: 60,
|
||||||
}}
|
maxWidth: 200,
|
||||||
>
|
}}
|
||||||
<DropdownMenu.Root>
|
>
|
||||||
<DropdownMenu.Trigger>
|
<View className="flex flex-col" {...props}>
|
||||||
<View className="flex flex-col " {...props}>
|
<Text className="opacity-50 mb-1 text-xs">Subtitle</Text>
|
||||||
<Text className="opacity-50 mb-1 text-xs">Subtitle</Text>
|
<TouchableOpacity
|
||||||
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
|
className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between"
|
||||||
<Text className=" ">
|
onPress={() => setIsModalVisible(true)}
|
||||||
{selectedSubtitleSteam
|
>
|
||||||
? tc(selectedSubtitleSteam?.DisplayTitle, 7)
|
<Text>
|
||||||
: "None"}
|
{selectedSubtitleSteam
|
||||||
|
? tc(selectedSubtitleSteam?.DisplayTitle, 7)
|
||||||
|
: "None"}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name="chevron-down"
|
||||||
|
size={16}
|
||||||
|
color="white"
|
||||||
|
style={{ opacity: 0.5 }}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
visible={isModalVisible}
|
||||||
|
transparent
|
||||||
|
animationType="slide"
|
||||||
|
onRequestClose={() => setIsModalVisible(false)}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
className="flex-1 bg-black/50"
|
||||||
|
activeOpacity={1}
|
||||||
|
onPress={() => setIsModalVisible(false)}
|
||||||
|
>
|
||||||
|
<View className="mt-auto bg-neutral-900 rounded-t-xl">
|
||||||
|
<View className="p-4 border-b border-neutral-800">
|
||||||
|
<Text className="text-lg font-bold text-center">
|
||||||
|
Subtitle Tracks
|
||||||
</Text>
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="max-h-[50%]">
|
||||||
|
<TouchableOpacity
|
||||||
|
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
|
||||||
|
onPress={() => {
|
||||||
|
onChange(-1);
|
||||||
|
setIsModalVisible(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text>None</Text>
|
||||||
|
{selected === -1 && (
|
||||||
|
<Ionicons name="checkmark" size={24} color="white" />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{subtitleStreams?.map((subtitle, idx: number) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={idx.toString()}
|
||||||
|
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
|
||||||
|
onPress={() => {
|
||||||
|
if (
|
||||||
|
subtitle.Index !== undefined &&
|
||||||
|
subtitle.Index !== null
|
||||||
|
) {
|
||||||
|
onChange(subtitle.Index);
|
||||||
|
setIsModalVisible(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text>{subtitle.DisplayTitle}</Text>
|
||||||
|
{subtitle.Index === selected && (
|
||||||
|
<Ionicons name="checkmark" size={24} color="white" />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
className="p-4 border-t border-neutral-800"
|
||||||
|
onPress={() => setIsModalVisible(false)}
|
||||||
|
>
|
||||||
|
<Text className="text-center text-purple-400">Cancel</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</DropdownMenu.Trigger>
|
</TouchableOpacity>
|
||||||
<DropdownMenu.Content
|
</Modal>
|
||||||
loop={true}
|
</>
|
||||||
side="bottom"
|
|
||||||
align="start"
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={8}
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Label>Subtitle tracks</DropdownMenu.Label>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={"-1"}
|
|
||||||
onSelect={() => {
|
|
||||||
onChange(-1);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>None</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
{subtitleStreams?.map((subtitle, idx: number) => (
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={idx.toString()}
|
|
||||||
onSelect={() => {
|
|
||||||
if (subtitle.Index !== undefined && subtitle.Index !== null)
|
|
||||||
onChange(subtitle.Index);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>
|
|
||||||
{subtitle.DisplayTitle}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
))}
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useImageColors } from "@/hooks/useImageColors";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { getItemImage } from "@/utils/getItemImage";
|
import { getItemImage } from "@/utils/getItemImage";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
|||||||
76
components/common/JellyseerrItemRouter.tsx
Normal file
76
components/common/JellyseerrItemRouter.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { useRouter, useSegments } from "expo-router";
|
||||||
|
import React, { PropsWithChildren, useCallback, useMemo } from "react";
|
||||||
|
import { TouchableOpacity, TouchableOpacityProps } from "react-native";
|
||||||
|
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
|
||||||
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
|
import {
|
||||||
|
hasPermission,
|
||||||
|
Permission,
|
||||||
|
} from "@/utils/jellyseerr/server/lib/permissions";
|
||||||
|
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||||
|
|
||||||
|
interface Props extends TouchableOpacityProps {
|
||||||
|
result: MovieResult | TvResult;
|
||||||
|
mediaTitle: string;
|
||||||
|
releaseYear: number;
|
||||||
|
canRequest: boolean;
|
||||||
|
posterSrc: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||||
|
result,
|
||||||
|
mediaTitle,
|
||||||
|
releaseYear,
|
||||||
|
canRequest,
|
||||||
|
posterSrc,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const segments = useSegments();
|
||||||
|
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
|
||||||
|
|
||||||
|
const from = segments[2];
|
||||||
|
|
||||||
|
const autoApprove = useMemo(() => {
|
||||||
|
return (
|
||||||
|
jellyseerrUser &&
|
||||||
|
hasPermission(Permission.AUTO_APPROVE, jellyseerrUser.permissions, {
|
||||||
|
type: "or",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}, [jellyseerrApi, jellyseerrUser]);
|
||||||
|
|
||||||
|
const request = useCallback(
|
||||||
|
() =>
|
||||||
|
requestMedia(mediaTitle, {
|
||||||
|
mediaId: result.id,
|
||||||
|
mediaType: result.mediaType,
|
||||||
|
}),
|
||||||
|
[jellyseerrApi, result]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (from === "(home)" || from === "(search)" || from === "(libraries)")
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
// @ts-ignore
|
||||||
|
router.push({
|
||||||
|
pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`,
|
||||||
|
params: {
|
||||||
|
...result,
|
||||||
|
mediaTitle,
|
||||||
|
releaseYear,
|
||||||
|
canRequest,
|
||||||
|
posterSrc,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
|
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import * as Haptics from "expo-haptics";
|
|
||||||
import { useRouter, useSegments } from "expo-router";
|
import { useRouter, useSegments } from "expo-router";
|
||||||
import { PropsWithChildren } from "react";
|
import { PropsWithChildren } from "react";
|
||||||
import { TouchableOpacity, TouchableOpacityProps } from "react-native";
|
import { TouchableOpacity, TouchableOpacityProps } from "react-native";
|
||||||
|
import * as ContextMenu from "zeego/context-menu";
|
||||||
|
|
||||||
interface Props extends TouchableOpacityProps {
|
interface Props extends TouchableOpacityProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -45,6 +46,10 @@ export const itemRouter = (item: BaseItemDto, from: string) => {
|
|||||||
return `/(auth)/(tabs)/(libraries)/${item.Id}`;
|
return `/(auth)/(tabs)/(libraries)/${item.Id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (item.Type === "Playlist") {
|
||||||
|
return `/(auth)/(tabs)/(libraries)/${item.Id}`;
|
||||||
|
}
|
||||||
|
|
||||||
return `/(auth)/(tabs)/${from}/items/page?id=${item.Id}`;
|
return `/(auth)/(tabs)/${from}/items/page?id=${item.Id}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -58,11 +63,12 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
|
|
||||||
const from = segments[2];
|
const from = segments[2];
|
||||||
|
|
||||||
|
const markAsPlayedStatus = useMarkAsPlayed(item);
|
||||||
|
|
||||||
if (from === "(home)" || from === "(search)" || from === "(libraries)")
|
if (from === "(home)" || from === "(search)" || from === "(libraries)")
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
const url = itemRouter(item, from);
|
const url = itemRouter(item, from);
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
router.push(url);
|
router.push(url);
|
||||||
|
|||||||
@@ -1,191 +0,0 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { JobStatus } from "@/utils/optimize-server";
|
|
||||||
import { formatTimeString } from "@/utils/time";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { checkForExistingDownloads } from "@kesha-antonov/react-native-background-downloader";
|
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useRouter } from "expo-router";
|
|
||||||
import { FFmpegKit } from "ffmpeg-kit-react-native";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import {
|
|
||||||
ActivityIndicator,
|
|
||||||
TouchableOpacity,
|
|
||||||
TouchableOpacityProps,
|
|
||||||
View,
|
|
||||||
ViewProps,
|
|
||||||
} from "react-native";
|
|
||||||
import { toast } from "sonner-native";
|
|
||||||
import { Button } from "../Button";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { storage } from "@/utils/mmkv";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
|
||||||
|
|
||||||
export const ActiveDownloads: React.FC<Props> = ({ ...props }) => {
|
|
||||||
const { processes } = useDownload();
|
|
||||||
if (processes?.length === 0)
|
|
||||||
return (
|
|
||||||
<View {...props} className="bg-neutral-900 p-4 rounded-2xl">
|
|
||||||
<Text className="text-lg font-bold">Active download</Text>
|
|
||||||
<Text className="opacity-50">No active downloads</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View {...props} className="bg-neutral-900 p-4 rounded-2xl">
|
|
||||||
<Text className="text-lg font-bold mb-2">Active downloads</Text>
|
|
||||||
<View className="space-y-2">
|
|
||||||
{processes?.map((p) => (
|
|
||||||
<DownloadCard key={p.id} process={p} />
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface DownloadCardProps extends TouchableOpacityProps {
|
|
||||||
process: JobStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|
||||||
const { processes, startDownload } = useDownload();
|
|
||||||
const router = useRouter();
|
|
||||||
const { removeProcess, setProcesses } = useDownload();
|
|
||||||
const [settings] = useSettings();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const cancelJobMutation = useMutation({
|
|
||||||
mutationFn: async (id: string) => {
|
|
||||||
if (!process) throw new Error("No active download");
|
|
||||||
|
|
||||||
if (settings?.downloadMethod === "optimized") {
|
|
||||||
try {
|
|
||||||
const tasks = await checkForExistingDownloads();
|
|
||||||
for (const task of tasks) {
|
|
||||||
if (task.id === id) {
|
|
||||||
task.stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
throw e;
|
|
||||||
} finally {
|
|
||||||
await removeProcess(id);
|
|
||||||
await queryClient.refetchQueries({ queryKey: ["jobs"] });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
FFmpegKit.cancel();
|
|
||||||
setProcesses((prev) => prev.filter((p) => p.id !== id));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success("Download canceled");
|
|
||||||
},
|
|
||||||
onError: (e) => {
|
|
||||||
console.error(e);
|
|
||||||
toast.error("Could not cancel download");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const eta = (p: JobStatus) => {
|
|
||||||
if (!p.speed || !p.progress) return null;
|
|
||||||
|
|
||||||
const length = p?.item?.RunTimeTicks || 0;
|
|
||||||
const timeLeft = (length - length * (p.progress / 100)) / p.speed;
|
|
||||||
return formatTimeString(timeLeft, "tick");
|
|
||||||
};
|
|
||||||
|
|
||||||
const base64Image = useMemo(() => {
|
|
||||||
return storage.getString(process.item.Id!);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => router.push(`/(auth)/items/page?id=${process.item.Id}`)}
|
|
||||||
className="relative bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{(process.status === "optimizing" ||
|
|
||||||
process.status === "downloading") && (
|
|
||||||
<View
|
|
||||||
className={`
|
|
||||||
bg-purple-600 h-1 absolute bottom-0 left-0
|
|
||||||
`}
|
|
||||||
style={{
|
|
||||||
width: process.progress
|
|
||||||
? `${Math.max(5, process.progress)}%`
|
|
||||||
: "5%",
|
|
||||||
}}
|
|
||||||
></View>
|
|
||||||
)}
|
|
||||||
<View className="px-3 py-1.5 flex flex-col w-full">
|
|
||||||
<View className="flex flex-row items-center w-full">
|
|
||||||
{base64Image && (
|
|
||||||
<View className="w-14 aspect-[10/15] rounded-lg overflow-hidden mr-4">
|
|
||||||
<Image
|
|
||||||
source={{
|
|
||||||
uri: `data:image/jpeg;base64,${base64Image}`,
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
resizeMode: "cover",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
<View className="shrink mb-1">
|
|
||||||
<Text className="text-xs opacity-50">{process.item.Type}</Text>
|
|
||||||
<Text className="font-semibold shrink">{process.item.Name}</Text>
|
|
||||||
<Text className="text-xs opacity-50">
|
|
||||||
{process.item.ProductionYear}
|
|
||||||
</Text>
|
|
||||||
<View className="flex flex-row items-center space-x-2 mt-1 text-purple-600">
|
|
||||||
{process.progress === 0 ? (
|
|
||||||
<ActivityIndicator size={"small"} color={"white"} />
|
|
||||||
) : (
|
|
||||||
<Text className="text-xs">{process.progress.toFixed(0)}%</Text>
|
|
||||||
)}
|
|
||||||
{process.speed && (
|
|
||||||
<Text className="text-xs">{process.speed?.toFixed(2)}x</Text>
|
|
||||||
)}
|
|
||||||
{eta(process) && (
|
|
||||||
<Text className="text-xs">ETA {eta(process)}</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className="flex flex-row items-center space-x-2 mt-1 text-purple-600">
|
|
||||||
<Text className="text-xs capitalize">{process.status}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<TouchableOpacity
|
|
||||||
disabled={cancelJobMutation.isPending}
|
|
||||||
onPress={() => cancelJobMutation.mutate(process.id)}
|
|
||||||
className="ml-auto"
|
|
||||||
>
|
|
||||||
{cancelJobMutation.isPending ? (
|
|
||||||
<ActivityIndicator size="small" color="white" />
|
|
||||||
) : (
|
|
||||||
<Ionicons name="close" size={24} color="red" />
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
{process.status === "completed" && (
|
|
||||||
<View className="flex flex-row mt-4 space-x-4">
|
|
||||||
<Button
|
|
||||||
onPress={() => {
|
|
||||||
startDownload(process);
|
|
||||||
}}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
Download now
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import * as Haptics from "expo-haptics";
|
|
||||||
import React, { useCallback, useMemo, useRef } from "react";
|
|
||||||
import { TouchableOpacity, View } from "react-native";
|
|
||||||
import {
|
|
||||||
ActionSheetProvider,
|
|
||||||
useActionSheet,
|
|
||||||
} from "@expo/react-native-action-sheet";
|
|
||||||
|
|
||||||
import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener";
|
|
||||||
import { Text } from "../common/Text";
|
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
|
||||||
import { storage } from "@/utils/mmkv";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { ItemCardText } from "../ItemCardText";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
|
|
||||||
interface EpisodeCardProps {
|
|
||||||
item: BaseItemDto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* EpisodeCard component displays an episode with action sheet options.
|
|
||||||
* @param {EpisodeCardProps} props - The component props.
|
|
||||||
* @returns {React.ReactElement} The rendered EpisodeCard component.
|
|
||||||
*/
|
|
||||||
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
|
|
||||||
const { deleteFile } = useDownload();
|
|
||||||
const { openFile } = useDownloadedFileOpener();
|
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
|
||||||
|
|
||||||
const base64Image = useMemo(() => {
|
|
||||||
return storage.getString(item.Id!);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleOpenFile = useCallback(() => {
|
|
||||||
openFile(item);
|
|
||||||
}, [item, openFile]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles deleting the file with haptic feedback.
|
|
||||||
*/
|
|
||||||
const handleDeleteFile = useCallback(() => {
|
|
||||||
if (item.Id) {
|
|
||||||
deleteFile(item.Id);
|
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
|
||||||
}
|
|
||||||
}, [deleteFile, item.Id]);
|
|
||||||
|
|
||||||
const showActionSheet = useCallback(() => {
|
|
||||||
const options = ["Delete", "Cancel"];
|
|
||||||
const destructiveButtonIndex = 0;
|
|
||||||
const cancelButtonIndex = 1;
|
|
||||||
|
|
||||||
showActionSheetWithOptions(
|
|
||||||
{
|
|
||||||
options,
|
|
||||||
cancelButtonIndex,
|
|
||||||
destructiveButtonIndex,
|
|
||||||
},
|
|
||||||
(selectedIndex) => {
|
|
||||||
switch (selectedIndex) {
|
|
||||||
case destructiveButtonIndex:
|
|
||||||
// Delete
|
|
||||||
handleDeleteFile();
|
|
||||||
break;
|
|
||||||
case cancelButtonIndex:
|
|
||||||
// Cancelled
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}, [showActionSheetWithOptions, handleDeleteFile]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleOpenFile}
|
|
||||||
onLongPress={showActionSheet}
|
|
||||||
className="flex flex-col w-44 mr-2"
|
|
||||||
>
|
|
||||||
{base64Image ? (
|
|
||||||
<View className="w-44 aspect-video rounded-lg overflow-hidden">
|
|
||||||
<Image
|
|
||||||
source={{
|
|
||||||
uri: `data:image/jpeg;base64,${base64Image}`,
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
resizeMode: "cover",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<View className="w-44 aspect-video rounded-lg bg-neutral-900 flex items-center justify-center">
|
|
||||||
<Ionicons
|
|
||||||
name="image-outline"
|
|
||||||
size={24}
|
|
||||||
color="gray"
|
|
||||||
className="self-center mt-16"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
<ItemCardText item={item} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Wrap the parent component with ActionSheetProvider
|
|
||||||
export const EpisodeCardWithActionSheet: React.FC<EpisodeCardProps> = (
|
|
||||||
props
|
|
||||||
) => (
|
|
||||||
<ActionSheetProvider>
|
|
||||||
<EpisodeCard {...props} />
|
|
||||||
</ActionSheetProvider>
|
|
||||||
);
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
import {
|
|
||||||
ActionSheetProvider,
|
|
||||||
useActionSheet,
|
|
||||||
} from "@expo/react-native-action-sheet";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import * as Haptics from "expo-haptics";
|
|
||||||
import React, { useCallback, useMemo } from "react";
|
|
||||||
import { TouchableOpacity, View } from "react-native";
|
|
||||||
|
|
||||||
import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener";
|
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
|
||||||
import { storage } from "@/utils/mmkv";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { ItemCardText } from "../ItemCardText";
|
|
||||||
|
|
||||||
interface MovieCardProps {
|
|
||||||
item: BaseItemDto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* MovieCard component displays a movie with action sheet options.
|
|
||||||
* @param {MovieCardProps} props - The component props.
|
|
||||||
* @returns {React.ReactElement} The rendered MovieCard component.
|
|
||||||
*/
|
|
||||||
export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
|
||||||
const { deleteFile } = useDownload();
|
|
||||||
const { openFile } = useDownloadedFileOpener();
|
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
|
||||||
|
|
||||||
const handleOpenFile = useCallback(() => {
|
|
||||||
openFile(item);
|
|
||||||
}, [item, openFile]);
|
|
||||||
|
|
||||||
const base64Image = useMemo(() => {
|
|
||||||
return storage.getString(item.Id!);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles deleting the file with haptic feedback.
|
|
||||||
*/
|
|
||||||
const handleDeleteFile = useCallback(() => {
|
|
||||||
if (item.Id) {
|
|
||||||
deleteFile(item.Id);
|
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
|
||||||
}
|
|
||||||
}, [deleteFile, item.Id]);
|
|
||||||
|
|
||||||
const showActionSheet = useCallback(() => {
|
|
||||||
const options = ["Delete", "Cancel"];
|
|
||||||
const destructiveButtonIndex = 0;
|
|
||||||
const cancelButtonIndex = 1;
|
|
||||||
|
|
||||||
showActionSheetWithOptions(
|
|
||||||
{
|
|
||||||
options,
|
|
||||||
cancelButtonIndex,
|
|
||||||
destructiveButtonIndex,
|
|
||||||
},
|
|
||||||
(selectedIndex) => {
|
|
||||||
switch (selectedIndex) {
|
|
||||||
case destructiveButtonIndex:
|
|
||||||
// Delete
|
|
||||||
handleDeleteFile();
|
|
||||||
break;
|
|
||||||
case cancelButtonIndex:
|
|
||||||
// Cancelled
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}, [showActionSheetWithOptions, handleDeleteFile]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TouchableOpacity onPress={handleOpenFile} onLongPress={showActionSheet}>
|
|
||||||
{base64Image ? (
|
|
||||||
<View className="w-28 aspect-[10/15] rounded-lg overflow-hidden mr-2 border border-neutral-900">
|
|
||||||
<Image
|
|
||||||
source={{
|
|
||||||
uri: `data:image/jpeg;base64,${base64Image}`,
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
resizeMode: "cover",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<View className="w-28 aspect-[10/15] rounded-lg bg-neutral-900 mr-2 flex items-center justify-center">
|
|
||||||
<Ionicons
|
|
||||||
name="image-outline"
|
|
||||||
size={24}
|
|
||||||
color="gray"
|
|
||||||
className="self-center mt-16"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
<ItemCardText item={item} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Wrap the parent component with ActionSheetProvider
|
|
||||||
export const MovieCardWithActionSheet: React.FC<MovieCardProps> = (props) => (
|
|
||||||
<ActionSheetProvider>
|
|
||||||
<MovieCard {...props} />
|
|
||||||
</ActionSheetProvider>
|
|
||||||
);
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { ScrollView, View } from "react-native";
|
|
||||||
import { EpisodeCard } from "./EpisodeCard";
|
|
||||||
import { Text } from "../common/Text";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { SeasonPicker } from "../series/SeasonPicker";
|
|
||||||
|
|
||||||
export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
|
|
||||||
const groupBySeason = useMemo(() => {
|
|
||||||
const seasons: Record<string, BaseItemDto[]> = {};
|
|
||||||
|
|
||||||
items.forEach((item) => {
|
|
||||||
if (!seasons[item.SeasonName!]) {
|
|
||||||
seasons[item.SeasonName!] = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
seasons[item.SeasonName!].push(item);
|
|
||||||
});
|
|
||||||
|
|
||||||
return Object.values(seasons).sort(
|
|
||||||
(a, b) => a[0].IndexNumber! - b[0].IndexNumber!
|
|
||||||
);
|
|
||||||
}, [items]);
|
|
||||||
|
|
||||||
const sortByIndex = (a: BaseItemDto, b: BaseItemDto) => {
|
|
||||||
return a.IndexNumber! > b.IndexNumber! ? 1 : -1;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View>
|
|
||||||
<View className="flex flex-row items-center justify-between px-4">
|
|
||||||
<Text className="text-lg font-bold shrink">{items[0].SeriesName}</Text>
|
|
||||||
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
|
|
||||||
<Text className="text-xs font-bold">{items.length}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Text className="opacity-50 mb-2 px-4">TV-Series</Text>
|
|
||||||
{groupBySeason.map((seasonItems, seasonIndex) => (
|
|
||||||
<View key={seasonIndex}>
|
|
||||||
<Text className="mb-2 font-semibold px-4">
|
|
||||||
{seasonItems[0].SeasonName}
|
|
||||||
</Text>
|
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
|
||||||
<View className="px-4 flex flex-row">
|
|
||||||
{seasonItems.sort(sortByIndex)?.map((item, index) => (
|
|
||||||
<EpisodeCard item={item} key={index} />
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { FontAwesome, Ionicons } from "@expo/vector-icons";
|
import { FontAwesome, Ionicons } from "@expo/vector-icons";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||||
import { FilterSheet } from "./FilterSheet";
|
import { FilterSheet } from "./FilterSheet";
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import { itemRouter, TouchableItemRouter } from "../common/TouchableItemRouter";
|
|||||||
import { Loader } from "../Loader";
|
import { Loader } from "../Loader";
|
||||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||||
import { useRouter, useSegments } from "expo-router";
|
import { useRouter, useSegments } from "expo-router";
|
||||||
import * as Haptics from "expo-haptics";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
@@ -147,7 +146,6 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
|||||||
const handleRoute = useCallback(() => {
|
const handleRoute = useCallback(() => {
|
||||||
if (!from) return;
|
if (!from) return;
|
||||||
const url = itemRouter(item, from);
|
const url = itemRouter(item, from);
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
if (url) router.push(url);
|
if (url) router.push(url);
|
||||||
}, [item, from]);
|
}, [item, from]);
|
||||||
|
|||||||
72
components/icons/JellyseerrIconStatus.tsx
Normal file
72
components/icons/JellyseerrIconStatus.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import {useEffect, useState} from "react";
|
||||||
|
import {MediaStatus} from "@/utils/jellyseerr/server/constants/media";
|
||||||
|
import {MaterialCommunityIcons} from "@expo/vector-icons";
|
||||||
|
import {TouchableOpacity, View, ViewProps} from "react-native";
|
||||||
|
import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
mediaStatus?: MediaStatus;
|
||||||
|
showRequestIcon: boolean;
|
||||||
|
onPress?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const JellyseerrIconStatus: React.FC<Props & ViewProps> = ({
|
||||||
|
mediaStatus,
|
||||||
|
showRequestIcon,
|
||||||
|
onPress,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const [badgeIcon, setBadgeIcon] = useState<keyof typeof MaterialCommunityIcons.glyphMap>();
|
||||||
|
const [badgeStyle, setBadgeStyle] = useState<string>();
|
||||||
|
|
||||||
|
// Match similar to what Jellyseerr is currently using
|
||||||
|
// https://github.com/Fallenbagel/jellyseerr/blob/8a097d5195749c8d1dca9b473b8afa96a50e2fe2/src/components/Common/StatusBadgeMini/index.tsx#L33C1-L62C4
|
||||||
|
useEffect(() => {
|
||||||
|
switch (mediaStatus) {
|
||||||
|
case MediaStatus.PROCESSING:
|
||||||
|
setBadgeStyle('bg-indigo-500 border-indigo-400 ring-indigo-400 text-indigo-100');
|
||||||
|
setBadgeIcon('clock');
|
||||||
|
break;
|
||||||
|
case MediaStatus.AVAILABLE:
|
||||||
|
setBadgeStyle('bg-purple-500 border-green-400 ring-green-400 text-green-100');
|
||||||
|
setBadgeIcon('check')
|
||||||
|
break;
|
||||||
|
case MediaStatus.PENDING:
|
||||||
|
setBadgeStyle('bg-yellow-500 border-yellow-400 ring-yellow-400 text-yellow-100');
|
||||||
|
setBadgeIcon('bell')
|
||||||
|
break;
|
||||||
|
case MediaStatus.BLACKLISTED:
|
||||||
|
setBadgeStyle('bg-red-500 border-white-400 ring-white-400 text-white');
|
||||||
|
setBadgeIcon('eye-off')
|
||||||
|
break;
|
||||||
|
case MediaStatus.PARTIALLY_AVAILABLE:
|
||||||
|
setBadgeStyle('bg-green-500 border-green-400 ring-green-400 text-green-100');
|
||||||
|
setBadgeIcon("minus");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
if (showRequestIcon) {
|
||||||
|
setBadgeStyle('bg-green-600');
|
||||||
|
setBadgeIcon("plus")
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}, [mediaStatus, showRequestIcon, setBadgeStyle, setBadgeIcon])
|
||||||
|
|
||||||
|
return (
|
||||||
|
badgeIcon &&
|
||||||
|
<TouchableOpacity onPress={onPress} disabled={onPress == undefined}>
|
||||||
|
<View
|
||||||
|
className={`${badgeStyle ?? 'bg-purple-600'} rounded-full h-6 w-6 flex items-center justify-center ${props.className}`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name={badgeIcon}
|
||||||
|
size={18}
|
||||||
|
color="white"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default JellyseerrIconStatus;
|
||||||
44
components/inputs/Stepper.tsx
Normal file
44
components/inputs/Stepper.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import {TouchableOpacity, View} from "react-native";
|
||||||
|
import {Text} from "@/components/common/Text";
|
||||||
|
|
||||||
|
interface StepperProps {
|
||||||
|
value: number,
|
||||||
|
step: number,
|
||||||
|
min: number,
|
||||||
|
max: number,
|
||||||
|
onUpdate: (value: number) => void,
|
||||||
|
appendValue?: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Stepper: React.FC<StepperProps> = ({
|
||||||
|
value,
|
||||||
|
step,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
onUpdate,
|
||||||
|
appendValue
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<View className="flex flex-row items-center">
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => onUpdate(Math.max(min, value - step))}
|
||||||
|
className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<Text>-</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text
|
||||||
|
className={
|
||||||
|
"w-auto h-8 bg-neutral-800 py-2 px-1 flex items-center justify-center" + (appendValue ? "first-letter:px-2" : "")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{value}{appendValue}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
className="w-8 h-8 bg-neutral-800 rounded-r-lg flex items-center justify-center"
|
||||||
|
onPress={() => onUpdate(Math.min(max, value + step))}
|
||||||
|
>
|
||||||
|
<Text>+</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
100
components/jellyseerr/DiscoverSlide.tsx
Normal file
100
components/jellyseerr/DiscoverSlide.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import React, { useMemo } from "react";
|
||||||
|
import DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
|
||||||
|
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||||
|
import {
|
||||||
|
DiscoverEndpoint,
|
||||||
|
Endpoints,
|
||||||
|
useJellyseerr,
|
||||||
|
} from "@/hooks/useJellyseerr";
|
||||||
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
|
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
|
||||||
|
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
import { View } from "react-native";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
slide: DiscoverSlider;
|
||||||
|
}
|
||||||
|
const DiscoverSlide: React.FC<Props> = ({ slide }) => {
|
||||||
|
const { jellyseerrApi } = useJellyseerr();
|
||||||
|
|
||||||
|
const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||||
|
queryKey: ["jellyseerr", "discover", slide.id],
|
||||||
|
queryFn: async ({ pageParam }) => {
|
||||||
|
let endpoint: DiscoverEndpoint | undefined = undefined;
|
||||||
|
let params: any = {
|
||||||
|
page: Number(pageParam),
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (slide.type) {
|
||||||
|
case DiscoverSliderType.TRENDING:
|
||||||
|
endpoint = Endpoints.DISCOVER_TRENDING;
|
||||||
|
break;
|
||||||
|
case DiscoverSliderType.POPULAR_MOVIES:
|
||||||
|
case DiscoverSliderType.UPCOMING_MOVIES:
|
||||||
|
endpoint = Endpoints.DISCOVER_MOVIES;
|
||||||
|
if (slide.type === DiscoverSliderType.UPCOMING_MOVIES)
|
||||||
|
params = {
|
||||||
|
...params,
|
||||||
|
primaryReleaseDateGte: new Date().toISOString().split("T")[0],
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case DiscoverSliderType.POPULAR_TV:
|
||||||
|
case DiscoverSliderType.UPCOMING_TV:
|
||||||
|
endpoint = Endpoints.DISCOVER_TV;
|
||||||
|
if (slide.type === DiscoverSliderType.UPCOMING_TV)
|
||||||
|
params = {
|
||||||
|
...params,
|
||||||
|
firstAirDateGte: new Date().toISOString().split("T")[0],
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return endpoint ? jellyseerrApi?.discover(endpoint, params) : null;
|
||||||
|
},
|
||||||
|
initialPageParam: 1,
|
||||||
|
getNextPageParam: (lastPage, pages) =>
|
||||||
|
(lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
|
||||||
|
1,
|
||||||
|
enabled: !!jellyseerrApi,
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const flatData = useMemo(
|
||||||
|
() =>
|
||||||
|
data?.pages?.filter((p) => p?.results.length).flatMap((p) => p?.results),
|
||||||
|
[data]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
flatData &&
|
||||||
|
flatData?.length > 0 && (
|
||||||
|
<View className="mb-4">
|
||||||
|
<Text className="font-bold text-lg mb-2">
|
||||||
|
{DiscoverSliderType[slide.type].toString().toTitle()}
|
||||||
|
</Text>
|
||||||
|
<FlashList
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
keyExtractor={(item) => item!!.id.toString()}
|
||||||
|
estimatedItemSize={250}
|
||||||
|
data={flatData}
|
||||||
|
onEndReachedThreshold={1}
|
||||||
|
onEndReached={() => {
|
||||||
|
if (hasNextPage) fetchNextPage();
|
||||||
|
}}
|
||||||
|
renderItem={({ item }) =>
|
||||||
|
item ? (
|
||||||
|
<JellyseerrPoster item={item as MovieResult | TvResult} />
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DiscoverSlide;
|
||||||
@@ -8,11 +8,6 @@ import { useRouter } from "expo-router";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
|
import { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
|
||||||
import CastContext, {
|
|
||||||
PlayServicesState,
|
|
||||||
useCastDevice,
|
|
||||||
useRemoteMediaClient,
|
|
||||||
} from "react-native-google-cast";
|
|
||||||
|
|
||||||
interface Props extends TouchableOpacityProps {
|
interface Props extends TouchableOpacityProps {
|
||||||
collectionId: string;
|
collectionId: string;
|
||||||
@@ -32,40 +27,14 @@ export const SongsListItem: React.FC<Props> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const castDevice = useCastDevice();
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const client = useRemoteMediaClient();
|
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
|
|
||||||
const { setPlaySettings } = usePlaySettings();
|
const { setPlaySettings } = usePlaySettings();
|
||||||
|
|
||||||
const openSelect = () => {
|
const openSelect = () => {
|
||||||
if (!castDevice?.deviceId) {
|
play("device");
|
||||||
play("device");
|
return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const options = ["Chromecast", "Device", "Cancel"];
|
|
||||||
const cancelButtonIndex = 2;
|
|
||||||
|
|
||||||
showActionSheetWithOptions(
|
|
||||||
{
|
|
||||||
options,
|
|
||||||
cancelButtonIndex,
|
|
||||||
},
|
|
||||||
(selectedIndex: number | undefined) => {
|
|
||||||
switch (selectedIndex) {
|
|
||||||
case 0:
|
|
||||||
play("cast");
|
|
||||||
break;
|
|
||||||
case 1:
|
|
||||||
play("device");
|
|
||||||
break;
|
|
||||||
case cancelButtonIndex:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const play = useCallback(async (type: "device" | "cast") => {
|
const play = useCallback(async (type: "device" | "cast") => {
|
||||||
|
|||||||
92
components/posters/JellyseerrPoster.tsx
Normal file
92
components/posters/JellyseerrPoster.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import {View, ViewProps} from "react-native";
|
||||||
|
import {Image} from "expo-image";
|
||||||
|
import {MaterialCommunityIcons} from "@expo/vector-icons";
|
||||||
|
import {Text} from "@/components/common/Text";
|
||||||
|
import {useEffect, useMemo, useState} from "react";
|
||||||
|
import {MovieResult, Results, TvResult} from "@/utils/jellyseerr/server/models/Search";
|
||||||
|
import {MediaStatus, MediaType} from "@/utils/jellyseerr/server/constants/media";
|
||||||
|
import {useJellyseerr} from "@/hooks/useJellyseerr";
|
||||||
|
import {hasPermission, Permission} from "@/utils/jellyseerr/server/lib/permissions";
|
||||||
|
import {TouchableJellyseerrRouter} from "@/components/common/JellyseerrItemRouter";
|
||||||
|
import JellyseerrIconStatus from "@/components/icons/JellyseerrIconStatus";
|
||||||
|
interface Props extends ViewProps {
|
||||||
|
item: MovieResult | TvResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const JellyseerrPoster: React.FC<Props> = ({
|
||||||
|
item,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const {jellyseerrUser, jellyseerrApi} = useJellyseerr();
|
||||||
|
// const imageSource =
|
||||||
|
|
||||||
|
const imageSrc = useMemo(() =>
|
||||||
|
item.posterPath ?
|
||||||
|
`https://image.tmdb.org/t/p/w300_and_h450_face${item.posterPath}`
|
||||||
|
: jellyseerrApi?.axios?.defaults.baseURL + `/images/overseerr_poster_not_found_logo_top.png`,
|
||||||
|
[item, jellyseerrApi]
|
||||||
|
)
|
||||||
|
const title = useMemo(() => item.mediaType === MediaType.MOVIE ? item.title : item.name, [item])
|
||||||
|
const releaseYear = useMemo(() =>
|
||||||
|
new Date(item.mediaType === MediaType.MOVIE ? item.releaseDate : item.firstAirDate).getFullYear(),
|
||||||
|
[item]
|
||||||
|
)
|
||||||
|
|
||||||
|
const showRequestButton = useMemo(() =>
|
||||||
|
jellyseerrUser && hasPermission(
|
||||||
|
[
|
||||||
|
Permission.REQUEST,
|
||||||
|
item.mediaType === 'movie'
|
||||||
|
? Permission.REQUEST_MOVIE
|
||||||
|
: Permission.REQUEST_TV,
|
||||||
|
],
|
||||||
|
jellyseerrUser.permissions,
|
||||||
|
{type: 'or'}
|
||||||
|
),
|
||||||
|
[item, jellyseerrUser]
|
||||||
|
)
|
||||||
|
|
||||||
|
const canRequest = useMemo(() => {
|
||||||
|
const status = item?.mediaInfo?.status
|
||||||
|
return showRequestButton && !status || status === MediaStatus.UNKNOWN
|
||||||
|
}, [item])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableJellyseerrRouter
|
||||||
|
result={item}
|
||||||
|
mediaTitle={title}
|
||||||
|
releaseYear={releaseYear}
|
||||||
|
canRequest={canRequest}
|
||||||
|
posterSrc={imageSrc}
|
||||||
|
>
|
||||||
|
<View className="flex flex-col w-28 mr-2">
|
||||||
|
<View className="relative rounded-lg overflow-hidden border border-neutral-900 w-28 aspect-[10/15]">
|
||||||
|
<Image
|
||||||
|
key={item.id}
|
||||||
|
id={item.id.toString()}
|
||||||
|
source={{uri: imageSrc}}
|
||||||
|
cachePolicy={"memory-disk"}
|
||||||
|
contentFit="cover"
|
||||||
|
style={{
|
||||||
|
aspectRatio: "10/15",
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<JellyseerrIconStatus
|
||||||
|
className="absolute bottom-1 right-1"
|
||||||
|
showRequestIcon={canRequest}
|
||||||
|
mediaStatus={item?.mediaInfo?.status}
|
||||||
|
/>
|
||||||
|
|
||||||
|
</View>
|
||||||
|
<View className="mt-2 flex flex-col">
|
||||||
|
<Text numberOfLines={2}>{title}</Text>
|
||||||
|
<Text className="text-xs opacity-50">{releaseYear}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</TouchableJellyseerrRouter>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default JellyseerrPoster;
|
||||||
275
components/series/JellyseerrSeasons.tsx
Normal file
275
components/series/JellyseerrSeasons.tsx
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import React, { useCallback, useMemo, useState } from "react";
|
||||||
|
import { Alert, TouchableOpacity, View } from "react-native";
|
||||||
|
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
import { orderBy } from "lodash";
|
||||||
|
import { Tags } from "@/components/GenreTags";
|
||||||
|
import JellyseerrIconStatus from "@/components/icons/JellyseerrIconStatus";
|
||||||
|
import Season from "@/utils/jellyseerr/server/entity/Season";
|
||||||
|
import {
|
||||||
|
MediaStatus,
|
||||||
|
MediaType,
|
||||||
|
} from "@/utils/jellyseerr/server/constants/media";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { RoundButton } from "@/components/RoundButton";
|
||||||
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
|
import { TvResult } from "@/utils/jellyseerr/server/models/Search";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
|
||||||
|
import { Loader } from "../Loader";
|
||||||
|
|
||||||
|
const JellyseerrSeasonEpisodes: React.FC<{
|
||||||
|
details: TvDetails;
|
||||||
|
seasonNumber: number;
|
||||||
|
}> = ({ details, seasonNumber }) => {
|
||||||
|
const { jellyseerrApi } = useJellyseerr();
|
||||||
|
|
||||||
|
const { data: seasonWithEpisodes, isLoading } = useQuery({
|
||||||
|
queryKey: ["jellyseerr", details.id, "season", seasonNumber],
|
||||||
|
queryFn: async () => jellyseerrApi?.tvSeason(details.id, seasonNumber),
|
||||||
|
enabled: details.seasons.filter((s) => s.seasonNumber !== 0).length > 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HorizontalScroll
|
||||||
|
horizontal
|
||||||
|
loading={isLoading}
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
estimatedItemSize={50}
|
||||||
|
data={seasonWithEpisodes?.episodes}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
renderItem={(item, index) => (
|
||||||
|
<RenderItem key={index} item={item} index={index} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const RenderItem = ({ item, index }: any) => {
|
||||||
|
const { jellyseerrApi } = useJellyseerr();
|
||||||
|
const [imageError, setImageError] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex flex-col w-44 mt-2">
|
||||||
|
<View className="relative aspect-video rounded-lg overflow-hidden border border-neutral-800">
|
||||||
|
{!imageError ? (
|
||||||
|
<Image
|
||||||
|
key={item.id}
|
||||||
|
id={item.id}
|
||||||
|
source={{
|
||||||
|
uri: jellyseerrApi?.tvStillImageProxy(item.stillPath),
|
||||||
|
}}
|
||||||
|
cachePolicy={"memory-disk"}
|
||||||
|
contentFit="cover"
|
||||||
|
className="w-full h-full"
|
||||||
|
onError={(e) => {
|
||||||
|
setImageError(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View className="flex flex-col w-full h-full items-center justify-center border border-neutral-800 bg-neutral-900">
|
||||||
|
<Ionicons
|
||||||
|
name="image-outline"
|
||||||
|
size={24}
|
||||||
|
color="white"
|
||||||
|
style={{ opacity: 0.4 }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View className="shrink mt-1">
|
||||||
|
<Text numberOfLines={2} className="">
|
||||||
|
{item.name}
|
||||||
|
</Text>
|
||||||
|
<Text numberOfLines={1} className="text-xs text-neutral-500">
|
||||||
|
{`S${item.seasonNumber}:E${item.episodeNumber}`}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text numberOfLines={3} className="text-xs text-neutral-500 shrink">
|
||||||
|
{item.overview}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const JellyseerrSeasons: React.FC<{
|
||||||
|
isLoading: boolean;
|
||||||
|
result?: TvResult;
|
||||||
|
details?: TvDetails;
|
||||||
|
}> = ({ isLoading, result, details }) => {
|
||||||
|
if (!details) return null;
|
||||||
|
|
||||||
|
const { jellyseerrApi, requestMedia } = useJellyseerr();
|
||||||
|
const [seasonStates, setSeasonStates] = useState<{
|
||||||
|
[key: number]: boolean;
|
||||||
|
}>();
|
||||||
|
const seasons = useMemo(() => {
|
||||||
|
const mediaInfoSeasons = details?.mediaInfo?.seasons?.filter(
|
||||||
|
(s: Season) => s.seasonNumber !== 0
|
||||||
|
);
|
||||||
|
const requestedSeasons = details?.mediaInfo?.requests?.flatMap(
|
||||||
|
(r: MediaRequest) => r.seasons
|
||||||
|
);
|
||||||
|
return details.seasons?.map((season) => {
|
||||||
|
return {
|
||||||
|
...season,
|
||||||
|
status:
|
||||||
|
// What our library status is
|
||||||
|
mediaInfoSeasons?.find(
|
||||||
|
(mediaSeason: Season) =>
|
||||||
|
mediaSeason.seasonNumber === season.seasonNumber
|
||||||
|
)?.status ??
|
||||||
|
// What our request status is
|
||||||
|
requestedSeasons?.find(
|
||||||
|
(s: Season) => s.seasonNumber === season.seasonNumber
|
||||||
|
)?.status ??
|
||||||
|
// Otherwise set it as unknown
|
||||||
|
MediaStatus.UNKNOWN,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [details]);
|
||||||
|
|
||||||
|
const allSeasonsAvailable = useMemo(
|
||||||
|
() => seasons?.every((season) => season.status === MediaStatus.AVAILABLE),
|
||||||
|
[seasons]
|
||||||
|
);
|
||||||
|
|
||||||
|
const requestAll = useCallback(() => {
|
||||||
|
if (details && jellyseerrApi) {
|
||||||
|
requestMedia(result?.name!!, {
|
||||||
|
mediaId: details.id,
|
||||||
|
mediaType: MediaType.TV,
|
||||||
|
tvdbId: details.externalIds?.tvdbId,
|
||||||
|
seasons: seasons
|
||||||
|
.filter(
|
||||||
|
(s) => s.status === MediaStatus.UNKNOWN && s.seasonNumber !== 0
|
||||||
|
)
|
||||||
|
.map((s) => s.seasonNumber),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [jellyseerrApi, seasons, details]);
|
||||||
|
|
||||||
|
const promptRequestAll = useCallback(
|
||||||
|
() =>
|
||||||
|
Alert.alert("Confirm", "Are you sure you want to request all seasons?", [
|
||||||
|
{
|
||||||
|
text: "Cancel",
|
||||||
|
style: "cancel",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "Yes",
|
||||||
|
onPress: requestAll,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
[requestAll]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoading)
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<View className="flex flex-row justify-between items-end px-4">
|
||||||
|
<Text className="text-lg font-bold mb-2">Seasons</Text>
|
||||||
|
{!allSeasonsAvailable && (
|
||||||
|
<RoundButton className="mb-2 pa-2" onPress={promptRequestAll}>
|
||||||
|
<Ionicons name="bag-add" color="white" size={26} />
|
||||||
|
</RoundButton>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlashList
|
||||||
|
data={orderBy(
|
||||||
|
details.seasons.filter((s) => s.seasonNumber !== 0),
|
||||||
|
"seasonNumber",
|
||||||
|
"desc"
|
||||||
|
)}
|
||||||
|
ListHeaderComponent={() => (
|
||||||
|
<View className="flex flex-row justify-between items-end px-4">
|
||||||
|
<Text className="text-lg font-bold mb-2">Seasons</Text>
|
||||||
|
{!allSeasonsAvailable && (
|
||||||
|
<RoundButton className="mb-2 pa-2" onPress={promptRequestAll}>
|
||||||
|
<Ionicons name="bag-add" color="white" size={26} />
|
||||||
|
</RoundButton>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
ItemSeparatorComponent={() => <View className="h-2" />}
|
||||||
|
estimatedItemSize={250}
|
||||||
|
renderItem={({ item: season }) => (
|
||||||
|
<>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() =>
|
||||||
|
setSeasonStates((prevState) => ({
|
||||||
|
...prevState,
|
||||||
|
[season.seasonNumber]: !prevState?.[season.seasonNumber],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className="px-4"
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className="flex flex-row justify-between items-center bg-gray-100/10 rounded-xl z-20 h-12 w-full px-4"
|
||||||
|
key={season.id}
|
||||||
|
>
|
||||||
|
<Tags
|
||||||
|
textClass=""
|
||||||
|
tags={[
|
||||||
|
`Season ${season.seasonNumber}`,
|
||||||
|
`${season.episodeCount} Episodes`,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{[0].map(() => {
|
||||||
|
const canRequest =
|
||||||
|
seasons?.find((s) => s.seasonNumber === season.seasonNumber)
|
||||||
|
?.status === MediaStatus.UNKNOWN;
|
||||||
|
return (
|
||||||
|
<JellyseerrIconStatus
|
||||||
|
key={0}
|
||||||
|
onPress={
|
||||||
|
canRequest
|
||||||
|
? () =>
|
||||||
|
requestMedia(
|
||||||
|
`${result?.name!!}, Season ${
|
||||||
|
season.seasonNumber
|
||||||
|
}`,
|
||||||
|
{
|
||||||
|
mediaId: details.id,
|
||||||
|
mediaType: MediaType.TV,
|
||||||
|
tvdbId: details.externalIds?.tvdbId,
|
||||||
|
seasons: [season.seasonNumber],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
className={canRequest ? "bg-gray-700/40" : undefined}
|
||||||
|
mediaStatus={
|
||||||
|
seasons?.find(
|
||||||
|
(s) => s.seasonNumber === season.seasonNumber
|
||||||
|
)?.status
|
||||||
|
}
|
||||||
|
showRequestIcon={canRequest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
{seasonStates?.[season.seasonNumber] && (
|
||||||
|
<JellyseerrSeasonEpisodes
|
||||||
|
key={season.seasonNumber}
|
||||||
|
details={details}
|
||||||
|
seasonNumber={season.seasonNumber}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default JellyseerrSeasons;
|
||||||
@@ -11,6 +11,7 @@ import { Text } from "../common/Text";
|
|||||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||||
import { ItemCardText } from "../ItemCardText";
|
import { ItemCardText } from "../ItemCardText";
|
||||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
|
||||||
export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
|
export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
@@ -43,10 +44,14 @@ export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
<Text className="text-lg font-bold mb-2 px-4">Next up</Text>
|
<Text className="text-lg font-bold px-4 mb-2">Next up</Text>
|
||||||
<HorizontalScroll
|
<FlashList
|
||||||
|
contentContainerStyle={{ paddingLeft: 16 }}
|
||||||
|
horizontal
|
||||||
|
estimatedItemSize={172}
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
data={items}
|
data={items}
|
||||||
renderItem={(item, index) => (
|
renderItem={({ item, index }) => (
|
||||||
<TouchableItemRouter
|
<TouchableItemRouter
|
||||||
item={item}
|
item={item}
|
||||||
key={index}
|
key={index}
|
||||||
|
|||||||
151
components/series/SeasonDropdown.tsx
Normal file
151
components/series/SeasonDropdown.tsx
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { TouchableOpacity, View, Modal } from "react-native";
|
||||||
|
import { Text } from "../common/Text";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
item: BaseItemDto;
|
||||||
|
seasons: BaseItemDto[];
|
||||||
|
initialSeasonIndex?: number;
|
||||||
|
state: SeasonIndexState;
|
||||||
|
onSelect: (season: BaseItemDto) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SeasonKeys = {
|
||||||
|
id: keyof BaseItemDto;
|
||||||
|
title: keyof BaseItemDto;
|
||||||
|
index: keyof BaseItemDto;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SeasonIndexState = {
|
||||||
|
[seriesId: string]: number | string | null | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SeasonDropdown: React.FC<Props> = ({
|
||||||
|
item,
|
||||||
|
seasons,
|
||||||
|
initialSeasonIndex,
|
||||||
|
state,
|
||||||
|
onSelect,
|
||||||
|
}) => {
|
||||||
|
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||||
|
|
||||||
|
const keys = useMemo<SeasonKeys>(
|
||||||
|
() =>
|
||||||
|
item.Type === "Episode"
|
||||||
|
? {
|
||||||
|
id: "ParentId",
|
||||||
|
title: "SeasonName",
|
||||||
|
index: "ParentIndexNumber",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
id: "Id",
|
||||||
|
title: "Name",
|
||||||
|
index: "IndexNumber",
|
||||||
|
},
|
||||||
|
[item]
|
||||||
|
);
|
||||||
|
|
||||||
|
const seasonIndex = useMemo(
|
||||||
|
() => state[(item[keys.id] as string) ?? ""],
|
||||||
|
[state]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (seasons && seasons.length > 0 && seasonIndex === undefined) {
|
||||||
|
let initialIndex: number | undefined;
|
||||||
|
|
||||||
|
if (initialSeasonIndex !== undefined) {
|
||||||
|
const seasonExists = seasons.some(
|
||||||
|
(season: any) => season[keys.index] === initialSeasonIndex
|
||||||
|
);
|
||||||
|
if (seasonExists) {
|
||||||
|
initialIndex = initialSeasonIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (initialIndex === undefined) {
|
||||||
|
const season1 = seasons.find((season: any) => season[keys.index] === 1);
|
||||||
|
const season0 = seasons.find((season: any) => season[keys.index] === 0);
|
||||||
|
const firstSeason = season1 || season0 || seasons[0];
|
||||||
|
onSelect(firstSeason);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (initialIndex !== undefined) {
|
||||||
|
const initialSeason = seasons.find(
|
||||||
|
(season: any) => season[keys.index] === initialIndex
|
||||||
|
);
|
||||||
|
|
||||||
|
if (initialSeason) onSelect(initialSeason!);
|
||||||
|
else throw Error("Initial index could not be found!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [seasons, seasonIndex, item[keys.id], initialSeasonIndex]);
|
||||||
|
|
||||||
|
const sortByIndex = (a: BaseItemDto, b: BaseItemDto) =>
|
||||||
|
Number(a[keys.index]) - Number(b[keys.index]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TouchableOpacity
|
||||||
|
className="bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between"
|
||||||
|
onPress={() => setIsModalVisible(true)}
|
||||||
|
>
|
||||||
|
<Text>Season {seasonIndex}</Text>
|
||||||
|
<Ionicons
|
||||||
|
name="chevron-down"
|
||||||
|
size={16}
|
||||||
|
color="white"
|
||||||
|
style={{ opacity: 0.5, marginLeft: 8 }}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
visible={isModalVisible}
|
||||||
|
transparent
|
||||||
|
animationType="slide"
|
||||||
|
onRequestClose={() => setIsModalVisible(false)}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
className="flex-1 bg-black/50"
|
||||||
|
activeOpacity={1}
|
||||||
|
onPress={() => setIsModalVisible(false)}
|
||||||
|
>
|
||||||
|
<View className="mt-auto bg-neutral-900 rounded-t-xl">
|
||||||
|
<View className="p-4 border-b border-neutral-800">
|
||||||
|
<Text className="text-lg font-bold text-center">
|
||||||
|
Select Season
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="max-h-[50%]">
|
||||||
|
{seasons?.sort(sortByIndex).map((season: any) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={season[keys.title]}
|
||||||
|
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
|
||||||
|
onPress={() => {
|
||||||
|
onSelect(season);
|
||||||
|
setIsModalVisible(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text>{season[keys.title]}</Text>
|
||||||
|
{Number(season[keys.index]) === Number(seasonIndex) && (
|
||||||
|
<Ionicons name="checkmark" size={24} color="white" />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
className="p-4 border-t border-neutral-800"
|
||||||
|
onPress={() => setIsModalVisible(false)}
|
||||||
|
>
|
||||||
|
<Text className="text-center text-purple-400">Cancel</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,20 +1,19 @@
|
|||||||
|
import {
|
||||||
|
SeasonDropdown,
|
||||||
|
SeasonIndexState,
|
||||||
|
} from "@/components/series/SeasonDropdown";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
import { runtimeTicksToSeconds } from "@/utils/time";
|
import { runtimeTicksToSeconds } from "@/utils/time";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useRouter } from "expo-router";
|
|
||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom } from "jotai";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { View } from "react-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
|
||||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||||
import { DownloadItem } from "../DownloadItem";
|
|
||||||
import { Loader } from "../Loader";
|
import { Loader } from "../Loader";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
|
||||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -22,10 +21,6 @@ type Props = {
|
|||||||
initialSeasonIndex?: number;
|
initialSeasonIndex?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SeasonIndexState = {
|
|
||||||
[seriesId: string]: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const seasonIndexAtom = atom<SeasonIndexState>({});
|
export const seasonIndexAtom = atom<SeasonIndexState>({});
|
||||||
|
|
||||||
export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
||||||
@@ -33,9 +28,10 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
|||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
|
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
|
||||||
|
|
||||||
const seasonIndex = seasonIndexState[item.Id ?? ""];
|
const seasonIndex = useMemo(
|
||||||
|
() => seasonIndexState[item.Id ?? ""],
|
||||||
const router = useRouter();
|
[item, seasonIndexState]
|
||||||
|
);
|
||||||
|
|
||||||
const { data: seasons } = useQuery({
|
const { data: seasons } = useQuery({
|
||||||
queryKey: ["seasons", item.Id],
|
queryKey: ["seasons", item.Id],
|
||||||
@@ -58,50 +54,28 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
|||||||
|
|
||||||
return response.data.Items;
|
return response.data.Items;
|
||||||
},
|
},
|
||||||
|
staleTime: 60,
|
||||||
enabled: !!api && !!user?.Id && !!item.Id,
|
enabled: !!api && !!user?.Id && !!item.Id,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
const selectedSeasonId: string | null = useMemo(() => {
|
||||||
if (seasons && seasons.length > 0 && seasonIndex === undefined) {
|
const season: BaseItemDto = seasons?.find(
|
||||||
let initialIndex: number | undefined;
|
(s: BaseItemDto) =>
|
||||||
|
s.IndexNumber === seasonIndex || s.Name === seasonIndex
|
||||||
|
);
|
||||||
|
|
||||||
if (initialSeasonIndex !== undefined) {
|
if (!season?.Id) return null;
|
||||||
// Use the provided initialSeasonIndex if it exists in the seasons
|
|
||||||
const seasonExists = seasons.some(
|
|
||||||
(season: any) => season.IndexNumber === initialSeasonIndex
|
|
||||||
);
|
|
||||||
if (seasonExists) {
|
|
||||||
initialIndex = initialSeasonIndex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (initialIndex === undefined) {
|
return season.Id!;
|
||||||
// Fall back to the previous logic if initialIndex is not set
|
}, [seasons, seasonIndex]);
|
||||||
const season1 = seasons.find((season: any) => season.IndexNumber === 1);
|
|
||||||
const season0 = seasons.find((season: any) => season.IndexNumber === 0);
|
|
||||||
const firstSeason = season1 || season0 || seasons[0];
|
|
||||||
initialIndex = firstSeason.IndexNumber;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (initialIndex !== undefined) {
|
|
||||||
setSeasonIndexState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[item.Id ?? ""]: initialIndex,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [seasons, seasonIndex, setSeasonIndexState, item.Id, initialSeasonIndex]);
|
|
||||||
|
|
||||||
const selectedSeasonId: string | null = useMemo(
|
|
||||||
() =>
|
|
||||||
seasons?.find((season: any) => season.IndexNumber === seasonIndex)?.Id,
|
|
||||||
[seasons, seasonIndex]
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data: episodes, isFetching } = useQuery({
|
const { data: episodes, isFetching } = useQuery({
|
||||||
queryKey: ["episodes", item.Id, selectedSeasonId],
|
queryKey: ["episodes", item.Id, selectedSeasonId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!api || !user?.Id || !item.Id || !selectedSeasonId) return [];
|
if (!api || !user?.Id || !item.Id || !selectedSeasonId) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
const res = await getTvShowsApi(api).getEpisodes({
|
const res = await getTvShowsApi(api).getEpisodes({
|
||||||
seriesId: item.Id,
|
seriesId: item.Id,
|
||||||
userId: user.Id,
|
userId: user.Id,
|
||||||
@@ -110,6 +84,12 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
|||||||
fields: ["MediaSources", "MediaStreams", "Overview"],
|
fields: ["MediaSources", "MediaStreams", "Overview"],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (res.data.TotalRecordCount === 0)
|
||||||
|
console.warn(
|
||||||
|
"No episodes found for season with ID ~",
|
||||||
|
selectedSeasonId
|
||||||
|
);
|
||||||
|
|
||||||
return res.data.Items;
|
return res.data.Items;
|
||||||
},
|
},
|
||||||
enabled: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
|
enabled: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
|
||||||
@@ -148,40 +128,21 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
|||||||
minHeight: 144 * nrOfEpisodes,
|
minHeight: 144 * nrOfEpisodes,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.Root>
|
<View className="flex flex-row justify-start items-center px-4">
|
||||||
<DropdownMenu.Trigger>
|
<SeasonDropdown
|
||||||
<View className="flex flex-row px-4">
|
item={item}
|
||||||
<TouchableOpacity className="bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
seasons={seasons}
|
||||||
<Text>Season {seasonIndex}</Text>
|
state={seasonIndexState}
|
||||||
</TouchableOpacity>
|
onSelect={(season) => {
|
||||||
</View>
|
if (!item.Id) return;
|
||||||
</DropdownMenu.Trigger>
|
setSeasonIndexState((prev) => ({
|
||||||
<DropdownMenu.Content
|
...prev,
|
||||||
loop={true}
|
[item.Id!]: season.IndexNumber ?? season.Name,
|
||||||
side="bottom"
|
}));
|
||||||
align="start"
|
}}
|
||||||
alignOffset={0}
|
/>
|
||||||
avoidCollisions={true}
|
</View>
|
||||||
collisionPadding={8}
|
<View className="px-4 flex flex-col mt-4">
|
||||||
sideOffset={8}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Label>Seasons</DropdownMenu.Label>
|
|
||||||
{seasons?.map((season: any) => (
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={season.Name}
|
|
||||||
onSelect={() => {
|
|
||||||
setSeasonIndexState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[item.Id ?? ""]: season.IndexNumber,
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>{season.Name}</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
))}
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
<View className="px-4 flex flex-col my-4">
|
|
||||||
{isFetching ? (
|
{isFetching ? (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
@@ -217,9 +178,6 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
|||||||
{runtimeTicksToSeconds(e.RunTimeTicks)}
|
{runtimeTicksToSeconds(e.RunTimeTicks)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className="self-start ml-auto -mt-0.5">
|
|
||||||
<DownloadItem item={e} />
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text
|
<Text
|
||||||
@@ -231,6 +189,13 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
|||||||
</TouchableItemRouter>
|
</TouchableItemRouter>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
{(episodes?.length || 0) === 0 ? (
|
||||||
|
<View className="flex flex-col">
|
||||||
|
<Text className="text-neutral-500">
|
||||||
|
No episodes for this season
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
32
components/series/SeriesActions.tsx
Normal file
32
components/series/SeriesActions.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { useCallback, useMemo } from "react";
|
||||||
|
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {
|
||||||
|
item: BaseItemDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ItemActions = ({ item, ...props }: Props) => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const trailerLink = useMemo(() => item.RemoteTrailers?.[0]?.Url, [item]);
|
||||||
|
|
||||||
|
const openTrailer = useCallback(async () => {
|
||||||
|
if (!trailerLink) return;
|
||||||
|
|
||||||
|
const encodedTrailerLink = encodeURIComponent(trailerLink);
|
||||||
|
router.push(`/trailer/page?url=${encodedTrailerLink}`);
|
||||||
|
}, [router, trailerLink]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="" {...props}>
|
||||||
|
{trailerLink && (
|
||||||
|
<TouchableOpacity onPress={openTrailer}>
|
||||||
|
<Ionicons name="film-outline" size={24} color="white" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
64
components/series/SeriesHeader.tsx
Normal file
64
components/series/SeriesHeader.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { View } from "react-native";
|
||||||
|
import { Text } from "../common/Text";
|
||||||
|
import { Ratings } from "../Ratings";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { ItemActions } from "./SeriesActions";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
item: BaseItemDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SeriesHeader = ({ item }: Props) => {
|
||||||
|
const startYear = useMemo(() => {
|
||||||
|
if (item?.StartDate) {
|
||||||
|
return new Date(item.StartDate)
|
||||||
|
.toLocaleDateString("sv-SE", {
|
||||||
|
calendar: "gregory",
|
||||||
|
year: "numeric",
|
||||||
|
})
|
||||||
|
.toString()
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
return item.ProductionYear?.toString().trim();
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
const endYear = useMemo(() => {
|
||||||
|
if (item.EndDate) {
|
||||||
|
return new Date(item.EndDate)
|
||||||
|
.toLocaleDateString("sv-SE", {
|
||||||
|
calendar: "gregory",
|
||||||
|
year: "numeric",
|
||||||
|
})
|
||||||
|
.toString()
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
const yearString = useMemo(() => {
|
||||||
|
if (startYear && endYear) {
|
||||||
|
if (startYear === endYear) return startYear;
|
||||||
|
return `${startYear} - ${endYear}`;
|
||||||
|
}
|
||||||
|
if (startYear) {
|
||||||
|
return startYear;
|
||||||
|
}
|
||||||
|
if (endYear) {
|
||||||
|
return endYear;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}, [startYear, endYear]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="px-4 py-4">
|
||||||
|
<Text className="text-3xl font-bold">{item?.Name}</Text>
|
||||||
|
<Text className="">{yearString}</Text>
|
||||||
|
<View className="flex flex-row items-center justify-between">
|
||||||
|
<Ratings item={item} className="mb-2" />
|
||||||
|
<ItemActions item={item} />
|
||||||
|
</View>
|
||||||
|
<Text className="">{item?.Overview}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
147
components/settings/AudioToggles.tsx
Normal file
147
components/settings/AudioToggles.tsx
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { TouchableOpacity, View, ViewProps, Modal } from "react-native";
|
||||||
|
import { Text } from "../common/Text";
|
||||||
|
import { useMedia } from "./MediaContext";
|
||||||
|
import { Switch } from "react-native-gesture-handler";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
|
export const AudioToggles: React.FC<Props> = ({ ...props }) => {
|
||||||
|
const media = useMedia();
|
||||||
|
const { settings, updateSettings } = media;
|
||||||
|
const cultures = media.cultures;
|
||||||
|
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||||
|
|
||||||
|
if (!settings) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<Text className="text-lg font-bold mb-2">Audio</Text>
|
||||||
|
<View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800">
|
||||||
|
<View className="flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4">
|
||||||
|
<View className="flex flex-col shrink">
|
||||||
|
<Text className="font-semibold">Audio language</Text>
|
||||||
|
<Text className="text-xs opacity-50">
|
||||||
|
Choose a default audio language.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between"
|
||||||
|
onPress={() => setIsModalVisible(true)}
|
||||||
|
>
|
||||||
|
<Text>{settings?.defaultAudioLanguage?.DisplayName || "None"}</Text>
|
||||||
|
<Ionicons
|
||||||
|
name="chevron-down"
|
||||||
|
size={16}
|
||||||
|
color="white"
|
||||||
|
style={{ opacity: 0.5, marginLeft: 8 }}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex flex-col">
|
||||||
|
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||||
|
<View className="flex flex-col">
|
||||||
|
<Text className="font-semibold">Use Default Audio</Text>
|
||||||
|
<Text className="text-xs opacity-50">
|
||||||
|
Play default audio track regardless of language.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={settings.playDefaultAudioTrack}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateSettings({ playDefaultAudioTrack: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex flex-col">
|
||||||
|
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||||
|
<View className="flex flex-col">
|
||||||
|
<Text className="font-semibold">
|
||||||
|
Set Audio Track From Previous Item
|
||||||
|
</Text>
|
||||||
|
<Text className="text-xs opacity-50 min max-w-[85%]">
|
||||||
|
Try to set the audio track to the closest match to the last
|
||||||
|
video.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={settings.rememberAudioSelections}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateSettings({ rememberAudioSelections: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
visible={isModalVisible}
|
||||||
|
transparent
|
||||||
|
animationType="slide"
|
||||||
|
onRequestClose={() => setIsModalVisible(false)}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
className="flex-1 bg-black/50"
|
||||||
|
activeOpacity={1}
|
||||||
|
onPress={() => setIsModalVisible(false)}
|
||||||
|
>
|
||||||
|
<View className="mt-auto bg-neutral-900 rounded-t-xl">
|
||||||
|
<View className="p-4 border-b border-neutral-800">
|
||||||
|
<Text className="text-lg font-bold text-center">
|
||||||
|
Select Language
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="max-h-[50%]">
|
||||||
|
<TouchableOpacity
|
||||||
|
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
|
||||||
|
onPress={() => {
|
||||||
|
updateSettings({
|
||||||
|
defaultAudioLanguage: null,
|
||||||
|
});
|
||||||
|
setIsModalVisible(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text>None</Text>
|
||||||
|
{!settings?.defaultAudioLanguage && (
|
||||||
|
<Ionicons name="checkmark" size={24} color="white" />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{cultures?.map((l) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={l?.ThreeLetterISOLanguageName ?? "unknown"}
|
||||||
|
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
|
||||||
|
onPress={() => {
|
||||||
|
updateSettings({
|
||||||
|
defaultAudioLanguage: l,
|
||||||
|
});
|
||||||
|
setIsModalVisible(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text>{l.DisplayName}</Text>
|
||||||
|
{settings?.defaultAudioLanguage
|
||||||
|
?.ThreeLetterISOLanguageName ===
|
||||||
|
l.ThreeLetterISOLanguageName && (
|
||||||
|
<Ionicons name="checkmark" size={24} color="white" />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
className="p-4 border-t border-neutral-800"
|
||||||
|
onPress={() => setIsModalVisible(false)}
|
||||||
|
>
|
||||||
|
<Text className="text-center text-purple-400">Cancel</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Modal>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
207
components/settings/Jellyseerr.tsx
Normal file
207
components/settings/Jellyseerr.tsx
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { Text } from "../common/Text";
|
||||||
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
import { Input } from "../common/Input";
|
||||||
|
import { ListItem } from "../ListItem";
|
||||||
|
import { Loader } from "../Loader";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { Button } from "../Button";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
export const JellyseerrSettings = () => {
|
||||||
|
const {
|
||||||
|
jellyseerrApi,
|
||||||
|
jellyseerrUser,
|
||||||
|
setJellyseerrUser,
|
||||||
|
clearAllJellyseerData,
|
||||||
|
} = useJellyseerr();
|
||||||
|
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const [settings, updateSettings] = useSettings();
|
||||||
|
|
||||||
|
const [promptForJellyseerrPass, setPromptForJellyseerrPass] =
|
||||||
|
useState<boolean>(false);
|
||||||
|
|
||||||
|
const [jellyseerrPassword, setJellyseerrPassword] = useState<
|
||||||
|
string | undefined
|
||||||
|
>(undefined);
|
||||||
|
|
||||||
|
const [jellyseerrServerUrl, setjellyseerrServerUrl] = useState<
|
||||||
|
string | undefined
|
||||||
|
>(settings?.jellyseerrServerUrl || undefined);
|
||||||
|
|
||||||
|
const loginToJellyseerrMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!jellyseerrServerUrl || !user?.Name || !jellyseerrPassword) {
|
||||||
|
throw new Error("Missing required information for login");
|
||||||
|
}
|
||||||
|
const jellyseerrTempApi = new JellyseerrApi(jellyseerrServerUrl);
|
||||||
|
return jellyseerrTempApi.login(user.Name, jellyseerrPassword);
|
||||||
|
},
|
||||||
|
onSuccess: (user) => {
|
||||||
|
setJellyseerrUser(user);
|
||||||
|
updateSettings({ jellyseerrServerUrl });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error("Failed to login");
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
setJellyseerrPassword(undefined);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const testJellyseerrServerUrlMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!jellyseerrServerUrl || jellyseerrApi) return null;
|
||||||
|
const jellyseerrTempApi = new JellyseerrApi(jellyseerrServerUrl);
|
||||||
|
return jellyseerrTempApi.test();
|
||||||
|
},
|
||||||
|
onSuccess: (result) => {
|
||||||
|
if (result && result.isValid) {
|
||||||
|
if (result.requiresPass) {
|
||||||
|
setPromptForJellyseerrPass(true);
|
||||||
|
} else {
|
||||||
|
updateSettings({ jellyseerrServerUrl });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setPromptForJellyseerrPass(false);
|
||||||
|
setjellyseerrServerUrl(undefined);
|
||||||
|
clearAllJellyseerData();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const clearData = () => {
|
||||||
|
clearAllJellyseerData().finally(() => {
|
||||||
|
setjellyseerrServerUrl(undefined);
|
||||||
|
setPromptForJellyseerrPass(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="mt-4">
|
||||||
|
<Text className="text-lg font-bold mb-2">Jellyseerr</Text>
|
||||||
|
<View>
|
||||||
|
{jellyseerrUser ? (
|
||||||
|
<View className="flex flex-col rounded-xl overflow-hidden bg-neutral-900 pt-0 divide-y divide-neutral-800">
|
||||||
|
<ListItem
|
||||||
|
title="Total media requests"
|
||||||
|
subTitle={jellyseerrUser?.requestCount?.toString()}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
title="Movie quota limit"
|
||||||
|
subTitle={
|
||||||
|
jellyseerrUser?.movieQuotaLimit?.toString() ?? "Unlimited"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
title="Movie quota days"
|
||||||
|
subTitle={
|
||||||
|
jellyseerrUser?.movieQuotaDays?.toString() ?? "Unlimited"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
title="TV quota limit"
|
||||||
|
subTitle={jellyseerrUser?.tvQuotaLimit?.toString() ?? "Unlimited"}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
title="TV quota days"
|
||||||
|
subTitle={jellyseerrUser?.tvQuotaDays?.toString() ?? "Unlimited"}
|
||||||
|
/>
|
||||||
|
<View className="p-4">
|
||||||
|
<Button color="red" onPress={clearData}>
|
||||||
|
Reset Jellyseerr config
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View className="flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900">
|
||||||
|
<Text className="text-xs text-red-600 mb-2">
|
||||||
|
This integration is in its early stages. Expect things to change.
|
||||||
|
</Text>
|
||||||
|
<Text className="font-bold mb-1">Server URL</Text>
|
||||||
|
<View className="flex flex-col shrink mb-2">
|
||||||
|
<Text className="text-xs text-gray-600">
|
||||||
|
Example: http(s)://your-host.url
|
||||||
|
</Text>
|
||||||
|
<Text className="text-xs text-gray-600">
|
||||||
|
(add port if required)
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Input
|
||||||
|
placeholder="Jellyseerr URL..."
|
||||||
|
value={settings?.jellyseerrServerUrl ?? jellyseerrServerUrl}
|
||||||
|
defaultValue={
|
||||||
|
settings?.jellyseerrServerUrl ?? jellyseerrServerUrl
|
||||||
|
}
|
||||||
|
keyboardType="url"
|
||||||
|
returnKeyType="done"
|
||||||
|
autoCapitalize="none"
|
||||||
|
textContentType="URL"
|
||||||
|
onChangeText={setjellyseerrServerUrl}
|
||||||
|
editable={!testJellyseerrServerUrlMutation.isPending}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
loading={testJellyseerrServerUrlMutation.isPending}
|
||||||
|
disabled={testJellyseerrServerUrlMutation.isPending}
|
||||||
|
color={promptForJellyseerrPass ? "red" : "purple"}
|
||||||
|
className="h-12 mt-2"
|
||||||
|
onPress={() => {
|
||||||
|
if (promptForJellyseerrPass) {
|
||||||
|
clearData();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
testJellyseerrServerUrlMutation.mutate();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{promptForJellyseerrPass ? "Clear" : "Save"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<View
|
||||||
|
pointerEvents={promptForJellyseerrPass ? "auto" : "none"}
|
||||||
|
style={{
|
||||||
|
opacity: promptForJellyseerrPass ? 1 : 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className="font-bold mb-2">Password</Text>
|
||||||
|
<Input
|
||||||
|
autoFocus={true}
|
||||||
|
focusable={true}
|
||||||
|
placeholder={`Enter password for Jellyfin user ${user?.Name}`}
|
||||||
|
value={jellyseerrPassword}
|
||||||
|
keyboardType="default"
|
||||||
|
secureTextEntry={true}
|
||||||
|
returnKeyType="done"
|
||||||
|
autoCapitalize="none"
|
||||||
|
textContentType="password"
|
||||||
|
onChangeText={setJellyseerrPassword}
|
||||||
|
editable={
|
||||||
|
!loginToJellyseerrMutation.isPending &&
|
||||||
|
promptForJellyseerrPass
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
loading={loginToJellyseerrMutation.isPending}
|
||||||
|
disabled={loginToJellyseerrMutation.isPending}
|
||||||
|
color="purple"
|
||||||
|
className="h-12 mt-2"
|
||||||
|
onPress={() => loginToJellyseerrMutation.mutate()}
|
||||||
|
>
|
||||||
|
Login
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
150
components/settings/MediaContext.tsx
Normal file
150
components/settings/MediaContext.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { Settings, useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
ReactNode,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getLocalizationApi, getUserApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import {
|
||||||
|
CultureDto,
|
||||||
|
UserDto,
|
||||||
|
UserConfiguration,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
interface MediaContextType {
|
||||||
|
settings: Settings | null;
|
||||||
|
updateSettings: (update: Partial<Settings>) => void;
|
||||||
|
user: UserDto | undefined;
|
||||||
|
cultures: CultureDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const MediaContext = createContext<MediaContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const useMedia = () => {
|
||||||
|
const context = useContext(MediaContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useMedia must be used within a MediaProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MediaProvider = ({ children }: { children: ReactNode }) => {
|
||||||
|
const [settings, updateSettings] = useSettings();
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const updateSetingsWrapper = (update: Partial<Settings>) => {
|
||||||
|
const updateUserConfiguration = async (
|
||||||
|
update: Partial<UserConfiguration>
|
||||||
|
) => {
|
||||||
|
if (api && user) {
|
||||||
|
try {
|
||||||
|
await getUserApi(api).updateUserConfiguration({
|
||||||
|
userConfiguration: {
|
||||||
|
...user.Configuration,
|
||||||
|
...update,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["authUser"] });
|
||||||
|
} catch (error) {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
updateSettings(update);
|
||||||
|
|
||||||
|
let updatePayload = {
|
||||||
|
SubtitleMode: update?.subtitleMode ?? settings?.subtitleMode,
|
||||||
|
PlayDefaultAudioTrack:
|
||||||
|
update?.playDefaultAudioTrack ?? settings?.playDefaultAudioTrack,
|
||||||
|
RememberAudioSelections:
|
||||||
|
update?.rememberAudioSelections ?? settings?.rememberAudioSelections,
|
||||||
|
RememberSubtitleSelections:
|
||||||
|
update?.rememberSubtitleSelections ??
|
||||||
|
settings?.rememberSubtitleSelections,
|
||||||
|
} as Partial<UserConfiguration>;
|
||||||
|
|
||||||
|
updatePayload.AudioLanguagePreference =
|
||||||
|
update?.defaultAudioLanguage === null
|
||||||
|
? ""
|
||||||
|
: update?.defaultAudioLanguage?.ThreeLetterISOLanguageName ||
|
||||||
|
settings?.defaultAudioLanguage?.ThreeLetterISOLanguageName ||
|
||||||
|
"";
|
||||||
|
|
||||||
|
updatePayload.SubtitleLanguagePreference =
|
||||||
|
update?.defaultSubtitleLanguage === null
|
||||||
|
? ""
|
||||||
|
: update?.defaultSubtitleLanguage?.ThreeLetterISOLanguageName ||
|
||||||
|
settings?.defaultSubtitleLanguage?.ThreeLetterISOLanguageName ||
|
||||||
|
"";
|
||||||
|
|
||||||
|
updateUserConfiguration(updatePayload);
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data: user } = useQuery({
|
||||||
|
queryKey: ["authUser"],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api) return;
|
||||||
|
const userApi = await getUserApi(api).getCurrentUser();
|
||||||
|
return userApi.data;
|
||||||
|
},
|
||||||
|
enabled: !!api,
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: cultures = [], isFetched: isCulturesFetched } = useQuery({
|
||||||
|
queryKey: ["cultures"],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api) return [];
|
||||||
|
const localizationApi = await getLocalizationApi(api).getCultures();
|
||||||
|
const cultures = localizationApi.data;
|
||||||
|
return cultures;
|
||||||
|
},
|
||||||
|
enabled: !!api,
|
||||||
|
staleTime: 43200000, // 12 hours
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set default settings from user configuration.s
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user || cultures.length === 0) return;
|
||||||
|
const userSubtitlePreference =
|
||||||
|
user?.Configuration?.SubtitleLanguagePreference;
|
||||||
|
const userAudioPreference = user?.Configuration?.AudioLanguagePreference;
|
||||||
|
|
||||||
|
const subtitlePreference = cultures.find(
|
||||||
|
(x) => x.ThreeLetterISOLanguageName === userSubtitlePreference
|
||||||
|
);
|
||||||
|
const audioPreference = cultures.find(
|
||||||
|
(x) => x.ThreeLetterISOLanguageName === userAudioPreference
|
||||||
|
);
|
||||||
|
|
||||||
|
updateSettings({
|
||||||
|
defaultSubtitleLanguage: subtitlePreference,
|
||||||
|
defaultAudioLanguage: audioPreference,
|
||||||
|
subtitleMode: user?.Configuration?.SubtitleMode,
|
||||||
|
playDefaultAudioTrack: user?.Configuration?.PlayDefaultAudioTrack,
|
||||||
|
rememberAudioSelections: user?.Configuration?.RememberAudioSelections,
|
||||||
|
rememberSubtitleSelections:
|
||||||
|
user?.Configuration?.RememberSubtitleSelections,
|
||||||
|
});
|
||||||
|
}, [user, isCulturesFetched]);
|
||||||
|
|
||||||
|
if (!api) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MediaContext.Provider
|
||||||
|
value={{
|
||||||
|
settings,
|
||||||
|
updateSettings: updateSetingsWrapper,
|
||||||
|
user,
|
||||||
|
cultures,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</MediaContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { LANGUAGES } from "@/constants/Languages";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
@@ -15,113 +13,6 @@ export const MediaToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
<View>
|
<View>
|
||||||
<Text className="text-lg font-bold mb-2">Media</Text>
|
<Text className="text-lg font-bold mb-2">Media</Text>
|
||||||
<View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800">
|
<View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800">
|
||||||
<View
|
|
||||||
className={`
|
|
||||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col shrink">
|
|
||||||
<Text className="font-semibold">Audio language</Text>
|
|
||||||
<Text className="text-xs opacity-50">
|
|
||||||
Choose a default audio language.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<DropdownMenu.Root>
|
|
||||||
<DropdownMenu.Trigger>
|
|
||||||
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
|
||||||
<Text>{settings?.defaultAudioLanguage?.label || "None"}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
loop={true}
|
|
||||||
side="bottom"
|
|
||||||
align="start"
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={8}
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Label>Languages</DropdownMenu.Label>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={"none-audio"}
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({
|
|
||||||
defaultAudioLanguage: null,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>None</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
{LANGUAGES.map((l) => (
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={l.value}
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({
|
|
||||||
defaultAudioLanguage: l,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>{l.label}</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
))}
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
className={`
|
|
||||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col shrink">
|
|
||||||
<Text className="font-semibold">Subtitle language</Text>
|
|
||||||
<Text className="text-xs opacity-50">
|
|
||||||
Choose a default subtitle language.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<DropdownMenu.Root>
|
|
||||||
<DropdownMenu.Trigger>
|
|
||||||
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
|
||||||
<Text>
|
|
||||||
{settings?.defaultSubtitleLanguage?.label || "None"}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
loop={true}
|
|
||||||
side="bottom"
|
|
||||||
align="start"
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={8}
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Label>Languages</DropdownMenu.Label>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={"none-subs"}
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({
|
|
||||||
defaultSubtitleLanguage: null,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>None</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
{LANGUAGES.map((l) => (
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={l.value}
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({
|
|
||||||
defaultSubtitleLanguage: l,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>{l.label}</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
))}
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View
|
<View
|
||||||
className={`
|
className={`
|
||||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||||
|
|||||||
@@ -1,82 +1,38 @@
|
|||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import {
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
apiAtom,
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
getOrSetDeviceId,
|
|
||||||
userAtom,
|
|
||||||
} from "@/providers/JellyfinProvider";
|
|
||||||
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
|
|
||||||
import {
|
|
||||||
BACKGROUND_FETCH_TASK,
|
|
||||||
registerBackgroundFetchAsync,
|
|
||||||
unregisterBackgroundFetchAsync,
|
|
||||||
} from "@/utils/background-tasks";
|
|
||||||
import { getStatistics } from "@/utils/optimize-server";
|
|
||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import * as BackgroundFetch from "expo-background-fetch";
|
|
||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
|
||||||
import * as TaskManager from "expo-task-manager";
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useEffect, useState } from "react";
|
import React, { useState } from "react";
|
||||||
import {
|
import {
|
||||||
Linking,
|
Linking,
|
||||||
|
Modal,
|
||||||
Switch,
|
Switch,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
ViewProps,
|
ViewProps,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { toast } from "sonner-native";
|
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
|
||||||
import { Button } from "../Button";
|
import { Button } from "../Button";
|
||||||
import { Input } from "../common/Input";
|
import { Input } from "../common/Input";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { Loader } from "../Loader";
|
import { Loader } from "../Loader";
|
||||||
|
import { AudioToggles } from "./AudioToggles";
|
||||||
|
import { JellyseerrSettings } from "./Jellyseerr";
|
||||||
|
import { MediaProvider } from "./MediaContext";
|
||||||
import { MediaToggles } from "./MediaToggles";
|
import { MediaToggles } from "./MediaToggles";
|
||||||
|
import { SubtitleToggles } from "./SubtitleToggles";
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
||||||
const [settings, updateSettings] = useSettings();
|
const [settings, updateSettings] = useSettings();
|
||||||
const { setProcesses } = useDownload();
|
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
const [marlinUrl, setMarlinUrl] = useState<string>("");
|
const [marlinUrl, setMarlinUrl] = useState<string>("");
|
||||||
const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] =
|
|
||||||
useState<string>(settings?.optimizedVersionsServerUrl || "");
|
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const [isSearchEngineModalVisible, setIsSearchEngineModalVisible] =
|
||||||
/********************
|
useState(false);
|
||||||
* Background task
|
|
||||||
*******************/
|
|
||||||
const checkStatusAsync = async () => {
|
|
||||||
await BackgroundFetch.getStatusAsync();
|
|
||||||
return await TaskManager.isTaskRegisteredAsync(BACKGROUND_FETCH_TASK);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
(async () => {
|
|
||||||
const registered = await checkStatusAsync();
|
|
||||||
|
|
||||||
if (settings?.autoDownload === true && !registered) {
|
|
||||||
registerBackgroundFetchAsync();
|
|
||||||
toast.success("Background downloads enabled");
|
|
||||||
} else if (settings?.autoDownload === false && registered) {
|
|
||||||
unregisterBackgroundFetchAsync();
|
|
||||||
toast.info("Background downloads disabled");
|
|
||||||
} else if (settings?.autoDownload === true && registered) {
|
|
||||||
// Don't to anything
|
|
||||||
} else if (settings?.autoDownload === false && !registered) {
|
|
||||||
// Don't to anything
|
|
||||||
} else {
|
|
||||||
updateSettings({ autoDownload: false });
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, [settings?.autoDownload]);
|
|
||||||
/**********************
|
|
||||||
*********************/
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: mediaListCollections,
|
data: mediaListCollections,
|
||||||
@@ -100,6 +56,13 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
type SearchEngine = "Jellyfin" | "Marlin";
|
||||||
|
|
||||||
|
const searchEngines: Array<{ id: SearchEngine; name: string }> = [
|
||||||
|
{ id: "Jellyfin", name: "Jellyfin" },
|
||||||
|
{ id: "Marlin", name: "Marlin" },
|
||||||
|
];
|
||||||
|
|
||||||
if (!settings) return null;
|
if (!settings) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -119,7 +82,11 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
</View>
|
</View>
|
||||||
</View> */}
|
</View> */}
|
||||||
|
|
||||||
<MediaToggles />
|
<MediaProvider>
|
||||||
|
<MediaToggles />
|
||||||
|
<AudioToggles />
|
||||||
|
<SubtitleToggles />
|
||||||
|
</MediaProvider>
|
||||||
|
|
||||||
<View>
|
<View>
|
||||||
<Text className="text-lg font-bold mb-2">Other</Text>
|
<Text className="text-lg font-bold mb-2">Other</Text>
|
||||||
@@ -139,111 +106,19 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View
|
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||||
pointerEvents={settings.autoRotate ? "none" : "auto"}
|
<View className="shrink">
|
||||||
className={`
|
<Text className="font-semibold">Safe area in controls</Text>
|
||||||
${
|
|
||||||
settings.autoRotate
|
|
||||||
? "opacity-50 pointer-events-none"
|
|
||||||
: "opacity-100"
|
|
||||||
}
|
|
||||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col shrink">
|
|
||||||
<Text className="font-semibold">Video orientation</Text>
|
|
||||||
<Text className="text-xs opacity-50">
|
<Text className="text-xs opacity-50">
|
||||||
Set the full screen video player orientation.
|
Enable safe area in video player controls
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<DropdownMenu.Root>
|
<Switch
|
||||||
<DropdownMenu.Trigger>
|
value={settings.safeAreaInControlsEnabled}
|
||||||
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
onValueChange={(value) =>
|
||||||
<Text>
|
updateSettings({ safeAreaInControlsEnabled: value })
|
||||||
{ScreenOrientationEnum[settings.defaultVideoOrientation]}
|
}
|
||||||
</Text>
|
/>
|
||||||
</TouchableOpacity>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
loop={true}
|
|
||||||
side="bottom"
|
|
||||||
align="start"
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={8}
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Label>Orientation</DropdownMenu.Label>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key="1"
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({
|
|
||||||
defaultVideoOrientation:
|
|
||||||
ScreenOrientation.OrientationLock.DEFAULT,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>
|
|
||||||
{
|
|
||||||
ScreenOrientationEnum[
|
|
||||||
ScreenOrientation.OrientationLock.DEFAULT
|
|
||||||
]
|
|
||||||
}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key="2"
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({
|
|
||||||
defaultVideoOrientation:
|
|
||||||
ScreenOrientation.OrientationLock.PORTRAIT_UP,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>
|
|
||||||
{
|
|
||||||
ScreenOrientationEnum[
|
|
||||||
ScreenOrientation.OrientationLock.PORTRAIT_UP
|
|
||||||
]
|
|
||||||
}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key="3"
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({
|
|
||||||
defaultVideoOrientation:
|
|
||||||
ScreenOrientation.OrientationLock.LANDSCAPE_LEFT,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>
|
|
||||||
{
|
|
||||||
ScreenOrientationEnum[
|
|
||||||
ScreenOrientation.OrientationLock.LANDSCAPE_LEFT
|
|
||||||
]
|
|
||||||
}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key="4"
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({
|
|
||||||
defaultVideoOrientation:
|
|
||||||
ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>
|
|
||||||
{
|
|
||||||
ScreenOrientationEnum[
|
|
||||||
ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT
|
|
||||||
]
|
|
||||||
}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="flex flex-col">
|
<View className="flex flex-col">
|
||||||
@@ -317,54 +192,27 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="flex flex-col">
|
<View className="flex flex-col">
|
||||||
<View
|
<View className="flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4">
|
||||||
className={`
|
|
||||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col shrink">
|
<View className="flex flex-col shrink">
|
||||||
<Text className="font-semibold">Search engine</Text>
|
<Text className="font-semibold">Search engine</Text>
|
||||||
<Text className="text-xs opacity-50">
|
<Text className="text-xs opacity-50">
|
||||||
Choose the search engine you want to use.
|
Choose the search engine you want to use.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<DropdownMenu.Root>
|
<TouchableOpacity
|
||||||
<DropdownMenu.Trigger>
|
className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between"
|
||||||
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
onPress={() => setIsSearchEngineModalVisible(true)}
|
||||||
<Text>{settings.searchEngine}</Text>
|
>
|
||||||
</TouchableOpacity>
|
<Text>{settings.searchEngine}</Text>
|
||||||
</DropdownMenu.Trigger>
|
<Ionicons
|
||||||
<DropdownMenu.Content
|
name="chevron-down"
|
||||||
loop={true}
|
size={16}
|
||||||
side="bottom"
|
color="white"
|
||||||
align="start"
|
style={{ opacity: 0.5, marginLeft: 8 }}
|
||||||
alignOffset={0}
|
/>
|
||||||
avoidCollisions={true}
|
</TouchableOpacity>
|
||||||
collisionPadding={8}
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Label>Profiles</DropdownMenu.Label>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key="1"
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({ searchEngine: "Jellyfin" });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>Jellyfin</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key="2"
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({ searchEngine: "Marlin" });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>Marlin</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{settings.searchEngine === "Marlin" && (
|
{settings.searchEngine === "Marlin" && (
|
||||||
<View className="flex flex-col bg-neutral-900 px-4 pb-4">
|
<View className="flex flex-col bg-neutral-900 px-4 pb-4">
|
||||||
<View className="flex flex-row items-center space-x-2">
|
<View className="flex flex-row items-center space-x-2">
|
||||||
@@ -403,141 +251,58 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className="mt-4">
|
<Modal
|
||||||
<Text className="text-lg font-bold mb-2">Downloads</Text>
|
visible={isSearchEngineModalVisible}
|
||||||
<View className="flex flex-col rounded-xl overflow-hidden divide-y-2 divide-solid divide-neutral-800">
|
transparent
|
||||||
<View
|
animationType="slide"
|
||||||
className={`
|
onRequestClose={() => setIsSearchEngineModalVisible(false)}
|
||||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
|
||||||
`}
|
|
||||||
>
|
>
|
||||||
<View className="flex flex-col shrink">
|
<TouchableOpacity
|
||||||
<Text className="font-semibold">Download method</Text>
|
className="flex-1 bg-black/50"
|
||||||
<Text className="text-xs opacity-50">
|
activeOpacity={1}
|
||||||
Choose the download method to use. Optimized requires the
|
onPress={() => setIsSearchEngineModalVisible(false)}
|
||||||
optimized server.
|
>
|
||||||
</Text>
|
<View className="mt-auto bg-neutral-900 rounded-t-xl">
|
||||||
</View>
|
<View className="p-4 border-b border-neutral-800">
|
||||||
<DropdownMenu.Root>
|
<Text className="text-lg font-bold text-center">
|
||||||
<DropdownMenu.Trigger>
|
Select Search Engine
|
||||||
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
|
||||||
<Text>
|
|
||||||
{settings.downloadMethod === "remux"
|
|
||||||
? "Default"
|
|
||||||
: "Optimized"}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
loop={true}
|
|
||||||
side="bottom"
|
|
||||||
align="start"
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={8}
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Label>Methods</DropdownMenu.Label>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key="1"
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({ downloadMethod: "remux" });
|
|
||||||
setProcesses([]);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>Default</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key="2"
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({ downloadMethod: "optimized" });
|
|
||||||
setProcesses([]);
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>Optimized</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</View>
|
|
||||||
<View className="flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4">
|
|
||||||
<View className="flex flex-col shrink">
|
|
||||||
<Text className="font-semibold">Auto download</Text>
|
|
||||||
<Text className="text-xs opacity-50 shrink">
|
|
||||||
This will automatically download the media file when it's
|
|
||||||
finished optimizing on the server.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Switch
|
|
||||||
value={settings.autoDownload}
|
|
||||||
onValueChange={(value) => updateSettings({ autoDownload: value })}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
pointerEvents={
|
|
||||||
settings.downloadMethod === "optimized" ? "auto" : "none"
|
|
||||||
}
|
|
||||||
className={`
|
|
||||||
${
|
|
||||||
settings.downloadMethod === "optimized"
|
|
||||||
? "opacity-100"
|
|
||||||
: "opacity-50"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col bg-neutral-900 px-4 py-4">
|
|
||||||
<View className="flex flex-col shrink mb-2">
|
|
||||||
<View className="flex flex-row justify-between items-center">
|
|
||||||
<Text className="font-semibold">
|
|
||||||
Optimized versions server
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text className="text-xs opacity-50">
|
|
||||||
Set the URL for the optimized versions server for downloads.
|
<View className="max-h-[50%]">
|
||||||
</Text>
|
{searchEngines.map((engine) => (
|
||||||
</View>
|
<TouchableOpacity
|
||||||
<View></View>
|
key={engine.id}
|
||||||
<View className="flex flex-col">
|
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
|
||||||
<Input
|
onPress={() => {
|
||||||
placeholder="Optimized versions server URL..."
|
updateSettings({
|
||||||
value={optimizedVersionsServerUrl}
|
searchEngine: engine.id,
|
||||||
keyboardType="url"
|
});
|
||||||
returnKeyType="done"
|
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||||
autoCapitalize="none"
|
setIsSearchEngineModalVisible(false);
|
||||||
textContentType="URL"
|
}}
|
||||||
onChangeText={(text) => setOptimizedVersionsServerUrl(text)}
|
>
|
||||||
/>
|
<Text>{engine.name}</Text>
|
||||||
<Button
|
{settings.searchEngine === engine.id && (
|
||||||
color="purple"
|
<Ionicons name="checkmark" size={24} color="white" />
|
||||||
className="h-12 mt-2"
|
)}
|
||||||
onPress={async () => {
|
</TouchableOpacity>
|
||||||
updateSettings({
|
))}
|
||||||
optimizedVersionsServerUrl:
|
</View>
|
||||||
optimizedVersionsServerUrl.length === 0
|
|
||||||
? null
|
<TouchableOpacity
|
||||||
: optimizedVersionsServerUrl.endsWith("/")
|
className="p-4 border-t border-neutral-800"
|
||||||
? optimizedVersionsServerUrl
|
onPress={() => setIsSearchEngineModalVisible(false)}
|
||||||
: optimizedVersionsServerUrl + "/",
|
|
||||||
});
|
|
||||||
const res = await getStatistics({
|
|
||||||
url: settings?.optimizedVersionsServerUrl,
|
|
||||||
authHeader: api?.accessToken,
|
|
||||||
deviceId: await getOrSetDeviceId(),
|
|
||||||
});
|
|
||||||
if (res) {
|
|
||||||
toast.success("Connected");
|
|
||||||
} else toast.error("Could not connect");
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Save
|
<Text className="text-center text-purple-400">Cancel</Text>
|
||||||
</Button>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</TouchableOpacity>
|
||||||
</View>
|
</Modal>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
<JellyseerrSettings />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
252
components/settings/SubtitleToggles.tsx
Normal file
252
components/settings/SubtitleToggles.tsx
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Modal, TouchableOpacity, View, ViewProps } from "react-native";
|
||||||
|
import { Switch } from "react-native-gesture-handler";
|
||||||
|
import { Text } from "../common/Text";
|
||||||
|
import { useMedia } from "./MediaContext";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
|
export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
||||||
|
const media = useMedia();
|
||||||
|
const { settings, updateSettings } = media;
|
||||||
|
const cultures = media.cultures;
|
||||||
|
const [isLanguageModalVisible, setIsLanguageModalVisible] = useState(false);
|
||||||
|
const [isModeModalVisible, setIsModeModalVisible] = useState(false);
|
||||||
|
|
||||||
|
if (!settings) return null;
|
||||||
|
|
||||||
|
const subtitleModes = [
|
||||||
|
SubtitlePlaybackMode.Default,
|
||||||
|
SubtitlePlaybackMode.Smart,
|
||||||
|
SubtitlePlaybackMode.OnlyForced,
|
||||||
|
SubtitlePlaybackMode.Always,
|
||||||
|
SubtitlePlaybackMode.None,
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<Text className="text-lg font-bold mb-2">Subtitle</Text>
|
||||||
|
<View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800">
|
||||||
|
<View className="flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4">
|
||||||
|
<View className="flex flex-col shrink">
|
||||||
|
<Text className="font-semibold">Subtitle language</Text>
|
||||||
|
<Text className="text-xs opacity-50">
|
||||||
|
Choose a default subtitle language.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between"
|
||||||
|
onPress={() => setIsLanguageModalVisible(true)}
|
||||||
|
>
|
||||||
|
<Text>
|
||||||
|
{settings?.defaultSubtitleLanguage?.DisplayName || "None"}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name="chevron-down"
|
||||||
|
size={16}
|
||||||
|
color="white"
|
||||||
|
style={{ opacity: 0.5, marginLeft: 8 }}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4">
|
||||||
|
<View className="flex flex-col shrink">
|
||||||
|
<Text className="font-semibold">Subtitle Mode</Text>
|
||||||
|
<Text className="text-xs opacity-50 mr-2">
|
||||||
|
Subtitles are loaded based on the default and forced flags in the
|
||||||
|
embedded metadata. Language preferences are considered when
|
||||||
|
multiple options are available.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between"
|
||||||
|
onPress={() => setIsModeModalVisible(true)}
|
||||||
|
>
|
||||||
|
<Text>{settings?.subtitleMode || "Loading"}</Text>
|
||||||
|
<Ionicons
|
||||||
|
name="chevron-down"
|
||||||
|
size={16}
|
||||||
|
color="white"
|
||||||
|
style={{ opacity: 0.5, marginLeft: 8 }}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex flex-col">
|
||||||
|
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||||
|
<View className="flex flex-col">
|
||||||
|
<Text className="font-semibold">
|
||||||
|
Set Subtitle Track From Previous Item
|
||||||
|
</Text>
|
||||||
|
<Text className="text-xs opacity-50 min max-w-[85%]">
|
||||||
|
Try to set the subtitle track to the closest match to the last
|
||||||
|
video.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={settings.rememberSubtitleSelections}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateSettings({ rememberSubtitleSelections: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View
|
||||||
|
className={`
|
||||||
|
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<View className="flex flex-col shrink">
|
||||||
|
<Text className="font-semibold">Subtitle Size</Text>
|
||||||
|
<Text className="text-xs opacity-50">
|
||||||
|
Choose a default subtitle size for direct play (only works for
|
||||||
|
some subtitle formats).
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="flex flex-row items-center">
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() =>
|
||||||
|
updateSettings({
|
||||||
|
subtitleSize: Math.max(0, settings.subtitleSize - 5),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<Text>-</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text className="w-12 h-8 bg-neutral-800 first-letter:px-3 py-2 flex items-center justify-center">
|
||||||
|
{settings.subtitleSize}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
className="w-8 h-8 bg-neutral-800 rounded-r-lg flex items-center justify-center"
|
||||||
|
onPress={() =>
|
||||||
|
updateSettings({
|
||||||
|
subtitleSize: Math.min(120, settings.subtitleSize + 5),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text>+</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Modal
|
||||||
|
visible={isLanguageModalVisible}
|
||||||
|
transparent
|
||||||
|
animationType="slide"
|
||||||
|
onRequestClose={() => setIsLanguageModalVisible(false)}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
className="flex-1 bg-black/50"
|
||||||
|
activeOpacity={1}
|
||||||
|
onPress={() => setIsLanguageModalVisible(false)}
|
||||||
|
>
|
||||||
|
<View className="mt-auto bg-neutral-900 rounded-t-xl">
|
||||||
|
<View className="p-4 border-b border-neutral-800">
|
||||||
|
<Text className="text-lg font-bold text-center">
|
||||||
|
Select Language
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="max-h-[50%]">
|
||||||
|
<TouchableOpacity
|
||||||
|
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
|
||||||
|
onPress={() => {
|
||||||
|
updateSettings({
|
||||||
|
defaultSubtitleLanguage: null,
|
||||||
|
});
|
||||||
|
setIsLanguageModalVisible(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text>None</Text>
|
||||||
|
{!settings?.defaultSubtitleLanguage && (
|
||||||
|
<Ionicons name="checkmark" size={24} color="white" />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{cultures?.map((l) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={l?.ThreeLetterISOLanguageName ?? "unknown"}
|
||||||
|
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
|
||||||
|
onPress={() => {
|
||||||
|
updateSettings({
|
||||||
|
defaultSubtitleLanguage: l,
|
||||||
|
});
|
||||||
|
setIsLanguageModalVisible(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text>{l.DisplayName}</Text>
|
||||||
|
{settings?.defaultSubtitleLanguage
|
||||||
|
?.ThreeLetterISOLanguageName ===
|
||||||
|
l.ThreeLetterISOLanguageName && (
|
||||||
|
<Ionicons name="checkmark" size={24} color="white" />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
className="p-4 border-t border-neutral-800"
|
||||||
|
onPress={() => setIsLanguageModalVisible(false)}
|
||||||
|
>
|
||||||
|
<Text className="text-center text-purple-400">Cancel</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Subtitle Mode Selection Modal */}
|
||||||
|
<Modal
|
||||||
|
visible={isModeModalVisible}
|
||||||
|
transparent
|
||||||
|
animationType="slide"
|
||||||
|
onRequestClose={() => setIsModeModalVisible(false)}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
className="flex-1 bg-black/50"
|
||||||
|
activeOpacity={1}
|
||||||
|
onPress={() => setIsModeModalVisible(false)}
|
||||||
|
>
|
||||||
|
<View className="mt-auto bg-neutral-900 rounded-t-xl">
|
||||||
|
<View className="p-4 border-b border-neutral-800">
|
||||||
|
<Text className="text-lg font-bold text-center">
|
||||||
|
Select Subtitle Mode
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="max-h-[50%]">
|
||||||
|
{subtitleModes?.map((mode) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={mode}
|
||||||
|
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
|
||||||
|
onPress={() => {
|
||||||
|
updateSettings({
|
||||||
|
subtitleMode: mode,
|
||||||
|
});
|
||||||
|
setIsModeModalVisible(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text>{mode}</Text>
|
||||||
|
{settings?.subtitleMode === mode && (
|
||||||
|
<Ionicons name="checkmark" size={24} color="white" />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
className="p-4 border-t border-neutral-800"
|
||||||
|
onPress={() => setIsModeModalVisible(false)}
|
||||||
|
>
|
||||||
|
<Text className="text-center text-purple-400">Cancel</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Modal>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,6 +1,15 @@
|
|||||||
|
import { NativeStackNavigationOptions } from "@react-navigation/native-stack";
|
||||||
import { HeaderBackButton } from "../common/HeaderBackButton";
|
import { HeaderBackButton } from "../common/HeaderBackButton";
|
||||||
|
import { ParamListBase, RouteProp } from "@react-navigation/native";
|
||||||
|
|
||||||
const commonScreenOptions = {
|
type ICommonScreenOptions =
|
||||||
|
| NativeStackNavigationOptions
|
||||||
|
| ((prop: {
|
||||||
|
route: RouteProp<ParamListBase, string>;
|
||||||
|
navigation: any;
|
||||||
|
}) => NativeStackNavigationOptions);
|
||||||
|
|
||||||
|
export const commonScreenOptions: ICommonScreenOptions = {
|
||||||
title: "",
|
title: "",
|
||||||
headerShown: true,
|
headerShown: true,
|
||||||
headerTransparent: true,
|
headerTransparent: true,
|
||||||
@@ -17,5 +26,5 @@ const routes = [
|
|||||||
"series/[id]",
|
"series/[id]",
|
||||||
];
|
];
|
||||||
|
|
||||||
export const nestedTabPageScreenOptions: { [key: string]: any } =
|
export const nestedTabPageScreenOptions: Record<string, ICommonScreenOptions> =
|
||||||
Object.fromEntries(routes.map((route) => [route, commonScreenOptions]));
|
Object.fromEntries(routes.map((route) => [route, commonScreenOptions]));
|
||||||
|
|||||||
111
components/video-player/controls/AudioSlider.tsx
Normal file
111
components/video-player/controls/AudioSlider.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import React, { useEffect, useRef } from "react";
|
||||||
|
import { View, StyleSheet } from "react-native";
|
||||||
|
import { useSharedValue } from "react-native-reanimated";
|
||||||
|
import { Slider } from "react-native-awesome-slider";
|
||||||
|
import { VolumeManager } from "react-native-volume-manager";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
|
||||||
|
interface AudioSliderProps {
|
||||||
|
setVisibility: (show: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AudioSlider: React.FC<AudioSliderProps> = ({ setVisibility }) => {
|
||||||
|
const volume = useSharedValue<number>(50); // Explicitly type as number
|
||||||
|
const min = useSharedValue<number>(0); // Explicitly type as number
|
||||||
|
const max = useSharedValue<number>(100); // Explicitly type as number
|
||||||
|
|
||||||
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null); // Use a ref to store the timeout ID
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchInitialVolume = async () => {
|
||||||
|
try {
|
||||||
|
const { volume: initialVolume } = await VolumeManager.getVolume();
|
||||||
|
volume.value = initialVolume * 100;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching initial volume:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchInitialVolume();
|
||||||
|
|
||||||
|
// Disable the native volume UI when the component mounts
|
||||||
|
VolumeManager.showNativeVolumeUI({ enabled: false });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// Re-enable the native volume UI when the component unmounts
|
||||||
|
VolumeManager.showNativeVolumeUI({ enabled: true });
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleValueChange = async (value: number) => {
|
||||||
|
volume.value = value;
|
||||||
|
await VolumeManager.setVolume(value / 100);
|
||||||
|
|
||||||
|
// Re-call showNativeVolumeUI to ensure the setting is applied on iOS
|
||||||
|
VolumeManager.showNativeVolumeUI({ enabled: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const volumeListener = VolumeManager.addVolumeListener((result) => {
|
||||||
|
volume.value = result.volume * 100;
|
||||||
|
setVisibility(true);
|
||||||
|
|
||||||
|
// Clear any existing timeout
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set a new timeout to hide the visibility after 2 seconds
|
||||||
|
timeoutRef.current = setTimeout(() => {
|
||||||
|
setVisibility(false);
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
volumeListener.remove();
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [volume]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.sliderContainer}>
|
||||||
|
<Slider
|
||||||
|
progress={volume}
|
||||||
|
minimumValue={min}
|
||||||
|
maximumValue={max}
|
||||||
|
thumbWidth={0}
|
||||||
|
onValueChange={handleValueChange}
|
||||||
|
containerStyle={{
|
||||||
|
borderRadius: 50,
|
||||||
|
}}
|
||||||
|
theme={{
|
||||||
|
minimumTrackTintColor: "#FDFDFD",
|
||||||
|
maximumTrackTintColor: "#5A5A5A",
|
||||||
|
bubbleBackgroundColor: "transparent", // Hide the value bubble
|
||||||
|
bubbleTextColor: "transparent", // Hide the value text
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Ionicons
|
||||||
|
name="volume-high"
|
||||||
|
size={20}
|
||||||
|
color="#FDFDFD"
|
||||||
|
style={{
|
||||||
|
marginLeft: 8,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
sliderContainer: {
|
||||||
|
width: 150,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default AudioSlider;
|
||||||
67
components/video-player/controls/BrightnessSlider.tsx
Normal file
67
components/video-player/controls/BrightnessSlider.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import React, { useEffect } from "react";
|
||||||
|
import { View, StyleSheet } from "react-native";
|
||||||
|
import { useSharedValue } from "react-native-reanimated";
|
||||||
|
import { Slider } from "react-native-awesome-slider";
|
||||||
|
import * as Brightness from "expo-brightness";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import MaterialCommunityIcons from "@expo/vector-icons/MaterialCommunityIcons";
|
||||||
|
|
||||||
|
const BrightnessSlider = () => {
|
||||||
|
const brightness = useSharedValue(50);
|
||||||
|
const min = useSharedValue(0);
|
||||||
|
const max = useSharedValue(100);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchInitialBrightness = async () => {
|
||||||
|
const initialBrightness = await Brightness.getBrightnessAsync();
|
||||||
|
brightness.value = initialBrightness * 100;
|
||||||
|
};
|
||||||
|
fetchInitialBrightness();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleValueChange = async (value: number) => {
|
||||||
|
brightness.value = value;
|
||||||
|
await Brightness.setBrightnessAsync(value / 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.sliderContainer}>
|
||||||
|
<Slider
|
||||||
|
progress={brightness}
|
||||||
|
minimumValue={min}
|
||||||
|
maximumValue={max}
|
||||||
|
thumbWidth={0}
|
||||||
|
onValueChange={handleValueChange}
|
||||||
|
containerStyle={{
|
||||||
|
borderRadius: 50,
|
||||||
|
}}
|
||||||
|
theme={{
|
||||||
|
minimumTrackTintColor: "#FDFDFD",
|
||||||
|
maximumTrackTintColor: "#5A5A5A",
|
||||||
|
bubbleBackgroundColor: "transparent", // Hide the value bubble
|
||||||
|
bubbleTextColor: "transparent", // Hide the value text
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Ionicons
|
||||||
|
name="sunny"
|
||||||
|
size={20}
|
||||||
|
color="#FDFDFD"
|
||||||
|
style={{
|
||||||
|
marginLeft: 8,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
sliderContainer: {
|
||||||
|
width: 150,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default BrightnessSlider;
|
||||||
File diff suppressed because it is too large
Load Diff
248
components/video-player/controls/EpisodeList.tsx
Normal file
248
components/video-player/controls/EpisodeList.tsx
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import {
|
||||||
|
HorizontalScroll,
|
||||||
|
HorizontalScrollRef,
|
||||||
|
} from "@/components/common/HorrizontalScroll";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import {
|
||||||
|
SeasonDropdown,
|
||||||
|
SeasonIndexState,
|
||||||
|
} from "@/components/series/SeasonDropdown";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
|
import { runtimeTicksToSeconds } from "@/utils/time";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { atom, useAtom } from "jotai";
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { TouchableOpacity, View } from "react-native";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
item: BaseItemDto;
|
||||||
|
close: () => void;
|
||||||
|
goToItem: (itemId: string) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const seasonIndexAtom = atom<SeasonIndexState>({});
|
||||||
|
|
||||||
|
export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
|
||||||
|
const scrollViewRef = useRef<HorizontalScrollRef>(null); // Reference to the HorizontalScroll
|
||||||
|
const scrollToIndex = (index: number) => {
|
||||||
|
scrollViewRef.current?.scrollToIndex(index, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set the initial season index
|
||||||
|
useEffect(() => {
|
||||||
|
if (item.SeriesId) {
|
||||||
|
setSeasonIndexState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[item.SeriesId ?? ""]: item.ParentIndexNumber ?? 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const seasonIndex = seasonIndexState[item.SeriesId ?? ""];
|
||||||
|
const [seriesItem, setSeriesItem] = useState<BaseItemDto | null>(null);
|
||||||
|
|
||||||
|
// This effect fetches the series item data/
|
||||||
|
useEffect(() => {
|
||||||
|
if (item.SeriesId) {
|
||||||
|
getUserItemData({ api, userId: user?.Id, itemId: item.SeriesId }).then(
|
||||||
|
(res) => {
|
||||||
|
setSeriesItem(res);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [item.SeriesId]);
|
||||||
|
|
||||||
|
const { data: seasons } = useQuery({
|
||||||
|
queryKey: ["seasons", item.SeriesId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api || !user?.Id || !item.SeriesId) return [];
|
||||||
|
const response = await api.axiosInstance.get(
|
||||||
|
`${api.basePath}/Shows/${item.SeriesId}/Seasons`,
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
userId: user?.Id,
|
||||||
|
itemId: item.SeriesId,
|
||||||
|
Fields:
|
||||||
|
"ItemCounts,PrimaryImageAspectRatio,CanDelete,MediaSourceCount",
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return response.data.Items;
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id && !!item.SeasonId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedSeasonId: string | null = useMemo(
|
||||||
|
() =>
|
||||||
|
seasons?.find((season: any) => season.IndexNumber === seasonIndex)?.Id,
|
||||||
|
[seasons, seasonIndex]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: episodes, isFetching } = useQuery({
|
||||||
|
queryKey: ["episodes", item.SeriesId, selectedSeasonId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api || !user?.Id || !item.Id || !selectedSeasonId) return [];
|
||||||
|
const res = await getTvShowsApi(api).getEpisodes({
|
||||||
|
seriesId: item.SeriesId || "",
|
||||||
|
userId: user.Id,
|
||||||
|
seasonId: selectedSeasonId || undefined,
|
||||||
|
enableUserData: true,
|
||||||
|
fields: ["MediaSources", "MediaStreams", "Overview"],
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.data.Items;
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id && !!selectedSeasonId,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (item?.Type === "Episode" && item.Id) {
|
||||||
|
const index = episodes?.findIndex((ep) => ep.Id === item.Id);
|
||||||
|
if (index !== undefined && index !== -1) {
|
||||||
|
setTimeout(() => {
|
||||||
|
scrollToIndex(index);
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [episodes, item]);
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
useEffect(() => {
|
||||||
|
for (let e of episodes || []) {
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: ["item", e.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!e.Id) return;
|
||||||
|
const res = await getUserItemData({
|
||||||
|
api,
|
||||||
|
userId: user?.Id,
|
||||||
|
itemId: e.Id,
|
||||||
|
});
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
staleTime: 60 * 5 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [episodes]);
|
||||||
|
|
||||||
|
// Scroll to the current item when episodes are fetched
|
||||||
|
useEffect(() => {
|
||||||
|
if (episodes && scrollViewRef.current) {
|
||||||
|
const currentItemIndex = episodes.findIndex((e) => e.Id === item.Id);
|
||||||
|
if (currentItemIndex !== -1) {
|
||||||
|
scrollViewRef.current.scrollToIndex(currentItemIndex, 16); // Adjust the scroll position based on item width
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [episodes, item.Id]);
|
||||||
|
|
||||||
|
if (!episodes) {
|
||||||
|
return <Loader />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
backgroundColor: "black",
|
||||||
|
height: "100%",
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
justifyContent: "space-between",
|
||||||
|
}}
|
||||||
|
className={`flex flex-row items-center space-x-2 z-10 p-4`}
|
||||||
|
>
|
||||||
|
{seriesItem && (
|
||||||
|
<SeasonDropdown
|
||||||
|
item={seriesItem}
|
||||||
|
seasons={seasons}
|
||||||
|
state={seasonIndexState}
|
||||||
|
onSelect={(season) => {
|
||||||
|
setSeasonIndexState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[item.SeriesId ?? ""]: season.IndexNumber,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={async () => {
|
||||||
|
close();
|
||||||
|
}}
|
||||||
|
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>
|
||||||
|
|
||||||
|
<HorizontalScroll
|
||||||
|
ref={scrollViewRef}
|
||||||
|
data={episodes}
|
||||||
|
extraData={item}
|
||||||
|
renderItem={(_item, idx) => (
|
||||||
|
<View
|
||||||
|
key={_item.Id}
|
||||||
|
style={{}}
|
||||||
|
className={`flex flex-col w-44 ${
|
||||||
|
item.Id !== _item.Id ? "opacity-75" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
goToItem(_item.Id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ContinueWatchingPoster
|
||||||
|
item={_item}
|
||||||
|
useEpisodePoster
|
||||||
|
showPlayButton={_item.Id !== item.Id}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<View className="shrink">
|
||||||
|
<Text
|
||||||
|
numberOfLines={2}
|
||||||
|
style={{
|
||||||
|
lineHeight: 18, // Adjust this value based on your text size
|
||||||
|
height: 36, // lineHeight * 2 for consistent two-line space
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{_item.Name}
|
||||||
|
</Text>
|
||||||
|
<Text numberOfLines={1} className="text-xs text-neutral-475">
|
||||||
|
{`S${_item.ParentIndexNumber?.toString()}:E${_item.IndexNumber?.toString()}`}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-xs text-neutral-500">
|
||||||
|
{runtimeTicksToSeconds(_item.RunTimeTicks)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
numberOfLines={5}
|
||||||
|
className="text-xs text-neutral-500 shrink"
|
||||||
|
>
|
||||||
|
{_item.Overview}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
keyExtractor={(e: BaseItemDto) => e.Id ?? ""}
|
||||||
|
estimatedItemSize={200}
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import React, { useEffect } from "react";
|
||||||
|
import { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import Animated, {
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withTiming,
|
||||||
|
Easing,
|
||||||
|
runOnJS,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
import { Colors } from "@/constants/Colors";
|
||||||
|
|
||||||
|
interface NextEpisodeCountDownButtonProps extends TouchableOpacityProps {
|
||||||
|
onFinish?: () => void;
|
||||||
|
onPress?: () => void;
|
||||||
|
show: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NextEpisodeCountDownButton: React.FC<NextEpisodeCountDownButtonProps> = ({
|
||||||
|
onFinish,
|
||||||
|
onPress,
|
||||||
|
show,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const progress = useSharedValue(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (show) {
|
||||||
|
progress.value = 0;
|
||||||
|
progress.value = withTiming(
|
||||||
|
1,
|
||||||
|
{
|
||||||
|
duration: 10000, // 10 seconds
|
||||||
|
easing: Easing.linear,
|
||||||
|
},
|
||||||
|
(finished) => {
|
||||||
|
if (finished && onFinish) {
|
||||||
|
runOnJS(onFinish)();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [show, onFinish]);
|
||||||
|
|
||||||
|
const animatedStyle = useAnimatedStyle(() => {
|
||||||
|
return {
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: `${progress.value * 100}%`,
|
||||||
|
backgroundColor: Colors.primary,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const handlePress = () => {
|
||||||
|
if (onPress) {
|
||||||
|
onPress();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!show) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
className="w-32 overflow-hidden rounded-md bg-black/60 border border-neutral-900"
|
||||||
|
{...props}
|
||||||
|
onPress={handlePress}
|
||||||
|
>
|
||||||
|
<Animated.View style={animatedStyle} />
|
||||||
|
<View className="px-3 py-3">
|
||||||
|
<Text className="text-center font-bold">Next Episode</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NextEpisodeCountDownButton;
|
||||||
28
components/video-player/controls/SkipButton.tsx
Normal file
28
components/video-player/controls/SkipButton.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { View, TouchableOpacity, Text, ViewProps } from "react-native";
|
||||||
|
|
||||||
|
interface SkipButtonProps extends ViewProps {
|
||||||
|
onPress: () => void;
|
||||||
|
showButton: boolean;
|
||||||
|
buttonText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SkipButton: React.FC<SkipButtonProps> = ({
|
||||||
|
onPress,
|
||||||
|
showButton,
|
||||||
|
buttonText,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<View className={showButton ? "flex" : "hidden"} {...props}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onPress}
|
||||||
|
className="bg-black/60 rounded-md px-3 py-3 border border-neutral-900"
|
||||||
|
>
|
||||||
|
<Text className="text-white font-bold">{buttonText}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SkipButton;
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import { TrackInfo } from '@/modules/vlc-player';
|
import { TrackInfo } from "@/modules/vlc-player";
|
||||||
import { BaseItemDto, MediaSourceInfo } from '@jellyfin/sdk/lib/generated-client';
|
import {
|
||||||
import React, { createContext, useContext, useState, ReactNode } from 'react';
|
BaseItemDto,
|
||||||
|
MediaSourceInfo,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import React, { createContext, useContext, useState, ReactNode } from "react";
|
||||||
|
|
||||||
interface ControlContextProps {
|
interface ControlContextProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -8,7 +11,9 @@ interface ControlContextProps {
|
|||||||
isVideoLoaded: boolean | undefined;
|
isVideoLoaded: boolean | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ControlContext = createContext<ControlContextProps | undefined>(undefined);
|
const ControlContext = createContext<ControlContextProps | undefined>(
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
interface ControlProviderProps {
|
interface ControlProviderProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -17,7 +22,12 @@ interface ControlProviderProps {
|
|||||||
isVideoLoaded: boolean | undefined;
|
isVideoLoaded: boolean | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ControlProvider: React.FC<ControlProviderProps> = ({ children, item, mediaSource, isVideoLoaded }) => {
|
export const ControlProvider: React.FC<ControlProviderProps> = ({
|
||||||
|
children,
|
||||||
|
item,
|
||||||
|
mediaSource,
|
||||||
|
isVideoLoaded,
|
||||||
|
}) => {
|
||||||
return (
|
return (
|
||||||
<ControlContext.Provider value={{ item, mediaSource, isVideoLoaded }}>
|
<ControlContext.Provider value={{ item, mediaSource, isVideoLoaded }}>
|
||||||
{children}
|
{children}
|
||||||
@@ -28,7 +38,7 @@ export const ControlProvider: React.FC<ControlProviderProps> = ({ children, item
|
|||||||
export const useControlContext = () => {
|
export const useControlContext = () => {
|
||||||
const context = useContext(ControlContext);
|
const context = useContext(ControlContext);
|
||||||
if (context === undefined) {
|
if (context === undefined) {
|
||||||
throw new Error('useControlContext must be used within a ControlProvider');
|
throw new Error("useControlContext must be used within a ControlProvider");
|
||||||
}
|
}
|
||||||
return context;
|
return context;
|
||||||
};
|
};
|
||||||
@@ -1,23 +1,26 @@
|
|||||||
import React, { useMemo, useState } from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
import { View, TouchableOpacity } from "react-native";
|
import { View, TouchableOpacity, Modal } from "react-native";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
|
||||||
import { useControlContext } from "../contexts/ControlContext";
|
import { useControlContext } from "../contexts/ControlContext";
|
||||||
import { useVideoContext } from "../contexts/VideoContext";
|
import { useVideoContext } from "../contexts/VideoContext";
|
||||||
import { EmbeddedSubtitle, ExternalSubtitle } from "../types";
|
import { EmbeddedSubtitle, ExternalSubtitle } from "../types";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { router, useLocalSearchParams } from "expo-router";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
|
||||||
interface DropdownViewDirectProps {
|
interface DropdownViewDirectProps {
|
||||||
showControls: boolean;
|
showControls: boolean;
|
||||||
offline?: boolean; // used to disable external subs for downloads
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const DropdownViewDirect: React.FC<DropdownViewDirectProps> = ({
|
const DropdownViewDirect: React.FC<DropdownViewDirectProps> = ({
|
||||||
showControls,
|
showControls,
|
||||||
offline = false,
|
|
||||||
}) => {
|
}) => {
|
||||||
|
const [isMainModalVisible, setIsMainModalVisible] = useState(false);
|
||||||
|
const [activeSubMenu, setActiveSubMenu] = useState<
|
||||||
|
"subtitle" | "audio" | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const ControlContext = useControlContext();
|
const ControlContext = useControlContext();
|
||||||
const mediaSource = ControlContext?.mediaSource;
|
const mediaSource = ControlContext?.mediaSource;
|
||||||
@@ -53,15 +56,11 @@ const DropdownViewDirect: React.FC<DropdownViewDirectProps> = ({
|
|||||||
deliveryUrl: s.DeliveryUrl,
|
deliveryUrl: s.DeliveryUrl,
|
||||||
})) || [];
|
})) || [];
|
||||||
|
|
||||||
// Combine embedded subs with external subs only if not offline
|
return [...embeddedSubs, ...externalSubs] as (
|
||||||
if (!offline) {
|
| EmbeddedSubtitle
|
||||||
return [...embeddedSubs, ...externalSubs] as (
|
| ExternalSubtitle
|
||||||
| EmbeddedSubtitle
|
)[];
|
||||||
| ExternalSubtitle
|
}, [item, isVideoLoaded, subtitleTracks, mediaSource?.MediaStreams]);
|
||||||
)[];
|
|
||||||
}
|
|
||||||
return embeddedSubs as EmbeddedSubtitle[];
|
|
||||||
}, [item, isVideoLoaded, subtitleTracks, mediaSource?.MediaStreams, offline]);
|
|
||||||
|
|
||||||
const { subtitleIndex, audioIndex } = useLocalSearchParams<{
|
const { subtitleIndex, audioIndex } = useLocalSearchParams<{
|
||||||
itemId: string;
|
itemId: string;
|
||||||
@@ -71,110 +70,143 @@ const DropdownViewDirect: React.FC<DropdownViewDirectProps> = ({
|
|||||||
bitrateValue: string;
|
bitrateValue: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const [selectedSubtitleIndex, setSelectedSubtitleIndex] = useState<Number>(
|
const closeAllModals = () => {
|
||||||
parseInt(subtitleIndex)
|
setIsMainModalVisible(false);
|
||||||
);
|
setActiveSubMenu(null);
|
||||||
const [selectedAudioIndex, setSelectedAudioIndex] = useState<Number>(
|
};
|
||||||
parseInt(audioIndex)
|
|
||||||
|
const MenuOption = ({
|
||||||
|
label,
|
||||||
|
onPress,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
onPress: () => void;
|
||||||
|
}) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
|
||||||
|
onPress={onPress}
|
||||||
|
>
|
||||||
|
<Text>{label}</Text>
|
||||||
|
<Ionicons name="chevron-forward" size={20} color="white" />
|
||||||
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<>
|
||||||
style={{
|
<TouchableOpacity
|
||||||
position: "absolute",
|
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
||||||
zIndex: 1000,
|
onPress={() => setIsMainModalVisible(true)}
|
||||||
opacity: showControls ? 1 : 0,
|
>
|
||||||
}}
|
<Ionicons name="ellipsis-horizontal" size={24} color="white" />
|
||||||
className="p-4"
|
</TouchableOpacity>
|
||||||
>
|
|
||||||
<DropdownMenu.Root>
|
<Modal
|
||||||
<DropdownMenu.Trigger>
|
visible={isMainModalVisible}
|
||||||
<TouchableOpacity className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2">
|
transparent
|
||||||
<Ionicons name="ellipsis-horizontal" size={24} color={"white"} />
|
animationType="slide"
|
||||||
</TouchableOpacity>
|
onRequestClose={closeAllModals}
|
||||||
</DropdownMenu.Trigger>
|
>
|
||||||
<DropdownMenu.Content
|
<TouchableOpacity
|
||||||
loop={true}
|
className="flex-1 bg-black/50"
|
||||||
side="bottom"
|
activeOpacity={1}
|
||||||
align="start"
|
onPress={closeAllModals}
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={8}
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
>
|
||||||
<DropdownMenu.Sub>
|
<View className="mt-auto bg-neutral-900 rounded-t-xl">
|
||||||
<DropdownMenu.SubTrigger key="subtitle-trigger">
|
{!activeSubMenu ? (
|
||||||
Subtitle
|
<>
|
||||||
</DropdownMenu.SubTrigger>
|
<View className="p-4 border-b border-neutral-800">
|
||||||
<DropdownMenu.SubContent
|
<Text className="text-lg font-bold text-center">
|
||||||
alignOffset={-10}
|
Settings
|
||||||
avoidCollisions={true}
|
</Text>
|
||||||
collisionPadding={0}
|
</View>
|
||||||
loop={true}
|
<View>
|
||||||
sideOffset={10}
|
<MenuOption
|
||||||
>
|
label="Subtitle"
|
||||||
{allSubtitleTracksForDirectPlay?.map((sub, idx: number) => (
|
onPress={() => setActiveSubMenu("subtitle")}
|
||||||
<DropdownMenu.CheckboxItem
|
/>
|
||||||
key={`subtitle-item-${idx}`}
|
<MenuOption
|
||||||
value={selectedSubtitleIndex === sub.index}
|
label="Audio"
|
||||||
onValueChange={() => {
|
onPress={() => setActiveSubMenu("audio")}
|
||||||
if ("deliveryUrl" in sub && sub.deliveryUrl) {
|
/>
|
||||||
setSubtitleURL &&
|
</View>
|
||||||
setSubtitleURL(
|
</>
|
||||||
api?.basePath + sub.deliveryUrl,
|
) : activeSubMenu === "subtitle" ? (
|
||||||
sub.name
|
<>
|
||||||
);
|
<View className="p-4 border-b border-neutral-800 flex-row items-center">
|
||||||
|
<TouchableOpacity onPress={() => setActiveSubMenu(null)}>
|
||||||
|
<Ionicons name="chevron-back" size={24} color="white" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text className="text-lg font-bold ml-2">Subtitle</Text>
|
||||||
|
</View>
|
||||||
|
<View className="max-h-[50%]">
|
||||||
|
{allSubtitleTracksForDirectPlay?.map((sub, idx) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={`subtitle-${idx}`}
|
||||||
|
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
|
||||||
|
onPress={() => {
|
||||||
|
if ("deliveryUrl" in sub && sub.deliveryUrl) {
|
||||||
|
setSubtitleURL?.(
|
||||||
|
api?.basePath + sub.deliveryUrl,
|
||||||
|
sub.name
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setSubtitleTrack?.(sub.index);
|
||||||
|
}
|
||||||
|
router.setParams({
|
||||||
|
subtitleIndex: sub.index.toString(),
|
||||||
|
});
|
||||||
|
closeAllModals();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text>{sub.name}</Text>
|
||||||
|
{subtitleIndex === sub.index.toString() && (
|
||||||
|
<Ionicons name="checkmark" size={24} color="white" />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<View className="p-4 border-b border-neutral-800 flex-row items-center">
|
||||||
|
<TouchableOpacity onPress={() => setActiveSubMenu(null)}>
|
||||||
|
<Ionicons name="chevron-back" size={24} color="white" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text className="text-lg font-bold ml-2">Audio</Text>
|
||||||
|
</View>
|
||||||
|
<View className="max-h-[50%]">
|
||||||
|
{audioTracks?.map((track, idx) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={`audio-${idx}`}
|
||||||
|
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
|
||||||
|
onPress={() => {
|
||||||
|
setAudioTrack?.(track.index);
|
||||||
|
router.setParams({
|
||||||
|
audioIndex: track.index.toString(),
|
||||||
|
});
|
||||||
|
closeAllModals();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text>{track.name}</Text>
|
||||||
|
{audioIndex === track.index.toString() && (
|
||||||
|
<Ionicons name="checkmark" size={24} color="white" />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
console.log(
|
<TouchableOpacity
|
||||||
"Set external subtitle: ",
|
className="p-4 border-t border-neutral-800"
|
||||||
api?.basePath + sub.deliveryUrl
|
onPress={closeAllModals}
|
||||||
);
|
|
||||||
} 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) => (
|
<Text className="text-center text-purple-400">Cancel</Text>
|
||||||
<DropdownMenu.CheckboxItem
|
</TouchableOpacity>
|
||||||
key={`audio-item-${idx}`}
|
</View>
|
||||||
value={selectedAudioIndex === track.index}
|
</TouchableOpacity>
|
||||||
onValueChange={() => {
|
</Modal>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,25 @@
|
|||||||
import React, { useCallback, useMemo, useState } from "react";
|
import { Text } from "@/components/common/Text";
|
||||||
import { View, TouchableOpacity } from "react-native";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import React, { useCallback, useMemo, useState } from "react";
|
||||||
|
import { Modal, TouchableOpacity, View } from "react-native";
|
||||||
import { useControlContext } from "../contexts/ControlContext";
|
import { useControlContext } from "../contexts/ControlContext";
|
||||||
import { useVideoContext } from "../contexts/VideoContext";
|
import { useVideoContext } from "../contexts/VideoContext";
|
||||||
import { TranscodedSubtitle } from "../types";
|
import { TranscodedSubtitle } from "../types";
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
|
||||||
|
|
||||||
interface DropdownViewProps {
|
interface DropdownViewProps {
|
||||||
showControls: boolean;
|
showControls: boolean;
|
||||||
offline?: boolean; // used to disable external subs for downloads
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const DropdownView: React.FC<DropdownViewProps> = ({
|
const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
|
||||||
showControls,
|
const [isMainModalVisible, setIsMainModalVisible] = useState(false);
|
||||||
offline = false,
|
const [activeSubMenu, setActiveSubMenu] = useState<
|
||||||
}) => {
|
"subtitle" | "audio" | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const ControlContext = useControlContext();
|
const ControlContext = useControlContext();
|
||||||
@@ -37,32 +39,20 @@ const DropdownView: React.FC<DropdownViewProps> = ({
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
// Either its on a text subtitle or its on not on any subtitle therefore it should show all the embedded HLS subtitles.
|
// 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(
|
const isOnTextSubtitle = useMemo(() => {
|
||||||
(x) => x.Index === parseInt(subtitleIndex) && x.IsTextSubtitleStream
|
const res = Boolean(
|
||||||
) || subtitleIndex === "-1";
|
mediaSource?.MediaStreams?.find(
|
||||||
|
(x) => x.Index === parseInt(subtitleIndex) && x.IsTextSubtitleStream
|
||||||
|
) || subtitleIndex === "-1"
|
||||||
|
);
|
||||||
|
return res;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const allSubs =
|
const allSubs =
|
||||||
mediaSource?.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [];
|
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 subtitleHelper = new SubtitleHelper(mediaSource?.MediaStreams ?? []);
|
||||||
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 allSubtitleTracksForTranscodingStream = useMemo(() => {
|
||||||
const disableSubtitle = {
|
const disableSubtitle = {
|
||||||
@@ -78,38 +68,7 @@ const DropdownView: React.FC<DropdownViewProps> = ({
|
|||||||
IsTextSubtitleStream: true,
|
IsTextSubtitleStream: true,
|
||||||
})) || [];
|
})) || [];
|
||||||
|
|
||||||
const imageSubtitles = allSubs
|
const sortedSubtitles = subtitleHelper.getSortedSubtitles(textSubtitles);
|
||||||
.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];
|
return [disableSubtitle, ...sortedSubtitles];
|
||||||
}
|
}
|
||||||
@@ -123,7 +82,7 @@ const DropdownView: React.FC<DropdownViewProps> = ({
|
|||||||
return [disableSubtitle, ...transcodedSubtitle];
|
return [disableSubtitle, ...transcodedSubtitle];
|
||||||
}, [item, isVideoLoaded, subtitleTracks, mediaSource?.MediaStreams]);
|
}, [item, isVideoLoaded, subtitleTracks, mediaSource?.MediaStreams]);
|
||||||
|
|
||||||
const ChangeTranscodingSubtitle = useCallback(
|
const changeToImageBasedSub = useCallback(
|
||||||
(subtitleIndex: number) => {
|
(subtitleIndex: number) => {
|
||||||
const queryParams = new URLSearchParams({
|
const queryParams = new URLSearchParams({
|
||||||
itemId: item.Id ?? "", // Ensure itemId is a string
|
itemId: item.Id ?? "", // Ensure itemId is a string
|
||||||
@@ -145,26 +104,13 @@ const DropdownView: React.FC<DropdownViewProps> = ({
|
|||||||
name: x.DisplayTitle!,
|
name: x.DisplayTitle!,
|
||||||
index: x.Index!,
|
index: x.Index!,
|
||||||
})) || [];
|
})) || [];
|
||||||
const ChangeTranscodingAudio = useCallback(
|
|
||||||
(audioIndex: number, currentSelectedSubtitleIndex: number) => {
|
|
||||||
let newSubtitleIndex: number;
|
|
||||||
|
|
||||||
if (!isOnTextSubtitle) {
|
const ChangeTranscodingAudio = useCallback(
|
||||||
newSubtitleIndex = parseInt(subtitleIndex);
|
(audioIndex: number) => {
|
||||||
} 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({
|
const queryParams = new URLSearchParams({
|
||||||
itemId: item.Id ?? "", // Ensure itemId is a string
|
itemId: item.Id ?? "", // Ensure itemId is a string
|
||||||
audioIndex: audioIndex?.toString() ?? "",
|
audioIndex: audioIndex?.toString() ?? "",
|
||||||
subtitleIndex: newSubtitleIndex?.toString() ?? "",
|
subtitleIndex: subtitleIndex?.toString() ?? "",
|
||||||
mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string
|
mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string
|
||||||
bitrateValue: bitrateValue,
|
bitrateValue: bitrateValue,
|
||||||
}).toString();
|
}).toString();
|
||||||
@@ -172,7 +118,28 @@ const DropdownView: React.FC<DropdownViewProps> = ({
|
|||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
router.replace(`player/transcoding-player?${queryParams}`);
|
router.replace(`player/transcoding-player?${queryParams}`);
|
||||||
},
|
},
|
||||||
[mediaSource]
|
[mediaSource, subtitleIndex, audioIndex]
|
||||||
|
);
|
||||||
|
|
||||||
|
const closeAllModals = () => {
|
||||||
|
setIsMainModalVisible(false);
|
||||||
|
setActiveSubMenu(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const MenuOption = ({
|
||||||
|
label,
|
||||||
|
onPress,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
onPress: () => void;
|
||||||
|
}) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
|
||||||
|
onPress={onPress}
|
||||||
|
>
|
||||||
|
<Text>{label}</Text>
|
||||||
|
<Ionicons name="chevron-forward" size={20} color="white" />
|
||||||
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -184,87 +151,135 @@ const DropdownView: React.FC<DropdownViewProps> = ({
|
|||||||
}}
|
}}
|
||||||
className="p-4"
|
className="p-4"
|
||||||
>
|
>
|
||||||
<DropdownMenu.Root>
|
<TouchableOpacity
|
||||||
<DropdownMenu.Trigger>
|
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
|
||||||
<TouchableOpacity className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2">
|
onPress={() => setIsMainModalVisible(true)}
|
||||||
<Ionicons name="ellipsis-horizontal" size={24} color={"white"} />
|
>
|
||||||
</TouchableOpacity>
|
<Ionicons name="ellipsis-horizontal" size={24} color="white" />
|
||||||
</DropdownMenu.Trigger>
|
</TouchableOpacity>
|
||||||
<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);
|
<Modal
|
||||||
}}
|
visible={isMainModalVisible}
|
||||||
>
|
transparent
|
||||||
<DropdownMenu.ItemTitle key={`subtitle-item-title-${idx}`}>
|
animationType="slide"
|
||||||
{sub.name}
|
onRequestClose={closeAllModals}
|
||||||
</DropdownMenu.ItemTitle>
|
>
|
||||||
</DropdownMenu.CheckboxItem>
|
<TouchableOpacity
|
||||||
)
|
className="flex-1 bg-black/50"
|
||||||
)}
|
activeOpacity={1}
|
||||||
</DropdownMenu.SubContent>
|
onPress={closeAllModals}
|
||||||
</DropdownMenu.Sub>
|
>
|
||||||
<DropdownMenu.Sub>
|
<View className="mt-auto bg-neutral-900 rounded-t-xl">
|
||||||
<DropdownMenu.SubTrigger key="audio-trigger">
|
{!activeSubMenu ? (
|
||||||
Audio
|
<>
|
||||||
</DropdownMenu.SubTrigger>
|
<View className="p-4 border-b border-neutral-800">
|
||||||
<DropdownMenu.SubContent
|
<Text className="text-lg font-bold text-center">
|
||||||
alignOffset={-10}
|
Settings
|
||||||
avoidCollisions={true}
|
</Text>
|
||||||
collisionPadding={0}
|
</View>
|
||||||
loop={true}
|
<View>
|
||||||
sideOffset={10}
|
<MenuOption
|
||||||
|
label="Subtitle"
|
||||||
|
onPress={() => setActiveSubMenu("subtitle")}
|
||||||
|
/>
|
||||||
|
<MenuOption
|
||||||
|
label="Audio"
|
||||||
|
onPress={() => setActiveSubMenu("audio")}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
) : activeSubMenu === "subtitle" ? (
|
||||||
|
<>
|
||||||
|
<View className="p-4 border-b border-neutral-800 flex-row items-center">
|
||||||
|
<TouchableOpacity onPress={() => setActiveSubMenu(null)}>
|
||||||
|
<Ionicons name="chevron-back" size={24} color="white" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text className="text-lg font-bold ml-2">Subtitle</Text>
|
||||||
|
</View>
|
||||||
|
<View className="max-h-[50%]">
|
||||||
|
{allSubtitleTracksForTranscodingStream?.map((sub, idx) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={`subtitle-${idx}`}
|
||||||
|
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
|
||||||
|
onPress={() => {
|
||||||
|
if (
|
||||||
|
subtitleIndex ===
|
||||||
|
(isOnTextSubtitle && sub.IsTextSubtitleStream
|
||||||
|
? subtitleHelper
|
||||||
|
.getSourceSubtitleIndex(sub.index)
|
||||||
|
.toString()
|
||||||
|
: sub?.index.toString())
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
|
router.setParams({
|
||||||
|
subtitleIndex: subtitleHelper
|
||||||
|
.getSourceSubtitleIndex(sub.index)
|
||||||
|
.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sub.IsTextSubtitleStream && isOnTextSubtitle) {
|
||||||
|
setSubtitleTrack && setSubtitleTrack(sub.index);
|
||||||
|
} else {
|
||||||
|
changeToImageBasedSub(sub.index);
|
||||||
|
}
|
||||||
|
closeAllModals();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text>{sub.name}</Text>
|
||||||
|
{subtitleIndex ===
|
||||||
|
(isOnTextSubtitle && sub.IsTextSubtitleStream
|
||||||
|
? subtitleHelper
|
||||||
|
.getSourceSubtitleIndex(sub.index)
|
||||||
|
.toString()
|
||||||
|
: sub?.index.toString()) && (
|
||||||
|
<Ionicons name="checkmark" size={24} color="white" />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<View className="p-4 border-b border-neutral-800 flex-row items-center">
|
||||||
|
<TouchableOpacity onPress={() => setActiveSubMenu(null)}>
|
||||||
|
<Ionicons name="chevron-back" size={24} color="white" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text className="text-lg font-bold ml-2">Audio</Text>
|
||||||
|
</View>
|
||||||
|
<View className="max-h-[50%]">
|
||||||
|
{allAudio?.map((track, idx) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={`audio-${idx}`}
|
||||||
|
className="p-4 border-b border-neutral-800 flex-row items-center justify-between"
|
||||||
|
onPress={() => {
|
||||||
|
if (audioIndex === track.index.toString()) return;
|
||||||
|
router.setParams({
|
||||||
|
audioIndex: track.index.toString(),
|
||||||
|
});
|
||||||
|
ChangeTranscodingAudio(track.index);
|
||||||
|
closeAllModals();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text>{track.name}</Text>
|
||||||
|
{audioIndex === track.index.toString() && (
|
||||||
|
<Ionicons name="checkmark" size={24} color="white" />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
className="p-4 border-t border-neutral-800"
|
||||||
|
onPress={closeAllModals}
|
||||||
>
|
>
|
||||||
{allAudio?.map((track, idx: number) => (
|
<Text className="text-center text-purple-400">Cancel</Text>
|
||||||
<DropdownMenu.CheckboxItem
|
</TouchableOpacity>
|
||||||
key={`audio-item-${idx}`}
|
</View>
|
||||||
value={selectedAudioIndex === track.index}
|
</TouchableOpacity>
|
||||||
onValueChange={() => {
|
</Modal>
|
||||||
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>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
4
eas.json
4
eas.json
@@ -22,13 +22,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"channel": "0.21.0",
|
"channel": "0.23.0",
|
||||||
"android": {
|
"android": {
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production-apk": {
|
"production-apk": {
|
||||||
"channel": "0.21.0",
|
"channel": "0.23.0",
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import index from "@/app/(auth)/(tabs)/(home)";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api/items-api";
|
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useMemo } from "react";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
|
|
||||||
interface AdjacentEpisodesProps {
|
interface AdjacentEpisodesProps {
|
||||||
@@ -12,81 +12,53 @@ interface AdjacentEpisodesProps {
|
|||||||
export const useAdjacentItems = ({ item }: AdjacentEpisodesProps) => {
|
export const useAdjacentItems = ({ item }: AdjacentEpisodesProps) => {
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
|
|
||||||
const { data: previousItem } = useQuery({
|
const { data: adjacentItems } = useQuery({
|
||||||
queryKey: ["previousItem", item?.Id, item?.ParentId, item?.IndexNumber],
|
queryKey: ["adjacentItems", item?.Id, item?.SeriesId],
|
||||||
queryFn: async (): Promise<BaseItemDto | null> => {
|
queryFn: async (): Promise<BaseItemDto[] | null> => {
|
||||||
const parentId = item?.AlbumId || item?.ParentId;
|
if (!api || !item || !item.SeriesId) {
|
||||||
const indexNumber = item?.IndexNumber;
|
|
||||||
|
|
||||||
if (
|
|
||||||
!api ||
|
|
||||||
!parentId ||
|
|
||||||
indexNumber === undefined ||
|
|
||||||
indexNumber === null ||
|
|
||||||
indexNumber - 1 < 1
|
|
||||||
) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newIndexNumber = indexNumber - 2;
|
const res = await getTvShowsApi(api).getEpisodes({
|
||||||
|
seriesId: item.SeriesId,
|
||||||
const res = await getItemsApi(api).getItems({
|
adjacentTo: item.Id,
|
||||||
parentId: parentId!,
|
limit: 3,
|
||||||
startIndex: newIndexNumber,
|
|
||||||
limit: 1,
|
|
||||||
sortBy: ["IndexNumber"],
|
|
||||||
includeItemTypes: ["Episode", "Audio"],
|
|
||||||
fields: ["MediaSources", "MediaStreams", "ParentId"],
|
fields: ["MediaSources", "MediaStreams", "ParentId"],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.data.Items?.[0]?.IndexNumber !== indexNumber - 1) {
|
return res.data.Items || null;
|
||||||
throw new Error("Previous item is not correct");
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.data.Items?.[0] || null;
|
|
||||||
},
|
},
|
||||||
enabled: item?.Type === "Episode" || item?.Type === "Audio",
|
enabled:
|
||||||
|
!!api &&
|
||||||
|
!!item?.Id &&
|
||||||
|
!!item?.SeriesId &&
|
||||||
|
(item?.Type === "Episode" || item?.Type === "Audio"),
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: nextItem } = useQuery({
|
const previousItem = useMemo(() => {
|
||||||
queryKey: ["nextItem", item?.Id, item?.ParentId, item?.IndexNumber],
|
if (!adjacentItems || adjacentItems.length <= 1) {
|
||||||
queryFn: async (): Promise<BaseItemDto | null> => {
|
return null;
|
||||||
const parentId = item?.AlbumId || item?.ParentId;
|
}
|
||||||
const indexNumber = item?.IndexNumber;
|
|
||||||
|
|
||||||
if (
|
if (adjacentItems.length === 2) {
|
||||||
!api ||
|
return adjacentItems[0].Id === item?.Id ? null : adjacentItems[0];
|
||||||
!parentId ||
|
}
|
||||||
indexNumber === undefined ||
|
|
||||||
indexNumber === null
|
|
||||||
) {
|
|
||||||
console.log("No next item", {
|
|
||||||
itemId: item?.Id,
|
|
||||||
parentId: parentId,
|
|
||||||
indexNumber: indexNumber,
|
|
||||||
});
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await getItemsApi(api).getItems({
|
return adjacentItems[0];
|
||||||
parentId: parentId!,
|
}, [adjacentItems, item]);
|
||||||
startIndex: indexNumber,
|
|
||||||
sortBy: ["IndexNumber"],
|
|
||||||
limit: 1,
|
|
||||||
includeItemTypes: ["Episode", "Audio"],
|
|
||||||
fields: ["MediaSources", "MediaStreams", "ParentId"],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.data.Items?.[0]?.IndexNumber !== indexNumber + 1) {
|
const nextItem = useMemo(() => {
|
||||||
throw new Error("Previous item is not correct");
|
if (!adjacentItems || adjacentItems.length <= 1) {
|
||||||
}
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return res.data.Items?.[0] || null;
|
if (adjacentItems.length === 2) {
|
||||||
},
|
return adjacentItems[1].Id === item?.Id ? null : adjacentItems[1];
|
||||||
enabled: item?.Type === "Episode" || item?.Type === "Audio",
|
}
|
||||||
staleTime: 0,
|
|
||||||
});
|
return adjacentItems[2];
|
||||||
|
}, [adjacentItems, item]);
|
||||||
|
|
||||||
return { previousItem, nextItem };
|
return { previousItem, nextItem };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
} from "@jellyfin/sdk/lib/generated-client";
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
// Used only for intial play settings.
|
||||||
const useDefaultPlaySettings = (
|
const useDefaultPlaySettings = (
|
||||||
item: BaseItemDto,
|
item: BaseItemDto,
|
||||||
settings: Settings | null
|
settings: Settings | null
|
||||||
@@ -17,18 +18,14 @@ const useDefaultPlaySettings = (
|
|||||||
// 2. Get default or preferred audio
|
// 2. Get default or preferred audio
|
||||||
const defaultAudioIndex = mediaSource?.DefaultAudioStreamIndex;
|
const defaultAudioIndex = mediaSource?.DefaultAudioStreamIndex;
|
||||||
const preferedAudioIndex = mediaSource?.MediaStreams?.find(
|
const preferedAudioIndex = mediaSource?.MediaStreams?.find(
|
||||||
(x) => x.Language === settings?.defaultAudioLanguage
|
(x) =>
|
||||||
)?.Index;
|
x.Type === "Audio" &&
|
||||||
const firstAudioIndex = mediaSource?.MediaStreams?.find(
|
x.Language ===
|
||||||
(x) => x.Type === "Audio"
|
settings?.defaultAudioLanguage?.ThreeLetterISOLanguageName
|
||||||
)?.Index;
|
)?.Index;
|
||||||
|
|
||||||
// 3. Get default or preferred subtitle
|
const firstAudioIndex = mediaSource?.MediaStreams?.find(
|
||||||
const preferedSubtitleIndex = mediaSource?.MediaStreams?.find(
|
(x) => x.Type === "Audio"
|
||||||
(x) => x.Language === settings?.defaultSubtitleLanguage?.value
|
|
||||||
)?.Index;
|
|
||||||
const defaultSubtitleIndex = mediaSource?.MediaStreams?.find(
|
|
||||||
(stream) => stream.Type === "Subtitle" && stream.IsDefault
|
|
||||||
)?.Index;
|
)?.Index;
|
||||||
|
|
||||||
// 4. Get default bitrate
|
// 4. Get default bitrate
|
||||||
@@ -37,8 +34,7 @@ const useDefaultPlaySettings = (
|
|||||||
return {
|
return {
|
||||||
defaultAudioIndex:
|
defaultAudioIndex:
|
||||||
preferedAudioIndex || defaultAudioIndex || firstAudioIndex || undefined,
|
preferedAudioIndex || defaultAudioIndex || firstAudioIndex || undefined,
|
||||||
defaultSubtitleIndex:
|
defaultSubtitleIndex: mediaSource?.DefaultSubtitleStreamIndex || -1,
|
||||||
preferedSubtitleIndex || defaultSubtitleIndex || undefined,
|
|
||||||
defaultMediaSource: mediaSource || undefined,
|
defaultMediaSource: mediaSource || undefined,
|
||||||
defaultBitrate: bitrate || undefined,
|
defaultBitrate: bitrate || undefined,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
|
|
||||||
import { writeToLog } from "@/utils/log";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import * as FileSystem from "expo-file-system";
|
|
||||||
import { useRouter } from "expo-router";
|
|
||||||
import { useCallback } from "react";
|
|
||||||
|
|
||||||
export const getDownloadedFileUrl = async (itemId: string): Promise<string> => {
|
|
||||||
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 { setPlayUrl, setOfflineSettings } = usePlaySettings();
|
|
||||||
|
|
||||||
const openFile = useCallback(
|
|
||||||
async (item: BaseItemDto) => {
|
|
||||||
try {
|
|
||||||
// @ts-expect-error
|
|
||||||
router.push("/player/direct-player?offline=true&itemId=" + item.Id);
|
|
||||||
} catch (error) {
|
|
||||||
writeToLog("ERROR", "Error opening file", error);
|
|
||||||
console.error("Error opening file:", error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[setOfflineSettings, setPlayUrl, router]
|
|
||||||
);
|
|
||||||
|
|
||||||
return { openFile };
|
|
||||||
};
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import {
|
|
||||||
adjustToNearBlack,
|
|
||||||
calculateTextColor,
|
|
||||||
isCloseToBlack,
|
|
||||||
itemThemeColorAtom,
|
|
||||||
} from "@/utils/atoms/primaryColor";
|
|
||||||
import { getItemImage } from "@/utils/getItemImage";
|
|
||||||
import { storage } from "@/utils/mmkv";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
|
||||||
import { useEffect, useMemo } from "react";
|
|
||||||
import { getColors } from "react-native-image-colors";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom hook to extract and manage image colors for a given item.
|
|
||||||
*
|
|
||||||
* @param item - The BaseItemDto object representing the item.
|
|
||||||
* @param disabled - A boolean flag to disable color extraction.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
export const useImageColors = ({
|
|
||||||
item,
|
|
||||||
url,
|
|
||||||
disabled,
|
|
||||||
}: {
|
|
||||||
item?: BaseItemDto | null;
|
|
||||||
url?: string | null;
|
|
||||||
disabled?: boolean;
|
|
||||||
}) => {
|
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const [, setPrimaryColor] = useAtom(itemThemeColorAtom);
|
|
||||||
|
|
||||||
const source = useMemo(() => {
|
|
||||||
if (!api) return;
|
|
||||||
if (url) return { uri: url };
|
|
||||||
else if (item)
|
|
||||||
return getItemImage({
|
|
||||||
item,
|
|
||||||
api,
|
|
||||||
variant: "Primary",
|
|
||||||
quality: 80,
|
|
||||||
width: 300,
|
|
||||||
});
|
|
||||||
else return null;
|
|
||||||
}, [api, item]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (disabled) return;
|
|
||||||
if (source?.uri) {
|
|
||||||
// Check if colors are already cached in storage
|
|
||||||
const _primary = storage.getString(`${source.uri}-primary`);
|
|
||||||
const _text = storage.getString(`${source.uri}-text`);
|
|
||||||
|
|
||||||
// If colors are cached, use them and exit
|
|
||||||
if (_primary && _text) {
|
|
||||||
setPrimaryColor({
|
|
||||||
primary: _primary,
|
|
||||||
text: _text,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract colors from the image
|
|
||||||
getColors(source.uri, {
|
|
||||||
fallback: "#fff",
|
|
||||||
cache: false,
|
|
||||||
})
|
|
||||||
.then((colors) => {
|
|
||||||
let primary: string = "#fff";
|
|
||||||
let text: string = "#000";
|
|
||||||
let backup: string = "#fff";
|
|
||||||
|
|
||||||
// Select the appropriate color based on the platform
|
|
||||||
if (colors.platform === "android") {
|
|
||||||
primary = colors.dominant;
|
|
||||||
backup = colors.vibrant;
|
|
||||||
} else if (colors.platform === "ios") {
|
|
||||||
primary = colors.detail;
|
|
||||||
backup = colors.primary;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Adjust the primary color if it's too close to black
|
|
||||||
if (primary && isCloseToBlack(primary)) {
|
|
||||||
if (backup && !isCloseToBlack(backup)) primary = backup;
|
|
||||||
primary = adjustToNearBlack(primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate the text color based on the primary color
|
|
||||||
if (primary) text = calculateTextColor(primary);
|
|
||||||
|
|
||||||
setPrimaryColor({
|
|
||||||
primary,
|
|
||||||
text,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Cache the colors in storage
|
|
||||||
if (source.uri && primary) {
|
|
||||||
storage.set(`${source.uri}-primary`, primary);
|
|
||||||
storage.set(`${source.uri}-text`, text);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("Error getting colors", error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [source?.uri, setPrimaryColor, disabled]);
|
|
||||||
};
|
|
||||||
@@ -75,7 +75,6 @@ export const useIntroSkipper = (
|
|||||||
}, [introTimestamps, currentTime]);
|
}, [introTimestamps, currentTime]);
|
||||||
|
|
||||||
const skipIntro = useCallback(() => {
|
const skipIntro = useCallback(() => {
|
||||||
console.log("skipIntro");
|
|
||||||
if (!introTimestamps) return;
|
if (!introTimestamps) return;
|
||||||
try {
|
try {
|
||||||
wrappedSeek(introTimestamps.IntroEnd);
|
wrappedSeek(introTimestamps.IntroEnd);
|
||||||
|
|||||||
378
hooks/useJellyseerr.ts
Normal file
378
hooks/useJellyseerr.ts
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
import axios, { AxiosError, AxiosInstance } from "axios";
|
||||||
|
import { Results } from "@/utils/jellyseerr/server/models/Search";
|
||||||
|
import { storage } from "@/utils/mmkv";
|
||||||
|
import { inRange } from "lodash";
|
||||||
|
import { User as JellyseerrUser } from "@/utils/jellyseerr/server/entity/User";
|
||||||
|
import { atom } from "jotai";
|
||||||
|
import { useAtom } from "jotai/index";
|
||||||
|
import "@/augmentations";
|
||||||
|
import { useCallback, useMemo } from "react";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
import {
|
||||||
|
MediaRequestStatus,
|
||||||
|
MediaType,
|
||||||
|
} from "@/utils/jellyseerr/server/constants/media";
|
||||||
|
import MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
|
||||||
|
import { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
||||||
|
import { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||||
|
import {
|
||||||
|
SeasonWithEpisodes,
|
||||||
|
TvDetails,
|
||||||
|
} from "@/utils/jellyseerr/server/models/Tv";
|
||||||
|
import {
|
||||||
|
IssueStatus,
|
||||||
|
IssueType,
|
||||||
|
} from "@/utils/jellyseerr/server/constants/issue";
|
||||||
|
import Issue from "@/utils/jellyseerr/server/entity/Issue";
|
||||||
|
import { RTRating } from "@/utils/jellyseerr/server/api/rating/rottentomatoes";
|
||||||
|
import { writeErrorLog } from "@/utils/log";
|
||||||
|
import DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
|
||||||
|
|
||||||
|
interface SearchParams {
|
||||||
|
query: string;
|
||||||
|
page: number;
|
||||||
|
language: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchResults {
|
||||||
|
page: number;
|
||||||
|
totalPages: number;
|
||||||
|
totalResults: number;
|
||||||
|
results: Results[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const JELLYSEERR_USER = "JELLYSEERR_USER";
|
||||||
|
const JELLYSEERR_COOKIES = "JELLYSEERR_COOKIES";
|
||||||
|
|
||||||
|
export const clearJellyseerrStorageData = () => {
|
||||||
|
storage.delete(JELLYSEERR_USER);
|
||||||
|
storage.delete(JELLYSEERR_COOKIES);
|
||||||
|
};
|
||||||
|
|
||||||
|
export enum Endpoints {
|
||||||
|
STATUS = "/status",
|
||||||
|
API_V1 = "/api/v1",
|
||||||
|
SEARCH = "/search",
|
||||||
|
REQUEST = "/request",
|
||||||
|
MOVIE = "/movie",
|
||||||
|
RATINGS = "/ratings",
|
||||||
|
ISSUE = "/issue",
|
||||||
|
TV = "/tv",
|
||||||
|
SETTINGS = "/settings",
|
||||||
|
DISCOVER = "/discover",
|
||||||
|
DISCOVER_TRENDING = DISCOVER + "/trending",
|
||||||
|
DISCOVER_MOVIES = DISCOVER + "/movies",
|
||||||
|
DISCOVER_TV = DISCOVER + TV,
|
||||||
|
AUTH_JELLYFIN = "/auth/jellyfin",
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DiscoverEndpoint =
|
||||||
|
| Endpoints.DISCOVER_TRENDING
|
||||||
|
| Endpoints.DISCOVER_MOVIES
|
||||||
|
| Endpoints.DISCOVER_TV;
|
||||||
|
|
||||||
|
export type TestResult =
|
||||||
|
| {
|
||||||
|
isValid: true;
|
||||||
|
requiresPass: boolean;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
isValid: false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class JellyseerrApi {
|
||||||
|
axios: AxiosInstance;
|
||||||
|
|
||||||
|
constructor(baseUrl: string) {
|
||||||
|
this.axios = axios.create({
|
||||||
|
baseURL: baseUrl,
|
||||||
|
withCredentials: true,
|
||||||
|
withXSRFToken: true,
|
||||||
|
xsrfHeaderName: "XSRF-TOKEN",
|
||||||
|
});
|
||||||
|
|
||||||
|
this.setInterceptors();
|
||||||
|
}
|
||||||
|
|
||||||
|
async test(): Promise<TestResult> {
|
||||||
|
const user = storage.get<JellyseerrUser>(JELLYSEERR_USER);
|
||||||
|
const cookies = storage.get<string[]>(JELLYSEERR_COOKIES);
|
||||||
|
|
||||||
|
if (user && cookies) {
|
||||||
|
return Promise.resolve({
|
||||||
|
isValid: true,
|
||||||
|
requiresPass: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.axios
|
||||||
|
.get(Endpoints.API_V1 + Endpoints.STATUS)
|
||||||
|
.then((response) => {
|
||||||
|
const { status, headers, data } = response;
|
||||||
|
if (inRange(status, 200, 299)) {
|
||||||
|
if (data.version < "2.0.0") {
|
||||||
|
const error =
|
||||||
|
"Jellyseerr server does not meet minimum version requirements! Please update to at least 2.0.0";
|
||||||
|
toast.error(error);
|
||||||
|
throw Error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
storage.setAny(
|
||||||
|
JELLYSEERR_COOKIES,
|
||||||
|
headers["set-cookie"]?.flatMap((c) => c.split("; ")) ?? []
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
isValid: true,
|
||||||
|
requiresPass: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
toast.error(`Jellyseerr test failed. Please try again.`);
|
||||||
|
writeErrorLog(
|
||||||
|
`Jellyseerr returned a ${status} for url:\n` +
|
||||||
|
response.config.url +
|
||||||
|
"\n" +
|
||||||
|
JSON.stringify(response.data)
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
requiresPass: false,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
const msg = "Failed to test jellyseerr server url";
|
||||||
|
toast.error(msg);
|
||||||
|
console.error(msg, e);
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
requiresPass: false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(username: string, password: string): Promise<JellyseerrUser> {
|
||||||
|
return this.axios
|
||||||
|
?.post<JellyseerrUser>(Endpoints.API_V1 + Endpoints.AUTH_JELLYFIN, {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
email: username,
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
const user = response?.data;
|
||||||
|
if (!user) throw Error("Login failed");
|
||||||
|
storage.setAny(JELLYSEERR_USER, user);
|
||||||
|
return user;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async discoverSettings(): Promise<DiscoverSlider[]> {
|
||||||
|
return this.axios
|
||||||
|
?.get<DiscoverSlider[]>(
|
||||||
|
Endpoints.API_V1 + Endpoints.SETTINGS + Endpoints.DISCOVER
|
||||||
|
)
|
||||||
|
.then(({ data }) => data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async discover(
|
||||||
|
endpoint: DiscoverEndpoint,
|
||||||
|
params: any
|
||||||
|
): Promise<SearchResults> {
|
||||||
|
return this.axios
|
||||||
|
?.get<SearchResults>(Endpoints.API_V1 + endpoint, { params })
|
||||||
|
.then(({ data }) => data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async search(params: SearchParams): Promise<SearchResults> {
|
||||||
|
const response = await this.axios?.get<SearchResults>(
|
||||||
|
Endpoints.API_V1 + Endpoints.SEARCH,
|
||||||
|
{ params }
|
||||||
|
);
|
||||||
|
return response?.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async request(request: MediaRequestBody): Promise<MediaRequest> {
|
||||||
|
return this.axios
|
||||||
|
?.post<MediaRequest>(Endpoints.API_V1 + Endpoints.REQUEST, request)
|
||||||
|
.then(({ data }) => data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async movieDetails(id: number) {
|
||||||
|
return this.axios
|
||||||
|
?.get<MovieDetails>(Endpoints.API_V1 + Endpoints.MOVIE + `/${id}`)
|
||||||
|
.then((response) => {
|
||||||
|
return response?.data;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async movieRatings(id: number) {
|
||||||
|
return this.axios
|
||||||
|
?.get<RTRating>(
|
||||||
|
`${Endpoints.API_V1}${Endpoints.MOVIE}/${id}${Endpoints.RATINGS}`
|
||||||
|
)
|
||||||
|
.then(({ data }) => data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async tvDetails(id: number) {
|
||||||
|
return this.axios
|
||||||
|
?.get<TvDetails>(`${Endpoints.API_V1}${Endpoints.TV}/${id}`)
|
||||||
|
.then((response) => {
|
||||||
|
return response?.data;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async tvRatings(id: number) {
|
||||||
|
return this.axios
|
||||||
|
?.get<RTRating>(
|
||||||
|
`${Endpoints.API_V1}${Endpoints.TV}/${id}${Endpoints.RATINGS}`
|
||||||
|
)
|
||||||
|
.then(({ data }) => data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async tvSeason(id: number, seasonId: number) {
|
||||||
|
return this.axios
|
||||||
|
?.get<SeasonWithEpisodes>(
|
||||||
|
`${Endpoints.API_V1}${Endpoints.TV}/${id}/season/${seasonId}`
|
||||||
|
)
|
||||||
|
.then((response) => {
|
||||||
|
return response?.data;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
tvStillImageProxy(path: string, width: number = 1920, quality: number = 75) {
|
||||||
|
return (
|
||||||
|
this.axios.defaults.baseURL +
|
||||||
|
`/_next/image?` +
|
||||||
|
new URLSearchParams(
|
||||||
|
`url=https://image.tmdb.org/t/p/original/${path}&w=${width}&q=${quality}`
|
||||||
|
).toString()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitIssue(mediaId: number, issueType: IssueType, message: string) {
|
||||||
|
return this.axios
|
||||||
|
?.post<Issue>(Endpoints.API_V1 + Endpoints.ISSUE, {
|
||||||
|
mediaId,
|
||||||
|
issueType,
|
||||||
|
message,
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
const issue = response.data;
|
||||||
|
|
||||||
|
if (issue.status === IssueStatus.OPEN) {
|
||||||
|
toast.success("Issue submitted!");
|
||||||
|
}
|
||||||
|
return issue;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private setInterceptors() {
|
||||||
|
this.axios.interceptors.response.use(
|
||||||
|
async (response) => {
|
||||||
|
const cookies = response.headers["set-cookie"];
|
||||||
|
if (cookies) {
|
||||||
|
storage.setAny(
|
||||||
|
JELLYSEERR_COOKIES,
|
||||||
|
response.headers["set-cookie"]?.flatMap((c) => c.split("; "))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
(error: AxiosError) => {
|
||||||
|
const errorMsg = "Jellyseerr response error";
|
||||||
|
console.error(errorMsg, error, error.response?.data);
|
||||||
|
writeErrorLog(
|
||||||
|
errorMsg +
|
||||||
|
`\n` +
|
||||||
|
`error: ${error.toString()}\n` +
|
||||||
|
`url: ${error?.config?.url}\n` +
|
||||||
|
`data:\n` +
|
||||||
|
JSON.stringify(error.response?.data)
|
||||||
|
);
|
||||||
|
if (error.status === 403) {
|
||||||
|
clearJellyseerrStorageData();
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
this.axios.interceptors.request.use(
|
||||||
|
async (config) => {
|
||||||
|
const cookies = storage.get<string[]>(JELLYSEERR_COOKIES);
|
||||||
|
if (cookies) {
|
||||||
|
const headerName = this.axios.defaults.xsrfHeaderName!!;
|
||||||
|
const xsrfToken = cookies
|
||||||
|
.find((c) => c.includes(headerName))
|
||||||
|
?.split(headerName + "=")?.[1];
|
||||||
|
if (xsrfToken) {
|
||||||
|
config.headers[headerName] = xsrfToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
console.error("Jellyseerr request error", error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const jellyseerrUserAtom = atom(storage.get<JellyseerrUser>(JELLYSEERR_USER));
|
||||||
|
|
||||||
|
export const useJellyseerr = () => {
|
||||||
|
const [jellyseerrUser, setJellyseerrUser] = useAtom(jellyseerrUserAtom);
|
||||||
|
const [settings, updateSettings] = useSettings();
|
||||||
|
|
||||||
|
const jellyseerrApi = useMemo(() => {
|
||||||
|
const cookies = storage.get<string[]>(JELLYSEERR_COOKIES);
|
||||||
|
if (settings?.jellyseerrServerUrl && cookies && jellyseerrUser) {
|
||||||
|
return new JellyseerrApi(settings?.jellyseerrServerUrl);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, [settings?.jellyseerrServerUrl, jellyseerrUser]);
|
||||||
|
|
||||||
|
const clearAllJellyseerData = useCallback(async () => {
|
||||||
|
clearJellyseerrStorageData();
|
||||||
|
setJellyseerrUser(undefined);
|
||||||
|
updateSettings({ jellyseerrServerUrl: undefined });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const requestMedia = useCallback(
|
||||||
|
(title: string, request: MediaRequestBody) => {
|
||||||
|
jellyseerrApi?.request?.(request)?.then((mediaRequest) => {
|
||||||
|
switch (mediaRequest.status) {
|
||||||
|
case MediaRequestStatus.PENDING:
|
||||||
|
case MediaRequestStatus.APPROVED:
|
||||||
|
toast.success(`Requested ${title}!`);
|
||||||
|
break;
|
||||||
|
case MediaRequestStatus.DECLINED:
|
||||||
|
toast.error(`You don't have permission to request!`);
|
||||||
|
break;
|
||||||
|
case MediaRequestStatus.FAILED:
|
||||||
|
toast.error(`Something went wrong requesting media!`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[jellyseerrApi]
|
||||||
|
);
|
||||||
|
|
||||||
|
const isJellyseerrResult = (
|
||||||
|
items: any[] | null | undefined
|
||||||
|
): items is Results[] => {
|
||||||
|
return (
|
||||||
|
!items ||
|
||||||
|
(items.length >= 0 &&
|
||||||
|
Object.hasOwn(items[0], "mediaType") &&
|
||||||
|
Object.values(MediaType).includes(items[0]["mediaType"]))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
jellyseerrApi,
|
||||||
|
jellyseerrUser,
|
||||||
|
setJellyseerrUser,
|
||||||
|
clearAllJellyseerData,
|
||||||
|
isJellyseerrResult,
|
||||||
|
requestMedia,
|
||||||
|
};
|
||||||
|
};
|
||||||
85
hooks/useMarkAsPlayed.ts
Normal file
85
hooks/useMarkAsPlayed.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { markAsNotPlayed } from "@/utils/jellyfin/playstate/markAsNotPlayed";
|
||||||
|
import { markAsPlayed } from "@/utils/jellyfin/playstate/markAsPlayed";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
|
||||||
|
export const useMarkAsPlayed = (item: BaseItemDto) => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const invalidateQueries = () => {
|
||||||
|
const queriesToInvalidate = [
|
||||||
|
["item", item.Id],
|
||||||
|
["resumeItems"],
|
||||||
|
["continueWatching"],
|
||||||
|
["nextUp-all"],
|
||||||
|
["nextUp"],
|
||||||
|
["episodes"],
|
||||||
|
["seasons"],
|
||||||
|
["home"],
|
||||||
|
];
|
||||||
|
|
||||||
|
queriesToInvalidate.forEach((queryKey) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const markAsPlayedStatus = async (played: boolean) => {
|
||||||
|
// Optimistic update
|
||||||
|
queryClient.setQueryData(
|
||||||
|
["item", item.Id],
|
||||||
|
(oldData: BaseItemDto | undefined) => {
|
||||||
|
if (oldData) {
|
||||||
|
return {
|
||||||
|
...oldData,
|
||||||
|
UserData: {
|
||||||
|
...oldData.UserData,
|
||||||
|
Played: !played,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return oldData;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (played) {
|
||||||
|
await markAsNotPlayed({
|
||||||
|
api: api,
|
||||||
|
itemId: item?.Id,
|
||||||
|
userId: user?.Id,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await markAsPlayed({
|
||||||
|
api: api,
|
||||||
|
item: item,
|
||||||
|
userId: user?.Id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
invalidateQueries();
|
||||||
|
} catch (error) {
|
||||||
|
// Revert optimistic update on error
|
||||||
|
queryClient.setQueryData(
|
||||||
|
["item", item.Id],
|
||||||
|
(oldData: BaseItemDto | undefined) => {
|
||||||
|
if (oldData) {
|
||||||
|
return {
|
||||||
|
...oldData,
|
||||||
|
UserData: {
|
||||||
|
...oldData.UserData,
|
||||||
|
Played: played,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return oldData;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
console.error("Error updating played status:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return markAsPlayedStatus;
|
||||||
|
};
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import orientationToOrientationLock from "@/utils/OrientationLockConverter";
|
|
||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
export const useOrientation = () => {
|
|
||||||
const [orientation, setOrientation] = useState(
|
|
||||||
ScreenOrientation.OrientationLock.UNKNOWN
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const orientationSubscription =
|
|
||||||
ScreenOrientation.addOrientationChangeListener((event) => {
|
|
||||||
setOrientation(
|
|
||||||
orientationToOrientationLock(event.orientationInfo.orientation)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
ScreenOrientation.getOrientationAsync().then((orientation) => {
|
|
||||||
setOrientation(orientationToOrientationLock(orientation));
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
orientationSubscription.remove();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { orientation, setOrientation };
|
|
||||||
};
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
|
|
||||||
export const useOrientationSettings = () => {
|
|
||||||
const [settings] = useSettings();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (settings?.autoRotate) {
|
|
||||||
// Don't need to do anything
|
|
||||||
} else if (settings?.defaultVideoOrientation) {
|
|
||||||
ScreenOrientation.lockAsync(settings.defaultVideoOrientation);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (settings?.autoRotate) {
|
|
||||||
ScreenOrientation.unlockAsync();
|
|
||||||
} else {
|
|
||||||
ScreenOrientation.lockAsync(
|
|
||||||
ScreenOrientation.OrientationLock.PORTRAIT_UP
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [settings]);
|
|
||||||
};
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user