forked from Ninjalama/streamyfin_mirror
Compare commits
134 Commits
fix/460
...
fix/playba
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
187f504d86 | ||
|
|
e651b975b7 | ||
|
|
1c550b1b77 | ||
|
|
5bcae81538 | ||
|
|
c951725222 | ||
|
|
0b966d7c04 | ||
|
|
8e0e35afe3 | ||
|
|
daf7f35196 | ||
|
|
d5ac30b6d8 | ||
|
|
81b91bbb97 | ||
|
|
af2bd030e9 | ||
|
|
5590c2f784 | ||
|
|
6cc70dd123 | ||
|
|
fae588b0f0 | ||
|
|
bd2aeb2234 | ||
|
|
cca0bbf42c | ||
|
|
06e0eb5c4e | ||
|
|
b478fbb6bf | ||
|
|
b98a7b0634 | ||
|
|
ce38024a3f | ||
|
|
04dce9265b | ||
|
|
5b8418cd82 | ||
|
|
b0c5255bd7 | ||
|
|
73dd171987 | ||
|
|
ff35559687 | ||
|
|
5aadd50946 | ||
|
|
63b5ba2112 | ||
|
|
8b955578a2 | ||
|
|
1e5c021c93 | ||
|
|
0b86f56486 | ||
|
|
728b93f4e5 | ||
|
|
2fc483b24e | ||
|
|
fc901bc01e | ||
|
|
2b0884b154 | ||
|
|
307d20e538 | ||
|
|
a2f03908f6 | ||
|
|
77aef8877e | ||
|
|
0cf930d6e1 | ||
|
|
4b0b949541 | ||
|
|
14b717f985 | ||
|
|
cfbac538f8 | ||
|
|
1ac6b7e3df | ||
|
|
c9f6e8676b | ||
|
|
5aab1450cd | ||
|
|
1e7080a136 | ||
|
|
993cec4138 | ||
|
|
6c524499f9 | ||
|
|
b3463ffdfc | ||
|
|
50942b44f1 | ||
|
|
f602f8919f | ||
|
|
0e86d8a00f | ||
|
|
56b1e1977c | ||
|
|
30e23b9079 | ||
|
|
d83ecb881b | ||
|
|
4c14c08b35 | ||
|
|
ecb9b90163 | ||
|
|
33a2be24f4 | ||
|
|
e8b0d52515 | ||
|
|
9faa0de2d6 | ||
|
|
221155d002 | ||
|
|
4a37e17324 | ||
|
|
52b2a3418e | ||
|
|
2753b243e5 | ||
|
|
f22b356b7c | ||
|
|
d8ba5af8d9 | ||
|
|
505ef39ee7 | ||
|
|
e71d5cc176 | ||
|
|
74e57bbd88 | ||
|
|
76eaeb9820 | ||
|
|
9a70f98dd5 | ||
|
|
f28f1d8736 | ||
|
|
e0f03ccb93 | ||
|
|
34d1dbb20e | ||
|
|
e3e2db659d | ||
|
|
528b4ad7ac | ||
|
|
d29501386b | ||
|
|
6688469b6c | ||
|
|
ae9c30aa6d | ||
|
|
364d2e8a51 | ||
|
|
6cc90b46b3 | ||
|
|
33adea2819 | ||
|
|
9f41861dcf | ||
|
|
2b2d23e574 | ||
|
|
f6e2bcb120 | ||
|
|
314cd62bee | ||
|
|
41e7123d1c | ||
|
|
2af42b39f5 | ||
|
|
0a06b336c8 | ||
|
|
028c9159f3 | ||
|
|
dee4fa07e3 | ||
|
|
2764f1736a | ||
|
|
d3d1a7bcde | ||
|
|
7fcd598fa1 | ||
|
|
0fc1506b11 | ||
|
|
e0aa7ea0df | ||
|
|
25f77645f8 | ||
|
|
1c81091e8b | ||
|
|
94502b558d | ||
|
|
a7d7d00eb3 | ||
|
|
3b5e07c1d2 | ||
|
|
db10369fb5 | ||
|
|
32da5918c7 | ||
|
|
dc542021b5 | ||
|
|
bfad157a28 | ||
|
|
a71a646743 | ||
|
|
366bc0137e | ||
|
|
3eb60840e6 | ||
|
|
65c4a1340d | ||
|
|
3e90447dd4 | ||
|
|
bd0768797e | ||
|
|
730ef4616f | ||
|
|
c4d4475aa9 | ||
|
|
d1eb40f2a9 | ||
|
|
77518d774e | ||
|
|
a6fb7b956d | ||
|
|
034ff3f478 | ||
|
|
98ca4e7a6d | ||
|
|
461a276a20 | ||
|
|
3cd8e41000 | ||
|
|
dd08826931 | ||
|
|
b681025389 | ||
|
|
65549428bf | ||
|
|
cda3b64a2b | ||
|
|
373d4ca3b1 | ||
|
|
8bc360d554 | ||
|
|
3fae21d559 | ||
|
|
74ce9d7eea | ||
|
|
5055a700c9 | ||
|
|
ab33693dd9 | ||
|
|
6a4621c377 | ||
|
|
2fb19f601b | ||
|
|
a602c35a8f | ||
|
|
46ac4a2cc7 | ||
|
|
962f65874e |
3
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
3
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -43,6 +43,9 @@ body:
|
|||||||
label: Version
|
label: Version
|
||||||
description: What version of Streamyfin are you running?
|
description: What version of Streamyfin are you running?
|
||||||
options:
|
options:
|
||||||
|
- 0.27.0
|
||||||
|
- 0.26.1
|
||||||
|
- 0.26.0
|
||||||
- 0.25.0
|
- 0.25.0
|
||||||
- 0.24.0
|
- 0.24.0
|
||||||
- 0.23.0
|
- 0.23.0
|
||||||
|
|||||||
41
.github/workflows/lint-pr.yaml
vendored
Normal file
41
.github/workflows/lint-pr.yaml
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
name: "Lint PR"
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
- edited
|
||||||
|
- synchronize
|
||||||
|
- reopened
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
main:
|
||||||
|
name: Validate PR title
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: amannn/action-semantic-pull-request@v5
|
||||||
|
id: lint_pr_title
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- uses: marocchino/sticky-pull-request-comment@v2
|
||||||
|
if: always() && (steps.lint_pr_title.outputs.error_message != null)
|
||||||
|
with:
|
||||||
|
header: pr-title-lint-error
|
||||||
|
message: |
|
||||||
|
Hey there and thank you for opening this pull request! 👋🏼
|
||||||
|
|
||||||
|
We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/) and it looks like your proposed title needs to be adjusted.
|
||||||
|
|
||||||
|
Details:
|
||||||
|
|
||||||
|
```
|
||||||
|
${{ steps.lint_pr_title.outputs.error_message }}
|
||||||
|
```
|
||||||
|
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
|
||||||
|
uses: marocchino/sticky-pull-request-comment@v2
|
||||||
|
with:
|
||||||
|
header: pr-title-lint-error
|
||||||
|
delete: true
|
||||||
39
.github/workflows/main.yml
vendored
Normal file
39
.github/workflows/main.yml
vendored
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
name: Handle Stale Issues
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "30 1 * * *" # Runs at 1:30 UTC every day
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
stale:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/stale@v9
|
||||||
|
with:
|
||||||
|
# Issue specific settings
|
||||||
|
days-before-issue-stale: 90
|
||||||
|
days-before-issue-close: 7
|
||||||
|
stale-issue-label: "stale"
|
||||||
|
stale-issue-message: |
|
||||||
|
This issue has been automatically marked as stale because it has had no activity in the last 30 days.
|
||||||
|
|
||||||
|
If this issue is still relevant, please leave a comment to keep it open.
|
||||||
|
Otherwise, it will be closed in 7 days if no further activity occurs.
|
||||||
|
|
||||||
|
Thank you for your contributions!
|
||||||
|
close-issue-message: |
|
||||||
|
This issue has been automatically closed because it has been inactive for 7 days since being marked as stale.
|
||||||
|
|
||||||
|
If you believe this issue is still relevant, please feel free to reopen it and add a comment explaining the current status.
|
||||||
|
|
||||||
|
# Pull request settings (disabled)
|
||||||
|
days-before-pr-stale: -1
|
||||||
|
days-before-pr-close: -1
|
||||||
|
|
||||||
|
# Other settings
|
||||||
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
operations-per-run: 100
|
||||||
|
exempt-issue-labels: "Roadmap v1,help needed,enhancement"
|
||||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -10,6 +10,7 @@ npm-debug.*
|
|||||||
*.orig.*
|
*.orig.*
|
||||||
web-build/
|
web-build/
|
||||||
modules/vlc-player/android/build
|
modules/vlc-player/android/build
|
||||||
|
bun.lockb
|
||||||
|
|
||||||
# macOS
|
# macOS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -26,6 +27,10 @@ package-lock.json
|
|||||||
|
|
||||||
/ios
|
/ios
|
||||||
/android
|
/android
|
||||||
|
/iostv
|
||||||
|
/iosmobile
|
||||||
|
/androidmobile
|
||||||
|
/androidtv
|
||||||
|
|
||||||
modules/player/android
|
modules/player/android
|
||||||
|
|
||||||
@@ -37,4 +42,5 @@ credentials.json
|
|||||||
|
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
.ruby-lsp
|
.ruby-lsp
|
||||||
|
modules/hls-downloader/android/build
|
||||||
|
|||||||
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
@@ -9,6 +9,7 @@
|
|||||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
"editor.formatOnSave": true
|
"editor.formatOnSave": true
|
||||||
},
|
},
|
||||||
|
"prettier.printWidth": 120,
|
||||||
"[swift]": {
|
"[swift]": {
|
||||||
"editor.defaultFormatter": "sswg.swift-lang"
|
"editor.defaultFormatter": "sswg.swift-lang"
|
||||||
}
|
}
|
||||||
|
|||||||
6
Makefile
Normal file
6
Makefile
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
e2e:
|
||||||
|
maestro start-device --platform android
|
||||||
|
maestro test login.yaml
|
||||||
|
|
||||||
|
e2e-setup:
|
||||||
|
curl -fsSL "https://get.maestro.mobile.dev" | bash
|
||||||
95
README.md
95
README.md
@@ -18,6 +18,7 @@ Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Exp
|
|||||||
- 🔊 **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.
|
||||||
|
- 📡 **Settings management** (Experimental): Manage app settings for all your users with a JF plugin.
|
||||||
- 🤖 **Jellyseerr integration**: Request media directly in the app.
|
- 🤖 **Jellyseerr integration**: Request media directly in the app.
|
||||||
|
|
||||||
## 🧪 Experimental Features
|
## 🧪 Experimental Features
|
||||||
@@ -37,7 +38,7 @@ Chromecast support is still in development, and we're working on improving it. C
|
|||||||
The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that hold all settings for the client Streamyfin. This allows you to syncronize settings accross all your users, like:
|
The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that hold all settings for the client Streamyfin. This allows you to syncronize settings accross all your users, like:
|
||||||
|
|
||||||
- Auto log in to Jellyseerr without the user having to do anythin
|
- Auto log in to Jellyseerr without the user having to do anythin
|
||||||
- Choose the default languages
|
- Choose the default languages
|
||||||
- Set download method and search provider
|
- Set download method and search provider
|
||||||
- Customize homescreen
|
- Customize homescreen
|
||||||
- And more...
|
- And more...
|
||||||
@@ -67,7 +68,7 @@ Or download the APKs [here on GitHub](https://github.com/streamyfin/streamyfin/r
|
|||||||
|
|
||||||
To access the Streamyfin beta, you need to subscribe to the Member tier (or higher) on [Patreon](https://www.patreon.com/streamyfin). This will give you immediate access to the 🧪-public-beta channel on Discord and i'll know that you have subscribed. This is where I post APKs and IPAs. This won't give automatic access to the TestFlight, however, so you need to send me a DM with the email you use for Apple so that i can manually add you.
|
To access the Streamyfin beta, you need to subscribe to the Member tier (or higher) on [Patreon](https://www.patreon.com/streamyfin). This will give you immediate access to the 🧪-public-beta channel on Discord and i'll know that you have subscribed. This is where I post APKs and IPAs. This won't give automatic access to the TestFlight, however, so you need to send me a DM with the email you use for Apple so that i can manually add you.
|
||||||
|
|
||||||
**Note**: Everyone who is actively contributing to the source code of Streamyfin will have automatic access to the betas.
|
**Note**: Everyone who is actively contributing to the source code of Streamyfin will have automatic access to the betas.
|
||||||
|
|
||||||
## 🚀 Getting Started
|
## 🚀 Getting Started
|
||||||
|
|
||||||
@@ -84,8 +85,14 @@ We welcome any help to make Streamyfin better. If you'd like to contribute, plea
|
|||||||
|
|
||||||
1. Use node `>20`
|
1. Use node `>20`
|
||||||
2. Install dependencies `bun i && bun run submodule-reload`
|
2. Install dependencies `bun i && bun run submodule-reload`
|
||||||
3. Make sure you have xcode and/or android studio installed.
|
3. Make sure you have xcode and/or android studio installed. (follow the guides for expo: https://docs.expo.dev/workflow/android-studio-emulator/)
|
||||||
4. Create an expo dev build by running `npx expo run:ios` or `npx expo run:android`. This will open a simulator on your computer and run the app.
|
4. run `npm run prebuild`
|
||||||
|
5. Create an expo dev build by running `npm run ios` or `npm run android`. This will open a simulator on your computer and run the app.
|
||||||
|
|
||||||
|
For the TV version suffix the npm commands with `:tv`.
|
||||||
|
|
||||||
|
`npm run prebuild:tv`
|
||||||
|
`npm run ios:tv or npm run android:tv`
|
||||||
|
|
||||||
## 📄 License
|
## 📄 License
|
||||||
|
|
||||||
@@ -116,7 +123,85 @@ Streamyfin is developed by [Fredrik Burmester](https://github.com/fredrikburmest
|
|||||||
|
|
||||||
## ✨ Acknowledgements
|
## ✨ Acknowledgements
|
||||||
|
|
||||||
I'd like to thank the following people and projects for their contributions to Streamyfin:
|
### Core Developers
|
||||||
|
|
||||||
|
Thanks to the following contributors for their significant contributions:
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr
|
||||||
|
style="
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/Alexk2309">
|
||||||
|
<img src="https://github.com/Alexk2309.png?size=80" width="80" style="border-radius: 50%;" />
|
||||||
|
<br /><sub><b>@Alexk2309</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/herrrta">
|
||||||
|
<img src="https://github.com/herrrta.png?size=80" width="80" style="border-radius: 50%;" />
|
||||||
|
<br /><sub><b>@herrrta</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/lostb1t">
|
||||||
|
<img src="https://github.com/lostb1t.png?size=80" width="80" style="border-radius: 50%;" />
|
||||||
|
<br /><sub><b>@lostb1t</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/Simon-Eklundh">
|
||||||
|
<img src="https://github.com/Simon-Eklundh.png?size=80" width="80" style="border-radius: 50%;" />
|
||||||
|
<br /><sub><b>@Simon-Eklundh</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/topiga">
|
||||||
|
<img src="https://github.com/topiga.png?size=80" width="80" style="border-radius: 50%;" />
|
||||||
|
<br /><sub><b>@topiga</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/simoncaron">
|
||||||
|
<img src="https://github.com/simoncaron.png?size=80" width="80" style="border-radius: 50%;" />
|
||||||
|
<br /><sub><b>@simoncaron</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/jakequade">
|
||||||
|
<img src="https://github.com/jakequade.png?size=80" width="80" style="border-radius: 50%;" />
|
||||||
|
<br /><sub><b>@jakequade</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/Ryan0204">
|
||||||
|
<img src="https://github.com/Ryan0204.png?size=80" width="80" style="border-radius: 50%;" />
|
||||||
|
<br /><sub><b>@Ryan0204</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/retardgerman">
|
||||||
|
<img src="https://github.com/retardgerman.png?size=80" width="80" style="border-radius: 50%;" />
|
||||||
|
<br /><sub><b>@retardgerman</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/whoopsi-daisy">
|
||||||
|
<img src="https://github.com/whoopsi-daisy.png?size=80" width="80" style="border-radius: 50%;" />
|
||||||
|
<br /><sub><b>@whoopsi-daisy</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
And all other developers who have contributed to Streamyfin, thank you for your contributions.
|
||||||
|
|
||||||
|
I'd also like to thank the following people and projects for their contributions to Streamyfin:
|
||||||
|
|
||||||
- [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.
|
||||||
|
|||||||
11
app.config.js
Normal file
11
app.config.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
module.exports = ({ config }) => {
|
||||||
|
if (process.env.EXPO_TV != "1") {
|
||||||
|
config.plugins.push([
|
||||||
|
"react-native-google-cast",
|
||||||
|
{ useDefaultExpandedMediaControls: true },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
};
|
||||||
|
};
|
||||||
51
app.json
51
app.json
@@ -2,16 +2,11 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.25.0",
|
"version": "0.27.0",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "streamyfin",
|
"scheme": "streamyfin",
|
||||||
"userInterfaceStyle": "dark",
|
"userInterfaceStyle": "dark",
|
||||||
"splash": {
|
|
||||||
"image": "./assets/images/splash.png",
|
|
||||||
"resizeMode": "contain",
|
|
||||||
"backgroundColor": "#2E2E2E"
|
|
||||||
},
|
|
||||||
"jsEngine": "hermes",
|
"jsEngine": "hermes",
|
||||||
"assetBundlePatterns": ["**/*"],
|
"assetBundlePatterns": ["**/*"],
|
||||||
"ios": {
|
"ios": {
|
||||||
@@ -36,7 +31,7 @@
|
|||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"jsEngine": "hermes",
|
"jsEngine": "hermes",
|
||||||
"versionCode": 50,
|
"versionCode": 53,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/images/adaptive_icon.png"
|
"foregroundImage": "./assets/images/adaptive_icon.png"
|
||||||
},
|
},
|
||||||
@@ -48,15 +43,10 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
|
"@react-native-tvos/config-tv",
|
||||||
"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",
|
||||||
{
|
{
|
||||||
@@ -78,18 +68,19 @@
|
|||||||
"useFrameworks": "static"
|
"useFrameworks": "static"
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"android": {
|
"compileSdkVersion": 35,
|
||||||
"compileSdkVersion": 34,
|
"targetSdkVersion": 35,
|
||||||
"targetSdkVersion": 34,
|
"buildToolsVersion": "35.0.0",
|
||||||
"buildToolsVersion": "34.0.0"
|
"kotlinVersion": "2.0.21",
|
||||||
},
|
|
||||||
"minSdkVersion": 24,
|
"minSdkVersion": 24,
|
||||||
"usesCleartextTraffic": true,
|
"usesCleartextTraffic": true,
|
||||||
"packagingOptions": {
|
"packagingOptions": {
|
||||||
"jniLibs": {
|
"jniLibs": {
|
||||||
"useLegacyPackaging": true
|
"useLegacyPackaging": true
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"useAndroidX": true,
|
||||||
|
"enableJetifier": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -109,12 +100,25 @@
|
|||||||
"expo-asset",
|
"expo-asset",
|
||||||
[
|
[
|
||||||
"react-native-edge-to-edge",
|
"react-native-edge-to-edge",
|
||||||
{ "android": { "parentTheme": "Material3" } }
|
{
|
||||||
|
"android": {
|
||||||
|
"parentTheme": "Material3"
|
||||||
|
}
|
||||||
|
}
|
||||||
],
|
],
|
||||||
["react-native-bottom-tabs"],
|
["react-native-bottom-tabs"],
|
||||||
["./plugins/withChangeNativeAndroidTextToWhite.js"],
|
["./plugins/withChangeNativeAndroidTextToWhite.js"],
|
||||||
["./plugins/withGoogleCastActivity.js"],
|
["./plugins/withAndroidManifest.js"],
|
||||||
["./plugins/withTrustLocalCerts.js"]
|
["./plugins/withTrustLocalCerts.js"],
|
||||||
|
["./plugins/withGradleProperties.js"],
|
||||||
|
[
|
||||||
|
"expo-splash-screen",
|
||||||
|
{
|
||||||
|
"backgroundColor": "#2e2e2e",
|
||||||
|
"image": "./assets/images/StreamyFinFinal.png",
|
||||||
|
"imageWidth": 100
|
||||||
|
}
|
||||||
|
]
|
||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
"typedRoutes": true
|
"typedRoutes": true
|
||||||
@@ -133,6 +137,7 @@
|
|||||||
},
|
},
|
||||||
"updates": {
|
"updates": {
|
||||||
"url": "https://u.expo.dev/e79219d1-797f-4fbe-9fa1-cfd360690a68"
|
"url": "https://u.expo.dev/e79219d1-797f-4fbe-9fa1-cfd360690a68"
|
||||||
}
|
},
|
||||||
|
"newArchEnabled": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
|
import { Platform } from "react-native";
|
||||||
import { FlatList, TouchableOpacity, View } from "react-native";
|
import { FlatList, TouchableOpacity, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import React, { useCallback, useEffect, useState } from "react";
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
import { useAtom } from "jotai/index";
|
import { useAtom } from "jotai/index";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
import * as WebBrowser from "expo-web-browser";
|
|
||||||
import Ionicons from "@expo/vector-icons/Ionicons";
|
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
const WebBrowser = !Platform.isTV ? require("expo-web-browser") : null;
|
||||||
|
|
||||||
export interface MenuLink {
|
export interface MenuLink {
|
||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url: string;
|
||||||
@@ -52,7 +54,13 @@ export default function menuLinks() {
|
|||||||
}}
|
}}
|
||||||
data={menuLinks}
|
data={menuLinks}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
<TouchableOpacity onPress={() => WebBrowser.openBrowserAsync(item.url)}>
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
if (!Platform.isTV) {
|
||||||
|
WebBrowser.openBrowserAsync(item.url);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={item.name}
|
title={item.name}
|
||||||
iconAfter={<Ionicons name="link" size={24} color="white" />}
|
iconAfter={<Ionicons name="link" size={24} color="white" />}
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
import { Chromecast } from "@/components/Chromecast";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||||
import { Feather } from "@expo/vector-icons";
|
import { Ionicons, 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";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
const Chromecast = !Platform.isTV ? require("@/components/Chromecast") : null;
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSessions, useSessionsProps } from "@/hooks/useSessions";
|
||||||
|
|
||||||
export default function IndexLayout() {
|
export default function IndexLayout() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -25,14 +29,15 @@ 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 />
|
{!Platform.isTV && (
|
||||||
<TouchableOpacity
|
<>
|
||||||
onPress={() => {
|
<Chromecast.Chromecast />
|
||||||
router.push("/(auth)/settings");
|
{user && user.Policy?.IsAdministrator && (
|
||||||
}}
|
<SessionsButton />
|
||||||
>
|
)}
|
||||||
<Feather name="settings" color={"white"} size={22} />
|
<SettingsButton />
|
||||||
</TouchableOpacity>
|
</>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
@@ -49,6 +54,12 @@ export default function IndexLayout() {
|
|||||||
title: t("home.downloads.tvseries"),
|
title: t("home.downloads.tvseries"),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="sessions/index"
|
||||||
|
options={{
|
||||||
|
title: t("home.sessions.title"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="settings"
|
name="settings"
|
||||||
options={{
|
options={{
|
||||||
@@ -67,6 +78,12 @@ export default function IndexLayout() {
|
|||||||
title: "",
|
title: "",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="settings/dashboard/sessions"
|
||||||
|
options={{
|
||||||
|
title: t("home.settings.dashboard.sessions_title"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="settings/jellyseerr/page"
|
name="settings/jellyseerr/page"
|
||||||
options={{
|
options={{
|
||||||
@@ -109,3 +126,38 @@ export default function IndexLayout() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SettingsButton = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
router.push("/(auth)/settings");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Feather name="settings" color={"white"} size={22} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SessionsButton = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { sessions = [], _ } = useSessions({} as useSessionsProps);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
router.push("/(auth)/sessions");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className="mr-4">
|
||||||
|
<Ionicons
|
||||||
|
name="play-circle"
|
||||||
|
color={sessions.length === 0 ? "white" : "#9333ea"}
|
||||||
|
size={25}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,478 +1,5 @@
|
|||||||
import { Button } from "@/components/Button";
|
import { HomeIndex } from "@/components/settings/HomeIndex";
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel";
|
|
||||||
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
|
||||||
import { Colors } from "@/constants/Colors";
|
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { HomeSectionStyle, useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
|
||||||
import { Api } from "@jellyfin/sdk";
|
|
||||||
import {
|
|
||||||
BaseItemDto,
|
|
||||||
BaseItemKind,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import {
|
|
||||||
getItemsApi,
|
|
||||||
getSuggestionsApi,
|
|
||||||
getTvShowsApi,
|
|
||||||
getUserLibraryApi,
|
|
||||||
getUserViewsApi,
|
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import NetInfo from "@react-native-community/netinfo";
|
|
||||||
import { QueryFunction, useQuery } from "@tanstack/react-query";
|
|
||||||
import { useNavigation, useRouter } from "expo-router";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
ActivityIndicator,
|
|
||||||
RefreshControl,
|
|
||||||
ScrollView,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
|
|
||||||
type ScrollingCollectionListSection = {
|
export default function page() {
|
||||||
type: "ScrollingCollectionList";
|
return <HomeIndex />;
|
||||||
title?: string;
|
|
||||||
queryKey: (string | undefined | null)[];
|
|
||||||
queryFn: QueryFunction<BaseItemDto[]>;
|
|
||||||
orientation?: "horizontal" | "vertical";
|
|
||||||
};
|
|
||||||
|
|
||||||
type MediaListSection = {
|
|
||||||
type: "MediaListSection";
|
|
||||||
queryKey: (string | undefined)[];
|
|
||||||
queryFn: QueryFunction<BaseItemDto>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Section = ScrollingCollectionListSection | MediaListSection;
|
|
||||||
|
|
||||||
export default function index() {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const user = useAtomValue(userAtom);
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [
|
|
||||||
settings,
|
|
||||||
updateSettings,
|
|
||||||
pluginSettings,
|
|
||||||
setPluginSettings,
|
|
||||||
refreshStreamyfinPluginSettings,
|
|
||||||
] = useSettings();
|
|
||||||
|
|
||||||
const [isConnected, setIsConnected] = useState<boolean | null>(null);
|
|
||||||
const [loadingRetry, setLoadingRetry] = useState(false);
|
|
||||||
|
|
||||||
const { downloadedFiles, cleanCacheDirectory } = useDownload();
|
|
||||||
const navigation = useNavigation();
|
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const hasDownloads = downloadedFiles && downloadedFiles.length > 0;
|
|
||||||
navigation.setOptions({
|
|
||||||
headerLeft: () => (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
router.push("/(auth)/downloads");
|
|
||||||
}}
|
|
||||||
className="p-2"
|
|
||||||
>
|
|
||||||
<Feather
|
|
||||||
name="download"
|
|
||||||
color={hasDownloads ? Colors.primary : "white"}
|
|
||||||
size={22}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}, [downloadedFiles, navigation, router]);
|
|
||||||
|
|
||||||
const checkConnection = useCallback(async () => {
|
|
||||||
setLoadingRetry(true);
|
|
||||||
const state = await NetInfo.fetch();
|
|
||||||
setIsConnected(state.isConnected);
|
|
||||||
setLoadingRetry(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const unsubscribe = NetInfo.addEventListener((state) => {
|
|
||||||
if (state.isConnected == false || state.isInternetReachable === false)
|
|
||||||
setIsConnected(false);
|
|
||||||
else setIsConnected(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
NetInfo.fetch().then((state) => {
|
|
||||||
setIsConnected(state.isConnected);
|
|
||||||
});
|
|
||||||
|
|
||||||
cleanCacheDirectory().catch((e) =>
|
|
||||||
console.error("Something went wrong cleaning cache directory")
|
|
||||||
);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
unsubscribe();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const {
|
|
||||||
data,
|
|
||||||
isError: e1,
|
|
||||||
isLoading: l1,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["home", "userViews", user?.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api || !user?.Id) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await getUserViewsApi(api).getUserViews({
|
|
||||||
userId: user.Id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.data.Items || null;
|
|
||||||
},
|
|
||||||
enabled: !!api && !!user?.Id,
|
|
||||||
staleTime: 60 * 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const userViews = useMemo(
|
|
||||||
() => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
|
|
||||||
[data, settings?.hiddenLibraries]
|
|
||||||
);
|
|
||||||
|
|
||||||
const collections = useMemo(() => {
|
|
||||||
const allow = ["movies", "tvshows"];
|
|
||||||
return (
|
|
||||||
userViews?.filter(
|
|
||||||
(c) => c.CollectionType && allow.includes(c.CollectionType)
|
|
||||||
) || []
|
|
||||||
);
|
|
||||||
}, [userViews]);
|
|
||||||
|
|
||||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
|
||||||
|
|
||||||
const refetch = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
await refreshStreamyfinPluginSettings();
|
|
||||||
await invalidateCache();
|
|
||||||
setLoading(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const createCollectionConfig = useCallback(
|
|
||||||
(
|
|
||||||
title: string,
|
|
||||||
queryKey: string[],
|
|
||||||
includeItemTypes: BaseItemKind[],
|
|
||||||
parentId: string | undefined
|
|
||||||
): ScrollingCollectionListSection => ({
|
|
||||||
title,
|
|
||||||
queryKey,
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api) return [];
|
|
||||||
return (
|
|
||||||
(
|
|
||||||
await getUserLibraryApi(api).getLatestMedia({
|
|
||||||
userId: user?.Id,
|
|
||||||
limit: 20,
|
|
||||||
fields: ["PrimaryImageAspectRatio", "Path"],
|
|
||||||
imageTypeLimit: 1,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
|
||||||
includeItemTypes,
|
|
||||||
parentId,
|
|
||||||
})
|
|
||||||
).data || []
|
|
||||||
);
|
|
||||||
},
|
|
||||||
type: "ScrollingCollectionList",
|
|
||||||
}),
|
|
||||||
[api, user?.Id]
|
|
||||||
);
|
|
||||||
|
|
||||||
let sections: Section[] = [];
|
|
||||||
if (!settings?.home || !settings?.home?.sections) {
|
|
||||||
sections = useMemo(() => {
|
|
||||||
if (!api || !user?.Id) return [];
|
|
||||||
|
|
||||||
const latestMediaViews = collections.map((c) => {
|
|
||||||
const includeItemTypes: BaseItemKind[] =
|
|
||||||
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"];
|
|
||||||
const title = t("home.recently_added_in", { libraryName: c.Name });
|
|
||||||
const queryKey = [
|
|
||||||
"home",
|
|
||||||
"recentlyAddedIn" + c.CollectionType,
|
|
||||||
user?.Id!,
|
|
||||||
c.Id!,
|
|
||||||
];
|
|
||||||
return createCollectionConfig(
|
|
||||||
title || "",
|
|
||||||
queryKey,
|
|
||||||
includeItemTypes,
|
|
||||||
c.Id
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const ss: Section[] = [
|
|
||||||
{
|
|
||||||
title: t("home.continue_watching"),
|
|
||||||
queryKey: ["home", "resumeItems"],
|
|
||||||
queryFn: async () =>
|
|
||||||
(
|
|
||||||
await getItemsApi(api).getResumeItems({
|
|
||||||
userId: user.Id,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
|
||||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
|
||||||
})
|
|
||||||
).data.Items || [],
|
|
||||||
type: "ScrollingCollectionList",
|
|
||||||
orientation: "horizontal",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t("home.next_up"),
|
|
||||||
queryKey: ["home", "nextUp-all"],
|
|
||||||
queryFn: async () =>
|
|
||||||
(
|
|
||||||
await getTvShowsApi(api).getNextUp({
|
|
||||||
userId: user?.Id,
|
|
||||||
fields: ["MediaSourceCount"],
|
|
||||||
limit: 20,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
|
||||||
enableResumable: false,
|
|
||||||
})
|
|
||||||
).data.Items || [],
|
|
||||||
type: "ScrollingCollectionList",
|
|
||||||
orientation: "horizontal",
|
|
||||||
},
|
|
||||||
...latestMediaViews,
|
|
||||||
// ...(mediaListCollections?.map(
|
|
||||||
// (ml) =>
|
|
||||||
// ({
|
|
||||||
// title: ml.Name,
|
|
||||||
// queryKey: ["home", "mediaList", ml.Id!],
|
|
||||||
// queryFn: async () => ml,
|
|
||||||
// type: "MediaListSection",
|
|
||||||
// orientation: "vertical",
|
|
||||||
// } as Section)
|
|
||||||
// ) || []),
|
|
||||||
{
|
|
||||||
title: t("home.suggested_movies"),
|
|
||||||
queryKey: ["home", "suggestedMovies", user?.Id],
|
|
||||||
queryFn: async () =>
|
|
||||||
(
|
|
||||||
await getSuggestionsApi(api).getSuggestions({
|
|
||||||
userId: user?.Id,
|
|
||||||
limit: 10,
|
|
||||||
mediaType: ["Video"],
|
|
||||||
type: ["Movie"],
|
|
||||||
})
|
|
||||||
).data.Items || [],
|
|
||||||
type: "ScrollingCollectionList",
|
|
||||||
orientation: "vertical",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t("home.suggested_episodes"),
|
|
||||||
queryKey: ["home", "suggestedEpisodes", user?.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
try {
|
|
||||||
const suggestions = await getSuggestions(api, user.Id);
|
|
||||||
const nextUpPromises = suggestions.map((series) =>
|
|
||||||
getNextUp(api, user.Id, series.Id)
|
|
||||||
);
|
|
||||||
const nextUpResults = await Promise.all(nextUpPromises);
|
|
||||||
|
|
||||||
return nextUpResults.filter((item) => item !== null) || [];
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching data:", error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
},
|
|
||||||
type: "ScrollingCollectionList",
|
|
||||||
orientation: "horizontal",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
return ss;
|
|
||||||
}, [api, user?.Id, collections]);
|
|
||||||
} else {
|
|
||||||
sections = useMemo(() => {
|
|
||||||
if (!api || !user?.Id) return [];
|
|
||||||
const ss: Section[] = [];
|
|
||||||
|
|
||||||
for (const key in settings.home?.sections) {
|
|
||||||
const section = settings.home?.sections[key];
|
|
||||||
const id = section.title || key;
|
|
||||||
ss.push({
|
|
||||||
title: id,
|
|
||||||
queryKey: ["home", id],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (section.items) {
|
|
||||||
const response = await getItemsApi(api).getItems({
|
|
||||||
userId: user?.Id,
|
|
||||||
limit: section.items?.limit || 25,
|
|
||||||
recursive: true,
|
|
||||||
includeItemTypes: section.items?.includeItemTypes,
|
|
||||||
sortBy: section.items?.sortBy,
|
|
||||||
sortOrder: section.items?.sortOrder,
|
|
||||||
filters: section.items?.filters,
|
|
||||||
parentId: section.items?.parentId,
|
|
||||||
});
|
|
||||||
return response.data.Items || [];
|
|
||||||
} else if (section.nextUp) {
|
|
||||||
const response = await getTvShowsApi(api).getNextUp({
|
|
||||||
userId: user?.Id,
|
|
||||||
fields: ["MediaSourceCount"],
|
|
||||||
limit: section.items?.limit || 25,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
|
||||||
enableResumable: section.items?.enableResumable || false,
|
|
||||||
enableRewatching: section.items?.enableRewatching || false,
|
|
||||||
});
|
|
||||||
return response.data.Items || [];
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
},
|
|
||||||
type: "ScrollingCollectionList",
|
|
||||||
orientation: section?.orientation || "vertical",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return ss;
|
|
||||||
}, [api, user?.Id, settings.home?.sections]);
|
|
||||||
}
|
|
||||||
|
|
||||||
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">{t("home.no_internet")}</Text>
|
|
||||||
<Text className="text-center opacity-70">
|
|
||||||
{t("home.no_internet_message")}
|
|
||||||
</Text>
|
|
||||||
<View className="mt-4">
|
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
onPress={() => router.push("/(auth)/downloads")}
|
|
||||||
justify="center"
|
|
||||||
iconRight={
|
|
||||||
<Ionicons name="arrow-forward" size={20} color="white" />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("home.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)
|
|
||||||
return (
|
|
||||||
<View className="flex flex-col items-center justify-center h-full -mt-6">
|
|
||||||
<Text className="text-3xl font-bold mb-2">{t("home.oops")}</Text>
|
|
||||||
<Text className="text-center opacity-70">
|
|
||||||
{t("home.error_message")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (l1)
|
|
||||||
return (
|
|
||||||
<View className="justify-center items-center h-full">
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView
|
|
||||||
nestedScrollEnabled
|
|
||||||
contentInsetAdjustmentBehavior="automatic"
|
|
||||||
refreshControl={
|
|
||||||
<RefreshControl refreshing={loading} onRefresh={refetch} />
|
|
||||||
}
|
|
||||||
contentContainerStyle={{
|
|
||||||
paddingLeft: insets.left,
|
|
||||||
paddingRight: insets.right,
|
|
||||||
paddingBottom: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col space-y-4">
|
|
||||||
<LargeMovieCarousel />
|
|
||||||
|
|
||||||
{sections.map((section, index) => {
|
|
||||||
if (section.type === "ScrollingCollectionList") {
|
|
||||||
return (
|
|
||||||
<ScrollingCollectionList
|
|
||||||
key={index}
|
|
||||||
title={section.title}
|
|
||||||
queryKey={section.queryKey}
|
|
||||||
queryFn={section.queryFn}
|
|
||||||
orientation={section.orientation}
|
|
||||||
hideIfEmpty
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else if (section.type === "MediaListSection") {
|
|
||||||
return (
|
|
||||||
<MediaListSection
|
|
||||||
key={index}
|
|
||||||
queryKey={section.queryKey}
|
|
||||||
queryFn={section.queryFn}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function to get suggestions
|
|
||||||
async function getSuggestions(api: Api, userId: string | undefined) {
|
|
||||||
if (!userId) return [];
|
|
||||||
const response = await getSuggestionsApi(api).getSuggestions({
|
|
||||||
userId,
|
|
||||||
limit: 10,
|
|
||||||
mediaType: ["Unknown"],
|
|
||||||
type: ["Series"],
|
|
||||||
});
|
|
||||||
return response.data.Items ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function to get the next up TV show for a series
|
|
||||||
async function getNextUp(
|
|
||||||
api: Api,
|
|
||||||
userId: string | undefined,
|
|
||||||
seriesId: string | undefined
|
|
||||||
) {
|
|
||||||
if (!userId || !seriesId) return null;
|
|
||||||
const response = await getTvShowsApi(api).getNextUp({
|
|
||||||
userId,
|
|
||||||
seriesId,
|
|
||||||
limit: 1,
|
|
||||||
});
|
|
||||||
return response.data.Items?.[0] ?? null;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Feather, Ionicons } from "@expo/vector-icons";
|
|||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useFocusEffect, useRouter } from "expo-router";
|
import { useFocusEffect, useRouter } from "expo-router";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import {useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Linking, TouchableOpacity, View } from "react-native";
|
import { Linking, TouchableOpacity, View } from "react-native";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
@@ -30,10 +30,10 @@ export default function page() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View>
|
<View>
|
||||||
<Text className="text-lg font-bold">{t("home.intro.features_title")}</Text>
|
<Text className="text-lg font-bold">
|
||||||
<Text className="text-xs">
|
{t("home.intro.features_title")}
|
||||||
{t("home.intro.features_description")}
|
|
||||||
</Text>
|
</Text>
|
||||||
|
<Text className="text-xs">{t("home.intro.features_description")}</Text>
|
||||||
<View className="flex flex-row items-center mt-4">
|
<View className="flex flex-row items-center mt-4">
|
||||||
<Image
|
<Image
|
||||||
source={require("@/assets/icons/jellyseerr-logo.svg")}
|
source={require("@/assets/icons/jellyseerr-logo.svg")}
|
||||||
@@ -60,7 +60,9 @@ export default function page() {
|
|||||||
<Ionicons name="cloud-download-outline" size={32} color="white" />
|
<Ionicons name="cloud-download-outline" size={32} color="white" />
|
||||||
</View>
|
</View>
|
||||||
<View className="shrink ml-2">
|
<View className="shrink ml-2">
|
||||||
<Text className="font-bold mb-1">{t("home.intro.downloads_feature_title")}</Text>
|
<Text className="font-bold mb-1">
|
||||||
|
{t("home.intro.downloads_feature_title")}
|
||||||
|
</Text>
|
||||||
<Text className="shrink text-xs">
|
<Text className="shrink text-xs">
|
||||||
{t("home.intro.downloads_feature_description")}
|
{t("home.intro.downloads_feature_description")}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -94,7 +96,9 @@ export default function page() {
|
|||||||
<Feather name="settings" size={28} color={"white"} />
|
<Feather name="settings" size={28} color={"white"} />
|
||||||
</View>
|
</View>
|
||||||
<View className="shrink ml-2">
|
<View className="shrink ml-2">
|
||||||
<Text className="font-bold mb-1">{t("home.intro.centralised_settings_plugin_title")}</Text>
|
<Text className="font-bold mb-1">
|
||||||
|
{t("home.intro.centralised_settings_plugin_title")}
|
||||||
|
</Text>
|
||||||
<Text className="shrink text-xs">
|
<Text className="shrink text-xs">
|
||||||
{t("home.intro.centralised_settings_plugin_description")}{" "}
|
{t("home.intro.centralised_settings_plugin_description")}{" "}
|
||||||
<Text
|
<Text
|
||||||
@@ -127,7 +131,9 @@ export default function page() {
|
|||||||
}}
|
}}
|
||||||
className="mt-4"
|
className="mt-4"
|
||||||
>
|
>
|
||||||
<Text className="text-purple-600 text-center">{t("home.intro.go_to_settings_button")}</Text>
|
<Text className="text-purple-600 text-center">
|
||||||
|
{t("home.intro.go_to_settings_button")}
|
||||||
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
361
app/(auth)/(tabs)/(home)/sessions/index.tsx
Normal file
361
app/(auth)/(tabs)/(home)/sessions/index.tsx
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useSessions, useSessionsProps } from "@/hooks/useSessions";
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { SessionInfoDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import Poster from "@/components/posters/Poster";
|
||||||
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
import { useInterval } from "@/hooks/useInterval";
|
||||||
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
import { formatTimeString } from "@/utils/time";
|
||||||
|
import { formatBitrate } from "@/utils/bitrate";
|
||||||
|
import {
|
||||||
|
Ionicons,
|
||||||
|
Entypo,
|
||||||
|
AntDesign,
|
||||||
|
MaterialCommunityIcons,
|
||||||
|
} from "@expo/vector-icons";
|
||||||
|
import { Badge } from "@/components/Badge";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const { sessions, isLoading } = useSessions({} as useSessionsProps);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (isLoading)
|
||||||
|
return (
|
||||||
|
<View className="justify-center items-center h-full">
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!sessions || sessions.length == 0)
|
||||||
|
return (
|
||||||
|
<View className="h-full w-full flex justify-center items-center">
|
||||||
|
<Text className="text-lg text-neutral-500">
|
||||||
|
{t("home.sessions.no_active_sessions")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlashList
|
||||||
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingTop: 17,
|
||||||
|
paddingHorizontal: 17,
|
||||||
|
paddingBottom: 150,
|
||||||
|
}}
|
||||||
|
data={sessions}
|
||||||
|
renderItem={({ item }) => <SessionCard session={item} />}
|
||||||
|
keyExtractor={(item) => item.Id || ""}
|
||||||
|
estimatedItemSize={200}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionCardProps {
|
||||||
|
session: SessionInfoDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SessionCard = ({ session }: SessionCardProps) => {
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const [remainingTicks, setRemainingTicks] = useState<number>(0);
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
if (session.PlayState?.IsPaused) return;
|
||||||
|
setRemainingTicks(remainingTicks - 10000000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getProgressPercentage = () => {
|
||||||
|
if (!session.NowPlayingItem || !session.NowPlayingItem.RunTimeTicks) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.round(
|
||||||
|
(100 / session.NowPlayingItem?.RunTimeTicks) *
|
||||||
|
(session.NowPlayingItem?.RunTimeTicks - remainingTicks)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const currentTime = session.PlayState?.PositionTicks;
|
||||||
|
const duration = session.NowPlayingItem?.RunTimeTicks;
|
||||||
|
if (
|
||||||
|
duration !== null &&
|
||||||
|
duration !== undefined &&
|
||||||
|
currentTime !== null &&
|
||||||
|
currentTime !== undefined
|
||||||
|
) {
|
||||||
|
const remainingTimeTicks = duration - currentTime;
|
||||||
|
setRemainingTicks(remainingTimeTicks);
|
||||||
|
}
|
||||||
|
}, [session]);
|
||||||
|
|
||||||
|
useInterval(tick, 1000);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex flex-col shadow-md bg-neutral-900 rounded-2xl mb-4">
|
||||||
|
<View className="flex flex-row p-4">
|
||||||
|
<View className="w-20 pr-4">
|
||||||
|
<Poster
|
||||||
|
id={session.NowPlayingItem?.Id}
|
||||||
|
url={getPrimaryImageUrl({ api, item: session.NowPlayingItem })}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View className="w-full flex-1">
|
||||||
|
<View className="flex flex-row justify-between">
|
||||||
|
<View className="flex-1 pr-4">
|
||||||
|
{session.NowPlayingItem?.Type === "Episode" ? (
|
||||||
|
<>
|
||||||
|
<Text className="font-bold">
|
||||||
|
{session.NowPlayingItem?.Name}
|
||||||
|
</Text>
|
||||||
|
<Text numberOfLines={1} className="text-xs opacity-50">
|
||||||
|
{`S${session.NowPlayingItem.ParentIndexNumber?.toString()}:E${session.NowPlayingItem.IndexNumber?.toString()}`}
|
||||||
|
{" - "}
|
||||||
|
{session.NowPlayingItem.SeriesName}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text className="font-bold">
|
||||||
|
{session.NowPlayingItem?.Name}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-xs opacity-50">
|
||||||
|
{session.NowPlayingItem?.ProductionYear}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-xs opacity-50">
|
||||||
|
{session.NowPlayingItem?.SeriesName}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text className="text-xs opacity-50 align-right text-right">
|
||||||
|
{session.UserName}
|
||||||
|
{"\n"}
|
||||||
|
{session.Client}
|
||||||
|
{"\n"}
|
||||||
|
{session.DeviceName}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="flex-1" />
|
||||||
|
<View className="flex flex-col align-bottom">
|
||||||
|
<View className="flex flex-row justify-between align-bottom mb-1">
|
||||||
|
<Text className="-ml-0.5 text-xs opacity-50 align-left text-left">
|
||||||
|
{!session.PlayState?.IsPaused ? (
|
||||||
|
<Ionicons name="play" size={14} color="white" />
|
||||||
|
) : (
|
||||||
|
<Ionicons name="pause" size={14} color="white" />
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-xs opacity-50 align-right text-right">
|
||||||
|
{formatTimeString(remainingTicks, "tick")} left
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="align-bottom bg-gray-800 h-1">
|
||||||
|
<View
|
||||||
|
className={`bg-purple-600 h-full`}
|
||||||
|
style={{
|
||||||
|
width: `${getProgressPercentage()}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<TranscodingView session={session} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TranscodingBadgesProps {
|
||||||
|
properties: StreamProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TranscodingBadges = ({ properties }: TranscodingBadgesProps) => {
|
||||||
|
const iconMap = {
|
||||||
|
bitrate: <Ionicons name="speedometer-outline" size={12} color="white" />,
|
||||||
|
codec: <Ionicons name="layers-outline" size={12} color="white" />,
|
||||||
|
videoRange: (
|
||||||
|
<Ionicons name="color-palette-outline" size={12} color="white" />
|
||||||
|
),
|
||||||
|
resolution: <Ionicons name="film-outline" size={12} color="white" />,
|
||||||
|
language: <Ionicons name="language-outline" size={12} color="white" />,
|
||||||
|
audioChannels: <Ionicons name="mic-outline" size={12} color="white" />,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const icon = (val: string) => {
|
||||||
|
return (
|
||||||
|
iconMap[val as keyof typeof iconMap] ?? (
|
||||||
|
<Ionicons name="layers-outline" size={12} color="white" />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatVal = (key: string, val: any) => {
|
||||||
|
switch (key) {
|
||||||
|
case "bitrate":
|
||||||
|
return formatBitrate(val);
|
||||||
|
default:
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return Object.entries(properties)
|
||||||
|
.filter(([_, value]) => value !== undefined && value !== null)
|
||||||
|
.map(([key]) => (
|
||||||
|
<Badge
|
||||||
|
key={key}
|
||||||
|
variant="gray"
|
||||||
|
className="m-0 p-0 pt-0.5 mr-1"
|
||||||
|
text={formatVal(key, properties[key as keyof StreamProps])}
|
||||||
|
iconLeft={icon(key)}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
interface StreamProps {
|
||||||
|
resolution?: string | null | undefined;
|
||||||
|
language?: string | null | undefined;
|
||||||
|
codec?: string | null | undefined;
|
||||||
|
bitrate?: number | null | undefined;
|
||||||
|
videoRange?: string | null | undefined;
|
||||||
|
audioChannels?: string | null | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TranscodingStreamViewProps {
|
||||||
|
title: string | undefined;
|
||||||
|
value?: string;
|
||||||
|
isTranscoding: Boolean;
|
||||||
|
transcodeValue?: string | undefined | null;
|
||||||
|
properties: StreamProps;
|
||||||
|
transcodeProperties?: StreamProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TranscodingStreamView = ({
|
||||||
|
title,
|
||||||
|
isTranscoding,
|
||||||
|
properties,
|
||||||
|
transcodeProperties,
|
||||||
|
value,
|
||||||
|
transcodeValue,
|
||||||
|
}: TranscodingStreamViewProps) => {
|
||||||
|
return (
|
||||||
|
<View className="flex flex-col pt-2 first:pt-0">
|
||||||
|
<View className="flex flex-row">
|
||||||
|
<Text className="text-xs opacity-50 w-20 font-bold text-right pr-4">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Text className="flex-1">
|
||||||
|
<TranscodingBadges properties={properties} />
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{isTranscoding && transcodeProperties ? (
|
||||||
|
<>
|
||||||
|
<View className="flex flex-row">
|
||||||
|
<Text className="-mt-0 text-xs opacity-50 w-20 font-bold text-right pr-4">
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="arrow-right-bottom"
|
||||||
|
size={14}
|
||||||
|
color="white"
|
||||||
|
/>
|
||||||
|
</Text>
|
||||||
|
<Text className="flex-1 text-sm mt-1">
|
||||||
|
<TranscodingBadges properties={transcodeProperties} />
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const TranscodingView = ({ session }: SessionCardProps) => {
|
||||||
|
const videoStream = useMemo(() => {
|
||||||
|
return session.NowPlayingItem?.MediaStreams?.filter(
|
||||||
|
(s) => s.Type == "Video"
|
||||||
|
)[0];
|
||||||
|
}, [session]);
|
||||||
|
|
||||||
|
const audioStream = useMemo(() => {
|
||||||
|
const index = session.PlayState?.AudioStreamIndex;
|
||||||
|
return index !== null && index !== undefined
|
||||||
|
? session.NowPlayingItem?.MediaStreams?.[index]
|
||||||
|
: undefined;
|
||||||
|
}, [session.PlayState?.AudioStreamIndex]);
|
||||||
|
|
||||||
|
const subtitleStream = useMemo(() => {
|
||||||
|
const index = session.PlayState?.SubtitleStreamIndex;
|
||||||
|
return index !== null && index !== undefined
|
||||||
|
? session.NowPlayingItem?.MediaStreams?.[index]
|
||||||
|
: undefined;
|
||||||
|
}, [session.PlayState?.SubtitleStreamIndex]);
|
||||||
|
|
||||||
|
const isTranscoding = useMemo(() => {
|
||||||
|
return session.PlayState?.PlayMethod == "Transcode";
|
||||||
|
}, [session.PlayState?.PlayMethod]);
|
||||||
|
|
||||||
|
const videoStreamTitle = () => {
|
||||||
|
return videoStream?.DisplayTitle?.split(" ")[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex flex-col bg-neutral-800 rounded-b-2xl p-4 pt-2">
|
||||||
|
<TranscodingStreamView
|
||||||
|
title="Video"
|
||||||
|
properties={{
|
||||||
|
resolution: videoStreamTitle(),
|
||||||
|
bitrate: videoStream?.BitRate,
|
||||||
|
codec: videoStream?.Codec,
|
||||||
|
}}
|
||||||
|
transcodeProperties={{
|
||||||
|
bitrate: session.TranscodingInfo?.Bitrate,
|
||||||
|
codec: session.TranscodingInfo?.VideoCodec,
|
||||||
|
}}
|
||||||
|
isTranscoding={
|
||||||
|
isTranscoding && !session.TranscodingInfo?.IsVideoDirect
|
||||||
|
? true
|
||||||
|
: false
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TranscodingStreamView
|
||||||
|
title="Audio"
|
||||||
|
properties={{
|
||||||
|
language: audioStream?.Language,
|
||||||
|
bitrate: audioStream?.BitRate,
|
||||||
|
codec: audioStream?.Codec,
|
||||||
|
audioChannels: audioStream?.ChannelLayout,
|
||||||
|
}}
|
||||||
|
transcodeProperties={{
|
||||||
|
bitrate: session.TranscodingInfo?.Bitrate,
|
||||||
|
codec: session.TranscodingInfo?.AudioCodec,
|
||||||
|
audioChannels: session.TranscodingInfo?.AudioChannels?.toString(),
|
||||||
|
}}
|
||||||
|
isTranscoding={
|
||||||
|
isTranscoding && !session.TranscodingInfo?.IsVideoDirect
|
||||||
|
? true
|
||||||
|
: false
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{subtitleStream && (
|
||||||
|
<>
|
||||||
|
<TranscodingStreamView
|
||||||
|
title="Subtitle"
|
||||||
|
isTranscoding={false}
|
||||||
|
properties={{
|
||||||
|
language: subtitleStream?.Language,
|
||||||
|
codec: subtitleStream?.Codec,
|
||||||
|
}}
|
||||||
|
transcodeValue={null}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
|
import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
|
||||||
import { AudioToggles } from "@/components/settings/AudioToggles";
|
import { AudioToggles } from "@/components/settings/AudioToggles";
|
||||||
import { DownloadSettings } from "@/components/settings/DownloadSettings";
|
import DownloadSettings from "@/components/settings/DownloadSettings";
|
||||||
import { MediaProvider } from "@/components/settings/MediaContext";
|
import { MediaProvider } from "@/components/settings/MediaContext";
|
||||||
import { MediaToggles } from "@/components/settings/MediaToggles";
|
import { MediaToggles } from "@/components/settings/MediaToggles";
|
||||||
import { OtherSettings } from "@/components/settings/OtherSettings";
|
import { OtherSettings } from "@/components/settings/OtherSettings";
|
||||||
@@ -10,21 +11,24 @@ import { PluginSettings } from "@/components/settings/PluginSettings";
|
|||||||
import { QuickConnect } from "@/components/settings/QuickConnect";
|
import { QuickConnect } from "@/components/settings/QuickConnect";
|
||||||
import { StorageSettings } from "@/components/settings/StorageSettings";
|
import { StorageSettings } from "@/components/settings/StorageSettings";
|
||||||
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
|
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
|
||||||
import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
|
|
||||||
import { UserInfo } from "@/components/settings/UserInfo";
|
import { UserInfo } from "@/components/settings/UserInfo";
|
||||||
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { useJellyfin } from "@/providers/JellyfinProvider";
|
import { useJellyfin } from "@/providers/JellyfinProvider";
|
||||||
import { clearLogs } from "@/utils/log";
|
import { clearLogs } from "@/utils/log";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { storage } from "@/utils/mmkv";
|
||||||
import { useNavigation, useRouter } from "expo-router";
|
import { useNavigation, useRouter } from "expo-router";
|
||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
import React, { useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
import { ScrollView, TouchableOpacity, View } from "react-native";
|
import { ScrollView, Switch, TouchableOpacity, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { useAtom } from "jotai";
|
||||||
|
import { userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { ChromecastSettings } from "@/components/settings/ChromecastSettings";
|
||||||
|
|
||||||
export default function settings() {
|
export default function settings() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
const { logout } = useJellyfin();
|
const { logout } = useJellyfin();
|
||||||
const successHapticFeedback = useHaptic("success");
|
const successHapticFeedback = useHaptic("success");
|
||||||
|
|
||||||
@@ -42,7 +46,9 @@ export default function settings() {
|
|||||||
logout();
|
logout();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text className="text-red-600">{t("home.settings.log_out_button")}</Text>
|
<Text className="text-red-600">
|
||||||
|
{t("home.settings.log_out_button")}
|
||||||
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
@@ -57,6 +63,7 @@ export default function settings() {
|
|||||||
>
|
>
|
||||||
<View className="p-4 flex flex-col gap-y-4">
|
<View className="p-4 flex flex-col gap-y-4">
|
||||||
<UserInfo />
|
<UserInfo />
|
||||||
|
|
||||||
<QuickConnect className="mb-4" />
|
<QuickConnect className="mb-4" />
|
||||||
|
|
||||||
<MediaProvider>
|
<MediaProvider>
|
||||||
@@ -66,11 +73,14 @@ export default function settings() {
|
|||||||
</MediaProvider>
|
</MediaProvider>
|
||||||
|
|
||||||
<OtherSettings />
|
<OtherSettings />
|
||||||
|
|
||||||
<DownloadSettings />
|
<DownloadSettings />
|
||||||
|
|
||||||
<PluginSettings />
|
<PluginSettings />
|
||||||
|
|
||||||
<AppLanguageSelector/>
|
<AppLanguageSelector />
|
||||||
|
|
||||||
|
<ChromecastSettings />
|
||||||
|
|
||||||
<ListGroup title={"Intro"}>
|
<ListGroup title={"Intro"}>
|
||||||
<ListItem
|
<ListItem
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ 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 * as ScreenOrientation from "@/packages/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";
|
||||||
|
|||||||
@@ -29,13 +29,19 @@ import {
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import React, {useCallback, useEffect, useMemo, useRef, useState} from "react";
|
import React, {
|
||||||
import { TouchableOpacity, View } from "react-native";
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
import RequestModal from "@/components/jellyseerr/RequestModal";
|
import RequestModal from "@/components/jellyseerr/RequestModal";
|
||||||
import {ANIME_KEYWORD_ID} from "@/utils/jellyseerr/server/api/themoviedb/constants";
|
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
|
||||||
import {MediaRequestBody} from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
import { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
||||||
|
|
||||||
const Page: React.FC = () => {
|
const Page: React.FC = () => {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
@@ -79,7 +85,8 @@ const Page: React.FC = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const [canRequest, hasAdvancedRequestPermission] = useJellyseerrCanRequest(details);
|
const [canRequest, hasAdvancedRequestPermission] =
|
||||||
|
useJellyseerrCanRequest(details);
|
||||||
|
|
||||||
const renderBackdrop = useCallback(
|
const renderBackdrop = useCallback(
|
||||||
(props: BottomSheetBackdropProps) => (
|
(props: BottomSheetBackdropProps) => (
|
||||||
@@ -112,20 +119,22 @@ const Page: React.FC = () => {
|
|||||||
seasons: (details as TvDetails)?.seasons
|
seasons: (details as TvDetails)?.seasons
|
||||||
?.filter?.((s) => s.seasonNumber !== 0)
|
?.filter?.((s) => s.seasonNumber !== 0)
|
||||||
?.map?.((s) => s.seasonNumber),
|
?.map?.((s) => s.seasonNumber),
|
||||||
}
|
};
|
||||||
|
|
||||||
if (hasAdvancedRequestPermission) {
|
if (hasAdvancedRequestPermission) {
|
||||||
advancedReqModalRef?.current?.present?.(body)
|
advancedReqModalRef?.current?.present?.(body);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
requestMedia(mediaTitle, body, refetch);
|
requestMedia(mediaTitle, body, refetch);
|
||||||
}, [details, result, requestMedia, hasAdvancedRequestPermission]);
|
}, [details, result, requestMedia, hasAdvancedRequestPermission]);
|
||||||
|
|
||||||
const isAnime = useMemo(
|
const isAnime = useMemo(
|
||||||
() => (details?.keywords.some(k => k.id === ANIME_KEYWORD_ID) || false) && result.mediaType === MediaType.TV,
|
() =>
|
||||||
|
(details?.keywords.some((k) => k.id === ANIME_KEYWORD_ID) || false) &&
|
||||||
|
result.mediaType === MediaType.TV,
|
||||||
[details]
|
[details]
|
||||||
)
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (details) {
|
if (details) {
|
||||||
@@ -247,7 +256,7 @@ const Page: React.FC = () => {
|
|||||||
hasAdvancedRequest={hasAdvancedRequestPermission}
|
hasAdvancedRequest={hasAdvancedRequestPermission}
|
||||||
onAdvancedRequest={(data) =>
|
onAdvancedRequest={(data) =>
|
||||||
advancedReqModalRef?.current?.present(data)
|
advancedReqModalRef?.current?.present(data)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<DetailFacts
|
<DetailFacts
|
||||||
@@ -265,8 +274,8 @@ const Page: React.FC = () => {
|
|||||||
type={result.mediaType as MediaType}
|
type={result.mediaType as MediaType}
|
||||||
isAnime={isAnime}
|
isAnime={isAnime}
|
||||||
onRequested={() => {
|
onRequested={() => {
|
||||||
advancedReqModalRef?.current?.close()
|
advancedReqModalRef?.current?.close();
|
||||||
refetch()
|
refetch();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<BottomSheetModal
|
<BottomSheetModal
|
||||||
@@ -313,7 +322,9 @@ const Page: React.FC = () => {
|
|||||||
collisionPadding={0}
|
collisionPadding={0}
|
||||||
sideOffset={0}
|
sideOffset={0}
|
||||||
>
|
>
|
||||||
<DropdownMenu.Label>{t("jellyseerr.types")}</DropdownMenu.Label>
|
<DropdownMenu.Label>
|
||||||
|
{t("jellyseerr.types")}
|
||||||
|
</DropdownMenu.Label>
|
||||||
{Object.entries(IssueTypeName)
|
{Object.entries(IssueTypeName)
|
||||||
.reverse()
|
.reverse()
|
||||||
.map(([key, value], idx) => (
|
.map(([key, value], idx) => (
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export default function page() {
|
|||||||
const local = useLocalSearchParams();
|
const local = useLocalSearchParams();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { jellyseerrApi, jellyseerrUser } = useJellyseerr();
|
const { jellyseerrApi, jellyseerrUser, jellyseerrRegion: region, jellyseerrLocale: locale } = useJellyseerr();
|
||||||
|
|
||||||
const { personId } = local as { personId: string };
|
const { personId } = local as { personId: string };
|
||||||
|
|
||||||
@@ -32,15 +32,6 @@ export default function page() {
|
|||||||
enabled: !!jellyseerrApi && !!personId,
|
enabled: !!jellyseerrApi && !!personId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const locale = useMemo(() => {
|
|
||||||
return jellyseerrUser?.settings?.locale || "en";
|
|
||||||
}, [jellyseerrUser]);
|
|
||||||
|
|
||||||
const region = useMemo(
|
|
||||||
() => jellyseerrUser?.settings?.region || "US",
|
|
||||||
[jellyseerrUser]
|
|
||||||
);
|
|
||||||
|
|
||||||
const castedRoles: PersonCreditCast[] = useMemo(
|
const castedRoles: PersonCreditCast[] = useMemo(
|
||||||
() =>
|
() =>
|
||||||
uniqBy(orderBy(
|
uniqBy(orderBy(
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { Image } from "expo-image";
|
|||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useEffect, useMemo } from "react";
|
import React, { useEffect, useMemo } from "react";
|
||||||
import { View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
const page: React.FC = () => {
|
const page: React.FC = () => {
|
||||||
@@ -84,22 +84,26 @@ const page: React.FC = () => {
|
|||||||
allEpisodes &&
|
allEpisodes &&
|
||||||
allEpisodes.length > 0 && (
|
allEpisodes.length > 0 && (
|
||||||
<View className="flex flex-row items-center space-x-2">
|
<View className="flex flex-row items-center space-x-2">
|
||||||
<AddToFavorites item={item} type="series" />
|
<AddToFavorites item={item} />
|
||||||
<DownloadItems
|
{!Platform.isTV && (
|
||||||
size="large"
|
<>
|
||||||
title={t("item_card.download.download_series")}
|
<DownloadItems
|
||||||
items={allEpisodes || []}
|
size="large"
|
||||||
MissingDownloadIconComponent={() => (
|
title={t("item_card.download.download_series")}
|
||||||
<Ionicons name="download" size={22} color="white" />
|
items={allEpisodes || []}
|
||||||
)}
|
MissingDownloadIconComponent={() => (
|
||||||
DownloadedIconComponent={() => (
|
<Ionicons name="download" size={22} color="white" />
|
||||||
<Ionicons
|
)}
|
||||||
name="checkmark-done-outline"
|
DownloadedIconComponent={() => (
|
||||||
size={24}
|
<Ionicons
|
||||||
color="#9333ea"
|
name="checkmark-done-outline"
|
||||||
|
size={24}
|
||||||
|
color="#9333ea"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
</>
|
||||||
/>
|
)}
|
||||||
</View>
|
</View>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
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 * as ScreenOrientation from "@/packages/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";
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ 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 { Platform } from "react-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function IndexLayout() {
|
export default function IndexLayout() {
|
||||||
@@ -27,166 +27,171 @@ export default function IndexLayout() {
|
|||||||
},
|
},
|
||||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerRight: () => (
|
headerRight: () =>
|
||||||
!pluginSettings?.libraryOptions?.locked &&
|
!pluginSettings?.libraryOptions?.locked &&
|
||||||
<DropdownMenu.Root>
|
!Platform.isTV && (
|
||||||
<DropdownMenu.Trigger>
|
<DropdownMenu.Root>
|
||||||
<Ionicons
|
<DropdownMenu.Trigger>
|
||||||
name="ellipsis-horizontal-outline"
|
<Ionicons
|
||||||
size={24}
|
name="ellipsis-horizontal-outline"
|
||||||
color="white"
|
size={24}
|
||||||
/>
|
color="white"
|
||||||
</DropdownMenu.Trigger>
|
/>
|
||||||
<DropdownMenu.Content
|
</DropdownMenu.Trigger>
|
||||||
align={"end"}
|
<DropdownMenu.Content
|
||||||
alignOffset={-10}
|
align={"end"}
|
||||||
avoidCollisions={false}
|
alignOffset={-10}
|
||||||
collisionPadding={0}
|
avoidCollisions={false}
|
||||||
loop={false}
|
collisionPadding={0}
|
||||||
side={"bottom"}
|
loop={false}
|
||||||
sideOffset={10}
|
side={"bottom"}
|
||||||
>
|
sideOffset={10}
|
||||||
<DropdownMenu.Label>{t("library.options.display")}</DropdownMenu.Label>
|
>
|
||||||
<DropdownMenu.Group key="display-group">
|
<DropdownMenu.Label>
|
||||||
<DropdownMenu.Sub>
|
{t("library.options.display")}
|
||||||
<DropdownMenu.SubTrigger key="image-style-trigger">
|
</DropdownMenu.Label>
|
||||||
{t("library.options.display")}
|
<DropdownMenu.Group key="display-group">
|
||||||
</DropdownMenu.SubTrigger>
|
<DropdownMenu.Sub>
|
||||||
<DropdownMenu.SubContent
|
<DropdownMenu.SubTrigger key="image-style-trigger">
|
||||||
alignOffset={-10}
|
{t("library.options.display")}
|
||||||
avoidCollisions={true}
|
</DropdownMenu.SubTrigger>
|
||||||
collisionPadding={0}
|
<DropdownMenu.SubContent
|
||||||
loop={true}
|
alignOffset={-10}
|
||||||
sideOffset={10}
|
avoidCollisions={true}
|
||||||
|
collisionPadding={0}
|
||||||
|
loop={true}
|
||||||
|
sideOffset={10}
|
||||||
|
>
|
||||||
|
<DropdownMenu.CheckboxItem
|
||||||
|
key="display-option-1"
|
||||||
|
value={settings.libraryOptions.display === "row"}
|
||||||
|
onValueChange={() =>
|
||||||
|
updateSettings({
|
||||||
|
libraryOptions: {
|
||||||
|
...settings.libraryOptions,
|
||||||
|
display: "row",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemIndicator />
|
||||||
|
<DropdownMenu.ItemTitle key="display-title-1">
|
||||||
|
{t("library.options.row")}
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.CheckboxItem>
|
||||||
|
<DropdownMenu.CheckboxItem
|
||||||
|
key="display-option-2"
|
||||||
|
value={settings.libraryOptions.display === "list"}
|
||||||
|
onValueChange={() =>
|
||||||
|
updateSettings({
|
||||||
|
libraryOptions: {
|
||||||
|
...settings.libraryOptions,
|
||||||
|
display: "list",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemIndicator />
|
||||||
|
<DropdownMenu.ItemTitle key="display-title-2">
|
||||||
|
{t("library.options.list")}
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.CheckboxItem>
|
||||||
|
</DropdownMenu.SubContent>
|
||||||
|
</DropdownMenu.Sub>
|
||||||
|
<DropdownMenu.Sub>
|
||||||
|
<DropdownMenu.SubTrigger key="image-style-trigger">
|
||||||
|
{t("library.options.image_style")}
|
||||||
|
</DropdownMenu.SubTrigger>
|
||||||
|
<DropdownMenu.SubContent
|
||||||
|
alignOffset={-10}
|
||||||
|
avoidCollisions={true}
|
||||||
|
collisionPadding={0}
|
||||||
|
loop={true}
|
||||||
|
sideOffset={10}
|
||||||
|
>
|
||||||
|
<DropdownMenu.CheckboxItem
|
||||||
|
key="poster-option"
|
||||||
|
value={
|
||||||
|
settings.libraryOptions.imageStyle === "poster"
|
||||||
|
}
|
||||||
|
onValueChange={() =>
|
||||||
|
updateSettings({
|
||||||
|
libraryOptions: {
|
||||||
|
...settings.libraryOptions,
|
||||||
|
imageStyle: "poster",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemIndicator />
|
||||||
|
<DropdownMenu.ItemTitle key="poster-title">
|
||||||
|
{t("library.options.poster")}
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.CheckboxItem>
|
||||||
|
<DropdownMenu.CheckboxItem
|
||||||
|
key="cover-option"
|
||||||
|
value={settings.libraryOptions.imageStyle === "cover"}
|
||||||
|
onValueChange={() =>
|
||||||
|
updateSettings({
|
||||||
|
libraryOptions: {
|
||||||
|
...settings.libraryOptions,
|
||||||
|
imageStyle: "cover",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemIndicator />
|
||||||
|
<DropdownMenu.ItemTitle key="cover-title">
|
||||||
|
{t("library.options.cover")}
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.CheckboxItem>
|
||||||
|
</DropdownMenu.SubContent>
|
||||||
|
</DropdownMenu.Sub>
|
||||||
|
</DropdownMenu.Group>
|
||||||
|
<DropdownMenu.Group key="show-titles-group">
|
||||||
|
<DropdownMenu.CheckboxItem
|
||||||
|
disabled={settings.libraryOptions.imageStyle === "poster"}
|
||||||
|
key="show-titles-option"
|
||||||
|
value={settings.libraryOptions.showTitles}
|
||||||
|
onValueChange={(newValue: string) => {
|
||||||
|
if (settings.libraryOptions.imageStyle === "poster")
|
||||||
|
return;
|
||||||
|
updateSettings({
|
||||||
|
libraryOptions: {
|
||||||
|
...settings.libraryOptions,
|
||||||
|
showTitles: newValue === "on" ? true : false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.CheckboxItem
|
<DropdownMenu.ItemIndicator />
|
||||||
key="display-option-1"
|
<DropdownMenu.ItemTitle key="show-titles-title">
|
||||||
value={settings.libraryOptions.display === "row"}
|
{t("library.options.show_titles")}
|
||||||
onValueChange={() =>
|
</DropdownMenu.ItemTitle>
|
||||||
updateSettings({
|
</DropdownMenu.CheckboxItem>
|
||||||
libraryOptions: {
|
<DropdownMenu.CheckboxItem
|
||||||
...settings.libraryOptions,
|
key="show-stats-option"
|
||||||
display: "row",
|
value={settings.libraryOptions.showStats}
|
||||||
},
|
onValueChange={(newValue: string) => {
|
||||||
})
|
updateSettings({
|
||||||
}
|
libraryOptions: {
|
||||||
>
|
...settings.libraryOptions,
|
||||||
<DropdownMenu.ItemIndicator />
|
showStats: newValue === "on" ? true : false,
|
||||||
<DropdownMenu.ItemTitle key="display-title-1">
|
},
|
||||||
{t("library.options.row")}
|
});
|
||||||
</DropdownMenu.ItemTitle>
|
}}
|
||||||
</DropdownMenu.CheckboxItem>
|
|
||||||
<DropdownMenu.CheckboxItem
|
|
||||||
key="display-option-2"
|
|
||||||
value={settings.libraryOptions.display === "list"}
|
|
||||||
onValueChange={() =>
|
|
||||||
updateSettings({
|
|
||||||
libraryOptions: {
|
|
||||||
...settings.libraryOptions,
|
|
||||||
display: "list",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemIndicator />
|
|
||||||
<DropdownMenu.ItemTitle key="display-title-2">
|
|
||||||
{t("library.options.list")}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.CheckboxItem>
|
|
||||||
</DropdownMenu.SubContent>
|
|
||||||
</DropdownMenu.Sub>
|
|
||||||
<DropdownMenu.Sub>
|
|
||||||
<DropdownMenu.SubTrigger key="image-style-trigger">
|
|
||||||
{t("library.options.image_style")}
|
|
||||||
</DropdownMenu.SubTrigger>
|
|
||||||
<DropdownMenu.SubContent
|
|
||||||
alignOffset={-10}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={0}
|
|
||||||
loop={true}
|
|
||||||
sideOffset={10}
|
|
||||||
>
|
>
|
||||||
<DropdownMenu.CheckboxItem
|
<DropdownMenu.ItemIndicator />
|
||||||
key="poster-option"
|
<DropdownMenu.ItemTitle key="show-stats-title">
|
||||||
value={settings.libraryOptions.imageStyle === "poster"}
|
{t("library.options.show_stats")}
|
||||||
onValueChange={() =>
|
</DropdownMenu.ItemTitle>
|
||||||
updateSettings({
|
</DropdownMenu.CheckboxItem>
|
||||||
libraryOptions: {
|
</DropdownMenu.Group>
|
||||||
...settings.libraryOptions,
|
|
||||||
imageStyle: "poster",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemIndicator />
|
|
||||||
<DropdownMenu.ItemTitle key="poster-title">
|
|
||||||
{t("library.options.poster")}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.CheckboxItem>
|
|
||||||
<DropdownMenu.CheckboxItem
|
|
||||||
key="cover-option"
|
|
||||||
value={settings.libraryOptions.imageStyle === "cover"}
|
|
||||||
onValueChange={() =>
|
|
||||||
updateSettings({
|
|
||||||
libraryOptions: {
|
|
||||||
...settings.libraryOptions,
|
|
||||||
imageStyle: "cover",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemIndicator />
|
|
||||||
<DropdownMenu.ItemTitle key="cover-title">
|
|
||||||
{t("library.options.cover")}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.CheckboxItem>
|
|
||||||
</DropdownMenu.SubContent>
|
|
||||||
</DropdownMenu.Sub>
|
|
||||||
</DropdownMenu.Group>
|
|
||||||
<DropdownMenu.Group key="show-titles-group">
|
|
||||||
<DropdownMenu.CheckboxItem
|
|
||||||
disabled={settings.libraryOptions.imageStyle === "poster"}
|
|
||||||
key="show-titles-option"
|
|
||||||
value={settings.libraryOptions.showTitles}
|
|
||||||
onValueChange={(newValue) => {
|
|
||||||
if (settings.libraryOptions.imageStyle === "poster")
|
|
||||||
return;
|
|
||||||
updateSettings({
|
|
||||||
libraryOptions: {
|
|
||||||
...settings.libraryOptions,
|
|
||||||
showTitles: newValue === "on" ? true : false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemIndicator />
|
|
||||||
<DropdownMenu.ItemTitle key="show-titles-title">
|
|
||||||
{t("library.options.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">
|
|
||||||
{t("library.options.show_stats")}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.CheckboxItem>
|
|
||||||
</DropdownMenu.Group>
|
|
||||||
|
|
||||||
<DropdownMenu.Separator />
|
<DropdownMenu.Separator />
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu.Root>
|
</DropdownMenu.Root>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
|
|||||||
@@ -38,9 +38,18 @@ export default function SearchLayout() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen name="jellyseerr/page" options={commonScreenOptions} />
|
<Stack.Screen name="jellyseerr/page" options={commonScreenOptions} />
|
||||||
<Stack.Screen name="jellyseerr/person/[personId]" options={commonScreenOptions} />
|
<Stack.Screen
|
||||||
<Stack.Screen name="jellyseerr/company/[companyId]" options={commonScreenOptions} />
|
name="jellyseerr/person/[personId]"
|
||||||
<Stack.Screen name="jellyseerr/genre/[genreId]" options={commonScreenOptions} />
|
options={commonScreenOptions}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="jellyseerr/company/[companyId]"
|
||||||
|
options={commonScreenOptions}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="jellyseerr/genre/[genreId]"
|
||||||
|
options={commonScreenOptions}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,12 +26,14 @@ import React, {
|
|||||||
useEffect,
|
useEffect,
|
||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "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 { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { eventBus } from "@/utils/eventBus";
|
||||||
|
|
||||||
type SearchType = "Library" | "Discover";
|
type SearchType = "Library" | "Discover";
|
||||||
|
|
||||||
@@ -50,7 +52,7 @@ export default function search() {
|
|||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { q, prev } = params as { q: string; prev: Href<string> };
|
const { q } = params as { q: string };
|
||||||
|
|
||||||
const [searchType, setSearchType] = useState<SearchType>("Library");
|
const [searchType, setSearchType] = useState<SearchType>("Library");
|
||||||
const [search, setSearch] = useState<string>("");
|
const [search, setSearch] = useState<string>("");
|
||||||
@@ -120,22 +122,44 @@ export default function search() {
|
|||||||
[api, searchEngine, settings]
|
[api, searchEngine, settings]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
type HeaderSearchBarRef = {
|
||||||
|
focus: () => void;
|
||||||
|
blur: () => void;
|
||||||
|
setText: (text: string) => void;
|
||||||
|
clearText: () => void;
|
||||||
|
cancelSearch: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchBarRef = useRef<HeaderSearchBarRef>(null);
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (Platform.OS === "ios")
|
navigation.setOptions({
|
||||||
navigation.setOptions({
|
headerSearchBarOptions: {
|
||||||
headerSearchBarOptions: {
|
ref: searchBarRef,
|
||||||
placeholder: t("search.search"),
|
placeholder: t("search.search"),
|
||||||
onChangeText: (e: any) => {
|
onChangeText: (e: any) => {
|
||||||
router.setParams({ q: "" });
|
router.setParams({ q: "" });
|
||||||
setSearch(e.nativeEvent.text);
|
setSearch(e.nativeEvent.text);
|
||||||
},
|
|
||||||
hideWhenScrolling: false,
|
|
||||||
autoFocus: true,
|
|
||||||
},
|
},
|
||||||
});
|
hideWhenScrolling: false,
|
||||||
|
autoFocus: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
}, [navigation]);
|
}, [navigation]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = eventBus.on("searchTabPressed", () => {
|
||||||
|
// Screen not actuve
|
||||||
|
if (!searchBarRef.current) return;
|
||||||
|
// Screen is active, focus search bar
|
||||||
|
searchBarRef.current?.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const { data: movies, isFetching: l1 } = useQuery({
|
const { data: movies, isFetching: l1 } = useQuery({
|
||||||
queryKey: ["search", "movies", debouncedSearch],
|
queryKey: ["search", "movies", debouncedSearch],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
@@ -210,19 +234,12 @@ export default function search() {
|
|||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className="flex flex-col">
|
<View
|
||||||
{Platform.OS === "android" && (
|
className="flex flex-col"
|
||||||
<View className="mb-4 px-4">
|
style={{
|
||||||
<Input
|
marginTop: Platform.OS === "android" ? 16 : 0,
|
||||||
autoCorrect={false}
|
}}
|
||||||
returnKeyType="done"
|
>
|
||||||
keyboardType="web-search"
|
|
||||||
placeholder={t("search.search_here")}
|
|
||||||
value={search}
|
|
||||||
onChangeText={(text) => setSearch(text)}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{jellyseerrApi && (
|
{jellyseerrApi && (
|
||||||
<View className="flex flex-row flex-wrap space-x-2 px-4 mb-2">
|
<View className="flex flex-row flex-wrap space-x-2 px-4 mb-2">
|
||||||
<TouchableOpacity onPress={() => setSearchType("Library")}>
|
<TouchableOpacity onPress={() => setSearchType("Library")}>
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
} from "@bottom-tabs/react-navigation";
|
} from "@bottom-tabs/react-navigation";
|
||||||
|
|
||||||
const { Navigator } = createNativeBottomTabNavigator();
|
const { Navigator } = createNativeBottomTabNavigator();
|
||||||
|
|
||||||
import { BottomTabNavigationOptions } from "@react-navigation/bottom-tabs";
|
import { BottomTabNavigationOptions } from "@react-navigation/bottom-tabs";
|
||||||
|
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
@@ -21,6 +20,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 { eventBus } from "@/utils/eventBus";
|
||||||
|
|
||||||
export const NativeTabs = withLayoutContext<
|
export const NativeTabs = withLayoutContext<
|
||||||
BottomTabNavigationOptions,
|
BottomTabNavigationOptions,
|
||||||
@@ -55,12 +55,19 @@ export default function TabLayout() {
|
|||||||
<NativeTabs
|
<NativeTabs
|
||||||
sidebarAdaptable={false}
|
sidebarAdaptable={false}
|
||||||
ignoresTopSafeArea
|
ignoresTopSafeArea
|
||||||
barTintColor={Platform.OS === "android" ? "#121212" : undefined}
|
tabBarStyle={{
|
||||||
|
backgroundColor: "#121212",
|
||||||
|
}}
|
||||||
tabBarActiveTintColor={Colors.primary}
|
tabBarActiveTintColor={Colors.primary}
|
||||||
scrollEdgeAppearance="default"
|
scrollEdgeAppearance="default"
|
||||||
>
|
>
|
||||||
<NativeTabs.Screen redirect name="index" />
|
<NativeTabs.Screen redirect name="index" />
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
|
listeners={({ navigation }) => ({
|
||||||
|
tabPress: (e) => {
|
||||||
|
eventBus.emit("scrollToTop");
|
||||||
|
},
|
||||||
|
})}
|
||||||
name="(home)"
|
name="(home)"
|
||||||
options={{
|
options={{
|
||||||
title: t("tabs.home"),
|
title: t("tabs.home"),
|
||||||
@@ -75,6 +82,11 @@ export default function TabLayout() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
|
listeners={({ navigation }) => ({
|
||||||
|
tabPress: (e) => {
|
||||||
|
eventBus.emit("searchTabPressed");
|
||||||
|
},
|
||||||
|
})}
|
||||||
name="(search)"
|
name="(search)"
|
||||||
options={{
|
options={{
|
||||||
title: t("tabs.search"),
|
title: t("tabs.search"),
|
||||||
|
|||||||
@@ -1,8 +1,33 @@
|
|||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
import React from "react";
|
import React, { useEffect } from "react";
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
|
const [settings] = useSettings();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
|
if (settings.defaultVideoOrientation) {
|
||||||
|
ScreenOrientation.lockAsync(settings.defaultVideoOrientation);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
|
if (settings.autoRotate === true) {
|
||||||
|
ScreenOrientation.unlockAsync();
|
||||||
|
} else {
|
||||||
|
ScreenOrientation.lockAsync(
|
||||||
|
ScreenOrientation.OrientationLock.PORTRAIT_UP
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [settings]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SystemBars hidden />
|
<SystemBars hidden />
|
||||||
@@ -16,15 +41,6 @@ export default function Layout() {
|
|||||||
animation: "fade",
|
animation: "fade",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
|
||||||
name="transcoding-player"
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
autoHideHomeIndicator: true,
|
|
||||||
title: "",
|
|
||||||
animation: "fade",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,59 +3,40 @@ 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 { 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";
|
||||||
import {
|
import {
|
||||||
|
PipStartedPayload,
|
||||||
PlaybackStatePayload,
|
PlaybackStatePayload,
|
||||||
ProgressUpdatePayload,
|
ProgressUpdatePayload,
|
||||||
VlcPlayerViewRef,
|
VlcPlayerViewRef,
|
||||||
} from "@/modules/vlc-player/src/VlcPlayer.types";
|
} from "@/modules/vlc-player/src/VlcPlayer.types";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
const downloadProvider = !Platform.isTV ? require("@/providers/DownloadProvider") : null;
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
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";
|
||||||
import native from "@/utils/profiles/native";
|
import native from "@/utils/profiles/native";
|
||||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||||
import { Api } from "@jellyfin/sdk";
|
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import { getPlaystateApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import {
|
|
||||||
getPlaystateApi,
|
|
||||||
getUserLibraryApi,
|
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { useFocusEffect, useGlobalSearchParams } from "expo-router";
|
import { useGlobalSearchParams, useNavigation } from "expo-router";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import React, {
|
import React, { useCallback, useMemo, useRef, useState, useEffect } from "react";
|
||||||
useCallback,
|
import { Alert, View, Platform } from "react-native";
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
useEffect,
|
|
||||||
} from "react";
|
|
||||||
import {
|
|
||||||
Alert,
|
|
||||||
BackHandler,
|
|
||||||
View,
|
|
||||||
AppState,
|
|
||||||
AppStateStatus,
|
|
||||||
Platform,
|
|
||||||
} from "react-native";
|
|
||||||
import { useSharedValue } from "react-native-reanimated";
|
import { useSharedValue } from "react-native-reanimated";
|
||||||
import settings from "../(tabs)/(home)/settings";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { BaseItemDto, MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const videoRef = useRef<VlcPlayerViewRef>(null);
|
const videoRef = useRef<VlcPlayerViewRef>(null);
|
||||||
const user = useAtomValue(userAtom);
|
const user = useAtomValue(userAtom);
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const navigation = useNavigation();
|
||||||
|
|
||||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
||||||
const [showControls, _setShowControls] = useState(true);
|
const [showControls, _setShowControls] = useState(true);
|
||||||
@@ -63,12 +44,16 @@ export default function page() {
|
|||||||
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 [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
||||||
|
const [isPipStarted, setIsPipStarted] = useState(false);
|
||||||
|
|
||||||
const progress = useSharedValue(0);
|
const progress = useSharedValue(0);
|
||||||
const isSeeking = useSharedValue(false);
|
const isSeeking = useSharedValue(false);
|
||||||
const cacheProgress = useSharedValue(0);
|
const cacheProgress = useSharedValue(0);
|
||||||
|
let getDownloadedItem = null;
|
||||||
|
if (!Platform.isTV) {
|
||||||
|
getDownloadedItem = downloadProvider.useDownload();
|
||||||
|
}
|
||||||
|
|
||||||
const { getDownloadedItem } = useDownload();
|
|
||||||
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
||||||
|
|
||||||
const lightHapticFeedback = useHaptic("light");
|
const lightHapticFeedback = useHaptic("light");
|
||||||
@@ -94,145 +79,116 @@ export default function page() {
|
|||||||
offline: string;
|
offline: string;
|
||||||
}>();
|
}>();
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
const offline = offlineStr === "true";
|
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;
|
||||||
const bitrateValue = bitrateValueStr
|
const bitrateValue = bitrateValueStr ? parseInt(bitrateValueStr, 10) : BITRATES[0].value;
|
||||||
? parseInt(bitrateValueStr, 10)
|
|
||||||
: BITRATES[0].value;
|
|
||||||
|
|
||||||
const {
|
const [item, setItem] = useState<BaseItemDto | null>(null);
|
||||||
data: item,
|
const [itemStatus, setItemStatus] = useState({
|
||||||
isLoading: isLoadingItem,
|
isLoading: true,
|
||||||
isError: isErrorItem,
|
isError: false,
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["item", itemId],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (offline) {
|
|
||||||
const item = await getDownloadedItem(itemId);
|
|
||||||
if (item) return item.item;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await getUserLibraryApi(api!).getItem({
|
|
||||||
itemId,
|
|
||||||
userId: user?.Id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.data;
|
|
||||||
},
|
|
||||||
enabled: !!itemId,
|
|
||||||
staleTime: 0,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
useEffect(() => {
|
||||||
data: stream,
|
const fetchItemData = async () => {
|
||||||
isLoading: isLoadingStreamUrl,
|
setItemStatus({ isLoading: true, isError: false });
|
||||||
isError: isErrorStreamUrl,
|
try {
|
||||||
} = useQuery({
|
let fetchedItem: BaseItemDto | null = null;
|
||||||
queryKey: ["stream-url", itemId, mediaSourceId, bitrateValue],
|
if (offline && !Platform.isTV) {
|
||||||
queryFn: async () => {
|
const data = await getDownloadedItem.getDownloadedItem(itemId);
|
||||||
if (offline) {
|
if (data) fetchedItem = data.item as BaseItemDto;
|
||||||
const data = await getDownloadedItem(itemId);
|
} else {
|
||||||
if (!data?.mediaSource) return null;
|
const res = await getUserLibraryApi(api!).getItem({
|
||||||
|
itemId,
|
||||||
const url = await getDownloadedFileUrl(data.item.Id!);
|
userId: user?.Id,
|
||||||
|
});
|
||||||
if (item)
|
fetchedItem = res.data;
|
||||||
return {
|
}
|
||||||
mediaSource: data.mediaSource,
|
setItem(fetchedItem);
|
||||||
url,
|
} catch (error) {
|
||||||
sessionId: undefined,
|
console.error("Failed to fetch item:", error);
|
||||||
};
|
setItemStatus({ isLoading: false, isError: true });
|
||||||
|
} finally {
|
||||||
|
setItemStatus({ isLoading: false, isError: false });
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const res = await getStreamUrl({
|
if (itemId) {
|
||||||
api,
|
fetchItemData();
|
||||||
item,
|
}
|
||||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
}, [itemId, offline, api, user?.Id]);
|
||||||
userId: user?.Id,
|
|
||||||
audioStreamIndex: audioIndex,
|
|
||||||
maxStreamingBitrate: bitrateValue,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
subtitleStreamIndex: subtitleIndex,
|
|
||||||
deviceProfile: native,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res) return null;
|
interface Stream {
|
||||||
|
mediaSource: MediaSourceInfo;
|
||||||
|
sessionId: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
const { mediaSource, sessionId, url } = res;
|
const [stream, setStream] = useState<Stream | null>(null);
|
||||||
|
const [streamStatus, setStreamStatus] = useState({
|
||||||
if (!sessionId || !mediaSource || !url) {
|
isLoading: true,
|
||||||
Alert.alert(t("player.error"), t("player.failed_to_get_stream_url"));
|
isError: false,
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
mediaSource,
|
|
||||||
sessionId,
|
|
||||||
url,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
enabled: !!itemId && !!item,
|
|
||||||
staleTime: 0,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchStreamData = async () => {
|
||||||
|
try {
|
||||||
|
let result: Stream | null = null;
|
||||||
|
if (offline && !Platform.isTV) {
|
||||||
|
const data = await getDownloadedItem.getDownloadedItem(itemId);
|
||||||
|
if (!data?.mediaSource) return;
|
||||||
|
const url = await getDownloadedFileUrl(data.item.Id!);
|
||||||
|
if (item) {
|
||||||
|
result = { mediaSource: data.mediaSource, sessionId: "", url };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const res = await getStreamUrl({
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
||||||
|
userId: user?.Id,
|
||||||
|
audioStreamIndex: audioIndex,
|
||||||
|
maxStreamingBitrate: bitrateValue,
|
||||||
|
mediaSourceId: mediaSourceId,
|
||||||
|
subtitleStreamIndex: subtitleIndex,
|
||||||
|
deviceProfile: native,
|
||||||
|
});
|
||||||
|
if (!res) return;
|
||||||
|
const { mediaSource, sessionId, url } = res;
|
||||||
|
if (!sessionId || !mediaSource || !url) {
|
||||||
|
Alert.alert(t("player.error"), t("player.failed_to_get_stream_url"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
result = { mediaSource, sessionId, url };
|
||||||
|
}
|
||||||
|
setStream(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch stream:", error);
|
||||||
|
setStreamStatus({ isLoading: false, isError: true });
|
||||||
|
} finally {
|
||||||
|
setStreamStatus({ isLoading: false, isError: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchStreamData();
|
||||||
|
}, [itemId, mediaSourceId, bitrateValue, api, item, user?.Id]);
|
||||||
|
|
||||||
const togglePlay = useCallback(async () => {
|
const togglePlay = useCallback(async () => {
|
||||||
if (!api) return;
|
if (!api) return;
|
||||||
|
|
||||||
lightHapticFeedback();
|
lightHapticFeedback();
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
await videoRef.current?.pause();
|
await videoRef.current?.pause();
|
||||||
|
|
||||||
if (!offline && stream) {
|
|
||||||
await getPlaystateApi(api).onPlaybackProgress({
|
|
||||||
itemId: item?.Id!,
|
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
positionTicks: msToTicks(progress.value),
|
|
||||||
isPaused: true,
|
|
||||||
playMethod: stream.url?.includes("m3u8")
|
|
||||||
? "Transcode"
|
|
||||||
: "DirectStream",
|
|
||||||
playSessionId: stream.sessionId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
videoRef.current?.play();
|
videoRef.current?.play();
|
||||||
if (!offline && stream) {
|
|
||||||
await getPlaystateApi(api).onPlaybackProgress({
|
|
||||||
itemId: item?.Id!,
|
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
positionTicks: msToTicks(progress.value),
|
|
||||||
isPaused: false,
|
|
||||||
playMethod: stream?.url.includes("m3u8")
|
|
||||||
? "Transcode"
|
|
||||||
: "DirectStream",
|
|
||||||
playSessionId: stream.sessionId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [
|
}, [isPlaying, api, item, stream, videoRef, audioIndex, subtitleIndex, mediaSourceId, offline, progress]);
|
||||||
isPlaying,
|
|
||||||
api,
|
|
||||||
item,
|
|
||||||
stream,
|
|
||||||
videoRef,
|
|
||||||
audioIndex,
|
|
||||||
subtitleIndex,
|
|
||||||
mediaSourceId,
|
|
||||||
offline,
|
|
||||||
progress.value,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const reportPlaybackStopped = useCallback(async () => {
|
const reportPlaybackStopped = useCallback(async () => {
|
||||||
if (offline) return;
|
if (offline) return;
|
||||||
|
const currentTimeInTicks = msToTicks(progress.get());
|
||||||
const currentTimeInTicks = msToTicks(progress.value);
|
|
||||||
|
|
||||||
await getPlaystateApi(api!).onPlaybackStopped({
|
await getPlaystateApi(api!).onPlaybackStopped({
|
||||||
itemId: item?.Id!,
|
itemId: item?.Id!,
|
||||||
mediaSourceId: mediaSourceId,
|
mediaSourceId: mediaSourceId,
|
||||||
@@ -249,33 +205,23 @@ export default function page() {
|
|||||||
videoRef.current?.stop();
|
videoRef.current?.stop();
|
||||||
}, [videoRef, reportPlaybackStopped]);
|
}, [videoRef, reportPlaybackStopped]);
|
||||||
|
|
||||||
// TODO: unused should remove.
|
useEffect(() => {
|
||||||
const reportPlaybackStart = useCallback(async () => {
|
const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
|
||||||
if (offline) return;
|
return () => {
|
||||||
|
beforeRemoveListener();
|
||||||
if (!stream) return;
|
};
|
||||||
await getPlaystateApi(api!).onPlaybackStart({
|
}, [navigation, stop]);
|
||||||
itemId: item?.Id!,
|
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
playMethod: stream.url?.includes("m3u8") ? "Transcode" : "DirectStream",
|
|
||||||
playSessionId: stream?.sessionId ? stream?.sessionId : undefined,
|
|
||||||
});
|
|
||||||
}, [api, item, mediaSourceId, stream]);
|
|
||||||
|
|
||||||
const onProgress = useCallback(
|
const onProgress = useCallback(
|
||||||
async (data: ProgressUpdatePayload) => {
|
async (data: ProgressUpdatePayload) => {
|
||||||
if (isSeeking.value === true) return;
|
if (isSeeking.get() || isPlaybackStopped) return;
|
||||||
if (isPlaybackStopped === true) return;
|
|
||||||
|
|
||||||
const { currentTime } = data.nativeEvent;
|
const { currentTime } = data.nativeEvent;
|
||||||
|
|
||||||
if (isBuffering) {
|
if (isBuffering) {
|
||||||
setIsBuffering(false);
|
setIsBuffering(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
progress.value = currentTime;
|
progress.set(currentTime);
|
||||||
|
|
||||||
if (offline) return;
|
if (offline) return;
|
||||||
|
|
||||||
@@ -294,11 +240,56 @@ export default function page() {
|
|||||||
playSessionId: stream.sessionId,
|
playSessionId: stream.sessionId,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[item?.Id, isPlaying, api, isPlaybackStopped, audioIndex, subtitleIndex]
|
[item?.Id, audioIndex, subtitleIndex, mediaSourceId, isPlaying, stream, isSeeking, isPlaybackStopped, isBuffering]
|
||||||
);
|
);
|
||||||
|
|
||||||
useOrientation();
|
const onPipStarted = useCallback((e: PipStartedPayload) => {
|
||||||
useOrientationSettings();
|
const { pipStarted } = e.nativeEvent;
|
||||||
|
setIsPipStarted(pipStarted);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const changePlaybackState = useCallback(
|
||||||
|
async (isPlaying: boolean) => {
|
||||||
|
if (!api || offline || !stream) return;
|
||||||
|
await getPlaystateApi(api).onPlaybackProgress({
|
||||||
|
itemId: item?.Id!,
|
||||||
|
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||||
|
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||||
|
mediaSourceId: mediaSourceId,
|
||||||
|
positionTicks: msToTicks(progress.get()),
|
||||||
|
isPaused: !isPlaying,
|
||||||
|
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||||
|
playSessionId: stream.sessionId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[api, offline, stream, item?.Id, audioIndex, subtitleIndex, mediaSourceId, progress]
|
||||||
|
);
|
||||||
|
|
||||||
|
const startPosition = useMemo(() => {
|
||||||
|
if (offline) return 0;
|
||||||
|
return item?.UserData?.PlaybackPositionTicks ? ticksToSeconds(item.UserData.PlaybackPositionTicks) : 0;
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
const reportPlaybackStart = useCallback(async () => {
|
||||||
|
if (offline || !stream) return;
|
||||||
|
await getPlaystateApi(api!).onPlaybackStart({
|
||||||
|
itemId: item?.Id!,
|
||||||
|
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||||
|
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||||
|
mediaSourceId: mediaSourceId,
|
||||||
|
playMethod: stream.url?.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||||
|
playSessionId: stream?.sessionId ? stream?.sessionId : undefined,
|
||||||
|
});
|
||||||
|
hasReportedRef.current = true;
|
||||||
|
}, [api, item, stream]);
|
||||||
|
|
||||||
|
const hasReportedRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (stream && !hasReportedRef.current) {
|
||||||
|
reportPlaybackStart();
|
||||||
|
hasReportedRef.current = true; // Mark as reported
|
||||||
|
}
|
||||||
|
}, [stream]);
|
||||||
|
|
||||||
useWebSocket({
|
useWebSocket({
|
||||||
isPlaying: isPlaying,
|
isPlaying: isPlaying,
|
||||||
@@ -307,125 +298,82 @@ export default function page() {
|
|||||||
offline,
|
offline,
|
||||||
});
|
});
|
||||||
|
|
||||||
const onPlaybackStateChanged = useCallback((e: PlaybackStatePayload) => {
|
const onPlaybackStateChanged = useCallback(
|
||||||
const { state, isBuffering, isPlaying } = e.nativeEvent;
|
async (e: PlaybackStatePayload) => {
|
||||||
|
const { state, isBuffering, isPlaying } = e.nativeEvent;
|
||||||
|
|
||||||
if (state === "Playing") {
|
if (state === "Playing") {
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
return;
|
await changePlaybackState(true);
|
||||||
}
|
if (!Platform.isTV) await activateKeepAwakeAsync();
|
||||||
|
return;
|
||||||
if (state === "Paused") {
|
|
||||||
setIsPlaying(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isPlaying) {
|
|
||||||
setIsPlaying(true);
|
|
||||||
setIsBuffering(false);
|
|
||||||
} else if (isBuffering) {
|
|
||||||
setIsBuffering(true);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const startPosition = useMemo(() => {
|
|
||||||
if (offline) return 0;
|
|
||||||
|
|
||||||
return item?.UserData?.PlaybackPositionTicks
|
|
||||||
? ticksToSeconds(item.UserData.PlaybackPositionTicks)
|
|
||||||
: 0;
|
|
||||||
}, [item]);
|
|
||||||
|
|
||||||
useFocusEffect(
|
|
||||||
React.useCallback(() => {
|
|
||||||
return async () => {
|
|
||||||
stop();
|
|
||||||
};
|
|
||||||
}, [])
|
|
||||||
);
|
|
||||||
|
|
||||||
const [appState, setAppState] = useState(AppState.currentState);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleAppStateChange = (nextAppState: AppStateStatus) => {
|
|
||||||
if (appState.match(/inactive|background/) && nextAppState === "active") {
|
|
||||||
// Handle app coming to the foreground
|
|
||||||
} else if (nextAppState.match(/inactive|background/)) {
|
|
||||||
// Handle app going to the background
|
|
||||||
if (videoRef.current && videoRef.current.pause) {
|
|
||||||
videoRef.current.pause();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
setAppState(nextAppState);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Use AppState.addEventListener and return a cleanup function
|
if (state === "Paused") {
|
||||||
const subscription = AppState.addEventListener(
|
setIsPlaying(false);
|
||||||
"change",
|
await changePlaybackState(false);
|
||||||
handleAppStateChange
|
if (!Platform.isTV) await deactivateKeepAwake();
|
||||||
);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
if (isPlaying) {
|
||||||
// Cleanup the event listener when the component is unmounted
|
setIsPlaying(true);
|
||||||
subscription.remove();
|
setIsBuffering(false);
|
||||||
};
|
} else if (isBuffering) {
|
||||||
}, [appState]);
|
setIsBuffering(true);
|
||||||
|
}
|
||||||
// Preselection of audio and subtitle tracks.
|
},
|
||||||
|
[changePlaybackState]
|
||||||
if (!settings) return null;
|
|
||||||
|
|
||||||
let initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
|
|
||||||
let externalTrack = { name: "", DeliveryUrl: "" };
|
|
||||||
|
|
||||||
const allSubs =
|
|
||||||
stream?.mediaSource.MediaStreams?.filter(
|
|
||||||
(sub) => sub.Type === "Subtitle"
|
|
||||||
) || [];
|
|
||||||
const chosenSubtitleTrack = allSubs.find(
|
|
||||||
(sub) => sub.Index === subtitleIndex
|
|
||||||
);
|
);
|
||||||
const allAudio =
|
|
||||||
stream?.mediaSource.MediaStreams?.filter(
|
const allAudio = stream?.mediaSource.MediaStreams?.filter((audio) => audio.Type === "Audio") || [];
|
||||||
(audio) => audio.Type === "Audio"
|
|
||||||
|
// Move all the external subtitles last, because vlc places them last.
|
||||||
|
const allSubs =
|
||||||
|
stream?.mediaSource.MediaStreams?.filter((sub) => sub.Type === "Subtitle").sort(
|
||||||
|
(a, b) => Number(a.IsExternal) - Number(b.IsExternal)
|
||||||
) || [];
|
) || [];
|
||||||
|
|
||||||
|
const externalSubtitles = allSubs
|
||||||
|
.filter((sub: any) => sub.DeliveryMethod === "External")
|
||||||
|
.map((sub: any) => ({
|
||||||
|
name: sub.DisplayTitle,
|
||||||
|
DeliveryUrl: api?.basePath + sub.DeliveryUrl,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream);
|
||||||
|
|
||||||
|
const chosenSubtitleTrack = allSubs.find((sub) => sub.Index === subtitleIndex);
|
||||||
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
|
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
|
||||||
|
|
||||||
// Direct playback CASE
|
const notTranscoding = !stream?.mediaSource.TranscodingUrl;
|
||||||
if (!bitrateValue) {
|
let initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
|
||||||
// If Subtitle is embedded we can use the position to select it straight away.
|
if (chosenSubtitleTrack && (notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)) {
|
||||||
if (chosenSubtitleTrack && !chosenSubtitleTrack.DeliveryUrl) {
|
const finalIndex = notTranscoding ? allSubs.indexOf(chosenSubtitleTrack) : textSubs.indexOf(chosenSubtitleTrack);
|
||||||
initOptions.push(`--sub-track=${allSubs.indexOf(chosenSubtitleTrack)}`);
|
initOptions.push(`--sub-track=${finalIndex}`);
|
||||||
} else if (chosenSubtitleTrack && chosenSubtitleTrack.DeliveryUrl) {
|
|
||||||
// If Subtitle is external we need to pass the URL to the player.
|
|
||||||
externalTrack = {
|
|
||||||
name: chosenSubtitleTrack.DisplayTitle || "",
|
|
||||||
DeliveryUrl: `${api?.basePath || ""}${chosenSubtitleTrack.DeliveryUrl}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chosenAudioTrack)
|
|
||||||
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
|
|
||||||
} else {
|
|
||||||
// Transcoded playback CASE
|
|
||||||
if (chosenSubtitleTrack?.DeliveryMethod === "Hls") {
|
|
||||||
externalTrack = {
|
|
||||||
name: `subs ${chosenSubtitleTrack.DisplayTitle}`,
|
|
||||||
DeliveryUrl: "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
if (notTranscoding && chosenAudioTrack) {
|
||||||
|
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (!item || isLoadingItem || isLoadingStreamUrl || !stream)
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
|
|
||||||
|
// Add useEffect to handle mounting
|
||||||
|
useEffect(() => {
|
||||||
|
setIsMounted(true);
|
||||||
|
return () => setIsMounted(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (itemStatus.isLoading || streamStatus.isLoading) {
|
||||||
return (
|
return (
|
||||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
||||||
<Loader />
|
<Loader />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (isErrorItem || isErrorStreamUrl)
|
if (!item || !stream || itemStatus.isError || streamStatus.isError)
|
||||||
return (
|
return (
|
||||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
||||||
<Text className="text-white">{t("player.error")}</Text>
|
<Text className="text-white">{t("player.error")}</Text>
|
||||||
@@ -449,32 +397,29 @@ export default function page() {
|
|||||||
<VlcPlayerView
|
<VlcPlayerView
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
source={{
|
source={{
|
||||||
uri: stream.url,
|
uri: stream?.url || "",
|
||||||
autoplay: true,
|
autoplay: true,
|
||||||
isNetwork: true,
|
isNetwork: true,
|
||||||
startPosition,
|
startPosition,
|
||||||
externalTrack,
|
externalSubtitles,
|
||||||
initOptions,
|
initOptions,
|
||||||
}}
|
}}
|
||||||
style={{ width: "100%", height: "100%" }}
|
style={{ width: "100%", height: "100%" }}
|
||||||
onVideoProgress={onProgress}
|
onVideoProgress={onProgress}
|
||||||
progressUpdateInterval={1000}
|
progressUpdateInterval={1000}
|
||||||
onVideoStateChange={onPlaybackStateChanged}
|
onVideoStateChange={onPlaybackStateChanged}
|
||||||
onVideoLoadStart={() => {}}
|
onPipStarted={onPipStarted}
|
||||||
onVideoLoadEnd={() => {
|
onVideoLoadEnd={() => {
|
||||||
setIsVideoLoaded(true);
|
setIsVideoLoaded(true);
|
||||||
}}
|
}}
|
||||||
onVideoError={(e) => {
|
onVideoError={(e) => {
|
||||||
console.error("Video Error:", e.nativeEvent);
|
console.error("Video Error:", e.nativeEvent);
|
||||||
Alert.alert(
|
Alert.alert(t("player.error"), t("player.an_error_occured_while_playing_the_video"));
|
||||||
t("player.error"),
|
|
||||||
t("player.an_error_occured_while_playing_the_video")
|
|
||||||
);
|
|
||||||
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
{videoRef.current && (
|
{videoRef.current && !isPipStarted && isMounted === true ? (
|
||||||
<Controls
|
<Controls
|
||||||
mediaSource={stream?.mediaSource}
|
mediaSource={stream?.mediaSource}
|
||||||
item={item}
|
item={item}
|
||||||
@@ -490,6 +435,7 @@ export default function page() {
|
|||||||
setIgnoreSafeAreas={setIgnoreSafeAreas}
|
setIgnoreSafeAreas={setIgnoreSafeAreas}
|
||||||
ignoreSafeAreas={ignoreSafeAreas}
|
ignoreSafeAreas={ignoreSafeAreas}
|
||||||
isVideoLoaded={isVideoLoaded}
|
isVideoLoaded={isVideoLoaded}
|
||||||
|
startPictureInPicture={videoRef?.current?.startPictureInPicture}
|
||||||
play={videoRef.current?.play}
|
play={videoRef.current?.play}
|
||||||
pause={videoRef.current?.pause}
|
pause={videoRef.current?.pause}
|
||||||
seek={videoRef.current?.seekTo}
|
seek={videoRef.current?.seekTo}
|
||||||
@@ -500,29 +446,9 @@ export default function page() {
|
|||||||
setSubtitleTrack={videoRef.current.setSubtitleTrack}
|
setSubtitleTrack={videoRef.current.setSubtitleTrack}
|
||||||
setSubtitleURL={videoRef.current.setSubtitleURL}
|
setSubtitleURL={videoRef.current.setSubtitleURL}
|
||||||
setAudioTrack={videoRef.current.setAudioTrack}
|
setAudioTrack={videoRef.current.setAudioTrack}
|
||||||
stop={stop}
|
|
||||||
isVlc
|
isVlc
|
||||||
/>
|
/>
|
||||||
)}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePoster(
|
|
||||||
item: BaseItemDto,
|
|
||||||
api: Api | null
|
|
||||||
): string | undefined {
|
|
||||||
const poster = useMemo(() => {
|
|
||||||
if (!item || !api) return undefined;
|
|
||||||
return item.Type === "Audio"
|
|
||||||
? `${api.basePath}/Items/${item.AlbumId}/Images/Primary?tag=${item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
|
|
||||||
: getBackdropUrl({
|
|
||||||
api,
|
|
||||||
item: item,
|
|
||||||
quality: 70,
|
|
||||||
width: 200,
|
|
||||||
});
|
|
||||||
}, [item, api]);
|
|
||||||
|
|
||||||
return poster ?? undefined;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,547 +0,0 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { Controls } from "@/components/video-player/controls/Controls";
|
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
|
||||||
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
|
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
|
||||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
|
||||||
import { TrackInfo } from "@/modules/vlc-player";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
|
||||||
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
|
||||||
import transcoding from "@/utils/profiles/transcoding";
|
|
||||||
import { secondsToTicks } from "@/utils/secondsToTicks";
|
|
||||||
import { Api } from "@jellyfin/sdk";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import {
|
|
||||||
getPlaystateApi,
|
|
||||||
getUserLibraryApi,
|
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
|
||||||
import { useFocusEffect, useLocalSearchParams } from "expo-router";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import React, {
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { View } from "react-native";
|
|
||||||
import { useSharedValue } from "react-native-reanimated";
|
|
||||||
import Video, {
|
|
||||||
OnProgressData,
|
|
||||||
SelectedTrack,
|
|
||||||
SelectedTrackType,
|
|
||||||
VideoRef,
|
|
||||||
} from "react-native-video";
|
|
||||||
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
const Player = () => {
|
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const user = useAtomValue(userAtom);
|
|
||||||
const [settings] = useSettings();
|
|
||||||
const videoRef = useRef<VideoRef | null>(null);
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const firstTime = useRef(true);
|
|
||||||
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
|
||||||
const lightHapticFeedback = useHaptic("light");
|
|
||||||
|
|
||||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
|
||||||
const [showControls, _setShowControls] = useState(true);
|
|
||||||
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
|
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
|
||||||
const [isBuffering, setIsBuffering] = useState(true);
|
|
||||||
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
|
||||||
|
|
||||||
const setShowControls = useCallback((show: boolean) => {
|
|
||||||
_setShowControls(show);
|
|
||||||
lightHapticFeedback();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const progress = useSharedValue(0);
|
|
||||||
const isSeeking = useSharedValue(false);
|
|
||||||
const cacheProgress = useSharedValue(0);
|
|
||||||
|
|
||||||
const {
|
|
||||||
itemId,
|
|
||||||
audioIndex: audioIndexStr,
|
|
||||||
subtitleIndex: subtitleIndexStr,
|
|
||||||
mediaSourceId,
|
|
||||||
bitrateValue: bitrateValueStr,
|
|
||||||
} = useLocalSearchParams<{
|
|
||||||
itemId: string;
|
|
||||||
audioIndex: string;
|
|
||||||
subtitleIndex: string;
|
|
||||||
mediaSourceId: string;
|
|
||||||
bitrateValue: string;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
|
|
||||||
const subtitleIndex = subtitleIndexStr
|
|
||||||
? parseInt(subtitleIndexStr, 10)
|
|
||||||
: undefined;
|
|
||||||
const bitrateValue = bitrateValueStr
|
|
||||||
? parseInt(bitrateValueStr, 10)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: item,
|
|
||||||
isLoading: isLoadingItem,
|
|
||||||
isError: isErrorItem,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["item", itemId],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api) {
|
|
||||||
throw new Error("No api");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!itemId) {
|
|
||||||
console.warn("No itemId");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await getUserLibraryApi(api).getItem({
|
|
||||||
itemId,
|
|
||||||
userId: user?.Id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.data;
|
|
||||||
},
|
|
||||||
staleTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
// TODO: NEED TO FIND A WAY TO FROM SWITCHING TO IMAGE BASED TO TEXT BASED SUBTITLES, THERE IS A BUG.
|
|
||||||
// MOST LIKELY LIKELY NEED A MASSIVE REFACTOR.
|
|
||||||
const {
|
|
||||||
data: stream,
|
|
||||||
isLoading: isLoadingStreamUrl,
|
|
||||||
isError: isErrorStreamUrl,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["stream-url", itemId, bitrateValue, mediaSourceId, audioIndex],
|
|
||||||
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api) {
|
|
||||||
throw new Error("No api");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!item) {
|
|
||||||
console.warn("No item", itemId, item);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await getStreamUrl({
|
|
||||||
api,
|
|
||||||
item,
|
|
||||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
|
||||||
userId: user?.Id,
|
|
||||||
audioStreamIndex: audioIndex,
|
|
||||||
maxStreamingBitrate: bitrateValue,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
subtitleStreamIndex: subtitleIndex,
|
|
||||||
deviceProfile: transcoding,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res) return null;
|
|
||||||
|
|
||||||
const { mediaSource, sessionId, url } = res;
|
|
||||||
|
|
||||||
if (!sessionId || !mediaSource || !url) {
|
|
||||||
console.warn("No sessionId or mediaSource or url", url);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
mediaSource,
|
|
||||||
sessionId,
|
|
||||||
url,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
enabled: !!item,
|
|
||||||
staleTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const poster = usePoster(item, api);
|
|
||||||
const videoSource = useVideoSource(item, api, poster, stream?.url);
|
|
||||||
|
|
||||||
const togglePlay = useCallback(async () => {
|
|
||||||
lightHapticFeedback();
|
|
||||||
if (isPlaying) {
|
|
||||||
videoRef.current?.pause();
|
|
||||||
await getPlaystateApi(api!).onPlaybackProgress({
|
|
||||||
itemId: item?.Id!,
|
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
positionTicks: Math.floor(progress.value),
|
|
||||||
isPaused: true,
|
|
||||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
|
||||||
playSessionId: stream?.sessionId,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
videoRef.current?.resume();
|
|
||||||
await getPlaystateApi(api!).onPlaybackProgress({
|
|
||||||
itemId: item?.Id!,
|
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
positionTicks: Math.floor(progress.value),
|
|
||||||
isPaused: false,
|
|
||||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
|
||||||
playSessionId: stream?.sessionId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
isPlaying,
|
|
||||||
api,
|
|
||||||
item,
|
|
||||||
videoRef,
|
|
||||||
settings,
|
|
||||||
stream,
|
|
||||||
audioIndex,
|
|
||||||
subtitleIndex,
|
|
||||||
mediaSourceId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const play = useCallback(() => {
|
|
||||||
videoRef.current?.resume();
|
|
||||||
reportPlaybackStart();
|
|
||||||
}, [videoRef]);
|
|
||||||
|
|
||||||
const pause = useCallback(() => {
|
|
||||||
videoRef.current?.pause();
|
|
||||||
}, [videoRef]);
|
|
||||||
|
|
||||||
const seek = useCallback(
|
|
||||||
(seconds: number) => {
|
|
||||||
videoRef.current?.seek(seconds);
|
|
||||||
},
|
|
||||||
[videoRef]
|
|
||||||
);
|
|
||||||
|
|
||||||
const reportPlaybackStopped = async () => {
|
|
||||||
if (!item?.Id) return;
|
|
||||||
await getPlaystateApi(api!).onPlaybackStopped({
|
|
||||||
itemId: item.Id,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
positionTicks: Math.floor(progress.value),
|
|
||||||
playSessionId: stream?.sessionId,
|
|
||||||
});
|
|
||||||
revalidateProgressCache();
|
|
||||||
};
|
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
|
||||||
reportPlaybackStopped();
|
|
||||||
videoRef.current?.pause();
|
|
||||||
setIsPlaybackStopped(true);
|
|
||||||
}, [videoRef, reportPlaybackStopped]);
|
|
||||||
|
|
||||||
const reportPlaybackStart = async () => {
|
|
||||||
if (!item?.Id) return;
|
|
||||||
await getPlaystateApi(api!).onPlaybackStart({
|
|
||||||
itemId: item.Id,
|
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
|
||||||
playSessionId: stream?.sessionId,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const onProgress = useCallback(
|
|
||||||
async (data: OnProgressData) => {
|
|
||||||
if (isSeeking.value === true) return;
|
|
||||||
if (isPlaybackStopped === true) return;
|
|
||||||
|
|
||||||
const ticks = secondsToTicks(data.currentTime);
|
|
||||||
|
|
||||||
progress.value = ticks;
|
|
||||||
cacheProgress.value = secondsToTicks(data.playableDuration);
|
|
||||||
|
|
||||||
// TODO: Use this when streaming with HLS url, but NOT when direct playing
|
|
||||||
// TODO: since playable duration is always 0 then.
|
|
||||||
setIsBuffering(data.playableDuration === 0);
|
|
||||||
|
|
||||||
if (!item?.Id || data.currentTime === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await getPlaystateApi(api!).onPlaybackProgress({
|
|
||||||
itemId: item.Id,
|
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
positionTicks: Math.round(ticks),
|
|
||||||
isPaused: !isPlaying,
|
|
||||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
|
||||||
playSessionId: stream?.sessionId,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[
|
|
||||||
item,
|
|
||||||
isPlaying,
|
|
||||||
api,
|
|
||||||
isPlaybackStopped,
|
|
||||||
isSeeking,
|
|
||||||
stream,
|
|
||||||
mediaSourceId,
|
|
||||||
audioIndex,
|
|
||||||
subtitleIndex,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
useOrientation();
|
|
||||||
useOrientationSettings();
|
|
||||||
|
|
||||||
useWebSocket({
|
|
||||||
isPlaying: isPlaying,
|
|
||||||
togglePlay: togglePlay,
|
|
||||||
stopPlayback: stop,
|
|
||||||
offline: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [selectedTextTrack, setSelectedTextTrack] = useState<
|
|
||||||
SelectedTrack | undefined
|
|
||||||
>();
|
|
||||||
|
|
||||||
const [embededTextTracks, setEmbededTextTracks] = useState<
|
|
||||||
{
|
|
||||||
index: number;
|
|
||||||
language?: string | undefined;
|
|
||||||
selected?: boolean | undefined;
|
|
||||||
title?: string | undefined;
|
|
||||||
type: any;
|
|
||||||
}[]
|
|
||||||
>([]);
|
|
||||||
|
|
||||||
const [audioTracks, setAudioTracks] = useState<TrackInfo[]>([]);
|
|
||||||
const [selectedAudioTrack, setSelectedAudioTrack] = useState<
|
|
||||||
SelectedTrack | undefined
|
|
||||||
>(undefined);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedTextTrack === undefined) {
|
|
||||||
const subtitleHelper = new SubtitleHelper(
|
|
||||||
stream?.mediaSource.MediaStreams ?? []
|
|
||||||
);
|
|
||||||
const embeddedTrackIndex = subtitleHelper.getEmbeddedTrackIndex(
|
|
||||||
subtitleIndex!
|
|
||||||
);
|
|
||||||
|
|
||||||
// Most likely the subtitle is burned in.
|
|
||||||
if (embeddedTrackIndex === -1) return;
|
|
||||||
|
|
||||||
setSelectedTextTrack({
|
|
||||||
type: SelectedTrackType.INDEX,
|
|
||||||
value: embeddedTrackIndex,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [embededTextTracks]);
|
|
||||||
|
|
||||||
const getAudioTracks = (): TrackInfo[] => {
|
|
||||||
return audioTracks.map((t) => ({
|
|
||||||
name: t.name,
|
|
||||||
index: t.index,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSubtitleTracks = (): TrackInfo[] => {
|
|
||||||
return embededTextTracks.map((t) => ({
|
|
||||||
name: t.title ?? "",
|
|
||||||
index: t.index,
|
|
||||||
language: t.language,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
useFocusEffect(
|
|
||||||
React.useCallback(() => {
|
|
||||||
return async () => {
|
|
||||||
stop();
|
|
||||||
};
|
|
||||||
}, [])
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isLoadingItem || isLoadingStreamUrl)
|
|
||||||
return (
|
|
||||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isErrorItem || isErrorStreamUrl)
|
|
||||||
return (
|
|
||||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
|
||||||
<Text className="text-white">{t("player.error")}</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={{ flex: 1, backgroundColor: "black" }}>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
position: "relative",
|
|
||||||
flexDirection: "column",
|
|
||||||
justifyContent: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{videoSource ? (
|
|
||||||
<>
|
|
||||||
<Video
|
|
||||||
ref={videoRef}
|
|
||||||
source={videoSource}
|
|
||||||
style={{
|
|
||||||
height: "100%",
|
|
||||||
width: "100%",
|
|
||||||
}}
|
|
||||||
resizeMode={ignoreSafeAreas ? "cover" : "contain"}
|
|
||||||
onProgress={onProgress}
|
|
||||||
onError={(e) => {
|
|
||||||
console.error("Error playing video", e);
|
|
||||||
}}
|
|
||||||
onLoad={() => {
|
|
||||||
if (firstTime.current === true) {
|
|
||||||
play();
|
|
||||||
firstTime.current = false;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
progressUpdateInterval={500}
|
|
||||||
playWhenInactive={true}
|
|
||||||
allowsExternalPlayback={true}
|
|
||||||
playInBackground={true}
|
|
||||||
showNotificationControls={true}
|
|
||||||
ignoreSilentSwitch="ignore"
|
|
||||||
fullscreen={false}
|
|
||||||
onPlaybackStateChanged={(state) => {
|
|
||||||
if (isSeeking.value === false) setIsPlaying(state.isPlaying);
|
|
||||||
}}
|
|
||||||
onTextTracks={(data) => {
|
|
||||||
setEmbededTextTracks(data.textTracks as any);
|
|
||||||
}}
|
|
||||||
onBuffer={(e) => {
|
|
||||||
setIsBuffering(e.isBuffering);
|
|
||||||
}}
|
|
||||||
onAudioTracks={(e) => {
|
|
||||||
setAudioTracks(
|
|
||||||
e.audioTracks.map((t) => ({
|
|
||||||
index: t.index,
|
|
||||||
name: t.title ?? "",
|
|
||||||
language: t.language,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
selectedTextTrack={selectedTextTrack}
|
|
||||||
selectedAudioTrack={selectedAudioTrack}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Text>{t("player.no_video_source")}</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{item && (
|
|
||||||
<Controls
|
|
||||||
mediaSource={stream?.mediaSource}
|
|
||||||
videoRef={videoRef}
|
|
||||||
enableTrickplay={true}
|
|
||||||
item={item}
|
|
||||||
togglePlay={togglePlay}
|
|
||||||
isPlaying={isPlaying}
|
|
||||||
isSeeking={isSeeking}
|
|
||||||
progress={progress}
|
|
||||||
cacheProgress={cacheProgress}
|
|
||||||
isBuffering={isBuffering}
|
|
||||||
showControls={showControls}
|
|
||||||
setShowControls={setShowControls}
|
|
||||||
setIgnoreSafeAreas={setIgnoreSafeAreas}
|
|
||||||
ignoreSafeAreas={ignoreSafeAreas}
|
|
||||||
seek={seek}
|
|
||||||
play={play}
|
|
||||||
pause={pause}
|
|
||||||
stop={stop}
|
|
||||||
getSubtitleTracks={getSubtitleTracks}
|
|
||||||
setSubtitleTrack={(i) => {
|
|
||||||
if (i === -1) {
|
|
||||||
setSelectedTextTrack({
|
|
||||||
type: SelectedTrackType.DISABLED,
|
|
||||||
value: undefined,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSelectedTextTrack({
|
|
||||||
type: SelectedTrackType.INDEX,
|
|
||||||
value: i,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
getAudioTracks={getAudioTracks}
|
|
||||||
setAudioTrack={(i) => {
|
|
||||||
setSelectedAudioTrack({
|
|
||||||
type: SelectedTrackType.INDEX,
|
|
||||||
value: i,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export function usePoster(
|
|
||||||
item: BaseItemDto | null | undefined,
|
|
||||||
api: Api | null
|
|
||||||
): string | undefined {
|
|
||||||
const poster = useMemo(() => {
|
|
||||||
if (!item || !api) return undefined;
|
|
||||||
return item.Type === "Audio"
|
|
||||||
? `${api.basePath}/Items/${item.AlbumId}/Images/Primary?tag=${item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
|
|
||||||
: getBackdropUrl({
|
|
||||||
api,
|
|
||||||
item: item,
|
|
||||||
quality: 70,
|
|
||||||
width: 200,
|
|
||||||
});
|
|
||||||
}, [item, api]);
|
|
||||||
|
|
||||||
return poster ?? undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useVideoSource(
|
|
||||||
item: BaseItemDto | null | undefined,
|
|
||||||
api: Api | null,
|
|
||||||
poster: string | undefined,
|
|
||||||
url?: string | null
|
|
||||||
) {
|
|
||||||
const videoSource = useMemo(() => {
|
|
||||||
if (!item || !api || !url) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const startPosition = item?.UserData?.PlaybackPositionTicks
|
|
||||||
? Math.round(item.UserData.PlaybackPositionTicks / 10000)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
uri: url,
|
|
||||||
isNetwork: true,
|
|
||||||
startPosition,
|
|
||||||
headers: getAuthHeaders(api),
|
|
||||||
metadata: {
|
|
||||||
title: item?.Name || "Unknown",
|
|
||||||
description: item?.Overview ?? undefined,
|
|
||||||
imageUri: poster,
|
|
||||||
subtitle: item?.Album ?? undefined,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}, [item, api, poster, url]);
|
|
||||||
|
|
||||||
return videoSource;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Player;
|
|
||||||
473
app/_layout.tsx
473
app/_layout.tsx
@@ -1,5 +1,6 @@
|
|||||||
import "@/augmentations";
|
import "@/augmentations";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Platform } from "react-native";
|
||||||
|
import i18n from "@/i18n";
|
||||||
import { DownloadProvider } from "@/providers/DownloadProvider";
|
import { DownloadProvider } from "@/providers/DownloadProvider";
|
||||||
import {
|
import {
|
||||||
getOrSetDeviceId,
|
getOrSetDeviceId,
|
||||||
@@ -9,7 +10,6 @@ import {
|
|||||||
import { JobQueueProvider } from "@/providers/JobQueueProvider";
|
import { JobQueueProvider } from "@/providers/JobQueueProvider";
|
||||||
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
||||||
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
||||||
import { orientationAtom } from "@/utils/atoms/orientation";
|
|
||||||
import { Settings, useSettings } from "@/utils/atoms/settings";
|
import { Settings, useSettings } from "@/utils/atoms/settings";
|
||||||
import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks";
|
import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks";
|
||||||
import { LogProvider, writeToLog } from "@/utils/log";
|
import { LogProvider, writeToLog } from "@/utils/log";
|
||||||
@@ -18,64 +18,73 @@ 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 { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import {
|
const BackGroundDownloader = !Platform.isTV
|
||||||
checkForExistingDownloads,
|
? require("@kesha-antonov/react-native-background-downloader")
|
||||||
completeHandler,
|
: null;
|
||||||
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";
|
const BackgroundFetch = !Platform.isTV
|
||||||
|
? require("expo-background-fetch")
|
||||||
|
: null;
|
||||||
import * as FileSystem from "expo-file-system";
|
import * as FileSystem from "expo-file-system";
|
||||||
import { useFonts } from "expo-font";
|
const Notifications = !Platform.isTV ? require("expo-notifications") : null;
|
||||||
import { useKeepAwake } from "expo-keep-awake";
|
|
||||||
import * as Linking from "expo-linking";
|
|
||||||
import * as Notifications from "expo-notifications";
|
|
||||||
import { router, Stack } from "expo-router";
|
import { router, Stack } from "expo-router";
|
||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
|
||||||
import * as SplashScreen from "expo-splash-screen";
|
import * as SplashScreen from "expo-splash-screen";
|
||||||
import * as TaskManager from "expo-task-manager";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import { Provider as JotaiProvider, useAtom } from "jotai";
|
const TaskManager = !Platform.isTV ? require("expo-task-manager") : null;
|
||||||
|
import { getLocales } from "expo-localization";
|
||||||
|
import { Provider as JotaiProvider } from "jotai";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { Appearance, AppState, TouchableOpacity } from "react-native";
|
import { I18nextProvider } from "react-i18next";
|
||||||
|
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 { I18nextProvider, useTranslation } from "react-i18next";
|
|
||||||
import i18n from "@/i18n";
|
|
||||||
import { getLocales } from "expo-localization";
|
|
||||||
import "react-native-reanimated";
|
import "react-native-reanimated";
|
||||||
import { Toaster } from "sonner-native";
|
import { Toaster } from "sonner-native";
|
||||||
|
|
||||||
|
if (!Platform.isTV) {
|
||||||
|
Notifications.setNotificationHandler({
|
||||||
|
handleNotification: async () => ({
|
||||||
|
shouldShowAlert: true,
|
||||||
|
shouldPlaySound: true,
|
||||||
|
shouldSetBadge: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the splash screen visible while we fetch resources
|
||||||
SplashScreen.preventAutoHideAsync();
|
SplashScreen.preventAutoHideAsync();
|
||||||
|
|
||||||
Notifications.setNotificationHandler({
|
// Set the animation options. This is optional.
|
||||||
handleNotification: async () => ({
|
SplashScreen.setOptions({
|
||||||
shouldShowAlert: true,
|
duration: 500,
|
||||||
shouldPlaySound: true,
|
fade: true,
|
||||||
shouldSetBadge: false,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function useNotificationObserver() {
|
function useNotificationObserver() {
|
||||||
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
|
|
||||||
function redirect(notification: Notifications.Notification) {
|
function redirect(notification: typeof Notifications.Notification) {
|
||||||
const url = notification.request.content.data?.url;
|
const url = notification.request.content.data?.url;
|
||||||
if (url) {
|
if (url) {
|
||||||
router.push(url);
|
router.push(url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Notifications.getLastNotificationResponseAsync().then((response) => {
|
Notifications.getLastNotificationResponseAsync().then(
|
||||||
if (!isMounted || !response?.notification) {
|
(response: { notification: any }) => {
|
||||||
return;
|
if (!isMounted || !response?.notification) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
redirect(response?.notification);
|
||||||
}
|
}
|
||||||
redirect(response?.notification);
|
);
|
||||||
});
|
|
||||||
|
|
||||||
const subscription = Notifications.addNotificationResponseReceivedListener(
|
const subscription = Notifications.addNotificationResponseReceivedListener(
|
||||||
(response) => {
|
(response: { notification: any }) => {
|
||||||
redirect(response.notification);
|
redirect(response.notification);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -87,99 +96,101 @@ function useNotificationObserver() {
|
|||||||
}, []);
|
}, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
|
if (!Platform.isTV) {
|
||||||
console.log("TaskManager ~ trigger");
|
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
|
||||||
|
console.log("TaskManager ~ trigger");
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
const settingsData = storage.getString("settings");
|
const settingsData = storage.getString("settings");
|
||||||
|
|
||||||
if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData;
|
if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||||
|
|
||||||
const settings: Partial<Settings> = JSON.parse(settingsData);
|
const settings: Partial<Settings> = JSON.parse(settingsData);
|
||||||
const url = settings?.optimizedVersionsServerUrl;
|
const url = settings?.optimizedVersionsServerUrl;
|
||||||
|
|
||||||
if (!settings?.autoDownload || !url)
|
if (!settings?.autoDownload || !url)
|
||||||
return BackgroundFetch.BackgroundFetchResult.NoData;
|
return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||||
|
|
||||||
const token = getTokenFromStorage();
|
const token = getTokenFromStorage();
|
||||||
const deviceId = getOrSetDeviceId();
|
const deviceId = getOrSetDeviceId();
|
||||||
const baseDirectory = FileSystem.documentDirectory;
|
const baseDirectory = FileSystem.documentDirectory;
|
||||||
|
|
||||||
if (!token || !deviceId || !baseDirectory)
|
if (!token || !deviceId || !baseDirectory)
|
||||||
return BackgroundFetch.BackgroundFetchResult.NoData;
|
return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||||
|
|
||||||
const jobs = await getAllJobsByDeviceId({
|
const jobs = await getAllJobsByDeviceId({
|
||||||
deviceId,
|
deviceId,
|
||||||
authHeader: token,
|
authHeader: token,
|
||||||
url,
|
url,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("TaskManager ~ Active jobs: ", jobs.length);
|
console.log("TaskManager ~ Active jobs: ", jobs.length);
|
||||||
|
|
||||||
for (let job of jobs) {
|
for (let job of jobs) {
|
||||||
if (job.status === "completed") {
|
if (job.status === "completed") {
|
||||||
const downloadUrl = url + "download/" + job.id;
|
const downloadUrl = url + "download/" + job.id;
|
||||||
const tasks = await checkForExistingDownloads();
|
const tasks = await BackGroundDownloader.checkForExistingDownloads();
|
||||||
|
|
||||||
if (tasks.find((task) => task.id === job.id)) {
|
if (tasks.find((task: { id: string }) => task.id === job.id)) {
|
||||||
console.log("TaskManager ~ Download already in progress: ", job.id);
|
console.log("TaskManager ~ Download already in progress: ", job.id);
|
||||||
continue;
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
BackGroundDownloader.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);
|
||||||
|
BackGroundDownloader.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: any) => {
|
||||||
|
console.log("TaskManager ~ Download error: ", job.id, error);
|
||||||
|
BackGroundDownloader.completeHandler(job.id);
|
||||||
|
Notifications.scheduleNotificationAsync({
|
||||||
|
content: {
|
||||||
|
title: job.item.Name,
|
||||||
|
body: "Download failed",
|
||||||
|
data: {
|
||||||
|
url: `/downloads`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
trigger: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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()}`);
|
console.log(`Auto download started: ${new Date(now).toISOString()}`);
|
||||||
|
|
||||||
// Be sure to return the successful result type!
|
// Be sure to return the successful result type!
|
||||||
return BackgroundFetch.BackgroundFetchResult.NewData;
|
return BackgroundFetch.BackgroundFetchResult.NewData;
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const checkAndRequestPermissions = async () => {
|
const checkAndRequestPermissions = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -213,28 +224,18 @@ const checkAndRequestPermissions = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
const [loaded] = useFonts({
|
|
||||||
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (loaded) {
|
|
||||||
SplashScreen.hideAsync();
|
|
||||||
}
|
|
||||||
}, [loaded]);
|
|
||||||
|
|
||||||
Appearance.setColorScheme("dark");
|
Appearance.setColorScheme("dark");
|
||||||
|
|
||||||
if (!loaded) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<JotaiProvider>
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
<I18nextProvider i18n={i18n}>
|
<JotaiProvider>
|
||||||
<Layout />
|
<ActionSheetProvider>
|
||||||
</I18nextProvider>
|
<I18nextProvider i18n={i18n}>
|
||||||
</JotaiProvider>
|
<Layout />
|
||||||
|
</I18nextProvider>
|
||||||
|
</ActionSheetProvider>
|
||||||
|
</JotaiProvider>
|
||||||
|
</GestureHandlerRootView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,26 +252,8 @@ const queryClient = new QueryClient({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function Layout() {
|
function Layout() {
|
||||||
const [settings, updateSettings] = useSettings();
|
const [settings] = useSettings();
|
||||||
const [orientation, setOrientation] = useAtom(orientationAtom);
|
const appState = useRef(AppState.currentState);
|
||||||
|
|
||||||
useKeepAwake();
|
|
||||||
useNotificationObserver();
|
|
||||||
|
|
||||||
const { i18n } = useTranslation();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
checkAndRequestPermissions();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (settings?.autoRotate === true)
|
|
||||||
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.DEFAULT);
|
|
||||||
else
|
|
||||||
ScreenOrientation.lockAsync(
|
|
||||||
ScreenOrientation.OrientationLock.PORTRAIT_UP
|
|
||||||
);
|
|
||||||
}, [settings]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
i18n.changeLanguage(
|
i18n.changeLanguage(
|
||||||
@@ -278,112 +261,108 @@ function Layout() {
|
|||||||
);
|
);
|
||||||
}, [settings?.preferedLanguage, i18n]);
|
}, [settings?.preferedLanguage, i18n]);
|
||||||
|
|
||||||
const appState = useRef(AppState.currentState);
|
if (!Platform.isTV) {
|
||||||
|
useNotificationObserver();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const subscription = AppState.addEventListener("change", (nextAppState) => {
|
checkAndRequestPermissions();
|
||||||
if (
|
}, []);
|
||||||
appState.current.match(/inactive|background/) &&
|
|
||||||
nextAppState === "active"
|
useEffect(() => {
|
||||||
) {
|
// If the user has auto rotate enabled, unlock the orientation
|
||||||
checkForExistingDownloads();
|
if (Platform.isTV) return;
|
||||||
|
if (settings.autoRotate === true) {
|
||||||
|
ScreenOrientation.unlockAsync();
|
||||||
|
} else {
|
||||||
|
// If the user has auto rotate disabled, lock the orientation to portrait
|
||||||
|
ScreenOrientation.lockAsync(
|
||||||
|
ScreenOrientation.OrientationLock.PORTRAIT_UP
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
}, [settings]);
|
||||||
|
|
||||||
checkForExistingDownloads();
|
useEffect(() => {
|
||||||
|
const subscription = AppState.addEventListener(
|
||||||
|
"change",
|
||||||
|
(nextAppState) => {
|
||||||
|
if (
|
||||||
|
appState.current.match(/inactive|background/) &&
|
||||||
|
nextAppState === "active"
|
||||||
|
) {
|
||||||
|
BackGroundDownloader.checkForExistingDownloads();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return () => {
|
BackGroundDownloader.checkForExistingDownloads();
|
||||||
subscription.remove();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
return () => {
|
||||||
const subscription = ScreenOrientation.addOrientationChangeListener(
|
subscription.remove();
|
||||||
(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 }}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<JobQueueProvider>
|
||||||
<ActionSheetProvider>
|
<JellyfinProvider>
|
||||||
<JobQueueProvider>
|
<PlaySettingsProvider>
|
||||||
<JellyfinProvider>
|
<LogProvider>
|
||||||
<PlaySettingsProvider>
|
<WebSocketProvider>
|
||||||
<LogProvider>
|
<DownloadProvider>
|
||||||
<WebSocketProvider>
|
<BottomSheetModalProvider>
|
||||||
<DownloadProvider>
|
<SystemBars style="light" hidden={false} />
|
||||||
<BottomSheetModalProvider>
|
<ThemeProvider value={DarkTheme}>
|
||||||
<SystemBars style="light" hidden={false} />
|
<Stack initialRouteName="(auth)/(tabs)">
|
||||||
<ThemeProvider value={DarkTheme}>
|
<Stack.Screen
|
||||||
<Stack initialRouteName="/home">
|
name="(auth)/(tabs)"
|
||||||
<Stack.Screen
|
options={{
|
||||||
name="(auth)/(tabs)"
|
headerShown: false,
|
||||||
options={{
|
title: "",
|
||||||
headerShown: false,
|
header: () => null,
|
||||||
title: "",
|
}}
|
||||||
header: () => null,
|
/>
|
||||||
}}
|
<Stack.Screen
|
||||||
/>
|
name="(auth)/player"
|
||||||
<Stack.Screen
|
options={{
|
||||||
name="(auth)/player"
|
headerShown: false,
|
||||||
options={{
|
title: "",
|
||||||
headerShown: false,
|
header: () => null,
|
||||||
title: "",
|
}}
|
||||||
header: () => null,
|
/>
|
||||||
}}
|
<Stack.Screen
|
||||||
/>
|
name="login"
|
||||||
<Stack.Screen
|
options={{
|
||||||
name="login"
|
headerShown: true,
|
||||||
options={{
|
title: "",
|
||||||
headerShown: true,
|
headerTransparent: true,
|
||||||
title: "",
|
}}
|
||||||
headerTransparent: true,
|
/>
|
||||||
}}
|
<Stack.Screen name="+not-found" />
|
||||||
/>
|
</Stack>
|
||||||
<Stack.Screen name="+not-found" />
|
<Toaster
|
||||||
</Stack>
|
duration={4000}
|
||||||
<Toaster
|
toastOptions={{
|
||||||
duration={4000}
|
style: {
|
||||||
toastOptions={{
|
backgroundColor: "#262626",
|
||||||
style: {
|
borderColor: "#363639",
|
||||||
backgroundColor: "#262626",
|
borderWidth: 1,
|
||||||
borderColor: "#363639",
|
},
|
||||||
borderWidth: 1,
|
titleStyle: {
|
||||||
},
|
color: "white",
|
||||||
titleStyle: {
|
},
|
||||||
color: "white",
|
}}
|
||||||
},
|
closeButton
|
||||||
}}
|
/>
|
||||||
closeButton
|
</ThemeProvider>
|
||||||
/>
|
</BottomSheetModalProvider>
|
||||||
</ThemeProvider>
|
</DownloadProvider>
|
||||||
</BottomSheetModalProvider>
|
</WebSocketProvider>
|
||||||
</DownloadProvider>
|
</LogProvider>
|
||||||
</WebSocketProvider>
|
</PlaySettingsProvider>
|
||||||
</LogProvider>
|
</JellyfinProvider>
|
||||||
</PlaySettingsProvider>
|
</JobQueueProvider>
|
||||||
</JellyfinProvider>
|
</QueryClientProvider>
|
||||||
</JobQueueProvider>
|
|
||||||
</ActionSheetProvider>
|
|
||||||
</QueryClientProvider>
|
|
||||||
</GestureHandlerRootView>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
|||||||
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import React, { useCallback, useEffect, useState } from "react";
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
@@ -19,17 +19,20 @@ import {
|
|||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
|
import { Keyboard } from "react-native";
|
||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { t } from 'i18next';
|
import { t } from "i18next";
|
||||||
const CredentialsSchema = z.object({
|
const CredentialsSchema = z.object({
|
||||||
username: z.string().min(1, t("login.username_required")),});
|
username: z.string().min(1, t("login.username_required")),
|
||||||
|
});
|
||||||
|
|
||||||
const Login: React.FC = () => {
|
const Login: React.FC = () => {
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const params = useLocalSearchParams();
|
||||||
const { setServer, login, removeServer, initiateQuickConnect } =
|
const { setServer, login, removeServer, initiateQuickConnect } =
|
||||||
useJellyfin();
|
useJellyfin();
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const params = useLocalSearchParams();
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
apiUrl: _apiUrl,
|
apiUrl: _apiUrl,
|
||||||
@@ -37,6 +40,8 @@ const CredentialsSchema = z.object({
|
|||||||
password: _password,
|
password: _password,
|
||||||
} = params as { apiUrl: string; username: string; password: string };
|
} = params as { apiUrl: string; username: string; password: string };
|
||||||
|
|
||||||
|
const [loadingServerCheck, setLoadingServerCheck] = useState<boolean>(false);
|
||||||
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
const [serverURL, setServerURL] = useState<string>(_apiUrl);
|
const [serverURL, setServerURL] = useState<string>(_apiUrl);
|
||||||
const [serverName, setServerName] = useState<string>("");
|
const [serverName, setServerName] = useState<string>("");
|
||||||
const [credentials, setCredentials] = useState<{
|
const [credentials, setCredentials] = useState<{
|
||||||
@@ -47,10 +52,11 @@ const CredentialsSchema = z.object({
|
|||||||
password: _password,
|
password: _password,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A way to auto login based on a link
|
||||||
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
// we might re-use the checkUrl function here to check the url as well
|
|
||||||
// however, I don't think it should be necessary for now
|
|
||||||
if (_apiUrl) {
|
if (_apiUrl) {
|
||||||
setServer({
|
setServer({
|
||||||
address: _apiUrl,
|
address: _apiUrl,
|
||||||
@@ -66,7 +72,6 @@ const CredentialsSchema = z.object({
|
|||||||
})();
|
})();
|
||||||
}, [_apiUrl, _username, _password]);
|
}, [_apiUrl, _username, _password]);
|
||||||
|
|
||||||
const navigation = useNavigation();
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerTitle: serverName,
|
headerTitle: serverName,
|
||||||
@@ -79,15 +84,17 @@ const CredentialsSchema = z.object({
|
|||||||
className="flex flex-row items-center"
|
className="flex flex-row items-center"
|
||||||
>
|
>
|
||||||
<Ionicons name="chevron-back" size={18} color={Colors.primary} />
|
<Ionicons name="chevron-back" size={18} color={Colors.primary} />
|
||||||
<Text className="ml-2 text-purple-600">{t("login.change_server")}</Text>
|
<Text className="ml-2 text-purple-600">
|
||||||
|
{t("login.change_server")}
|
||||||
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
) : null,
|
) : null,
|
||||||
});
|
});
|
||||||
}, [serverName, navigation, api?.basePath]);
|
}, [serverName, navigation, api?.basePath]);
|
||||||
|
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
|
||||||
|
|
||||||
const handleLogin = async () => {
|
const handleLogin = async () => {
|
||||||
|
Keyboard.dismiss();
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const result = CredentialsSchema.safeParse(credentials);
|
const result = CredentialsSchema.safeParse(credentials);
|
||||||
@@ -98,15 +105,16 @@ const CredentialsSchema = z.object({
|
|||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
Alert.alert(t("login.connection_failed"), error.message);
|
Alert.alert(t("login.connection_failed"), error.message);
|
||||||
} else {
|
} else {
|
||||||
Alert.alert(t("login.connection_failed"), t("login.an_unexpected_error_occured"));
|
Alert.alert(
|
||||||
|
t("login.connection_failed"),
|
||||||
|
t("login.an_unexpected_error_occured")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const [loadingServerCheck, setLoadingServerCheck] = useState<boolean>(false);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks the availability and validity of a Jellyfin server URL.
|
* Checks the availability and validity of a Jellyfin server URL.
|
||||||
*
|
*
|
||||||
@@ -180,14 +188,21 @@ const CredentialsSchema = z.object({
|
|||||||
try {
|
try {
|
||||||
const code = await initiateQuickConnect();
|
const code = await initiateQuickConnect();
|
||||||
if (code) {
|
if (code) {
|
||||||
Alert.alert(t("login.quick_connect"), t("login.enter_code_to_login", {code: code}), [
|
Alert.alert(
|
||||||
{
|
t("login.quick_connect"),
|
||||||
text: t("login.got_it"),
|
t("login.enter_code_to_login", { code: code }),
|
||||||
},
|
[
|
||||||
]);
|
{
|
||||||
|
text: t("login.got_it"),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Alert.alert(t("login.error_title"), t("login.failed_to_initiate_quick_connect"));
|
Alert.alert(
|
||||||
|
t("login.error_title"),
|
||||||
|
t("login.failed_to_initiate_quick_connect")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -201,16 +216,18 @@ const CredentialsSchema = z.object({
|
|||||||
<View className="flex flex-col 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 w-full">
|
<View className="px-4 -mt-20 w-full">
|
||||||
<View className="flex flex-col space-y-2">
|
<View className="flex flex-col space-y-2">
|
||||||
<Text className="text-2xl font-bold -mb-2">
|
<Text className="text-2xl font-bold -mb-2">
|
||||||
<>
|
<>
|
||||||
{serverName ? (
|
{serverName ? (
|
||||||
<>
|
<>
|
||||||
{t("login.login_to_title") + " "}
|
{t("login.login_to_title") + " "}
|
||||||
<Text className="text-purple-600">{serverName}</Text>
|
<Text className="text-purple-600">{serverName}</Text>
|
||||||
</>
|
</>
|
||||||
) : t("login.login_title")}
|
) : (
|
||||||
</>
|
t("login.login_title")
|
||||||
</Text>
|
)}
|
||||||
|
</>
|
||||||
|
</Text>
|
||||||
<Text className="text-xs text-neutral-400">
|
<Text className="text-xs text-neutral-400">
|
||||||
{api.basePath}
|
{api.basePath}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -220,7 +237,6 @@ const CredentialsSchema = z.object({
|
|||||||
setCredentials({ ...credentials, username: text })
|
setCredentials({ ...credentials, username: text })
|
||||||
}
|
}
|
||||||
value={credentials.username}
|
value={credentials.username}
|
||||||
autoFocus
|
|
||||||
secureTextEntry={false}
|
secureTextEntry={false}
|
||||||
keyboardType="default"
|
keyboardType="default"
|
||||||
returnKeyType="done"
|
returnKeyType="done"
|
||||||
@@ -300,7 +316,9 @@ const CredentialsSchema = z.object({
|
|||||||
<Button
|
<Button
|
||||||
loading={loadingServerCheck}
|
loading={loadingServerCheck}
|
||||||
disabled={loadingServerCheck}
|
disabled={loadingServerCheck}
|
||||||
onPress={async () => await handleConnect(serverURL)}
|
onPress={async () => {
|
||||||
|
await handleConnect(serverURL);
|
||||||
|
}}
|
||||||
className="w-full grow"
|
className="w-full grow"
|
||||||
>
|
>
|
||||||
{t("server.connect_button")}
|
{t("server.connect_button")}
|
||||||
|
|||||||
@@ -1,113 +1,23 @@
|
|||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
import { useFavorite } from "@/hooks/useFavorite";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { View } from "react-native";
|
||||||
import { useAtom } from "jotai";
|
import { RoundButton } from "@/components/RoundButton";
|
||||||
import { useMemo } from "react";
|
|
||||||
import { TouchableOpacityProps, View, ViewProps } from "react-native";
|
|
||||||
import { RoundButton } from "./RoundButton";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
type: "item" | "series";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AddToFavorites: React.FC<Props> = ({ item, type, ...props }) => {
|
export const AddToFavorites = ({ item, ...props }) => {
|
||||||
const queryClient = useQueryClient();
|
const { isFavorite, toggleFavorite, _} = useFavorite(item);
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
|
|
||||||
const isFavorite = useMemo(() => {
|
|
||||||
return item.UserData?.IsFavorite;
|
|
||||||
}, [item.UserData?.IsFavorite]);
|
|
||||||
|
|
||||||
const updateItemInQueries = (newData: Partial<BaseItemDto>) => {
|
|
||||||
queryClient.setQueryData<BaseItemDto | undefined>(
|
|
||||||
[type, item.Id],
|
|
||||||
(old) => {
|
|
||||||
if (!old) return old;
|
|
||||||
return {
|
|
||||||
...old,
|
|
||||||
...newData,
|
|
||||||
UserData: { ...old.UserData, ...newData.UserData },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const markFavoriteMutation = useMutation({
|
|
||||||
mutationFn: async () => {
|
|
||||||
if (api && user) {
|
|
||||||
await getUserLibraryApi(api).markFavoriteItem({
|
|
||||||
userId: user.Id,
|
|
||||||
itemId: item.Id!,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onMutate: async () => {
|
|
||||||
await queryClient.cancelQueries({ queryKey: [type, item.Id] });
|
|
||||||
const previousItem = queryClient.getQueryData<BaseItemDto>([
|
|
||||||
type,
|
|
||||||
item.Id,
|
|
||||||
]);
|
|
||||||
updateItemInQueries({ UserData: { IsFavorite: true } });
|
|
||||||
|
|
||||||
return { previousItem };
|
|
||||||
},
|
|
||||||
onError: (err, variables, context) => {
|
|
||||||
if (context?.previousItem) {
|
|
||||||
queryClient.setQueryData([type, item.Id], context.previousItem);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSettled: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: [type, item.Id] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["home", "favorites"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const unmarkFavoriteMutation = useMutation({
|
|
||||||
mutationFn: async () => {
|
|
||||||
if (api && user) {
|
|
||||||
await getUserLibraryApi(api).unmarkFavoriteItem({
|
|
||||||
userId: user.Id,
|
|
||||||
itemId: item.Id!,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onMutate: async () => {
|
|
||||||
await queryClient.cancelQueries({ queryKey: [type, item.Id] });
|
|
||||||
const previousItem = queryClient.getQueryData<BaseItemDto>([
|
|
||||||
type,
|
|
||||||
item.Id,
|
|
||||||
]);
|
|
||||||
updateItemInQueries({ UserData: { IsFavorite: false } });
|
|
||||||
|
|
||||||
return { previousItem };
|
|
||||||
},
|
|
||||||
onError: (err, variables, context) => {
|
|
||||||
if (context?.previousItem) {
|
|
||||||
queryClient.setQueryData([type, item.Id], context.previousItem);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSettled: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: [type, item.Id] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["home", "favorites"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<RoundButton
|
<RoundButton
|
||||||
size="large"
|
size="large"
|
||||||
icon={isFavorite ? "heart" : "heart-outline"}
|
icon={isFavorite ? "heart" : "heart-outline"}
|
||||||
fillColor={isFavorite ? "primary" : undefined}
|
fillColor={isFavorite ? "primary" : undefined}
|
||||||
onPress={() => {
|
onPress={toggleFavorite}
|
||||||
if (isFavorite) {
|
|
||||||
unmarkFavoriteMutation.mutate();
|
|
||||||
} else {
|
|
||||||
markFavoriteMutation.mutate();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
|||||||
selected,
|
selected,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
|
if (Platform.isTV) return null;
|
||||||
const audioStreams = useMemo(
|
const audioStreams = useMemo(
|
||||||
() => source?.MediaStreams?.filter((x) => x.Type === "Audio"),
|
() => source?.MediaStreams?.filter((x) => x.Type === "Audio"),
|
||||||
[source]
|
[source]
|
||||||
@@ -39,7 +40,9 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
|||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger>
|
<DropdownMenu.Trigger>
|
||||||
<View className="flex flex-col" {...props}>
|
<View className="flex flex-col" {...props}>
|
||||||
<Text className="opacity-50 mb-1 text-xs">{t("item_card.audio")}</Text>
|
<Text className="opacity-50 mb-1 text-xs">
|
||||||
|
{t("item_card.audio")}
|
||||||
|
</Text>
|
||||||
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
|
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
|
||||||
<Text className="" numberOfLines={1}>
|
<Text className="" numberOfLines={1}>
|
||||||
{selectedAudioSteam?.DisplayTitle}
|
{selectedAudioSteam?.DisplayTitle}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -54,6 +54,7 @@ export const BitrateSelector: React.FC<Props> = ({
|
|||||||
inverted,
|
inverted,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
|
if (Platform.isTV) return null;
|
||||||
const sorted = useMemo(() => {
|
const sorted = useMemo(() => {
|
||||||
if (inverted)
|
if (inverted)
|
||||||
return BITRATES.sort(
|
return BITRATES.sort(
|
||||||
@@ -77,7 +78,9 @@ export const BitrateSelector: React.FC<Props> = ({
|
|||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger>
|
<DropdownMenu.Trigger>
|
||||||
<View className="flex flex-col" {...props}>
|
<View className="flex flex-col" {...props}>
|
||||||
<Text className="opacity-50 mb-1 text-xs">{t("item_card.quality")}</Text>
|
<Text className="opacity-50 mb-1 text-xs">
|
||||||
|
{t("item_card.quality")}
|
||||||
|
</Text>
|
||||||
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
|
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
|
||||||
<Text style={{}} className="" numberOfLines={1}>
|
<Text style={{}} className="" numberOfLines={1}>
|
||||||
{BITRATES.find((b) => b.value === selected?.value)?.key}
|
{BITRATES.find((b) => b.value === selected?.value)?.key}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import React, { PropsWithChildren, ReactNode, useMemo } from "react";
|
import React, { PropsWithChildren, ReactNode, useMemo } from "react";
|
||||||
import { Text, TouchableOpacity, View } from "react-native";
|
import { Platform, Text, TouchableOpacity, View } from "react-native";
|
||||||
import { Loader } from "./Loader";
|
import { Loader } from "./Loader";
|
||||||
|
|
||||||
export interface ButtonProps
|
export interface ButtonProps
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Feather } from "@expo/vector-icons";
|
import { Feather } from "@expo/vector-icons";
|
||||||
import { BlurView } from "expo-blur";
|
|
||||||
import React, { useCallback, useEffect } from "react";
|
import React, { useCallback, useEffect } from "react";
|
||||||
import { Platform, TouchableOpacity, ViewProps } from "react-native";
|
import { Platform, TouchableOpacity, ViewProps } from "react-native";
|
||||||
import GoogleCast, {
|
import GoogleCast, {
|
||||||
@@ -18,12 +17,12 @@ interface Props extends ViewProps {
|
|||||||
background?: "blur" | "transparent";
|
background?: "blur" | "transparent";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Chromecast: React.FC<Props> = ({
|
export function Chromecast({
|
||||||
width = 48,
|
width = 48,
|
||||||
height = 48,
|
height = 48,
|
||||||
background = "transparent",
|
background = "transparent",
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) {
|
||||||
const client = useRemoteMediaClient();
|
const client = useRemoteMediaClient();
|
||||||
const castDevice = useCastDevice();
|
const castDevice = useCastDevice();
|
||||||
const devices = useDevices();
|
const devices = useDevices();
|
||||||
@@ -83,4 +82,4 @@ export const Chromecast: React.FC<Props> = ({
|
|||||||
<Feather name="cast" size={22} color={"white"} />
|
<Feather name="cast" size={22} color={"white"} />
|
||||||
</RoundButton>
|
</RoundButton>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|||||||
0
components/Chromecast.tv.tsx
Normal file
0
components/Chromecast.tv.tsx
Normal file
1
components/ContextMenu.ts
Normal file
1
components/ContextMenu.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "zeego/context-menu";
|
||||||
0
components/ContextMenu.tv.ts
Normal file
0
components/ContextMenu.tv.ts
Normal file
@@ -2,7 +2,7 @@ import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
|
|||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { queueActions, queueAtom } from "@/utils/atoms/queue";
|
import { queueActions, queueAtom } from "@/utils/atoms/queue";
|
||||||
import {DownloadMethod, useSettings} from "@/utils/atoms/settings";
|
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
|
||||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server";
|
import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server";
|
||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
import { Href, router, useFocusEffect } from "expo-router";
|
import { Href, router, useFocusEffect } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { Alert, View, ViewProps } from "react-native";
|
import { Alert, Platform, View, ViewProps } from "react-native";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { AudioTrackSelector } from "./AudioTrackSelector";
|
import { AudioTrackSelector } from "./AudioTrackSelector";
|
||||||
import { Bitrate, BitrateSelector } from "./BitrateSelector";
|
import { Bitrate, BitrateSelector } from "./BitrateSelector";
|
||||||
@@ -66,10 +66,12 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
||||||
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
||||||
useState<number>(0);
|
useState<number>(0);
|
||||||
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
|
const [maxBitrate, setMaxBitrate] = useState<Bitrate>(
|
||||||
key: "Max",
|
settings?.defaultBitrate ?? {
|
||||||
value: undefined,
|
key: "Max",
|
||||||
});
|
value: undefined,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const userCanDownload = useMemo(
|
const userCanDownload = useMemo(
|
||||||
() => user?.Policy?.EnableContentDownloading,
|
() => user?.Policy?.EnableContentDownloading,
|
||||||
@@ -162,7 +164,9 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
toast.error(t("home.downloads.toasts.you_are_not_allowed_to_download_files"));
|
toast.error(
|
||||||
|
t("home.downloads.toasts.you_are_not_allowed_to_download_files")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
queue,
|
queue,
|
||||||
@@ -194,10 +198,11 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (itemsNotDownloaded.length > 1) {
|
if (itemsNotDownloaded.length > 1) {
|
||||||
({ mediaSource, audioIndex, subtitleIndex } = getDefaultPlaySettings(
|
const defaults = getDefaultPlaySettings(item, settings!);
|
||||||
item,
|
mediaSource = defaults.mediaSource;
|
||||||
settings!
|
audioIndex = defaults.audioIndex;
|
||||||
));
|
subtitleIndex = defaults.subtitleIndex;
|
||||||
|
// Keep using the selected bitrate for consistency across all downloads
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await getStreamUrl({
|
const res = await getStreamUrl({
|
||||||
@@ -332,7 +337,10 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-neutral-300">
|
<Text className="text-neutral-300">
|
||||||
{subtitle || t("item_card.download.download_x_item", {item_count: itemsNotDownloaded.length})}
|
{subtitle ||
|
||||||
|
t("item_card.download.download_x_item", {
|
||||||
|
item_count: itemsNotDownloaded.length,
|
||||||
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className="flex flex-col space-y-2 w-full items-start">
|
<View className="flex flex-col space-y-2 w-full items-start">
|
||||||
@@ -390,12 +398,16 @@ export const DownloadSingleItem: React.FC<{
|
|||||||
size?: "default" | "large";
|
size?: "default" | "large";
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
}> = ({ item, size = "default" }) => {
|
}> = ({ item, size = "default" }) => {
|
||||||
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DownloadItems
|
<DownloadItems
|
||||||
size={size}
|
size={size}
|
||||||
title={item.Type == "Episode"
|
title={
|
||||||
? t("item_card.download.download_episode")
|
item.Type == "Episode"
|
||||||
: t("item_card.download.download_movie")}
|
? t("item_card.download.download_episode")
|
||||||
|
: t("item_card.download.download_movie")
|
||||||
|
}
|
||||||
subtitle={item.Name!}
|
subtitle={item.Name!}
|
||||||
items={[item]}
|
items={[item]}
|
||||||
MissingDownloadIconComponent={() => (
|
MissingDownloadIconComponent={() => (
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
|||||||
import { DownloadSingleItem } from "@/components/DownloadItem";
|
import { DownloadSingleItem } from "@/components/DownloadItem";
|
||||||
import { OverviewText } from "@/components/OverviewText";
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
|
// const PlayButton = !Platform.isTV ? require("@/components/PlayButton") : null;
|
||||||
import { PlayButton } from "@/components/PlayButton";
|
import { PlayButton } from "@/components/PlayButton";
|
||||||
import { PlayedStatus } from "@/components/PlayedStatus";
|
import { PlayedStatus } from "@/components/PlayedStatus";
|
||||||
import { SimilarItems } from "@/components/SimilarItems";
|
import { SimilarItems } from "@/components/SimilarItems";
|
||||||
@@ -14,8 +15,8 @@ import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarous
|
|||||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||||
import { useImageColors } from "@/hooks/useImageColors";
|
import { useImageColors } from "@/hooks/useImageColors";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
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 {
|
||||||
@@ -24,17 +25,16 @@ 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 { Platform, 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 { AddToFavorites } from "./AddToFavorites";
|
||||||
import { ItemHeader } from "./ItemHeader";
|
import { ItemHeader } from "./ItemHeader";
|
||||||
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
||||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||||
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
||||||
import { AddToFavorites } from "./AddToFavorites";
|
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
|
||||||
|
|
||||||
export type SelectedOptions = {
|
export type SelectedOptions = {
|
||||||
bitrate: Bitrate;
|
bitrate: Bitrate;
|
||||||
@@ -81,23 +81,31 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
defaultMediaSource,
|
defaultMediaSource,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
if (!Platform.isTV) {
|
||||||
navigation.setOptions({
|
useEffect(() => {
|
||||||
headerRight: () =>
|
navigation.setOptions({
|
||||||
item && (
|
headerRight: () =>
|
||||||
<View className="flex flex-row items-center space-x-2">
|
item && (
|
||||||
<Chromecast background="blur" width={22} height={22} />
|
<View className="flex flex-row items-center space-x-2">
|
||||||
{item.Type !== "Program" && (
|
<Chromecast.Chromecast
|
||||||
<View className="flex flex-row items-center space-x-2">
|
background="blur"
|
||||||
<DownloadSingleItem item={item} size="large" />
|
width={22}
|
||||||
<PlayedStatus item={item} />
|
height={22}
|
||||||
<AddToFavorites item={item} type="item" />
|
/>
|
||||||
</View>
|
{item.Type !== "Program" && (
|
||||||
)}
|
<View className="flex flex-row items-center space-x-2">
|
||||||
</View>
|
{!Platform.isTV && (
|
||||||
),
|
<DownloadSingleItem item={item} size="large" />
|
||||||
});
|
)}
|
||||||
}, [item]);
|
<PlayedStatus items={[item]} size="large" />
|
||||||
|
<AddToFavorites item={item} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}, [item]);
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP)
|
if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP)
|
||||||
@@ -111,37 +119,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
const loading = useMemo(() => {
|
const loading = useMemo(() => {
|
||||||
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 (
|
||||||
@@ -191,7 +168,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
|||||||
<View className="flex flex-col bg-transparent shrink">
|
<View className="flex flex-col bg-transparent shrink">
|
||||||
<View className="flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink">
|
<View className="flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink">
|
||||||
<ItemHeader item={item} className="mb-4" />
|
<ItemHeader item={item} className="mb-4" />
|
||||||
{item.Type !== "Program" && (
|
{item.Type !== "Program" && !Platform.isTV && (
|
||||||
<View className="flex flex-row items-center justify-start w-full h-16">
|
<View className="flex flex-row items-center justify-start w-full h-16">
|
||||||
<BitrateSelector
|
<BitrateSelector
|
||||||
className="mr-1"
|
className="mr-1"
|
||||||
@@ -231,7 +208,6 @@ 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(
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
} from "@gorhom/bottom-sheet";
|
} from "@gorhom/bottom-sheet";
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { formatBitrate } from "@/utils/bitrate";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
source?: MediaSourceInfo;
|
source?: MediaSourceInfo;
|
||||||
@@ -54,14 +55,18 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
|
|||||||
<BottomSheetScrollView>
|
<BottomSheetScrollView>
|
||||||
<View className="flex flex-col space-y-2 p-4 mb-4">
|
<View className="flex flex-col space-y-2 p-4 mb-4">
|
||||||
<View className="">
|
<View className="">
|
||||||
<Text className="text-lg font-bold mb-4">{t("item_card.video")}</Text>
|
<Text className="text-lg font-bold mb-4">
|
||||||
|
{t("item_card.video")}
|
||||||
|
</Text>
|
||||||
<View className="flex flex-row space-x-2">
|
<View className="flex flex-row space-x-2">
|
||||||
<VideoStreamInfo source={source} />
|
<VideoStreamInfo source={source} />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="">
|
<View className="">
|
||||||
<Text className="text-lg font-bold mb-2">{t("item_card.audio")}</Text>
|
<Text className="text-lg font-bold mb-2">
|
||||||
|
{t("item_card.audio")}
|
||||||
|
</Text>
|
||||||
<AudioStreamInfo
|
<AudioStreamInfo
|
||||||
audioStreams={
|
audioStreams={
|
||||||
source?.MediaStreams?.filter(
|
source?.MediaStreams?.filter(
|
||||||
@@ -72,7 +77,9 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="">
|
<View className="">
|
||||||
<Text className="text-lg font-bold mb-2">{t("item_card.subtitles")}</Text>
|
<Text className="text-lg font-bold mb-2">
|
||||||
|
{t("item_card.subtitles")}
|
||||||
|
</Text>
|
||||||
<SubtitleStreamInfo
|
<SubtitleStreamInfo
|
||||||
subtitleStreams={
|
subtitleStreams={
|
||||||
source?.MediaStreams?.filter(
|
source?.MediaStreams?.filter(
|
||||||
@@ -229,12 +236,3 @@ const formatFileSize = (bytes?: number | null) => {
|
|||||||
const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)).toString());
|
const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)).toString());
|
||||||
return Math.round((bytes / Math.pow(1024, i)) * 100) / 100 + " " + sizes[i];
|
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];
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import {
|
|||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@@ -20,6 +20,7 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
|||||||
selected,
|
selected,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
|
if (Platform.isTV) return null;
|
||||||
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(
|
||||||
@@ -61,7 +62,9 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
|||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger>
|
<DropdownMenu.Trigger>
|
||||||
<View className="flex flex-col" {...props}>
|
<View className="flex flex-col" {...props}>
|
||||||
<Text className="opacity-50 mb-1 text-xs">{t("item_card.video")}</Text>
|
<Text className="opacity-50 mb-1 text-xs">
|
||||||
|
{t("item_card.video")}
|
||||||
|
</Text>
|
||||||
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center">
|
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center">
|
||||||
<Text numberOfLines={1}>{selectedName}</Text>
|
<Text numberOfLines={1}>{selectedName}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Platform, Pressable } from "react-native";
|
||||||
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";
|
||||||
@@ -31,7 +32,8 @@ import Animated, {
|
|||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
import { SelectedOptions } from "./ItemContent";
|
import { SelectedOptions } from "./ItemContent";
|
||||||
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
import { chromecast } from "@/utils/profiles/chromecast";
|
||||||
|
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
|
||||||
@@ -69,17 +71,14 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
const lightHapticFeedback = useHaptic("light");
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
const goToPlayer = useCallback(
|
const goToPlayer = useCallback(
|
||||||
(q: string, bitrateValue: number | undefined) => {
|
(q: string) => {
|
||||||
if (!bitrateValue) {
|
router.push(`/player/direct-player?${q}`);
|
||||||
router.push(`/player/direct-player?${q}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
router.push(`/player/transcoding-player?${q}`);
|
|
||||||
},
|
},
|
||||||
[router]
|
[router]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onPress = useCallback(async () => {
|
const onPress = useCallback(async () => {
|
||||||
|
console.log("onPress");
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
|
|
||||||
lightHapticFeedback();
|
lightHapticFeedback();
|
||||||
@@ -95,7 +94,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
const queryString = queryParams.toString();
|
const queryString = queryParams.toString();
|
||||||
|
|
||||||
if (!client) {
|
if (!client) {
|
||||||
goToPlayer(queryString, selectedOptions.bitrate?.value);
|
goToPlayer(queryString);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,101 +114,110 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
switch (selectedIndex) {
|
switch (selectedIndex) {
|
||||||
case 0:
|
case 0:
|
||||||
await CastContext.getPlayServicesState().then(async (state) => {
|
await CastContext.getPlayServicesState().then(async (state) => {
|
||||||
if (state && state !== PlayServicesState.SUCCESS)
|
if (state && state !== PlayServicesState.SUCCESS) {
|
||||||
CastContext.showPlayServicesErrorDialog(state);
|
CastContext.showPlayServicesErrorDialog(state);
|
||||||
else {
|
} else {
|
||||||
// Get a new URL with the Chromecast device profile:
|
// Check if user wants H265 for Chromecast
|
||||||
const data = await getStreamUrl({
|
const enableH265 = settings.enableH265ForChromecast;
|
||||||
api,
|
|
||||||
item,
|
|
||||||
deviceProfile: chromecastProfile,
|
|
||||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
|
||||||
userId: user?.Id,
|
|
||||||
audioStreamIndex: selectedOptions.audioIndex,
|
|
||||||
maxStreamingBitrate: selectedOptions.bitrate?.value,
|
|
||||||
mediaSourceId: selectedOptions.mediaSource?.Id,
|
|
||||||
subtitleStreamIndex: selectedOptions.subtitleIndex,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!data?.url) {
|
// Get a new URL with the Chromecast device profile
|
||||||
console.warn("No URL returned from getStreamUrl", data);
|
try {
|
||||||
Alert.alert(
|
const data = await getStreamUrl({
|
||||||
t("player.client_error"),
|
api,
|
||||||
t("player.could_not_create_stream_for_chromecast")
|
item,
|
||||||
);
|
deviceProfile: enableH265 ? chromecasth265 : chromecast,
|
||||||
return;
|
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
||||||
}
|
userId: user?.Id,
|
||||||
|
audioStreamIndex: selectedOptions.audioIndex,
|
||||||
client
|
maxStreamingBitrate: selectedOptions.bitrate?.value,
|
||||||
.loadMedia({
|
mediaSourceId: selectedOptions.mediaSource?.Id,
|
||||||
mediaInfo: {
|
subtitleStreamIndex: selectedOptions.subtitleIndex,
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log("URL: ", data?.url, enableH265);
|
||||||
|
|
||||||
|
if (!data?.url) {
|
||||||
|
console.warn("No URL returned from getStreamUrl", data);
|
||||||
|
Alert.alert(
|
||||||
|
t("player.client_error"),
|
||||||
|
t("player.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();
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case 1:
|
case 1:
|
||||||
goToPlayer(queryString, selectedOptions.bitrate?.value);
|
goToPlayer(queryString);
|
||||||
break;
|
break;
|
||||||
case cancelButtonIndex:
|
case cancelButtonIndex:
|
||||||
break;
|
break;
|
||||||
@@ -318,75 +326,62 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<TouchableOpacity
|
||||||
<TouchableOpacity
|
disabled={!item}
|
||||||
disabled={!item}
|
accessibilityLabel="Play button"
|
||||||
accessibilityLabel="Play button"
|
accessibilityHint="Tap to play the media"
|
||||||
accessibilityHint="Tap to play the media"
|
onPress={onPress}
|
||||||
onPress={onPress}
|
className={`relative`}
|
||||||
className={`relative`}
|
{...props}
|
||||||
{...props}
|
>
|
||||||
>
|
<View className="absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden">
|
||||||
<View className="absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden">
|
|
||||||
<Animated.View
|
|
||||||
style={[
|
|
||||||
animatedPrimaryStyle,
|
|
||||||
animatedWidthStyle,
|
|
||||||
{
|
|
||||||
height: "100%",
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[animatedAverageStyle, { opacity: 0.5 }]}
|
style={[
|
||||||
className="absolute w-full h-full top-0 left-0 rounded-xl"
|
animatedPrimaryStyle,
|
||||||
|
animatedWidthStyle,
|
||||||
|
{
|
||||||
|
height: "100%",
|
||||||
|
},
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
<View
|
</View>
|
||||||
style={{
|
|
||||||
borderWidth: 1,
|
<Animated.View
|
||||||
borderColor: colorAtom.primary,
|
style={[animatedAverageStyle, { opacity: 0.5 }]}
|
||||||
borderStyle: "solid",
|
className="absolute w-full h-full top-0 left-0 rounded-xl"
|
||||||
}}
|
/>
|
||||||
className="flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full "
|
<View
|
||||||
>
|
style={{
|
||||||
<View className="flex flex-row items-center space-x-2">
|
borderWidth: 1,
|
||||||
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
|
borderColor: colorAtom.primary,
|
||||||
{runtimeTicksToMinutes(item?.RunTimeTicks)}
|
borderStyle: "solid",
|
||||||
</Animated.Text>
|
}}
|
||||||
|
className="flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full "
|
||||||
|
>
|
||||||
|
<View className="flex flex-row items-center space-x-2">
|
||||||
|
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
|
||||||
|
{runtimeTicksToMinutes(item?.RunTimeTicks)}
|
||||||
|
</Animated.Text>
|
||||||
|
<Animated.Text style={animatedTextStyle}>
|
||||||
|
<Ionicons name="play-circle" size={24} />
|
||||||
|
</Animated.Text>
|
||||||
|
{client && (
|
||||||
<Animated.Text style={animatedTextStyle}>
|
<Animated.Text style={animatedTextStyle}>
|
||||||
<Ionicons name="play-circle" size={24} />
|
<Feather name="cast" size={22} />
|
||||||
|
<CastButton tintColor="transparent" />
|
||||||
</Animated.Text>
|
</Animated.Text>
|
||||||
{client && (
|
)}
|
||||||
<Animated.Text style={animatedTextStyle}>
|
{!client && settings?.openInVLC && (
|
||||||
<Feather name="cast" size={22} />
|
<Animated.Text style={animatedTextStyle}>
|
||||||
<CastButton tintColor="transparent" />
|
<MaterialCommunityIcons
|
||||||
</Animated.Text>
|
name="vlc"
|
||||||
)}
|
size={18}
|
||||||
{!client && settings?.openInVLC && (
|
color={animatedTextStyle.color}
|
||||||
<Animated.Text style={animatedTextStyle}>
|
/>
|
||||||
<MaterialCommunityIcons
|
</Animated.Text>
|
||||||
name="vlc"
|
)}
|
||||||
size={18}
|
|
||||||
color={animatedTextStyle.color}
|
|
||||||
/>
|
|
||||||
</Animated.Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</View>
|
||||||
{/* <View className="mt-2 flex flex-row items-center">
|
</TouchableOpacity>
|
||||||
<Ionicons
|
|
||||||
name="information-circle"
|
|
||||||
size={12}
|
|
||||||
className=""
|
|
||||||
color={"#9BA1A6"}
|
|
||||||
/>
|
|
||||||
<Text className="text-neutral-500 ml-1">
|
|
||||||
{directStream ? "Direct stream" : "Transcoded stream"}
|
|
||||||
</Text>
|
|
||||||
</View> */}
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
226
components/PlayButton.tv.tsx
Normal file
226
components/PlayButton.tv.tsx
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
import { Platform } from "react-native";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
|
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||||
|
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
|
import { useCallback, useEffect } from "react";
|
||||||
|
import { Alert, TouchableOpacity, View } from "react-native";
|
||||||
|
import Animated, {
|
||||||
|
Easing,
|
||||||
|
interpolate,
|
||||||
|
interpolateColor,
|
||||||
|
useAnimatedReaction,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useDerivedValue,
|
||||||
|
useSharedValue,
|
||||||
|
withTiming,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
import { Button } from "./Button";
|
||||||
|
import { SelectedOptions } from "./ItemContent";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
|
||||||
|
interface Props extends React.ComponentProps<typeof Button> {
|
||||||
|
item: BaseItemDto;
|
||||||
|
selectedOptions: SelectedOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ANIMATION_DURATION = 500;
|
||||||
|
const MIN_PLAYBACK_WIDTH = 15;
|
||||||
|
|
||||||
|
export const PlayButton: React.FC<Props> = ({
|
||||||
|
item,
|
||||||
|
selectedOptions,
|
||||||
|
...props
|
||||||
|
}: Props) => {
|
||||||
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [colorAtom] = useAtom(itemThemeColorAtom);
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const startWidth = useSharedValue(0);
|
||||||
|
const targetWidth = useSharedValue(0);
|
||||||
|
const endColor = useSharedValue(colorAtom);
|
||||||
|
const startColor = useSharedValue(colorAtom);
|
||||||
|
const widthProgress = useSharedValue(0);
|
||||||
|
const colorChangeProgress = useSharedValue(0);
|
||||||
|
const [settings] = useSettings();
|
||||||
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
|
const goToPlayer = useCallback(
|
||||||
|
(q: string) => {
|
||||||
|
router.push(`/player/direct-player?${q}`);
|
||||||
|
},
|
||||||
|
[router]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onPress = () => {
|
||||||
|
console.log("onpress");
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
lightHapticFeedback();
|
||||||
|
|
||||||
|
const queryParams = new URLSearchParams({
|
||||||
|
itemId: item.Id!,
|
||||||
|
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
|
||||||
|
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
|
||||||
|
mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
|
||||||
|
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const queryString = queryParams.toString();
|
||||||
|
goToPlayer(queryString);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
const derivedTargetWidth = useDerivedValue(() => {
|
||||||
|
if (!item || !item.RunTimeTicks) return 0;
|
||||||
|
const userData = item.UserData;
|
||||||
|
if (userData && userData.PlaybackPositionTicks) {
|
||||||
|
return userData.PlaybackPositionTicks > 0
|
||||||
|
? Math.max(
|
||||||
|
(userData.PlaybackPositionTicks / item.RunTimeTicks) * 100,
|
||||||
|
MIN_PLAYBACK_WIDTH
|
||||||
|
)
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
useAnimatedReaction(
|
||||||
|
() => derivedTargetWidth.value,
|
||||||
|
(newWidth) => {
|
||||||
|
targetWidth.value = newWidth;
|
||||||
|
widthProgress.value = 0;
|
||||||
|
widthProgress.value = withTiming(1, {
|
||||||
|
duration: ANIMATION_DURATION,
|
||||||
|
easing: Easing.bezier(0.7, 0, 0.3, 1.0),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[item]
|
||||||
|
);
|
||||||
|
|
||||||
|
useAnimatedReaction(
|
||||||
|
() => colorAtom,
|
||||||
|
(newColor) => {
|
||||||
|
endColor.value = newColor;
|
||||||
|
colorChangeProgress.value = 0;
|
||||||
|
colorChangeProgress.value = withTiming(1, {
|
||||||
|
duration: ANIMATION_DURATION,
|
||||||
|
easing: Easing.bezier(0.9, 0, 0.31, 0.99),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[colorAtom]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeout_2 = setTimeout(() => {
|
||||||
|
startColor.value = colorAtom;
|
||||||
|
startWidth.value = targetWidth.value;
|
||||||
|
}, ANIMATION_DURATION);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeout_2);
|
||||||
|
};
|
||||||
|
}, [colorAtom, item]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ANIMATED STYLES
|
||||||
|
*/
|
||||||
|
const animatedAverageStyle = useAnimatedStyle(() => ({
|
||||||
|
backgroundColor: interpolateColor(
|
||||||
|
colorChangeProgress.value,
|
||||||
|
[0, 1],
|
||||||
|
[startColor.value.primary, endColor.value.primary]
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const animatedPrimaryStyle = useAnimatedStyle(() => ({
|
||||||
|
backgroundColor: interpolateColor(
|
||||||
|
colorChangeProgress.value,
|
||||||
|
[0, 1],
|
||||||
|
[startColor.value.primary, endColor.value.primary]
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const animatedWidthStyle = useAnimatedStyle(() => ({
|
||||||
|
width: `${interpolate(
|
||||||
|
widthProgress.value,
|
||||||
|
[0, 1],
|
||||||
|
[startWidth.value, targetWidth.value]
|
||||||
|
)}%`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const animatedTextStyle = useAnimatedStyle(() => ({
|
||||||
|
color: interpolateColor(
|
||||||
|
colorChangeProgress.value,
|
||||||
|
[0, 1],
|
||||||
|
[startColor.value.text, endColor.value.text]
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
/**
|
||||||
|
* *********************
|
||||||
|
*/
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
accessibilityLabel="Play button"
|
||||||
|
accessibilityHint="Tap to play the media"
|
||||||
|
onPress={onPress}
|
||||||
|
className={`relative`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<View className="absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden">
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
animatedPrimaryStyle,
|
||||||
|
animatedWidthStyle,
|
||||||
|
{
|
||||||
|
height: "100%",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Animated.View
|
||||||
|
style={[animatedAverageStyle, { opacity: 0.5 }]}
|
||||||
|
className="absolute w-full h-full top-0 left-0 rounded-xl"
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colorAtom.primary,
|
||||||
|
borderStyle: "solid",
|
||||||
|
}}
|
||||||
|
className="flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full "
|
||||||
|
>
|
||||||
|
<View className="flex flex-row items-center space-x-2">
|
||||||
|
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
|
||||||
|
{runtimeTicksToMinutes(item?.RunTimeTicks)}
|
||||||
|
</Animated.Text>
|
||||||
|
<Animated.Text style={animatedTextStyle}>
|
||||||
|
<Ionicons name="play-circle" size={24} />
|
||||||
|
</Animated.Text>
|
||||||
|
{settings?.openInVLC && (
|
||||||
|
<Animated.Text style={animatedTextStyle}>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="vlc"
|
||||||
|
size={18}
|
||||||
|
color={animatedTextStyle.color}
|
||||||
|
/>
|
||||||
|
</Animated.Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -6,16 +6,19 @@ import { View, ViewProps } from "react-native";
|
|||||||
import { RoundButton } from "./RoundButton";
|
import { RoundButton } from "./RoundButton";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
item: BaseItemDto;
|
items: BaseItemDto[];
|
||||||
|
size?: "default" | "large";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PlayedStatus: React.FC<Props> = ({ item, ...props }) => {
|
export const PlayedStatus: React.FC<Props> = ({ items, ...props }) => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const invalidateQueries = () => {
|
const invalidateQueries = () => {
|
||||||
queryClient.invalidateQueries({
|
items.forEach((item) => {
|
||||||
queryKey: ["item", item.Id],
|
queryClient.invalidateQueries({
|
||||||
});
|
queryKey: ["item", item.Id],
|
||||||
|
});
|
||||||
|
})
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["resumeItems"],
|
queryKey: ["resumeItems"],
|
||||||
});
|
});
|
||||||
@@ -39,15 +42,20 @@ export const PlayedStatus: React.FC<Props> = ({ item, ...props }) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const markAsPlayedStatus = useMarkAsPlayed(item);
|
const allPlayed = items.every((item) => item.UserData?.Played);
|
||||||
|
|
||||||
|
const markAsPlayedStatus = useMarkAsPlayed(items);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<RoundButton
|
<RoundButton
|
||||||
fillColor={item.UserData?.Played ? "primary" : undefined}
|
fillColor={allPlayed ? "primary" : undefined}
|
||||||
icon={item.UserData?.Played ? "checkmark" : "checkmark"}
|
icon={allPlayed ? "checkmark" : "checkmark"}
|
||||||
onPress={() => markAsPlayedStatus(item.UserData?.Played || false)}
|
onPress={async () => {
|
||||||
size="large"
|
console.log(allPlayed);
|
||||||
|
await markAsPlayedStatus(!allPlayed)
|
||||||
|
}}
|
||||||
|
size={props.size}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,41 +2,33 @@ 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 } from "react";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
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
|
||||||
}) => {
|
}) => {
|
||||||
|
if (Platform.isTV) return null;
|
||||||
const subtitleStreams = useMemo(() => {
|
const subtitleStreams = useMemo(() => {
|
||||||
const subtitleHelper = new SubtitleHelper(source?.MediaStreams ?? []);
|
return source?.MediaStreams?.filter((x) => x.Type === "Subtitle");
|
||||||
|
}, [source]);
|
||||||
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),
|
||||||
[subtitleStreams, selected]
|
[subtitleStreams, selected]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (subtitleStreams.length === 0) return null;
|
if (subtitleStreams?.length === 0) return null;
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -51,7 +43,9 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
|||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger>
|
<DropdownMenu.Trigger>
|
||||||
<View className="flex flex-col " {...props}>
|
<View className="flex flex-col " {...props}>
|
||||||
<Text className="opacity-50 mb-1 text-xs">{t("item_card.subtitles")}</Text>
|
<Text numberOfLines={1} className="opacity-50 mb-1 text-xs">
|
||||||
|
{t("item_card.subtitles")}
|
||||||
|
</Text>
|
||||||
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
|
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
|
||||||
<Text className=" ">
|
<Text className=" ">
|
||||||
{selectedSubtitleSteam
|
{selectedSubtitleSteam
|
||||||
|
|||||||
@@ -1,22 +1,27 @@
|
|||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
import {TouchableOpacity, View, ViewProps} from "react-native";
|
import { Platform, TouchableOpacity, View, ViewProps } from "react-native";
|
||||||
import {Text} from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import React, {PropsWithChildren, ReactNode, useEffect, useState} from "react";
|
import React, {
|
||||||
|
PropsWithChildren,
|
||||||
|
ReactNode,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
|
|
||||||
interface Props<T> {
|
interface Props<T> {
|
||||||
data: T[]
|
data: T[];
|
||||||
disabled?: boolean
|
disabled?: boolean;
|
||||||
placeholderText?: string,
|
placeholderText?: string;
|
||||||
keyExtractor: (item: T) => string
|
keyExtractor: (item: T) => string;
|
||||||
titleExtractor: (item: T) => string | undefined
|
titleExtractor: (item: T) => string | undefined;
|
||||||
title: string | ReactNode,
|
title: string | ReactNode;
|
||||||
label: string,
|
label: string;
|
||||||
onSelected: (...item: T[]) => void
|
onSelected: (...item: T[]) => void;
|
||||||
multi?: boolean
|
multi?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Dropdown = <T extends unknown>({
|
const Dropdown = <T extends unknown>({
|
||||||
data,
|
data,
|
||||||
disabled,
|
disabled,
|
||||||
placeholderText,
|
placeholderText,
|
||||||
@@ -28,38 +33,32 @@ const Dropdown = <T extends unknown>({
|
|||||||
multi = false,
|
multi = false,
|
||||||
...props
|
...props
|
||||||
}: PropsWithChildren<Props<T> & ViewProps>) => {
|
}: PropsWithChildren<Props<T> & ViewProps>) => {
|
||||||
|
if (Platform.isTV) return null;
|
||||||
const [selected, setSelected] = useState<T[]>();
|
const [selected, setSelected] = useState<T[]>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selected !== undefined) {
|
if (selected !== undefined) {
|
||||||
onSelected(...selected)
|
onSelected(...selected);
|
||||||
}
|
}
|
||||||
}, [selected]);
|
}, [selected]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DisabledSetting
|
<DisabledSetting disabled={disabled === true} showText={false} {...props}>
|
||||||
disabled={disabled === true}
|
|
||||||
showText={false}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger>
|
<DropdownMenu.Trigger>
|
||||||
{typeof title === 'string' ? (
|
{typeof title === "string" ? (
|
||||||
<View className="flex flex-col">
|
<View className="flex flex-col">
|
||||||
<Text className="opacity-50 mb-1 text-xs">
|
<Text className="opacity-50 mb-1 text-xs">{title}</Text>
|
||||||
{title}
|
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
|
||||||
</Text>
|
|
||||||
<TouchableOpacity
|
|
||||||
className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
|
|
||||||
<Text style={{}} className="" numberOfLines={1}>
|
<Text style={{}} className="" numberOfLines={1}>
|
||||||
{selected?.length !== undefined ? selected.map(titleExtractor).join(",") : placeholderText}
|
{selected?.length !== undefined
|
||||||
|
? selected.map(titleExtractor).join(",")
|
||||||
|
: placeholderText}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>{title}</>
|
||||||
{title}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
<DropdownMenu.Content
|
<DropdownMenu.Content
|
||||||
@@ -72,37 +71,48 @@ const Dropdown = <T extends unknown>({
|
|||||||
sideOffset={0}
|
sideOffset={0}
|
||||||
>
|
>
|
||||||
<DropdownMenu.Label>{label}</DropdownMenu.Label>
|
<DropdownMenu.Label>{label}</DropdownMenu.Label>
|
||||||
{data.map((item, idx) => (
|
{data.map((item, idx) =>
|
||||||
multi ? (
|
multi ? (
|
||||||
<DropdownMenu.CheckboxItem
|
<DropdownMenu.CheckboxItem
|
||||||
value={selected?.some(s => keyExtractor(s) == keyExtractor(item)) ? 'on' : 'off'}
|
value={
|
||||||
key={keyExtractor(item)}
|
selected?.some((s) => keyExtractor(s) == keyExtractor(item))
|
||||||
onValueChange={(next, previous) =>
|
? "on"
|
||||||
setSelected((p) => {
|
: "off"
|
||||||
const prev = p || []
|
}
|
||||||
if (next == 'on') {
|
key={keyExtractor(item)}
|
||||||
return [...prev, item]
|
onValueChange={(next, previous) =>
|
||||||
}
|
setSelected((p) => {
|
||||||
return [...prev.filter(p => keyExtractor(p) !== keyExtractor(item))]
|
const prev = p || [];
|
||||||
})
|
if (next == "on") {
|
||||||
}
|
return [...prev, item];
|
||||||
>
|
}
|
||||||
<DropdownMenu.ItemTitle>{titleExtractor(item)}</DropdownMenu.ItemTitle>
|
return [
|
||||||
</DropdownMenu.CheckboxItem>
|
...prev.filter(
|
||||||
)
|
(p) => keyExtractor(p) !== keyExtractor(item)
|
||||||
: (
|
),
|
||||||
<DropdownMenu.Item
|
];
|
||||||
key={keyExtractor(item)}
|
})
|
||||||
onSelect={() => setSelected([item])}
|
}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemTitle>{titleExtractor(item)}</DropdownMenu.ItemTitle>
|
<DropdownMenu.ItemTitle>
|
||||||
</DropdownMenu.Item>
|
{titleExtractor(item)}
|
||||||
)
|
</DropdownMenu.ItemTitle>
|
||||||
))}
|
</DropdownMenu.CheckboxItem>
|
||||||
|
) : (
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key={keyExtractor(item)}
|
||||||
|
onSelect={() => setSelected([item])}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemTitle>
|
||||||
|
{titleExtractor(item)}
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
)
|
||||||
|
)}
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu.Root>
|
</DropdownMenu.Root>
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
)
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Dropdown;
|
export default Dropdown;
|
||||||
|
|||||||
@@ -1,10 +1,24 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { TextInput, TextInputProps } from "react-native";
|
import {Platform, TextInput, TextInputProps, TouchableOpacity} from "react-native";
|
||||||
export function Input(props: TextInputProps) {
|
export function Input(props: TextInputProps) {
|
||||||
const { style, ...otherProps } = props;
|
const { style, ...otherProps } = props;
|
||||||
const inputRef = React.useRef<TextInput>(null);
|
const inputRef = React.useRef<TextInput>(null);
|
||||||
|
|
||||||
return (
|
return Platform.isTV ? (
|
||||||
|
<TouchableOpacity
|
||||||
|
onFocus={() => inputRef?.current?.focus?.()}
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
ref={inputRef}
|
||||||
|
className="p-4 rounded-xl bg-neutral-900"
|
||||||
|
allowFontScaling={false}
|
||||||
|
style={[{ color: "white" }, style]}
|
||||||
|
placeholderTextColor={"#9CA3AF"}
|
||||||
|
clearButtonMode="while-editing"
|
||||||
|
{...otherProps}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : (
|
||||||
<TextInput
|
<TextInput
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
className="p-4 rounded-xl bg-neutral-900"
|
className="p-4 rounded-xl bg-neutral-900"
|
||||||
@@ -14,5 +28,5 @@ export function Input(props: TextInputProps) {
|
|||||||
clearButtonMode="while-editing"
|
clearButtonMode="while-editing"
|
||||||
{...otherProps}
|
{...otherProps}
|
||||||
/>
|
/>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import {useRouter, useSegments} from "expo-router";
|
import { useRouter, useSegments } from "expo-router";
|
||||||
import React, {PropsWithChildren, useCallback, useMemo} from "react";
|
import React, { PropsWithChildren, useCallback, useMemo } from "react";
|
||||||
import {TouchableOpacity, TouchableOpacityProps} from "react-native";
|
import { TouchableOpacity, TouchableOpacityProps } from "react-native";
|
||||||
import * as ContextMenu from "zeego/context-menu";
|
import * as ContextMenu from "@/components/ContextMenu";
|
||||||
import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search";
|
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
|
||||||
import {useJellyseerr} from "@/hooks/useJellyseerr";
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import {hasPermission, Permission} from "@/utils/jellyseerr/server/lib/permissions";
|
import {
|
||||||
import {MediaType} from "@/utils/jellyseerr/server/constants/media";
|
hasPermission,
|
||||||
|
Permission,
|
||||||
|
} from "@/utils/jellyseerr/server/lib/permissions";
|
||||||
|
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||||
|
|
||||||
interface Props extends TouchableOpacityProps {
|
interface Props extends TouchableOpacityProps {
|
||||||
result: MovieResult | TvResult;
|
result: MovieResult | TvResult;
|
||||||
@@ -26,26 +29,27 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
const {jellyseerrApi, jellyseerrUser, requestMedia} = useJellyseerr()
|
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
|
||||||
|
|
||||||
const from = segments[2];
|
const from = segments[2];
|
||||||
|
|
||||||
const autoApprove = useMemo(() => {
|
const autoApprove = useMemo(() => {
|
||||||
return jellyseerrUser && hasPermission(
|
return (
|
||||||
Permission.AUTO_APPROVE,
|
jellyseerrUser &&
|
||||||
jellyseerrUser.permissions,
|
hasPermission(Permission.AUTO_APPROVE, jellyseerrUser.permissions, {
|
||||||
{type: 'or'}
|
type: "or",
|
||||||
)
|
})
|
||||||
}, [jellyseerrApi, jellyseerrUser])
|
);
|
||||||
|
}, [jellyseerrApi, jellyseerrUser]);
|
||||||
|
|
||||||
const request = useCallback(() =>
|
const request = useCallback(
|
||||||
|
() =>
|
||||||
requestMedia(mediaTitle, {
|
requestMedia(mediaTitle, {
|
||||||
mediaId: result.id,
|
mediaId: result.id,
|
||||||
mediaType: result.mediaType
|
mediaType: result.mediaType,
|
||||||
}
|
}),
|
||||||
),
|
|
||||||
[jellyseerrApi, result]
|
[jellyseerrApi, result]
|
||||||
)
|
);
|
||||||
|
|
||||||
if (from === "(home)" || from === "(search)" || from === "(libraries)")
|
if (from === "(home)" || from === "(search)" || from === "(libraries)")
|
||||||
return (
|
return (
|
||||||
@@ -55,7 +59,16 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
router.push({pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`, params: {...result, mediaTitle, releaseYear, canRequest, posterSrc}});
|
router.push({
|
||||||
|
pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`,
|
||||||
|
params: {
|
||||||
|
...result,
|
||||||
|
mediaTitle,
|
||||||
|
releaseYear,
|
||||||
|
canRequest,
|
||||||
|
posterSrc,
|
||||||
|
},
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -71,31 +84,33 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
>
|
>
|
||||||
<ContextMenu.Label key="label-1">Actions</ContextMenu.Label>
|
<ContextMenu.Label key="label-1">Actions</ContextMenu.Label>
|
||||||
{canRequest && result.mediaType === MediaType.MOVIE && (
|
{canRequest && result.mediaType === MediaType.MOVIE && (
|
||||||
<ContextMenu.Item
|
<ContextMenu.Item
|
||||||
key="item-1"
|
key="item-1"
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
if (autoApprove) {
|
if (autoApprove) {
|
||||||
request()
|
request();
|
||||||
}
|
}
|
||||||
|
}}
|
||||||
|
shouldDismissMenuOnSelect
|
||||||
|
>
|
||||||
|
<ContextMenu.ItemTitle key="item-1-title">
|
||||||
|
Request
|
||||||
|
</ContextMenu.ItemTitle>
|
||||||
|
<ContextMenu.ItemIcon
|
||||||
|
ios={{
|
||||||
|
name: "arrow.down.to.line",
|
||||||
|
pointSize: 18,
|
||||||
|
weight: "semibold",
|
||||||
|
scale: "medium",
|
||||||
|
hierarchicalColor: {
|
||||||
|
dark: "purple",
|
||||||
|
light: "purple",
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
shouldDismissMenuOnSelect
|
androidIconName="download"
|
||||||
>
|
/>
|
||||||
<ContextMenu.ItemTitle key="item-1-title">Request</ContextMenu.ItemTitle>
|
</ContextMenu.Item>
|
||||||
<ContextMenu.ItemIcon
|
)}
|
||||||
ios={{
|
|
||||||
name: "arrow.down.to.line",
|
|
||||||
pointSize: 18,
|
|
||||||
weight: "semibold",
|
|
||||||
scale: "medium",
|
|
||||||
hierarchicalColor: {
|
|
||||||
dark: "purple",
|
|
||||||
light: "purple",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
androidIconName="download"
|
|
||||||
/>
|
|
||||||
</ContextMenu.Item>
|
|
||||||
)}
|
|
||||||
</ContextMenu.Content>
|
</ContextMenu.Content>
|
||||||
</ContextMenu.Root>
|
</ContextMenu.Root>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,19 +1,27 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { TextProps } from "react-native";
|
import { Platform, TextProps } from "react-native";
|
||||||
import { UITextView } from "react-native-uitextview";
|
import { UITextView } from "react-native-uitextview";
|
||||||
|
import { Text as RNText } from "react-native";
|
||||||
export function Text(
|
export function Text(
|
||||||
props: TextProps & {
|
props: TextProps & {
|
||||||
uiTextView?: boolean;
|
uiTextView?: boolean;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
const { style, ...otherProps } = props;
|
const { style, ...otherProps } = props;
|
||||||
|
if (Platform.isTV)
|
||||||
return (
|
return (
|
||||||
<UITextView
|
<RNText
|
||||||
allowFontScaling={false}
|
allowFontScaling={false}
|
||||||
style={[{ color: "white" }, style]}
|
style={[{ color: "white" }, style]}
|
||||||
{...otherProps}
|
{...otherProps}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
else
|
||||||
|
return (
|
||||||
|
<UITextView
|
||||||
|
allowFontScaling={false}
|
||||||
|
style={[{ color: "white" }, style]}
|
||||||
|
{...otherProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||||
|
import { useFavorite } from "@/hooks/useFavorite";
|
||||||
import {
|
import {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
BaseItemPerson,
|
BaseItemPerson,
|
||||||
@@ -6,9 +7,7 @@ import {
|
|||||||
import { useRouter, useSegments } from "expo-router";
|
import { useRouter, useSegments } from "expo-router";
|
||||||
import { PropsWithChildren, useCallback } from "react";
|
import { PropsWithChildren, useCallback } from "react";
|
||||||
import { TouchableOpacity, TouchableOpacityProps } from "react-native";
|
import { TouchableOpacity, TouchableOpacityProps } from "react-native";
|
||||||
import * as ContextMenu from "zeego/context-menu";
|
|
||||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||||
import * as Haptics from "expo-haptics";
|
|
||||||
|
|
||||||
interface Props extends TouchableOpacityProps {
|
interface Props extends TouchableOpacityProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -57,15 +56,15 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
const markAsPlayedStatus = useMarkAsPlayed(item);
|
const markAsPlayedStatus = useMarkAsPlayed([item]);
|
||||||
|
const { isFavorite, toggleFavorite } = useFavorite(item);
|
||||||
|
|
||||||
const from = segments[2];
|
const from = segments[2];
|
||||||
|
|
||||||
const showActionSheet = useCallback(() => {
|
const showActionSheet = useCallback(() => {
|
||||||
if (!(item.Type === "Movie" || item.Type === "Episode")) return;
|
if (!(item.Type === "Movie" || item.Type === "Episode" || item.Type === "Series")) return;
|
||||||
|
const options = ["Mark as Played", "Mark as Not Played", isFavorite ? "Unmark as Favorite" : "Mark as Favorite", "Cancel"];
|
||||||
const options = ["Mark as Played", "Mark as Not Played", "Cancel"];
|
const cancelButtonIndex = 3;
|
||||||
const cancelButtonIndex = 2;
|
|
||||||
|
|
||||||
showActionSheetWithOptions(
|
showActionSheetWithOptions(
|
||||||
{
|
{
|
||||||
@@ -75,14 +74,14 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
async (selectedIndex) => {
|
async (selectedIndex) => {
|
||||||
if (selectedIndex === 0) {
|
if (selectedIndex === 0) {
|
||||||
await markAsPlayedStatus(true);
|
await markAsPlayedStatus(true);
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
|
||||||
} else if (selectedIndex === 1) {
|
} else if (selectedIndex === 1) {
|
||||||
await markAsPlayedStatus(false);
|
await markAsPlayedStatus(false);
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
} else if (selectedIndex === 2) {
|
||||||
|
toggleFavorite()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}, [showActionSheetWithOptions, markAsPlayedStatus]);
|
}, [showActionSheetWithOptions, isFavorite, markAsPlayedStatus]);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
from === "(home)" ||
|
from === "(home)" ||
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import {DownloadMethod, useSettings} from "@/utils/atoms/settings";
|
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { storage } from "@/utils/mmkv";
|
||||||
import { JobStatus } from "@/utils/optimize-server";
|
import { JobStatus } from "@/utils/optimize-server";
|
||||||
import { formatTimeString } from "@/utils/time";
|
import { formatTimeString } from "@/utils/time";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { checkForExistingDownloads } from "@kesha-antonov/react-native-background-downloader";
|
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Image } from "expo-image";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { FFmpegKit } from "ffmpeg-kit-react-native";
|
import { t } from "i18next";
|
||||||
|
import { useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
|
Platform,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
TouchableOpacityProps,
|
TouchableOpacityProps,
|
||||||
View,
|
View,
|
||||||
@@ -17,10 +20,12 @@ import {
|
|||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { Button } from "../Button";
|
import { Button } from "../Button";
|
||||||
import { Image } from "expo-image";
|
const BackGroundDownloader = !Platform.isTV
|
||||||
import { useMemo } from "react";
|
? require("@kesha-antonov/react-native-background-downloader")
|
||||||
import { storage } from "@/utils/mmkv";
|
: null;
|
||||||
import { t } from "i18next";
|
const FFmpegKitProvider = !Platform.isTV
|
||||||
|
? require("ffmpeg-kit-react-native")
|
||||||
|
: null;
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
@@ -29,16 +34,22 @@ export const ActiveDownloads: React.FC<Props> = ({ ...props }) => {
|
|||||||
if (processes?.length === 0)
|
if (processes?.length === 0)
|
||||||
return (
|
return (
|
||||||
<View {...props} className="bg-neutral-900 p-4 rounded-2xl">
|
<View {...props} className="bg-neutral-900 p-4 rounded-2xl">
|
||||||
<Text className="text-lg font-bold">{t("home.downloads.active_download")}</Text>
|
<Text className="text-lg font-bold">
|
||||||
<Text className="opacity-50">{t("home.downloads.no_active_downloads")}</Text>
|
{t("home.downloads.active_download")}
|
||||||
|
</Text>
|
||||||
|
<Text className="opacity-50">
|
||||||
|
{t("home.downloads.no_active_downloads")}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props} className="bg-neutral-900 p-4 rounded-2xl">
|
<View {...props} className="bg-neutral-900 p-4 rounded-2xl">
|
||||||
<Text className="text-lg font-bold mb-2">{t("home.downloads.active_downloads")}</Text>
|
<Text className="text-lg font-bold mb-2">
|
||||||
|
{t("home.downloads.active_downloads")}
|
||||||
|
</Text>
|
||||||
<View className="space-y-2">
|
<View className="space-y-2">
|
||||||
{processes?.map((p) => (
|
{processes?.map((p: JobStatus) => (
|
||||||
<DownloadCard key={p.item.Id} process={p} />
|
<DownloadCard key={p.item.Id} process={p} />
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
@@ -63,7 +74,7 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|||||||
|
|
||||||
if (settings?.downloadMethod === DownloadMethod.Optimized) {
|
if (settings?.downloadMethod === DownloadMethod.Optimized) {
|
||||||
try {
|
try {
|
||||||
const tasks = await checkForExistingDownloads();
|
const tasks = await BackGroundDownloader.checkForExistingDownloads();
|
||||||
for (const task of tasks) {
|
for (const task of tasks) {
|
||||||
if (task.id === id) {
|
if (task.id === id) {
|
||||||
task.stop();
|
task.stop();
|
||||||
@@ -76,8 +87,10 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|||||||
await queryClient.refetchQueries({ queryKey: ["jobs"] });
|
await queryClient.refetchQueries({ queryKey: ["jobs"] });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
FFmpegKit.cancel(Number(id));
|
FFmpegKitProvider.FFmpegKit.cancel(Number(id));
|
||||||
setProcesses((prev) => prev.filter((p) => p.id !== id));
|
setProcesses((prev: any[]) =>
|
||||||
|
prev.filter((p: { id: string }) => p.id !== id)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -152,7 +165,9 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|||||||
<Text className="text-xs">{process.speed?.toFixed(2)}x</Text>
|
<Text className="text-xs">{process.speed?.toFixed(2)}x</Text>
|
||||||
)}
|
)}
|
||||||
{eta(process) && (
|
{eta(process) && (
|
||||||
<Text className="text-xs">{t("home.downloads.eta", {eta: eta(process)})}</Text>
|
<Text className="text-xs">
|
||||||
|
{t("home.downloads.eta", { eta: eta(process) })}
|
||||||
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ interface Release {
|
|||||||
type: number;
|
type: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dateOpts: Intl.DateTimeFormatOptions = {
|
export const dateOpts: Intl.DateTimeFormatOptions = {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "long",
|
month: "long",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
@@ -50,18 +50,9 @@ const Fact: React.FC<{ title: string; fact?: string | null } & ViewProps> = ({
|
|||||||
const DetailFacts: React.FC<
|
const DetailFacts: React.FC<
|
||||||
{ details?: MovieDetails | TvDetails } & ViewProps
|
{ details?: MovieDetails | TvDetails } & ViewProps
|
||||||
> = ({ details, className, ...props }) => {
|
> = ({ details, className, ...props }) => {
|
||||||
const { jellyseerrUser } = useJellyseerr();
|
const { jellyseerrUser, jellyseerrRegion: region, jellyseerrLocale: locale } = useJellyseerr();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const locale = useMemo(() => {
|
|
||||||
return jellyseerrUser?.settings?.locale || "en";
|
|
||||||
}, [jellyseerrUser]);
|
|
||||||
|
|
||||||
const region = useMemo(
|
|
||||||
() => jellyseerrUser?.settings?.region || "US",
|
|
||||||
[jellyseerrUser]
|
|
||||||
);
|
|
||||||
|
|
||||||
const releases = useMemo(
|
const releases = useMemo(
|
||||||
() =>
|
() =>
|
||||||
(details as MovieDetails)?.releases?.results.find(
|
(details as MovieDetails)?.releases?.results.find(
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ const GenreSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
|
|||||||
className="w-28 rounded-lg overflow-hidden border border-neutral-900"
|
className="w-28 rounded-lg overflow-hidden border border-neutral-900"
|
||||||
id={item.id.toString()}
|
id={item.id.toString()}
|
||||||
title={item.name}
|
title={item.name}
|
||||||
colors={[]}
|
colors={['transparent', 'transparent']}
|
||||||
contentFit={"cover"}
|
contentFit={"cover"}
|
||||||
url={jellyseerrApi?.imageProxy(
|
url={jellyseerrApi?.imageProxy(
|
||||||
item.backdrops?.[0],
|
item.backdrops?.[0],
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export const ListGroup: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
</Text>
|
</Text>
|
||||||
<View
|
<View
|
||||||
style={[]}
|
style={[]}
|
||||||
className="flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900"
|
className="flex flex-col rounded-xl overflow-hidden pl-0 bg-neutral-900"
|
||||||
>
|
>
|
||||||
{Children.map(childrenArray, (child, index) => {
|
{Children.map(childrenArray, (child, index) => {
|
||||||
if (isValidElement<{ style?: ViewStyle }>(child)) {
|
if (isValidElement<{ style?: ViewStyle }>(child)) {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
className={`flex flex-row items-center justify-between bg-neutral-900 h-11 pr-4 ${
|
className={`flex flex-row items-center justify-between bg-neutral-900 h-11 pr-4 pl-4 ${
|
||||||
disabled ? "opacity-50" : ""
|
disabled ? "opacity-50" : ""
|
||||||
}`}
|
}`}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -55,7 +55,7 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className={`flex flex-row items-center justify-between bg-neutral-900 h-11 pr-4 ${
|
className={`flex flex-row items-center justify-between bg-neutral-900 h-11 pr-4 pl-4 ${
|
||||||
disabled ? "opacity-50" : ""
|
disabled ? "opacity-50" : ""
|
||||||
}`}
|
}`}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import { Loader } from "../Loader";
|
|||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
import {MovieDetails} from "@/utils/jellyseerr/server/models/Movie";
|
import {MovieDetails} from "@/utils/jellyseerr/server/models/Movie";
|
||||||
import {MediaRequestBody} from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
import {MediaRequestBody} from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
||||||
|
import {textShadowStyle} from "@/components/jellyseerr/discover/GenericSlideCard";
|
||||||
|
import {dateOpts} from "@/components/jellyseerr/DetailFacts";
|
||||||
|
|
||||||
const JellyseerrSeasonEpisodes: React.FC<{
|
const JellyseerrSeasonEpisodes: React.FC<{
|
||||||
details: TvDetails;
|
details: TvDetails;
|
||||||
@@ -52,26 +54,51 @@ const JellyseerrSeasonEpisodes: React.FC<{
|
|||||||
};
|
};
|
||||||
|
|
||||||
const RenderItem = ({ item, index }: any) => {
|
const RenderItem = ({ item, index }: any) => {
|
||||||
const { jellyseerrApi } = useJellyseerr();
|
const { jellyseerrApi, jellyseerrRegion: region, jellyseerrLocale: locale } = useJellyseerr();
|
||||||
const [imageError, setImageError] = useState(false);
|
const [imageError, setImageError] = useState(false);
|
||||||
|
|
||||||
|
const upcomingAirDate = useMemo(() => {
|
||||||
|
const airDate = item.airDate;
|
||||||
|
if (airDate) {
|
||||||
|
let airDateObj = new Date(airDate);
|
||||||
|
|
||||||
|
if (new Date() < airDateObj) {
|
||||||
|
return airDateObj.toLocaleDateString(
|
||||||
|
`${locale}-${region}`,
|
||||||
|
dateOpts
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex flex-col w-44 mt-2">
|
<View className="flex flex-col w-44 mt-2">
|
||||||
<View className="relative aspect-video rounded-lg overflow-hidden border border-neutral-800">
|
<View className="relative aspect-video rounded-lg overflow-hidden border border-neutral-800">
|
||||||
{!imageError ? (
|
{!imageError ? (
|
||||||
<Image
|
<>
|
||||||
key={item.id}
|
<Image
|
||||||
id={item.id}
|
key={item.id}
|
||||||
source={{
|
id={item.id}
|
||||||
uri: jellyseerrApi?.imageProxy(item.stillPath),
|
source={{
|
||||||
}}
|
uri: jellyseerrApi?.imageProxy(item.stillPath),
|
||||||
cachePolicy={"memory-disk"}
|
}}
|
||||||
contentFit="cover"
|
cachePolicy={"memory-disk"}
|
||||||
className="w-full h-full"
|
contentFit="cover"
|
||||||
onError={(e) => {
|
className="w-full h-full"
|
||||||
setImageError(true);
|
onError={(e) => {
|
||||||
}}
|
setImageError(true);
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
{upcomingAirDate && (
|
||||||
|
<View className="absolute justify-center bottom-0 right-0.5 items-center">
|
||||||
|
<View className="rounded-full bg-purple-600/30 p-1">
|
||||||
|
<Text className="text-center text-xs" style={textShadowStyle.shadow}>
|
||||||
|
{upcomingAirDate}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<View className="flex flex-col w-full h-full items-center justify-center border border-neutral-800 bg-neutral-900">
|
<View className="flex flex-col w-full h-full items-center justify-center border border-neutral-800 bg-neutral-900">
|
||||||
<Ionicons
|
<Ionicons
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
|
|
||||||
@@ -30,6 +30,8 @@ export const SeasonDropdown: React.FC<Props> = ({
|
|||||||
state,
|
state,
|
||||||
onSelect,
|
onSelect,
|
||||||
}) => {
|
}) => {
|
||||||
|
if (Platform.isTV) return null;
|
||||||
|
|
||||||
const keys = useMemo<SeasonKeys>(
|
const keys = useMemo<SeasonKeys>(
|
||||||
() =>
|
() =>
|
||||||
item.Type === "Episode"
|
item.Type === "Episode"
|
||||||
@@ -92,7 +94,9 @@ export const SeasonDropdown: React.FC<Props> = ({
|
|||||||
<DropdownMenu.Trigger>
|
<DropdownMenu.Trigger>
|
||||||
<View className="flex flex-row">
|
<View className="flex flex-row">
|
||||||
<TouchableOpacity className="bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
<TouchableOpacity className="bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||||
<Text>{t("item_card.season")} {seasonIndex}</Text>
|
<Text>
|
||||||
|
{t("item_card.season")} {seasonIndex}
|
||||||
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ import {
|
|||||||
SeasonIndexState,
|
SeasonIndexState,
|
||||||
} from "@/components/series/SeasonDropdown";
|
} from "@/components/series/SeasonDropdown";
|
||||||
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
|
import { PlayedStatus } from "../PlayedStatus";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
initialSeasonIndex?: number;
|
initialSeasonIndex?: number;
|
||||||
@@ -145,17 +147,20 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{episodes?.length || 0 > 0 ? (
|
{episodes?.length || 0 > 0 ? (
|
||||||
<DownloadItems
|
<View className="flex flex-row items-center space-x-2">
|
||||||
title={t("item_card.download.download_season")}
|
<DownloadItems
|
||||||
className="ml-2"
|
title={t("item_card.download.download_season")}
|
||||||
items={episodes || []}
|
className="ml-2"
|
||||||
MissingDownloadIconComponent={() => (
|
items={episodes || []}
|
||||||
<Ionicons name="download" size={20} color="white" />
|
MissingDownloadIconComponent={() => (
|
||||||
)}
|
<Ionicons name="download" size={20} color="white" />
|
||||||
DownloadedIconComponent={() => (
|
)}
|
||||||
<Ionicons name="download" size={20} color="#9333ea" />
|
DownloadedIconComponent={() => (
|
||||||
)}
|
<Ionicons name="download" size={20} color="#9333ea" />
|
||||||
/>
|
)}
|
||||||
|
/>
|
||||||
|
<PlayedStatus items={episodes || []} />
|
||||||
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
<View className="px-4 flex flex-col mt-4">
|
<View className="px-4 flex flex-col mt-4">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
import { Platform, TouchableOpacity, View, ViewProps } from "react-native";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
@@ -10,6 +10,7 @@ import { APP_LANGUAGES } from "@/i18n";
|
|||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
export const AppLanguageSelector: React.FC<Props> = ({ ...props }) => {
|
export const AppLanguageSelector: React.FC<Props> = ({ ...props }) => {
|
||||||
|
if (Platform.isTV) return null;
|
||||||
const [settings, updateSettings] = useSettings();
|
const [settings, updateSettings] = useSettings();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -17,60 +18,58 @@ export const AppLanguageSelector: React.FC<Props> = ({ ...props }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
<ListGroup
|
<ListGroup title={t("home.settings.languages.title")}>
|
||||||
title={t("home.settings.languages.title")}
|
|
||||||
>
|
|
||||||
<ListItem title={t("home.settings.languages.app_language")}>
|
<ListItem title={t("home.settings.languages.app_language")}>
|
||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger>
|
<DropdownMenu.Trigger>
|
||||||
<TouchableOpacity 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">
|
||||||
<Text>
|
<Text>
|
||||||
{APP_LANGUAGES.find(
|
{APP_LANGUAGES.find(
|
||||||
(l) => l.value === settings?.preferedLanguage
|
(l) => l.value === settings?.preferedLanguage
|
||||||
)?.label || t("home.settings.languages.system")}
|
)?.label || t("home.settings.languages.system")}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
<DropdownMenu.Content
|
<DropdownMenu.Content
|
||||||
loop={true}
|
loop={true}
|
||||||
side="bottom"
|
side="bottom"
|
||||||
align="start"
|
align="start"
|
||||||
alignOffset={0}
|
alignOffset={0}
|
||||||
avoidCollisions={true}
|
avoidCollisions={true}
|
||||||
collisionPadding={8}
|
collisionPadding={8}
|
||||||
sideOffset={8}
|
sideOffset={8}
|
||||||
>
|
|
||||||
<DropdownMenu.Label>
|
|
||||||
{t("home.settings.languages.title")}
|
|
||||||
</DropdownMenu.Label>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={"unknown"}
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({
|
|
||||||
preferedLanguage: undefined,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemTitle>
|
<DropdownMenu.Label>
|
||||||
{t("home.settings.languages.system")}
|
{t("home.settings.languages.title")}
|
||||||
</DropdownMenu.ItemTitle>
|
</DropdownMenu.Label>
|
||||||
</DropdownMenu.Item>
|
|
||||||
{APP_LANGUAGES?.map((l) => (
|
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
key={l?.value ?? "unknown"}
|
key={"unknown"}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
updateSettings({
|
updateSettings({
|
||||||
preferedLanguage: l.value,
|
preferedLanguage: undefined,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemTitle>{l.label}</DropdownMenu.ItemTitle>
|
<DropdownMenu.ItemTitle>
|
||||||
|
{t("home.settings.languages.system")}
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
))}
|
{APP_LANGUAGES?.map((l) => (
|
||||||
</DropdownMenu.Content>
|
<DropdownMenu.Item
|
||||||
</DropdownMenu.Root>
|
key={l?.value ?? "unknown"}
|
||||||
</ListItem>
|
onSelect={() => {
|
||||||
</ListGroup>
|
updateSettings({
|
||||||
</View>
|
preferedLanguage: l.value,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemTitle>{l.label}</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
))}
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
</ListItem>
|
||||||
|
</ListGroup>
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
import { Platform, TouchableOpacity, View, ViewProps } from "react-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { useMedia } from "./MediaContext";
|
import { useMedia } from "./MediaContext";
|
||||||
import { Switch } from "react-native-gesture-handler";
|
import { Switch } from "react-native-gesture-handler";
|
||||||
@@ -7,11 +7,12 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
import { ListItem } from "../list/ListItem";
|
import { ListItem } from "../list/ListItem";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import {useSettings} from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
export const AudioToggles: React.FC<Props> = ({ ...props }) => {
|
export const AudioToggles: React.FC<Props> = ({ ...props }) => {
|
||||||
|
if (Platform.isTV) return null;
|
||||||
const media = useMedia();
|
const media = useMedia();
|
||||||
const [_, __, pluginSettings] = useSettings();
|
const [_, __, pluginSettings] = useSettings();
|
||||||
const { settings, updateSettings } = media;
|
const { settings, updateSettings } = media;
|
||||||
@@ -47,7 +48,8 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
<DropdownMenu.Trigger>
|
<DropdownMenu.Trigger>
|
||||||
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3 ">
|
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3 ">
|
||||||
<Text className="mr-1 text-[#8E8D91]">
|
<Text className="mr-1 text-[#8E8D91]">
|
||||||
{settings?.defaultAudioLanguage?.DisplayName || t("home.settings.audio.none")}
|
{settings?.defaultAudioLanguage?.DisplayName ||
|
||||||
|
t("home.settings.audio.none")}
|
||||||
</Text>
|
</Text>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name="chevron-expand-sharp"
|
name="chevron-expand-sharp"
|
||||||
@@ -65,7 +67,9 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
collisionPadding={8}
|
collisionPadding={8}
|
||||||
sideOffset={8}
|
sideOffset={8}
|
||||||
>
|
>
|
||||||
<DropdownMenu.Label>{t("home.settings.audio.language")}</DropdownMenu.Label>
|
<DropdownMenu.Label>
|
||||||
|
{t("home.settings.audio.language")}
|
||||||
|
</DropdownMenu.Label>
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
key={"none-audio"}
|
key={"none-audio"}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
@@ -74,7 +78,9 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemTitle>{t("home.settings.audio.none")}</DropdownMenu.ItemTitle>
|
<DropdownMenu.ItemTitle>
|
||||||
|
{t("home.settings.audio.none")}
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
{cultures?.map((l) => (
|
{cultures?.map((l) => (
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
|
|||||||
22
components/settings/ChromecastSettings.tsx
Normal file
22
components/settings/ChromecastSettings.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Switch, View } from "react-native";
|
||||||
|
import { ListGroup } from "../list/ListGroup";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { ListItem } from "../list/ListItem";
|
||||||
|
|
||||||
|
export const ChromecastSettings: React.FC = ({ ...props }) => {
|
||||||
|
const [settings, updateSettings] = useSettings();
|
||||||
|
return (
|
||||||
|
<View {...props}>
|
||||||
|
<ListGroup title={"Chromecast"}>
|
||||||
|
<ListItem title={"Enable H265 for Chromecast"}>
|
||||||
|
<Switch
|
||||||
|
value={settings.enableH265ForChromecast}
|
||||||
|
onValueChange={(enableH265ForChromecast) =>
|
||||||
|
updateSettings({ enableH265ForChromecast })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
</ListGroup>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
30
components/settings/Dashboard.tsx
Normal file
30
components/settings/Dashboard.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import React from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { ListGroup } from "../list/ListGroup";
|
||||||
|
import { ListItem } from "../list/ListItem";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useSessions, useSessionsProps } from "@/hooks/useSessions";
|
||||||
|
|
||||||
|
export const Dashboard = () => {
|
||||||
|
const [settings, updateSettings] = useSettings();
|
||||||
|
const { sessions = [], isLoading } = useSessions({} as useSessionsProps);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (!settings) return null;
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<ListGroup title={t("home.settings.dashboard.title")} className="mt-4">
|
||||||
|
<ListItem
|
||||||
|
className={sessions.length != 0 ? "bg-purple-900" : ""}
|
||||||
|
onPress={() => router.push("/settings/dashboard/sessions")}
|
||||||
|
title={t("home.settings.dashboard.sessions_title")}
|
||||||
|
showArrow
|
||||||
|
/>
|
||||||
|
</ListGroup>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -5,15 +5,15 @@ import { Ionicons } from "@expo/vector-icons";
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import React, { useMemo } from "react";
|
import React, { useMemo } from "react";
|
||||||
import { Switch, TouchableOpacity } from "react-native";
|
import { Platform, Switch, TouchableOpacity } from "react-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
import { ListItem } from "../list/ListItem";
|
import { ListItem } from "../list/ListItem";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
|
|
||||||
export const DownloadSettings: React.FC = ({ ...props }) => {
|
export default function DownloadSettings({ ...props }) {
|
||||||
const [settings, updateSettings, pluginSettings] = useSettings();
|
const [settings, updateSettings, pluginSettings] = useSettings();
|
||||||
const { setProcesses } = useDownload();
|
const { setProcesses } = useDownload();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -61,7 +61,9 @@ export const DownloadSettings: React.FC = ({ ...props }) => {
|
|||||||
collisionPadding={8}
|
collisionPadding={8}
|
||||||
sideOffset={8}
|
sideOffset={8}
|
||||||
>
|
>
|
||||||
<DropdownMenu.Label>{t("home.settings.downloads.methods")}</DropdownMenu.Label>
|
<DropdownMenu.Label>
|
||||||
|
{t("home.settings.downloads.download_method")}
|
||||||
|
</DropdownMenu.Label>
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
key="1"
|
key="1"
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
@@ -69,7 +71,9 @@ export const DownloadSettings: React.FC = ({ ...props }) => {
|
|||||||
setProcesses([]);
|
setProcesses([]);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemTitle>{t("home.settings.downloads.default")}</DropdownMenu.ItemTitle>
|
<DropdownMenu.ItemTitle>
|
||||||
|
{t("home.settings.downloads.default")}
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
key="2"
|
key="2"
|
||||||
@@ -79,7 +83,9 @@ export const DownloadSettings: React.FC = ({ ...props }) => {
|
|||||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemTitle>{t("home.settings.downloads.optimized")}</DropdownMenu.ItemTitle>
|
<DropdownMenu.ItemTitle>
|
||||||
|
{t("home.settings.downloads.optimized")}
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu.Root>
|
</DropdownMenu.Root>
|
||||||
@@ -134,4 +140,4 @@ export const DownloadSettings: React.FC = ({ ...props }) => {
|
|||||||
</ListGroup>
|
</ListGroup>
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|||||||
5
components/settings/DownloadSettings.tv.tsx
Normal file
5
components/settings/DownloadSettings.tv.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export default function DownloadSettings({ ...props }) {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
507
components/settings/HomeIndex.tsx
Normal file
507
components/settings/HomeIndex.tsx
Normal file
@@ -0,0 +1,507 @@
|
|||||||
|
import { Button } from "@/components/Button";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel";
|
||||||
|
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
||||||
|
import { Colors } from "@/constants/Colors";
|
||||||
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { eventBus } from "@/utils/eventBus";
|
||||||
|
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Api } from "@jellyfin/sdk";
|
||||||
|
import {
|
||||||
|
BaseItemDto,
|
||||||
|
BaseItemKind,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import {
|
||||||
|
getItemsApi,
|
||||||
|
getSuggestionsApi,
|
||||||
|
getTvShowsApi,
|
||||||
|
getUserLibraryApi,
|
||||||
|
getUserViewsApi,
|
||||||
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import NetInfo from "@react-native-community/netinfo";
|
||||||
|
import { QueryFunction, useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
useNavigation,
|
||||||
|
usePathname,
|
||||||
|
useRouter,
|
||||||
|
useSegments,
|
||||||
|
} from "expo-router";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
RefreshControl,
|
||||||
|
ScrollView,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
|
type ScrollingCollectionListSection = {
|
||||||
|
type: "ScrollingCollectionList";
|
||||||
|
title?: string;
|
||||||
|
queryKey: (string | undefined | null)[];
|
||||||
|
queryFn: QueryFunction<BaseItemDto[]>;
|
||||||
|
orientation?: "horizontal" | "vertical";
|
||||||
|
};
|
||||||
|
|
||||||
|
type MediaListSection = {
|
||||||
|
type: "MediaListSection";
|
||||||
|
queryKey: (string | undefined)[];
|
||||||
|
queryFn: QueryFunction<BaseItemDto>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Section = ScrollingCollectionListSection | MediaListSection;
|
||||||
|
|
||||||
|
export const HomeIndex = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [
|
||||||
|
settings,
|
||||||
|
updateSettings,
|
||||||
|
pluginSettings,
|
||||||
|
setPluginSettings,
|
||||||
|
refreshStreamyfinPluginSettings,
|
||||||
|
] = useSettings();
|
||||||
|
|
||||||
|
const [isConnected, setIsConnected] = useState<boolean | null>(null);
|
||||||
|
const [loadingRetry, setLoadingRetry] = useState(false);
|
||||||
|
|
||||||
|
const navigation = useNavigation();
|
||||||
|
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const scrollViewRef = useRef<ScrollView>(null);
|
||||||
|
|
||||||
|
const { downloadedFiles, cleanCacheDirectory } = useDownload();
|
||||||
|
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]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
cleanCacheDirectory().catch((e) =>
|
||||||
|
console.error("Something went wrong cleaning cache directory")
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const segments = useSegments();
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = eventBus.on("scrollToTop", () => {
|
||||||
|
if (segments[2] === "(home)")
|
||||||
|
scrollViewRef.current?.scrollTo({ y: -152, animated: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
}, [segments]);
|
||||||
|
|
||||||
|
const checkConnection = useCallback(async () => {
|
||||||
|
setLoadingRetry(true);
|
||||||
|
const state = await NetInfo.fetch();
|
||||||
|
setIsConnected(state.isConnected);
|
||||||
|
setLoadingRetry(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = NetInfo.addEventListener((state) => {
|
||||||
|
if (state.isConnected == false || state.isInternetReachable === false)
|
||||||
|
setIsConnected(false);
|
||||||
|
else setIsConnected(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
NetInfo.fetch().then((state) => {
|
||||||
|
setIsConnected(state.isConnected);
|
||||||
|
});
|
||||||
|
|
||||||
|
// cleanCacheDirectory().catch((e) =>
|
||||||
|
// console.error("Something went wrong cleaning cache directory")
|
||||||
|
// );
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isError: e1,
|
||||||
|
isLoading: l1,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["home", "userViews", user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api || !user?.Id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await getUserViewsApi(api).getUserViews({
|
||||||
|
userId: user.Id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data.Items || null;
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id,
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const userViews = useMemo(
|
||||||
|
() => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
|
||||||
|
[data, settings?.hiddenLibraries]
|
||||||
|
);
|
||||||
|
|
||||||
|
const collections = useMemo(() => {
|
||||||
|
const allow = ["movies", "tvshows"];
|
||||||
|
return (
|
||||||
|
userViews?.filter(
|
||||||
|
(c) => c.CollectionType && allow.includes(c.CollectionType)
|
||||||
|
) || []
|
||||||
|
);
|
||||||
|
}, [userViews]);
|
||||||
|
|
||||||
|
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||||
|
|
||||||
|
const refetch = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
await refreshStreamyfinPluginSettings();
|
||||||
|
await invalidateCache();
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const createCollectionConfig = useCallback(
|
||||||
|
(
|
||||||
|
title: string,
|
||||||
|
queryKey: string[],
|
||||||
|
includeItemTypes: BaseItemKind[],
|
||||||
|
parentId: string | undefined
|
||||||
|
): ScrollingCollectionListSection => ({
|
||||||
|
title,
|
||||||
|
queryKey,
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api) return [];
|
||||||
|
return (
|
||||||
|
(
|
||||||
|
await getUserLibraryApi(api).getLatestMedia({
|
||||||
|
userId: user?.Id,
|
||||||
|
limit: 20,
|
||||||
|
fields: ["PrimaryImageAspectRatio", "Path"],
|
||||||
|
imageTypeLimit: 1,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
|
includeItemTypes,
|
||||||
|
parentId,
|
||||||
|
})
|
||||||
|
).data || []
|
||||||
|
);
|
||||||
|
},
|
||||||
|
type: "ScrollingCollectionList",
|
||||||
|
}),
|
||||||
|
[api, user?.Id]
|
||||||
|
);
|
||||||
|
|
||||||
|
let sections: Section[] = [];
|
||||||
|
if (!settings?.home || !settings?.home?.sections) {
|
||||||
|
sections = useMemo(() => {
|
||||||
|
if (!api || !user?.Id) return [];
|
||||||
|
|
||||||
|
const latestMediaViews = collections.map((c) => {
|
||||||
|
const includeItemTypes: BaseItemKind[] =
|
||||||
|
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"];
|
||||||
|
const title = t("home.recently_added_in", { libraryName: c.Name });
|
||||||
|
const queryKey = [
|
||||||
|
"home",
|
||||||
|
"recentlyAddedIn" + c.CollectionType,
|
||||||
|
user?.Id!,
|
||||||
|
c.Id!,
|
||||||
|
];
|
||||||
|
return createCollectionConfig(
|
||||||
|
title || "",
|
||||||
|
queryKey,
|
||||||
|
includeItemTypes,
|
||||||
|
c.Id
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const ss: Section[] = [
|
||||||
|
{
|
||||||
|
title: t("home.continue_watching"),
|
||||||
|
queryKey: ["home", "resumeItems"],
|
||||||
|
queryFn: async () =>
|
||||||
|
(
|
||||||
|
await getItemsApi(api).getResumeItems({
|
||||||
|
userId: user.Id,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
|
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||||
|
})
|
||||||
|
).data.Items || [],
|
||||||
|
type: "ScrollingCollectionList",
|
||||||
|
orientation: "horizontal",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("home.next_up"),
|
||||||
|
queryKey: ["home", "nextUp-all"],
|
||||||
|
queryFn: async () =>
|
||||||
|
(
|
||||||
|
await getTvShowsApi(api).getNextUp({
|
||||||
|
userId: user?.Id,
|
||||||
|
fields: ["MediaSourceCount"],
|
||||||
|
limit: 20,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
|
enableResumable: false,
|
||||||
|
})
|
||||||
|
).data.Items || [],
|
||||||
|
type: "ScrollingCollectionList",
|
||||||
|
orientation: "horizontal",
|
||||||
|
},
|
||||||
|
...latestMediaViews,
|
||||||
|
// ...(mediaListCollections?.map(
|
||||||
|
// (ml) =>
|
||||||
|
// ({
|
||||||
|
// title: ml.Name,
|
||||||
|
// queryKey: ["home", "mediaList", ml.Id!],
|
||||||
|
// queryFn: async () => ml,
|
||||||
|
// type: "MediaListSection",
|
||||||
|
// orientation: "vertical",
|
||||||
|
// } as Section)
|
||||||
|
// ) || []),
|
||||||
|
{
|
||||||
|
title: t("home.suggested_movies"),
|
||||||
|
queryKey: ["home", "suggestedMovies", user?.Id],
|
||||||
|
queryFn: async () =>
|
||||||
|
(
|
||||||
|
await getSuggestionsApi(api).getSuggestions({
|
||||||
|
userId: user?.Id,
|
||||||
|
limit: 10,
|
||||||
|
mediaType: ["Video"],
|
||||||
|
type: ["Movie"],
|
||||||
|
})
|
||||||
|
).data.Items || [],
|
||||||
|
type: "ScrollingCollectionList",
|
||||||
|
orientation: "vertical",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("home.suggested_episodes"),
|
||||||
|
queryKey: ["home", "suggestedEpisodes", user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
try {
|
||||||
|
const suggestions = await getSuggestions(api, user.Id);
|
||||||
|
const nextUpPromises = suggestions.map((series) =>
|
||||||
|
getNextUp(api, user.Id, series.Id)
|
||||||
|
);
|
||||||
|
const nextUpResults = await Promise.all(nextUpPromises);
|
||||||
|
|
||||||
|
return nextUpResults.filter((item) => item !== null) || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching data:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: "ScrollingCollectionList",
|
||||||
|
orientation: "horizontal",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return ss;
|
||||||
|
}, [api, user?.Id, collections]);
|
||||||
|
} else {
|
||||||
|
sections = useMemo(() => {
|
||||||
|
if (!api || !user?.Id) return [];
|
||||||
|
const ss: Section[] = [];
|
||||||
|
|
||||||
|
for (const key in settings.home?.sections) {
|
||||||
|
// @ts-expect-error
|
||||||
|
const section = settings.home?.sections[key];
|
||||||
|
const id = section.title || key;
|
||||||
|
ss.push({
|
||||||
|
title: id,
|
||||||
|
queryKey: ["home", id],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (section.items) {
|
||||||
|
const response = await getItemsApi(api).getItems({
|
||||||
|
userId: user?.Id,
|
||||||
|
limit: section.items?.limit || 25,
|
||||||
|
recursive: true,
|
||||||
|
includeItemTypes: section.items?.includeItemTypes,
|
||||||
|
sortBy: section.items?.sortBy,
|
||||||
|
sortOrder: section.items?.sortOrder,
|
||||||
|
filters: section.items?.filters,
|
||||||
|
parentId: section.items?.parentId,
|
||||||
|
});
|
||||||
|
return response.data.Items || [];
|
||||||
|
} else if (section.nextUp) {
|
||||||
|
const response = await getTvShowsApi(api).getNextUp({
|
||||||
|
userId: user?.Id,
|
||||||
|
fields: ["MediaSourceCount"],
|
||||||
|
limit: section.items?.limit || 25,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
|
enableResumable: section.items?.enableResumable || false,
|
||||||
|
enableRewatching: section.items?.enableRewatching || false,
|
||||||
|
});
|
||||||
|
return response.data.Items || [];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
type: "ScrollingCollectionList",
|
||||||
|
orientation: section?.orientation || "vertical",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return ss;
|
||||||
|
}, [api, user?.Id, settings.home?.sections]);
|
||||||
|
}
|
||||||
|
|
||||||
|
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">{t("home.no_internet")}</Text>
|
||||||
|
<Text className="text-center opacity-70">
|
||||||
|
{t("home.no_internet_message")}
|
||||||
|
</Text>
|
||||||
|
<View className="mt-4">
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
onPress={() => router.push("/(auth)/downloads")}
|
||||||
|
justify="center"
|
||||||
|
iconRight={
|
||||||
|
<Ionicons name="arrow-forward" size={20} color="white" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("home.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)
|
||||||
|
return (
|
||||||
|
<View className="flex flex-col items-center justify-center h-full -mt-6">
|
||||||
|
<Text className="text-3xl font-bold mb-2">{t("home.oops")}</Text>
|
||||||
|
<Text className="text-center opacity-70">
|
||||||
|
{t("home.error_message")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (l1)
|
||||||
|
return (
|
||||||
|
<View className="justify-center items-center h-full">
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
scrollToOverflowEnabled={true}
|
||||||
|
ref={scrollViewRef}
|
||||||
|
nestedScrollEnabled
|
||||||
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={loading} onRefresh={refetch} />
|
||||||
|
}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
paddingBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className="flex flex-col space-y-4">
|
||||||
|
<LargeMovieCarousel />
|
||||||
|
|
||||||
|
{sections.map((section, index) => {
|
||||||
|
if (section.type === "ScrollingCollectionList") {
|
||||||
|
return (
|
||||||
|
<ScrollingCollectionList
|
||||||
|
key={index}
|
||||||
|
title={section.title}
|
||||||
|
queryKey={section.queryKey}
|
||||||
|
queryFn={section.queryFn}
|
||||||
|
orientation={section.orientation}
|
||||||
|
hideIfEmpty
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (section.type === "MediaListSection") {
|
||||||
|
return (
|
||||||
|
<MediaListSection
|
||||||
|
key={index}
|
||||||
|
queryKey={section.queryKey}
|
||||||
|
queryFn={section.queryFn}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to get suggestions
|
||||||
|
async function getSuggestions(api: Api, userId: string | undefined) {
|
||||||
|
if (!userId) return [];
|
||||||
|
const response = await getSuggestionsApi(api).getSuggestions({
|
||||||
|
userId,
|
||||||
|
limit: 10,
|
||||||
|
mediaType: ["Unknown"],
|
||||||
|
type: ["Series"],
|
||||||
|
});
|
||||||
|
return response.data.Items ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to get the next up TV show for a series
|
||||||
|
async function getNextUp(
|
||||||
|
api: Api,
|
||||||
|
userId: string | undefined,
|
||||||
|
seriesId: string | undefined
|
||||||
|
) {
|
||||||
|
if (!userId || !seriesId) return null;
|
||||||
|
const response = await getTvShowsApi(api).getNextUp({
|
||||||
|
userId,
|
||||||
|
seriesId,
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
return response.data.Items?.[0] ?? null;
|
||||||
|
}
|
||||||
453
components/settings/HomeIndex.tv.tsx
Normal file
453
components/settings/HomeIndex.tv.tsx
Normal file
@@ -0,0 +1,453 @@
|
|||||||
|
import { Button } from "@/components/Button";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel";
|
||||||
|
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
||||||
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Api } from "@jellyfin/sdk";
|
||||||
|
import {
|
||||||
|
BaseItemDto,
|
||||||
|
BaseItemKind,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import {
|
||||||
|
getItemsApi,
|
||||||
|
getSuggestionsApi,
|
||||||
|
getTvShowsApi,
|
||||||
|
getUserLibraryApi,
|
||||||
|
getUserViewsApi,
|
||||||
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import NetInfo from "@react-native-community/netinfo";
|
||||||
|
import { QueryFunction, useQuery } from "@tanstack/react-query";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
RefreshControl,
|
||||||
|
ScrollView,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
|
type ScrollingCollectionListSection = {
|
||||||
|
type: "ScrollingCollectionList";
|
||||||
|
title?: string;
|
||||||
|
queryKey: (string | undefined | null)[];
|
||||||
|
queryFn: QueryFunction<BaseItemDto[]>;
|
||||||
|
orientation?: "horizontal" | "vertical";
|
||||||
|
};
|
||||||
|
|
||||||
|
type MediaListSection = {
|
||||||
|
type: "MediaListSection";
|
||||||
|
queryKey: (string | undefined)[];
|
||||||
|
queryFn: QueryFunction<BaseItemDto>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Section = ScrollingCollectionListSection | MediaListSection;
|
||||||
|
|
||||||
|
export const HomeIndex = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [
|
||||||
|
settings,
|
||||||
|
updateSettings,
|
||||||
|
pluginSettings,
|
||||||
|
setPluginSettings,
|
||||||
|
refreshStreamyfinPluginSettings,
|
||||||
|
] = useSettings();
|
||||||
|
|
||||||
|
const [isConnected, setIsConnected] = useState<boolean | null>(null);
|
||||||
|
const [loadingRetry, setLoadingRetry] = useState(false);
|
||||||
|
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const checkConnection = useCallback(async () => {
|
||||||
|
setLoadingRetry(true);
|
||||||
|
const state = await NetInfo.fetch();
|
||||||
|
setIsConnected(state.isConnected);
|
||||||
|
setLoadingRetry(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = NetInfo.addEventListener((state) => {
|
||||||
|
if (state.isConnected == false || state.isInternetReachable === false)
|
||||||
|
setIsConnected(false);
|
||||||
|
else setIsConnected(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
NetInfo.fetch().then((state) => {
|
||||||
|
setIsConnected(state.isConnected);
|
||||||
|
});
|
||||||
|
|
||||||
|
// cleanCacheDirectory().catch((e) =>
|
||||||
|
// console.error("Something went wrong cleaning cache directory")
|
||||||
|
// );
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isError: e1,
|
||||||
|
isLoading: l1,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["home", "userViews", user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api || !user?.Id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await getUserViewsApi(api).getUserViews({
|
||||||
|
userId: user.Id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data.Items || null;
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id,
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const userViews = useMemo(
|
||||||
|
() => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
|
||||||
|
[data, settings?.hiddenLibraries]
|
||||||
|
);
|
||||||
|
|
||||||
|
const collections = useMemo(() => {
|
||||||
|
const allow = ["movies", "tvshows"];
|
||||||
|
return (
|
||||||
|
userViews?.filter(
|
||||||
|
(c) => c.CollectionType && allow.includes(c.CollectionType)
|
||||||
|
) || []
|
||||||
|
);
|
||||||
|
}, [userViews]);
|
||||||
|
|
||||||
|
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||||
|
|
||||||
|
const refetch = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
await refreshStreamyfinPluginSettings();
|
||||||
|
await invalidateCache();
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const createCollectionConfig = useCallback(
|
||||||
|
(
|
||||||
|
title: string,
|
||||||
|
queryKey: string[],
|
||||||
|
includeItemTypes: BaseItemKind[],
|
||||||
|
parentId: string | undefined
|
||||||
|
): ScrollingCollectionListSection => ({
|
||||||
|
title,
|
||||||
|
queryKey,
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api) return [];
|
||||||
|
return (
|
||||||
|
(
|
||||||
|
await getUserLibraryApi(api).getLatestMedia({
|
||||||
|
userId: user?.Id,
|
||||||
|
limit: 20,
|
||||||
|
fields: ["PrimaryImageAspectRatio", "Path"],
|
||||||
|
imageTypeLimit: 1,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
|
includeItemTypes,
|
||||||
|
parentId,
|
||||||
|
})
|
||||||
|
).data || []
|
||||||
|
);
|
||||||
|
},
|
||||||
|
type: "ScrollingCollectionList",
|
||||||
|
}),
|
||||||
|
[api, user?.Id]
|
||||||
|
);
|
||||||
|
|
||||||
|
let sections: Section[] = [];
|
||||||
|
if (!settings?.home || !settings?.home?.sections) {
|
||||||
|
sections = useMemo(() => {
|
||||||
|
if (!api || !user?.Id) return [];
|
||||||
|
|
||||||
|
const latestMediaViews = collections.map((c) => {
|
||||||
|
const includeItemTypes: BaseItemKind[] =
|
||||||
|
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"];
|
||||||
|
const title = t("home.recently_added_in", { libraryName: c.Name });
|
||||||
|
const queryKey = [
|
||||||
|
"home",
|
||||||
|
"recentlyAddedIn" + c.CollectionType,
|
||||||
|
user?.Id!,
|
||||||
|
c.Id!,
|
||||||
|
];
|
||||||
|
return createCollectionConfig(
|
||||||
|
title || "",
|
||||||
|
queryKey,
|
||||||
|
includeItemTypes,
|
||||||
|
c.Id
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const ss: Section[] = [
|
||||||
|
{
|
||||||
|
title: t("home.continue_watching"),
|
||||||
|
queryKey: ["home", "resumeItems"],
|
||||||
|
queryFn: async () =>
|
||||||
|
(
|
||||||
|
await getItemsApi(api).getResumeItems({
|
||||||
|
userId: user.Id,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
|
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||||
|
})
|
||||||
|
).data.Items || [],
|
||||||
|
type: "ScrollingCollectionList",
|
||||||
|
orientation: "horizontal",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("home.next_up"),
|
||||||
|
queryKey: ["home", "nextUp-all"],
|
||||||
|
queryFn: async () =>
|
||||||
|
(
|
||||||
|
await getTvShowsApi(api).getNextUp({
|
||||||
|
userId: user?.Id,
|
||||||
|
fields: ["MediaSourceCount"],
|
||||||
|
limit: 20,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
|
enableResumable: false,
|
||||||
|
})
|
||||||
|
).data.Items || [],
|
||||||
|
type: "ScrollingCollectionList",
|
||||||
|
orientation: "horizontal",
|
||||||
|
},
|
||||||
|
...latestMediaViews,
|
||||||
|
// ...(mediaListCollections?.map(
|
||||||
|
// (ml) =>
|
||||||
|
// ({
|
||||||
|
// title: ml.Name,
|
||||||
|
// queryKey: ["home", "mediaList", ml.Id!],
|
||||||
|
// queryFn: async () => ml,
|
||||||
|
// type: "MediaListSection",
|
||||||
|
// orientation: "vertical",
|
||||||
|
// } as Section)
|
||||||
|
// ) || []),
|
||||||
|
{
|
||||||
|
title: t("home.suggested_movies"),
|
||||||
|
queryKey: ["home", "suggestedMovies", user?.Id],
|
||||||
|
queryFn: async () =>
|
||||||
|
(
|
||||||
|
await getSuggestionsApi(api).getSuggestions({
|
||||||
|
userId: user?.Id,
|
||||||
|
limit: 10,
|
||||||
|
mediaType: ["Video"],
|
||||||
|
type: ["Movie"],
|
||||||
|
})
|
||||||
|
).data.Items || [],
|
||||||
|
type: "ScrollingCollectionList",
|
||||||
|
orientation: "vertical",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("home.suggested_episodes"),
|
||||||
|
queryKey: ["home", "suggestedEpisodes", user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
try {
|
||||||
|
const suggestions = await getSuggestions(api, user.Id);
|
||||||
|
const nextUpPromises = suggestions.map((series) =>
|
||||||
|
getNextUp(api, user.Id, series.Id)
|
||||||
|
);
|
||||||
|
const nextUpResults = await Promise.all(nextUpPromises);
|
||||||
|
|
||||||
|
return nextUpResults.filter((item) => item !== null) || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching data:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: "ScrollingCollectionList",
|
||||||
|
orientation: "horizontal",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return ss;
|
||||||
|
}, [api, user?.Id, collections]);
|
||||||
|
} else {
|
||||||
|
sections = useMemo(() => {
|
||||||
|
if (!api || !user?.Id) return [];
|
||||||
|
const ss: Section[] = [];
|
||||||
|
|
||||||
|
for (const key in settings.home?.sections) {
|
||||||
|
// @ts-expect-error
|
||||||
|
const section = settings.home?.sections[key];
|
||||||
|
const id = section.title || key;
|
||||||
|
ss.push({
|
||||||
|
title: id,
|
||||||
|
queryKey: ["home", id],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (section.items) {
|
||||||
|
const response = await getItemsApi(api).getItems({
|
||||||
|
userId: user?.Id,
|
||||||
|
limit: section.items?.limit || 25,
|
||||||
|
recursive: true,
|
||||||
|
includeItemTypes: section.items?.includeItemTypes,
|
||||||
|
sortBy: section.items?.sortBy,
|
||||||
|
sortOrder: section.items?.sortOrder,
|
||||||
|
filters: section.items?.filters,
|
||||||
|
parentId: section.items?.parentId,
|
||||||
|
});
|
||||||
|
return response.data.Items || [];
|
||||||
|
} else if (section.nextUp) {
|
||||||
|
const response = await getTvShowsApi(api).getNextUp({
|
||||||
|
userId: user?.Id,
|
||||||
|
fields: ["MediaSourceCount"],
|
||||||
|
limit: section.items?.limit || 25,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
|
enableResumable: section.items?.enableResumable || false,
|
||||||
|
enableRewatching: section.items?.enableRewatching || false,
|
||||||
|
});
|
||||||
|
return response.data.Items || [];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
type: "ScrollingCollectionList",
|
||||||
|
orientation: section?.orientation || "vertical",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return ss;
|
||||||
|
}, [api, user?.Id, settings.home?.sections]);
|
||||||
|
}
|
||||||
|
|
||||||
|
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">{t("home.no_internet")}</Text>
|
||||||
|
<Text className="text-center opacity-70">
|
||||||
|
{t("home.no_internet_message")}
|
||||||
|
</Text>
|
||||||
|
<View className="mt-4">
|
||||||
|
<Button
|
||||||
|
color="purple"
|
||||||
|
onPress={() => router.push("/(auth)/downloads")}
|
||||||
|
justify="center"
|
||||||
|
iconRight={
|
||||||
|
<Ionicons name="arrow-forward" size={20} color="white" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("home.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)
|
||||||
|
return (
|
||||||
|
<View className="flex flex-col items-center justify-center h-full -mt-6">
|
||||||
|
<Text className="text-3xl font-bold mb-2">{t("home.oops")}</Text>
|
||||||
|
<Text className="text-center opacity-70">
|
||||||
|
{t("home.error_message")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (l1)
|
||||||
|
return (
|
||||||
|
<View className="justify-center items-center h-full">
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
nestedScrollEnabled
|
||||||
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={loading} onRefresh={refetch} />
|
||||||
|
}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
paddingBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className="flex flex-col space-y-4">
|
||||||
|
<LargeMovieCarousel />
|
||||||
|
|
||||||
|
{sections.map((section, index) => {
|
||||||
|
if (section.type === "ScrollingCollectionList") {
|
||||||
|
return (
|
||||||
|
<ScrollingCollectionList
|
||||||
|
key={index}
|
||||||
|
title={section.title}
|
||||||
|
queryKey={section.queryKey}
|
||||||
|
queryFn={section.queryFn}
|
||||||
|
orientation={section.orientation}
|
||||||
|
hideIfEmpty
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (section.type === "MediaListSection") {
|
||||||
|
return (
|
||||||
|
<MediaListSection
|
||||||
|
key={index}
|
||||||
|
queryKey={section.queryKey}
|
||||||
|
queryFn={section.queryFn}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to get suggestions
|
||||||
|
async function getSuggestions(api: Api, userId: string | undefined) {
|
||||||
|
if (!userId) return [];
|
||||||
|
const response = await getSuggestionsApi(api).getSuggestions({
|
||||||
|
userId,
|
||||||
|
limit: 10,
|
||||||
|
mediaType: ["Unknown"],
|
||||||
|
type: ["Series"],
|
||||||
|
});
|
||||||
|
return response.data.Items ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to get the next up TV show for a series
|
||||||
|
async function getNextUp(
|
||||||
|
api: Api,
|
||||||
|
userId: string | undefined,
|
||||||
|
seriesId: string | undefined
|
||||||
|
) {
|
||||||
|
if (!userId || !seriesId) return null;
|
||||||
|
const response = await getTvShowsApi(api).getNextUp({
|
||||||
|
userId,
|
||||||
|
seriesId,
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
return response.data.Items?.[0] ?? null;
|
||||||
|
}
|
||||||
@@ -26,9 +26,6 @@ export const JellyseerrSettings = () => {
|
|||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const [settings, updateSettings, pluginSettings] = useSettings();
|
const [settings, updateSettings, pluginSettings] = useSettings();
|
||||||
|
|
||||||
const [promptForJellyseerrPass, setPromptForJellyseerrPass] =
|
|
||||||
useState<boolean>(false);
|
|
||||||
|
|
||||||
const [jellyseerrPassword, setJellyseerrPassword] = useState<
|
const [jellyseerrPassword, setJellyseerrPassword] = useState<
|
||||||
string | undefined
|
string | undefined
|
||||||
>(undefined);
|
>(undefined);
|
||||||
@@ -39,11 +36,16 @@ export const JellyseerrSettings = () => {
|
|||||||
|
|
||||||
const loginToJellyseerrMutation = useMutation({
|
const loginToJellyseerrMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
if (!jellyseerrServerUrl || !user?.Name || !jellyseerrPassword) {
|
if (!jellyseerrServerUrl && !settings?.jellyseerrServerUrl)
|
||||||
|
throw new Error("Missing server url");
|
||||||
|
if (!user?.Name)
|
||||||
throw new Error("Missing required information for login");
|
throw new Error("Missing required information for login");
|
||||||
}
|
const jellyseerrTempApi = new JellyseerrApi(
|
||||||
const jellyseerrTempApi = new JellyseerrApi(jellyseerrServerUrl);
|
jellyseerrServerUrl || settings.jellyseerrServerUrl || ""
|
||||||
return jellyseerrTempApi.login(user.Name, jellyseerrPassword);
|
);
|
||||||
|
const testResult = await jellyseerrTempApi.test();
|
||||||
|
if (!testResult.isValid) throw new Error("Invalid server url");
|
||||||
|
return jellyseerrTempApi.login(user.Name, jellyseerrPassword || "");
|
||||||
},
|
},
|
||||||
onSuccess: (user) => {
|
onSuccess: (user) => {
|
||||||
setJellyseerrUser(user);
|
setJellyseerrUser(user);
|
||||||
@@ -57,31 +59,11 @@ export const JellyseerrSettings = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
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 = () => {
|
const clearData = () => {
|
||||||
clearAllJellyseerData().finally(() => {
|
clearAllJellyseerData().finally(() => {
|
||||||
|
setJellyseerrUser(undefined);
|
||||||
|
setJellyseerrPassword(undefined);
|
||||||
setjellyseerrServerUrl(undefined);
|
setjellyseerrServerUrl(undefined);
|
||||||
setPromptForJellyseerrPass(false);
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -92,34 +74,46 @@ export const JellyseerrSettings = () => {
|
|||||||
<>
|
<>
|
||||||
<ListGroup title={"Jellyseerr"}>
|
<ListGroup title={"Jellyseerr"}>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.plugins.jellyseerr.total_media_requests")}
|
title={t(
|
||||||
|
"home.settings.plugins.jellyseerr.total_media_requests"
|
||||||
|
)}
|
||||||
value={jellyseerrUser?.requestCount?.toString()}
|
value={jellyseerrUser?.requestCount?.toString()}
|
||||||
/>
|
/>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.plugins.jellyseerr.movie_quota_limit")}
|
title={t("home.settings.plugins.jellyseerr.movie_quota_limit")}
|
||||||
value={
|
value={
|
||||||
jellyseerrUser?.movieQuotaLimit?.toString() ?? t("home.settings.plugins.jellyseerr.unlimited")
|
jellyseerrUser?.movieQuotaLimit?.toString() ??
|
||||||
|
t("home.settings.plugins.jellyseerr.unlimited")
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.plugins.jellyseerr.movie_quota_days")}
|
title={t("home.settings.plugins.jellyseerr.movie_quota_days")}
|
||||||
value={
|
value={
|
||||||
jellyseerrUser?.movieQuotaDays?.toString() ?? t("home.settings.plugins.jellyseerr.unlimited")
|
jellyseerrUser?.movieQuotaDays?.toString() ??
|
||||||
|
t("home.settings.plugins.jellyseerr.unlimited")
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.plugins.jellyseerr.tv_quota_limit")}
|
title={t("home.settings.plugins.jellyseerr.tv_quota_limit")}
|
||||||
value={jellyseerrUser?.tvQuotaLimit?.toString() ?? t("home.settings.plugins.jellyseerr.unlimited")}
|
value={
|
||||||
|
jellyseerrUser?.tvQuotaLimit?.toString() ??
|
||||||
|
t("home.settings.plugins.jellyseerr.unlimited")
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.plugins.jellyseerr.tv_quota_days")}
|
title={t("home.settings.plugins.jellyseerr.tv_quota_days")}
|
||||||
value={jellyseerrUser?.tvQuotaDays?.toString() ?? t("home.settings.plugins.jellyseerr.unlimited")}
|
value={
|
||||||
|
jellyseerrUser?.tvQuotaDays?.toString() ??
|
||||||
|
t("home.settings.plugins.jellyseerr.unlimited")
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
|
|
||||||
<View className="p-4">
|
<View className="p-4">
|
||||||
<Button color="red" onPress={clearData}>
|
<Button color="red" onPress={clearData}>
|
||||||
{t("home.settings.plugins.jellyseerr.reset_jellyseerr_config_button")}
|
{t(
|
||||||
|
"home.settings.plugins.jellyseerr.reset_jellyseerr_config_button"
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</>
|
</>
|
||||||
@@ -128,15 +122,20 @@ export const JellyseerrSettings = () => {
|
|||||||
<Text className="text-xs text-red-600 mb-2">
|
<Text className="text-xs text-red-600 mb-2">
|
||||||
{t("home.settings.plugins.jellyseerr.jellyseerr_warning")}
|
{t("home.settings.plugins.jellyseerr.jellyseerr_warning")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="font-bold mb-1">{t("home.settings.plugins.jellyseerr.server_url")}</Text>
|
<Text className="font-bold mb-1">
|
||||||
|
{t("home.settings.plugins.jellyseerr.server_url")}
|
||||||
|
</Text>
|
||||||
<View className="flex flex-col shrink mb-2">
|
<View className="flex flex-col shrink mb-2">
|
||||||
<Text className="text-xs text-gray-600">
|
<Text className="text-xs text-gray-600">
|
||||||
{t("home.settings.plugins.jellyseerr.server_url_hint")}
|
{t("home.settings.plugins.jellyseerr.server_url_hint")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Input
|
<Input
|
||||||
placeholder={t("home.settings.plugins.jellyseerr.server_url_placeholder")}
|
className="border border-neutral-800 mb-2"
|
||||||
value={settings?.jellyseerrServerUrl ?? jellyseerrServerUrl}
|
placeholder={t(
|
||||||
|
"home.settings.plugins.jellyseerr.server_url_placeholder"
|
||||||
|
)}
|
||||||
|
value={jellyseerrServerUrl ?? settings?.jellyseerrServerUrl}
|
||||||
defaultValue={
|
defaultValue={
|
||||||
settings?.jellyseerrServerUrl ?? jellyseerrServerUrl
|
settings?.jellyseerrServerUrl ?? jellyseerrServerUrl
|
||||||
}
|
}
|
||||||
@@ -145,40 +144,20 @@ export const JellyseerrSettings = () => {
|
|||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
textContentType="URL"
|
textContentType="URL"
|
||||||
onChangeText={setjellyseerrServerUrl}
|
onChangeText={setjellyseerrServerUrl}
|
||||||
editable={!testJellyseerrServerUrlMutation.isPending}
|
editable={!loginToJellyseerrMutation.isPending}
|
||||||
/>
|
/>
|
||||||
|
<View>
|
||||||
<Button
|
<Text className="font-bold mb-2">
|
||||||
loading={testJellyseerrServerUrlMutation.isPending}
|
{t("home.settings.plugins.jellyseerr.password")}
|
||||||
disabled={testJellyseerrServerUrlMutation.isPending}
|
</Text>
|
||||||
color={promptForJellyseerrPass ? "red" : "purple"}
|
|
||||||
className="h-12 mt-2"
|
|
||||||
onPress={() => {
|
|
||||||
if (promptForJellyseerrPass) {
|
|
||||||
clearData();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
testJellyseerrServerUrlMutation.mutate();
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
marginBottom: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{promptForJellyseerrPass ? t("home.settings.plugins.jellyseerr.clear_button") : t("home.settings.plugins.jellyseerr.save_button")}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<View
|
|
||||||
pointerEvents={promptForJellyseerrPass ? "auto" : "none"}
|
|
||||||
style={{
|
|
||||||
opacity: promptForJellyseerrPass ? 1 : 0.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text className="font-bold mb-2">{t("home.settings.plugins.jellyseerr.password")}</Text>
|
|
||||||
<Input
|
<Input
|
||||||
|
className="border border-neutral-800"
|
||||||
autoFocus={true}
|
autoFocus={true}
|
||||||
focusable={true}
|
focusable={true}
|
||||||
placeholder={t("home.settings.plugins.jellyseerr.password_placeholder", {username: user?.Name})}
|
placeholder={t(
|
||||||
|
"home.settings.plugins.jellyseerr.password_placeholder",
|
||||||
|
{ username: user?.Name }
|
||||||
|
)}
|
||||||
value={jellyseerrPassword}
|
value={jellyseerrPassword}
|
||||||
keyboardType="default"
|
keyboardType="default"
|
||||||
secureTextEntry={true}
|
secureTextEntry={true}
|
||||||
@@ -186,10 +165,7 @@ export const JellyseerrSettings = () => {
|
|||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
textContentType="password"
|
textContentType="password"
|
||||||
onChangeText={setJellyseerrPassword}
|
onChangeText={setJellyseerrPassword}
|
||||||
editable={
|
editable={!loginToJellyseerrMutation.isPending}
|
||||||
!loginToJellyseerrMutation.isPending &&
|
|
||||||
promptForJellyseerrPass
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
loading={loginToJellyseerrMutation.isPending}
|
loading={loginToJellyseerrMutation.isPending}
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
|
import { Platform } from "react-native";
|
||||||
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
|
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { BitrateSelector, BITRATES } from "@/components/BitrateSelector";
|
||||||
import {
|
import {
|
||||||
BACKGROUND_FETCH_TASK,
|
BACKGROUND_FETCH_TASK,
|
||||||
registerBackgroundFetchAsync,
|
registerBackgroundFetchAsync,
|
||||||
unregisterBackgroundFetchAsync,
|
unregisterBackgroundFetchAsync,
|
||||||
} from "@/utils/background-tasks";
|
} from "@/utils/background-tasks";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import * as BackgroundFetch from "expo-background-fetch";
|
const BackgroundFetch = !Platform.isTV
|
||||||
|
? require("expo-background-fetch")
|
||||||
|
: null;
|
||||||
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
|
const TaskManager = !Platform.isTV ? require("expo-task-manager") : null;
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
|
||||||
import * as TaskManager from "expo-task-manager";
|
|
||||||
import React, { useEffect, useMemo } from "react";
|
import React, { useEffect, useMemo } from "react";
|
||||||
import { Linking, Switch, TouchableOpacity } from "react-native";
|
import { Linking, Switch, TouchableOpacity } from "react-native";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
@@ -29,6 +33,8 @@ export const OtherSettings: React.FC = () => {
|
|||||||
* Background task
|
* Background task
|
||||||
*******************/
|
*******************/
|
||||||
const checkStatusAsync = async () => {
|
const checkStatusAsync = async () => {
|
||||||
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
await BackgroundFetch.getStatusAsync();
|
await BackgroundFetch.getStatusAsync();
|
||||||
return await TaskManager.isTaskRegisteredAsync(BACKGROUND_FETCH_TASK);
|
return await TaskManager.isTaskRegisteredAsync(BACKGROUND_FETCH_TASK);
|
||||||
};
|
};
|
||||||
@@ -158,6 +164,32 @@ export const OtherSettings: React.FC = () => {
|
|||||||
title={t("home.settings.other.hide_libraries")}
|
title={t("home.settings.other.hide_libraries")}
|
||||||
showArrow
|
showArrow
|
||||||
/>
|
/>
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.other.default_quality")}
|
||||||
|
disabled={pluginSettings?.defaultBitrate?.locked}
|
||||||
|
>
|
||||||
|
<Dropdown
|
||||||
|
data={BITRATES}
|
||||||
|
disabled={pluginSettings?.defaultBitrate?.locked}
|
||||||
|
keyExtractor={(item) => item.key}
|
||||||
|
titleExtractor={(item) => item.key}
|
||||||
|
selected={settings.defaultBitrate}
|
||||||
|
title={
|
||||||
|
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
|
||||||
|
<Text className="mr-1 text-[#8E8D91]">
|
||||||
|
{settings.defaultBitrate?.key}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name="chevron-expand-sharp"
|
||||||
|
size={18}
|
||||||
|
color="#5A5960"
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
}
|
||||||
|
label={t("home.settings.other.default_quality")}
|
||||||
|
onSelected={(defaultBitrate) => updateSettings({ defaultBitrate })}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.other.disable_haptic_feedback")}
|
title={t("home.settings.other.disable_haptic_feedback")}
|
||||||
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
||||||
|
|||||||
@@ -49,16 +49,25 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
|||||||
});
|
});
|
||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
successHapticFeedback();
|
successHapticFeedback();
|
||||||
Alert.alert(t("home.settings.quick_connect.success"), t("home.settings.quick_connect.quick_connect_autorized"));
|
Alert.alert(
|
||||||
|
t("home.settings.quick_connect.success"),
|
||||||
|
t("home.settings.quick_connect.quick_connect_autorized")
|
||||||
|
);
|
||||||
setQuickConnectCode(undefined);
|
setQuickConnectCode(undefined);
|
||||||
bottomSheetModalRef?.current?.close();
|
bottomSheetModalRef?.current?.close();
|
||||||
} else {
|
} else {
|
||||||
errorHapticFeedback();
|
errorHapticFeedback();
|
||||||
Alert.alert(t("home.settings.quick_connect.error"), t("home.settings.quick_connect.invalid_code"));
|
Alert.alert(
|
||||||
|
t("home.settings.quick_connect.error"),
|
||||||
|
t("home.settings.quick_connect.invalid_code")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorHapticFeedback();
|
errorHapticFeedback();
|
||||||
Alert.alert(t("home.settings.quick_connect.error"), t("home.settings.quick_connect.invalid_code"));
|
Alert.alert(
|
||||||
|
t("home.settings.quick_connect.error"),
|
||||||
|
t("home.settings.quick_connect.invalid_code")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [api, user, quickConnectCode]);
|
}, [api, user, quickConnectCode]);
|
||||||
@@ -96,7 +105,9 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
|||||||
<BottomSheetTextInput
|
<BottomSheetTextInput
|
||||||
style={{ color: "white" }}
|
style={{ color: "white" }}
|
||||||
clearButtonMode="always"
|
clearButtonMode="always"
|
||||||
placeholder={t("home.settings.quick_connect.enter_the_quick_connect_code")}
|
placeholder={t(
|
||||||
|
"home.settings.quick_connect.enter_the_quick_connect_code"
|
||||||
|
)}
|
||||||
placeholderTextColor="#9CA3AF"
|
placeholderTextColor="#9CA3AF"
|
||||||
value={quickConnectCode}
|
value={quickConnectCode}
|
||||||
onChangeText={setQuickConnectCode}
|
onChangeText={setQuickConnectCode}
|
||||||
|
|||||||
@@ -48,7 +48,10 @@ export const StorageSettings = () => {
|
|||||||
<Text className="">{t("home.settings.storage.storage_title")}</Text>
|
<Text className="">{t("home.settings.storage.storage_title")}</Text>
|
||||||
{size && (
|
{size && (
|
||||||
<Text className="text-neutral-500">
|
<Text className="text-neutral-500">
|
||||||
{t("home.settings.storage.size_used", {used: Number(size.total - size.remaining).bytesToReadable(), total: size.total?.bytesToReadable()})}
|
{t("home.settings.storage.size_used", {
|
||||||
|
used: Number(size.total - size.remaining).bytesToReadable(),
|
||||||
|
total: size.total?.bytesToReadable(),
|
||||||
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -79,13 +82,20 @@ export const StorageSettings = () => {
|
|||||||
<View className="flex flex-row items-center">
|
<View className="flex flex-row items-center">
|
||||||
<View className="w-3 h-3 rounded-full bg-purple-600 mr-1"></View>
|
<View className="w-3 h-3 rounded-full bg-purple-600 mr-1"></View>
|
||||||
<Text className="text-white text-xs">
|
<Text className="text-white text-xs">
|
||||||
{t("home.settings.storage.app_usage", {usedSpace: calculatePercentage(size.app, size.total)})}
|
{t("home.settings.storage.app_usage", {
|
||||||
|
usedSpace: calculatePercentage(size.app, size.total),
|
||||||
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className="flex flex-row items-center">
|
<View className="flex flex-row items-center">
|
||||||
<View className="w-3 h-3 rounded-full bg-purple-400 mr-1"></View>
|
<View className="w-3 h-3 rounded-full bg-purple-400 mr-1"></View>
|
||||||
<Text className="text-white text-xs">
|
<Text className="text-white text-xs">
|
||||||
{t("home.settings.storage.phone_usage", {availableSpace: calculatePercentage(size.total - size.remaining - size.app, size.total)})}
|
{t("home.settings.storage.device_usage", {
|
||||||
|
availableSpace: calculatePercentage(
|
||||||
|
size.total - size.remaining - size.app,
|
||||||
|
size.total
|
||||||
|
),
|
||||||
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
import { Platform, TouchableOpacity, View, ViewProps } from "react-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { useMedia } from "./MediaContext";
|
import { useMedia } from "./MediaContext";
|
||||||
import { Switch } from "react-native-gesture-handler";
|
import { Switch } from "react-native-gesture-handler";
|
||||||
@@ -8,13 +8,14 @@ import { ListItem } from "../list/ListItem";
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
|
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {useSettings} from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import {Stepper} from "@/components/inputs/Stepper";
|
import { Stepper } from "@/components/inputs/Stepper";
|
||||||
import Dropdown from "@/components/common/Dropdown";
|
import Dropdown from "@/components/common/Dropdown";
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
||||||
|
if (Platform.isTV) return null;
|
||||||
const media = useMedia();
|
const media = useMedia();
|
||||||
const [_, __, pluginSettings] = useSettings();
|
const [_, __, pluginSettings] = useSettings();
|
||||||
const { settings, updateSettings } = media;
|
const { settings, updateSettings } = media;
|
||||||
@@ -34,7 +35,8 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
const subtitleModeKeys = {
|
const subtitleModeKeys = {
|
||||||
[SubtitlePlaybackMode.Default]: "home.settings.subtitles.modes.Default",
|
[SubtitlePlaybackMode.Default]: "home.settings.subtitles.modes.Default",
|
||||||
[SubtitlePlaybackMode.Smart]: "home.settings.subtitles.modes.Smart",
|
[SubtitlePlaybackMode.Smart]: "home.settings.subtitles.modes.Smart",
|
||||||
[SubtitlePlaybackMode.OnlyForced]: "home.settings.subtitles.modes.OnlyForced",
|
[SubtitlePlaybackMode.OnlyForced]:
|
||||||
|
"home.settings.subtitles.modes.OnlyForced",
|
||||||
[SubtitlePlaybackMode.Always]: "home.settings.subtitles.modes.Always",
|
[SubtitlePlaybackMode.Always]: "home.settings.subtitles.modes.Always",
|
||||||
[SubtitlePlaybackMode.None]: "home.settings.subtitles.modes.None",
|
[SubtitlePlaybackMode.None]: "home.settings.subtitles.modes.None",
|
||||||
};
|
};
|
||||||
@@ -51,13 +53,22 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
>
|
>
|
||||||
<ListItem title={t("home.settings.subtitles.subtitle_language")}>
|
<ListItem title={t("home.settings.subtitles.subtitle_language")}>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
data={[{DisplayName: t("home.settings.subtitles.none"), ThreeLetterISOLanguageName: "none-subs" },...(cultures ?? [])]}
|
data={[
|
||||||
keyExtractor={(item) => item?.ThreeLetterISOLanguageName ?? "unknown"}
|
{
|
||||||
|
DisplayName: t("home.settings.subtitles.none"),
|
||||||
|
ThreeLetterISOLanguageName: "none-subs",
|
||||||
|
},
|
||||||
|
...(cultures ?? []),
|
||||||
|
]}
|
||||||
|
keyExtractor={(item) =>
|
||||||
|
item?.ThreeLetterISOLanguageName ?? "unknown"
|
||||||
|
}
|
||||||
titleExtractor={(item) => item?.DisplayName}
|
titleExtractor={(item) => item?.DisplayName}
|
||||||
title={
|
title={
|
||||||
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
|
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
|
||||||
<Text className="mr-1 text-[#8E8D91]">
|
<Text className="mr-1 text-[#8E8D91]">
|
||||||
{settings?.defaultSubtitleLanguage?.DisplayName || t("home.settings.subtitles.none")}
|
{settings?.defaultSubtitleLanguage?.DisplayName ||
|
||||||
|
t("home.settings.subtitles.none")}
|
||||||
</Text>
|
</Text>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name="chevron-expand-sharp"
|
name="chevron-expand-sharp"
|
||||||
@@ -69,11 +80,13 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
label={t("home.settings.subtitles.language")}
|
label={t("home.settings.subtitles.language")}
|
||||||
onSelected={(defaultSubtitleLanguage) =>
|
onSelected={(defaultSubtitleLanguage) =>
|
||||||
updateSettings({
|
updateSettings({
|
||||||
defaultSubtitleLanguage: defaultSubtitleLanguage.DisplayName === t("home.settings.subtitles.none")
|
defaultSubtitleLanguage:
|
||||||
? null
|
defaultSubtitleLanguage.DisplayName ===
|
||||||
: defaultSubtitleLanguage
|
t("home.settings.subtitles.none")
|
||||||
|
? null
|
||||||
|
: defaultSubtitleLanguage,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
@@ -89,7 +102,8 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
title={
|
title={
|
||||||
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
|
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
|
||||||
<Text className="mr-1 text-[#8E8D91]">
|
<Text className="mr-1 text-[#8E8D91]">
|
||||||
{t(subtitleModeKeys[settings?.subtitleMode]) || t("home.settings.subtitles.loading")}
|
{t(subtitleModeKeys[settings?.subtitleMode]) ||
|
||||||
|
t("home.settings.subtitles.loading")}
|
||||||
</Text>
|
</Text>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name="chevron-expand-sharp"
|
name="chevron-expand-sharp"
|
||||||
@@ -99,9 +113,7 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
}
|
}
|
||||||
label={t("home.settings.subtitles.subtitle_mode")}
|
label={t("home.settings.subtitles.subtitle_mode")}
|
||||||
onSelected={(subtitleMode) =>
|
onSelected={(subtitleMode) => updateSettings({ subtitleMode })}
|
||||||
updateSettings({subtitleMode})
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
@@ -128,7 +140,7 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
step={5}
|
step={5}
|
||||||
min={0}
|
min={0}
|
||||||
max={120}
|
max={120}
|
||||||
onUpdate={(subtitleSize) => updateSettings({subtitleSize})}
|
onUpdate={(subtitleSize) => updateSettings({ subtitleSize })}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import React, { useEffect, useRef } from "react";
|
import React, { useEffect, useRef } from "react";
|
||||||
import { View, StyleSheet } from "react-native";
|
import { View, StyleSheet, Platform } from "react-native";
|
||||||
import { useSharedValue } from "react-native-reanimated";
|
import { useSharedValue } from "react-native-reanimated";
|
||||||
import { Slider } from "react-native-awesome-slider";
|
import { Slider } from "react-native-awesome-slider";
|
||||||
import { VolumeManager } from "react-native-volume-manager";
|
// import { VolumeManager } from "react-native-volume-manager";
|
||||||
|
const VolumeManager = !Platform.isTV
|
||||||
|
? require("react-native-volume-manager")
|
||||||
|
: null;
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
|
||||||
interface AudioSliderProps {
|
interface AudioSliderProps {
|
||||||
@@ -10,6 +13,8 @@ interface AudioSliderProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const AudioSlider: React.FC<AudioSliderProps> = ({ setVisibility }) => {
|
const AudioSlider: React.FC<AudioSliderProps> = ({ setVisibility }) => {
|
||||||
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
const volume = useSharedValue<number>(50); // Explicitly type as number
|
const volume = useSharedValue<number>(50); // Explicitly type as number
|
||||||
const min = useSharedValue<number>(0); // Explicitly type as number
|
const min = useSharedValue<number>(0); // Explicitly type as number
|
||||||
const max = useSharedValue<number>(100); // Explicitly type as number
|
const max = useSharedValue<number>(100); // Explicitly type as number
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import React, { useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
import { View, StyleSheet } from "react-native";
|
import { View, StyleSheet, Platform } from "react-native";
|
||||||
import { useSharedValue } from "react-native-reanimated";
|
import { useSharedValue } from "react-native-reanimated";
|
||||||
import { Slider } from "react-native-awesome-slider";
|
import { Slider } from "react-native-awesome-slider";
|
||||||
import * as Brightness from "expo-brightness";
|
// import * as Brightness from "expo-brightness";
|
||||||
|
const Brightness = !Platform.isTV ? require("expo-brightness") : null;
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import MaterialCommunityIcons from "@expo/vector-icons/MaterialCommunityIcons";
|
import MaterialCommunityIcons from "@expo/vector-icons/MaterialCommunityIcons";
|
||||||
|
|
||||||
const BrightnessSlider = () => {
|
const BrightnessSlider = () => {
|
||||||
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
const brightness = useSharedValue(50);
|
const brightness = useSharedValue(50);
|
||||||
const min = useSharedValue(0);
|
const min = useSharedValue(0);
|
||||||
const max = useSharedValue(100);
|
const max = useSharedValue(100);
|
||||||
|
|||||||
@@ -24,18 +24,23 @@ import {
|
|||||||
ticksToMs,
|
ticksToMs,
|
||||||
ticksToSeconds,
|
ticksToSeconds,
|
||||||
} from "@/utils/time";
|
} from "@/utils/time";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons, MaterialIcons } from "@expo/vector-icons";
|
||||||
import {
|
import {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { debounce } from "lodash";
|
import { debounce } from "lodash";
|
||||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { TouchableOpacity, useWindowDimensions, View } from "react-native";
|
import {
|
||||||
|
Platform,
|
||||||
|
TouchableOpacity,
|
||||||
|
useWindowDimensions,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
import { Slider } from "react-native-awesome-slider";
|
import { Slider } from "react-native-awesome-slider";
|
||||||
import {
|
import {
|
||||||
runOnJS,
|
runOnJS,
|
||||||
@@ -49,8 +54,7 @@ import AudioSlider from "./AudioSlider";
|
|||||||
import BrightnessSlider from "./BrightnessSlider";
|
import BrightnessSlider from "./BrightnessSlider";
|
||||||
import { ControlProvider } from "./contexts/ControlContext";
|
import { ControlProvider } from "./contexts/ControlContext";
|
||||||
import { VideoProvider } from "./contexts/VideoContext";
|
import { VideoProvider } from "./contexts/VideoContext";
|
||||||
import DropdownViewDirect from "./dropdown/DropdownViewDirect";
|
import DropdownView from "./dropdown/DropdownView";
|
||||||
import DropdownViewTranscoding from "./dropdown/DropdownViewTranscoding";
|
|
||||||
import { EpisodeList } from "./EpisodeList";
|
import { EpisodeList } from "./EpisodeList";
|
||||||
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
|
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
|
||||||
import SkipButton from "./SkipButton";
|
import SkipButton from "./SkipButton";
|
||||||
@@ -75,6 +79,7 @@ interface Props {
|
|||||||
isVideoLoaded?: boolean;
|
isVideoLoaded?: boolean;
|
||||||
mediaSource?: MediaSourceInfo | null;
|
mediaSource?: MediaSourceInfo | null;
|
||||||
seek: (ticks: number) => void;
|
seek: (ticks: number) => void;
|
||||||
|
startPictureInPicture: () => Promise<void>;
|
||||||
play: (() => Promise<void>) | (() => void);
|
play: (() => Promise<void>) | (() => void);
|
||||||
pause: () => void;
|
pause: () => void;
|
||||||
getAudioTracks?: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]);
|
getAudioTracks?: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]);
|
||||||
@@ -82,39 +87,38 @@ interface Props {
|
|||||||
setSubtitleURL?: (url: string, customName: string) => void;
|
setSubtitleURL?: (url: string, customName: string) => void;
|
||||||
setSubtitleTrack?: (index: number) => void;
|
setSubtitleTrack?: (index: number) => void;
|
||||||
setAudioTrack?: (index: number) => void;
|
setAudioTrack?: (index: number) => void;
|
||||||
stop: (() => Promise<void>) | (() => void);
|
|
||||||
isVlc?: boolean;
|
isVlc?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CONTROLS_TIMEOUT = 4000;
|
const CONTROLS_TIMEOUT = 4000;
|
||||||
|
|
||||||
export const Controls: React.FC<Props> = ({
|
export const Controls: React.FC<Props> = ({
|
||||||
item,
|
item,
|
||||||
seek,
|
seek,
|
||||||
play,
|
startPictureInPicture,
|
||||||
pause,
|
play,
|
||||||
togglePlay,
|
pause,
|
||||||
isPlaying,
|
togglePlay,
|
||||||
isSeeking,
|
isPlaying,
|
||||||
progress,
|
isSeeking,
|
||||||
isBuffering,
|
progress,
|
||||||
cacheProgress,
|
isBuffering,
|
||||||
showControls,
|
cacheProgress,
|
||||||
setShowControls,
|
showControls,
|
||||||
ignoreSafeAreas,
|
setShowControls,
|
||||||
setIgnoreSafeAreas,
|
ignoreSafeAreas,
|
||||||
mediaSource,
|
setIgnoreSafeAreas,
|
||||||
isVideoLoaded,
|
mediaSource,
|
||||||
getAudioTracks,
|
isVideoLoaded,
|
||||||
getSubtitleTracks,
|
getAudioTracks,
|
||||||
setSubtitleURL,
|
getSubtitleTracks,
|
||||||
setSubtitleTrack,
|
setSubtitleURL,
|
||||||
setAudioTrack,
|
setSubtitleTrack,
|
||||||
stop,
|
setAudioTrack,
|
||||||
offline = false,
|
offline = false,
|
||||||
enableTrickplay = true,
|
enableTrickplay = true,
|
||||||
isVlc = false,
|
isVlc = false,
|
||||||
}) => {
|
}) => {
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
@@ -183,81 +187,60 @@ export const Controls: React.FC<Props> = ({
|
|||||||
isVlc
|
isVlc
|
||||||
);
|
);
|
||||||
|
|
||||||
const goToPreviousItem = useCallback(() => {
|
const goToItemCommon = useCallback(
|
||||||
if (!previousItem || !settings) return;
|
(item: BaseItemDto) => {
|
||||||
|
if (!item || !settings) return;
|
||||||
|
|
||||||
lightHapticFeedback();
|
lightHapticFeedback();
|
||||||
|
|
||||||
const previousIndexes: previousIndexes = {
|
const previousIndexes = {
|
||||||
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
|
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
|
||||||
audioIndex: audioIndex ? parseInt(audioIndex) : undefined,
|
audioIndex: audioIndex ? parseInt(audioIndex) : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
const {
|
const {
|
||||||
mediaSource: newMediaSource,
|
mediaSource: newMediaSource,
|
||||||
audioIndex: defaultAudioIndex,
|
audioIndex: defaultAudioIndex,
|
||||||
subtitleIndex: defaultSubtitleIndex,
|
subtitleIndex: defaultSubtitleIndex,
|
||||||
} = getDefaultPlaySettings(
|
} = getDefaultPlaySettings(
|
||||||
previousItem,
|
item,
|
||||||
settings,
|
settings,
|
||||||
previousIndexes,
|
previousIndexes,
|
||||||
mediaSource ?? undefined
|
mediaSource ?? undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
const queryParams = new URLSearchParams({
|
const queryParams = new URLSearchParams({
|
||||||
itemId: previousItem.Id ?? "", // Ensure itemId is a string
|
itemId: item.Id ?? "",
|
||||||
audioIndex: defaultAudioIndex?.toString() ?? "",
|
audioIndex: defaultAudioIndex?.toString() ?? "",
|
||||||
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
|
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
|
||||||
mediaSourceId: newMediaSource?.Id ?? "", // Ensure mediaSourceId is a string
|
mediaSourceId: newMediaSource?.Id ?? "",
|
||||||
bitrateValue: bitrateValue.toString(),
|
bitrateValue: bitrateValue.toString(),
|
||||||
}).toString();
|
}).toString();
|
||||||
|
|
||||||
if (!bitrateValue) {
|
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
router.replace(`player/direct-player?${queryParams}`);
|
router.replace(`player/direct-player?${queryParams}`);
|
||||||
return;
|
},
|
||||||
}
|
[settings, subtitleIndex, audioIndex, mediaSource, bitrateValue, router]
|
||||||
// @ts-expect-error
|
);
|
||||||
router.replace(`player/transcoding-player?${queryParams}`);
|
|
||||||
}, [previousItem, settings, subtitleIndex, audioIndex]);
|
const goToPreviousItem = useCallback(() => {
|
||||||
|
if (!previousItem) return;
|
||||||
|
goToItemCommon(previousItem);
|
||||||
|
}, [previousItem, goToItemCommon]);
|
||||||
|
|
||||||
const goToNextItem = useCallback(() => {
|
const goToNextItem = useCallback(() => {
|
||||||
if (!nextItem || !settings) return;
|
if (!nextItem) return;
|
||||||
|
goToItemCommon(nextItem);
|
||||||
|
}, [nextItem, goToItemCommon]);
|
||||||
|
|
||||||
lightHapticFeedback();
|
const goToItem = useCallback(
|
||||||
|
async (itemId: string) => {
|
||||||
const previousIndexes: previousIndexes = {
|
const gotoItem = await getItemById(api, itemId);
|
||||||
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
|
if (!gotoItem) return;
|
||||||
audioIndex: audioIndex ? parseInt(audioIndex) : undefined,
|
goToItemCommon(gotoItem);
|
||||||
};
|
},
|
||||||
|
[goToItemCommon, api]
|
||||||
const {
|
);
|
||||||
mediaSource: newMediaSource,
|
|
||||||
audioIndex: defaultAudioIndex,
|
|
||||||
subtitleIndex: defaultSubtitleIndex,
|
|
||||||
} = getDefaultPlaySettings(
|
|
||||||
nextItem,
|
|
||||||
settings,
|
|
||||||
previousIndexes,
|
|
||||||
mediaSource ?? undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
const queryParams = new URLSearchParams({
|
|
||||||
itemId: nextItem.Id ?? "", // Ensure itemId is a string
|
|
||||||
audioIndex: defaultAudioIndex?.toString() ?? "",
|
|
||||||
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
|
|
||||||
mediaSourceId: newMediaSource?.Id ?? "", // Ensure mediaSourceId is a string
|
|
||||||
bitrateValue: bitrateValue.toString(),
|
|
||||||
}).toString();
|
|
||||||
|
|
||||||
if (!bitrateValue) {
|
|
||||||
// @ts-expect-error
|
|
||||||
router.replace(`player/direct-player?${queryParams}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// @ts-expect-error
|
|
||||||
router.replace(`player/transcoding-player?${queryParams}`);
|
|
||||||
}, [nextItem, settings, subtitleIndex, audioIndex]);
|
|
||||||
|
|
||||||
const updateTimes = useCallback(
|
const updateTimes = useCallback(
|
||||||
(currentProgress: number, maxValue: number) => {
|
(currentProgress: number, maxValue: number) => {
|
||||||
@@ -381,52 +364,6 @@ export const Controls: React.FC<Props> = ({
|
|||||||
}
|
}
|
||||||
}, [settings, isPlaying, isVlc]);
|
}, [settings, isPlaying, isVlc]);
|
||||||
|
|
||||||
const goToItem = useCallback(
|
|
||||||
async (itemId: string) => {
|
|
||||||
try {
|
|
||||||
const gotoItem = await getItemById(api, itemId);
|
|
||||||
if (!settings || !gotoItem) return;
|
|
||||||
|
|
||||||
lightHapticFeedback();
|
|
||||||
|
|
||||||
const previousIndexes: previousIndexes = {
|
|
||||||
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
|
|
||||||
audioIndex: audioIndex ? parseInt(audioIndex) : undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
const {
|
|
||||||
mediaSource: newMediaSource,
|
|
||||||
audioIndex: defaultAudioIndex,
|
|
||||||
subtitleIndex: defaultSubtitleIndex,
|
|
||||||
} = getDefaultPlaySettings(
|
|
||||||
gotoItem,
|
|
||||||
settings,
|
|
||||||
previousIndexes,
|
|
||||||
mediaSource ?? undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
const queryParams = new URLSearchParams({
|
|
||||||
itemId: gotoItem.Id ?? "", // Ensure itemId is a string
|
|
||||||
audioIndex: defaultAudioIndex?.toString() ?? "",
|
|
||||||
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
|
|
||||||
mediaSourceId: newMediaSource?.Id ?? "", // Ensure mediaSourceId is a string
|
|
||||||
bitrateValue: bitrateValue.toString(),
|
|
||||||
}).toString();
|
|
||||||
|
|
||||||
if (!bitrateValue) {
|
|
||||||
// @ts-expect-error
|
|
||||||
router.replace(`player/direct-player?${queryParams}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// @ts-expect-error
|
|
||||||
router.replace(`player/transcoding-player?${queryParams}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error in gotoEpisode:", error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[settings, subtitleIndex, audioIndex]
|
|
||||||
);
|
|
||||||
|
|
||||||
const toggleIgnoreSafeAreas = useCallback(() => {
|
const toggleIgnoreSafeAreas = useCallback(() => {
|
||||||
setIgnoreSafeAreas((prev) => !prev);
|
setIgnoreSafeAreas((prev) => !prev);
|
||||||
lightHapticFeedback();
|
lightHapticFeedback();
|
||||||
@@ -499,6 +436,14 @@ export const Controls: React.FC<Props> = ({
|
|||||||
);
|
);
|
||||||
}, [trickPlayUrl, trickplayInfo, time]);
|
}, [trickPlayUrl, trickplayInfo, time]);
|
||||||
|
|
||||||
|
const onClose = async () => {
|
||||||
|
lightHapticFeedback();
|
||||||
|
await ScreenOrientation.lockAsync(
|
||||||
|
ScreenOrientation.OrientationLock.PORTRAIT_UP
|
||||||
|
);
|
||||||
|
router.back();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ControlProvider
|
<ControlProvider
|
||||||
item={item}
|
item={item}
|
||||||
@@ -534,23 +479,35 @@ export const Controls: React.FC<Props> = ({
|
|||||||
pointerEvents={showControls ? "auto" : "none"}
|
pointerEvents={showControls ? "auto" : "none"}
|
||||||
className={`flex flex-row w-full pt-2`}
|
className={`flex flex-row w-full pt-2`}
|
||||||
>
|
>
|
||||||
<View className="mr-auto">
|
{!Platform.isTV && (
|
||||||
<VideoProvider
|
<View className="mr-auto">
|
||||||
getAudioTracks={getAudioTracks}
|
<VideoProvider
|
||||||
getSubtitleTracks={getSubtitleTracks}
|
getAudioTracks={getAudioTracks}
|
||||||
setAudioTrack={setAudioTrack}
|
getSubtitleTracks={getSubtitleTracks}
|
||||||
setSubtitleTrack={setSubtitleTrack}
|
setAudioTrack={setAudioTrack}
|
||||||
setSubtitleURL={setSubtitleURL}
|
setSubtitleTrack={setSubtitleTrack}
|
||||||
>
|
setSubtitleURL={setSubtitleURL}
|
||||||
{!mediaSource?.TranscodingUrl ? (
|
>
|
||||||
<DropdownViewDirect showControls={showControls} />
|
<DropdownView />
|
||||||
) : (
|
</VideoProvider>
|
||||||
<DropdownViewTranscoding showControls={showControls} />
|
</View>
|
||||||
)}
|
)}
|
||||||
</VideoProvider>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className="flex flex-row items-center space-x-2 ">
|
<View className="flex flex-row items-center space-x-2 ">
|
||||||
|
{!Platform.isTV && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={startPictureInPicture}
|
||||||
|
className="aspect-square flex flex-col rounded-xl items-center justify-center p-2"
|
||||||
|
>
|
||||||
|
<MaterialIcons
|
||||||
|
name="picture-in-picture"
|
||||||
|
size={24}
|
||||||
|
color="white"
|
||||||
|
style={{ opacity: showControls ? 1 : 0 }}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
|
||||||
{item?.Type === "Episode" && !offline && (
|
{item?.Type === "Episode" && !offline && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
@@ -592,13 +549,7 @@ export const Controls: React.FC<Props> = ({
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
{/* )} */}
|
{/* )} */}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={async () => {
|
onPress={onClose}
|
||||||
lightHapticFeedback();
|
|
||||||
await ScreenOrientation.lockAsync(
|
|
||||||
ScreenOrientation.OrientationLock.PORTRAIT_UP
|
|
||||||
);
|
|
||||||
router.back();
|
|
||||||
}}
|
|
||||||
className="aspect-square flex flex-col rounded-xl items-center justify-center p-2"
|
className="aspect-square flex flex-col rounded-xl items-center justify-center p-2"
|
||||||
>
|
>
|
||||||
<Ionicons name="close" size={24} color="white" />
|
<Ionicons name="close" size={24} color="white" />
|
||||||
@@ -778,8 +729,8 @@ export const Controls: React.FC<Props> = ({
|
|||||||
!nextItem
|
!nextItem
|
||||||
? false
|
? false
|
||||||
: isVlc
|
: isVlc
|
||||||
? remainingTime < 10000
|
? remainingTime < 10000
|
||||||
: remainingTime < 10
|
: remainingTime < 10
|
||||||
}
|
}
|
||||||
onFinish={goToNextItem}
|
onFinish={goToNextItem}
|
||||||
onPress={goToNextItem}
|
onPress={goToNextItem}
|
||||||
|
|||||||
@@ -60,12 +60,12 @@ const NextEpisodeCountDownButton: React.FC<NextEpisodeCountDownButtonProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (!show) {
|
if (!show) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
className="w-32 overflow-hidden rounded-md bg-black/60 border border-neutral-900"
|
className="w-32 overflow-hidden rounded-md bg-black/60 border border-neutral-900"
|
||||||
|
|||||||
@@ -1,20 +1,12 @@
|
|||||||
import { TrackInfo } from "@/modules/vlc-player";
|
import { TrackInfo } from "@/modules/vlc-player";
|
||||||
import {
|
import React, { createContext, useContext, useState, ReactNode, useEffect, useMemo } from "react";
|
||||||
BaseItemDto,
|
|
||||||
MediaSourceInfo,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import React, {
|
|
||||||
createContext,
|
|
||||||
useContext,
|
|
||||||
useState,
|
|
||||||
ReactNode,
|
|
||||||
useEffect,
|
|
||||||
} from "react";
|
|
||||||
import { useControlContext } from "./ControlContext";
|
import { useControlContext } from "./ControlContext";
|
||||||
|
import { Track } from "../types";
|
||||||
|
import { router, useLocalSearchParams } from "expo-router";
|
||||||
|
|
||||||
interface VideoContextProps {
|
interface VideoContextProps {
|
||||||
audioTracks: TrackInfo[] | null;
|
audioTracks: Track[] | null;
|
||||||
subtitleTracks: TrackInfo[] | null;
|
subtitleTracks: Track[] | null;
|
||||||
setAudioTrack: ((index: number) => void) | undefined;
|
setAudioTrack: ((index: number) => void) | undefined;
|
||||||
setSubtitleTrack: ((index: number) => void) | undefined;
|
setSubtitleTrack: ((index: number) => void) | undefined;
|
||||||
setSubtitleURL: ((url: string, customName: string) => void) | undefined;
|
setSubtitleURL: ((url: string, customName: string) => void) | undefined;
|
||||||
@@ -24,14 +16,8 @@ const VideoContext = createContext<VideoContextProps | undefined>(undefined);
|
|||||||
|
|
||||||
interface VideoProviderProps {
|
interface VideoProviderProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
getAudioTracks:
|
getAudioTracks: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]) | undefined;
|
||||||
| (() => Promise<TrackInfo[] | null>)
|
getSubtitleTracks: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]) | undefined;
|
||||||
| (() => TrackInfo[])
|
|
||||||
| undefined;
|
|
||||||
getSubtitleTracks:
|
|
||||||
| (() => Promise<TrackInfo[] | null>)
|
|
||||||
| (() => TrackInfo[])
|
|
||||||
| undefined;
|
|
||||||
setAudioTrack: ((index: number) => void) | undefined;
|
setAudioTrack: ((index: number) => void) | undefined;
|
||||||
setSubtitleTrack: ((index: number) => void) | undefined;
|
setSubtitleTrack: ((index: number) => void) | undefined;
|
||||||
setSubtitleURL: ((url: string, customName: string) => void) | undefined;
|
setSubtitleURL: ((url: string, customName: string) => void) | undefined;
|
||||||
@@ -45,30 +31,135 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
|
|||||||
setSubtitleURL,
|
setSubtitleURL,
|
||||||
setAudioTrack,
|
setAudioTrack,
|
||||||
}) => {
|
}) => {
|
||||||
const [audioTracks, setAudioTracks] = useState<TrackInfo[] | null>(null);
|
const [audioTracks, setAudioTracks] = useState<Track[] | null>(null);
|
||||||
const [subtitleTracks, setSubtitleTracks] = useState<TrackInfo[] | null>(
|
const [subtitleTracks, setSubtitleTracks] = useState<Track[] | null>(null);
|
||||||
null
|
|
||||||
);
|
|
||||||
|
|
||||||
const ControlContext = useControlContext();
|
const ControlContext = useControlContext();
|
||||||
const isVideoLoaded = ControlContext?.isVideoLoaded;
|
const isVideoLoaded = ControlContext?.isVideoLoaded;
|
||||||
|
const mediaSource = ControlContext?.mediaSource;
|
||||||
|
|
||||||
|
const allSubs = mediaSource?.MediaStreams?.filter((s) => s.Type === "Subtitle") || [];
|
||||||
|
|
||||||
|
const { itemId, audioIndex, bitrateValue, subtitleIndex } = useLocalSearchParams<{
|
||||||
|
itemId: string;
|
||||||
|
audioIndex: string;
|
||||||
|
subtitleIndex: string;
|
||||||
|
mediaSourceId: string;
|
||||||
|
bitrateValue: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const onTextBasedSubtitle = useMemo(
|
||||||
|
() =>
|
||||||
|
allSubs.find((s) => s.Index?.toString() === subtitleIndex && s.IsTextSubtitleStream) || subtitleIndex === "-1",
|
||||||
|
[allSubs, subtitleIndex]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setPlayerParams = ({
|
||||||
|
chosenAudioIndex = audioIndex,
|
||||||
|
chosenSubtitleIndex = subtitleIndex,
|
||||||
|
}: {
|
||||||
|
chosenAudioIndex?: string;
|
||||||
|
chosenSubtitleIndex?: string;
|
||||||
|
}) => {
|
||||||
|
console.log("chosenSubtitleIndex", chosenSubtitleIndex);
|
||||||
|
const queryParams = new URLSearchParams({
|
||||||
|
itemId: itemId ?? "",
|
||||||
|
audioIndex: chosenAudioIndex,
|
||||||
|
subtitleIndex: chosenSubtitleIndex,
|
||||||
|
mediaSourceId: mediaSource?.Id ?? "",
|
||||||
|
bitrateValue: bitrateValue,
|
||||||
|
}).toString();
|
||||||
|
|
||||||
|
//@ts-ignore
|
||||||
|
router.replace(`player/direct-player?${queryParams}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setTrackParams = (type: "audio" | "subtitle", index: number, serverIndex: number) => {
|
||||||
|
const setTrack = type === "audio" ? setAudioTrack : setSubtitleTrack;
|
||||||
|
const paramKey = type === "audio" ? "audioIndex" : "subtitleIndex";
|
||||||
|
|
||||||
|
// If we're transcoding and we're going from a image based subtitle
|
||||||
|
// to a text based subtitle, we need to change the player params.
|
||||||
|
|
||||||
|
const shouldChangePlayerParams = type === "subtitle" && mediaSource?.TranscodingUrl && !onTextBasedSubtitle;
|
||||||
|
|
||||||
|
console.log("Set player params", index, serverIndex);
|
||||||
|
if (shouldChangePlayerParams) {
|
||||||
|
setPlayerParams({
|
||||||
|
chosenSubtitleIndex: serverIndex.toString(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTrack && setTrack(index);
|
||||||
|
router.setParams({
|
||||||
|
[paramKey]: serverIndex.toString(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchTracks = async () => {
|
const fetchTracks = async () => {
|
||||||
if (
|
if (getSubtitleTracks) {
|
||||||
getSubtitleTracks &&
|
const subtitleData = await getSubtitleTracks();
|
||||||
(subtitleTracks === null || subtitleTracks.length === 0)
|
|
||||||
) {
|
// Step 1: Move external subs to the end, because VLC puts external subs at the end
|
||||||
const subtitles = await getSubtitleTracks();
|
const sortedSubs = allSubs.sort((a, b) => Number(a.IsExternal) - Number(b.IsExternal));
|
||||||
console.log("Getting embeded subtitles...", subtitles);
|
|
||||||
|
// Step 2: Apply VLC indexing logic
|
||||||
|
let textSubIndex = 0;
|
||||||
|
const processedSubs: Track[] = sortedSubs?.map((sub) => {
|
||||||
|
// Always increment for non-transcoding subtitles
|
||||||
|
// Only increment for text-based subtitles when transcoding
|
||||||
|
const shouldIncrement = !mediaSource?.TranscodingUrl || sub.IsTextSubtitleStream;
|
||||||
|
const vlcIndex = subtitleData?.at(textSubIndex)?.index ?? -1;
|
||||||
|
const finalIndex = shouldIncrement ? vlcIndex : sub.Index ?? -1;
|
||||||
|
|
||||||
|
if (shouldIncrement) textSubIndex++;
|
||||||
|
return {
|
||||||
|
name: sub.DisplayTitle || "Undefined Subtitle",
|
||||||
|
index: sub.Index ?? -1,
|
||||||
|
setTrack: () =>
|
||||||
|
shouldIncrement
|
||||||
|
? setTrackParams("subtitle", finalIndex, sub.Index ?? -1)
|
||||||
|
: setPlayerParams({
|
||||||
|
chosenSubtitleIndex: sub.Index?.toString(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 3: Restore the original order
|
||||||
|
const subtitles: Track[] = processedSubs.sort((a, b) => a.index - b.index);
|
||||||
|
|
||||||
|
// Add a "Disable Subtitles" option
|
||||||
|
subtitles.unshift({
|
||||||
|
name: "Disable",
|
||||||
|
index: -1,
|
||||||
|
setTrack: () =>
|
||||||
|
!mediaSource?.TranscodingUrl || onTextBasedSubtitle
|
||||||
|
? setTrackParams("subtitle", -1, -1)
|
||||||
|
: setPlayerParams({ chosenSubtitleIndex: "-1" }),
|
||||||
|
});
|
||||||
setSubtitleTracks(subtitles);
|
setSubtitleTracks(subtitles);
|
||||||
}
|
}
|
||||||
if (
|
if (getAudioTracks) {
|
||||||
getAudioTracks &&
|
const audioData = await getAudioTracks();
|
||||||
(audioTracks === null || audioTracks.length === 0)
|
|
||||||
) {
|
const allAudio = mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") || [];
|
||||||
const audio = await getAudioTracks();
|
const audioTracks: Track[] = allAudio?.map((audio, idx) => {
|
||||||
setAudioTracks(audio);
|
if (!mediaSource?.TranscodingUrl) {
|
||||||
|
const vlcIndex = audioData?.at(idx)?.index ?? -1;
|
||||||
|
return {
|
||||||
|
name: audio.DisplayTitle ?? "Undefined Audio",
|
||||||
|
index: audio.Index ?? -1,
|
||||||
|
setTrack: () => setTrackParams("audio", vlcIndex, audio.Index ?? -1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: audio.DisplayTitle ?? "Undefined Audio",
|
||||||
|
index: audio.Index ?? -1,
|
||||||
|
setTrack: () => setPlayerParams({ chosenAudioIndex: audio.Index?.toString() }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setAudioTracks(audioTracks);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchTracks();
|
fetchTracks();
|
||||||
|
|||||||
121
components/video-player/controls/dropdown/DropdownView.tsx
Normal file
121
components/video-player/controls/dropdown/DropdownView.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import React, { useCallback } from "react";
|
||||||
|
import { TouchableOpacity, Platform } from "react-native";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
|
import { useVideoContext } from "../contexts/VideoContext";
|
||||||
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||||
|
import { BITRATES } from "@/components/BitrateSelector";
|
||||||
|
import { useControlContext } from "../contexts/ControlContext";
|
||||||
|
|
||||||
|
const DropdownView = () => {
|
||||||
|
const videoContext = useVideoContext();
|
||||||
|
const { subtitleTracks, audioTracks } = videoContext;
|
||||||
|
const ControlContext = useControlContext();
|
||||||
|
const [item, mediaSource] = [ControlContext?.item, ControlContext?.mediaSource];
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { subtitleIndex, audioIndex, bitrateValue } = useLocalSearchParams<{
|
||||||
|
itemId: string;
|
||||||
|
audioIndex: string;
|
||||||
|
subtitleIndex: string;
|
||||||
|
mediaSourceId: string;
|
||||||
|
bitrateValue: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const changeBitrate = useCallback(
|
||||||
|
(bitrate: string) => {
|
||||||
|
const queryParams = new URLSearchParams({
|
||||||
|
itemId: item.Id ?? "",
|
||||||
|
audioIndex: audioIndex?.toString() ?? "",
|
||||||
|
subtitleIndex: subtitleIndex.toString() ?? "",
|
||||||
|
mediaSourceId: mediaSource?.Id ?? "",
|
||||||
|
bitrateValue: bitrate.toString(),
|
||||||
|
}).toString();
|
||||||
|
// @ts-expect-error
|
||||||
|
router.replace(`player/direct-player?${queryParams}`);
|
||||||
|
},
|
||||||
|
[item, mediaSource, subtitleIndex, audioIndex]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger>
|
||||||
|
<TouchableOpacity className="aspect-square flex flex-col rounded-xl items-center justify-center p-2">
|
||||||
|
<Ionicons name="ellipsis-horizontal" size={24} color={"white"} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Content
|
||||||
|
loop={true}
|
||||||
|
side="bottom"
|
||||||
|
align="start"
|
||||||
|
alignOffset={0}
|
||||||
|
avoidCollisions={true}
|
||||||
|
collisionPadding={8}
|
||||||
|
sideOffset={8}
|
||||||
|
>
|
||||||
|
<DropdownMenu.Sub>
|
||||||
|
<DropdownMenu.SubTrigger key="qualitytrigger">Quality</DropdownMenu.SubTrigger>
|
||||||
|
<DropdownMenu.SubContent
|
||||||
|
alignOffset={-10}
|
||||||
|
avoidCollisions={true}
|
||||||
|
collisionPadding={0}
|
||||||
|
loop={true}
|
||||||
|
sideOffset={10}
|
||||||
|
>
|
||||||
|
{BITRATES?.map((bitrate, idx: number) => (
|
||||||
|
<DropdownMenu.CheckboxItem
|
||||||
|
key={`quality-item-${idx}`}
|
||||||
|
value={bitrateValue === (bitrate.value?.toString() ?? "")}
|
||||||
|
onValueChange={() => changeBitrate(bitrate.value?.toString() ?? "")}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>{bitrate.key}</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.CheckboxItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenu.SubContent>
|
||||||
|
</DropdownMenu.Sub>
|
||||||
|
<DropdownMenu.Sub>
|
||||||
|
<DropdownMenu.SubTrigger key="subtitle-trigger">Subtitle</DropdownMenu.SubTrigger>
|
||||||
|
<DropdownMenu.SubContent
|
||||||
|
alignOffset={-10}
|
||||||
|
avoidCollisions={true}
|
||||||
|
collisionPadding={0}
|
||||||
|
loop={true}
|
||||||
|
sideOffset={10}
|
||||||
|
>
|
||||||
|
{subtitleTracks?.map((sub, idx: number) => (
|
||||||
|
<DropdownMenu.CheckboxItem
|
||||||
|
key={`subtitle-item-${idx}`}
|
||||||
|
value={subtitleIndex === sub.index.toString()}
|
||||||
|
onValueChange={() => sub.setTrack()}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemTitle key={`subtitle-item-title-${idx}`}>{sub.name}</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.CheckboxItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenu.SubContent>
|
||||||
|
</DropdownMenu.Sub>
|
||||||
|
<DropdownMenu.Sub>
|
||||||
|
<DropdownMenu.SubTrigger key="audio-trigger">Audio</DropdownMenu.SubTrigger>
|
||||||
|
<DropdownMenu.SubContent
|
||||||
|
alignOffset={-10}
|
||||||
|
avoidCollisions={true}
|
||||||
|
collisionPadding={0}
|
||||||
|
loop={true}
|
||||||
|
sideOffset={10}
|
||||||
|
>
|
||||||
|
{audioTracks?.map((track, idx: number) => (
|
||||||
|
<DropdownMenu.CheckboxItem
|
||||||
|
key={`audio-item-${idx}`}
|
||||||
|
value={audioIndex === track.index.toString()}
|
||||||
|
onValueChange={() => track.setTrack()}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>{track.name}</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.CheckboxItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenu.SubContent>
|
||||||
|
</DropdownMenu.Sub>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DropdownView;
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
import React, { useMemo, useState } from "react";
|
|
||||||
import { View, TouchableOpacity } from "react-native";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
|
||||||
import { useControlContext } from "../contexts/ControlContext";
|
|
||||||
import { useVideoContext } from "../contexts/VideoContext";
|
|
||||||
import { EmbeddedSubtitle, ExternalSubtitle } from "../types";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { router, useLocalSearchParams } from "expo-router";
|
|
||||||
|
|
||||||
interface DropdownViewDirectProps {
|
|
||||||
showControls: boolean;
|
|
||||||
offline?: boolean; // used to disable external subs for downloads
|
|
||||||
}
|
|
||||||
|
|
||||||
const DropdownViewDirect: React.FC<DropdownViewDirectProps> = ({
|
|
||||||
showControls,
|
|
||||||
offline = false,
|
|
||||||
}) => {
|
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const ControlContext = useControlContext();
|
|
||||||
const mediaSource = ControlContext?.mediaSource;
|
|
||||||
const item = ControlContext?.item;
|
|
||||||
const isVideoLoaded = ControlContext?.isVideoLoaded;
|
|
||||||
|
|
||||||
const videoContext = useVideoContext();
|
|
||||||
const {
|
|
||||||
subtitleTracks,
|
|
||||||
audioTracks,
|
|
||||||
setSubtitleURL,
|
|
||||||
setSubtitleTrack,
|
|
||||||
setAudioTrack,
|
|
||||||
} = videoContext;
|
|
||||||
|
|
||||||
const allSubtitleTracksForDirectPlay = useMemo(() => {
|
|
||||||
if (mediaSource?.TranscodingUrl) return null;
|
|
||||||
const embeddedSubs =
|
|
||||||
subtitleTracks
|
|
||||||
?.map((s) => ({
|
|
||||||
name: s.name,
|
|
||||||
index: s.index,
|
|
||||||
deliveryUrl: undefined,
|
|
||||||
}))
|
|
||||||
.filter((sub) => !sub.name.endsWith("[External]")) || [];
|
|
||||||
|
|
||||||
const externalSubs =
|
|
||||||
mediaSource?.MediaStreams?.filter(
|
|
||||||
(stream) => stream.Type === "Subtitle" && !!stream.DeliveryUrl
|
|
||||||
).map((s) => ({
|
|
||||||
name: s.DisplayTitle! + " [External]",
|
|
||||||
index: s.Index!,
|
|
||||||
deliveryUrl: s.DeliveryUrl,
|
|
||||||
})) || [];
|
|
||||||
|
|
||||||
// Combine embedded subs with external subs only if not offline
|
|
||||||
if (!offline) {
|
|
||||||
return [...embeddedSubs, ...externalSubs] as (
|
|
||||||
| EmbeddedSubtitle
|
|
||||||
| ExternalSubtitle
|
|
||||||
)[];
|
|
||||||
}
|
|
||||||
return embeddedSubs as EmbeddedSubtitle[];
|
|
||||||
}, [item, isVideoLoaded, subtitleTracks, mediaSource?.MediaStreams, offline]);
|
|
||||||
|
|
||||||
const { subtitleIndex, audioIndex } = useLocalSearchParams<{
|
|
||||||
itemId: string;
|
|
||||||
audioIndex: string;
|
|
||||||
subtitleIndex: string;
|
|
||||||
mediaSourceId: string;
|
|
||||||
bitrateValue: string;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu.Root>
|
|
||||||
<DropdownMenu.Trigger>
|
|
||||||
<TouchableOpacity className="aspect-square flex flex-col rounded-xl items-center justify-center p-2">
|
|
||||||
<Ionicons name="ellipsis-horizontal" size={24} color={"white"} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
loop={true}
|
|
||||||
side="bottom"
|
|
||||||
align="start"
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={8}
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Sub>
|
|
||||||
<DropdownMenu.SubTrigger key="subtitle-trigger">
|
|
||||||
Subtitle
|
|
||||||
</DropdownMenu.SubTrigger>
|
|
||||||
<DropdownMenu.SubContent
|
|
||||||
alignOffset={-10}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={0}
|
|
||||||
loop={true}
|
|
||||||
sideOffset={10}
|
|
||||||
>
|
|
||||||
{allSubtitleTracksForDirectPlay?.map((sub, idx: number) => (
|
|
||||||
<DropdownMenu.CheckboxItem
|
|
||||||
key={`subtitle-item-${idx}`}
|
|
||||||
value={subtitleIndex === sub.index.toString()}
|
|
||||||
onValueChange={() => {
|
|
||||||
if ("deliveryUrl" in sub && sub.deliveryUrl) {
|
|
||||||
setSubtitleURL &&
|
|
||||||
setSubtitleURL(api?.basePath + sub.deliveryUrl, sub.name);
|
|
||||||
} else {
|
|
||||||
setSubtitleTrack && setSubtitleTrack(sub.index);
|
|
||||||
}
|
|
||||||
router.setParams({
|
|
||||||
subtitleIndex: sub.index.toString(),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle key={`subtitle-item-title-${idx}`}>
|
|
||||||
{sub.name}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.CheckboxItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenu.SubContent>
|
|
||||||
</DropdownMenu.Sub>
|
|
||||||
<DropdownMenu.Sub>
|
|
||||||
<DropdownMenu.SubTrigger key="audio-trigger">
|
|
||||||
Audio
|
|
||||||
</DropdownMenu.SubTrigger>
|
|
||||||
<DropdownMenu.SubContent
|
|
||||||
alignOffset={-10}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={0}
|
|
||||||
loop={true}
|
|
||||||
sideOffset={10}
|
|
||||||
>
|
|
||||||
{audioTracks?.map((track, idx: number) => (
|
|
||||||
<DropdownMenu.CheckboxItem
|
|
||||||
key={`audio-item-${idx}`}
|
|
||||||
value={audioIndex === track.index.toString()}
|
|
||||||
onValueChange={() => {
|
|
||||||
setAudioTrack && setAudioTrack(track.index);
|
|
||||||
router.setParams({
|
|
||||||
audioIndex: track.index.toString(),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>
|
|
||||||
{track.name}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.CheckboxItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenu.SubContent>
|
|
||||||
</DropdownMenu.Sub>
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DropdownViewDirect;
|
|
||||||
@@ -1,228 +0,0 @@
|
|||||||
import React, { useCallback, useMemo, useState } from "react";
|
|
||||||
import { View, TouchableOpacity } from "react-native";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
|
||||||
import { useControlContext } from "../contexts/ControlContext";
|
|
||||||
import { useVideoContext } from "../contexts/VideoContext";
|
|
||||||
import { TranscodedSubtitle } from "../types";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
|
||||||
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
|
||||||
|
|
||||||
interface DropdownViewProps {
|
|
||||||
showControls: boolean;
|
|
||||||
offline?: boolean; // used to disable external subs for downloads
|
|
||||||
}
|
|
||||||
|
|
||||||
const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
|
|
||||||
const router = useRouter();
|
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const ControlContext = useControlContext();
|
|
||||||
const mediaSource = ControlContext?.mediaSource;
|
|
||||||
const item = ControlContext?.item;
|
|
||||||
const isVideoLoaded = ControlContext?.isVideoLoaded;
|
|
||||||
|
|
||||||
const videoContext = useVideoContext();
|
|
||||||
const { subtitleTracks, setSubtitleTrack } = videoContext;
|
|
||||||
|
|
||||||
const { subtitleIndex, audioIndex, bitrateValue } = useLocalSearchParams<{
|
|
||||||
itemId: string;
|
|
||||||
audioIndex: string;
|
|
||||||
subtitleIndex: string;
|
|
||||||
mediaSourceId: string;
|
|
||||||
bitrateValue: string;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
// Either its on a text subtitle or its on not on any subtitle therefore it should show all the embedded HLS subtitles.
|
|
||||||
|
|
||||||
const isOnTextSubtitle = useMemo(() => {
|
|
||||||
const res = Boolean(
|
|
||||||
mediaSource?.MediaStreams?.find(
|
|
||||||
(x) => x.Index === parseInt(subtitleIndex) && x.IsTextSubtitleStream
|
|
||||||
) || subtitleIndex === "-1"
|
|
||||||
);
|
|
||||||
return res;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const allSubs =
|
|
||||||
mediaSource?.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [];
|
|
||||||
|
|
||||||
const subtitleHelper = new SubtitleHelper(mediaSource?.MediaStreams ?? []);
|
|
||||||
|
|
||||||
const allSubtitleTracksForTranscodingStream = useMemo(() => {
|
|
||||||
const disableSubtitle = {
|
|
||||||
name: "Disable",
|
|
||||||
index: -1,
|
|
||||||
IsTextSubtitleStream: true,
|
|
||||||
} as TranscodedSubtitle;
|
|
||||||
if (isOnTextSubtitle) {
|
|
||||||
const textSubtitles =
|
|
||||||
subtitleTracks?.map((s) => ({
|
|
||||||
name: s.name,
|
|
||||||
index: s.index,
|
|
||||||
IsTextSubtitleStream: true,
|
|
||||||
})) || [];
|
|
||||||
|
|
||||||
const sortedSubtitles = subtitleHelper.getSortedSubtitles(textSubtitles);
|
|
||||||
|
|
||||||
return [disableSubtitle, ...sortedSubtitles];
|
|
||||||
}
|
|
||||||
|
|
||||||
const transcodedSubtitle: TranscodedSubtitle[] = allSubs.map((x) => ({
|
|
||||||
name: x.DisplayTitle!,
|
|
||||||
index: x.Index!,
|
|
||||||
IsTextSubtitleStream: x.IsTextSubtitleStream!,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return [disableSubtitle, ...transcodedSubtitle];
|
|
||||||
}, [item, isVideoLoaded, subtitleTracks, mediaSource?.MediaStreams]);
|
|
||||||
|
|
||||||
const changeToImageBasedSub = useCallback(
|
|
||||||
(subtitleIndex: number) => {
|
|
||||||
const queryParams = new URLSearchParams({
|
|
||||||
itemId: item.Id ?? "", // Ensure itemId is a string
|
|
||||||
audioIndex: audioIndex?.toString() ?? "",
|
|
||||||
subtitleIndex: subtitleIndex?.toString() ?? "",
|
|
||||||
mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string
|
|
||||||
bitrateValue: bitrateValue,
|
|
||||||
}).toString();
|
|
||||||
|
|
||||||
// @ts-expect-error
|
|
||||||
router.replace(`player/transcoding-player?${queryParams}`);
|
|
||||||
},
|
|
||||||
[mediaSource]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Audio tracks for transcoding streams.
|
|
||||||
const allAudio =
|
|
||||||
mediaSource?.MediaStreams?.filter((x) => x.Type === "Audio").map((x) => ({
|
|
||||||
name: x.DisplayTitle!,
|
|
||||||
index: x.Index!,
|
|
||||||
})) || [];
|
|
||||||
|
|
||||||
const ChangeTranscodingAudio = useCallback(
|
|
||||||
(audioIndex: number) => {
|
|
||||||
const queryParams = new URLSearchParams({
|
|
||||||
itemId: item.Id ?? "", // Ensure itemId is a string
|
|
||||||
audioIndex: audioIndex?.toString() ?? "",
|
|
||||||
subtitleIndex: subtitleIndex?.toString() ?? "",
|
|
||||||
mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string
|
|
||||||
bitrateValue: bitrateValue,
|
|
||||||
}).toString();
|
|
||||||
|
|
||||||
// @ts-expect-error
|
|
||||||
router.replace(`player/transcoding-player?${queryParams}`);
|
|
||||||
},
|
|
||||||
[mediaSource, subtitleIndex, audioIndex]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View>
|
|
||||||
<DropdownMenu.Root>
|
|
||||||
<DropdownMenu.Trigger>
|
|
||||||
<TouchableOpacity className="aspect-square flex flex-col rounded-xl items-center justify-center p-2">
|
|
||||||
<Ionicons name="ellipsis-horizontal" size={24} color={"white"} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
loop={true}
|
|
||||||
side="bottom"
|
|
||||||
align="start"
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={8}
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Sub>
|
|
||||||
<DropdownMenu.SubTrigger key="subtitle-trigger">
|
|
||||||
Subtitle
|
|
||||||
</DropdownMenu.SubTrigger>
|
|
||||||
<DropdownMenu.SubContent
|
|
||||||
alignOffset={-10}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={0}
|
|
||||||
loop={true}
|
|
||||||
sideOffset={10}
|
|
||||||
>
|
|
||||||
{allSubtitleTracksForTranscodingStream?.map(
|
|
||||||
(sub, idx: number) => (
|
|
||||||
<DropdownMenu.CheckboxItem
|
|
||||||
value={
|
|
||||||
subtitleIndex ===
|
|
||||||
(isOnTextSubtitle && sub.IsTextSubtitleStream
|
|
||||||
? subtitleHelper
|
|
||||||
.getSourceSubtitleIndex(sub.index)
|
|
||||||
.toString()
|
|
||||||
: sub?.index.toString())
|
|
||||||
}
|
|
||||||
key={`subtitle-item-${idx}`}
|
|
||||||
onValueChange={() => {
|
|
||||||
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);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
changeToImageBasedSub(sub.index);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle key={`subtitle-item-title-${idx}`}>
|
|
||||||
{sub.name}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.CheckboxItem>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</DropdownMenu.SubContent>
|
|
||||||
</DropdownMenu.Sub>
|
|
||||||
<DropdownMenu.Sub>
|
|
||||||
<DropdownMenu.SubTrigger key="audio-trigger">
|
|
||||||
Audio
|
|
||||||
</DropdownMenu.SubTrigger>
|
|
||||||
<DropdownMenu.SubContent
|
|
||||||
alignOffset={-10}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={0}
|
|
||||||
loop={true}
|
|
||||||
sideOffset={10}
|
|
||||||
>
|
|
||||||
{allAudio?.map((track, idx: number) => (
|
|
||||||
<DropdownMenu.CheckboxItem
|
|
||||||
key={`audio-item-${idx}`}
|
|
||||||
value={audioIndex === track.index.toString()}
|
|
||||||
onValueChange={() => {
|
|
||||||
if (audioIndex === track.index.toString()) return;
|
|
||||||
router.setParams({
|
|
||||||
audioIndex: track.index.toString(),
|
|
||||||
});
|
|
||||||
ChangeTranscodingAudio(track.index);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>
|
|
||||||
{track.name}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.CheckboxItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenu.SubContent>
|
|
||||||
</DropdownMenu.Sub>
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DropdownView;
|
|
||||||
@@ -13,7 +13,14 @@ type ExternalSubtitle = {
|
|||||||
type TranscodedSubtitle = {
|
type TranscodedSubtitle = {
|
||||||
name: string;
|
name: string;
|
||||||
index: number;
|
index: number;
|
||||||
|
deliveryUrl: string;
|
||||||
IsTextSubtitleStream: boolean;
|
IsTextSubtitleStream: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export { EmbeddedSubtitle, ExternalSubtitle, TranscodedSubtitle };
|
type Track = {
|
||||||
|
name: string;
|
||||||
|
index: number;
|
||||||
|
setTrack: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export { EmbeddedSubtitle, ExternalSubtitle, TranscodedSubtitle, Track };
|
||||||
|
|||||||
24
eas.json
24
eas.json
@@ -11,6 +11,16 @@
|
|||||||
"buildType": "apk"
|
"buildType": "apk"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"development_tv": {
|
||||||
|
"developmentClient": true,
|
||||||
|
"distribution": "internal",
|
||||||
|
"android": {
|
||||||
|
"buildType": "apk"
|
||||||
|
},
|
||||||
|
"env": {
|
||||||
|
"EXPO_TV": "1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"preview": {
|
"preview": {
|
||||||
"distribution": "internal"
|
"distribution": "internal"
|
||||||
},
|
},
|
||||||
@@ -22,17 +32,27 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"channel": "0.25.0",
|
"channel": "0.27.0",
|
||||||
"android": {
|
"android": {
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production-apk": {
|
"production-apk": {
|
||||||
"channel": "0.25.0",
|
"channel": "0.27.0",
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"production-apk-tv": {
|
||||||
|
"channel": "0.27.0",
|
||||||
|
"android": {
|
||||||
|
"buildType": "apk",
|
||||||
|
"image": "latest"
|
||||||
|
},
|
||||||
|
"env": {
|
||||||
|
"EXPO_TV": "1"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"submit": {
|
"submit": {
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
--- expo.js.original 2024-11-10 09:08:19
|
|
||||||
+++ node_modules/react-native-edge-to-edge/dist/commonjs/expo.js 2024-11-10 09:08:23
|
|
||||||
@@ -19,10 +19,8 @@
|
|
||||||
const {
|
|
||||||
barStyle
|
|
||||||
} = androidStatusBar;
|
|
||||||
+ const android = props?.android || {};
|
|
||||||
const {
|
|
||||||
- android = {}
|
|
||||||
- } = props;
|
|
||||||
- const {
|
|
||||||
parentTheme = "Default"
|
|
||||||
} = android;
|
|
||||||
config.modResults.resources.style = config.modResults.resources.style?.map(style => {
|
|
||||||
\ No newline at end of file
|
|
||||||
@@ -28,8 +28,8 @@ const useDefaultPlaySettings = (
|
|||||||
(x) => x.Type === "Audio"
|
(x) => x.Type === "Audio"
|
||||||
)?.Index;
|
)?.Index;
|
||||||
|
|
||||||
// 4. Get default bitrate
|
// 4. Get default bitrate from settings or fallback to max
|
||||||
const bitrate = BITRATES[0];
|
const bitrate = settings?.defaultBitrate ?? BITRATES[0];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
defaultAudioIndex:
|
defaultAudioIndex:
|
||||||
|
|||||||
109
hooks/useFavorite.ts
Normal file
109
hooks/useFavorite.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useEffect, useState, useMemo } from "react";
|
||||||
|
|
||||||
|
export const useFavorite = (item: BaseItemDto) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const type = "item";
|
||||||
|
const [isFavorite, setIsFavorite] = useState(item.UserData?.IsFavorite);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsFavorite(item.UserData?.IsFavorite);
|
||||||
|
}, [item.UserData?.IsFavorite]);
|
||||||
|
|
||||||
|
const updateItemInQueries = (newData: Partial<BaseItemDto>) => {
|
||||||
|
queryClient.setQueryData<BaseItemDto | undefined>(
|
||||||
|
[type, item.Id],
|
||||||
|
(old) => {
|
||||||
|
if (!old) return old;
|
||||||
|
return {
|
||||||
|
...old,
|
||||||
|
...newData,
|
||||||
|
UserData: { ...old.UserData, ...newData.UserData },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const markFavoriteMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (api && user) {
|
||||||
|
await getUserLibraryApi(api).markFavoriteItem({
|
||||||
|
userId: user.Id,
|
||||||
|
itemId: item.Id!,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onMutate: async () => {
|
||||||
|
await queryClient.cancelQueries({ queryKey: [type, item.Id] });
|
||||||
|
const previousItem = queryClient.getQueryData<BaseItemDto>([
|
||||||
|
type,
|
||||||
|
item.Id,
|
||||||
|
]);
|
||||||
|
updateItemInQueries({ UserData: { IsFavorite: true } });
|
||||||
|
|
||||||
|
return { previousItem };
|
||||||
|
},
|
||||||
|
onError: (err, variables, context) => {
|
||||||
|
if (context?.previousItem) {
|
||||||
|
queryClient.setQueryData([type, item.Id], context.previousItem);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [type, item.Id] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["home", "favorites"] });
|
||||||
|
setIsFavorite(true);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const unmarkFavoriteMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (api && user) {
|
||||||
|
await getUserLibraryApi(api).unmarkFavoriteItem({
|
||||||
|
userId: user.Id,
|
||||||
|
itemId: item.Id!,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onMutate: async () => {
|
||||||
|
await queryClient.cancelQueries({ queryKey: [type, item.Id] });
|
||||||
|
const previousItem = queryClient.getQueryData<BaseItemDto>([
|
||||||
|
type,
|
||||||
|
item.Id,
|
||||||
|
]);
|
||||||
|
updateItemInQueries({ UserData: { IsFavorite: false } });
|
||||||
|
|
||||||
|
return { previousItem };
|
||||||
|
},
|
||||||
|
onError: (err, variables, context) => {
|
||||||
|
if (context?.previousItem) {
|
||||||
|
queryClient.setQueryData([type, item.Id], context.previousItem);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [type, item.Id] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["home", "favorites"] });
|
||||||
|
setIsFavorite(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleFavorite = () => {
|
||||||
|
if (isFavorite) {
|
||||||
|
unmarkFavoriteMutation.mutate();
|
||||||
|
} else {
|
||||||
|
markFavoriteMutation.mutate();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
isFavorite,
|
||||||
|
toggleFavorite,
|
||||||
|
markFavoriteMutation,
|
||||||
|
unmarkFavoriteMutation,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import * as Haptics from "expo-haptics";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
const Haptics = !Platform.isTV ? require("expo-haptics") : null;
|
||||||
|
|
||||||
export type HapticFeedbackType =
|
export type HapticFeedbackType =
|
||||||
| "light"
|
| "light"
|
||||||
@@ -15,15 +15,21 @@ export type HapticFeedbackType =
|
|||||||
export const useHaptic = (feedbackType: HapticFeedbackType = "selection") => {
|
export const useHaptic = (feedbackType: HapticFeedbackType = "selection") => {
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
|
|
||||||
|
if (Platform.isTV) {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
const createHapticHandler = useCallback(
|
const createHapticHandler = useCallback(
|
||||||
(type: Haptics.ImpactFeedbackStyle) => {
|
(type: typeof Haptics.ImpactFeedbackStyle) => {
|
||||||
return Platform.OS === "web" ? () => {} : () => Haptics.impactAsync(type);
|
return Platform.OS === "web" || Platform.isTV
|
||||||
|
? () => {}
|
||||||
|
: () => Haptics.impactAsync(type);
|
||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
const createNotificationFeedback = useCallback(
|
const createNotificationFeedback = useCallback(
|
||||||
(type: Haptics.NotificationFeedbackType) => {
|
(type: typeof Haptics.NotificationFeedbackType) => {
|
||||||
return Platform.OS === "web"
|
return Platform.OS === "web" || Platform.isTV
|
||||||
? () => {}
|
? () => {}
|
||||||
: () => Haptics.notificationAsync(type);
|
: () => Haptics.notificationAsync(type);
|
||||||
},
|
},
|
||||||
@@ -35,7 +41,10 @@ export const useHaptic = (feedbackType: HapticFeedbackType = "selection") => {
|
|||||||
light: createHapticHandler(Haptics.ImpactFeedbackStyle.Light),
|
light: createHapticHandler(Haptics.ImpactFeedbackStyle.Light),
|
||||||
medium: createHapticHandler(Haptics.ImpactFeedbackStyle.Medium),
|
medium: createHapticHandler(Haptics.ImpactFeedbackStyle.Medium),
|
||||||
heavy: createHapticHandler(Haptics.ImpactFeedbackStyle.Heavy),
|
heavy: createHapticHandler(Haptics.ImpactFeedbackStyle.Heavy),
|
||||||
selection: Platform.OS === "web" ? () => {} : Haptics.selectionAsync,
|
selection:
|
||||||
|
Platform.OS === "web" || Platform.isTV
|
||||||
|
? () => {}
|
||||||
|
: Haptics.selectionAsync,
|
||||||
success: createNotificationFeedback(
|
success: createNotificationFeedback(
|
||||||
Haptics.NotificationFeedbackType.Success
|
Haptics.NotificationFeedbackType.Success
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import { storage } from "@/utils/mmkv";
|
|||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { getColors } from "react-native-image-colors";
|
import { Platform } from "react-native";
|
||||||
|
// import { getColors } from "react-native-image-colors";
|
||||||
|
const Colors = !Platform.isTV ? require("react-native-image-colors") : null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom hook to extract and manage image colors for a given item.
|
* Custom hook to extract and manage image colors for a given item.
|
||||||
@@ -28,6 +30,8 @@ export const useImageColors = ({
|
|||||||
url?: string | null;
|
url?: string | null;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const [, setPrimaryColor] = useAtom(itemThemeColorAtom);
|
const [, setPrimaryColor] = useAtom(itemThemeColorAtom);
|
||||||
|
|
||||||
@@ -62,11 +66,11 @@ export const useImageColors = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Extract colors from the image
|
// Extract colors from the image
|
||||||
getColors(source.uri, {
|
Colors.getColors(source.uri, {
|
||||||
fallback: "#fff",
|
fallback: "#fff",
|
||||||
cache: false,
|
cache: false,
|
||||||
})
|
})
|
||||||
.then((colors) => {
|
.then((colors: { platform: string; dominant: string; vibrant: string; detail: string; primary: string; }) => {
|
||||||
let primary: string = "#fff";
|
let primary: string = "#fff";
|
||||||
let text: string = "#000";
|
let text: string = "#000";
|
||||||
let backup: string = "#fff";
|
let backup: string = "#fff";
|
||||||
@@ -100,7 +104,7 @@ export const useImageColors = ({
|
|||||||
storage.set(`${source.uri}-text`, text);
|
storage.set(`${source.uri}-text`, text);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error: any) => {
|
||||||
console.error("Error getting colors", error);
|
console.error("Error getting colors", error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -449,12 +449,23 @@ export const useJellyseerr = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const jellyseerrRegion = useMemo(
|
||||||
|
() => jellyseerrUser?.settings?.region || "US",
|
||||||
|
[jellyseerrUser]
|
||||||
|
);
|
||||||
|
|
||||||
|
const jellyseerrLocale = useMemo(() => {
|
||||||
|
return jellyseerrUser?.settings?.locale || "en";
|
||||||
|
}, [jellyseerrUser]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
jellyseerrApi,
|
jellyseerrApi,
|
||||||
jellyseerrUser,
|
jellyseerrUser,
|
||||||
setJellyseerrUser,
|
setJellyseerrUser,
|
||||||
clearAllJellyseerData,
|
clearAllJellyseerData,
|
||||||
isJellyseerrResult,
|
isJellyseerrResult,
|
||||||
|
jellyseerrRegion,
|
||||||
|
jellyseerrLocale,
|
||||||
requestMedia,
|
requestMedia,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
|||||||
import { useHaptic } from "./useHaptic";
|
import { useHaptic } from "./useHaptic";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
|
|
||||||
export const useMarkAsPlayed = (item: BaseItemDto) => {
|
export const useMarkAsPlayed = (items: BaseItemDto[]) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -14,7 +14,6 @@ export const useMarkAsPlayed = (item: BaseItemDto) => {
|
|||||||
|
|
||||||
const invalidateQueries = () => {
|
const invalidateQueries = () => {
|
||||||
const queriesToInvalidate = [
|
const queriesToInvalidate = [
|
||||||
["item", item.Id],
|
|
||||||
["resumeItems"],
|
["resumeItems"],
|
||||||
["continueWatching"],
|
["continueWatching"],
|
||||||
["nextUp-all"],
|
["nextUp-all"],
|
||||||
@@ -24,6 +23,11 @@ export const useMarkAsPlayed = (item: BaseItemDto) => {
|
|||||||
["home"],
|
["home"],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
items.forEach((item) => {
|
||||||
|
if(!item.Id) return;
|
||||||
|
queriesToInvalidate.push(["item", item.Id]);
|
||||||
|
});
|
||||||
|
|
||||||
queriesToInvalidate.forEach((queryKey) => {
|
queriesToInvalidate.forEach((queryKey) => {
|
||||||
queryClient.invalidateQueries({ queryKey });
|
queryClient.invalidateQueries({ queryKey });
|
||||||
});
|
});
|
||||||
@@ -32,40 +36,8 @@ export const useMarkAsPlayed = (item: BaseItemDto) => {
|
|||||||
const markAsPlayedStatus = async (played: boolean) => {
|
const markAsPlayedStatus = async (played: boolean) => {
|
||||||
lightHapticFeedback();
|
lightHapticFeedback();
|
||||||
|
|
||||||
// Optimistic update
|
items.forEach((item) => {
|
||||||
queryClient.setQueryData(
|
// Optimistic update
|
||||||
["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(
|
queryClient.setQueryData(
|
||||||
["item", item.Id],
|
["item", item.Id],
|
||||||
(oldData: BaseItemDto | undefined) => {
|
(oldData: BaseItemDto | undefined) => {
|
||||||
@@ -81,8 +53,45 @@ export const useMarkAsPlayed = (item: BaseItemDto) => {
|
|||||||
return oldData;
|
return oldData;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Process all items
|
||||||
|
await Promise.all(items.map(item =>
|
||||||
|
played
|
||||||
|
? markAsPlayed({ api, item, userId: user?.Id })
|
||||||
|
: markAsNotPlayed({ api, itemId: item?.Id, userId: user?.Id })
|
||||||
|
));
|
||||||
|
|
||||||
|
// Bulk invalidate
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: [
|
||||||
|
"resumeItems",
|
||||||
|
"continueWatching",
|
||||||
|
"nextUp-all",
|
||||||
|
"nextUp",
|
||||||
|
"episodes",
|
||||||
|
"seasons",
|
||||||
|
"home",
|
||||||
|
...items.map(item => ["item", item.Id])
|
||||||
|
].flat()
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Revert all optimistic updates on any failure
|
||||||
|
items.forEach(item => {
|
||||||
|
queryClient.setQueryData(
|
||||||
|
["item", item.Id],
|
||||||
|
(oldData: BaseItemDto | undefined) =>
|
||||||
|
oldData ? {
|
||||||
|
...oldData,
|
||||||
|
UserData: { ...oldData.UserData, Played: played }
|
||||||
|
} : oldData
|
||||||
|
);
|
||||||
|
});
|
||||||
console.error("Error updating played status:", error);
|
console.error("Error updating played status:", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
invalidateQueries();
|
||||||
};
|
};
|
||||||
|
|
||||||
return markAsPlayedStatus;
|
return markAsPlayedStatus;
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
import orientationToOrientationLock from "@/utils/OrientationLockConverter";
|
import orientationToOrientationLock from "@/utils/OrientationLockConverter";
|
||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
export const useOrientation = () => {
|
export const useOrientation = () => {
|
||||||
const [orientation, setOrientation] = useState(
|
const [orientation, setOrientation] = useState(
|
||||||
ScreenOrientation.OrientationLock.UNKNOWN
|
Platform.isTV
|
||||||
|
? ScreenOrientation.OrientationLock.LANDSCAPE
|
||||||
|
: ScreenOrientation.OrientationLock.UNKNOWN
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (Platform.isTV) return { orientation, setOrientation };
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const orientationSubscription =
|
const orientationSubscription =
|
||||||
ScreenOrientation.addOrientationChangeListener((event) => {
|
ScreenOrientation.addOrientationChangeListener((event) => {
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
export const useOrientationSettings = () => {
|
export const useOrientationSettings = () => {
|
||||||
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ import {
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import * as FileSystem from "expo-file-system";
|
import * as FileSystem from "expo-file-system";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { FFmpegKit, FFmpegSession, Statistics } from "ffmpeg-kit-react-native";
|
|
||||||
|
// import { FFmpegKit, FFmpegSession, Statistics } from "ffmpeg-kit-react-native";
|
||||||
|
const FFMPEGKitReactNative = !Platform.isTV
|
||||||
|
? require("ffmpeg-kit-react-native")
|
||||||
|
: null;
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
@@ -18,8 +22,14 @@ import useDownloadHelper from "@/utils/download";
|
|||||||
import { Api } from "@jellyfin/sdk";
|
import { Api } from "@jellyfin/sdk";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { JobStatus } from "@/utils/optimize-server";
|
import { JobStatus } from "@/utils/optimize-server";
|
||||||
|
import { Platform } from "react-native";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
type FFmpegSession = typeof FFMPEGKitReactNative.FFmpegSession;
|
||||||
|
type Statistics = typeof FFMPEGKitReactNative.Statistics;
|
||||||
|
const FFmpegKit = Platform.isTV
|
||||||
|
? null
|
||||||
|
: (FFMPEGKitReactNative.FFmpegKit as typeof FFMPEGKitReactNative.FFmpegKit);
|
||||||
const createFFmpegCommand = (url: string, output: string) => [
|
const createFFmpegCommand = (url: string, output: string) => [
|
||||||
"-y", // overwrite output files without asking
|
"-y", // overwrite output files without asking
|
||||||
"-thread_queue_size 512", // https://ffmpeg.org/ffmpeg.html#toc-Advanced-options
|
"-thread_queue_size 512", // https://ffmpeg.org/ffmpeg.html#toc-Advanced-options
|
||||||
@@ -55,7 +65,12 @@ export const useRemuxHlsToMp4 = () => {
|
|||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
const { saveImage } = useImageStorage();
|
const { saveImage } = useImageStorage();
|
||||||
const { saveSeriesPrimaryImage } = useDownloadHelper();
|
const { saveSeriesPrimaryImage } = useDownloadHelper();
|
||||||
const { saveDownloadedItemInfo, setProcesses, processes, APP_CACHE_DOWNLOAD_DIRECTORY } = useDownload();
|
const {
|
||||||
|
saveDownloadedItemInfo,
|
||||||
|
setProcesses,
|
||||||
|
processes,
|
||||||
|
APP_CACHE_DOWNLOAD_DIRECTORY,
|
||||||
|
} = useDownload();
|
||||||
|
|
||||||
const onSaveAssets = async (api: Api, item: BaseItemDto) => {
|
const onSaveAssets = async (api: Api, item: BaseItemDto) => {
|
||||||
await saveSeriesPrimaryImage(item);
|
await saveSeriesPrimaryImage(item);
|
||||||
@@ -79,9 +94,9 @@ export const useRemuxHlsToMp4 = () => {
|
|||||||
if (returnCode.isValueSuccess()) {
|
if (returnCode.isValueSuccess()) {
|
||||||
const stat = await session.getLastReceivedStatistics();
|
const stat = await session.getLastReceivedStatistics();
|
||||||
await FileSystem.moveAsync({
|
await FileSystem.moveAsync({
|
||||||
from: `${APP_CACHE_DOWNLOAD_DIRECTORY}${item.Id}.mp4`,
|
from: `${APP_CACHE_DOWNLOAD_DIRECTORY}${item.Id}.mp4`,
|
||||||
to: `${FileSystem.documentDirectory}${item.Id}.mp4`
|
to: `${FileSystem.documentDirectory}${item.Id}.mp4`,
|
||||||
})
|
});
|
||||||
await queryClient.invalidateQueries({
|
await queryClient.invalidateQueries({
|
||||||
queryKey: ["downloadedItems"],
|
queryKey: ["downloadedItems"],
|
||||||
});
|
});
|
||||||
@@ -89,8 +104,11 @@ export const useRemuxHlsToMp4 = () => {
|
|||||||
toast.success(t("home.downloads.toasts.download_completed"));
|
toast.success(t("home.downloads.toasts.download_completed"));
|
||||||
}
|
}
|
||||||
|
|
||||||
setProcesses((prev) => {
|
setProcesses((prev: any[]) => {
|
||||||
return prev.filter((process) => process.itemId !== item.Id);
|
return prev.filter(
|
||||||
|
(process: { itemId: string | undefined }) =>
|
||||||
|
process.itemId !== item.Id
|
||||||
|
);
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
@@ -114,8 +132,8 @@ export const useRemuxHlsToMp4 = () => {
|
|||||||
totalFrames > 0 ? Math.floor((processedFrames / totalFrames) * 100) : 0;
|
totalFrames > 0 ? Math.floor((processedFrames / totalFrames) * 100) : 0;
|
||||||
|
|
||||||
if (!item.Id) throw new Error("Item is undefined");
|
if (!item.Id) throw new Error("Item is undefined");
|
||||||
setProcesses((prev) => {
|
setProcesses((prev: any[]) => {
|
||||||
return prev.map((process) => {
|
return prev.map((process: { itemId: string | undefined }) => {
|
||||||
if (process.itemId === item.Id) {
|
if (process.itemId === item.Id) {
|
||||||
return {
|
return {
|
||||||
...process,
|
...process,
|
||||||
@@ -133,12 +151,16 @@ export const useRemuxHlsToMp4 = () => {
|
|||||||
|
|
||||||
const startRemuxing = useCallback(
|
const startRemuxing = useCallback(
|
||||||
async (item: BaseItemDto, url: string, mediaSource: MediaSourceInfo) => {
|
async (item: BaseItemDto, url: string, mediaSource: MediaSourceInfo) => {
|
||||||
const cacheDir = await FileSystem.getInfoAsync(APP_CACHE_DOWNLOAD_DIRECTORY);
|
const cacheDir = await FileSystem.getInfoAsync(
|
||||||
|
APP_CACHE_DOWNLOAD_DIRECTORY
|
||||||
|
);
|
||||||
if (!cacheDir.exists) {
|
if (!cacheDir.exists) {
|
||||||
await FileSystem.makeDirectoryAsync(APP_CACHE_DOWNLOAD_DIRECTORY, {intermediates: true})
|
await FileSystem.makeDirectoryAsync(APP_CACHE_DOWNLOAD_DIRECTORY, {
|
||||||
|
intermediates: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const output = APP_CACHE_DOWNLOAD_DIRECTORY + `${item.Id}.mp4`
|
const output = APP_CACHE_DOWNLOAD_DIRECTORY + `${item.Id}.mp4`;
|
||||||
|
|
||||||
if (!api) throw new Error("API is not defined");
|
if (!api) throw new Error("API is not defined");
|
||||||
if (!item.Id) throw new Error("Item must have an Id");
|
if (!item.Id) throw new Error("Item must have an Id");
|
||||||
@@ -146,15 +168,18 @@ export const useRemuxHlsToMp4 = () => {
|
|||||||
// First lets save any important assets we want to present to the user offline
|
// First lets save any important assets we want to present to the user offline
|
||||||
await onSaveAssets(api, item);
|
await onSaveAssets(api, item);
|
||||||
|
|
||||||
toast.success(t("home.downloads.toasts.download_started_for", {item: item.Name}), {
|
toast.success(
|
||||||
action: {
|
t("home.downloads.toasts.download_started_for", { item: item.Name }),
|
||||||
label: "Go to download",
|
{
|
||||||
onClick: () => {
|
action: {
|
||||||
router.push("/downloads");
|
label: "Go to download",
|
||||||
toast.dismiss();
|
onClick: () => {
|
||||||
|
router.push("/downloads");
|
||||||
|
toast.dismiss();
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
});
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const job: JobStatus = {
|
const job: JobStatus = {
|
||||||
@@ -170,13 +195,13 @@ export const useRemuxHlsToMp4 = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
writeInfoLog(`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`);
|
writeInfoLog(`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`);
|
||||||
setProcesses((prev) => [...prev, job]);
|
setProcesses((prev: any) => [...prev, job]);
|
||||||
|
|
||||||
await FFmpegKit.executeAsync(
|
await FFmpegKit.executeAsync(
|
||||||
createFFmpegCommand(url, output).join(" "),
|
createFFmpegCommand(url, output).join(" "),
|
||||||
(session) => completeCallback(session, item),
|
(session: any) => completeCallback(session, item),
|
||||||
undefined,
|
undefined,
|
||||||
(s) => statisticsCallback(s, item)
|
(s: any) => statisticsCallback(s, item)
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const error = e as Error;
|
const error = e as Error;
|
||||||
@@ -185,8 +210,11 @@ export const useRemuxHlsToMp4 = () => {
|
|||||||
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name},
|
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name},
|
||||||
Error: ${error.message}, Stack: ${error.stack}`
|
Error: ${error.message}, Stack: ${error.stack}`
|
||||||
);
|
);
|
||||||
setProcesses((prev) => {
|
setProcesses((prev: any[]) => {
|
||||||
return prev.filter((process) => process.itemId !== item.Id);
|
return prev.filter(
|
||||||
|
(process: { itemId: string | undefined }) =>
|
||||||
|
process.itemId !== item.Id
|
||||||
|
);
|
||||||
});
|
});
|
||||||
throw error; // Re-throw the error to propagate it to the caller
|
throw error; // Re-throw the error to propagate it to the caller
|
||||||
}
|
}
|
||||||
|
|||||||
36
hooks/useSessions.ts
Normal file
36
hooks/useSessions.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
||||||
|
import { userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
|
export interface useSessionsProps {
|
||||||
|
refetchInterval: number;
|
||||||
|
activeWithinSeconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSessions = ({
|
||||||
|
refetchInterval = 5 * 1000,
|
||||||
|
activeWithinSeconds = 360,
|
||||||
|
}: useSessionsProps) => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: ["sessions"],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api || !user || !user.Policy?.IsAdministrator) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const response = await getSessionApi(api).getSessions({
|
||||||
|
activeWithinSeconds: activeWithinSeconds,
|
||||||
|
});
|
||||||
|
return response.data.filter((s) => s.NowPlayingItem);
|
||||||
|
},
|
||||||
|
refetchInterval: refetchInterval,
|
||||||
|
//enabled: !!user || !!user.Policy?.IsAdministrator,
|
||||||
|
//cacheTime: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
return { sessions: data, isLoading };
|
||||||
|
};
|
||||||
21
i18n.ts
21
i18n.ts
@@ -1,23 +1,44 @@
|
|||||||
import i18n from "i18next";
|
import i18n from "i18next";
|
||||||
import { initReactI18next } from "react-i18next";
|
import { initReactI18next } from "react-i18next";
|
||||||
|
|
||||||
|
import de from "./translations/de.json";
|
||||||
import en from "./translations/en.json";
|
import en from "./translations/en.json";
|
||||||
|
import es from "./translations/es.json";
|
||||||
import fr from "./translations/fr.json";
|
import fr from "./translations/fr.json";
|
||||||
|
import it from "./translations/it.json";
|
||||||
|
import ja from "./translations/ja.json";
|
||||||
|
import nl from "./translations/nl.json";
|
||||||
import sv from "./translations/sv.json";
|
import sv from "./translations/sv.json";
|
||||||
|
import zhCN from './translations/zh-CN.json';
|
||||||
|
import zhTW from './translations/zh-TW.json';
|
||||||
import { getLocales } from "expo-localization";
|
import { getLocales } from "expo-localization";
|
||||||
|
|
||||||
export const APP_LANGUAGES = [
|
export const APP_LANGUAGES = [
|
||||||
|
{ label: "Deutsch", value: "de" },
|
||||||
{ label: "English", value: "en" },
|
{ label: "English", value: "en" },
|
||||||
|
{ label: "Español", value: "es" },
|
||||||
{ label: "Français", value: "fr" },
|
{ label: "Français", value: "fr" },
|
||||||
|
{ label: "Italiano", value: "it" },
|
||||||
|
{ label: "日本語", value: "ja" },
|
||||||
|
{ label: "Nederlands", value: "nl" },
|
||||||
{ label: "Svenska", value: "sv" },
|
{ label: "Svenska", value: "sv" },
|
||||||
|
{ label: "简体中文", value: "zh-CN" },
|
||||||
|
{ label: "繁體中文", value: "zh-TW" },
|
||||||
];
|
];
|
||||||
|
|
||||||
i18n.use(initReactI18next).init({
|
i18n.use(initReactI18next).init({
|
||||||
compatibilityJSON: "v4",
|
compatibilityJSON: "v4",
|
||||||
resources: {
|
resources: {
|
||||||
|
de: { translation: de },
|
||||||
en: { translation: en },
|
en: { translation: en },
|
||||||
|
es: { translation: es },
|
||||||
fr: { translation: fr },
|
fr: { translation: fr },
|
||||||
|
it: { translation: it },
|
||||||
|
ja: { translation: ja },
|
||||||
|
nl: { translation: nl },
|
||||||
sv: { translation: sv },
|
sv: { translation: sv },
|
||||||
|
"zh-CN": { translation: zhCN },
|
||||||
|
"zh-TW": { translation: zhTW },
|
||||||
},
|
},
|
||||||
|
|
||||||
lng: getLocales()[0].languageCode || "en",
|
lng: getLocales()[0].languageCode || "en",
|
||||||
|
|||||||
6
login.yaml
Normal file
6
login.yaml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# login.yaml
|
||||||
|
|
||||||
|
appId: your.app.id
|
||||||
|
---
|
||||||
|
- launchApp
|
||||||
|
- tapOn: "Text on the screen"
|
||||||
14
metro.config.js
Normal file
14
metro.config.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
const { getDefaultConfig } = require("expo/metro-config");
|
||||||
|
|
||||||
|
const config = getDefaultConfig(__dirname);
|
||||||
|
|
||||||
|
if (process.env?.EXPO_TV === "1") {
|
||||||
|
const originalSourceExts = config.resolver.sourceExts;
|
||||||
|
const tvSourceExts = [
|
||||||
|
...originalSourceExts.map((e) => `tv.${e}`),
|
||||||
|
...originalSourceExts,
|
||||||
|
];
|
||||||
|
config.resolver.sourceExts = tvSourceExts;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = config;
|
||||||
@@ -1,12 +1,17 @@
|
|||||||
apply plugin: 'com.android.library'
|
plugins {
|
||||||
apply plugin: 'kotlin-android'
|
id 'com.android.library'
|
||||||
apply plugin: 'kotlin-kapt'
|
id 'kotlin-android'
|
||||||
|
id 'kotlin-kapt'
|
||||||
|
}
|
||||||
|
|
||||||
group = 'expo.modules.vlcplayer'
|
group = 'expo.modules.vlcplayer'
|
||||||
version = '0.6.0'
|
version = '0.6.0'
|
||||||
|
|
||||||
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
|
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
|
||||||
|
def kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.25'
|
||||||
|
|
||||||
apply from: expoModulesCorePlugin
|
apply from: expoModulesCorePlugin
|
||||||
|
|
||||||
applyKotlinExpoModulesCorePlugin()
|
applyKotlinExpoModulesCorePlugin()
|
||||||
useCoreDependencies()
|
useCoreDependencies()
|
||||||
useExpoPublishing()
|
useExpoPublishing()
|
||||||
@@ -37,8 +42,8 @@ if (useManagedAndroidSdkVersions) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation 'org.videolan.android:libvlc-all:3.6.0-eap12'
|
implementation 'org.videolan.android:libvlc-all:3.6.0'
|
||||||
implementation "org.jetbrains.kotlin:kotlin-stdlib:1.5.31"
|
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package expo.modules.vlcplayer
|
||||||
|
|
||||||
|
import expo.modules.core.interfaces.ReactActivityLifecycleListener
|
||||||
|
|
||||||
|
// TODO: Creating a separate package class and adding this as a lifecycle listener did not work...
|
||||||
|
// https://docs.expo.dev/modules/android-lifecycle-listeners/
|
||||||
|
object VLCManager: ReactActivityLifecycleListener {
|
||||||
|
val listeners: MutableList<ReactActivityLifecycleListener> = mutableListOf()
|
||||||
|
// override fun onCreate(activity: Activity?, savedInstanceState: Bundle?) {
|
||||||
|
// listeners.forEach {
|
||||||
|
// it.onCreate(activity, savedInstanceState)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// override fun onResume(activity: Activity?) {
|
||||||
|
// listeners.forEach {
|
||||||
|
// it.onResume(activity)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// override fun onPause(activity: Activity?) {
|
||||||
|
// listeners.forEach {
|
||||||
|
// it.onPause(activity)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// override fun onUserLeaveHint(activity: Activity?) {
|
||||||
|
// listeners.forEach {
|
||||||
|
// it.onUserLeaveHint(activity)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// override fun onDestroy(activity: Activity?) {
|
||||||
|
// listeners.forEach {
|
||||||
|
// it.onDestroy(activity)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package expo.modules.vlcplayer
|
package expo.modules.vlcplayer
|
||||||
|
|
||||||
|
import androidx.core.os.bundleOf
|
||||||
import expo.modules.kotlin.modules.Module
|
import expo.modules.kotlin.modules.Module
|
||||||
import expo.modules.kotlin.modules.ModuleDefinition
|
import expo.modules.kotlin.modules.ModuleDefinition
|
||||||
|
|
||||||
@@ -7,6 +8,18 @@ class VlcPlayerModule : Module() {
|
|||||||
override fun definition() = ModuleDefinition {
|
override fun definition() = ModuleDefinition {
|
||||||
Name("VlcPlayer")
|
Name("VlcPlayer")
|
||||||
|
|
||||||
|
OnActivityEntersForeground {
|
||||||
|
VLCManager.listeners.forEach {
|
||||||
|
it.onResume(appContext.currentActivity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OnActivityEntersBackground {
|
||||||
|
VLCManager.listeners.forEach {
|
||||||
|
it.onPause(appContext.currentActivity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
View(VlcPlayerView::class) {
|
View(VlcPlayerView::class) {
|
||||||
Prop("source") { view: VlcPlayerView, source: Map<String, Any> ->
|
Prop("source") { view: VlcPlayerView, source: Map<String, Any> ->
|
||||||
view.setSource(source)
|
view.setSource(source)
|
||||||
@@ -26,9 +39,14 @@ class VlcPlayerModule : Module() {
|
|||||||
"onVideoLoadStart",
|
"onVideoLoadStart",
|
||||||
"onVideoLoadEnd",
|
"onVideoLoadEnd",
|
||||||
"onVideoProgress",
|
"onVideoProgress",
|
||||||
"onVideoError"
|
"onVideoError",
|
||||||
|
"onPipStarted"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
AsyncFunction("startPictureInPicture") { view: VlcPlayerView ->
|
||||||
|
view.startPictureInPicture()
|
||||||
|
}
|
||||||
|
|
||||||
AsyncFunction("play") { view: VlcPlayerView ->
|
AsyncFunction("play") { view: VlcPlayerView ->
|
||||||
view.play()
|
view.play()
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user