mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-18 00:58:03 +00:00
Compare commits
1 Commits
chromecast
...
pr-descrip
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
620a3224fc |
5
.github/pull_request_template.md
vendored
5
.github/pull_request_template.md
vendored
@@ -10,10 +10,13 @@
|
||||
## 🔖 Summary
|
||||
<!--
|
||||
A concise description of the changes introduced by this PR.
|
||||
CodeRabbit will auto-generate a summary here if enabled.
|
||||
Example:
|
||||
“Add real-time currency conversion widget to dashboard.”
|
||||
"Add real-time currency conversion widget to dashboard."
|
||||
-->
|
||||
|
||||
@coderabbitai summary
|
||||
|
||||
## 🏷️ Ticket / Issue
|
||||
<!--
|
||||
Link to the related ticket, issue or user story.
|
||||
|
||||
39
.github/workflows/main.yml
vendored
39
.github/workflows/main.yml
vendored
@@ -1,39 +0,0 @@
|
||||
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"
|
||||
83
.gitignore
vendored
83
.gitignore
vendored
@@ -1,30 +1,16 @@
|
||||
# Dependencies and Package Managers
|
||||
node_modules/
|
||||
.expo/
|
||||
dist/
|
||||
npm-debug.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
*.orig.*
|
||||
web-build/
|
||||
modules/vlc-player/android/build
|
||||
bun.lock
|
||||
bun.lockb
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
expo-env.d.ts
|
||||
|
||||
Streamyfin.app
|
||||
|
||||
build-*
|
||||
*.mp4
|
||||
build-*
|
||||
Streamyfin.app
|
||||
package-lock.json
|
||||
|
||||
# Expo and React Native Build Artifacts
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
.tsbuildinfo
|
||||
|
||||
# Platform-specific Build Directories
|
||||
/ios
|
||||
/android
|
||||
/iostv
|
||||
@@ -32,15 +18,58 @@ package-lock.json
|
||||
/androidmobile
|
||||
/androidtv
|
||||
|
||||
# Module-specific Builds
|
||||
modules/mpv-player/android/build
|
||||
modules/player/android
|
||||
modules/hls-downloader/android/build
|
||||
|
||||
pc-api-7079014811501811218-719-3b9f15aeccf8.json
|
||||
credentials.json
|
||||
# Generated Applications
|
||||
Streamyfin.app
|
||||
*.apk
|
||||
*.ipa
|
||||
.continuerc.json
|
||||
*.aab
|
||||
|
||||
# Certificates and Keys
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
|
||||
# Debug and Temporary Files
|
||||
npm-debug.*
|
||||
*.orig.*
|
||||
*.mp4
|
||||
|
||||
# OS-specific Files
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# IDE and Editor Files
|
||||
.vscode/
|
||||
.idea/
|
||||
.ruby-lsp
|
||||
modules/hls-downloader/android/build
|
||||
.cursor/
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
|
||||
# Environment and Configuration
|
||||
expo-env.d.ts
|
||||
.continuerc.json
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Secrets and Credentials
|
||||
pc-api-7079014811501811218-719-3b9f15aeccf8.json
|
||||
credentials.json
|
||||
streamyfin-4fec1-firebase-adminsdk.json
|
||||
|
||||
# Version and Backup Files
|
||||
/version-backup-*
|
||||
/modules/sf-player/android/build
|
||||
/modules/music-controls/android/build
|
||||
modules/background-downloader/android/build/*
|
||||
/modules/mpv-player/android/build
|
||||
|
||||
# ios:unsigned-build Artifacts
|
||||
build/
|
||||
359
README.md
359
README.md
@@ -1,99 +1,256 @@
|
||||
# 📺 Streamyfin
|
||||
|
||||
<a href="https://www.buymeacoffee.com/fredrikbur3" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a>
|
||||
|
||||
Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Expo. If you're looking for an alternative to other Jellyfin clients, we hope you'll find Streamyfin to be a useful addition to your media streaming toolbox.
|
||||
|
||||
<div style="display: flex; flex-direction: row; gap: 8px">
|
||||
<img width=150 src="./assets/images/screenshots/screenshot1.png" />
|
||||
<img width=150 src="./assets/images/screenshots/screenshot3.png" />
|
||||
<img width=150 src="./assets/images/screenshots/screenshot2.png" />
|
||||
<img width=159 src="./assets/images/jellyseerr.PNG"/>
|
||||
</div>
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/streamyfin/.github/refs/heads/main/streamyfin-github-banner.png" alt="Streamyfin" width="100%">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://discord.gg/aJvAYeycyY">
|
||||
<img alt="Streamyfin Discord" src="https://img.shields.io/badge/Discord-Streamyfin-blue?style=flat-square&logo=discord">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
**Streamyfin is a user-friendly Jellyfin video streaming client built with Expo. Designed as an alternative to other Jellyfin clients, it aims to offer a smooth and reliable streaming experience. We hope you'll find it a valuable addition to your media streaming toolbox.**
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<img src="./assets/images/screenshots/screenshot1.png" width="20%">
|
||||
|
||||
<img src="./assets/images/screenshots/screenshot3.png" width="20%">
|
||||
|
||||
<img src="./assets/images/screenshots/screenshot2.png" width="20%">
|
||||
|
||||
<img src="./assets/images/jellyseerr.PNG" width="21%">
|
||||
</p>
|
||||
|
||||
|
||||
## 🌟 Features
|
||||
|
||||
- 🚀 **Skip Intro / Credits Support**
|
||||
- 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking.
|
||||
- 🔊 **Background audio**: Stream music in the background, even when locking the phone.
|
||||
- 📥 **Download media** (Experimental): Save your media locally and watch it offline.
|
||||
- 📡 **Chromecast** (Experimental): Cast your media to any Chromecast-enabled device.
|
||||
- 📡 **Settings management** (Experimental): Manage app settings for all your users with a JF plugin.
|
||||
- 🤖 **Jellyseerr integration**: Request media directly in the app.
|
||||
- 🚀 **Skip Intro / Credits Support**: Lets you quickly skip intros and credits during playback
|
||||
- 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking
|
||||
- 📥 **Download media**: Save your media locally and watch it offline
|
||||
- ⚙️ **Settings management**: Manage app configurations for all users through our plugin
|
||||
- 🤖 **Seerr (formerly Jellyseerr) integration**: Request media directly in the app
|
||||
- 👁️ **Sessions view:** View all active sessions currently streaming on your server
|
||||
- 📡 **Chromecast**: Cast your media to any Chromecast-enabled device
|
||||
|
||||
## 🧪 Experimental Features
|
||||
|
||||
Streamyfin includes some exciting experimental features like media downloading and Chromecast support. These are still in development, and we appreciate your patience and feedback as we work to improve them.
|
||||
Streamyfin offers exciting experimental features such as media downloading and Chromecast support. These features are under active development, and your feedback and patience help us make them even better.
|
||||
|
||||
### Downloading
|
||||
### 📥 Downloading
|
||||
|
||||
Downloading works by using ffmpeg to convert an HLS stream into a video file on the device. This means that you can download and view any file you can stream! The file is converted by Jellyfin on the server in real time as it is downloaded. This means a **bit longer download times** but supports any file that your server can transcode.
|
||||
Downloading works by using FFmpeg to convert an HLS stream into a video file on your device. This lets you download and watch any content that you can stream. The conversion is handled in real time by Jellyfin on the server during the download. While this may take a bit longer, it ensures compatibility with any file your server can transcode.
|
||||
|
||||
### Chromecast
|
||||
### 🧩 Streamyfin Plugin
|
||||
|
||||
Chromecast support is still in development, and we're working on improving it. Currently, it supports casting videos and audio, but we're working on adding support for subtitles and other features.
|
||||
The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that holds all settings for the client Streamyfin. This allows you to synchronize settings across all your users, like for example:
|
||||
|
||||
### Streamyfin Plugin
|
||||
|
||||
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
|
||||
- Choose the default languages
|
||||
- Set download method and search provider
|
||||
- Customize homescreen
|
||||
- And more...
|
||||
- Automatic Seerr login with no user input required
|
||||
- Set your preferred default languages
|
||||
- Configure download method and search provider
|
||||
- Personalize your home screen
|
||||
- And much more
|
||||
|
||||
[Streamyfin Plugin](https://github.com/streamyfin/jellyfin-plugin-streamyfin)
|
||||
|
||||
### Jellysearch
|
||||
### 📡 Chromecast
|
||||
|
||||
[Jellysearch](https://gitlab.com/DomiStyle/jellysearch) now works with Streamyfin! 🚀
|
||||
Chromecast support is currently under development. Video casting is already available, and we're actively working on adding subtitle support and additional features.
|
||||
|
||||
### 🎬 MPV Player
|
||||
|
||||
Streamyfin uses [MPV](https://mpv.io/) as its primary video player on all platforms, powered by [MPVKit](https://github.com/mpvkit/MPVKit). MPV is a powerful, open-source media player known for its wide format support and high-quality playback.
|
||||
Thanks to [@Alexk2309](https://github.com/Alexk2309) for the hard work building the native MPV module in Streamyfin.
|
||||
|
||||
### 🔍 Jellysearch
|
||||
|
||||
[Jellysearch](https://gitlab.com/DomiStyle/jellysearch) works with Streamyfin
|
||||
|
||||
> A fast full-text search proxy for Jellyfin. Integrates seamlessly with most Jellyfin clients.
|
||||
|
||||
## Roadmap for V1
|
||||
## 🛣️ Roadmap
|
||||
|
||||
Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) to see what we're working on next. We are always open for feedback and suggestions, so please let us know if you have any ideas or feature requests.
|
||||
Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) To see what we're working on next, we are always open to feedback and suggestions. Please let us know if you have any ideas or feature requests.
|
||||
|
||||
## Get it now
|
||||
## 📥 Download Streamyfin
|
||||
|
||||
<div style="display: flex; gap: 5px;">
|
||||
<a href="https://apps.apple.com/app/streamyfin/id6593660679?l=en-GB"><img height=50 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/></a>
|
||||
<a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin"><img height=50 alt="Get the beta on Google Play" src="./assets/Google_Play_Store_badge_EN.svg"/></a>
|
||||
<a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin"><img height=50 alt="Get Streamyfin on Google Play Store" src="./assets/Google_Play_Store_badge_EN.svg"/></a>
|
||||
<a href="https://github.com/streamyfin/streamyfin/releases/latest"><img height=50 alt="Get Streamyfin on Github" src="./assets/Download_on_Github_.png"/></a>
|
||||
<a href="https://apps.obtainium.imranr.dev/redirect.html?r=obtainium://add/https://github.com/streamyfin/streamyfin"><img height=50 alt="Add Streamyfin to Obtainium" src="./assets/Download_with_Obtainium.png"/></a>
|
||||
</div>
|
||||
|
||||
Or download the APKs [here on GitHub](https://github.com/streamyfin/streamyfin/releases) for Android.
|
||||
### 🧪 Beta Testing
|
||||
|
||||
### Beta testing
|
||||
To access the Streamyfin beta, you need to subscribe to the Member tier (or higher) on [Patreon](https://www.patreon.com/streamyfin). This grants you immediate access to the 🧪-beta-releases channel on Discord and lets me know you’ve subscribed. This is where I share APKs and IPAs. It does not provide automatic TestFlight access, so please send me a DM (Cagemaster) with the email you use for Apple so we can add you manually.
|
||||
|
||||
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**: Anyone actively contributing to Streamyfin’s source code will receive automatic access to beta releases.
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
### Prerequisites
|
||||
### ⚙️ Prerequisites
|
||||
|
||||
- Ensure you have an active Jellyfin server.
|
||||
- Make sure your device is connected to the same network as your Jellyfin server.
|
||||
- Your device is on the same network as the Jellyfin server (for local connections)
|
||||
- Your Jellyfin server is up and running with remote access enabled if you plan to connect from outside your local network
|
||||
- Your server version is up to date (older versions may cause compatibility issues)
|
||||
- You have a valid Jellyfin user account with access to the media libraries you want to view
|
||||
- If using features such as **downloads** or **Seerr integration**, confirm the required plugins are installed and configured on your Jellyfin server
|
||||
|
||||
## 🙌 Contributing
|
||||
|
||||
We welcome any help to make Streamyfin better. If you'd like to contribute, please fork the repository and submit a pull request. For major changes, it's best to open an issue first to discuss your ideas.
|
||||
We welcome contributions that improve Streamyfin. Start by forking the repository and submitting a pull request. For major changes or new features, please open an issue first to discuss your ideas and ensure alignment with the project.
|
||||
|
||||
### Development info
|
||||
## 🌍 Translations
|
||||
|
||||
[](https://crowdin.com/project/streamyfin)
|
||||
|
||||
Streamyfin is available in multiple languages, and we’re always looking for contributors to help make the app accessible worldwide.
|
||||
You can contribute translations directly on our [Crowdin project page](https://crowdin.com/project/streamyfin).
|
||||
|
||||
### 👨💻 Development Info
|
||||
|
||||
1. Use node `>20`
|
||||
2. Install dependencies `bun i && bun run submodule-reload`
|
||||
3. Make sure you have xcode and/or android studio installed. (follow the guides for expo: https://docs.expo.dev/workflow/android-studio-emulator/)
|
||||
- If iOS builds fail with `missing Metal Toolchain` (KSPlayer shaders), run `npm run ios:install-metal-toolchain` once
|
||||
4. Install BiomeJS extension in VSCode/Your IDE (https://biomejs.dev/)
|
||||
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.
|
||||
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`
|
||||
|
||||
## 👋 Get in Touch with Us
|
||||
|
||||
Need assistance or have any questions?
|
||||
|
||||
- **Discord:** [Join our server](https://discord.gg/BuGG9ZNhaE)
|
||||
- **GitHub Issues:** [Report bugs or request features](https://github.com/streamyfin/streamyfin/issues)
|
||||
- **Email:** [developer@streamyfin.app](mailto:developer@streamyfin.app)
|
||||
|
||||
|
||||
## ❓ FAQ
|
||||
|
||||
1. Q: Why can't I see my libraries in Streamyfin?
|
||||
A: Make sure your server is running one of the latest versions and that you have at least one library that isn't audio only
|
||||
2. Q: Why can't I see my music library?
|
||||
A: We don't currently support music and are unlikely to support music in the near future
|
||||
|
||||
## 📝 Credits
|
||||
|
||||
Streamyfin is developed by [Fredrik Burmester](https://github.com/fredrikburmester) and is not affiliated with Jellyfin. The app is built using Expo, React Native, and other open-source libraries.
|
||||
|
||||
## 🎖️ Core Developers
|
||||
|
||||
Thanks to the following contributors for their significant contributions:
|
||||
|
||||
<div align="left">
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="https://github.com/Alexk2309">
|
||||
<img src="https://github.com/Alexk2309.png?size=55" width="55" 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=55" width="55" 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=55" width="55" 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=55" width="55" 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=55" width="55" style="border-radius: 50%;" />
|
||||
<br /><sub><b>@topiga</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/lancechant">
|
||||
<img src="https://github.com/lancechant.png?size=55" width="55" style="border-radius: 50%;" />
|
||||
<br /><sub><b>@lancechant</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="https://github.com/simoncaron">
|
||||
<img src="https://github.com/simoncaron.png?size=55" width="55" 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=55" width="55" 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=55" width="55" 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=55" width="55" 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=55" width="55" style="border-radius: 50%;" />
|
||||
<br /><sub><b>@whoopsi-daisy</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/Gauvino">
|
||||
<img src="https://github.com/Gauvino.png?size=55" width="55" style="border-radius: 50%;" />
|
||||
<br /><sub><b>@Gauvino</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
## ✨ Acknowledgements
|
||||
|
||||
We would like to thank the Jellyfin team for their excellent software and support on Discord.
|
||||
|
||||
Special thanks to the official Jellyfin clients, which have served as an inspiration for Streamyfin.
|
||||
|
||||
We also thank all other developers who have contributed to Streamyfin, your efforts are greatly appreciated.
|
||||
|
||||
A special mention to the following people and projects for their contributions:
|
||||
|
||||
- [@Alexk2309](https://github.com/Alexk2309) for building the native MPV module that integrates [MPVKit](https://github.com/mpvkit/MPVKit) with React Native
|
||||
- [Reiverr](https://github.com/aleksilassila/reiverr) for invaluable help with understanding the Jellyfin API
|
||||
- [Jellyfin TS SDK](https://github.com/jellyfin/jellyfin-sdk-typescript) for providing the TypeScript SDK
|
||||
- [Seerr](https://github.com/seerr-team/seerr) for enabling API integration with their project
|
||||
|
||||
|
||||
## ⭐ Star History
|
||||
|
||||
[](https://star-history.com/#streamyfin/streamyfin&Date)
|
||||
|
||||
## 📄 License
|
||||
|
||||
Streamyfin is licensed under the Mozilla Public License 2.0 (MPL-2.0).
|
||||
@@ -106,108 +263,10 @@ Key points of the MPL-2.0:
|
||||
- You must disclose your source code for any modifications to the covered files
|
||||
- Larger works may combine MPL code with code under other licenses
|
||||
- MPL-licensed components must remain under the MPL, but the larger work can be under a different license
|
||||
- For the full text of the license, please see the LICENSE file in this repository.
|
||||
- For the full text of the license, please see the LICENSE file in this repository
|
||||
|
||||
## 🌐 Connect with Us
|
||||
## ⚠️ Disclaimer
|
||||
Streamyfin does not promote, support, or condone piracy in any form. The app is intended solely for streaming media that you personally own and control. It does not provide or include any media content. Any discussions, support requests, or references to piracy, as well as any tools, software, or websites related to piracy, are strictly prohibited across all our channels.
|
||||
|
||||
Join our Discord: [https://discord.gg/aJvAYeycyY](https://discord.gg/aJvAYeycyY)
|
||||
|
||||
If you have questions or need support, feel free to reach out:
|
||||
|
||||
- GitHub Issues: Report bugs or request features here.
|
||||
- Email: [fredrik.burmester@gmail.com](mailto:fredrik.burmester@gmail.com)
|
||||
|
||||
## 📝 Credits
|
||||
|
||||
Streamyfin is developed by [Fredrik Burmester](https://github.com/fredrikburmester) and is not affiliated with Jellyfin. The app is built with Expo, React Native, and other open-source libraries.
|
||||
|
||||
## ✨ Acknowledgements
|
||||
|
||||
### 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.
|
||||
- [Jellyfin TS SDK](https://github.com/jellyfin/jellyfin-sdk-typescript) for the TypeScript SDK.
|
||||
- [Jellyseerr](https://github.com/Fallenbagel/jellyseerr) for enabling API integration with their project.
|
||||
- The Jellyfin devs for always being helpful in the Discord.
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#streamyfin/streamyfin&Date)
|
||||
## 🤝 Sponsorship
|
||||
VPS hosting generously provided by [Hexabyte](https://hexabyte.se/en/vps/?currency=eur) and [SweHosting](https://swehosting.se/en/#tj%C3%A4nster)
|
||||
|
||||
80
app.json
80
app.json
@@ -2,12 +2,13 @@
|
||||
"expo": {
|
||||
"name": "Streamyfin",
|
||||
"slug": "streamyfin",
|
||||
"version": "0.27.0",
|
||||
"version": "0.52.0",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "streamyfin",
|
||||
"userInterfaceStyle": "dark",
|
||||
"jsEngine": "hermes",
|
||||
"newArchEnabled": true,
|
||||
"assetBundlePatterns": ["**/*"],
|
||||
"ios": {
|
||||
"requireFullScreen": true,
|
||||
@@ -16,6 +17,7 @@
|
||||
"NSMicrophoneUsageDescription": "The app needs access to your microphone.",
|
||||
"UIBackgroundModes": ["audio", "fetch"],
|
||||
"NSLocalNetworkUsageDescription": "The app needs access to your local network to connect to your Jellyfin server.",
|
||||
"NSLocationWhenInUseUsageDescription": "Streamyfin uses your location to detect your home WiFi network for automatic local server switching.",
|
||||
"NSAppTransportSecurity": {
|
||||
"NSAllowsArbitraryLoads": true
|
||||
},
|
||||
@@ -27,39 +29,36 @@
|
||||
"usesNonExemptEncryption": false
|
||||
},
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.fredrikburmester.streamyfin"
|
||||
"entitlements": {
|
||||
"com.apple.developer.networking.wifi-info": true
|
||||
},
|
||||
"bundleIdentifier": "com.fredrikburmester.streamyfin",
|
||||
"icon": "./assets/images/icon-ios-liquid-glass.icon",
|
||||
"appleTeamId": "MWD5K362T8"
|
||||
},
|
||||
"android": {
|
||||
"jsEngine": "hermes",
|
||||
"versionCode": 53,
|
||||
"versionCode": 92,
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/adaptive_icon.png"
|
||||
"foregroundImage": "./assets/images/icon-android-plain.png",
|
||||
"monochromeImage": "./assets/images/icon-android-themed.png",
|
||||
"backgroundColor": "#2E2E2E"
|
||||
},
|
||||
"package": "com.fredrikburmester.streamyfin",
|
||||
"permissions": [
|
||||
"android.permission.FOREGROUND_SERVICE",
|
||||
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
|
||||
"android.permission.WRITE_SETTINGS"
|
||||
]
|
||||
"android.permission.WRITE_SETTINGS",
|
||||
"android.permission.ACCESS_FINE_LOCATION"
|
||||
],
|
||||
"blockedPermissions": ["android.permission.ACTIVITY_RECOGNITION"],
|
||||
"googleServicesFile": "./google-services.json"
|
||||
},
|
||||
"plugins": [
|
||||
"@react-native-tvos/config-tv",
|
||||
"expo-router",
|
||||
"expo-font",
|
||||
"@config-plugins/ffmpeg-kit-react-native",
|
||||
[
|
||||
"react-native-video",
|
||||
{
|
||||
"enableNotificationControls": true,
|
||||
"enableBackgroundAudio": true,
|
||||
"androidExtensions": {
|
||||
"useExoplayerRtsp": false,
|
||||
"useExoplayerSmoothStreaming": false,
|
||||
"useExoplayerHls": true,
|
||||
"useExoplayerDash": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"./plugins/withExcludeMedia3Dash.js",
|
||||
[
|
||||
"expo-build-properties",
|
||||
{
|
||||
@@ -68,11 +67,12 @@
|
||||
"useFrameworks": "static"
|
||||
},
|
||||
"android": {
|
||||
"compileSdkVersion": 35,
|
||||
"buildArchs": ["arm64-v8a", "x86_64"],
|
||||
"compileSdkVersion": 36,
|
||||
"targetSdkVersion": 35,
|
||||
"buildToolsVersion": "35.0.0",
|
||||
"kotlinVersion": "2.0.21",
|
||||
"minSdkVersion": 24,
|
||||
"minSdkVersion": 26,
|
||||
"usesCleartextTraffic": true,
|
||||
"packagingOptions": {
|
||||
"jniLibs": {
|
||||
@@ -90,12 +90,6 @@
|
||||
"initialOrientation": "DEFAULT"
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-sensors",
|
||||
{
|
||||
"motionPermission": "Allow Streamyfin to access your device motion for landscape video watching."
|
||||
}
|
||||
],
|
||||
"expo-localization",
|
||||
"expo-asset",
|
||||
[
|
||||
@@ -106,17 +100,32 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
["react-native-bottom-tabs"],
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
"backgroundColor": "#010101",
|
||||
"image": "./assets/images/icon-ios-plain.png",
|
||||
"imageWidth": 100
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-notifications",
|
||||
{
|
||||
"icon": "./assets/images/notification.png",
|
||||
"color": "#9333EA"
|
||||
}
|
||||
],
|
||||
"expo-web-browser",
|
||||
["./plugins/with-runtime-framework-headers.js"],
|
||||
["./plugins/withChangeNativeAndroidTextToWhite.js"],
|
||||
["./plugins/withAndroidManifest.js"],
|
||||
["./plugins/withTrustLocalCerts.js"],
|
||||
["./plugins/withGradleProperties.js"],
|
||||
[
|
||||
"expo-splash-screen",
|
||||
"./plugins/withGitPod.js",
|
||||
{
|
||||
"backgroundColor": "#2e2e2e",
|
||||
"image": "./assets/images/StreamyFinFinal.png",
|
||||
"imageWidth": 100
|
||||
"podName": "MPVKit-GPL",
|
||||
"podspecUrl": "https://raw.githubusercontent.com/streamyfin/MPVKit/0.40.0-av/MPVKit-GPL.podspec"
|
||||
}
|
||||
]
|
||||
],
|
||||
@@ -131,13 +140,12 @@
|
||||
"projectId": "e79219d1-797f-4fbe-9fa1-cfd360690a68"
|
||||
}
|
||||
},
|
||||
"owner": "fredrikburmester",
|
||||
"owner": "streamyfin",
|
||||
"runtimeVersion": {
|
||||
"policy": "appVersion"
|
||||
},
|
||||
"updates": {
|
||||
"url": "https://u.expo.dev/e79219d1-797f-4fbe-9fa1-cfd360690a68"
|
||||
},
|
||||
"newArchEnabled": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,40 +1,38 @@
|
||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||
import { Ionicons, Feather } from "@expo/vector-icons";
|
||||
import { Stack, useRouter } from "expo-router";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||
import { Stack } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
const Chromecast = !Platform.isTV ? require("@/components/Chromecast") : null;
|
||||
import { Platform, View } from "react-native";
|
||||
import { Pressable } from "react-native-gesture-handler";
|
||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
|
||||
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
|
||||
|
||||
import { useAtom } from "jotai";
|
||||
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
|
||||
import { userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSessions, useSessionsProps } from "@/hooks/useSessions";
|
||||
|
||||
export default function IndexLayout() {
|
||||
const router = useRouter();
|
||||
const _router = useRouter();
|
||||
const [user] = useAtom(userAtom);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
name="index"
|
||||
name='index'
|
||||
options={{
|
||||
headerShown: true,
|
||||
headerLargeTitle: true,
|
||||
headerShown: !Platform.isTV,
|
||||
headerTitle: t("tabs.home"),
|
||||
headerBlurEffect: "prominent",
|
||||
headerLargeStyle: {
|
||||
backgroundColor: "black",
|
||||
},
|
||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerRight: () => (
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
<View className='flex flex-row items-center px-2'>
|
||||
{!Platform.isTV && (
|
||||
<>
|
||||
<Chromecast.Chromecast />
|
||||
{user && user.Policy?.IsAdministrator && (
|
||||
<SessionsButton />
|
||||
)}
|
||||
<Chromecast.Chromecast background='transparent' />
|
||||
{user?.Policy?.IsAdministrator && <SessionsButton />}
|
||||
<SettingsButton />
|
||||
</>
|
||||
)}
|
||||
@@ -43,83 +41,309 @@ export default function IndexLayout() {
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="downloads/index"
|
||||
name='downloads/index'
|
||||
options={{
|
||||
headerShown: true,
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
title: t("home.downloads.downloads_title"),
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="downloads/[seriesId]"
|
||||
options={{
|
||||
title: t("home.downloads.tvseries"),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="sessions/index"
|
||||
name='sessions/index'
|
||||
options={{
|
||||
title: t("home.sessions.title"),
|
||||
headerShown: true,
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="settings"
|
||||
name='settings'
|
||||
options={{
|
||||
title: t("home.settings.settings_title"),
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="settings/optimized-server/page"
|
||||
name='settings/playback-controls/page'
|
||||
options={{
|
||||
title: "",
|
||||
title: t("home.settings.playback_controls.title"),
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="settings/marlin-search/page"
|
||||
name='settings/audio-subtitles/page'
|
||||
options={{
|
||||
title: "",
|
||||
title: t("home.settings.audio_subtitles.title"),
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="settings/dashboard/sessions"
|
||||
name='settings/appearance/page'
|
||||
options={{
|
||||
title: t("home.settings.dashboard.sessions_title"),
|
||||
title: t("home.settings.appearance.title"),
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="settings/jellyseerr/page"
|
||||
name='settings/music/page'
|
||||
options={{
|
||||
title: "",
|
||||
title: t("home.settings.music.title"),
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="settings/hide-libraries/page"
|
||||
name='settings/appearance/hide-libraries/page'
|
||||
options={{
|
||||
title: "",
|
||||
title: t("home.settings.other.hide_libraries"),
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="settings/logs/page"
|
||||
name='settings/plugins/page'
|
||||
options={{
|
||||
title: "",
|
||||
title: t("home.settings.plugins.plugins_title"),
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="intro/page"
|
||||
name='settings/plugins/marlin-search/page'
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
presentation: "modal",
|
||||
title: "Marlin Search",
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='settings/plugins/jellyseerr/page'
|
||||
options={{
|
||||
title: "Jellyseerr",
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='settings/plugins/streamystats/page'
|
||||
options={{
|
||||
title: "Streamystats",
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='settings/plugins/kefinTweaks/page'
|
||||
options={{
|
||||
title: "KefinTweaks",
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='settings/intro/page'
|
||||
options={{
|
||||
title: t("home.settings.intro.title"),
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='settings/logs/page'
|
||||
options={{
|
||||
title: t("home.settings.logs.logs_title"),
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='settings/network/page'
|
||||
options={{
|
||||
title: t("home.settings.network.title"),
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||
<Stack.Screen key={name} name={name} options={options} />
|
||||
))}
|
||||
<Stack.Screen
|
||||
name="collections/[collectionId]"
|
||||
name='collections/[collectionId]'
|
||||
options={{
|
||||
title: "",
|
||||
headerLeft: () => (
|
||||
<Pressable onPress={() => _router.back()} className='pl-0.5'>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
headerShown: true,
|
||||
headerBlurEffect: "prominent",
|
||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
@@ -131,33 +355,32 @@ const SettingsButton = () => {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
router.push("/(auth)/settings");
|
||||
}}
|
||||
>
|
||||
<Feather name="settings" color={"white"} size={22} />
|
||||
</TouchableOpacity>
|
||||
<Feather name='settings' color={"white"} size={22} />
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
const SessionsButton = () => {
|
||||
const router = useRouter();
|
||||
const { sessions = [], _ } = useSessions({} as useSessionsProps);
|
||||
const { sessions = [] } = useSessions({} as useSessionsProps);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
router.push("/(auth)/sessions");
|
||||
}}
|
||||
className='mr-4'
|
||||
>
|
||||
<View className="mr-4">
|
||||
<Ionicons
|
||||
name="play-circle"
|
||||
color={sessions.length === 0 ? "white" : "#9333ea"}
|
||||
size={25}
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
<Ionicons
|
||||
name='play-circle'
|
||||
color={sessions.length === 0 ? "white" : "#9333ea"}
|
||||
size={28}
|
||||
/>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import { HomeIndex } from "@/components/settings/HomeIndex";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { Home } from "../../../../components/home/Home";
|
||||
import { HomeWithCarousel } from "../../../../components/home/HomeWithCarousel";
|
||||
|
||||
export default function page() {
|
||||
return <HomeIndex />;
|
||||
}
|
||||
const Index = () => {
|
||||
const { settings } = useSettings();
|
||||
const showLargeHomeCarousel = settings.showLargeHomeCarousel ?? false;
|
||||
|
||||
if (showLargeHomeCarousel) {
|
||||
return <HomeWithCarousel />;
|
||||
}
|
||||
|
||||
return <Home />;
|
||||
};
|
||||
|
||||
export default Index;
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
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, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import { HardwareAccelerationType } from "@jellyfin/sdk/lib/generated-client";
|
||||
import {
|
||||
Ionicons,
|
||||
Entypo,
|
||||
AntDesign,
|
||||
MaterialCommunityIcons,
|
||||
} from "@expo/vector-icons";
|
||||
GeneralCommandType,
|
||||
PlaystateCommand,
|
||||
SessionInfoDto,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { Badge } from "@/components/Badge";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import Poster from "@/components/posters/Poster";
|
||||
import { useInterval } from "@/hooks/useInterval";
|
||||
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { formatBitrate } from "@/utils/bitrate";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { formatTimeString } from "@/utils/time";
|
||||
|
||||
export default function page() {
|
||||
const { sessions, isLoading } = useSessions({} as useSessionsProps);
|
||||
@@ -27,15 +29,15 @@ export default function page() {
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<View className="justify-center items-center h-full">
|
||||
<View className='justify-center items-center h-full'>
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
|
||||
if (!sessions || sessions.length == 0)
|
||||
if (!sessions || sessions.length === 0)
|
||||
return (
|
||||
<View className="h-full w-full flex justify-center items-center">
|
||||
<Text className="text-lg text-neutral-500">
|
||||
<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>
|
||||
@@ -43,16 +45,15 @@ export default function page() {
|
||||
|
||||
return (
|
||||
<FlashList
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentContainerStyle={{
|
||||
paddingTop: 17,
|
||||
paddingTop: Platform.OS === "android" ? 17 : 0,
|
||||
paddingHorizontal: 17,
|
||||
paddingBottom: 150,
|
||||
}}
|
||||
data={sessions}
|
||||
renderItem={({ item }) => <SessionCard session={item} />}
|
||||
keyExtractor={(item) => item.Id || ""}
|
||||
estimatedItemSize={200}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -77,7 +78,7 @@ const SessionCard = ({ session }: SessionCardProps) => {
|
||||
|
||||
return Math.round(
|
||||
(100 / session.NowPlayingItem?.RunTimeTicks) *
|
||||
(session.NowPlayingItem?.RunTimeTicks - remainingTicks)
|
||||
(session.NowPlayingItem?.RunTimeTicks - remainingTicks),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -95,26 +96,112 @@ const SessionCard = ({ session }: SessionCardProps) => {
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
const { data: ipInfo } = useQuery<{
|
||||
cityName?: string;
|
||||
countryCode?: string;
|
||||
}>({
|
||||
queryKey: ["ipinfo", session.RemoteEndPoint],
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
queryFn: async () => {
|
||||
const resp = await api!.axiosInstance.get(
|
||||
`https://freeipapi.com/api/json/${session.RemoteEndPoint}`,
|
||||
);
|
||||
return resp.data;
|
||||
},
|
||||
enabled: !!api,
|
||||
});
|
||||
|
||||
// Handle session controls
|
||||
const [isControlLoading, setIsControlLoading] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
|
||||
const handleSystemCommand = async (command: GeneralCommandType) => {
|
||||
if (!api || !session.Id) return false;
|
||||
|
||||
setIsControlLoading({ ...isControlLoading, [command]: true });
|
||||
|
||||
try {
|
||||
getSessionApi(api).sendSystemCommand({
|
||||
sessionId: session.Id,
|
||||
command,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Error sending ${command} command:`, error);
|
||||
return false;
|
||||
} finally {
|
||||
setIsControlLoading({ ...isControlLoading, [command]: false });
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlaystateCommand = async (command: PlaystateCommand) => {
|
||||
if (!api || !session.Id) return false;
|
||||
|
||||
setIsControlLoading({ ...isControlLoading, [command]: true });
|
||||
|
||||
try {
|
||||
getSessionApi(api).sendPlaystateCommand({
|
||||
sessionId: session.Id,
|
||||
command,
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Error sending playstate ${command} command:`, error);
|
||||
return false;
|
||||
} finally {
|
||||
setIsControlLoading({ ...isControlLoading, [command]: false });
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlayPause = async () => {
|
||||
console.log("handlePlayPause");
|
||||
await handlePlaystateCommand(PlaystateCommand.PlayPause);
|
||||
};
|
||||
|
||||
const handleStop = async () => {
|
||||
await handlePlaystateCommand(PlaystateCommand.Stop);
|
||||
};
|
||||
|
||||
const handlePrevious = async () => {
|
||||
await handlePlaystateCommand(PlaystateCommand.PreviousTrack);
|
||||
};
|
||||
|
||||
const handleNext = async () => {
|
||||
await handlePlaystateCommand(PlaystateCommand.NextTrack);
|
||||
};
|
||||
|
||||
const handleToggleMute = async () => {
|
||||
await handleSystemCommand(GeneralCommandType.ToggleMute);
|
||||
};
|
||||
const handleVolumeUp = async () => {
|
||||
await handleSystemCommand(GeneralCommandType.VolumeUp);
|
||||
};
|
||||
const handleVolumeDown = async () => {
|
||||
await handleSystemCommand(GeneralCommandType.VolumeDown);
|
||||
};
|
||||
|
||||
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">
|
||||
<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">
|
||||
<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">
|
||||
<Text className='font-bold'>
|
||||
{session.NowPlayingItem?.Name}
|
||||
</Text>
|
||||
<Text numberOfLines={1} className="text-xs opacity-50">
|
||||
<Text numberOfLines={1} className='text-xs opacity-50'>
|
||||
{`S${session.NowPlayingItem.ParentIndexNumber?.toString()}:E${session.NowPlayingItem.IndexNumber?.toString()}`}
|
||||
{" - "}
|
||||
{session.NowPlayingItem.SeriesName}
|
||||
@@ -122,48 +209,151 @@ const SessionCard = ({ session }: SessionCardProps) => {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text className="font-bold">
|
||||
<Text className='font-bold'>
|
||||
{session.NowPlayingItem?.Name}
|
||||
</Text>
|
||||
<Text className="text-xs opacity-50">
|
||||
<Text className='text-xs opacity-50'>
|
||||
{session.NowPlayingItem?.ProductionYear}
|
||||
</Text>
|
||||
<Text className="text-xs opacity-50">
|
||||
<Text className='text-xs opacity-50'>
|
||||
{session.NowPlayingItem?.SeriesName}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
<Text className="text-xs opacity-50 align-right text-right">
|
||||
<Text className='text-xs opacity-50 align-right text-right'>
|
||||
{session.UserName}
|
||||
{"\n"}
|
||||
{session.Client}
|
||||
{"\n"}
|
||||
{session.DeviceName}
|
||||
{"\n"}
|
||||
{ipInfo?.cityName} {ipInfo?.countryCode}
|
||||
</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">
|
||||
<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='play' size={14} color='white' />
|
||||
) : (
|
||||
<Ionicons name="pause" size={14} color="white" />
|
||||
<Ionicons name='pause' size={14} color='white' />
|
||||
)}
|
||||
</Text>
|
||||
<Text className="text-xs opacity-50 align-right text-right">
|
||||
<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='align-bottom bg-gray-800 h-1'>
|
||||
<View
|
||||
className={`bg-purple-600 h-full`}
|
||||
className={"bg-purple-600 h-full"}
|
||||
style={{
|
||||
width: `${getProgressPercentage()}%`,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Session controls */}
|
||||
<View className='flex flex-row mt-2 space-x-4 justify-center'>
|
||||
<TouchableOpacity
|
||||
onPress={handlePrevious}
|
||||
disabled={isControlLoading[PlaystateCommand.PreviousTrack]}
|
||||
style={{
|
||||
opacity: isControlLoading[PlaystateCommand.PreviousTrack]
|
||||
? 0.5
|
||||
: 1,
|
||||
}}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name='skip-previous'
|
||||
size={24}
|
||||
color='white'
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={handlePlayPause}
|
||||
disabled={isControlLoading[PlaystateCommand.PlayPause]}
|
||||
style={{
|
||||
opacity: isControlLoading[PlaystateCommand.PlayPause]
|
||||
? 0.5
|
||||
: 1,
|
||||
}}
|
||||
>
|
||||
{session.PlayState?.IsPaused ? (
|
||||
<Ionicons name='play' size={24} color='white' />
|
||||
) : (
|
||||
<Ionicons name='pause' size={24} color='white' />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={handleStop}
|
||||
disabled={isControlLoading[PlaystateCommand.Stop]}
|
||||
style={{
|
||||
opacity: isControlLoading[PlaystateCommand.Stop] ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<Ionicons name='stop' size={24} color='white' />
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={handleNext}
|
||||
disabled={isControlLoading[PlaystateCommand.NextTrack]}
|
||||
style={{
|
||||
opacity: isControlLoading[PlaystateCommand.NextTrack]
|
||||
? 0.5
|
||||
: 1,
|
||||
}}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name='skip-next'
|
||||
size={24}
|
||||
color='white'
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={handleVolumeDown}
|
||||
disabled={isControlLoading[GeneralCommandType.VolumeDown]}
|
||||
style={{
|
||||
opacity: isControlLoading[GeneralCommandType.VolumeDown]
|
||||
? 0.5
|
||||
: 1,
|
||||
}}
|
||||
>
|
||||
<Ionicons name='volume-low' size={24} color='white' />
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={handleToggleMute}
|
||||
disabled={isControlLoading[GeneralCommandType.ToggleMute]}
|
||||
style={{
|
||||
opacity: isControlLoading[GeneralCommandType.ToggleMute]
|
||||
? 0.5
|
||||
: 1,
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name='volume-mute'
|
||||
size={24}
|
||||
color={session.PlayState?.IsMuted ? "red" : "white"}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={handleVolumeUp}
|
||||
disabled={isControlLoading[GeneralCommandType.VolumeUp]}
|
||||
style={{
|
||||
opacity: isControlLoading[GeneralCommandType.VolumeUp]
|
||||
? 0.5
|
||||
: 1,
|
||||
}}
|
||||
>
|
||||
<Ionicons name='volume-high' size={24} color='white' />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
@@ -178,20 +368,21 @@ interface TranscodingBadgesProps {
|
||||
|
||||
const TranscodingBadges = ({ properties }: TranscodingBadgesProps) => {
|
||||
const iconMap = {
|
||||
bitrate: <Ionicons name="speedometer-outline" size={12} color="white" />,
|
||||
codec: <Ionicons name="layers-outline" size={12} color="white" />,
|
||||
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" />
|
||||
<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" />,
|
||||
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' />,
|
||||
hwType: <Ionicons name='hardware-chip-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" />
|
||||
<Ionicons name='layers-outline' size={12} color='white' />
|
||||
)
|
||||
);
|
||||
};
|
||||
@@ -200,6 +391,8 @@ const TranscodingBadges = ({ properties }: TranscodingBadgesProps) => {
|
||||
switch (key) {
|
||||
case "bitrate":
|
||||
return formatBitrate(val);
|
||||
case "hwType":
|
||||
return val === HardwareAccelerationType.None ? "sw" : "hw";
|
||||
default:
|
||||
return val;
|
||||
}
|
||||
@@ -210,8 +403,8 @@ const TranscodingBadges = ({ properties }: TranscodingBadgesProps) => {
|
||||
.map(([key]) => (
|
||||
<Badge
|
||||
key={key}
|
||||
variant="gray"
|
||||
className="m-0 p-0 pt-0.5 mr-1"
|
||||
variant='gray'
|
||||
className='m-0 p-0 pt-0.5 mr-1'
|
||||
text={formatVal(key, properties[key as keyof StreamProps])}
|
||||
iconLeft={icon(key)}
|
||||
/>
|
||||
@@ -219,6 +412,7 @@ const TranscodingBadges = ({ properties }: TranscodingBadgesProps) => {
|
||||
};
|
||||
|
||||
interface StreamProps {
|
||||
hwType?: HardwareAccelerationType | null | undefined;
|
||||
resolution?: string | null | undefined;
|
||||
language?: string | null | undefined;
|
||||
codec?: string | null | undefined;
|
||||
@@ -230,7 +424,7 @@ interface StreamProps {
|
||||
interface TranscodingStreamViewProps {
|
||||
title: string | undefined;
|
||||
value?: string;
|
||||
isTranscoding: Boolean;
|
||||
isTranscoding: boolean;
|
||||
transcodeValue?: string | undefined | null;
|
||||
properties: StreamProps;
|
||||
transcodeProperties?: StreamProps;
|
||||
@@ -241,43 +435,40 @@ const TranscodingStreamView = ({
|
||||
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">
|
||||
<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">
|
||||
<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>
|
||||
</>
|
||||
<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 { t } = useTranslation();
|
||||
const videoStream = useMemo(() => {
|
||||
return session.NowPlayingItem?.MediaStreams?.filter(
|
||||
(s) => s.Type == "Video"
|
||||
(s) => s.Type === "Video",
|
||||
)[0];
|
||||
}, [session]);
|
||||
|
||||
@@ -296,35 +487,36 @@ const TranscodingView = ({ session }: SessionCardProps) => {
|
||||
}, [session.PlayState?.SubtitleStreamIndex]);
|
||||
|
||||
const isTranscoding = useMemo(() => {
|
||||
return session.PlayState?.PlayMethod == "Transcode";
|
||||
}, [session.PlayState?.PlayMethod]);
|
||||
return (
|
||||
session.PlayState?.PlayMethod === "Transcode" && session.TranscodingInfo
|
||||
);
|
||||
}, [session.PlayState?.PlayMethod, session.TranscodingInfo]);
|
||||
|
||||
const videoStreamTitle = () => {
|
||||
return videoStream?.DisplayTitle?.split(" ")[0];
|
||||
};
|
||||
|
||||
return (
|
||||
<View className="flex flex-col bg-neutral-800 rounded-b-2xl p-4 pt-2">
|
||||
<View className='flex flex-col bg-neutral-800 rounded-b-2xl p-4 pt-2'>
|
||||
<TranscodingStreamView
|
||||
title="Video"
|
||||
title={t("common.video")}
|
||||
properties={{
|
||||
resolution: videoStreamTitle(),
|
||||
bitrate: videoStream?.BitRate,
|
||||
codec: videoStream?.Codec,
|
||||
}}
|
||||
transcodeProperties={{
|
||||
hwType: session.TranscodingInfo?.HardwareAccelerationType,
|
||||
bitrate: session.TranscodingInfo?.Bitrate,
|
||||
codec: session.TranscodingInfo?.VideoCodec,
|
||||
}}
|
||||
isTranscoding={
|
||||
isTranscoding && !session.TranscodingInfo?.IsVideoDirect
|
||||
? true
|
||||
: false
|
||||
!!(isTranscoding && !session.TranscodingInfo?.IsVideoDirect)
|
||||
}
|
||||
/>
|
||||
|
||||
<TranscodingStreamView
|
||||
title="Audio"
|
||||
title={t("common.audio")}
|
||||
properties={{
|
||||
language: audioStream?.Language,
|
||||
bitrate: audioStream?.BitRate,
|
||||
@@ -332,29 +524,24 @@ const TranscodingView = ({ session }: SessionCardProps) => {
|
||||
audioChannels: audioStream?.ChannelLayout,
|
||||
}}
|
||||
transcodeProperties={{
|
||||
bitrate: session.TranscodingInfo?.Bitrate,
|
||||
codec: session.TranscodingInfo?.AudioCodec,
|
||||
audioChannels: session.TranscodingInfo?.AudioChannels?.toString(),
|
||||
}}
|
||||
isTranscoding={
|
||||
isTranscoding && !session.TranscodingInfo?.IsVideoDirect
|
||||
? true
|
||||
: false
|
||||
!!(isTranscoding && !session.TranscodingInfo?.IsVideoDirect)
|
||||
}
|
||||
/>
|
||||
|
||||
{subtitleStream && (
|
||||
<>
|
||||
<TranscodingStreamView
|
||||
title="Subtitle"
|
||||
isTranscoding={false}
|
||||
properties={{
|
||||
language: subtitleStream?.Language,
|
||||
codec: subtitleStream?.Codec,
|
||||
}}
|
||||
transcodeValue={null}
|
||||
/>
|
||||
</>
|
||||
<TranscodingStreamView
|
||||
title={t("common.subtitle")}
|
||||
isTranscoding={false}
|
||||
properties={{
|
||||
language: subtitleStream?.Language,
|
||||
codec: subtitleStream?.Codec,
|
||||
}}
|
||||
transcodeValue={null}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1,41 +1,24 @@
|
||||
import { useNavigation } from "expo-router";
|
||||
import { t } from "i18next";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect } from "react";
|
||||
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ListGroup } from "@/components/list/ListGroup";
|
||||
import { ListItem } from "@/components/list/ListItem";
|
||||
import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
|
||||
import { AudioToggles } from "@/components/settings/AudioToggles";
|
||||
import DownloadSettings from "@/components/settings/DownloadSettings";
|
||||
import { MediaProvider } from "@/components/settings/MediaContext";
|
||||
import { MediaToggles } from "@/components/settings/MediaToggles";
|
||||
import { OtherSettings } from "@/components/settings/OtherSettings";
|
||||
import { PluginSettings } from "@/components/settings/PluginSettings";
|
||||
import { QuickConnect } from "@/components/settings/QuickConnect";
|
||||
import { StorageSettings } from "@/components/settings/StorageSettings";
|
||||
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
|
||||
import { UserInfo } from "@/components/settings/UserInfo";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useJellyfin } from "@/providers/JellyfinProvider";
|
||||
import { clearLogs } from "@/utils/log";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { useNavigation, useRouter } from "expo-router";
|
||||
import { t } from "i18next";
|
||||
import React, { useEffect } from "react";
|
||||
import { ScrollView, Switch, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useAtom } from "jotai";
|
||||
import { userAtom } from "@/providers/JellyfinProvider";
|
||||
import { ChromecastSettings } from "@/components/settings/ChromecastSettings";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
export default function settings() {
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [user] = useAtom(userAtom);
|
||||
const [_user] = useAtom(userAtom);
|
||||
const { logout } = useJellyfin();
|
||||
const successHapticFeedback = useHaptic("success");
|
||||
|
||||
const onClearLogsClicked = async () => {
|
||||
clearLogs();
|
||||
successHapticFeedback();
|
||||
};
|
||||
|
||||
const navigation = useNavigation();
|
||||
useEffect(() => {
|
||||
@@ -46,7 +29,7 @@ export default function settings() {
|
||||
logout();
|
||||
}}
|
||||
>
|
||||
<Text className="text-red-600">
|
||||
<Text className='text-red-600 px-2'>
|
||||
{t("home.settings.log_out_button")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
@@ -56,64 +39,72 @@ export default function settings() {
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<View className="p-4 flex flex-col gap-y-4">
|
||||
<UserInfo />
|
||||
<View
|
||||
className='p-4 flex flex-col'
|
||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||
>
|
||||
<View className='mb-4'>
|
||||
<UserInfo />
|
||||
</View>
|
||||
|
||||
<QuickConnect className="mb-4" />
|
||||
<QuickConnect className='mb-4' />
|
||||
|
||||
<MediaProvider>
|
||||
<MediaToggles className="mb-4" />
|
||||
<AudioToggles className="mb-4" />
|
||||
<SubtitleToggles className="mb-4" />
|
||||
</MediaProvider>
|
||||
<View className='mb-4'>
|
||||
<AppLanguageSelector />
|
||||
</View>
|
||||
|
||||
<OtherSettings />
|
||||
|
||||
<DownloadSettings />
|
||||
|
||||
<PluginSettings />
|
||||
|
||||
<AppLanguageSelector />
|
||||
|
||||
<ChromecastSettings />
|
||||
|
||||
<ListGroup title={"Intro"}>
|
||||
<ListItem
|
||||
onPress={() => {
|
||||
router.push("/intro/page");
|
||||
}}
|
||||
title={t("home.settings.intro.show_intro")}
|
||||
/>
|
||||
<ListItem
|
||||
textColor="red"
|
||||
onPress={() => {
|
||||
storage.set("hasShownIntro", false);
|
||||
}}
|
||||
title={t("home.settings.intro.reset_intro")}
|
||||
/>
|
||||
</ListGroup>
|
||||
|
||||
<View className="mb-4">
|
||||
<ListGroup title={t("home.settings.logs.logs_title")}>
|
||||
<View className='mb-4'>
|
||||
<ListGroup title={t("home.settings.categories.title")}>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/playback-controls/page")}
|
||||
showArrow
|
||||
title={t("home.settings.playback_controls.title")}
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/audio-subtitles/page")}
|
||||
showArrow
|
||||
title={t("home.settings.audio_subtitles.title")}
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/music/page")}
|
||||
showArrow
|
||||
title={t("home.settings.music.title")}
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/appearance/page")}
|
||||
showArrow
|
||||
title={t("home.settings.appearance.title")}
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/plugins/page")}
|
||||
showArrow
|
||||
title={t("home.settings.plugins.plugins_title")}
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/intro/page")}
|
||||
showArrow
|
||||
title={t("home.settings.intro.title")}
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/network/page")}
|
||||
showArrow
|
||||
title={t("home.settings.network.title")}
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/logs/page")}
|
||||
showArrow
|
||||
title={t("home.settings.logs.logs_title")}
|
||||
/>
|
||||
<ListItem
|
||||
textColor="red"
|
||||
onPress={onClearLogsClicked}
|
||||
title={t("home.settings.logs.delete_all_logs")}
|
||||
/>
|
||||
</ListGroup>
|
||||
</View>
|
||||
|
||||
<StorageSettings />
|
||||
{!Platform.isTV && <StorageSettings />}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
import {
|
||||
useLocalSearchParams,
|
||||
useSegments,
|
||||
} from "expo-router";
|
||||
import React, { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Image } from "expo-image";
|
||||
import { OverviewText } from "@/components/OverviewText";
|
||||
import {orderBy, uniqBy} from "lodash";
|
||||
import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
|
||||
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
|
||||
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
||||
import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { orderBy, uniqBy } from "lodash";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
|
||||
import { OverviewText } from "@/components/OverviewText";
|
||||
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import type { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
|
||||
|
||||
export default function page() {
|
||||
const local = useLocalSearchParams();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { jellyseerrApi, jellyseerrUser, jellyseerrRegion: region, jellyseerrLocale: locale } = useJellyseerr();
|
||||
const {
|
||||
jellyseerrApi,
|
||||
jellyseerrRegion: region,
|
||||
jellyseerrLocale: locale,
|
||||
} = useJellyseerr();
|
||||
|
||||
const { personId } = local as { personId: string };
|
||||
|
||||
const { data, isLoading, isFetching } = useQuery({
|
||||
const { data } = useQuery({
|
||||
queryKey: ["jellyseerr", "person", personId],
|
||||
queryFn: async () => ({
|
||||
details: await jellyseerrApi?.personDetails(personId),
|
||||
@@ -34,18 +34,27 @@ export default function page() {
|
||||
|
||||
const castedRoles: PersonCreditCast[] = useMemo(
|
||||
() =>
|
||||
uniqBy(orderBy(
|
||||
data?.combinedCredits?.cast,
|
||||
["voteCount", "voteAverage"],
|
||||
"desc"
|
||||
), 'id'),
|
||||
[data?.combinedCredits]
|
||||
uniqBy(
|
||||
orderBy(
|
||||
data?.combinedCredits?.cast,
|
||||
["voteCount", "voteAverage"],
|
||||
"desc",
|
||||
),
|
||||
"id",
|
||||
),
|
||||
[data?.combinedCredits],
|
||||
);
|
||||
const backdrops = useMemo(
|
||||
() => jellyseerrApi
|
||||
? castedRoles.map((c) => jellyseerrApi.imageProxy(c.backdropPath, "w1920_and_h800_multi_faces"))
|
||||
: [],
|
||||
[jellyseerrApi, data?.combinedCredits]
|
||||
() =>
|
||||
jellyseerrApi
|
||||
? castedRoles.map((c) =>
|
||||
jellyseerrApi.imageProxy(
|
||||
c.backdropPath,
|
||||
"w1920_and_h800_multi_faces",
|
||||
),
|
||||
)
|
||||
: [],
|
||||
[jellyseerrApi, data?.combinedCredits],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -58,15 +67,15 @@ export default function page() {
|
||||
<Image
|
||||
key={data?.details?.id}
|
||||
id={data?.details?.id.toString()}
|
||||
className="rounded-full bottom-1"
|
||||
className='rounded-full bottom-1'
|
||||
source={{
|
||||
uri: jellyseerrApi?.imageProxy(
|
||||
data?.details?.profilePath,
|
||||
"w600_and_h600_bestv2"
|
||||
"w600_and_h600_bestv2",
|
||||
),
|
||||
}}
|
||||
cachePolicy={"memory-disk"}
|
||||
contentFit="cover"
|
||||
contentFit='cover'
|
||||
style={{
|
||||
width: 125,
|
||||
height: 125,
|
||||
@@ -75,27 +84,26 @@ export default function page() {
|
||||
}
|
||||
HeaderContent={() => (
|
||||
<>
|
||||
<Text className="font-bold text-2xl mb-1">
|
||||
{data?.details?.name}
|
||||
</Text>
|
||||
<Text className="opacity-50">
|
||||
<Text className='font-bold text-2xl mb-1'>{data?.details?.name}</Text>
|
||||
<Text className='opacity-50'>
|
||||
{t("jellyseerr.born")}{" "}
|
||||
{new Date(data?.details?.birthday!!).toLocaleDateString(
|
||||
`${locale}-${region}`,
|
||||
{
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}
|
||||
)}{" "}
|
||||
{data?.details?.birthday &&
|
||||
new Date(data.details.birthday).toLocaleDateString(
|
||||
`${locale}-${region}`,
|
||||
{
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
},
|
||||
)}{" "}
|
||||
| {data?.details?.placeOfBirth}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
MainContent={() => (
|
||||
<OverviewText text={data?.details?.biography} className="mt-4" />
|
||||
<OverviewText text={data?.details?.biography} className='mt-4' />
|
||||
)}
|
||||
renderItem={(item, index) => <JellyseerrPoster item={item as MovieResult | TvResult} />}
|
||||
renderItem={(item, _index) => <JellyseerrPoster item={item} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,26 +1,22 @@
|
||||
import { Stack } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform } from "react-native";
|
||||
import {
|
||||
commonScreenOptions,
|
||||
nestedTabPageScreenOptions,
|
||||
} from "@/components/stacks/NestedTabPageStack";
|
||||
import { Stack } from "expo-router";
|
||||
import { Platform } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function SearchLayout() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
name="index"
|
||||
name='index'
|
||||
options={{
|
||||
headerShown: true,
|
||||
headerLargeTitle: true,
|
||||
headerShown: !Platform.isTV,
|
||||
headerTitle: t("tabs.search"),
|
||||
headerLargeStyle: {
|
||||
backgroundColor: "black",
|
||||
},
|
||||
headerBlurEffect: "prominent",
|
||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
@@ -28,26 +24,26 @@ export default function SearchLayout() {
|
||||
<Stack.Screen key={name} name={name} options={options} />
|
||||
))}
|
||||
<Stack.Screen
|
||||
name="collections/[collectionId]"
|
||||
name='collections/[collectionId]'
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: true,
|
||||
headerBlurEffect: "prominent",
|
||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||
headerShown: !Platform.isTV,
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name="jellyseerr/page" options={commonScreenOptions} />
|
||||
<Stack.Screen name='jellyseerr/page' options={commonScreenOptions} />
|
||||
<Stack.Screen
|
||||
name="jellyseerr/person/[personId]"
|
||||
name='jellyseerr/person/[personId]'
|
||||
options={commonScreenOptions}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="jellyseerr/company/[companyId]"
|
||||
name='jellyseerr/company/[companyId]'
|
||||
options={commonScreenOptions}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="jellyseerr/genre/[genreId]"
|
||||
name='jellyseerr/genre/[genreId]'
|
||||
options={commonScreenOptions}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
@@ -1,39 +1,48 @@
|
||||
import { Input } from "@/components/common/Input";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||
import { Tag } from "@/components/GenreTags";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import { JellyserrIndexPage } from "@/components/jellyseerr/JellyseerrIndexPage";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import SeriesPoster from "@/components/posters/SeriesPoster";
|
||||
import { LoadingSkeleton } from "@/components/search/LoadingSkeleton";
|
||||
import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import {
|
||||
import type {
|
||||
BaseItemDto,
|
||||
BaseItemKind,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi, getSearchApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useAsyncDebouncer } from "@tanstack/react-pacer";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
import { Href, router, useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, {
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useDebounce } from "use-debounce";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||
import { Input } from "@/components/common/Input";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import {
|
||||
JellyseerrSearchSort,
|
||||
JellyserrIndexPage,
|
||||
} from "@/components/jellyseerr/JellyseerrIndexPage";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import SeriesPoster from "@/components/posters/SeriesPoster";
|
||||
import { DiscoverFilters } from "@/components/search/DiscoverFilters";
|
||||
import { LoadingSkeleton } from "@/components/search/LoadingSkeleton";
|
||||
import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
|
||||
import { SearchTabButtons } from "@/components/search/SearchTabButtons";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { eventBus } from "@/utils/eventBus";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { createStreamystatsApi } from "@/utils/streamystats";
|
||||
|
||||
type SearchType = "Library" | "Discover";
|
||||
|
||||
@@ -49,77 +58,218 @@ const exampleSearches = [
|
||||
export default function search() {
|
||||
const params = useLocalSearchParams();
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const searchFilterId = useId();
|
||||
const orderFilterId = useId();
|
||||
|
||||
const { q } = params as { q: string };
|
||||
|
||||
const [searchType, setSearchType] = useState<SearchType>("Library");
|
||||
const [search, setSearch] = useState<string>("");
|
||||
|
||||
const [debouncedSearch] = useDebounce(search, 500);
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const searchDebouncer = useAsyncDebouncer(
|
||||
async (query: string) => {
|
||||
// Cancel previous in-flight requests
|
||||
abortControllerRef.current?.abort();
|
||||
abortControllerRef.current = new AbortController();
|
||||
setDebouncedSearch(query);
|
||||
return query;
|
||||
},
|
||||
{ wait: 200 },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
searchDebouncer.maybeExecute(search);
|
||||
}, [search]);
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const [settings] = useSettings();
|
||||
const { settings } = useSettings();
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
const [jellyseerrOrderBy, setJellyseerrOrderBy] =
|
||||
useState<JellyseerrSearchSort>(
|
||||
JellyseerrSearchSort[
|
||||
JellyseerrSearchSort.DEFAULT
|
||||
] as unknown as JellyseerrSearchSort,
|
||||
);
|
||||
const [jellyseerrSortOrder, setJellyseerrSortOrder] = useState<
|
||||
"asc" | "desc"
|
||||
>("desc");
|
||||
|
||||
const searchEngine = useMemo(() => {
|
||||
return settings?.searchEngine || "Jellyfin";
|
||||
}, [settings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (q && q.length > 0) setSearch(q);
|
||||
if (q && q.length > 0) {
|
||||
setSearch(q);
|
||||
}
|
||||
}, [q]);
|
||||
|
||||
const searchFn = useCallback(
|
||||
async ({
|
||||
types,
|
||||
query,
|
||||
signal,
|
||||
}: {
|
||||
types: BaseItemKind[];
|
||||
query: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<BaseItemDto[]> => {
|
||||
if (!api || !query) return [];
|
||||
if (!api || !query) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
if (searchEngine === "Jellyfin") {
|
||||
const searchApi = await getSearchApi(api).getSearchHints({
|
||||
const searchApi = await getItemsApi(api).getItems(
|
||||
{
|
||||
searchTerm: query,
|
||||
limit: 10,
|
||||
includeItemTypes: types,
|
||||
recursive: true,
|
||||
userId: user?.Id,
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
|
||||
return (searchApi.data.Items as BaseItemDto[]) || [];
|
||||
}
|
||||
|
||||
if (searchEngine === "Streamystats") {
|
||||
if (!settings?.streamyStatsServerUrl || !api.accessToken) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const streamyStatsApi = createStreamystatsApi({
|
||||
serverUrl: settings.streamyStatsServerUrl,
|
||||
jellyfinToken: api.accessToken,
|
||||
});
|
||||
|
||||
const typeMap: Record<BaseItemKind, string> = {
|
||||
Movie: "movies",
|
||||
Series: "series",
|
||||
Episode: "episodes",
|
||||
Person: "actors",
|
||||
BoxSet: "movies",
|
||||
Audio: "audio",
|
||||
} as Record<BaseItemKind, string>;
|
||||
|
||||
const searchType = types.length === 1 ? typeMap[types[0]] : "media";
|
||||
const response = await streamyStatsApi.searchIds(
|
||||
query,
|
||||
searchType as "movies" | "series" | "episodes" | "actors" | "media",
|
||||
10,
|
||||
signal,
|
||||
);
|
||||
|
||||
const allIds: string[] = [
|
||||
...(response.data.movies || []),
|
||||
...(response.data.series || []),
|
||||
...(response.data.episodes || []),
|
||||
...(response.data.actors || []),
|
||||
...(response.data.audio || []),
|
||||
];
|
||||
|
||||
if (!allIds.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const itemsResponse = await getItemsApi(api).getItems(
|
||||
{
|
||||
ids: allIds,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
|
||||
return (itemsResponse.data.Items as BaseItemDto[]) || [];
|
||||
}
|
||||
|
||||
// Marlin search
|
||||
if (!settings?.marlinServerUrl) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const url = `${
|
||||
settings.marlinServerUrl
|
||||
}/search?q=${encodeURIComponent(query)}&includeItemTypes=${types
|
||||
.map((type) => encodeURIComponent(type))
|
||||
.join("&includeItemTypes=")}`;
|
||||
|
||||
const response1 = await axios.get(url, { signal });
|
||||
|
||||
const ids = response1.data.ids;
|
||||
|
||||
if (!ids || !ids.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const response2 = await getItemsApi(api).getItems(
|
||||
{
|
||||
ids,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
|
||||
return (response2.data.Items as BaseItemDto[]) || [];
|
||||
} catch (error) {
|
||||
// Silently handle aborted requests
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
return [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
},
|
||||
[api, searchEngine, settings, user?.Id],
|
||||
);
|
||||
|
||||
// Separate search function for music types - always uses Jellyfin since Streamystats doesn't support music
|
||||
const jellyfinSearchFn = useCallback(
|
||||
async ({
|
||||
types,
|
||||
query,
|
||||
signal,
|
||||
}: {
|
||||
types: BaseItemKind[];
|
||||
query: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<BaseItemDto[]> => {
|
||||
if (!api || !query) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const searchApi = await getItemsApi(api).getItems(
|
||||
{
|
||||
searchTerm: query,
|
||||
limit: 10,
|
||||
includeItemTypes: types,
|
||||
});
|
||||
recursive: true,
|
||||
userId: user?.Id,
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
|
||||
return (searchApi.data.SearchHints as BaseItemDto[]) || [];
|
||||
} else {
|
||||
if (!settings?.marlinServerUrl) return [];
|
||||
|
||||
const url = `${
|
||||
settings.marlinServerUrl
|
||||
}/search?q=${encodeURIComponent(query)}&includeItemTypes=${types
|
||||
.map((type) => encodeURIComponent(type))
|
||||
.join("&includeItemTypes=")}`;
|
||||
|
||||
const response1 = await axios.get(url);
|
||||
|
||||
const ids = response1.data.ids;
|
||||
|
||||
if (!ids || !ids.length) return [];
|
||||
|
||||
const response2 = await getItemsApi(api).getItems({
|
||||
ids,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
});
|
||||
|
||||
return (response2.data.Items as BaseItemDto[]) || [];
|
||||
}
|
||||
return (searchApi.data.Items as BaseItemDto[]) || [];
|
||||
} catch (error) {
|
||||
console.error("Error during search:", error);
|
||||
return []; // Ensure an empty array is returned in case of an error
|
||||
// Silently handle aborted requests
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
return [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
},
|
||||
[api, searchEngine, settings]
|
||||
[api, user?.Id],
|
||||
);
|
||||
|
||||
type HeaderSearchBarRef = {
|
||||
@@ -149,8 +299,10 @@ export default function search() {
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = eventBus.on("searchTabPressed", () => {
|
||||
// Screen not actuve
|
||||
if (!searchBarRef.current) return;
|
||||
// Screen not active
|
||||
if (!searchBarRef.current) {
|
||||
return;
|
||||
}
|
||||
// Screen is active, focus search bar
|
||||
searchBarRef.current?.focus();
|
||||
});
|
||||
@@ -166,6 +318,7 @@ export default function search() {
|
||||
searchFn({
|
||||
query: debouncedSearch,
|
||||
types: ["Movie"],
|
||||
signal: abortControllerRef.current?.signal,
|
||||
}),
|
||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||
});
|
||||
@@ -176,6 +329,7 @@ export default function search() {
|
||||
searchFn({
|
||||
query: debouncedSearch,
|
||||
types: ["Series"],
|
||||
signal: abortControllerRef.current?.signal,
|
||||
}),
|
||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||
});
|
||||
@@ -186,6 +340,7 @@ export default function search() {
|
||||
searchFn({
|
||||
query: debouncedSearch,
|
||||
types: ["Episode"],
|
||||
signal: abortControllerRef.current?.signal,
|
||||
}),
|
||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||
});
|
||||
@@ -196,6 +351,7 @@ export default function search() {
|
||||
searchFn({
|
||||
query: debouncedSearch,
|
||||
types: ["BoxSet"],
|
||||
signal: abortControllerRef.current?.signal,
|
||||
}),
|
||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||
});
|
||||
@@ -206,6 +362,52 @@ export default function search() {
|
||||
searchFn({
|
||||
query: debouncedSearch,
|
||||
types: ["Person"],
|
||||
signal: abortControllerRef.current?.signal,
|
||||
}),
|
||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
// Music search queries - always use Jellyfin since Streamystats doesn't support music
|
||||
const { data: artists, isFetching: l9 } = useQuery({
|
||||
queryKey: ["search", "artists", debouncedSearch],
|
||||
queryFn: () =>
|
||||
jellyfinSearchFn({
|
||||
query: debouncedSearch,
|
||||
types: ["MusicArtist"],
|
||||
signal: abortControllerRef.current?.signal,
|
||||
}),
|
||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const { data: albums, isFetching: l10 } = useQuery({
|
||||
queryKey: ["search", "albums", debouncedSearch],
|
||||
queryFn: () =>
|
||||
jellyfinSearchFn({
|
||||
query: debouncedSearch,
|
||||
types: ["MusicAlbum"],
|
||||
signal: abortControllerRef.current?.signal,
|
||||
}),
|
||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const { data: songs, isFetching: l11 } = useQuery({
|
||||
queryKey: ["search", "songs", debouncedSearch],
|
||||
queryFn: () =>
|
||||
jellyfinSearchFn({
|
||||
query: debouncedSearch,
|
||||
types: ["Audio"],
|
||||
signal: abortControllerRef.current?.signal,
|
||||
}),
|
||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||
});
|
||||
|
||||
const { data: playlists, isFetching: l12 } = useQuery({
|
||||
queryKey: ["search", "playlists", debouncedSearch],
|
||||
queryFn: () =>
|
||||
jellyfinSearchFn({
|
||||
query: debouncedSearch,
|
||||
types: ["Playlist"],
|
||||
signal: abortControllerRef.current?.signal,
|
||||
}),
|
||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||
});
|
||||
@@ -216,174 +418,376 @@ export default function search() {
|
||||
episodes?.length ||
|
||||
series?.length ||
|
||||
collections?.length ||
|
||||
actors?.length
|
||||
actors?.length ||
|
||||
artists?.length ||
|
||||
albums?.length ||
|
||||
songs?.length ||
|
||||
playlists?.length
|
||||
);
|
||||
}, [episodes, movies, series, collections, actors]);
|
||||
}, [
|
||||
episodes,
|
||||
movies,
|
||||
series,
|
||||
collections,
|
||||
actors,
|
||||
artists,
|
||||
albums,
|
||||
songs,
|
||||
playlists,
|
||||
]);
|
||||
|
||||
const loading = useMemo(() => {
|
||||
return l1 || l2 || l3 || l7 || l8;
|
||||
}, [l1, l2, l3, l7, l8]);
|
||||
return l1 || l2 || l3 || l7 || l8 || l9 || l10 || l11 || l12;
|
||||
}, [l1, l2, l3, l7, l8, l9, l10, l11, l12]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScrollView
|
||||
keyboardDismissMode="on-drag"
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
<ScrollView
|
||||
keyboardDismissMode='on-drag'
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: 60,
|
||||
}}
|
||||
>
|
||||
{/* <View
|
||||
className='flex flex-col'
|
||||
style={{
|
||||
marginTop: Platform.OS === "android" ? 16 : 0,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
className="flex flex-col"
|
||||
style={{
|
||||
marginTop: Platform.OS === "android" ? 16 : 0,
|
||||
> */}
|
||||
{Platform.isTV && (
|
||||
<Input
|
||||
placeholder={t("search.search")}
|
||||
onChangeText={(text) => {
|
||||
router.setParams({ q: "" });
|
||||
setSearch(text);
|
||||
}}
|
||||
>
|
||||
{jellyseerrApi && (
|
||||
<View className="flex flex-row flex-wrap space-x-2 px-4 mb-2">
|
||||
<TouchableOpacity onPress={() => setSearchType("Library")}>
|
||||
<Tag
|
||||
text={t("search.library")}
|
||||
textClass="p-1"
|
||||
className={
|
||||
searchType === "Library" ? "bg-purple-600" : undefined
|
||||
}
|
||||
keyboardType='default'
|
||||
returnKeyType='done'
|
||||
autoCapitalize='none'
|
||||
clearButtonMode='while-editing'
|
||||
maxLength={500}
|
||||
/>
|
||||
)}
|
||||
<View
|
||||
className='flex flex-col'
|
||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||
>
|
||||
{jellyseerrApi && (
|
||||
<View className='pl-4 pr-4 flex flex-row'>
|
||||
<SearchTabButtons
|
||||
searchType={searchType}
|
||||
setSearchType={setSearchType}
|
||||
t={t}
|
||||
/>
|
||||
{searchType === "Discover" &&
|
||||
!loading &&
|
||||
noResults &&
|
||||
debouncedSearch.length > 0 && (
|
||||
<DiscoverFilters
|
||||
searchFilterId={searchFilterId}
|
||||
orderFilterId={orderFilterId}
|
||||
jellyseerrOrderBy={jellyseerrOrderBy}
|
||||
setJellyseerrOrderBy={setJellyseerrOrderBy}
|
||||
jellyseerrSortOrder={jellyseerrSortOrder}
|
||||
setJellyseerrSortOrder={setJellyseerrSortOrder}
|
||||
t={t}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => setSearchType("Discover")}>
|
||||
<Tag
|
||||
text={t("search.discover")}
|
||||
textClass="p-1"
|
||||
className={
|
||||
searchType === "Discover" ? "bg-purple-600" : undefined
|
||||
}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className="mt-2">
|
||||
<LoadingSkeleton isLoading={loading} />
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{searchType === "Library" ? (
|
||||
<View className={l1 || l2 ? "opacity-0" : "opacity-100"}>
|
||||
<SearchItemWrapper
|
||||
header={t("search.movies")}
|
||||
ids={movies?.map((m) => m.Id!)}
|
||||
renderItem={(item: BaseItemDto) => (
|
||||
<TouchableItemRouter
|
||||
key={item.Id}
|
||||
className="flex flex-col w-28 mr-2"
|
||||
item={item}
|
||||
>
|
||||
<MoviePoster item={item} key={item.Id} />
|
||||
<Text numberOfLines={2} className="mt-2">
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text className="opacity-50 text-xs">
|
||||
{item.ProductionYear}
|
||||
</Text>
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
ids={series?.map((m) => m.Id!)}
|
||||
header={t("search.series")}
|
||||
renderItem={(item: BaseItemDto) => (
|
||||
<TouchableItemRouter
|
||||
key={item.Id}
|
||||
item={item}
|
||||
className="flex flex-col w-28 mr-2"
|
||||
>
|
||||
<SeriesPoster item={item} key={item.Id} />
|
||||
<Text numberOfLines={2} className="mt-2">
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text className="opacity-50 text-xs">
|
||||
{item.ProductionYear}
|
||||
</Text>
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
ids={episodes?.map((m) => m.Id!)}
|
||||
header={t("search.episodes")}
|
||||
renderItem={(item: BaseItemDto) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className="flex flex-col w-44 mr-2"
|
||||
>
|
||||
<ContinueWatchingPoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
ids={collections?.map((m) => m.Id!)}
|
||||
header={t("search.collections")}
|
||||
renderItem={(item: BaseItemDto) => (
|
||||
<TouchableItemRouter
|
||||
key={item.Id}
|
||||
item={item}
|
||||
className="flex flex-col w-28 mr-2"
|
||||
>
|
||||
<MoviePoster item={item} key={item.Id} />
|
||||
<Text numberOfLines={2} className="mt-2">
|
||||
{item.Name}
|
||||
</Text>
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
ids={actors?.map((m) => m.Id!)}
|
||||
header={t("search.actors")}
|
||||
renderItem={(item: BaseItemDto) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className="flex flex-col w-28 mr-2"
|
||||
>
|
||||
<MoviePoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<JellyserrIndexPage searchQuery={debouncedSearch} />
|
||||
)}
|
||||
|
||||
{searchType === "Library" && (
|
||||
<>
|
||||
{!loading && noResults && debouncedSearch.length > 0 ? (
|
||||
<View>
|
||||
<Text className="text-center text-lg font-bold mt-4">
|
||||
{t("search.no_results_found_for")}
|
||||
</Text>
|
||||
<Text className="text-xs text-purple-600 text-center">
|
||||
"{debouncedSearch}"
|
||||
</Text>
|
||||
</View>
|
||||
) : debouncedSearch.length === 0 ? (
|
||||
<View className="mt-4 flex flex-col items-center space-y-2">
|
||||
{exampleSearches.map((e) => (
|
||||
<TouchableOpacity
|
||||
onPress={() => setSearch(e)}
|
||||
key={e}
|
||||
className="mb-2"
|
||||
>
|
||||
<Text className="text-purple-600">{e}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
<View className='mt-2'>
|
||||
<LoadingSkeleton isLoading={loading} />
|
||||
</View>
|
||||
</ScrollView>
|
||||
</>
|
||||
|
||||
{searchType === "Library" ? (
|
||||
<View className={l1 || l2 ? "opacity-0" : "opacity-100"}>
|
||||
<SearchItemWrapper
|
||||
header={t("search.movies")}
|
||||
items={movies}
|
||||
renderItem={(item: BaseItemDto) => (
|
||||
<TouchableItemRouter
|
||||
key={item.Id}
|
||||
className='flex flex-col w-28 mr-2'
|
||||
item={item}
|
||||
>
|
||||
<MoviePoster item={item} key={item.Id} />
|
||||
<Text numberOfLines={2} className='mt-2'>
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text className='opacity-50 text-xs'>
|
||||
{item.ProductionYear}
|
||||
</Text>
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
items={series}
|
||||
header={t("search.series")}
|
||||
renderItem={(item: BaseItemDto) => (
|
||||
<TouchableItemRouter
|
||||
key={item.Id}
|
||||
item={item}
|
||||
className='flex flex-col w-28 mr-2'
|
||||
>
|
||||
<SeriesPoster item={item} key={item.Id} />
|
||||
<Text numberOfLines={2} className='mt-2'>
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text className='opacity-50 text-xs'>
|
||||
{item.ProductionYear}
|
||||
</Text>
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
items={episodes}
|
||||
header={t("search.episodes")}
|
||||
renderItem={(item: BaseItemDto) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className='flex flex-col w-44 mr-2'
|
||||
>
|
||||
<ContinueWatchingPoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
items={collections}
|
||||
header={t("search.collections")}
|
||||
renderItem={(item: BaseItemDto) => (
|
||||
<TouchableItemRouter
|
||||
key={item.Id}
|
||||
item={item}
|
||||
className='flex flex-col w-28 mr-2'
|
||||
>
|
||||
<MoviePoster item={item} key={item.Id} />
|
||||
<Text numberOfLines={2} className='mt-2'>
|
||||
{item.Name}
|
||||
</Text>
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
items={actors}
|
||||
header={t("search.actors")}
|
||||
renderItem={(item: BaseItemDto) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className='flex flex-col w-28 mr-2'
|
||||
>
|
||||
<MoviePoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
{/* Music search results */}
|
||||
<SearchItemWrapper
|
||||
items={artists}
|
||||
header={t("search.artists")}
|
||||
renderItem={(item: BaseItemDto) => {
|
||||
const imageUrl = getPrimaryImageUrl({ api, item });
|
||||
return (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className='flex flex-col w-24 mr-2 items-center'
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "#1a1a1a",
|
||||
}}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<Image
|
||||
source={{ uri: imageUrl }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit='cover'
|
||||
/>
|
||||
) : (
|
||||
<View className='flex-1 items-center justify-center bg-neutral-800'>
|
||||
<Text className='text-xl'>👤</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text numberOfLines={2} className='mt-2 text-center'>
|
||||
{item.Name}
|
||||
</Text>
|
||||
</TouchableItemRouter>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
items={albums}
|
||||
header={t("search.albums")}
|
||||
renderItem={(item: BaseItemDto) => {
|
||||
const imageUrl = getPrimaryImageUrl({ api, item });
|
||||
return (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className='flex flex-col w-28 mr-2'
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 112,
|
||||
height: 112,
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "#1a1a1a",
|
||||
}}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<Image
|
||||
source={{ uri: imageUrl }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit='cover'
|
||||
/>
|
||||
) : (
|
||||
<View className='flex-1 items-center justify-center bg-neutral-800'>
|
||||
<Text className='text-4xl'>🎵</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text numberOfLines={2} className='mt-2'>
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text className='opacity-50 text-xs' numberOfLines={1}>
|
||||
{item.AlbumArtist || item.Artists?.join(", ")}
|
||||
</Text>
|
||||
</TouchableItemRouter>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
items={songs}
|
||||
header={t("search.songs")}
|
||||
renderItem={(item: BaseItemDto) => {
|
||||
const imageUrl = getPrimaryImageUrl({ api, item });
|
||||
return (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className='flex flex-col w-28 mr-2'
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 112,
|
||||
height: 112,
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "#1a1a1a",
|
||||
}}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<Image
|
||||
source={{ uri: imageUrl }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit='cover'
|
||||
/>
|
||||
) : (
|
||||
<View className='flex-1 items-center justify-center bg-neutral-800'>
|
||||
<Text className='text-4xl'>🎵</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text numberOfLines={2} className='mt-2'>
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text className='opacity-50 text-xs' numberOfLines={1}>
|
||||
{item.Artists?.join(", ") || item.AlbumArtist}
|
||||
</Text>
|
||||
</TouchableItemRouter>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
items={playlists}
|
||||
header={t("search.playlists")}
|
||||
renderItem={(item: BaseItemDto) => {
|
||||
const imageUrl = getPrimaryImageUrl({ api, item });
|
||||
return (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className='flex flex-col w-28 mr-2'
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 112,
|
||||
height: 112,
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "#1a1a1a",
|
||||
}}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<Image
|
||||
source={{ uri: imageUrl }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit='cover'
|
||||
/>
|
||||
) : (
|
||||
<View className='flex-1 items-center justify-center bg-neutral-800'>
|
||||
<Text className='text-4xl'>🎶</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text numberOfLines={2} className='mt-2'>
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text className='opacity-50 text-xs'>
|
||||
{item.ChildCount} tracks
|
||||
</Text>
|
||||
</TouchableItemRouter>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<JellyserrIndexPage
|
||||
searchQuery={debouncedSearch}
|
||||
sortType={jellyseerrOrderBy}
|
||||
order={jellyseerrSortOrder}
|
||||
/>
|
||||
)}
|
||||
|
||||
{searchType === "Library" &&
|
||||
(!loading && noResults && debouncedSearch.length > 0 ? (
|
||||
<View>
|
||||
<Text className='text-center text-lg font-bold mt-4'>
|
||||
{t("search.no_results_found_for")}
|
||||
</Text>
|
||||
<Text className='text-xs text-purple-600 text-center'>
|
||||
"{debouncedSearch}"
|
||||
</Text>
|
||||
</View>
|
||||
) : debouncedSearch.length === 0 ? (
|
||||
<View className='mt-4 flex flex-col items-center space-y-2'>
|
||||
{exampleSearches.map((e) => (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
setSearch(e);
|
||||
searchBarRef.current?.setText(e);
|
||||
}}
|
||||
key={e}
|
||||
className='mb-2'
|
||||
>
|
||||
<Text className='text-purple-600'>{e}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
) : null)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,151 +1,125 @@
|
||||
import React, { useCallback, useRef } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useFocusEffect, useRouter, withLayoutContext } from "expo-router";
|
||||
|
||||
import {
|
||||
createNativeBottomTabNavigator,
|
||||
NativeBottomTabNavigationEventMap,
|
||||
type NativeBottomTabNavigationEventMap,
|
||||
type NativeBottomTabNavigationOptions,
|
||||
} from "@bottom-tabs/react-navigation";
|
||||
|
||||
const { Navigator } = createNativeBottomTabNavigator();
|
||||
import { BottomTabNavigationOptions } from "@react-navigation/bottom-tabs";
|
||||
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import type {
|
||||
ParamListBase,
|
||||
TabNavigationState,
|
||||
} from "@react-navigation/native";
|
||||
import { withLayoutContext } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View } from "react-native";
|
||||
import { SystemBars } from "react-native-edge-to-edge";
|
||||
import { MiniPlayerBar } from "@/components/music/MiniPlayerBar";
|
||||
import { MusicPlaybackEngine } from "@/components/music/MusicPlaybackEngine";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { eventBus } from "@/utils/eventBus";
|
||||
|
||||
const { Navigator } = createNativeBottomTabNavigator();
|
||||
|
||||
export const NativeTabs = withLayoutContext<
|
||||
BottomTabNavigationOptions,
|
||||
NativeBottomTabNavigationOptions,
|
||||
typeof Navigator,
|
||||
TabNavigationState<ParamListBase>,
|
||||
NativeBottomTabNavigationEventMap
|
||||
>(Navigator);
|
||||
|
||||
export default function TabLayout() {
|
||||
const [settings] = useSettings();
|
||||
const { settings } = useSettings();
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
const hasShownIntro = storage.getBoolean("hasShownIntro");
|
||||
if (!hasShownIntro) {
|
||||
const timer = setTimeout(() => {
|
||||
router.push("/intro/page");
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}
|
||||
}, [])
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SystemBars hidden={false} style="light" />
|
||||
<View style={{ flex: 1 }}>
|
||||
<SystemBars hidden={false} style='light' />
|
||||
<NativeTabs
|
||||
sidebarAdaptable={false}
|
||||
ignoresTopSafeArea
|
||||
tabBarStyle={{
|
||||
backgroundColor: "#121212",
|
||||
}}
|
||||
tabBarActiveTintColor={Colors.primary}
|
||||
scrollEdgeAppearance="default"
|
||||
activeIndicatorColor={"#392c3b"}
|
||||
scrollEdgeAppearance='default'
|
||||
>
|
||||
<NativeTabs.Screen redirect name="index" />
|
||||
<NativeTabs.Screen redirect name='index' />
|
||||
<NativeTabs.Screen
|
||||
listeners={({ navigation }) => ({
|
||||
tabPress: (e) => {
|
||||
listeners={(_e) => ({
|
||||
tabPress: (_e) => {
|
||||
eventBus.emit("scrollToTop");
|
||||
},
|
||||
})}
|
||||
name="(home)"
|
||||
name='(home)'
|
||||
options={{
|
||||
title: t("tabs.home"),
|
||||
tabBarIcon:
|
||||
Platform.OS == "android"
|
||||
? ({ color, focused, size }) =>
|
||||
require("@/assets/icons/house.fill.png")
|
||||
: ({ focused }) =>
|
||||
focused
|
||||
? { sfSymbol: "house.fill" }
|
||||
: { sfSymbol: "house" },
|
||||
Platform.OS === "android"
|
||||
? (_e) => require("@/assets/icons/house.fill.png")
|
||||
: (_e) => ({ sfSymbol: "house.fill" }),
|
||||
}}
|
||||
/>
|
||||
<NativeTabs.Screen
|
||||
listeners={({ navigation }) => ({
|
||||
tabPress: (e) => {
|
||||
listeners={(_e) => ({
|
||||
tabPress: (_e) => {
|
||||
eventBus.emit("searchTabPressed");
|
||||
},
|
||||
})}
|
||||
name="(search)"
|
||||
name='(search)'
|
||||
options={{
|
||||
role: "search",
|
||||
title: t("tabs.search"),
|
||||
tabBarIcon:
|
||||
Platform.OS == "android"
|
||||
? ({ color, focused, size }) =>
|
||||
require("@/assets/icons/magnifyingglass.png")
|
||||
: ({ focused }) =>
|
||||
focused
|
||||
? { sfSymbol: "magnifyingglass" }
|
||||
: { sfSymbol: "magnifyingglass" },
|
||||
Platform.OS === "android"
|
||||
? (_e) => require("@/assets/icons/magnifyingglass.png")
|
||||
: (_e) => ({ sfSymbol: "magnifyingglass" }),
|
||||
}}
|
||||
/>
|
||||
<NativeTabs.Screen
|
||||
name="(favorites)"
|
||||
name='(favorites)'
|
||||
options={{
|
||||
title: t("tabs.favorites"),
|
||||
tabBarIcon:
|
||||
Platform.OS == "android"
|
||||
? ({ color, focused, size }) =>
|
||||
focused
|
||||
? require("@/assets/icons/heart.fill.png")
|
||||
: require("@/assets/icons/heart.png")
|
||||
: ({ focused }) =>
|
||||
focused
|
||||
? { sfSymbol: "heart.fill" }
|
||||
: { sfSymbol: "heart" },
|
||||
Platform.OS === "android"
|
||||
? (_e) => require("@/assets/icons/heart.fill.png")
|
||||
: (_e) => ({ sfSymbol: "heart.fill" }),
|
||||
}}
|
||||
/>
|
||||
<NativeTabs.Screen
|
||||
name="(libraries)"
|
||||
name='(watchlists)'
|
||||
options={{
|
||||
title: t("watchlists.title"),
|
||||
tabBarItemHidden:
|
||||
!settings?.streamyStatsServerUrl || settings?.hideWatchlistsTab,
|
||||
tabBarIcon:
|
||||
Platform.OS === "android"
|
||||
? (_e) => require("@/assets/icons/list.png")
|
||||
: (_e) => ({ sfSymbol: "list.bullet.rectangle" }),
|
||||
}}
|
||||
/>
|
||||
<NativeTabs.Screen
|
||||
name='(libraries)'
|
||||
options={{
|
||||
title: t("tabs.library"),
|
||||
tabBarIcon:
|
||||
Platform.OS == "android"
|
||||
? ({ color, focused, size }) =>
|
||||
require("@/assets/icons/server.rack.png")
|
||||
: ({ focused }) =>
|
||||
focused
|
||||
? { sfSymbol: "rectangle.stack.fill" }
|
||||
: { sfSymbol: "rectangle.stack" },
|
||||
Platform.OS === "android"
|
||||
? (_e) => require("@/assets/icons/server.rack.png")
|
||||
: (_e) => ({ sfSymbol: "rectangle.stack.fill" }),
|
||||
}}
|
||||
/>
|
||||
<NativeTabs.Screen
|
||||
name="(custom-links)"
|
||||
name='(custom-links)'
|
||||
options={{
|
||||
title: t("tabs.custom_links"),
|
||||
// @ts-expect-error
|
||||
tabBarItemHidden: settings?.showCustomMenuLinks ? false : true,
|
||||
tabBarItemHidden: !settings?.showCustomMenuLinks,
|
||||
tabBarIcon:
|
||||
Platform.OS == "android"
|
||||
? ({ focused }) => require("@/assets/icons/list.png")
|
||||
: ({ focused }) =>
|
||||
focused
|
||||
? { sfSymbol: "list.dash.fill" }
|
||||
: { sfSymbol: "list.dash" },
|
||||
Platform.OS === "android"
|
||||
? (_e) => require("@/assets/icons/list.png")
|
||||
: (_e) => ({ sfSymbol: "list.dash.fill" }),
|
||||
}}
|
||||
/>
|
||||
</NativeTabs>
|
||||
</>
|
||||
<MiniPlayerBar />
|
||||
<MusicPlaybackEngine />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,57 +1,39 @@
|
||||
import { Stack } from "expo-router";
|
||||
import React, { useEffect } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { AppState } from "react-native";
|
||||
import { SystemBars } from "react-native-edge-to-edge";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
export default function Layout() {
|
||||
const [settings] = useSettings();
|
||||
const { settings } = useSettings();
|
||||
const { lockOrientation, unlockOrientation } = useOrientation();
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.isTV) return;
|
||||
|
||||
if (settings.defaultVideoOrientation) {
|
||||
ScreenOrientation.lockAsync(settings.defaultVideoOrientation);
|
||||
if (settings?.defaultVideoOrientation) {
|
||||
lockOrientation(settings.defaultVideoOrientation);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (Platform.isTV) return;
|
||||
|
||||
if (settings.autoRotate === true) {
|
||||
ScreenOrientation.unlockAsync();
|
||||
} else {
|
||||
ScreenOrientation.lockAsync(
|
||||
ScreenOrientation.OrientationLock.PORTRAIT_UP
|
||||
);
|
||||
// Re-apply orientation lock when app returns to foreground (iOS resets it)
|
||||
const subscription = AppState.addEventListener("change", (nextAppState) => {
|
||||
if (nextAppState === "active" && settings?.defaultVideoOrientation) {
|
||||
lockOrientation(settings.defaultVideoOrientation);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.remove();
|
||||
unlockOrientation();
|
||||
};
|
||||
}, [settings]);
|
||||
}, [settings?.defaultVideoOrientation, lockOrientation, unlockOrientation]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SystemBars hidden />
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
name="direct-player"
|
||||
options={{
|
||||
headerShown: false,
|
||||
autoHideHomeIndicator: true,
|
||||
title: "",
|
||||
animation: "fade",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="transcoding-player"
|
||||
options={{
|
||||
headerShown: false,
|
||||
autoHideHomeIndicator: true,
|
||||
title: "",
|
||||
animation: "fade",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="google-cast-player"
|
||||
name='direct-player'
|
||||
options={{
|
||||
headerShown: false,
|
||||
autoHideHomeIndicator: true,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,184 +0,0 @@
|
||||
import React, { useMemo, useRef, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { RoundButton } from "@/components/RoundButton";
|
||||
|
||||
import GoogleCast, {
|
||||
CastButton,
|
||||
CastContext,
|
||||
CastState,
|
||||
useCastDevice,
|
||||
useCastState,
|
||||
useDevices,
|
||||
useMediaStatus,
|
||||
useRemoteMediaClient,
|
||||
} from "react-native-google-cast";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import ChromecastControls from "@/components/ChromecastControls";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function Player() {
|
||||
const castState = useCastState();
|
||||
|
||||
const client = useRemoteMediaClient();
|
||||
const castDevice = useCastDevice();
|
||||
const devices = useDevices();
|
||||
const sessionManager = GoogleCast.getSessionManager();
|
||||
const discoveryManager = GoogleCast.getDiscoveryManager();
|
||||
const mediaStatus = useMediaStatus();
|
||||
|
||||
const [wasMediaPlaying, setWasMediaPlaying] = useState(false);
|
||||
const reportPlaybackStopedRef = useRef(() => {});
|
||||
|
||||
useEffect(() => {
|
||||
if (mediaStatus) return; // media currently playing
|
||||
|
||||
// media was just playing, report playback stopped
|
||||
if (wasMediaPlaying) {
|
||||
reportPlaybackStopedRef.current();
|
||||
setWasMediaPlaying(false);
|
||||
}
|
||||
}, [mediaStatus, wasMediaPlaying]);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (!discoveryManager) {
|
||||
console.warn("DiscoveryManager is not initialized");
|
||||
return;
|
||||
}
|
||||
|
||||
await discoveryManager.startDiscovery();
|
||||
})();
|
||||
}, [client, devices, castDevice, sessionManager, discoveryManager]);
|
||||
|
||||
// Android requires the cast button to be present for startDiscovery to work
|
||||
const AndroidCastButton = useCallback(
|
||||
() =>
|
||||
Platform.OS === "android" ? (
|
||||
<CastButton tintColor="transparent" />
|
||||
) : (
|
||||
<></>
|
||||
),
|
||||
[Platform.OS]
|
||||
);
|
||||
|
||||
const GoHomeButton = useCallback(
|
||||
() => (
|
||||
<Button
|
||||
onPress={() => {
|
||||
router.push("/(auth)/(home)/");
|
||||
}}
|
||||
>
|
||||
{t("chromecast.go_home")}
|
||||
</Button>
|
||||
),
|
||||
[router]
|
||||
);
|
||||
|
||||
const ChromecastControlsMemoized = useMemo(() => {
|
||||
if (!mediaStatus || !client) return undefined;
|
||||
return (
|
||||
<ChromecastControls
|
||||
mediaStatus={mediaStatus}
|
||||
client={client}
|
||||
setWasMediaPlaying={setWasMediaPlaying}
|
||||
reportPlaybackStopedRef={reportPlaybackStopedRef}
|
||||
/>
|
||||
);
|
||||
}, [mediaStatus, client, setWasMediaPlaying]);
|
||||
|
||||
if (
|
||||
castState === CastState.NO_DEVICES_AVAILABLE ||
|
||||
castState === CastState.NOT_CONNECTED
|
||||
) {
|
||||
// no devices to connect to
|
||||
if (devices.length === 0) {
|
||||
return (
|
||||
<View className="w-screen h-screen flex flex-col ">
|
||||
<AndroidCastButton />
|
||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
||||
<Text className="text-white text-lg">
|
||||
{t("chromecast.no_devices_available")}
|
||||
</Text>
|
||||
<Text className="text-gray-400">
|
||||
{t("chromecast.are_you_on_same_network")}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="px-10">
|
||||
<GoHomeButton />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
// no device selected
|
||||
return (
|
||||
<View className="w-screen h-screen flex flex-col ">
|
||||
<AndroidCastButton />
|
||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
||||
<RoundButton
|
||||
size="large"
|
||||
background={false}
|
||||
onPress={() => {
|
||||
lightHapticFeedback();
|
||||
CastContext.showCastDialog();
|
||||
}}
|
||||
>
|
||||
<AndroidCastButton />
|
||||
<Feather name="cast" size={42} color={"white"} />
|
||||
</RoundButton>
|
||||
<Text className="text-white text-xl mt-2">
|
||||
{t("chromecast.no_device_selected")}
|
||||
</Text>
|
||||
<Text className="text-gray-400">
|
||||
{t("chromecast.click_icon_to_connect")}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="px-10">
|
||||
<GoHomeButton />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (castState === CastState.CONNECTING) {
|
||||
return (
|
||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
||||
<Text className="text-white font-semibold lg mb-2">
|
||||
{t("chromecast.establishing_connection")}
|
||||
</Text>
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// connected, but no media playing
|
||||
if (!mediaStatus) {
|
||||
return (
|
||||
<View className="w-screen h-screen flex flex-col ">
|
||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
||||
<Text className="text-white text-lg">
|
||||
{t("chromecast.no_media_selected")}
|
||||
</Text>
|
||||
<Text className="text-gray-400">{t("chromecast.start_playing")}</Text>
|
||||
</View>
|
||||
<View className="px-10">
|
||||
<GoHomeButton />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return ChromecastControlsMemoized;
|
||||
}
|
||||
609
app/_layout.tsx
609
app/_layout.tsx
@@ -1,44 +1,65 @@
|
||||
import "@/augmentations";
|
||||
import { Platform } from "react-native";
|
||||
import i18n from "@/i18n";
|
||||
import { DownloadProvider } from "@/providers/DownloadProvider";
|
||||
import {
|
||||
getOrSetDeviceId,
|
||||
getTokenFromStorage,
|
||||
JellyfinProvider,
|
||||
} from "@/providers/JellyfinProvider";
|
||||
import { JobQueueProvider } from "@/providers/JobQueueProvider";
|
||||
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
||||
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
||||
import { Settings, useSettings } from "@/utils/atoms/settings";
|
||||
import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks";
|
||||
import { LogProvider, writeToLog } from "@/utils/log";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server";
|
||||
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
const BackGroundDownloader = !Platform.isTV
|
||||
? require("@kesha-antonov/react-native-background-downloader")
|
||||
: null;
|
||||
import NetInfo from "@react-native-community/netinfo";
|
||||
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
const BackgroundFetch = !Platform.isTV
|
||||
? require("expo-background-fetch")
|
||||
: null;
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
|
||||
import { onlineManager, QueryClient } from "@tanstack/react-query";
|
||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||
import * as BackgroundTask from "expo-background-task";
|
||||
import * as Device from "expo-device";
|
||||
import { Platform } from "react-native";
|
||||
import { GlobalModal } from "@/components/GlobalModal";
|
||||
import i18n from "@/i18n";
|
||||
import { DownloadProvider } from "@/providers/DownloadProvider";
|
||||
import { GlobalModalProvider } from "@/providers/GlobalModalProvider";
|
||||
import { IntroSheetProvider } from "@/providers/IntroSheetProvider";
|
||||
import {
|
||||
apiAtom,
|
||||
getOrSetDeviceId,
|
||||
JellyfinProvider,
|
||||
} from "@/providers/JellyfinProvider";
|
||||
import { MusicPlayerProvider } from "@/providers/MusicPlayerProvider";
|
||||
import { NetworkStatusProvider } from "@/providers/NetworkStatusProvider";
|
||||
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
||||
import { ServerUrlProvider } from "@/providers/ServerUrlProvider";
|
||||
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import {
|
||||
BACKGROUND_FETCH_TASK,
|
||||
BACKGROUND_FETCH_TASK_SESSIONS,
|
||||
registerBackgroundFetchAsyncSessions,
|
||||
} from "@/utils/background-tasks";
|
||||
import {
|
||||
LogProvider,
|
||||
writeErrorLog,
|
||||
writeInfoLog,
|
||||
writeToLog,
|
||||
} from "@/utils/log";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
|
||||
const Notifications = !Platform.isTV ? require("expo-notifications") : null;
|
||||
import { router, Stack } from "expo-router";
|
||||
import * as SplashScreen from "expo-splash-screen";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
const TaskManager = !Platform.isTV ? require("expo-task-manager") : null;
|
||||
|
||||
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
||||
import { getLocales } from "expo-localization";
|
||||
import { Provider as JotaiProvider } from "jotai";
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { EventSubscription } from "expo-modules-core";
|
||||
import type {
|
||||
Notification,
|
||||
NotificationResponse,
|
||||
} from "expo-notifications/build/Notifications.types";
|
||||
import type { ExpoPushToken } from "expo-notifications/build/Tokens.types";
|
||||
import { Stack, useSegments } from "expo-router";
|
||||
import * as SplashScreen from "expo-splash-screen";
|
||||
import * as TaskManager from "expo-task-manager";
|
||||
import { Provider as JotaiProvider, useAtom } from "jotai";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import { Appearance, AppState } from "react-native";
|
||||
import { Appearance } from "react-native";
|
||||
import { SystemBars } from "react-native-edge-to-edge";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { userAtom } from "@/providers/JellyfinProvider";
|
||||
import { store } from "@/utils/store";
|
||||
import "react-native-reanimated";
|
||||
import { Toaster } from "sonner-native";
|
||||
|
||||
@@ -62,164 +83,93 @@ SplashScreen.setOptions({
|
||||
});
|
||||
|
||||
function useNotificationObserver() {
|
||||
if (Platform.isTV) return;
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
if (Platform.isTV) return;
|
||||
|
||||
function redirect(notification: typeof Notifications.Notification) {
|
||||
const url = notification.request.content.data?.url;
|
||||
if (url) {
|
||||
router.push(url);
|
||||
}
|
||||
}
|
||||
let isMounted = true;
|
||||
|
||||
Notifications.getLastNotificationResponseAsync().then(
|
||||
(response: { notification: any }) => {
|
||||
if (!isMounted || !response?.notification) {
|
||||
return;
|
||||
}
|
||||
redirect(response?.notification);
|
||||
}
|
||||
);
|
||||
|
||||
const subscription = Notifications.addNotificationResponseReceivedListener(
|
||||
(response: { notification: any }) => {
|
||||
redirect(response.notification);
|
||||
}
|
||||
const url = response?.notification.request.content.data?.url;
|
||||
if (url) {
|
||||
router.push(url);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
subscription.remove();
|
||||
};
|
||||
}, []);
|
||||
}, [router]);
|
||||
}
|
||||
|
||||
if (!Platform.isTV) {
|
||||
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
|
||||
console.log("TaskManager ~ trigger");
|
||||
TaskManager.defineTask(BACKGROUND_FETCH_TASK_SESSIONS, async () => {
|
||||
console.log("TaskManager ~ sessions trigger");
|
||||
|
||||
const now = Date.now();
|
||||
const api = store.get(apiAtom);
|
||||
if (api === null || api === undefined) return;
|
||||
|
||||
const settingsData = storage.getString("settings");
|
||||
|
||||
if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||
|
||||
const settings: Partial<Settings> = JSON.parse(settingsData);
|
||||
const url = settings?.optimizedVersionsServerUrl;
|
||||
|
||||
if (!settings?.autoDownload || !url)
|
||||
return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||
|
||||
const token = getTokenFromStorage();
|
||||
const deviceId = getOrSetDeviceId();
|
||||
const baseDirectory = FileSystem.documentDirectory;
|
||||
|
||||
if (!token || !deviceId || !baseDirectory)
|
||||
return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||
|
||||
const jobs = await getAllJobsByDeviceId({
|
||||
deviceId,
|
||||
authHeader: token,
|
||||
url,
|
||||
const response = await getSessionApi(api).getSessions({
|
||||
activeWithinSeconds: 360,
|
||||
});
|
||||
|
||||
console.log("TaskManager ~ Active jobs: ", jobs.length);
|
||||
const result = response.data.filter((s) => s.NowPlayingItem);
|
||||
Notifications.setBadgeCountAsync(result.length);
|
||||
|
||||
for (let job of jobs) {
|
||||
if (job.status === "completed") {
|
||||
const downloadUrl = url + "download/" + job.id;
|
||||
const tasks = await BackGroundDownloader.checkForExistingDownloads();
|
||||
return BackgroundTask.BackgroundTaskResult.Success;
|
||||
});
|
||||
|
||||
if (tasks.find((task: { id: string }) => task.id === job.id)) {
|
||||
console.log("TaskManager ~ Download already in progress: ", job.id);
|
||||
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,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Auto download started: ${new Date(now).toISOString()}`);
|
||||
|
||||
// Be sure to return the successful result type!
|
||||
return BackgroundFetch.BackgroundFetchResult.NewData;
|
||||
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
|
||||
console.log("TaskManager ~ trigger");
|
||||
// Background fetch task placeholder - currently unused
|
||||
return BackgroundTask.BackgroundTaskResult.Success;
|
||||
});
|
||||
}
|
||||
|
||||
const checkAndRequestPermissions = async () => {
|
||||
try {
|
||||
const hasAskedBefore = storage.getString(
|
||||
"hasAskedForNotificationPermission"
|
||||
"hasAskedForNotificationPermission",
|
||||
);
|
||||
|
||||
let granted = false;
|
||||
if (hasAskedBefore !== "true") {
|
||||
const { status } = await Notifications.requestPermissionsAsync();
|
||||
|
||||
if (status === "granted") {
|
||||
granted = status === "granted";
|
||||
if (granted) {
|
||||
writeToLog("INFO", "Notification permissions granted.");
|
||||
console.log("Notification permissions granted.");
|
||||
} else {
|
||||
writeToLog("ERROR", "Notification permissions denied.");
|
||||
console.log("Notification permissions denied.");
|
||||
}
|
||||
|
||||
storage.set("hasAskedForNotificationPermission", "true");
|
||||
} else {
|
||||
console.log("Already asked for notification permissions before.");
|
||||
// Already asked before, check current status
|
||||
const { status } = await Notifications.getPermissionsAsync();
|
||||
granted = status === "granted";
|
||||
if (!granted) {
|
||||
writeToLog(
|
||||
"ERROR",
|
||||
"Notification permissions denied (already asked before).",
|
||||
);
|
||||
console.log("Notification permissions denied (already asked before).");
|
||||
}
|
||||
}
|
||||
return granted;
|
||||
} catch (error) {
|
||||
writeToLog(
|
||||
"ERROR",
|
||||
"Error checking/requesting notification permissions:",
|
||||
error
|
||||
error,
|
||||
);
|
||||
console.error("Error checking/requesting notification permissions:", error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -239,150 +189,273 @@ export default function RootLayout() {
|
||||
);
|
||||
}
|
||||
|
||||
// Set up online manager for network-aware query behavior
|
||||
onlineManager.setEventListener((setOnline) => {
|
||||
return NetInfo.addEventListener((state) => {
|
||||
setOnline(!!state.isConnected);
|
||||
});
|
||||
});
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 0,
|
||||
refetchOnMount: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchOnWindowFocus: true,
|
||||
retryOnMount: true,
|
||||
staleTime: 0, // Always stale - triggers background refetch on mount
|
||||
gcTime: 1000 * 60 * 60 * 24, // 24 hours - keep in cache for offline
|
||||
networkMode: "offlineFirst", // Return cache first, refetch if online
|
||||
refetchOnMount: true, // Refetch when component mounts
|
||||
refetchOnReconnect: true, // Refetch when network reconnects
|
||||
refetchOnWindowFocus: false, // Not needed for mobile
|
||||
retry: (failureCount) => {
|
||||
if (!onlineManager.isOnline()) return false;
|
||||
return failureCount < 3;
|
||||
},
|
||||
},
|
||||
mutations: {
|
||||
networkMode: "online", // Only run mutations when online
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Create MMKV-based persister for offline support
|
||||
const mmkvPersister = createSyncStoragePersister({
|
||||
storage: {
|
||||
getItem: (key) => storage.getString(key) ?? null,
|
||||
setItem: (key, value) => storage.set(key, value),
|
||||
removeItem: (key) => storage.remove(key),
|
||||
},
|
||||
});
|
||||
|
||||
function Layout() {
|
||||
const [settings] = useSettings();
|
||||
const appState = useRef(AppState.currentState);
|
||||
const { settings } = useSettings();
|
||||
const [user] = useAtom(userAtom);
|
||||
const [api] = useAtom(apiAtom);
|
||||
const _segments = useSegments();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
i18n.changeLanguage(
|
||||
settings?.preferedLanguage ?? getLocales()[0].languageCode ?? "en"
|
||||
settings?.preferedLanguage ?? getLocales()[0].languageCode ?? "en",
|
||||
);
|
||||
}, [settings?.preferedLanguage, i18n]);
|
||||
|
||||
if (!Platform.isTV) {
|
||||
useNotificationObserver();
|
||||
useNotificationObserver();
|
||||
|
||||
useEffect(() => {
|
||||
checkAndRequestPermissions();
|
||||
}, []);
|
||||
const [expoPushToken, setExpoPushToken] = useState<ExpoPushToken>();
|
||||
const notificationListener = useRef<EventSubscription>(null);
|
||||
const responseListener = useRef<EventSubscription>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// If the user has auto rotate enabled, unlock the orientation
|
||||
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
|
||||
useEffect(() => {
|
||||
if (!Platform.isTV && expoPushToken && api && user) {
|
||||
api
|
||||
?.post("/Streamyfin/device", {
|
||||
token: expoPushToken.data,
|
||||
deviceId: getOrSetDeviceId(),
|
||||
userId: user.Id,
|
||||
})
|
||||
.then((_) => console.log("Posted expo push token"))
|
||||
.catch((_) =>
|
||||
writeErrorLog("Failed to push expo push token to plugin"),
|
||||
);
|
||||
}
|
||||
}, [settings]);
|
||||
} else console.log("No token available");
|
||||
}, [api, expoPushToken, user]);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = AppState.addEventListener(
|
||||
"change",
|
||||
(nextAppState) => {
|
||||
if (
|
||||
appState.current.match(/inactive|background/) &&
|
||||
nextAppState === "active"
|
||||
) {
|
||||
BackGroundDownloader.checkForExistingDownloads();
|
||||
}
|
||||
}
|
||||
);
|
||||
const registerNotifications = useCallback(async () => {
|
||||
if (Platform.OS === "android") {
|
||||
console.log("Setting android notification channel 'default'");
|
||||
await Notifications?.setNotificationChannelAsync("default", {
|
||||
name: "default",
|
||||
});
|
||||
|
||||
BackGroundDownloader.checkForExistingDownloads();
|
||||
|
||||
return () => {
|
||||
subscription.remove();
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<JobQueueProvider>
|
||||
<JellyfinProvider>
|
||||
<PlaySettingsProvider>
|
||||
<LogProvider>
|
||||
<WebSocketProvider>
|
||||
<DownloadProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<SystemBars style="light" hidden={false} />
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<Stack initialRouteName="(auth)/(tabs)">
|
||||
<Stack.Screen
|
||||
name="(auth)/(tabs)"
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
header: () => null,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/player"
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
header: () => null,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="login"
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: "",
|
||||
headerTransparent: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name="+not-found" />
|
||||
</Stack>
|
||||
<Toaster
|
||||
duration={4000}
|
||||
toastOptions={{
|
||||
style: {
|
||||
backgroundColor: "#262626",
|
||||
borderColor: "#363639",
|
||||
borderWidth: 1,
|
||||
},
|
||||
titleStyle: {
|
||||
color: "white",
|
||||
},
|
||||
}}
|
||||
closeButton
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</DownloadProvider>
|
||||
</WebSocketProvider>
|
||||
</LogProvider>
|
||||
</PlaySettingsProvider>
|
||||
</JellyfinProvider>
|
||||
</JobQueueProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function saveDownloadedItemInfo(item: BaseItemDto) {
|
||||
try {
|
||||
const downloadedItems = storage.getString("downloadedItems");
|
||||
let items: BaseItemDto[] = downloadedItems
|
||||
? JSON.parse(downloadedItems)
|
||||
: [];
|
||||
|
||||
const existingItemIndex = items.findIndex((i) => i.Id === item.Id);
|
||||
if (existingItemIndex !== -1) {
|
||||
items[existingItemIndex] = item;
|
||||
} else {
|
||||
items.push(item);
|
||||
// Create dedicated channel for download notifications
|
||||
console.log("Setting android notification channel 'downloads'");
|
||||
await Notifications?.setNotificationChannelAsync("downloads", {
|
||||
name: "Downloads",
|
||||
importance: Notifications.AndroidImportance.DEFAULT,
|
||||
vibrationPattern: [0, 250, 250, 250],
|
||||
lightColor: "#FF231F7C",
|
||||
});
|
||||
}
|
||||
|
||||
storage.set("downloadedItems", JSON.stringify(items));
|
||||
} catch (error) {
|
||||
writeToLog("ERROR", "Failed to save downloaded item information:", error);
|
||||
console.error("Failed to save downloaded item information:", error);
|
||||
}
|
||||
const granted = await checkAndRequestPermissions();
|
||||
if (!granted) {
|
||||
console.log(
|
||||
"Notification permissions not granted, skipping background fetch and push token registration.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Platform.isTV && user && user.Policy?.IsAdministrator) {
|
||||
await registerBackgroundFetchAsyncSessions();
|
||||
}
|
||||
|
||||
// only create push token for real devices (pointless for emulators)
|
||||
if (Device.isDevice) {
|
||||
Notifications?.getExpoPushTokenAsync({
|
||||
projectId: "e79219d1-797f-4fbe-9fa1-cfd360690a68",
|
||||
})
|
||||
.then((token: ExpoPushToken) => {
|
||||
if (token) {
|
||||
console.log("Expo push token obtained:", token.data);
|
||||
setExpoPushToken(token);
|
||||
}
|
||||
})
|
||||
.catch((reason: any) => {
|
||||
console.error("Failed to get push token:", reason);
|
||||
writeErrorLog("Failed to get Expo push token", reason);
|
||||
});
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!Platform.isTV) {
|
||||
void registerNotifications();
|
||||
|
||||
notificationListener.current =
|
||||
Notifications?.addNotificationReceivedListener(
|
||||
(notification: Notification) => {
|
||||
console.log(
|
||||
"Notification received while app running",
|
||||
notification,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
responseListener.current =
|
||||
Notifications?.addNotificationResponseReceivedListener(
|
||||
(response: NotificationResponse) => {
|
||||
// Currently the notifications supported by the plugin will send data for deep links.
|
||||
const { title, data } = response.notification.request.content;
|
||||
writeInfoLog(`Notification ${title} opened`, data);
|
||||
|
||||
let url: any;
|
||||
const type = (data?.type ?? "").toString().toLowerCase();
|
||||
const itemId = data?.id;
|
||||
|
||||
switch (type) {
|
||||
case "movie":
|
||||
url = `/(auth)/(tabs)/home/items/page?id=${itemId}`;
|
||||
break;
|
||||
case "episode":
|
||||
// `/(auth)/(tabs)/${from}/items/page?id=${item.Id}`;
|
||||
// We just clicked a notification for an individual episode.
|
||||
if (itemId) {
|
||||
url = `/(auth)/(tabs)/home/items/page?id=${itemId}`;
|
||||
// summarized season notification for multiple episodes. Bring them to series season
|
||||
} else {
|
||||
const seriesId = data.seriesId;
|
||||
const seasonIndex = data.seasonIndex;
|
||||
if (seasonIndex) {
|
||||
url = `/(auth)/(tabs)/home/series/${seriesId}?seasonIndex=${seasonIndex}`;
|
||||
} else {
|
||||
url = `/(auth)/(tabs)/home/series/${seriesId}`;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
writeInfoLog(`Notification attempting to redirect to ${url}`);
|
||||
if (url) {
|
||||
router.push(url);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
notificationListener.current?.remove();
|
||||
responseListener.current?.remove();
|
||||
};
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
return (
|
||||
<PersistQueryClientProvider
|
||||
client={queryClient}
|
||||
persistOptions={{
|
||||
persister: mmkvPersister,
|
||||
maxAge: 1000 * 60 * 60 * 24, // 24 hours max cache age
|
||||
dehydrateOptions: {
|
||||
shouldDehydrateQuery: (query) => {
|
||||
// Only persist successful queries
|
||||
return query.state.status === "success";
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<JellyfinProvider>
|
||||
<ServerUrlProvider>
|
||||
<NetworkStatusProvider>
|
||||
<PlaySettingsProvider>
|
||||
<LogProvider>
|
||||
<WebSocketProvider>
|
||||
<DownloadProvider>
|
||||
<MusicPlayerProvider>
|
||||
<GlobalModalProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<IntroSheetProvider>
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<SystemBars style='light' hidden={false} />
|
||||
<Stack initialRouteName='(auth)/(tabs)'>
|
||||
<Stack.Screen
|
||||
name='(auth)/(tabs)'
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
header: () => null,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='(auth)/player'
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
header: () => null,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='(auth)/now-playing'
|
||||
options={{
|
||||
headerShown: false,
|
||||
presentation: "modal",
|
||||
gestureEnabled: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='login'
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: "",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name='+not-found' />
|
||||
</Stack>
|
||||
<Toaster
|
||||
duration={4000}
|
||||
toastOptions={{
|
||||
style: {
|
||||
backgroundColor: "#262626",
|
||||
borderColor: "#363639",
|
||||
borderWidth: 1,
|
||||
},
|
||||
titleStyle: {
|
||||
color: "white",
|
||||
},
|
||||
}}
|
||||
closeButton
|
||||
/>
|
||||
<GlobalModal />
|
||||
</ThemeProvider>
|
||||
</IntroSheetProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</GlobalModalProvider>
|
||||
</MusicPlayerProvider>
|
||||
</DownloadProvider>
|
||||
</WebSocketProvider>
|
||||
</LogProvider>
|
||||
</PlaySettingsProvider>
|
||||
</NetworkStatusProvider>
|
||||
</ServerUrlProvider>
|
||||
</JellyfinProvider>
|
||||
</PersistQueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
650
app/login.tsx
650
app/login.tsx
@@ -1,28 +1,34 @@
|
||||
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { t } from "i18next";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Keyboard,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
Switch,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Input } from "@/components/common/Input";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
|
||||
import { PreviousServersList } from "@/components/PreviousServersList";
|
||||
import { SaveAccountModal } from "@/components/SaveAccountModal";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
||||
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
SafeAreaView,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Keyboard } from "react-native";
|
||||
import type {
|
||||
AccountSecurityType,
|
||||
SavedServer,
|
||||
} from "@/utils/secureCredentials";
|
||||
|
||||
import { z } from "zod";
|
||||
import { t } from "i18next";
|
||||
const CredentialsSchema = z.object({
|
||||
username: z.string().min(1, t("login.username_required")),
|
||||
});
|
||||
@@ -31,8 +37,14 @@ const Login: React.FC = () => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const navigation = useNavigation();
|
||||
const params = useLocalSearchParams();
|
||||
const { setServer, login, removeServer, initiateQuickConnect } =
|
||||
useJellyfin();
|
||||
const {
|
||||
setServer,
|
||||
login,
|
||||
removeServer,
|
||||
initiateQuickConnect,
|
||||
loginWithSavedCredential,
|
||||
loginWithPassword,
|
||||
} = useJellyfin();
|
||||
|
||||
const {
|
||||
apiUrl: _apiUrl,
|
||||
@@ -42,32 +54,41 @@ const Login: React.FC = () => {
|
||||
|
||||
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 [credentials, setCredentials] = useState<{
|
||||
username: string;
|
||||
password: string;
|
||||
}>({
|
||||
username: _username,
|
||||
password: _password,
|
||||
username: _username || "",
|
||||
password: _password || "",
|
||||
});
|
||||
|
||||
// Save account state
|
||||
const [saveAccount, setSaveAccount] = useState(false);
|
||||
const [showSaveModal, setShowSaveModal] = useState(false);
|
||||
const [pendingLogin, setPendingLogin] = useState<{
|
||||
username: string;
|
||||
password: string;
|
||||
} | null>(null);
|
||||
|
||||
/**
|
||||
* A way to auto login based on a link
|
||||
*/
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (_apiUrl) {
|
||||
setServer({
|
||||
await setServer({
|
||||
address: _apiUrl,
|
||||
});
|
||||
|
||||
// Wait for server setup and state updates to complete
|
||||
setTimeout(() => {
|
||||
if (_username && _password) {
|
||||
setCredentials({ username: _username, password: _password });
|
||||
login(_username, _password);
|
||||
}
|
||||
}, 300);
|
||||
}, 0);
|
||||
}
|
||||
})();
|
||||
}, [_apiUrl, _username, _password]);
|
||||
@@ -81,10 +102,10 @@ const Login: React.FC = () => {
|
||||
onPress={() => {
|
||||
removeServer();
|
||||
}}
|
||||
className="flex flex-row items-center"
|
||||
className='flex flex-row items-center pr-2 pl-1'
|
||||
>
|
||||
<Ionicons name="chevron-back" size={18} color={Colors.primary} />
|
||||
<Text className="ml-2 text-purple-600">
|
||||
<Ionicons name='chevron-back' size={18} color={Colors.primary} />
|
||||
<Text className=' ml-1 text-purple-600'>
|
||||
{t("login.change_server")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
@@ -95,23 +116,83 @@ const Login: React.FC = () => {
|
||||
const handleLogin = async () => {
|
||||
Keyboard.dismiss();
|
||||
|
||||
const result = CredentialsSchema.safeParse(credentials);
|
||||
if (!result.success) return;
|
||||
|
||||
if (saveAccount) {
|
||||
// Show save account modal to choose security type
|
||||
setPendingLogin({
|
||||
username: credentials.username,
|
||||
password: credentials.password,
|
||||
});
|
||||
setShowSaveModal(true);
|
||||
} else {
|
||||
// Login without saving
|
||||
await performLogin(credentials.username, credentials.password);
|
||||
}
|
||||
};
|
||||
|
||||
const performLogin = async (
|
||||
username: string,
|
||||
password: string,
|
||||
options?: {
|
||||
saveAccount?: boolean;
|
||||
securityType?: AccountSecurityType;
|
||||
pinCode?: string;
|
||||
},
|
||||
) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = CredentialsSchema.safeParse(credentials);
|
||||
if (result.success) {
|
||||
await login(credentials.username, credentials.password);
|
||||
}
|
||||
await login(username, password, serverName, options);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
Alert.alert(t("login.connection_failed"), error.message);
|
||||
} else {
|
||||
Alert.alert(
|
||||
t("login.connection_failed"),
|
||||
t("login.an_unexpected_error_occured")
|
||||
t("login.an_unexpected_error_occured"),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setPendingLogin(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveAccountConfirm = async (
|
||||
securityType: AccountSecurityType,
|
||||
pinCode?: string,
|
||||
) => {
|
||||
setShowSaveModal(false);
|
||||
if (pendingLogin) {
|
||||
await performLogin(pendingLogin.username, pendingLogin.password, {
|
||||
saveAccount: true,
|
||||
securityType,
|
||||
pinCode,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuickLoginWithSavedCredential = async (
|
||||
serverUrl: string,
|
||||
userId: string,
|
||||
) => {
|
||||
await loginWithSavedCredential(serverUrl, userId);
|
||||
};
|
||||
|
||||
const handlePasswordLogin = async (
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
) => {
|
||||
await loginWithPassword(serverUrl, username, password);
|
||||
};
|
||||
|
||||
const handleAddAccount = (server: SavedServer) => {
|
||||
// Server is already selected, go to credential entry
|
||||
setServer({ address: server.address });
|
||||
if (server.name) {
|
||||
setServerName(server.name);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -132,27 +213,52 @@ const Login: React.FC = () => {
|
||||
*/
|
||||
const checkUrl = useCallback(async (url: string) => {
|
||||
setLoadingServerCheck(true);
|
||||
|
||||
const baseUrl = url.replace(/^https?:\/\//i, "");
|
||||
const protocols = ["https", "http"];
|
||||
try {
|
||||
const response = await fetch(`${url}/System/Info/Public`, {
|
||||
mode: "cors",
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = (await response.json()) as PublicSystemInfo;
|
||||
|
||||
setServerName(data.ServerName || "");
|
||||
return url;
|
||||
return checkHttp(baseUrl, protocols);
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message === "Server too old") {
|
||||
throw e;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
} finally {
|
||||
setLoadingServerCheck(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
async function checkHttp(baseUrl: string, protocols: string[]) {
|
||||
for (const protocol of protocols) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${protocol}://${baseUrl}/System/Info/Public`,
|
||||
{
|
||||
mode: "cors",
|
||||
},
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = (await response.json()) as PublicSystemInfo;
|
||||
const serverVersion = data.Version?.split(".");
|
||||
if (serverVersion && +serverVersion[0] <= 10) {
|
||||
if (+serverVersion[1] < 10) {
|
||||
Alert.alert(
|
||||
t("login.too_old_server_text"),
|
||||
t("login.too_old_server_description"),
|
||||
);
|
||||
throw new Error("Server too old");
|
||||
}
|
||||
}
|
||||
setServerName(data.ServerName || "");
|
||||
return `${protocol}://${baseUrl}`;
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message === "Server too old") {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
/**
|
||||
* Handles the connection attempt to a Jellyfin server.
|
||||
*
|
||||
@@ -171,17 +277,17 @@ const Login: React.FC = () => {
|
||||
*/
|
||||
const handleConnect = useCallback(async (url: string) => {
|
||||
url = url.trim().replace(/\/$/, "");
|
||||
const result = await checkUrl(url);
|
||||
|
||||
if (result === undefined) {
|
||||
Alert.alert(
|
||||
t("login.connection_failed"),
|
||||
t("login.could_not_connect_to_server")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setServer({ address: url });
|
||||
try {
|
||||
const result = await checkUrl(url);
|
||||
if (result === undefined) {
|
||||
Alert.alert(
|
||||
t("login.connection_failed"),
|
||||
t("login.could_not_connect_to_server"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await setServer({ address: result });
|
||||
} catch {}
|
||||
}, []);
|
||||
|
||||
const handleQuickConnect = async () => {
|
||||
@@ -195,154 +301,358 @@ const Login: React.FC = () => {
|
||||
{
|
||||
text: t("login.got_it"),
|
||||
},
|
||||
]
|
||||
],
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
Alert.alert(
|
||||
t("login.error_title"),
|
||||
t("login.failed_to_initiate_quick_connect")
|
||||
t("login.failed_to_initiate_quick_connect"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, paddingBottom: 16 }}>
|
||||
return Platform.isTV ? (
|
||||
// TV layout
|
||||
<SafeAreaView className='flex-1 bg-black'>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
{api?.basePath ? (
|
||||
<>
|
||||
<View className="flex flex-col h-full relative items-center justify-center">
|
||||
<View className="px-4 -mt-20 w-full">
|
||||
<View className="flex flex-col space-y-2">
|
||||
<Text className="text-2xl font-bold -mb-2">
|
||||
<>
|
||||
{serverName ? (
|
||||
<>
|
||||
{t("login.login_to_title") + " "}
|
||||
<Text className="text-purple-600">{serverName}</Text>
|
||||
</>
|
||||
) : (
|
||||
t("login.login_title")
|
||||
)}
|
||||
</>
|
||||
</Text>
|
||||
<Text className="text-xs text-neutral-400">
|
||||
{api.basePath}
|
||||
</Text>
|
||||
<Input
|
||||
placeholder={t("login.username_placeholder")}
|
||||
onChangeText={(text) =>
|
||||
setCredentials({ ...credentials, username: text })
|
||||
}
|
||||
value={credentials.username}
|
||||
secureTextEntry={false}
|
||||
keyboardType="default"
|
||||
returnKeyType="done"
|
||||
autoCapitalize="none"
|
||||
textContentType="username"
|
||||
clearButtonMode="while-editing"
|
||||
maxLength={500}
|
||||
/>
|
||||
// ------------ Username/Password view ------------
|
||||
<View className='flex-1 items-center justify-center'>
|
||||
{/* Safe centered column with max width so TV doesn’t stretch too far */}
|
||||
<View className='w-[92%] max-w-[900px] px-2 -mt-12'>
|
||||
<Text className='text-3xl font-bold text-white mb-1'>
|
||||
{serverName ? (
|
||||
<>
|
||||
{`${t("login.login_to_title")} `}
|
||||
<Text className='text-purple-500'>{serverName}</Text>
|
||||
</>
|
||||
) : (
|
||||
t("login.login_title")
|
||||
)}
|
||||
</Text>
|
||||
<Text className='text-xs text-neutral-400 mb-6'>
|
||||
{api.basePath}
|
||||
</Text>
|
||||
|
||||
<Input
|
||||
placeholder={t("login.password_placeholder")}
|
||||
onChangeText={(text) =>
|
||||
setCredentials({ ...credentials, password: text })
|
||||
}
|
||||
value={credentials.password}
|
||||
secureTextEntry
|
||||
keyboardType="default"
|
||||
returnKeyType="done"
|
||||
autoCapitalize="none"
|
||||
textContentType="password"
|
||||
clearButtonMode="while-editing"
|
||||
maxLength={500}
|
||||
/>
|
||||
<View className="flex flex-row items-center justify-between">
|
||||
<Button
|
||||
onPress={handleLogin}
|
||||
loading={loading}
|
||||
className="flex-1 mr-2"
|
||||
>
|
||||
{t("login.login_button")}
|
||||
</Button>
|
||||
<TouchableOpacity
|
||||
onPress={handleQuickConnect}
|
||||
className="p-2 bg-neutral-900 rounded-xl h-12 w-12 flex items-center justify-center"
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="cellphone-lock"
|
||||
size={24}
|
||||
color="white"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
{/* Username */}
|
||||
<Input
|
||||
placeholder={t("login.username_placeholder")}
|
||||
onChangeText={(text: string) =>
|
||||
setCredentials((prev) => ({ ...prev, username: text }))
|
||||
}
|
||||
onEndEditing={(e) => {
|
||||
const newValue = e.nativeEvent.text;
|
||||
if (newValue && newValue !== credentials.username) {
|
||||
setCredentials((prev) => ({ ...prev, username: newValue }));
|
||||
}
|
||||
}}
|
||||
value={credentials.username}
|
||||
keyboardType='default'
|
||||
returnKeyType='done'
|
||||
autoCapitalize='none'
|
||||
autoCorrect={false}
|
||||
textContentType='username'
|
||||
clearButtonMode='while-editing'
|
||||
maxLength={500}
|
||||
extraClassName='mb-4'
|
||||
autoFocus={false}
|
||||
blurOnSubmit={true}
|
||||
/>
|
||||
|
||||
{/* Password */}
|
||||
<Input
|
||||
placeholder={t("login.password_placeholder")}
|
||||
onChangeText={(text: string) =>
|
||||
setCredentials((prev) => ({ ...prev, password: text }))
|
||||
}
|
||||
onEndEditing={(e) => {
|
||||
const newValue = e.nativeEvent.text;
|
||||
if (newValue && newValue !== credentials.password) {
|
||||
setCredentials((prev) => ({ ...prev, password: newValue }));
|
||||
}
|
||||
}}
|
||||
value={credentials.password}
|
||||
secureTextEntry
|
||||
keyboardType='default'
|
||||
returnKeyType='done'
|
||||
autoCapitalize='none'
|
||||
textContentType='password'
|
||||
clearButtonMode='while-editing'
|
||||
maxLength={500}
|
||||
extraClassName='mb-4'
|
||||
autoFocus={false}
|
||||
blurOnSubmit={true}
|
||||
/>
|
||||
|
||||
<View className='mt-4'>
|
||||
<Button
|
||||
onPress={handleLogin}
|
||||
disabled={!credentials.username.trim()}
|
||||
>
|
||||
{t("login.login_button")}
|
||||
</Button>
|
||||
</View>
|
||||
<View className='mt-3'>
|
||||
<Button
|
||||
onPress={handleQuickConnect}
|
||||
className='bg-neutral-800 border border-neutral-700'
|
||||
>
|
||||
{t("login.quick_connect")}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
// ------------ Server connect view ------------
|
||||
<View className='flex-1 items-center justify-center'>
|
||||
<View className='w-[92%] max-w-[900px] -mt-2'>
|
||||
<View className='items-center mb-1'>
|
||||
<Image
|
||||
source={require("@/assets/images/icon-ios-plain.png")}
|
||||
style={{ width: 110, height: 110 }}
|
||||
contentFit='contain'
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="absolute bottom-0 left-0 w-full px-4 mb-2"></View>
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<View className="flex flex-col h-full items-center justify-center w-full">
|
||||
<View className="flex flex-col gap-y-2 px-4 w-full -mt-36">
|
||||
<Image
|
||||
style={{
|
||||
width: 100,
|
||||
height: 100,
|
||||
marginLeft: -23,
|
||||
marginBottom: -20,
|
||||
}}
|
||||
source={require("@/assets/images/StreamyFinFinal.png")}
|
||||
/>
|
||||
<Text className="text-3xl font-bold">Streamyfin</Text>
|
||||
<Text className="text-neutral-500">
|
||||
{t("server.enter_url_to_jellyfin_server")}
|
||||
</Text>
|
||||
<Input
|
||||
aria-label="Server URL"
|
||||
placeholder={t("server.server_url_placeholder")}
|
||||
onChangeText={setServerURL}
|
||||
value={serverURL}
|
||||
keyboardType="url"
|
||||
returnKeyType="done"
|
||||
autoCapitalize="none"
|
||||
textContentType="URL"
|
||||
maxLength={500}
|
||||
/>
|
||||
<Text className='text-white text-4xl font-bold text-center'>
|
||||
Streamyfin
|
||||
</Text>
|
||||
<Text className='text-neutral-400 text-base text-left mt-2 mb-1'>
|
||||
{t("server.enter_url_to_jellyfin_server")}
|
||||
</Text>
|
||||
|
||||
{/* Full-width Input with clear focus ring */}
|
||||
<Input
|
||||
aria-label='Server URL'
|
||||
placeholder={t("server.server_url_placeholder")}
|
||||
onChangeText={setServerURL}
|
||||
value={serverURL}
|
||||
keyboardType='url'
|
||||
returnKeyType='done'
|
||||
autoCapitalize='none'
|
||||
textContentType='URL'
|
||||
maxLength={500}
|
||||
autoFocus={false}
|
||||
blurOnSubmit={true}
|
||||
/>
|
||||
|
||||
{/* Full-width primary button */}
|
||||
<View className='mt-4'>
|
||||
<Button
|
||||
loading={loadingServerCheck}
|
||||
disabled={loadingServerCheck}
|
||||
onPress={async () => {
|
||||
await handleConnect(serverURL);
|
||||
}}
|
||||
className="w-full grow"
|
||||
>
|
||||
{t("server.connect_button")}
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
{/* Lists stay full width but inside max width container */}
|
||||
<View className='mt-2'>
|
||||
<JellyfinServerDiscovery
|
||||
onServerSelect={(server) => {
|
||||
onServerSelect={async (server: any) => {
|
||||
setServerURL(server.address);
|
||||
if (server.serverName) {
|
||||
setServerName(server.serverName);
|
||||
}
|
||||
handleConnect(server.address);
|
||||
if (server.serverName) setServerName(server.serverName);
|
||||
await handleConnect(server.address);
|
||||
}}
|
||||
/>
|
||||
<PreviousServersList
|
||||
onServerSelect={(s) => {
|
||||
handleConnect(s.address);
|
||||
onServerSelect={async (s) => {
|
||||
await handleConnect(s.address);
|
||||
}}
|
||||
onQuickLogin={handleQuickLoginWithSavedCredential}
|
||||
onPasswordLogin={handlePasswordLogin}
|
||||
onAddAccount={handleAddAccount}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
</View>
|
||||
)}
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
) : (
|
||||
// Mobile layout
|
||||
<SafeAreaView style={{ flex: 1, paddingBottom: 16 }}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
{api?.basePath ? (
|
||||
<View className='flex flex-col flex-1 justify-center'>
|
||||
<View className='px-4 w-full'>
|
||||
<View className='flex flex-col space-y-2'>
|
||||
<Text className='text-2xl font-bold -mb-2'>
|
||||
{serverName ? (
|
||||
<>
|
||||
{`${t("login.login_to_title")} `}
|
||||
<Text className='text-purple-600'>{serverName}</Text>
|
||||
</>
|
||||
) : (
|
||||
t("login.login_title")
|
||||
)}
|
||||
</Text>
|
||||
<Text className='text-xs text-neutral-400'>{api.basePath}</Text>
|
||||
<Input
|
||||
placeholder={t("login.username_placeholder")}
|
||||
onChangeText={(text) =>
|
||||
setCredentials((prev) => ({ ...prev, username: text }))
|
||||
}
|
||||
onEndEditing={(e) => {
|
||||
const newValue = e.nativeEvent.text;
|
||||
if (newValue && newValue !== credentials.username) {
|
||||
setCredentials((prev) => ({
|
||||
...prev,
|
||||
username: newValue,
|
||||
}));
|
||||
}
|
||||
}}
|
||||
value={credentials.username}
|
||||
keyboardType='default'
|
||||
returnKeyType='done'
|
||||
autoCapitalize='none'
|
||||
autoCorrect={false}
|
||||
textContentType='username'
|
||||
clearButtonMode='while-editing'
|
||||
maxLength={500}
|
||||
/>
|
||||
|
||||
<Input
|
||||
placeholder={t("login.password_placeholder")}
|
||||
onChangeText={(text) =>
|
||||
setCredentials((prev) => ({ ...prev, password: text }))
|
||||
}
|
||||
onEndEditing={(e) => {
|
||||
const newValue = e.nativeEvent.text;
|
||||
if (newValue && newValue !== credentials.password) {
|
||||
setCredentials((prev) => ({
|
||||
...prev,
|
||||
password: newValue,
|
||||
}));
|
||||
}
|
||||
}}
|
||||
value={credentials.password}
|
||||
secureTextEntry
|
||||
keyboardType='default'
|
||||
returnKeyType='done'
|
||||
autoCapitalize='none'
|
||||
textContentType='password'
|
||||
clearButtonMode='while-editing'
|
||||
maxLength={500}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
onPress={() => setSaveAccount(!saveAccount)}
|
||||
className='flex flex-row items-center py-2'
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Switch
|
||||
value={saveAccount}
|
||||
onValueChange={setSaveAccount}
|
||||
trackColor={{ false: "#3f3f46", true: Colors.primary }}
|
||||
thumbColor='white'
|
||||
/>
|
||||
<Text className='ml-3 text-neutral-300'>
|
||||
{t("save_account.save_for_later")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<View className='flex flex-row items-center justify-between'>
|
||||
<Button
|
||||
onPress={handleLogin}
|
||||
loading={loading}
|
||||
disabled={!credentials.username.trim()}
|
||||
className='flex-1 mr-2'
|
||||
>
|
||||
{t("login.login_button")}
|
||||
</Button>
|
||||
<TouchableOpacity
|
||||
onPress={handleQuickConnect}
|
||||
className='p-2 bg-neutral-900 rounded-xl h-12 w-12 flex items-center justify-center'
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name='cellphone-lock'
|
||||
size={24}
|
||||
color='white'
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='absolute bottom-0 left-0 w-full px-4 mb-2' />
|
||||
</View>
|
||||
) : (
|
||||
<View className='flex flex-col flex-1 items-center justify-center w-full'>
|
||||
<View className='flex flex-col gap-y-2 px-4 w-full -mt-36'>
|
||||
<Image
|
||||
style={{
|
||||
width: 100,
|
||||
height: 100,
|
||||
marginLeft: -23,
|
||||
marginBottom: -20,
|
||||
}}
|
||||
source={require("@/assets/images/icon-ios-plain.png")}
|
||||
/>
|
||||
<Text className='text-3xl font-bold'>Streamyfin</Text>
|
||||
<Text className='text-neutral-500'>
|
||||
{t("server.enter_url_to_jellyfin_server")}
|
||||
</Text>
|
||||
<Input
|
||||
aria-label='Server URL'
|
||||
placeholder={t("server.server_url_placeholder")}
|
||||
onChangeText={setServerURL}
|
||||
value={serverURL}
|
||||
keyboardType='url'
|
||||
returnKeyType='done'
|
||||
autoCapitalize='none'
|
||||
textContentType='URL'
|
||||
maxLength={500}
|
||||
/>
|
||||
<Button
|
||||
loading={loadingServerCheck}
|
||||
disabled={loadingServerCheck}
|
||||
onPress={async () => {
|
||||
await handleConnect(serverURL);
|
||||
}}
|
||||
className='w-full grow'
|
||||
>
|
||||
{t("server.connect_button")}
|
||||
</Button>
|
||||
<JellyfinServerDiscovery
|
||||
onServerSelect={async (server) => {
|
||||
setServerURL(server.address);
|
||||
if (server.serverName) {
|
||||
setServerName(server.serverName);
|
||||
}
|
||||
await handleConnect(server.address);
|
||||
}}
|
||||
/>
|
||||
<PreviousServersList
|
||||
onServerSelect={async (s) => {
|
||||
await handleConnect(s.address);
|
||||
}}
|
||||
onQuickLogin={handleQuickLoginWithSavedCredential}
|
||||
onPasswordLogin={handlePasswordLogin}
|
||||
onAddAccount={handleAddAccount}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
{/* Save Account Modal */}
|
||||
<SaveAccountModal
|
||||
visible={showSaveModal}
|
||||
onClose={() => {
|
||||
setShowSaveModal(false);
|
||||
setPendingLogin(null);
|
||||
}}
|
||||
onSave={handleSaveAccountConfirm}
|
||||
username={pendingLogin?.username || credentials.username}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 129 KiB |
@@ -1,22 +1,22 @@
|
||||
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useFavorite } from "@/hooks/useFavorite";
|
||||
import { View } from "react-native";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import type { FC } from "react";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { RoundButton } from "@/components/RoundButton";
|
||||
import { useFavorite } from "@/hooks/useFavorite";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
item: BaseItemDto;
|
||||
}
|
||||
|
||||
export const AddToFavorites = ({ item, ...props }) => {
|
||||
const { isFavorite, toggleFavorite, _} = useFavorite(item);
|
||||
|
||||
export const AddToFavorites: FC<Props> = ({ item, ...props }) => {
|
||||
const { isFavorite, toggleFavorite } = useFavorite(item);
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<RoundButton
|
||||
size="large"
|
||||
size='large'
|
||||
icon={isFavorite ? "heart" : "heart-outline"}
|
||||
fillColor={isFavorite ? "primary" : undefined}
|
||||
color={isFavorite ? "purple" : "white"}
|
||||
onPress={toggleFavorite}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import type { PlaybackProgressInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import { Pressable } from "react-native-gesture-handler";
|
||||
import GoogleCast, {
|
||||
@@ -13,9 +10,7 @@ import GoogleCast, {
|
||||
useMediaStatus,
|
||||
useRemoteMediaClient,
|
||||
} from "react-native-google-cast";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { RoundButton } from "./RoundButton";
|
||||
import { useRouter } from "expo-router";
|
||||
|
||||
export function Chromecast({
|
||||
width = 48,
|
||||
@@ -29,12 +24,6 @@ export function Chromecast({
|
||||
const sessionManager = GoogleCast.getSessionManager();
|
||||
const discoveryManager = GoogleCast.getDiscoveryManager();
|
||||
const mediaStatus = useMediaStatus();
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
|
||||
const lastReportedProgressRef = useRef(0);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
@@ -47,53 +36,6 @@ export function Chromecast({
|
||||
})();
|
||||
}, [client, devices, castDevice, sessionManager, discoveryManager]);
|
||||
|
||||
// Report video progress to Jellyfin server
|
||||
useEffect(() => {
|
||||
if (
|
||||
!api ||
|
||||
!user?.Id ||
|
||||
!mediaStatus ||
|
||||
!mediaStatus.mediaInfo?.contentId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const streamPosition = mediaStatus.streamPosition || 0;
|
||||
|
||||
// Report every 10 seconds
|
||||
if (Math.abs(streamPosition - lastReportedProgressRef.current) < 10) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contentId = mediaStatus.mediaInfo.contentId;
|
||||
const positionTicks = Math.floor(streamPosition * 10000000);
|
||||
const isPaused = mediaStatus.playerState === "paused";
|
||||
const streamUrl = mediaStatus.mediaInfo.contentUrl || "";
|
||||
const isTranscoding = streamUrl.includes("m3u8");
|
||||
|
||||
const progressInfo: PlaybackProgressInfo = {
|
||||
ItemId: contentId,
|
||||
PositionTicks: positionTicks,
|
||||
IsPaused: isPaused,
|
||||
PlayMethod: isTranscoding ? "Transcode" : "DirectStream",
|
||||
PlaySessionId: contentId,
|
||||
};
|
||||
|
||||
getPlaystateApi(api)
|
||||
.reportPlaybackProgress({ playbackProgressInfo: progressInfo })
|
||||
.then(() => {
|
||||
lastReportedProgressRef.current = streamPosition;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to report Chromecast progress:", error);
|
||||
});
|
||||
}, [
|
||||
api,
|
||||
user?.Id,
|
||||
mediaStatus?.streamPosition,
|
||||
mediaStatus?.mediaInfo?.contentId,
|
||||
]);
|
||||
|
||||
// Android requires the cast button to be present for startDiscovery to work
|
||||
const AndroidCastButton = useCallback(
|
||||
() =>
|
||||
@@ -124,7 +66,7 @@ export function Chromecast({
|
||||
className='mr-2'
|
||||
background={false}
|
||||
onPress={() => {
|
||||
if (mediaStatus?.currentItemId) router.push('/player/google-cast-player');
|
||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||
else CastContext.showCastDialog();
|
||||
}}
|
||||
{...props}
|
||||
@@ -138,7 +80,7 @@ export function Chromecast({
|
||||
<RoundButton
|
||||
size='large'
|
||||
onPress={() => {
|
||||
if (mediaStatus?.currentItemId) router.push('/player/google-cast-player');
|
||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||
else CastContext.showCastDialog();
|
||||
}}
|
||||
{...props}
|
||||
|
||||
@@ -1,897 +0,0 @@
|
||||
import React, { useMemo, useRef, useState } from "react";
|
||||
import { Alert, TouchableOpacity, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||
import { RoundButton } from "@/components/RoundButton";
|
||||
|
||||
import {
|
||||
CastButton,
|
||||
CastContext,
|
||||
MediaStatus,
|
||||
RemoteMediaClient,
|
||||
useStreamPosition,
|
||||
} from "react-native-google-cast";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import { Image } from "expo-image";
|
||||
import { Slider } from "react-native-awesome-slider";
|
||||
import {
|
||||
runOnJS,
|
||||
SharedValue,
|
||||
useAnimatedReaction,
|
||||
useSharedValue,
|
||||
} from "react-native-reanimated";
|
||||
import { debounce } from "lodash";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import { formatTimeString } from "@/utils/time";
|
||||
import SkipButton from "@/components/video-player/controls/SkipButton";
|
||||
import NextEpisodeCountDownButton from "@/components/video-player/controls/NextEpisodeCountDownButton";
|
||||
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
|
||||
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
|
||||
import { useAdjacentItems } from "@/hooks/useAdjacentEpisodes";
|
||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||
import { secondsToTicks } from "@/utils/secondsToTicks";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { chromecastLoadMedia } from "@/utils/chromecastLoadMedia";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { chromecast as chromecastProfile } from "@/utils/profiles/chromecast";
|
||||
import { SelectedOptions } from "./ItemContent";
|
||||
import {
|
||||
getDefaultPlaySettings,
|
||||
previousIndexes,
|
||||
} from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
getPlaystateApi,
|
||||
getUserLibraryApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { useRouter } from "expo-router";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
import { ItemImage } from "@/components/common/ItemImage";
|
||||
import { BitrateSelector } from "@/components/BitrateSelector";
|
||||
import { ItemHeader } from "@/components/ItemHeader";
|
||||
import { MediaSourceSelector } from "@/components/MediaSourceSelector";
|
||||
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
||||
import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector";
|
||||
import { ItemTechnicalDetails } from "@/components/ItemTechnicalDetails";
|
||||
import { OverviewText } from "@/components/OverviewText";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { PlayedStatus } from "./PlayedStatus";
|
||||
import { AddToFavorites } from "./AddToFavorites";
|
||||
|
||||
export default function ChromecastControls({
|
||||
mediaStatus,
|
||||
client,
|
||||
setWasMediaPlaying,
|
||||
reportPlaybackStopedRef,
|
||||
}: {
|
||||
mediaStatus: MediaStatus;
|
||||
client: RemoteMediaClient;
|
||||
setWasMediaPlaying: (wasPlaying: boolean) => void;
|
||||
reportPlaybackStopedRef: React.MutableRefObject<() => void>;
|
||||
}) {
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
|
||||
const [settings] = useSettings();
|
||||
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [remainingTime, setRemainingTime] = useState(Infinity);
|
||||
const max = useSharedValue(mediaStatus.mediaInfo?.streamDuration || 0);
|
||||
|
||||
const streamPosition = useStreamPosition();
|
||||
const progress = useSharedValue(streamPosition || 0);
|
||||
|
||||
const wasPlayingRef = useRef(false);
|
||||
|
||||
const isSeeking = useSharedValue(false);
|
||||
const isPlaying = useMemo(
|
||||
() => mediaStatus.playerState === "playing",
|
||||
[mediaStatus.playerState]
|
||||
);
|
||||
const isBufferingOrLoading = useMemo(
|
||||
() =>
|
||||
mediaStatus.playerState === null ||
|
||||
mediaStatus.playerState === "buffering" ||
|
||||
mediaStatus.playerState === "loading",
|
||||
[mediaStatus.playerState]
|
||||
);
|
||||
|
||||
// request update of media status every player state change
|
||||
useEffect(() => {
|
||||
client.requestStatus();
|
||||
}, [mediaStatus.playerState]);
|
||||
|
||||
// update max progress
|
||||
useEffect(() => {
|
||||
if (mediaStatus.mediaInfo?.streamDuration)
|
||||
max.value = mediaStatus.mediaInfo?.streamDuration;
|
||||
}, [mediaStatus.mediaInfo?.streamDuration]);
|
||||
|
||||
const updateTimes = useCallback(
|
||||
(currentProgress: number, maxValue: number) => {
|
||||
setCurrentTime(currentProgress);
|
||||
setRemainingTime(maxValue - currentProgress);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useAnimatedReaction(
|
||||
() => ({
|
||||
progress: progress.value,
|
||||
max: max.value,
|
||||
isSeeking: isSeeking.value,
|
||||
}),
|
||||
(result) => {
|
||||
if (result.isSeeking === false) {
|
||||
runOnJS(updateTimes)(result.progress, result.max);
|
||||
}
|
||||
},
|
||||
[updateTimes]
|
||||
);
|
||||
|
||||
const { mediaMetadata, itemId, streamURL } = useMemo(
|
||||
() => ({
|
||||
mediaMetadata: mediaStatus.mediaInfo?.metadata,
|
||||
itemId: mediaStatus.mediaInfo?.contentId,
|
||||
streamURL: mediaStatus.mediaInfo?.contentUrl,
|
||||
}),
|
||||
[mediaStatus]
|
||||
);
|
||||
|
||||
const type = useMemo(
|
||||
() => mediaMetadata?.type || "generic",
|
||||
[mediaMetadata?.type]
|
||||
);
|
||||
const images = useMemo(
|
||||
() => mediaMetadata?.images || [],
|
||||
[mediaMetadata?.images]
|
||||
);
|
||||
|
||||
const { playbackOptions, sessionId, mediaSourceId } = useMemo(() => {
|
||||
const mediaCustomData = mediaStatus.mediaInfo?.customData as
|
||||
| {
|
||||
playbackOptions: SelectedOptions;
|
||||
sessionId?: string;
|
||||
mediaSourceId?: string;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
return (
|
||||
mediaCustomData || {
|
||||
playbackOptions: undefined,
|
||||
sessionId: undefined,
|
||||
mediaSourceId: undefined,
|
||||
}
|
||||
);
|
||||
}, [mediaStatus.mediaInfo?.customData]);
|
||||
|
||||
const {
|
||||
data: item,
|
||||
// currently nothing is indicating that item is loading, because most of the time it loads very fast
|
||||
isLoading: isLoadingItem,
|
||||
isError: isErrorItem,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ["item", itemId],
|
||||
queryFn: async () => {
|
||||
if (!itemId) return;
|
||||
const res = await getUserLibraryApi(api!).getItem({
|
||||
itemId,
|
||||
userId: user?.Id,
|
||||
});
|
||||
|
||||
return res.data;
|
||||
},
|
||||
enabled: !!itemId,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const onProgress = useCallback(
|
||||
async (progressInTicks: number, isPlaying: boolean) => {
|
||||
if (!item?.Id || !streamURL) return;
|
||||
|
||||
await getPlaystateApi(api!).onPlaybackProgress({
|
||||
itemId: item.Id,
|
||||
audioStreamIndex: playbackOptions?.audioIndex,
|
||||
subtitleStreamIndex: playbackOptions?.subtitleIndex,
|
||||
mediaSourceId,
|
||||
positionTicks: Math.floor(progressInTicks),
|
||||
isPaused: !isPlaying,
|
||||
playMethod: streamURL.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||
playSessionId: sessionId,
|
||||
});
|
||||
},
|
||||
[api, item, playbackOptions, mediaSourceId, streamURL, sessionId]
|
||||
);
|
||||
|
||||
// update progess on stream position change
|
||||
useEffect(() => {
|
||||
if (streamPosition) {
|
||||
progress.value = streamPosition;
|
||||
onProgress(secondsToTicks(streamPosition), isPlaying);
|
||||
}
|
||||
}, [streamPosition, isPlaying]);
|
||||
|
||||
const reportPlaybackStart = useCallback(async () => {
|
||||
if (!streamURL) return;
|
||||
|
||||
await getPlaystateApi(api!).onPlaybackStart({
|
||||
itemId: item?.Id!,
|
||||
audioStreamIndex: playbackOptions?.audioIndex,
|
||||
subtitleStreamIndex: playbackOptions?.subtitleIndex,
|
||||
mediaSourceId,
|
||||
playMethod: streamURL.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||
playSessionId: sessionId,
|
||||
});
|
||||
}, [api, item, playbackOptions, mediaSourceId, streamURL, sessionId]);
|
||||
|
||||
// report playback started
|
||||
useEffect(() => {
|
||||
setWasMediaPlaying(true);
|
||||
reportPlaybackStart();
|
||||
}, [reportPlaybackStart]);
|
||||
|
||||
// update the reportPlaybackStoppedRef
|
||||
useEffect(() => {
|
||||
reportPlaybackStopedRef.current = async () => {
|
||||
if (!streamURL) return;
|
||||
|
||||
await getPlaystateApi(api!).onPlaybackStopped({
|
||||
itemId: item?.Id!,
|
||||
mediaSourceId,
|
||||
positionTicks: secondsToTicks(progress.value),
|
||||
playSessionId: sessionId,
|
||||
});
|
||||
};
|
||||
}, [
|
||||
api,
|
||||
item,
|
||||
playbackOptions,
|
||||
progress,
|
||||
mediaSourceId,
|
||||
streamURL,
|
||||
sessionId,
|
||||
]);
|
||||
|
||||
const { previousItem, nextItem } = useAdjacentItems({
|
||||
item: {
|
||||
Id: itemId,
|
||||
SeriesId: item?.SeriesId,
|
||||
Type: item?.Type,
|
||||
},
|
||||
});
|
||||
|
||||
const goToItem = useCallback(
|
||||
async (item: BaseItemDto) => {
|
||||
if (!api) {
|
||||
console.warn("Failed to go to item: No api!");
|
||||
return;
|
||||
}
|
||||
|
||||
const previousIndexes: previousIndexes = {
|
||||
subtitleIndex: playbackOptions?.subtitleIndex || undefined,
|
||||
audioIndex: playbackOptions?.audioIndex || undefined,
|
||||
};
|
||||
|
||||
const {
|
||||
mediaSource,
|
||||
audioIndex: defaultAudioIndex,
|
||||
subtitleIndex: defaultSubtitleIndex,
|
||||
} = getDefaultPlaySettings(item, settings, previousIndexes, undefined);
|
||||
|
||||
// Get a new URL with the Chromecast device profile:
|
||||
const data = await getStreamUrl({
|
||||
api,
|
||||
item,
|
||||
deviceProfile: chromecastProfile,
|
||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
||||
userId: user?.Id,
|
||||
audioStreamIndex: defaultAudioIndex,
|
||||
// maxStreamingBitrate: playbackOptions.bitrate?.value, // TODO handle bitrate limit
|
||||
subtitleStreamIndex: defaultSubtitleIndex,
|
||||
mediaSourceId: mediaSource?.Id,
|
||||
});
|
||||
|
||||
if (!data?.url) {
|
||||
console.warn("No URL returned from getStreamUrl", data);
|
||||
Alert.alert("Client error", "Could not create stream for Chromecast");
|
||||
return;
|
||||
}
|
||||
|
||||
await chromecastLoadMedia({
|
||||
client,
|
||||
item,
|
||||
contentUrl: data.url,
|
||||
sessionId: data.sessionId || undefined,
|
||||
mediaSourceId: data.mediaSource?.Id || undefined,
|
||||
playbackOptions,
|
||||
images: [
|
||||
{
|
||||
url: getParentBackdropImageUrl({
|
||||
api,
|
||||
item,
|
||||
quality: 90,
|
||||
width: 2000,
|
||||
})!,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await client.requestStatus();
|
||||
},
|
||||
[client, api]
|
||||
);
|
||||
|
||||
const goToNextItem = useCallback(() => {
|
||||
if (!nextItem) {
|
||||
console.warn("Failed to skip to next item: No next item!");
|
||||
return;
|
||||
}
|
||||
lightHapticFeedback();
|
||||
goToItem(nextItem);
|
||||
}, [nextItem, lightHapticFeedback]);
|
||||
|
||||
const goToPreviousItem = useCallback(() => {
|
||||
if (!previousItem) {
|
||||
console.warn("Failed to skip to next item: No next item!");
|
||||
return;
|
||||
}
|
||||
lightHapticFeedback();
|
||||
goToItem(previousItem);
|
||||
}, [previousItem, lightHapticFeedback]);
|
||||
|
||||
const pause = useCallback(() => {
|
||||
client.pause();
|
||||
}, [client]);
|
||||
|
||||
const play = useCallback(() => {
|
||||
client.play();
|
||||
}, [client]);
|
||||
|
||||
const seek = useCallback(
|
||||
(time: number) => {
|
||||
// skip to next episode if seeking to end (for credit skipping)
|
||||
// with 1 second room to react
|
||||
if (nextItem && time >= max.value - 1) {
|
||||
goToNextItem();
|
||||
return;
|
||||
}
|
||||
client.seek({
|
||||
position: time,
|
||||
});
|
||||
},
|
||||
[client, goToNextItem, nextItem, max]
|
||||
);
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
if (isPlaying) pause();
|
||||
else play();
|
||||
}, [isPlaying, play, pause]);
|
||||
|
||||
const handleSkipBackward = useCallback(async () => {
|
||||
if (!settings?.rewindSkipTime) return;
|
||||
wasPlayingRef.current = isPlaying;
|
||||
lightHapticFeedback();
|
||||
try {
|
||||
const curr = progress.value;
|
||||
if (curr !== undefined) {
|
||||
const newTime = Math.max(0, curr - settings.rewindSkipTime);
|
||||
seek(newTime);
|
||||
if (wasPlayingRef.current === true) play();
|
||||
}
|
||||
} catch (error) {
|
||||
writeToLog("ERROR", "Error seeking video backwards", error);
|
||||
}
|
||||
}, [settings, isPlaying]);
|
||||
|
||||
const handleSkipForward = useCallback(async () => {
|
||||
if (!settings?.forwardSkipTime) return;
|
||||
wasPlayingRef.current = isPlaying;
|
||||
lightHapticFeedback();
|
||||
try {
|
||||
const curr = progress.value;
|
||||
if (curr !== undefined) {
|
||||
const newTime = curr + settings.forwardSkipTime;
|
||||
seek(Math.max(0, newTime));
|
||||
if (wasPlayingRef.current === true) play();
|
||||
}
|
||||
} catch (error) {
|
||||
writeToLog("ERROR", "Error seeking video forwards", error);
|
||||
}
|
||||
}, [settings, isPlaying]);
|
||||
|
||||
const { showSkipButton, skipIntro } = useIntroSkipper(
|
||||
itemId,
|
||||
currentTime,
|
||||
seek,
|
||||
play,
|
||||
false
|
||||
);
|
||||
|
||||
const { showSkipCreditButton, skipCredit } = useCreditSkipper(
|
||||
itemId,
|
||||
currentTime,
|
||||
seek,
|
||||
play,
|
||||
false
|
||||
);
|
||||
|
||||
// Android requires the cast button to be present for startDiscovery to work
|
||||
const AndroidCastButton = useCallback(
|
||||
() =>
|
||||
Platform.OS === "android" ? (
|
||||
<CastButton tintColor="transparent" />
|
||||
) : (
|
||||
<></>
|
||||
),
|
||||
[Platform.OS]
|
||||
);
|
||||
|
||||
const TrickplaySliderMemoized = useMemo(
|
||||
() => (
|
||||
<TrickplaySlider
|
||||
item={item}
|
||||
progress={progress}
|
||||
wasPlayingRef={wasPlayingRef}
|
||||
isPlaying={isPlaying}
|
||||
isSeeking={isSeeking}
|
||||
range={{ max }}
|
||||
play={play}
|
||||
pause={pause}
|
||||
seek={seek}
|
||||
/>
|
||||
),
|
||||
[
|
||||
item,
|
||||
progress,
|
||||
wasPlayingRef,
|
||||
isPlaying,
|
||||
isSeeking,
|
||||
max,
|
||||
play,
|
||||
pause,
|
||||
seek,
|
||||
]
|
||||
);
|
||||
|
||||
const NextEpisodeButtonMemoized = useMemo(
|
||||
() => (
|
||||
<NextEpisodeCountDownButton
|
||||
show={nextItem !== null && max.value > 0 && remainingTime < 10}
|
||||
onFinish={goToNextItem}
|
||||
onPress={goToNextItem}
|
||||
/>
|
||||
),
|
||||
[nextItem, max, remainingTime, goToNextItem]
|
||||
);
|
||||
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const [loadingLogo, setLoadingLogo] = useState(true);
|
||||
const [headerHeight, setHeaderHeight] = useState(350);
|
||||
|
||||
const logoUrl = useMemo(() => images[0]?.url, [images]);
|
||||
|
||||
if (isErrorItem) {
|
||||
return (
|
||||
<View className="w-full h-full flex flex-col items-center justify-center bg-black">
|
||||
<View className="p-12 flex gap-4">
|
||||
<Text className="text-center font-semibold text-red-500 text-lg">
|
||||
{t("chromecast.error_loading_item")}
|
||||
</Text>
|
||||
{error && (
|
||||
<Text className="text-center opacity-80">{error.message}</Text>
|
||||
)}
|
||||
</View>
|
||||
<View className="flex gap-2 mt-auto mb-20">
|
||||
<TouchableOpacity
|
||||
className="flex flex-row items-center justify-center gap-2"
|
||||
onPress={() => refetch()}
|
||||
>
|
||||
<Ionicons name="reload" size={24} color={Colors.primary} />
|
||||
<Text className="ml-2 text-purple-600 text-lg">
|
||||
{t("chromecast.retry_load_item")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
className="flex flex-row items-center justify-center gap-2"
|
||||
onPress={() => {
|
||||
router.push("/(auth)/(home)/");
|
||||
}}
|
||||
>
|
||||
<Ionicons name="home" size={16} color={Colors.text} />
|
||||
<Text className="ml-2 text-white text-sm underline">
|
||||
{t("chromecast.go_home")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!item) {
|
||||
return <Text>Do something when item is undefined</Text>;
|
||||
}
|
||||
|
||||
if (!playbackOptions) {
|
||||
return <Text>Do something when playbackOptions is undefined</Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
className="flex-1 relative"
|
||||
style={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
{/* TODO do navigation header properly */}
|
||||
<View
|
||||
className="flex flex-row justify-between absolute w-full top-2 z-50"
|
||||
style={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<RoundButton size="large" icon="arrow-back" />
|
||||
{item.Type !== "Program" && (
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
<RoundButton
|
||||
size="large"
|
||||
onPress={() => {
|
||||
CastContext.showCastDialog();
|
||||
}}
|
||||
>
|
||||
<AndroidCastButton />
|
||||
<Feather name="cast" size={24} color={"white"} />
|
||||
</RoundButton>
|
||||
<PlayedStatus items={[item]} size="large" />
|
||||
<AddToFavorites item={item} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<ParallaxScrollView
|
||||
className={`flex-1 ${loadingLogo ? "opacity-0" : "opacity-100"}`}
|
||||
headerHeight={headerHeight}
|
||||
headerImage={
|
||||
<View style={[{ flex: 1 }]}>
|
||||
<ItemImage
|
||||
variant={
|
||||
item.Type === "Movie" && logoUrl ? "Backdrop" : "Primary"
|
||||
}
|
||||
item={item}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
logo={
|
||||
<>
|
||||
{logoUrl ? (
|
||||
<Image
|
||||
source={{
|
||||
uri: logoUrl,
|
||||
}}
|
||||
style={{
|
||||
height: 130,
|
||||
width: "100%",
|
||||
resizeMode: "contain",
|
||||
}}
|
||||
onLoad={() => setLoadingLogo(false)}
|
||||
onError={() => setLoadingLogo(false)}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<View className="flex flex-col bg-transparent shrink">
|
||||
<View className="flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink">
|
||||
<ItemHeader item={item} className="mb-4" />
|
||||
{item.Type !== "Program" && !Platform.isTV && (
|
||||
<View className="flex flex-row items-center justify-start w-full h-16">
|
||||
<BitrateSelector
|
||||
className="mr-1"
|
||||
onChange={(val) =>
|
||||
// setSelectedOptions(
|
||||
// (prev) => prev && { ...prev, bitrate: val }
|
||||
// )
|
||||
console.log("new selected options", val)
|
||||
}
|
||||
selected={playbackOptions.bitrate}
|
||||
/>
|
||||
<MediaSourceSelector
|
||||
className="mr-1"
|
||||
item={item}
|
||||
onChange={(val) =>
|
||||
// setSelectedOptions((prev) =>
|
||||
// prev && {
|
||||
// ...prev,
|
||||
// mediaSource: val,
|
||||
// }
|
||||
// )
|
||||
console.log("new selected options", val)
|
||||
}
|
||||
selected={playbackOptions.mediaSource}
|
||||
/>
|
||||
<AudioTrackSelector
|
||||
className="mr-1"
|
||||
source={playbackOptions.mediaSource}
|
||||
onChange={(val) => {
|
||||
// setSelectedOptions((prev) =>
|
||||
// prev && {
|
||||
// ...prev,
|
||||
// audioIndex: val,
|
||||
// }
|
||||
// );
|
||||
console.log("new selected options", val);
|
||||
}}
|
||||
selected={playbackOptions.audioIndex}
|
||||
/>
|
||||
<SubtitleTrackSelector
|
||||
source={playbackOptions.mediaSource}
|
||||
onChange={(val) =>
|
||||
// setSelectedOptions(
|
||||
// (prev) =>
|
||||
// prev && {
|
||||
// ...prev,
|
||||
// subtitleIndex: val,
|
||||
// }
|
||||
// )
|
||||
console.log("new selected options", val)
|
||||
}
|
||||
selected={playbackOptions.subtitleIndex}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<ItemTechnicalDetails source={playbackOptions.mediaSource} />
|
||||
<OverviewText text={item.Overview} className="px-4 mb-4" />
|
||||
</View>
|
||||
</ParallaxScrollView>
|
||||
<View className="pt-2">
|
||||
{TrickplaySliderMemoized}
|
||||
<View className="flex flex-row items-center justify-between mt-2">
|
||||
<Text className="text-[12px] text-neutral-400">
|
||||
{formatTimeString(currentTime, "s")}
|
||||
</Text>
|
||||
<Text className="text-[12px] text-neutral-400">
|
||||
-{formatTimeString(remainingTime, "s")}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex flex-row w-full items-center justify-evenly mt-2 mb-10">
|
||||
<TouchableOpacity onPress={goToPreviousItem} disabled={!previousItem}>
|
||||
<Ionicons
|
||||
name="play-skip-back-outline"
|
||||
size={30}
|
||||
color={previousItem ? "white" : "gray"}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={handleSkipBackward}>
|
||||
<Ionicons name="play-back-outline" size={30} color="white" />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={() => togglePlay()}
|
||||
className="flex w-14 h-14 items-center justify-center"
|
||||
>
|
||||
{!isBufferingOrLoading ? (
|
||||
<Ionicons
|
||||
name={isPlaying ? "pause" : "play"}
|
||||
size={50}
|
||||
color="white"
|
||||
/>
|
||||
) : (
|
||||
<Loader size={"large"} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={handleSkipForward}>
|
||||
<Ionicons name="play-forward-outline" size={30} color="white" />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={goToNextItem} disabled={!nextItem}>
|
||||
<Ionicons
|
||||
name="play-skip-forward-outline"
|
||||
size={30}
|
||||
color={nextItem ? "white" : "gray"}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
{/* TODO find proper placement for these buttons */}
|
||||
{/* <View className="flex flex-row w-full justify-end px-6 pb-6">
|
||||
<SkipButton
|
||||
showButton={showSkipButton}
|
||||
onPress={skipIntro}
|
||||
buttonText="Skip Intro"
|
||||
/>
|
||||
<SkipButton
|
||||
showButton={showSkipCreditButton}
|
||||
onPress={skipCredit}
|
||||
buttonText="Skip Credits"
|
||||
/>
|
||||
{NextEpisodeButtonMemoized}
|
||||
</View> */}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
type TrickplaySliderProps = {
|
||||
item?: BaseItemDto;
|
||||
progress: SharedValue<number>;
|
||||
wasPlayingRef: React.MutableRefObject<boolean>;
|
||||
isPlaying: boolean;
|
||||
isSeeking: SharedValue<boolean>;
|
||||
range: { min?: SharedValue<number>; max: SharedValue<number> };
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
seek: (time: number) => void;
|
||||
};
|
||||
|
||||
function TrickplaySlider({
|
||||
item,
|
||||
progress,
|
||||
wasPlayingRef,
|
||||
isPlaying,
|
||||
isSeeking,
|
||||
range,
|
||||
play,
|
||||
pause,
|
||||
seek,
|
||||
}: TrickplaySliderProps) {
|
||||
const [isSliding, setIsSliding] = useState(false);
|
||||
const lastProgressRef = useRef<number>(0);
|
||||
|
||||
const min = useSharedValue(range.min?.value || 0);
|
||||
|
||||
const {
|
||||
trickPlayUrl,
|
||||
calculateTrickplayUrl,
|
||||
trickplayInfo,
|
||||
prefetchAllTrickplayImages,
|
||||
} = useTrickplay(
|
||||
{
|
||||
Id: item?.Id,
|
||||
RunTimeTicks: secondsToTicks(progress.value),
|
||||
Trickplay: item?.Trickplay,
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
prefetchAllTrickplayImages();
|
||||
}, []);
|
||||
|
||||
const handleSliderStart = useCallback(() => {
|
||||
setIsSliding(true);
|
||||
wasPlayingRef.current = isPlaying;
|
||||
lastProgressRef.current = progress.value;
|
||||
|
||||
pause();
|
||||
isSeeking.value = true;
|
||||
}, [isPlaying]);
|
||||
|
||||
const handleSliderComplete = useCallback(async (value: number) => {
|
||||
isSeeking.value = false;
|
||||
progress.value = value;
|
||||
setIsSliding(false);
|
||||
|
||||
seek(Math.max(0, Math.floor(value)));
|
||||
if (wasPlayingRef.current === true) play();
|
||||
}, []);
|
||||
|
||||
const [time, setTime] = useState({ hours: 0, minutes: 0, seconds: 0 });
|
||||
const handleSliderChange = useCallback(
|
||||
debounce((value: number) => {
|
||||
calculateTrickplayUrl(secondsToTicks(value));
|
||||
const progressInSeconds = Math.floor(value);
|
||||
const hours = Math.floor(progressInSeconds / 3600);
|
||||
const minutes = Math.floor((progressInSeconds % 3600) / 60);
|
||||
const seconds = progressInSeconds % 60;
|
||||
setTime({ hours, minutes, seconds });
|
||||
}, 3),
|
||||
[]
|
||||
);
|
||||
|
||||
const memoizedRenderBubble = useCallback(() => {
|
||||
if (!trickPlayUrl || !trickplayInfo) {
|
||||
return null;
|
||||
}
|
||||
const { x, y, url } = trickPlayUrl;
|
||||
const tileWidth = 150;
|
||||
const tileHeight = 150 / trickplayInfo.aspectRatio!;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: -62,
|
||||
bottom: 0,
|
||||
paddingTop: 30,
|
||||
paddingBottom: 5,
|
||||
width: tileWidth * 1.5,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: tileWidth,
|
||||
height: tileHeight,
|
||||
alignSelf: "center",
|
||||
transform: [{ scale: 1.4 }],
|
||||
borderRadius: 5,
|
||||
}}
|
||||
className="bg-neutral-800 overflow-hidden"
|
||||
>
|
||||
<Image
|
||||
cachePolicy={"memory-disk"}
|
||||
style={{
|
||||
width: 150 * trickplayInfo?.data.TileWidth!,
|
||||
height:
|
||||
(150 / trickplayInfo.aspectRatio!) *
|
||||
trickplayInfo?.data.TileHeight!,
|
||||
transform: [
|
||||
{ translateX: -x * tileWidth },
|
||||
{ translateY: -y * tileHeight },
|
||||
],
|
||||
resizeMode: "cover",
|
||||
}}
|
||||
source={{ uri: url }}
|
||||
contentFit="cover"
|
||||
/>
|
||||
</View>
|
||||
<Text
|
||||
style={{
|
||||
marginTop: 30,
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
{`${time.hours > 0 ? `${time.hours}:` : ""}${
|
||||
time.minutes < 10 ? `0${time.minutes}` : time.minutes
|
||||
}:${time.seconds < 10 ? `0${time.seconds}` : time.seconds}`}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}, [trickPlayUrl, trickplayInfo, time]);
|
||||
|
||||
return (
|
||||
<Slider
|
||||
theme={{
|
||||
maximumTrackTintColor: "rgba(255,255,255,0.2)",
|
||||
minimumTrackTintColor: "#fff",
|
||||
cacheTrackTintColor: "rgba(255,255,255,0.3)",
|
||||
bubbleBackgroundColor: "#fff",
|
||||
bubbleTextColor: "#666",
|
||||
heartbeatColor: "#999",
|
||||
}}
|
||||
renderThumb={() => null}
|
||||
onSlidingStart={handleSliderStart}
|
||||
onSlidingComplete={handleSliderComplete}
|
||||
onValueChange={handleSliderChange}
|
||||
containerStyle={{
|
||||
borderRadius: 100,
|
||||
}}
|
||||
renderBubble={() => isSliding && memoizedRenderBubble()}
|
||||
sliderHeight={10}
|
||||
thumbWidth={0}
|
||||
progress={progress}
|
||||
minimumValue={min}
|
||||
maximumValue={range.max}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +1,31 @@
|
||||
import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { queueActions, queueAtom } from "@/utils/atoms/queue";
|
||||
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server";
|
||||
import download from "@/utils/profiles/download";
|
||||
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetBackdropProps,
|
||||
type BottomSheetBackdropProps,
|
||||
BottomSheetModal,
|
||||
BottomSheetView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import {
|
||||
import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Href, router, useFocusEffect } from "expo-router";
|
||||
import { type Href } from "expo-router";
|
||||
import { t } from "i18next";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { Alert, Platform, View, ViewProps } from "react-native";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Alert, Platform, Switch, View, type ViewProps } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { queueAtom } from "@/utils/atoms/queue";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
import { getDownloadUrl } from "@/utils/jellyfin/media/getDownloadUrl";
|
||||
import { AudioTrackSelector } from "./AudioTrackSelector";
|
||||
import { Bitrate, BitrateSelector } from "./BitrateSelector";
|
||||
import { type Bitrate, BitrateSelector } from "./BitrateSelector";
|
||||
import { Button } from "./Button";
|
||||
import { Text } from "./common/Text";
|
||||
import { Loader } from "./Loader";
|
||||
@@ -32,7 +33,13 @@ import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||
import ProgressCircle from "./ProgressCircle";
|
||||
import { RoundButton } from "./RoundButton";
|
||||
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
|
||||
import { t } from "i18next";
|
||||
|
||||
export type SelectedOptions = {
|
||||
bitrate: Bitrate;
|
||||
mediaSource: MediaSourceInfo | undefined;
|
||||
audioIndex: number | undefined;
|
||||
subtitleIndex: number;
|
||||
};
|
||||
|
||||
interface DownloadProps extends ViewProps {
|
||||
items: BaseItemDto[];
|
||||
@@ -54,32 +61,28 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
}) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const [queue, setQueue] = useAtom(queueAtom);
|
||||
const [settings] = useSettings();
|
||||
const [queue, _setQueue] = useAtom(queueAtom);
|
||||
const { settings } = useSettings();
|
||||
const router = useRouter();
|
||||
const [downloadUnwatchedOnly, setDownloadUnwatchedOnly] = useState(false);
|
||||
|
||||
const { processes, startBackgroundDownload, downloadedFiles } = useDownload();
|
||||
const { startRemuxing } = useRemuxHlsToMp4();
|
||||
const { processes, startBackgroundDownload, downloadedItems } = useDownload();
|
||||
const downloadedFiles = downloadedItems;
|
||||
|
||||
const [selectedMediaSource, setSelectedMediaSource] = useState<
|
||||
MediaSourceInfo | undefined | null
|
||||
const [selectedOptions, setSelectedOptions] = useState<
|
||||
SelectedOptions | undefined
|
||||
>(undefined);
|
||||
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
||||
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
||||
useState<number>(0);
|
||||
const [maxBitrate, setMaxBitrate] = useState<Bitrate>(
|
||||
settings?.defaultBitrate ?? {
|
||||
key: "Max",
|
||||
value: undefined,
|
||||
}
|
||||
);
|
||||
|
||||
const {
|
||||
defaultAudioIndex,
|
||||
defaultBitrate,
|
||||
defaultMediaSource,
|
||||
defaultSubtitleIndex,
|
||||
} = useDefaultPlaySettings(items[0], settings);
|
||||
|
||||
const userCanDownload = useMemo(
|
||||
() => user?.Policy?.EnableContentDownloading,
|
||||
[user]
|
||||
);
|
||||
const usingOptimizedServer = useMemo(
|
||||
() => settings?.downloadMethod === DownloadMethod.Optimized,
|
||||
[settings]
|
||||
[user],
|
||||
);
|
||||
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
@@ -88,7 +91,9 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
bottomSheetModalRef.current?.present();
|
||||
}, []);
|
||||
|
||||
const handleSheetChanges = useCallback((index: number) => {}, []);
|
||||
const handleSheetChanges = useCallback((_index: number) => {
|
||||
// Modal state tracking handled by BottomSheetModal
|
||||
}, []);
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
bottomSheetModalRef.current?.dismiss();
|
||||
@@ -99,21 +104,45 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
const itemsNotDownloaded = useMemo(
|
||||
() =>
|
||||
items.filter((i) => !downloadedFiles?.some((f) => f.item.Id === i.Id)),
|
||||
[items, downloadedFiles]
|
||||
[items, downloadedFiles],
|
||||
);
|
||||
|
||||
// Initialize selectedOptions with default values
|
||||
useEffect(() => {
|
||||
setSelectedOptions(() => ({
|
||||
bitrate: defaultBitrate,
|
||||
mediaSource: defaultMediaSource ?? undefined,
|
||||
subtitleIndex: defaultSubtitleIndex ?? -1,
|
||||
audioIndex: defaultAudioIndex,
|
||||
}));
|
||||
}, [
|
||||
defaultAudioIndex,
|
||||
defaultBitrate,
|
||||
defaultSubtitleIndex,
|
||||
defaultMediaSource,
|
||||
]);
|
||||
|
||||
const itemsToDownload = useMemo(() => {
|
||||
if (downloadUnwatchedOnly) {
|
||||
return itemsNotDownloaded.filter((item) => !item.UserData?.Played);
|
||||
}
|
||||
return itemsNotDownloaded;
|
||||
}, [itemsNotDownloaded, downloadUnwatchedOnly]);
|
||||
|
||||
const allItemsDownloaded = useMemo(() => {
|
||||
if (items.length === 0) return false;
|
||||
return itemsNotDownloaded.length === 0;
|
||||
}, [items, itemsNotDownloaded]);
|
||||
const itemsProcesses = useMemo(
|
||||
() => processes?.filter((p) => itemIds.includes(p.item.Id)),
|
||||
[processes, itemIds]
|
||||
() =>
|
||||
processes?.filter((p) => p?.item?.Id && itemIds.includes(p.item.Id)) ||
|
||||
[],
|
||||
[processes, itemIds],
|
||||
);
|
||||
|
||||
const progress = useMemo(() => {
|
||||
if (itemIds.length == 1)
|
||||
return itemsProcesses.reduce((acc, p) => acc + p.progress, 0);
|
||||
if (itemIds.length === 1)
|
||||
return itemsProcesses.reduce((acc, p) => acc + (p.progress || 0), 0);
|
||||
return (
|
||||
((itemIds.length -
|
||||
queue.filter((q) => itemIds.includes(q.item.Id)).length) /
|
||||
@@ -125,9 +154,16 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
const itemsQueued = useMemo(() => {
|
||||
return (
|
||||
itemsNotDownloaded.length > 0 &&
|
||||
itemsNotDownloaded.every((p) => queue.some((q) => p.Id == q.item.Id))
|
||||
itemsNotDownloaded.every((p) => queue.some((q) => p.Id === q.item.Id))
|
||||
);
|
||||
}, [queue, itemsNotDownloaded]);
|
||||
|
||||
const itemsInProgressOrQueued = useMemo(() => {
|
||||
const inProgress = itemsProcesses.length;
|
||||
const inQueue = queue.filter((q) => itemIds.includes(q.item.Id)).length;
|
||||
return inProgress + inQueue;
|
||||
}, [itemsProcesses, queue, itemIds]);
|
||||
|
||||
const navigateToDownloads = () => router.push("/downloads");
|
||||
|
||||
const onDownloadedPress = () => {
|
||||
@@ -136,123 +172,125 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
firstItem.Type !== "Episode"
|
||||
? "/downloads"
|
||||
: ({
|
||||
pathname: `/downloads/${firstItem.SeriesId}`,
|
||||
pathname: "/series/[id]",
|
||||
params: {
|
||||
episodeSeasonIndex: firstItem.ParentIndexNumber,
|
||||
id: firstItem.SeriesId!,
|
||||
seasonIndex: firstItem.ParentIndexNumber?.toString(),
|
||||
offline: "true",
|
||||
},
|
||||
} as Href)
|
||||
} as Href),
|
||||
);
|
||||
};
|
||||
|
||||
const acceptDownloadOptions = useCallback(() => {
|
||||
if (userCanDownload === true) {
|
||||
if (itemsNotDownloaded.some((i) => !i.Id)) {
|
||||
throw new Error("No item id");
|
||||
}
|
||||
closeModal();
|
||||
|
||||
if (usingOptimizedServer) initiateDownload(...itemsNotDownloaded);
|
||||
else {
|
||||
queueActions.enqueue(
|
||||
queue,
|
||||
setQueue,
|
||||
...itemsNotDownloaded.map((item) => ({
|
||||
id: item.Id!,
|
||||
execute: async () => await initiateDownload(item),
|
||||
item,
|
||||
}))
|
||||
);
|
||||
}
|
||||
} else {
|
||||
toast.error(
|
||||
t("home.downloads.toasts.you_are_not_allowed_to_download_files")
|
||||
);
|
||||
}
|
||||
}, [
|
||||
queue,
|
||||
setQueue,
|
||||
itemsNotDownloaded,
|
||||
usingOptimizedServer,
|
||||
userCanDownload,
|
||||
maxBitrate,
|
||||
selectedMediaSource,
|
||||
selectedAudioStream,
|
||||
selectedSubtitleStream,
|
||||
]);
|
||||
|
||||
const initiateDownload = useCallback(
|
||||
async (...items: BaseItemDto[]) => {
|
||||
if (
|
||||
!api ||
|
||||
!user?.Id ||
|
||||
items.some((p) => !p.Id) ||
|
||||
(itemsNotDownloaded.length === 1 && !selectedMediaSource?.Id)
|
||||
(itemsNotDownloaded.length === 1 && !selectedOptions?.mediaSource?.Id)
|
||||
) {
|
||||
throw new Error(
|
||||
"DownloadItem ~ initiateDownload: No api or user or item"
|
||||
"DownloadItem ~ initiateDownload: No api or user or item",
|
||||
);
|
||||
}
|
||||
let mediaSource = selectedMediaSource;
|
||||
let audioIndex: number | undefined = selectedAudioStream;
|
||||
let subtitleIndex: number | undefined = selectedSubtitleStream;
|
||||
const downloadDetailsPromises = items.map(async (item) => {
|
||||
const { mediaSource, audioIndex, subtitleIndex } =
|
||||
itemsNotDownloaded.length > 1
|
||||
? getDefaultPlaySettings(item, settings!)
|
||||
: {
|
||||
mediaSource: selectedOptions?.mediaSource,
|
||||
audioIndex: selectedOptions?.audioIndex,
|
||||
subtitleIndex: selectedOptions?.subtitleIndex,
|
||||
};
|
||||
|
||||
for (const item of items) {
|
||||
if (itemsNotDownloaded.length > 1) {
|
||||
const defaults = getDefaultPlaySettings(item, settings!);
|
||||
mediaSource = defaults.mediaSource;
|
||||
audioIndex = defaults.audioIndex;
|
||||
subtitleIndex = defaults.subtitleIndex;
|
||||
// Keep using the selected bitrate for consistency across all downloads
|
||||
}
|
||||
|
||||
const res = await getStreamUrl({
|
||||
const downloadDetails = await getDownloadUrl({
|
||||
api,
|
||||
item,
|
||||
startTimeTicks: 0,
|
||||
userId: user?.Id,
|
||||
audioStreamIndex: audioIndex,
|
||||
maxStreamingBitrate: maxBitrate.value,
|
||||
mediaSourceId: mediaSource?.Id,
|
||||
subtitleStreamIndex: subtitleIndex,
|
||||
deviceProfile: download,
|
||||
userId: user.Id!,
|
||||
mediaSource: mediaSource!,
|
||||
audioStreamIndex: audioIndex ?? -1,
|
||||
subtitleStreamIndex: subtitleIndex ?? -1,
|
||||
maxBitrate: selectedOptions?.bitrate || defaultBitrate,
|
||||
deviceId: api.deviceInfo.id,
|
||||
audioMode: settings?.audioTranscodeMode,
|
||||
});
|
||||
|
||||
if (!res) {
|
||||
return {
|
||||
url: downloadDetails?.url,
|
||||
item,
|
||||
mediaSource: downloadDetails?.mediaSource,
|
||||
};
|
||||
});
|
||||
|
||||
const downloadDetails = await Promise.all(downloadDetailsPromises);
|
||||
for (const { url, item, mediaSource } of downloadDetails) {
|
||||
if (!url) {
|
||||
Alert.alert(
|
||||
t("home.downloads.something_went_wrong"),
|
||||
t("home.downloads.could_not_get_stream_url_from_jellyfin")
|
||||
t("home.downloads.could_not_get_stream_url_from_jellyfin"),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const { mediaSource: source, url } = res;
|
||||
|
||||
if (!url || !source) throw new Error("No url");
|
||||
|
||||
saveDownloadItemInfoToDiskTmp(item, source, url);
|
||||
|
||||
if (usingOptimizedServer) {
|
||||
await startBackgroundDownload(url, item, source);
|
||||
} else {
|
||||
await startRemuxing(item, url, source);
|
||||
if (!mediaSource) {
|
||||
console.error(`Could not get download URL for ${item.Name}`);
|
||||
toast.error(
|
||||
t("home.downloads.toasts.could_not_get_download_url_for_item", {
|
||||
itemName: item.Name,
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// Get the audio/subtitle indices that were used for this download
|
||||
const downloadAudioIndex =
|
||||
itemsNotDownloaded.length > 1
|
||||
? getDefaultPlaySettings(item, settings!).audioIndex
|
||||
: selectedOptions?.audioIndex;
|
||||
const downloadSubtitleIndex =
|
||||
itemsNotDownloaded.length > 1
|
||||
? getDefaultPlaySettings(item, settings!).subtitleIndex
|
||||
: selectedOptions?.subtitleIndex;
|
||||
|
||||
await startBackgroundDownload(
|
||||
url,
|
||||
item,
|
||||
mediaSource,
|
||||
selectedOptions?.bitrate || defaultBitrate,
|
||||
downloadAudioIndex,
|
||||
downloadSubtitleIndex,
|
||||
);
|
||||
}
|
||||
},
|
||||
[
|
||||
api,
|
||||
user?.Id,
|
||||
itemsNotDownloaded,
|
||||
selectedMediaSource,
|
||||
selectedAudioStream,
|
||||
selectedSubtitleStream,
|
||||
selectedOptions,
|
||||
settings,
|
||||
maxBitrate,
|
||||
usingOptimizedServer,
|
||||
defaultBitrate,
|
||||
startBackgroundDownload,
|
||||
startRemuxing,
|
||||
]
|
||||
],
|
||||
);
|
||||
|
||||
const acceptDownloadOptions = useCallback(async () => {
|
||||
if (userCanDownload === true) {
|
||||
if (itemsToDownload.some((i) => !i.Id)) {
|
||||
throw new Error("No item id");
|
||||
}
|
||||
|
||||
closeModal();
|
||||
|
||||
// Wait for modal dismiss animation to complete
|
||||
setTimeout(() => {
|
||||
initiateDownload(...itemsToDownload);
|
||||
}, 300);
|
||||
} else {
|
||||
toast.error(
|
||||
t("home.downloads.toasts.you_are_not_allowed_to_download_files"),
|
||||
);
|
||||
}
|
||||
}, [closeModal, initiateDownload, itemsToDownload, userCanDownload]);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: BottomSheetBackdropProps) => (
|
||||
<BottomSheetBackdrop
|
||||
@@ -261,44 +299,42 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
appearsOnIndex={0}
|
||||
/>
|
||||
),
|
||||
[]
|
||||
);
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (!settings) return;
|
||||
if (itemsNotDownloaded.length !== 1) return;
|
||||
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
|
||||
getDefaultPlaySettings(items[0], settings);
|
||||
|
||||
setSelectedMediaSource(mediaSource ?? undefined);
|
||||
setSelectedAudioStream(audioIndex ?? 0);
|
||||
setSelectedSubtitleStream(subtitleIndex ?? -1);
|
||||
setMaxBitrate(bitrate);
|
||||
}, [items, itemsNotDownloaded, settings])
|
||||
[],
|
||||
);
|
||||
|
||||
const renderButtonContent = () => {
|
||||
if (processes && itemsProcesses.length > 0) {
|
||||
// For single item downloads, show progress if item is being processed
|
||||
// For multi-item downloads (season/series), show progress only if 2+ items are in progress or queued
|
||||
const shouldShowProgress =
|
||||
itemIds.length === 1
|
||||
? itemsProcesses.length > 0
|
||||
: itemsInProgressOrQueued > 1;
|
||||
|
||||
if (processes.length > 0 && shouldShowProgress) {
|
||||
return progress === 0 ? (
|
||||
<Loader />
|
||||
) : (
|
||||
<View className="-rotate-45">
|
||||
<View className='-rotate-45'>
|
||||
<ProgressCircle
|
||||
size={24}
|
||||
fill={progress}
|
||||
width={4}
|
||||
tintColor="#9334E9"
|
||||
backgroundColor="#bdc3c7"
|
||||
tintColor='#9334E9'
|
||||
backgroundColor='#bdc3c7'
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
} else if (itemsQueued) {
|
||||
return <Ionicons name="hourglass" size={24} color="white" />;
|
||||
} else if (allItemsDownloaded) {
|
||||
return <DownloadedIconComponent />;
|
||||
} else {
|
||||
return <MissingDownloadIconComponent />;
|
||||
}
|
||||
|
||||
if (itemsQueued) {
|
||||
return <Ionicons name='hourglass' size={24} color='white' />;
|
||||
}
|
||||
|
||||
if (allItemsDownloaded) {
|
||||
return <DownloadedIconComponent />;
|
||||
}
|
||||
|
||||
return <MissingDownloadIconComponent />;
|
||||
};
|
||||
|
||||
const onButtonPress = () => {
|
||||
@@ -329,64 +365,100 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
}}
|
||||
onChange={handleSheetChanges}
|
||||
backdropComponent={renderBackdrop}
|
||||
enablePanDownToClose
|
||||
enableDismissOnClose
|
||||
android_keyboardInputMode='adjustResize'
|
||||
keyboardBehavior='interactive'
|
||||
keyboardBlurBehavior='restore'
|
||||
>
|
||||
<BottomSheetView>
|
||||
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
|
||||
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
||||
<View>
|
||||
<Text className="font-bold text-2xl text-neutral-100">
|
||||
<Text className='font-bold text-2xl text-neutral-100'>
|
||||
{title}
|
||||
</Text>
|
||||
<Text className="text-neutral-300">
|
||||
<Text className='text-neutral-300'>
|
||||
{subtitle ||
|
||||
t("item_card.download.download_x_item", {
|
||||
item_count: itemsNotDownloaded.length,
|
||||
item_count: itemsToDownload.length,
|
||||
})}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex flex-col space-y-2 w-full items-start">
|
||||
<BitrateSelector
|
||||
inverted
|
||||
onChange={setMaxBitrate}
|
||||
selected={maxBitrate}
|
||||
/>
|
||||
{itemsNotDownloaded.length === 1 && (
|
||||
<>
|
||||
<MediaSourceSelector
|
||||
item={items[0]}
|
||||
onChange={setSelectedMediaSource}
|
||||
selected={selectedMediaSource}
|
||||
<View className='flex flex-col space-y-2 w-full'>
|
||||
<View className='items-start'>
|
||||
<BitrateSelector
|
||||
inverted
|
||||
onChange={(val) =>
|
||||
setSelectedOptions(
|
||||
(prev) => prev && { ...prev, bitrate: val },
|
||||
)
|
||||
}
|
||||
selected={selectedOptions?.bitrate}
|
||||
/>
|
||||
</View>
|
||||
{itemsNotDownloaded.length > 1 && (
|
||||
<View className='flex flex-row items-center justify-between w-full py-2'>
|
||||
<Text>{t("item_card.download.download_unwatched_only")}</Text>
|
||||
<Switch
|
||||
onValueChange={setDownloadUnwatchedOnly}
|
||||
value={downloadUnwatchedOnly}
|
||||
/>
|
||||
{selectedMediaSource && (
|
||||
<View className="flex flex-col space-y-2">
|
||||
</View>
|
||||
)}
|
||||
{itemsNotDownloaded.length === 1 && (
|
||||
<View>
|
||||
<View className='items-start'>
|
||||
<MediaSourceSelector
|
||||
item={items[0]}
|
||||
onChange={(val) =>
|
||||
setSelectedOptions(
|
||||
(prev) =>
|
||||
prev && {
|
||||
...prev,
|
||||
mediaSource: val,
|
||||
},
|
||||
)
|
||||
}
|
||||
selected={selectedOptions?.mediaSource}
|
||||
/>
|
||||
</View>
|
||||
{selectedOptions?.mediaSource && (
|
||||
<View className='flex flex-col space-y-2 items-start'>
|
||||
<AudioTrackSelector
|
||||
source={selectedMediaSource}
|
||||
onChange={setSelectedAudioStream}
|
||||
selected={selectedAudioStream}
|
||||
source={selectedOptions.mediaSource}
|
||||
onChange={(val) => {
|
||||
setSelectedOptions(
|
||||
(prev) =>
|
||||
prev && {
|
||||
...prev,
|
||||
audioIndex: val,
|
||||
},
|
||||
);
|
||||
}}
|
||||
selected={selectedOptions.audioIndex}
|
||||
/>
|
||||
<SubtitleTrackSelector
|
||||
source={selectedMediaSource}
|
||||
onChange={setSelectedSubtitleStream}
|
||||
selected={selectedSubtitleStream}
|
||||
source={selectedOptions.mediaSource}
|
||||
onChange={(val) => {
|
||||
setSelectedOptions(
|
||||
(prev) =>
|
||||
prev && {
|
||||
...prev,
|
||||
subtitleIndex: val,
|
||||
},
|
||||
);
|
||||
}}
|
||||
selected={selectedOptions.subtitleIndex}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Button
|
||||
className="mt-auto"
|
||||
onPress={acceptDownloadOptions}
|
||||
color="purple"
|
||||
>
|
||||
|
||||
<Button onPress={acceptDownloadOptions} color='purple'>
|
||||
{t("item_card.download.download_button")}
|
||||
</Button>
|
||||
<View className="opacity-70 text-center w-full flex items-center">
|
||||
<Text className="text-xs">
|
||||
{usingOptimizedServer
|
||||
? t("item_card.download.using_optimized_server")
|
||||
: t("item_card.download.using_default_method")}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</BottomSheetView>
|
||||
</BottomSheetModal>
|
||||
@@ -404,17 +476,17 @@ export const DownloadSingleItem: React.FC<{
|
||||
<DownloadItems
|
||||
size={size}
|
||||
title={
|
||||
item.Type == "Episode"
|
||||
item.Type === "Episode"
|
||||
? t("item_card.download.download_episode")
|
||||
: t("item_card.download.download_movie")
|
||||
}
|
||||
subtitle={item.Name!}
|
||||
items={[item]}
|
||||
MissingDownloadIconComponent={() => (
|
||||
<Ionicons name="cloud-download-outline" size={24} color="white" />
|
||||
<Ionicons name='cloud-download-outline' size={24} color='white' />
|
||||
)}
|
||||
DownloadedIconComponent={() => (
|
||||
<Ionicons name="cloud-download" size={26} color="#9333ea" />
|
||||
<Ionicons name='cloud-download' size={26} color='#9333ea' />
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,25 +1,4 @@
|
||||
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
||||
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
||||
import { DownloadSingleItem } from "@/components/DownloadItem";
|
||||
import { OverviewText } from "@/components/OverviewText";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
// const PlayButton = !Platform.isTV ? require("@/components/PlayButton") : null;
|
||||
import { PlayButton } from "@/components/PlayButton";
|
||||
import { PlayedStatus } from "@/components/PlayedStatus";
|
||||
import { SimilarItems } from "@/components/SimilarItems";
|
||||
import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector";
|
||||
import { ItemImage } from "@/components/common/ItemImage";
|
||||
import { CastAndCrew } from "@/components/series/CastAndCrew";
|
||||
import { CurrentSeries } from "@/components/series/CurrentSeries";
|
||||
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
|
||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||
import { useImageColors } from "@/hooks/useImageColors";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import {
|
||||
import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
@@ -29,11 +8,33 @@ import { useAtom } from "jotai";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { Platform, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { type Bitrate } from "@/components/BitrateSelector";
|
||||
import { ItemImage } from "@/components/common/ItemImage";
|
||||
import { DownloadSingleItem } from "@/components/DownloadItem";
|
||||
import { ItemPeopleSections } from "@/components/item/ItemPeopleSections";
|
||||
import { MediaSourceButton } from "@/components/MediaSourceButton";
|
||||
import { OverviewText } from "@/components/OverviewText";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
// const PlayButton = !Platform.isTV ? require("@/components/PlayButton") : null;
|
||||
import { PlayButton } from "@/components/PlayButton";
|
||||
import { PlayedStatus } from "@/components/PlayedStatus";
|
||||
import { SimilarItems } from "@/components/SimilarItems";
|
||||
import { CurrentSeries } from "@/components/series/CurrentSeries";
|
||||
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
|
||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import { AddToFavorites } from "./AddToFavorites";
|
||||
import { AddToWatchlist } from "./AddToWatchlist";
|
||||
import { ItemHeader } from "./ItemHeader";
|
||||
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
||||
import { PlayInRemoteSessionButton } from "./PlayInRemoteSession";
|
||||
|
||||
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
|
||||
|
||||
export type SelectedOptions = {
|
||||
@@ -43,14 +44,22 @@ export type SelectedOptions = {
|
||||
subtitleIndex: number;
|
||||
};
|
||||
|
||||
export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
({ item }) => {
|
||||
interface ItemContentProps {
|
||||
item: BaseItemDto;
|
||||
itemWithSources?: BaseItemDto | null;
|
||||
}
|
||||
|
||||
export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
({ item, itemWithSources }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [settings] = useSettings();
|
||||
const isOffline = useOfflineMode();
|
||||
const { settings } = useSettings();
|
||||
const { orientation } = useOrientation();
|
||||
const navigation = useNavigation();
|
||||
const insets = useSafeAreaInsets();
|
||||
useImageColors({ item });
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const itemColors = useImageColorsReturn({ item });
|
||||
|
||||
const [loadingLogo, setLoadingLogo] = useState(true);
|
||||
const [headerHeight, setHeaderHeight] = useState(350);
|
||||
@@ -59,18 +68,32 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
SelectedOptions | undefined
|
||||
>(undefined);
|
||||
|
||||
// Use itemWithSources for play settings since it has MediaSources data
|
||||
const {
|
||||
defaultAudioIndex,
|
||||
defaultBitrate,
|
||||
defaultMediaSource,
|
||||
defaultSubtitleIndex,
|
||||
} = useDefaultPlaySettings(item, settings);
|
||||
} = useDefaultPlaySettings(itemWithSources ?? item, settings);
|
||||
|
||||
const logoUrl = useMemo(
|
||||
() => (item ? getLogoImageUrlById({ api, item }) : null),
|
||||
[api, item],
|
||||
);
|
||||
|
||||
const onLogoLoad = React.useCallback(() => {
|
||||
setLoadingLogo(false);
|
||||
}, []);
|
||||
|
||||
const loading = useMemo(() => {
|
||||
return Boolean(logoUrl && loadingLogo);
|
||||
}, [loadingLogo, logoUrl]);
|
||||
|
||||
// Needs to automatically change the selected to the default values for default indexes.
|
||||
useEffect(() => {
|
||||
setSelectedOptions(() => ({
|
||||
bitrate: defaultBitrate,
|
||||
mediaSource: defaultMediaSource,
|
||||
mediaSource: defaultMediaSource ?? undefined,
|
||||
subtitleIndex: defaultSubtitleIndex ?? -1,
|
||||
audioIndex: defaultAudioIndex,
|
||||
}));
|
||||
@@ -81,56 +104,89 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
defaultMediaSource,
|
||||
]);
|
||||
|
||||
if (!Platform.isTV) {
|
||||
useEffect(() => {
|
||||
useEffect(() => {
|
||||
if (!Platform.isTV && itemWithSources) {
|
||||
navigation.setOptions({
|
||||
headerRight: () =>
|
||||
item && (
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
<Chromecast.Chromecast
|
||||
background="blur"
|
||||
width={22}
|
||||
height={22}
|
||||
/>
|
||||
item &&
|
||||
(Platform.OS === "ios" ? (
|
||||
<View className='flex flex-row items-center pl-2'>
|
||||
<Chromecast.Chromecast width={22} height={22} />
|
||||
{item.Type !== "Program" && (
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
<View className='flex flex-row items-center'>
|
||||
{!Platform.isTV && (
|
||||
<DownloadSingleItem item={item} size="large" />
|
||||
<DownloadSingleItem item={itemWithSources} size='large' />
|
||||
)}
|
||||
<PlayedStatus items={[item]} size="large" />
|
||||
{user?.Policy?.IsAdministrator &&
|
||||
!settings.hideRemoteSessionButton && (
|
||||
<PlayInRemoteSessionButton item={item} size='large' />
|
||||
)}
|
||||
|
||||
<PlayedStatus items={[item]} size='large' />
|
||||
<AddToFavorites item={item} />
|
||||
{settings.streamyStatsServerUrl &&
|
||||
!settings.hideWatchlistsTab && (
|
||||
<AddToWatchlist item={item} />
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
),
|
||||
) : (
|
||||
<View className='flex flex-row items-center space-x-2'>
|
||||
<Chromecast.Chromecast width={22} height={22} />
|
||||
{item.Type !== "Program" && (
|
||||
<View className='flex flex-row items-center space-x-2'>
|
||||
{!Platform.isTV && (
|
||||
<DownloadSingleItem item={itemWithSources} size='large' />
|
||||
)}
|
||||
{user?.Policy?.IsAdministrator &&
|
||||
!settings.hideRemoteSessionButton && (
|
||||
<PlayInRemoteSessionButton item={item} size='large' />
|
||||
)}
|
||||
|
||||
<PlayedStatus items={[item]} size='large' />
|
||||
<AddToFavorites item={item} />
|
||||
{settings.streamyStatsServerUrl &&
|
||||
!settings.hideWatchlistsTab && (
|
||||
<AddToWatchlist item={item} />
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)),
|
||||
});
|
||||
}, [item]);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
item,
|
||||
navigation,
|
||||
user,
|
||||
itemWithSources,
|
||||
settings.hideRemoteSessionButton,
|
||||
settings.streamyStatsServerUrl,
|
||||
settings.hideWatchlistsTab,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP)
|
||||
setHeaderHeight(230);
|
||||
else if (item.Type === "Movie") setHeaderHeight(500);
|
||||
else setHeaderHeight(350);
|
||||
}, [item.Type, orientation]);
|
||||
if (item) {
|
||||
if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP)
|
||||
setHeaderHeight(230);
|
||||
else if (item.Type === "Movie") setHeaderHeight(500);
|
||||
else setHeaderHeight(350);
|
||||
}
|
||||
}, [item, orientation]);
|
||||
|
||||
const logoUrl = useMemo(() => getLogoImageUrlById({ api, item }), [item]);
|
||||
|
||||
const loading = useMemo(() => {
|
||||
return Boolean(logoUrl && loadingLogo);
|
||||
}, [loadingLogo, logoUrl]);
|
||||
if (!selectedOptions) return null;
|
||||
if (!item || !selectedOptions) return null;
|
||||
|
||||
return (
|
||||
<View
|
||||
className="flex-1 relative"
|
||||
className='flex-1 relative'
|
||||
style={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<ParallaxScrollView
|
||||
className={`flex-1 ${loading ? "opacity-0" : "opacity-100"}`}
|
||||
className='flex-1'
|
||||
headerHeight={headerHeight}
|
||||
headerImage={
|
||||
<View style={[{ flex: 1 }]}>
|
||||
@@ -147,123 +203,71 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
</View>
|
||||
}
|
||||
logo={
|
||||
<>
|
||||
{logoUrl ? (
|
||||
<Image
|
||||
source={{
|
||||
uri: logoUrl,
|
||||
}}
|
||||
style={{
|
||||
height: 130,
|
||||
width: "100%",
|
||||
resizeMode: "contain",
|
||||
}}
|
||||
onLoad={() => setLoadingLogo(false)}
|
||||
onError={() => setLoadingLogo(false)}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
logoUrl ? (
|
||||
<Image
|
||||
source={{
|
||||
uri: logoUrl,
|
||||
}}
|
||||
style={{
|
||||
height: 130,
|
||||
width: "100%",
|
||||
}}
|
||||
contentFit='contain'
|
||||
onLoad={onLogoLoad}
|
||||
onError={onLogoLoad}
|
||||
/>
|
||||
) : (
|
||||
<View />
|
||||
)
|
||||
}
|
||||
>
|
||||
<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">
|
||||
<ItemHeader item={item} className="mb-4" />
|
||||
{item.Type !== "Program" && !Platform.isTV && (
|
||||
<View className="flex flex-row items-center justify-start w-full h-16">
|
||||
<BitrateSelector
|
||||
className="mr-1"
|
||||
onChange={(val) =>
|
||||
setSelectedOptions(
|
||||
(prev) => prev && { ...prev, bitrate: val }
|
||||
)
|
||||
}
|
||||
selected={selectedOptions.bitrate}
|
||||
/>
|
||||
<MediaSourceSelector
|
||||
className="mr-1"
|
||||
item={item}
|
||||
onChange={(val) =>
|
||||
setSelectedOptions(
|
||||
(prev) =>
|
||||
prev && {
|
||||
...prev,
|
||||
mediaSource: val,
|
||||
}
|
||||
)
|
||||
}
|
||||
selected={selectedOptions.mediaSource}
|
||||
/>
|
||||
<AudioTrackSelector
|
||||
className="mr-1"
|
||||
source={selectedOptions.mediaSource}
|
||||
onChange={(val) => {
|
||||
setSelectedOptions(
|
||||
(prev) =>
|
||||
prev && {
|
||||
...prev,
|
||||
audioIndex: val,
|
||||
}
|
||||
);
|
||||
}}
|
||||
selected={selectedOptions.audioIndex}
|
||||
/>
|
||||
<SubtitleTrackSelector
|
||||
source={selectedOptions.mediaSource}
|
||||
onChange={(val) =>
|
||||
setSelectedOptions(
|
||||
(prev) =>
|
||||
prev && {
|
||||
...prev,
|
||||
subtitleIndex: val,
|
||||
}
|
||||
)
|
||||
}
|
||||
selected={selectedOptions.subtitleIndex}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<View className='flex flex-col bg-transparent shrink'>
|
||||
<View className='flex flex-col px-4 w-full pt-2 mb-2 shrink'>
|
||||
<ItemHeader item={item} className='mb-2' />
|
||||
|
||||
<PlayButton
|
||||
className="grow"
|
||||
selectedOptions={selectedOptions}
|
||||
item={item}
|
||||
/>
|
||||
<View className='flex flex-row px-0 mb-2 justify-between space-x-2'>
|
||||
<PlayButton
|
||||
selectedOptions={selectedOptions}
|
||||
item={item}
|
||||
colors={itemColors}
|
||||
/>
|
||||
<View className='w-1' />
|
||||
{!isOffline && (
|
||||
<MediaSourceButton
|
||||
selectedOptions={selectedOptions}
|
||||
setSelectedOptions={setSelectedOptions}
|
||||
item={itemWithSources}
|
||||
colors={itemColors}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{item.Type === "Episode" && (
|
||||
<SeasonEpisodesCarousel item={item} loading={loading} />
|
||||
)}
|
||||
|
||||
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
|
||||
<OverviewText text={item.Overview} className="px-4 mb-4" />
|
||||
{!isOffline &&
|
||||
selectedOptions.mediaSource?.MediaStreams &&
|
||||
selectedOptions.mediaSource.MediaStreams.length > 0 && (
|
||||
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
|
||||
)}
|
||||
|
||||
<OverviewText text={item.Overview} className='px-4 mb-4' />
|
||||
|
||||
{item.Type !== "Program" && (
|
||||
<>
|
||||
{item.Type === "Episode" && (
|
||||
<CurrentSeries item={item} className="mb-4" />
|
||||
{item.Type === "Episode" && !isOffline && (
|
||||
<CurrentSeries item={item} className='mb-2' />
|
||||
)}
|
||||
|
||||
<CastAndCrew item={item} className="mb-4" loading={loading} />
|
||||
<ItemPeopleSections item={item} />
|
||||
|
||||
{item.People && item.People.length > 0 && (
|
||||
<View className="mb-4">
|
||||
{item.People.slice(0, 3).map((person, idx) => (
|
||||
<MoreMoviesWithActor
|
||||
currentItem={item}
|
||||
key={idx}
|
||||
actorId={person.Id!}
|
||||
className="mb-4"
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<SimilarItems itemId={item.Id} />
|
||||
{!isOffline && <SimilarItems itemId={item.Id} />}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</ParallaxScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,39 +1,38 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import {
|
||||
MediaSourceInfo,
|
||||
type MediaStream,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import React, { useMemo, useRef } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { Badge } from "./Badge";
|
||||
import { Text } from "./common/Text";
|
||||
import {
|
||||
BottomSheetModal,
|
||||
BottomSheetBackdropProps,
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetView,
|
||||
type BottomSheetBackdropProps,
|
||||
BottomSheetModal,
|
||||
BottomSheetScrollView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { Button } from "./Button";
|
||||
import type {
|
||||
MediaSourceInfo,
|
||||
MediaStream,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import type React from "react";
|
||||
import { useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { formatBitrate } from "@/utils/bitrate";
|
||||
import { Badge } from "./Badge";
|
||||
import { Text } from "./common/Text";
|
||||
|
||||
interface Props {
|
||||
source?: MediaSourceInfo;
|
||||
}
|
||||
|
||||
export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
|
||||
export const ItemTechnicalDetails: React.FC<Props> = ({ source }) => {
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<View className="px-4 mt-2 mb-4">
|
||||
<Text className="text-lg font-bold mb-4">{t("item_card.video")}</Text>
|
||||
<View className='px-4 mt-2 mb-4'>
|
||||
<Text className='text-lg font-bold mb-4'>{t("item_card.video")}</Text>
|
||||
<TouchableOpacity onPress={() => bottomSheetModalRef.current?.present()}>
|
||||
<View className="flex flex-row space-x-2">
|
||||
<View className='flex flex-row space-x-2'>
|
||||
<VideoStreamInfo source={source} />
|
||||
</View>
|
||||
<Text className="text-purple-600">{t("item_card.more_details")}</Text>
|
||||
<Text className='text-purple-600'>{t("item_card.more_details")}</Text>
|
||||
</TouchableOpacity>
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetModalRef}
|
||||
@@ -53,37 +52,37 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
|
||||
)}
|
||||
>
|
||||
<BottomSheetScrollView>
|
||||
<View className="flex flex-col space-y-2 p-4 mb-4">
|
||||
<View className="">
|
||||
<Text className="text-lg font-bold mb-4">
|
||||
<View className='flex flex-col space-y-2 p-4 mb-4'>
|
||||
<View>
|
||||
<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} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="">
|
||||
<Text className="text-lg font-bold mb-2">
|
||||
<View>
|
||||
<Text className='text-lg font-bold mb-2'>
|
||||
{t("item_card.audio")}
|
||||
</Text>
|
||||
<AudioStreamInfo
|
||||
audioStreams={
|
||||
source?.MediaStreams?.filter(
|
||||
(stream) => stream.Type === "Audio"
|
||||
(stream) => stream.Type === "Audio",
|
||||
) || []
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="">
|
||||
<Text className="text-lg font-bold mb-2">
|
||||
<View>
|
||||
<Text className='text-lg font-bold mb-2'>
|
||||
{t("item_card.subtitles")}
|
||||
</Text>
|
||||
<SubtitleStreamInfo
|
||||
subtitleStreams={
|
||||
source?.MediaStreams?.filter(
|
||||
(stream) => stream.Type === "Subtitle"
|
||||
(stream) => stream.Type === "Subtitle",
|
||||
) || []
|
||||
}
|
||||
/>
|
||||
@@ -101,25 +100,25 @@ const SubtitleStreamInfo = ({
|
||||
subtitleStreams: MediaStream[];
|
||||
}) => {
|
||||
return (
|
||||
<View className="flex flex-col">
|
||||
{subtitleStreams.map((stream, index) => (
|
||||
<View key={stream.Index} className="flex flex-col">
|
||||
<Text className="text-xs mb-3 text-neutral-400">
|
||||
<View className='flex flex-col'>
|
||||
{subtitleStreams.map((stream, _index) => (
|
||||
<View key={stream.Index} className='flex flex-col'>
|
||||
<Text className='text-xs mb-3 text-neutral-400'>
|
||||
{stream.DisplayTitle}
|
||||
</Text>
|
||||
<View className="flex flex-row flex-wrap gap-2">
|
||||
<View className='flex flex-row flex-wrap gap-2'>
|
||||
<Badge
|
||||
variant="gray"
|
||||
variant='gray'
|
||||
iconLeft={
|
||||
<Ionicons name="language-outline" size={16} color="white" />
|
||||
<Ionicons name='language-outline' size={16} color='white' />
|
||||
}
|
||||
text={stream.Language}
|
||||
/>
|
||||
<Badge
|
||||
variant="gray"
|
||||
variant='gray'
|
||||
text={stream.Codec}
|
||||
iconLeft={
|
||||
<Ionicons name="layers-outline" size={16} color="white" />
|
||||
<Ionicons name='layers-outline' size={16} color='white' />
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
@@ -131,40 +130,40 @@ const SubtitleStreamInfo = ({
|
||||
|
||||
const AudioStreamInfo = ({ audioStreams }: { audioStreams: MediaStream[] }) => {
|
||||
return (
|
||||
<View className="flex flex-col">
|
||||
<View className='flex flex-col'>
|
||||
{audioStreams.map((audioStreams, index) => (
|
||||
<View key={index} className="flex flex-col">
|
||||
<Text className="mb-3 text-neutral-400 text-xs">
|
||||
<View key={index} className='flex flex-col'>
|
||||
<Text className='mb-3 text-neutral-400 text-xs'>
|
||||
{audioStreams.DisplayTitle}
|
||||
</Text>
|
||||
<View className="flex-row flex-wrap gap-2">
|
||||
<View className='flex-row flex-wrap gap-2'>
|
||||
<Badge
|
||||
variant="gray"
|
||||
variant='gray'
|
||||
iconLeft={
|
||||
<Ionicons name="language-outline" size={16} color="white" />
|
||||
<Ionicons name='language-outline' size={16} color='white' />
|
||||
}
|
||||
text={audioStreams.Language}
|
||||
/>
|
||||
<Badge
|
||||
variant="gray"
|
||||
variant='gray'
|
||||
iconLeft={
|
||||
<Ionicons
|
||||
name="musical-notes-outline"
|
||||
name='musical-notes-outline'
|
||||
size={16}
|
||||
color="white"
|
||||
color='white'
|
||||
/>
|
||||
}
|
||||
text={audioStreams.Codec}
|
||||
/>
|
||||
<Badge
|
||||
variant="gray"
|
||||
iconLeft={<Ionicons name="mic-outline" size={16} color="white" />}
|
||||
variant='gray'
|
||||
iconLeft={<Ionicons name='mic-outline' size={16} color='white' />}
|
||||
text={audioStreams.ChannelLayout}
|
||||
/>
|
||||
<Badge
|
||||
variant="gray"
|
||||
variant='gray'
|
||||
iconLeft={
|
||||
<Ionicons name="speedometer-outline" size={16} color="white" />
|
||||
<Ionicons name='speedometer-outline' size={16} color='white' />
|
||||
}
|
||||
text={formatBitrate(audioStreams.BitRate)}
|
||||
/>
|
||||
@@ -176,53 +175,70 @@ const AudioStreamInfo = ({ audioStreams }: { audioStreams: MediaStream[] }) => {
|
||||
};
|
||||
|
||||
const VideoStreamInfo = ({ source }: { source?: MediaSourceInfo }) => {
|
||||
if (!source) return null;
|
||||
|
||||
const videoStream = useMemo(() => {
|
||||
return source.MediaStreams?.find(
|
||||
(stream) => stream.Type === "Video"
|
||||
) as MediaStream;
|
||||
}, [source.MediaStreams]);
|
||||
return source?.MediaStreams?.find((stream) => stream.Type === "Video") as
|
||||
| MediaStream
|
||||
| undefined;
|
||||
}, [source?.MediaStreams]);
|
||||
|
||||
if (!videoStream) return null;
|
||||
if (!source || !videoStream) return null;
|
||||
|
||||
// Dolby Vision video check
|
||||
const isDolbyVision =
|
||||
videoStream.VideoRangeType === "DOVI" ||
|
||||
videoStream.DvVersionMajor != null ||
|
||||
videoStream.DvVersionMinor != null;
|
||||
|
||||
return (
|
||||
<View className="flex-row flex-wrap gap-2">
|
||||
<View className='flex-row flex-wrap gap-2'>
|
||||
<Badge
|
||||
variant="gray"
|
||||
iconLeft={<Ionicons name="film-outline" size={16} color="white" />}
|
||||
variant='gray'
|
||||
iconLeft={<Ionicons name='film-outline' size={16} color='white' />}
|
||||
text={formatFileSize(source.Size)}
|
||||
/>
|
||||
<Badge
|
||||
variant="gray"
|
||||
iconLeft={<Ionicons name="film-outline" size={16} color="white" />}
|
||||
variant='gray'
|
||||
iconLeft={<Ionicons name='film-outline' size={16} color='white' />}
|
||||
text={`${videoStream.Width}x${videoStream.Height}`}
|
||||
/>
|
||||
{isDolbyVision && (
|
||||
<Badge
|
||||
variant='gray'
|
||||
iconLeft={
|
||||
<Ionicons name='sparkles-outline' size={16} color='white' />
|
||||
}
|
||||
text={"DV"}
|
||||
/>
|
||||
)}
|
||||
<Badge
|
||||
variant="gray"
|
||||
variant='gray'
|
||||
iconLeft={
|
||||
<Ionicons name="color-palette-outline" size={16} color="white" />
|
||||
<Ionicons name='color-palette-outline' size={16} color='white' />
|
||||
}
|
||||
text={videoStream.VideoRange}
|
||||
/>
|
||||
<Badge
|
||||
variant="gray"
|
||||
variant='gray'
|
||||
iconLeft={
|
||||
<Ionicons name="code-working-outline" size={16} color="white" />
|
||||
<Ionicons name='code-working-outline' size={16} color='white' />
|
||||
}
|
||||
text={videoStream.Codec}
|
||||
/>
|
||||
<Badge
|
||||
variant="gray"
|
||||
variant='gray'
|
||||
iconLeft={
|
||||
<Ionicons name="speedometer-outline" size={16} color="white" />
|
||||
<Ionicons name='speedometer-outline' size={16} color='white' />
|
||||
}
|
||||
text={formatBitrate(videoStream.BitRate)}
|
||||
/>
|
||||
<Badge
|
||||
variant="gray"
|
||||
iconLeft={<Ionicons name="play-outline" size={16} color="white" />}
|
||||
text={`${videoStream.AverageFrameRate?.toFixed(0)} fps`}
|
||||
variant='gray'
|
||||
iconLeft={<Ionicons name='play-outline' size={16} color='white' />}
|
||||
text={
|
||||
videoStream.AverageFrameRate != null
|
||||
? `${videoStream.AverageFrameRate.toFixed(0)} fps`
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
@@ -233,6 +249,9 @@ const formatFileSize = (bytes?: number | null) => {
|
||||
|
||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
|
||||
if (bytes === 0) return "0 Byte";
|
||||
const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)).toString());
|
||||
return Math.round((bytes / Math.pow(1024, i)) * 100) / 100 + " " + sizes[i];
|
||||
const i = Number.parseInt(
|
||||
Math.floor(Math.log(bytes) / Math.log(1024)).toString(),
|
||||
10,
|
||||
);
|
||||
return `${Math.round((bytes / 1024 ** i) * 100) / 100} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
import { Platform, Pressable } from "react-native";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import ios from "@/utils/profiles/ios";
|
||||
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 { Feather, Ionicons } from "@expo/vector-icons";
|
||||
import { BottomSheetView } from "@gorhom/bottom-sheet";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { Alert, TouchableOpacity, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, Platform, TouchableOpacity, View } from "react-native";
|
||||
import CastContext, {
|
||||
CastButton,
|
||||
MediaStreamType,
|
||||
PlayServicesState,
|
||||
useMediaStatus,
|
||||
useRemoteMediaClient,
|
||||
@@ -30,17 +23,29 @@ import Animated, {
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { Button } from "./Button";
|
||||
import { SelectedOptions } from "./ItemContent";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
||||
import { getDownloadedItemById } from "@/providers/Downloads/database";
|
||||
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { chromecast } from "@/utils/profiles/chromecast";
|
||||
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { chromecastLoadMedia } from "@/utils/chromecastLoadMedia";
|
||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||
import { Button } from "./Button";
|
||||
import { Text } from "./common/Text";
|
||||
import type { SelectedOptions } from "./ItemContent";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof Button> {
|
||||
interface Props extends React.ComponentProps<typeof TouchableOpacity> {
|
||||
item: BaseItemDto;
|
||||
selectedOptions: SelectedOptions;
|
||||
colors?: ThemeColors;
|
||||
}
|
||||
|
||||
const ANIMATION_DURATION = 500;
|
||||
@@ -49,53 +54,60 @@ const MIN_PLAYBACK_WIDTH = 15;
|
||||
export const PlayButton: React.FC<Props> = ({
|
||||
item,
|
||||
selectedOptions,
|
||||
...props
|
||||
colors,
|
||||
}: Props) => {
|
||||
const isOffline = useOfflineMode();
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
const client = useRemoteMediaClient();
|
||||
const mediaStatus = useMediaStatus();
|
||||
const { t } = useTranslation();
|
||||
const { showModal, hideModal } = useGlobalModal();
|
||||
|
||||
const [colorAtom] = useAtom(itemThemeColorAtom);
|
||||
const [globalColorAtom] = useAtom(itemThemeColorAtom);
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
|
||||
// Use colors prop if provided, otherwise fallback to global atom
|
||||
const effectiveColors = colors || globalColorAtom;
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const startWidth = useSharedValue(0);
|
||||
const targetWidth = useSharedValue(0);
|
||||
const endColor = useSharedValue(colorAtom);
|
||||
const startColor = useSharedValue(colorAtom);
|
||||
const endColor = useSharedValue(effectiveColors);
|
||||
const startColor = useSharedValue(effectiveColors);
|
||||
const widthProgress = useSharedValue(0);
|
||||
const colorChangeProgress = useSharedValue(0);
|
||||
const [settings] = useSettings();
|
||||
const { settings, updateSettings } = useSettings();
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
|
||||
const goToPlayer = useCallback(
|
||||
(q: string, bitrateValue: number | undefined) => {
|
||||
(q: string) => {
|
||||
if (settings.maxAutoPlayEpisodeCount.value !== -1) {
|
||||
updateSettings({ autoPlayEpisodeCount: 0 });
|
||||
}
|
||||
router.push(`/player/direct-player?${q}`);
|
||||
},
|
||||
[router]
|
||||
[router, isOffline],
|
||||
);
|
||||
|
||||
const onPress = useCallback(async () => {
|
||||
console.log("onPress");
|
||||
const handleNormalPlayFlow = useCallback(async () => {
|
||||
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() ?? "",
|
||||
playbackPosition: item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
||||
offline: isOffline ? "true" : "false",
|
||||
});
|
||||
|
||||
const queryString = queryParams.toString();
|
||||
|
||||
if (!client) {
|
||||
goToPlayer(queryString, selectedOptions.bitrate?.value);
|
||||
goToPlayer(queryString);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -121,14 +133,42 @@ export const PlayButton: React.FC<Props> = ({
|
||||
// Check if user wants H265 for Chromecast
|
||||
const enableH265 = settings.enableH265ForChromecast;
|
||||
|
||||
// Validate required parameters before calling getStreamUrl
|
||||
if (!api) {
|
||||
console.warn("API not available for Chromecast streaming");
|
||||
Alert.alert(
|
||||
t("player.client_error"),
|
||||
t("player.missing_parameters"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!user?.Id) {
|
||||
console.warn(
|
||||
"User not authenticated for Chromecast streaming",
|
||||
);
|
||||
Alert.alert(
|
||||
t("player.client_error"),
|
||||
t("player.missing_parameters"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!item?.Id) {
|
||||
console.warn("Item not available for Chromecast streaming");
|
||||
Alert.alert(
|
||||
t("player.client_error"),
|
||||
t("player.missing_parameters"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get a new URL with the Chromecast device profile
|
||||
try {
|
||||
const data = await getStreamUrl({
|
||||
api,
|
||||
item,
|
||||
deviceProfile: enableH265 ? chromecasth265 : chromecast,
|
||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
||||
userId: user?.Id,
|
||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks ?? 0,
|
||||
userId: user.Id,
|
||||
audioStreamIndex: selectedOptions.audioIndex,
|
||||
maxStreamingBitrate: selectedOptions.bitrate?.value,
|
||||
mediaSourceId: selectedOptions.mediaSource?.Id,
|
||||
@@ -141,35 +181,88 @@ export const PlayButton: React.FC<Props> = ({
|
||||
console.warn("No URL returned from getStreamUrl", data);
|
||||
Alert.alert(
|
||||
t("player.client_error"),
|
||||
t("player.could_not_create_stream_for_chromecast")
|
||||
t("player.could_not_create_stream_for_chromecast"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
chromecastLoadMedia({
|
||||
client,
|
||||
item,
|
||||
contentUrl: data.url,
|
||||
sessionId: data.sessionId || undefined,
|
||||
mediaSourceId: data.mediaSource?.Id || undefined,
|
||||
playbackOptions: selectedOptions,
|
||||
images: [
|
||||
{
|
||||
url: getParentBackdropImageUrl({
|
||||
api,
|
||||
item,
|
||||
quality: 90,
|
||||
width: 2000,
|
||||
})!,
|
||||
// Calculate start time in seconds from playback position
|
||||
const startTimeSeconds =
|
||||
(item?.UserData?.PlaybackPositionTicks ?? 0) / 10000000;
|
||||
|
||||
// Calculate stream duration in seconds from runtime
|
||||
const streamDurationSeconds = item.RunTimeTicks
|
||||
? item.RunTimeTicks / 10000000
|
||||
: undefined;
|
||||
|
||||
client
|
||||
.loadMedia({
|
||||
mediaInfo: {
|
||||
contentId: item.Id,
|
||||
contentUrl: data?.url,
|
||||
contentType: "video/mp4",
|
||||
streamType: MediaStreamType.BUFFERED,
|
||||
streamDuration: streamDurationSeconds,
|
||||
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,
|
||||
})!,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}).then(() => {
|
||||
// state is already set when reopening current media, so skip it here.
|
||||
if (isOpeningCurrentlyPlayingMedia) {
|
||||
return;
|
||||
}
|
||||
router.push("/player/google-cast-player");
|
||||
});
|
||||
startTime: startTimeSeconds,
|
||||
})
|
||||
.then(() => {
|
||||
// state is already set when reopening current media, so skip it here.
|
||||
if (isOpeningCurrentlyPlayingMedia) {
|
||||
return;
|
||||
}
|
||||
CastContext.showExpandedControls();
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
@@ -177,12 +270,12 @@ export const PlayButton: React.FC<Props> = ({
|
||||
});
|
||||
break;
|
||||
case 1:
|
||||
goToPlayer(queryString, selectedOptions.bitrate?.value);
|
||||
goToPlayer(queryString);
|
||||
break;
|
||||
case cancelButtonIndex:
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}, [
|
||||
item,
|
||||
@@ -194,16 +287,140 @@ export const PlayButton: React.FC<Props> = ({
|
||||
showActionSheetWithOptions,
|
||||
mediaStatus,
|
||||
selectedOptions,
|
||||
goToPlayer,
|
||||
isOffline,
|
||||
t,
|
||||
]);
|
||||
|
||||
const onPress = useCallback(async () => {
|
||||
if (!item) return;
|
||||
|
||||
lightHapticFeedback();
|
||||
|
||||
// Check if item is downloaded
|
||||
const downloadedItem = item.Id ? getDownloadedItemById(item.Id) : undefined;
|
||||
|
||||
// If already in offline mode, play downloaded file directly
|
||||
if (isOffline && downloadedItem) {
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: item.Id!,
|
||||
offline: "true",
|
||||
playbackPosition:
|
||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
||||
});
|
||||
goToPlayer(queryParams.toString());
|
||||
return;
|
||||
}
|
||||
|
||||
// If online but file is downloaded, ask user which version to play
|
||||
if (downloadedItem) {
|
||||
if (Platform.OS === "android") {
|
||||
// Show bottom sheet for Android
|
||||
showModal(
|
||||
<BottomSheetView>
|
||||
<View className='px-4 mt-4 mb-12'>
|
||||
<View className='pb-6'>
|
||||
<Text className='text-2xl font-bold mb-2'>
|
||||
{t("player.downloaded_file_title")}
|
||||
</Text>
|
||||
<Text className='opacity-70 text-base'>
|
||||
{t("player.downloaded_file_message")}
|
||||
</Text>
|
||||
</View>
|
||||
<View className='space-y-3'>
|
||||
<Button
|
||||
onPress={() => {
|
||||
hideModal();
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: item.Id!,
|
||||
offline: "true",
|
||||
playbackPosition:
|
||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
||||
});
|
||||
goToPlayer(queryParams.toString());
|
||||
}}
|
||||
color='purple'
|
||||
>
|
||||
{Platform.OS === "android"
|
||||
? "Play downloaded file"
|
||||
: t("player.downloaded_file_yes")}
|
||||
</Button>
|
||||
<Button
|
||||
onPress={() => {
|
||||
hideModal();
|
||||
handleNormalPlayFlow();
|
||||
}}
|
||||
color='white'
|
||||
variant='border'
|
||||
>
|
||||
{Platform.OS === "android"
|
||||
? "Stream file"
|
||||
: t("player.downloaded_file_no")}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</BottomSheetView>,
|
||||
{
|
||||
snapPoints: ["35%"],
|
||||
enablePanDownToClose: true,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// Show alert for iOS
|
||||
Alert.alert(
|
||||
t("player.downloaded_file_title"),
|
||||
t("player.downloaded_file_message"),
|
||||
[
|
||||
{
|
||||
text: t("player.downloaded_file_yes"),
|
||||
onPress: () => {
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: item.Id!,
|
||||
offline: "true",
|
||||
playbackPosition:
|
||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
||||
});
|
||||
goToPlayer(queryParams.toString());
|
||||
},
|
||||
isPreferred: true,
|
||||
},
|
||||
{
|
||||
text: t("player.downloaded_file_no"),
|
||||
onPress: () => {
|
||||
handleNormalPlayFlow();
|
||||
},
|
||||
},
|
||||
{
|
||||
text: t("player.downloaded_file_cancel"),
|
||||
style: "cancel",
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// If not downloaded, proceed with normal flow
|
||||
handleNormalPlayFlow();
|
||||
}, [
|
||||
item,
|
||||
lightHapticFeedback,
|
||||
handleNormalPlayFlow,
|
||||
goToPlayer,
|
||||
t,
|
||||
showModal,
|
||||
hideModal,
|
||||
effectiveColors,
|
||||
]);
|
||||
|
||||
const derivedTargetWidth = useDerivedValue(() => {
|
||||
if (!item || !item.RunTimeTicks) return 0;
|
||||
const userData = item.UserData;
|
||||
if (userData && userData.PlaybackPositionTicks) {
|
||||
if (userData?.PlaybackPositionTicks) {
|
||||
return userData.PlaybackPositionTicks > 0
|
||||
? Math.max(
|
||||
(userData.PlaybackPositionTicks / item.RunTimeTicks) * 100,
|
||||
MIN_PLAYBACK_WIDTH
|
||||
MIN_PLAYBACK_WIDTH,
|
||||
)
|
||||
: 0;
|
||||
}
|
||||
@@ -220,11 +437,11 @@ export const PlayButton: React.FC<Props> = ({
|
||||
easing: Easing.bezier(0.7, 0, 0.3, 1.0),
|
||||
});
|
||||
},
|
||||
[item]
|
||||
[item],
|
||||
);
|
||||
|
||||
useAnimatedReaction(
|
||||
() => colorAtom,
|
||||
() => effectiveColors,
|
||||
(newColor) => {
|
||||
endColor.value = newColor;
|
||||
colorChangeProgress.value = 0;
|
||||
@@ -233,19 +450,19 @@ export const PlayButton: React.FC<Props> = ({
|
||||
easing: Easing.bezier(0.9, 0, 0.31, 0.99),
|
||||
});
|
||||
},
|
||||
[colorAtom]
|
||||
[effectiveColors],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout_2 = setTimeout(() => {
|
||||
startColor.value = colorAtom;
|
||||
startColor.value = effectiveColors;
|
||||
startWidth.value = targetWidth.value;
|
||||
}, ANIMATION_DURATION);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout_2);
|
||||
};
|
||||
}, [colorAtom, item]);
|
||||
}, [effectiveColors, item]);
|
||||
|
||||
/**
|
||||
* ANIMATED STYLES
|
||||
@@ -254,7 +471,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
backgroundColor: interpolateColor(
|
||||
colorChangeProgress.value,
|
||||
[0, 1],
|
||||
[startColor.value.primary, endColor.value.primary]
|
||||
[startColor.value.primary, endColor.value.primary],
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -262,7 +479,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
backgroundColor: interpolateColor(
|
||||
colorChangeProgress.value,
|
||||
[0, 1],
|
||||
[startColor.value.primary, endColor.value.primary]
|
||||
[startColor.value.primary, endColor.value.primary],
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -270,7 +487,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
width: `${interpolate(
|
||||
widthProgress.value,
|
||||
[0, 1],
|
||||
[startWidth.value, targetWidth.value]
|
||||
[startWidth.value, targetWidth.value],
|
||||
)}%`,
|
||||
}));
|
||||
|
||||
@@ -278,23 +495,19 @@ export const PlayButton: React.FC<Props> = ({
|
||||
color: interpolateColor(
|
||||
colorChangeProgress.value,
|
||||
[0, 1],
|
||||
[startColor.value.text, endColor.value.text]
|
||||
[startColor.value.text, endColor.value.text],
|
||||
),
|
||||
}));
|
||||
/**
|
||||
* *********************
|
||||
*/
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
disabled={!item}
|
||||
accessibilityLabel="Play button"
|
||||
accessibilityHint="Tap to play the media"
|
||||
accessibilityLabel='Play button'
|
||||
accessibilityHint='Tap to play the media'
|
||||
onPress={onPress}
|
||||
className={`relative`}
|
||||
{...props}
|
||||
className={"relative flex-1"}
|
||||
>
|
||||
<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-full z-10 overflow-hidden'>
|
||||
<Animated.View
|
||||
style={[
|
||||
animatedPrimaryStyle,
|
||||
@@ -308,36 +521,31 @@ export const PlayButton: React.FC<Props> = ({
|
||||
|
||||
<Animated.View
|
||||
style={[animatedAverageStyle, { opacity: 0.5 }]}
|
||||
className="absolute w-full h-full top-0 left-0 rounded-xl"
|
||||
className='absolute w-full h-full top-0 left-0 rounded-full'
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderColor: colorAtom.primary,
|
||||
borderColor: effectiveColors.primary,
|
||||
borderStyle: "solid",
|
||||
}}
|
||||
className="flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full "
|
||||
className='flex flex-row items-center justify-center bg-transparent rounded-full z-20 h-12 w-full '
|
||||
>
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
<View className='flex flex-row items-center space-x-2'>
|
||||
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
|
||||
{runtimeTicksToMinutes(item?.RunTimeTicks)}
|
||||
{runtimeTicksToMinutes(
|
||||
(item?.RunTimeTicks || 0) -
|
||||
(item?.UserData?.PlaybackPositionTicks || 0),
|
||||
)}
|
||||
{(item?.UserData?.PlaybackPositionTicks || 0) > 0 && " left"}
|
||||
</Animated.Text>
|
||||
<Animated.Text style={animatedTextStyle}>
|
||||
<Ionicons name="play-circle" size={24} />
|
||||
<Ionicons name='play-circle' size={24} />
|
||||
</Animated.Text>
|
||||
{client && (
|
||||
<Animated.Text style={animatedTextStyle}>
|
||||
<Feather name="cast" size={22} />
|
||||
<CastButton tintColor="transparent" />
|
||||
</Animated.Text>
|
||||
)}
|
||||
{!client && settings?.openInVLC && (
|
||||
<Animated.Text style={animatedTextStyle}>
|
||||
<MaterialCommunityIcons
|
||||
name="vlc"
|
||||
size={18}
|
||||
color={animatedTextStyle.color}
|
||||
/>
|
||||
<Feather name='cast' size={22} />
|
||||
<CastButton tintColor='transparent' />
|
||||
</Animated.Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
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 { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { Alert, TouchableOpacity, View } from "react-native";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import Animated, {
|
||||
Easing,
|
||||
interpolate,
|
||||
@@ -20,14 +13,18 @@ import Animated, {
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { Button } from "./Button";
|
||||
import { SelectedOptions } from "./ItemContent";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||
import type { Button } from "./Button";
|
||||
import type { SelectedOptions } from "./ItemContent";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof Button> {
|
||||
item: BaseItemDto;
|
||||
selectedOptions: SelectedOptions;
|
||||
colors?: ThemeColors;
|
||||
}
|
||||
|
||||
const ANIMATION_DURATION = 500;
|
||||
@@ -36,35 +33,32 @@ const MIN_PLAYBACK_WIDTH = 15;
|
||||
export const PlayButton: React.FC<Props> = ({
|
||||
item,
|
||||
selectedOptions,
|
||||
colors,
|
||||
...props
|
||||
}: Props) => {
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
const { t } = useTranslation();
|
||||
const [globalColorAtom] = useAtom(itemThemeColorAtom);
|
||||
|
||||
const [colorAtom] = useAtom(itemThemeColorAtom);
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
// Use colors prop if provided, otherwise fallback to global atom
|
||||
const effectiveColors = colors || globalColorAtom;
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const startWidth = useSharedValue(0);
|
||||
const targetWidth = useSharedValue(0);
|
||||
const endColor = useSharedValue(colorAtom);
|
||||
const startColor = useSharedValue(colorAtom);
|
||||
const endColor = useSharedValue(effectiveColors);
|
||||
const startColor = useSharedValue(effectiveColors);
|
||||
const widthProgress = useSharedValue(0);
|
||||
const colorChangeProgress = useSharedValue(0);
|
||||
const [settings] = useSettings();
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
|
||||
const goToPlayer = useCallback(
|
||||
(q: string, bitrateValue: number | undefined) => {
|
||||
(q: string) => {
|
||||
router.push(`/player/direct-player?${q}`);
|
||||
},
|
||||
[router]
|
||||
[router],
|
||||
);
|
||||
|
||||
const onPress = () => {
|
||||
console.log("onpress");
|
||||
if (!item) return;
|
||||
|
||||
lightHapticFeedback();
|
||||
@@ -78,18 +72,18 @@ export const PlayButton: React.FC<Props> = ({
|
||||
});
|
||||
|
||||
const queryString = queryParams.toString();
|
||||
goToPlayer(queryString, selectedOptions.bitrate?.value);
|
||||
goToPlayer(queryString);
|
||||
return;
|
||||
};
|
||||
|
||||
const derivedTargetWidth = useDerivedValue(() => {
|
||||
if (!item || !item.RunTimeTicks) return 0;
|
||||
const userData = item.UserData;
|
||||
if (userData && userData.PlaybackPositionTicks) {
|
||||
if (userData?.PlaybackPositionTicks) {
|
||||
return userData.PlaybackPositionTicks > 0
|
||||
? Math.max(
|
||||
(userData.PlaybackPositionTicks / item.RunTimeTicks) * 100,
|
||||
MIN_PLAYBACK_WIDTH
|
||||
MIN_PLAYBACK_WIDTH,
|
||||
)
|
||||
: 0;
|
||||
}
|
||||
@@ -106,11 +100,11 @@ export const PlayButton: React.FC<Props> = ({
|
||||
easing: Easing.bezier(0.7, 0, 0.3, 1.0),
|
||||
});
|
||||
},
|
||||
[item]
|
||||
[item],
|
||||
);
|
||||
|
||||
useAnimatedReaction(
|
||||
() => colorAtom,
|
||||
() => effectiveColors,
|
||||
(newColor) => {
|
||||
endColor.value = newColor;
|
||||
colorChangeProgress.value = 0;
|
||||
@@ -119,19 +113,19 @@ export const PlayButton: React.FC<Props> = ({
|
||||
easing: Easing.bezier(0.9, 0, 0.31, 0.99),
|
||||
});
|
||||
},
|
||||
[colorAtom]
|
||||
[effectiveColors],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout_2 = setTimeout(() => {
|
||||
startColor.value = colorAtom;
|
||||
startColor.value = effectiveColors;
|
||||
startWidth.value = targetWidth.value;
|
||||
}, ANIMATION_DURATION);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout_2);
|
||||
};
|
||||
}, [colorAtom, item]);
|
||||
}, [effectiveColors, item]);
|
||||
|
||||
/**
|
||||
* ANIMATED STYLES
|
||||
@@ -140,7 +134,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
backgroundColor: interpolateColor(
|
||||
colorChangeProgress.value,
|
||||
[0, 1],
|
||||
[startColor.value.primary, endColor.value.primary]
|
||||
[startColor.value.primary, endColor.value.primary],
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -148,7 +142,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
backgroundColor: interpolateColor(
|
||||
colorChangeProgress.value,
|
||||
[0, 1],
|
||||
[startColor.value.primary, endColor.value.primary]
|
||||
[startColor.value.primary, endColor.value.primary],
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -156,7 +150,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
width: `${interpolate(
|
||||
widthProgress.value,
|
||||
[0, 1],
|
||||
[startWidth.value, targetWidth.value]
|
||||
[startWidth.value, targetWidth.value],
|
||||
)}%`,
|
||||
}));
|
||||
|
||||
@@ -164,7 +158,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
color: interpolateColor(
|
||||
colorChangeProgress.value,
|
||||
[0, 1],
|
||||
[startColor.value.text, endColor.value.text]
|
||||
[startColor.value.text, endColor.value.text],
|
||||
),
|
||||
}));
|
||||
/**
|
||||
@@ -173,13 +167,13 @@ export const PlayButton: React.FC<Props> = ({
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
accessibilityLabel="Play button"
|
||||
accessibilityHint="Tap to play the media"
|
||||
accessibilityLabel='Play button'
|
||||
accessibilityHint='Tap to play the media'
|
||||
onPress={onPress}
|
||||
className={`relative`}
|
||||
className={"relative"}
|
||||
{...props}
|
||||
>
|
||||
<View className="absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden">
|
||||
<View className='absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden'>
|
||||
<Animated.View
|
||||
style={[
|
||||
animatedPrimaryStyle,
|
||||
@@ -193,32 +187,23 @@ export const PlayButton: React.FC<Props> = ({
|
||||
|
||||
<Animated.View
|
||||
style={[animatedAverageStyle, { opacity: 0.5 }]}
|
||||
className="absolute w-full h-full top-0 left-0 rounded-xl"
|
||||
className='absolute w-full h-full top-0 left-0 rounded-xl'
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderColor: colorAtom.primary,
|
||||
borderColor: effectiveColors.primary,
|
||||
borderStyle: "solid",
|
||||
}}
|
||||
className="flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full "
|
||||
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">
|
||||
<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} />
|
||||
<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>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { tc } from "@/utils/textTools";
|
||||
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useMemo } from "react";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||
import { Text } from "./common/Text";
|
||||
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { tc } from "@/utils/textTools";
|
||||
import { Text } from "./common/Text";
|
||||
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof View> {
|
||||
source?: MediaSourceInfo;
|
||||
@@ -18,76 +18,95 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
||||
selected,
|
||||
...props
|
||||
}) => {
|
||||
if (Platform.isTV) return null;
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const subtitleStreams = useMemo(() => {
|
||||
return source?.MediaStreams?.filter((x) => x.Type === "Subtitle");
|
||||
}, [source]);
|
||||
|
||||
const selectedSubtitleSteam = useMemo(
|
||||
() => subtitleStreams?.find((x) => x.Index === selected),
|
||||
[subtitleStreams, selected]
|
||||
[subtitleStreams, selected],
|
||||
);
|
||||
|
||||
if (subtitleStreams?.length === 0) return null;
|
||||
const optionGroups: OptionGroup[] = useMemo(() => {
|
||||
const options = [
|
||||
{
|
||||
type: "radio" as const,
|
||||
label: t("item_card.none"),
|
||||
value: -1,
|
||||
selected: selected === -1,
|
||||
onPress: () => onChange(-1),
|
||||
},
|
||||
...(subtitleStreams?.map((subtitle, idx) => ({
|
||||
type: "radio" as const,
|
||||
label: subtitle.DisplayTitle || `Subtitle Stream ${idx + 1}`,
|
||||
value: subtitle.Index,
|
||||
selected: subtitle.Index === selected,
|
||||
onPress: () => onChange(subtitle.Index ?? -1),
|
||||
})) || []),
|
||||
];
|
||||
|
||||
const { t } = useTranslation();
|
||||
return [
|
||||
{
|
||||
options,
|
||||
},
|
||||
];
|
||||
}, [subtitleStreams, selected, t, onChange]);
|
||||
|
||||
return (
|
||||
<View
|
||||
className="flex col shrink justify-start place-self-start items-start"
|
||||
style={{
|
||||
minWidth: 60,
|
||||
maxWidth: 200,
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className="flex flex-col " {...props}>
|
||||
<Text 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">
|
||||
<Text className=" ">
|
||||
{selectedSubtitleSteam
|
||||
? tc(selectedSubtitleSteam?.DisplayTitle, 7)
|
||||
: t("item_card.none")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>Subtitle tracks</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
key={"-1"}
|
||||
onSelect={() => {
|
||||
onChange(-1);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>None</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
{subtitleStreams?.map((subtitle, idx: number) => (
|
||||
<DropdownMenu.Item
|
||||
key={idx.toString()}
|
||||
onSelect={() => {
|
||||
if (subtitle.Index !== undefined && subtitle.Index !== null)
|
||||
onChange(subtitle.Index);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{subtitle.DisplayTitle}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
const handleOptionSelect = (optionId: string) => {
|
||||
if (optionId === "none") {
|
||||
onChange(-1);
|
||||
} else {
|
||||
const selectedStream = subtitleStreams?.find(
|
||||
(subtitle, idx) => `${subtitle.Index || idx}` === optionId,
|
||||
);
|
||||
if (
|
||||
selectedStream &&
|
||||
selectedStream.Index !== undefined &&
|
||||
selectedStream.Index !== null
|
||||
) {
|
||||
onChange(selectedStream.Index);
|
||||
}
|
||||
}
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const trigger = (
|
||||
<View className='flex flex-col' {...props}>
|
||||
<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'
|
||||
onPress={() => setOpen(true)}
|
||||
>
|
||||
<Text>
|
||||
{selectedSubtitleSteam
|
||||
? tc(selectedSubtitleSteam?.DisplayTitle, 7)
|
||||
: t("item_card.none")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
|
||||
if (Platform.isTV || subtitleStreams?.length === 0) return null;
|
||||
|
||||
return (
|
||||
<PlatformDropdown
|
||||
groups={optionGroups}
|
||||
trigger={trigger}
|
||||
title={t("item_card.subtitles")}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
onOptionSelect={handleOptionSelect}
|
||||
expoUIConfig={{
|
||||
hostStyle: { flex: 1 },
|
||||
}}
|
||||
bottomSheetConfig={{
|
||||
enablePanDownToClose: true,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import React from "react";
|
||||
import { Platform, TextProps } from "react-native";
|
||||
import { UITextView } from "react-native-uitextview";
|
||||
import { Text as RNText } from "react-native";
|
||||
export function Text(
|
||||
props: TextProps & {
|
||||
uiTextView?: boolean;
|
||||
}
|
||||
) {
|
||||
import { Platform, Text as RNText, type TextProps } from "react-native";
|
||||
export function Text(props: TextProps) {
|
||||
const { style, ...otherProps } = props;
|
||||
if (Platform.isTV)
|
||||
return (
|
||||
@@ -16,12 +9,12 @@ export function Text(
|
||||
{...otherProps}
|
||||
/>
|
||||
);
|
||||
else
|
||||
return (
|
||||
<UITextView
|
||||
allowFontScaling={false}
|
||||
style={[{ color: "white" }, style]}
|
||||
{...otherProps}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<RNText
|
||||
allowFontScaling={false}
|
||||
style={[{ color: "white" }, style]}
|
||||
{...otherProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,33 @@
|
||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||
import { useFavorite } from "@/hooks/useFavorite";
|
||||
import {
|
||||
BaseItemDto,
|
||||
BaseItemPerson,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useRouter, useSegments } from "expo-router";
|
||||
import { PropsWithChildren, useCallback } from "react";
|
||||
import { TouchableOpacity, TouchableOpacityProps } from "react-native";
|
||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useSegments } from "expo-router";
|
||||
import { type PropsWithChildren, useCallback } from "react";
|
||||
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useFavorite } from "@/hooks/useFavorite";
|
||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
|
||||
interface Props extends TouchableOpacityProps {
|
||||
item: BaseItemDto;
|
||||
}
|
||||
|
||||
export const itemRouter = (
|
||||
item: BaseItemDto | BaseItemPerson,
|
||||
from: string
|
||||
) => {
|
||||
export const itemRouter = (item: BaseItemDto, from: string) => {
|
||||
if ("CollectionType" in item && item.CollectionType === "livetv") {
|
||||
return `/(auth)/(tabs)/${from}/livetv`;
|
||||
}
|
||||
|
||||
if ("CollectionType" in item && item.CollectionType === "music") {
|
||||
return `/(auth)/(tabs)/(libraries)/music/${item.Id}`;
|
||||
}
|
||||
|
||||
if (item.Type === "Series") {
|
||||
return `/(auth)/(tabs)/${from}/series/${item.Id}`;
|
||||
}
|
||||
|
||||
if (item.Type === "Person" || item.Type === "Actor") {
|
||||
return `/(auth)/(tabs)/${from}/actors/${item.Id}`;
|
||||
if (item.Type === "Person") {
|
||||
return `/(auth)/(tabs)/${from}/persons/${item.Id}`;
|
||||
}
|
||||
|
||||
if (item.Type === "BoxSet") {
|
||||
@@ -48,28 +49,145 @@ export const itemRouter = (
|
||||
return `/(auth)/(tabs)/${from}/items/page?id=${item.Id}`;
|
||||
};
|
||||
|
||||
export const getItemNavigation = (item: BaseItemDto, _from: string) => {
|
||||
if ("CollectionType" in item && item.CollectionType === "livetv") {
|
||||
return {
|
||||
pathname: "/livetv" as const,
|
||||
};
|
||||
}
|
||||
|
||||
if ("CollectionType" in item && item.CollectionType === "music") {
|
||||
return {
|
||||
pathname: "/music/[libraryId]" as const,
|
||||
params: { libraryId: item.Id! },
|
||||
};
|
||||
}
|
||||
|
||||
if (item.Type === "Series") {
|
||||
return {
|
||||
pathname: "/series/[id]" as const,
|
||||
params: { id: item.Id! },
|
||||
};
|
||||
}
|
||||
|
||||
if (item.Type === "Person") {
|
||||
return {
|
||||
pathname: "/persons/[personId]" as const,
|
||||
params: { personId: item.Id! },
|
||||
};
|
||||
}
|
||||
|
||||
if (item.Type === "BoxSet" || item.Type === "UserView") {
|
||||
return {
|
||||
pathname: "/collections/[collectionId]" as const,
|
||||
params: { collectionId: item.Id! },
|
||||
};
|
||||
}
|
||||
|
||||
if (item.Type === "CollectionFolder") {
|
||||
return {
|
||||
pathname: "/[libraryId]" as const,
|
||||
params: { libraryId: item.Id! },
|
||||
};
|
||||
}
|
||||
|
||||
// Music types - use shared routes for proper back navigation
|
||||
if (item.Type === "MusicArtist") {
|
||||
return {
|
||||
pathname: "/music/artist/[artistId]" as const,
|
||||
params: { artistId: item.Id! },
|
||||
};
|
||||
}
|
||||
|
||||
if (item.Type === "MusicAlbum") {
|
||||
return {
|
||||
pathname: "/music/album/[albumId]" as const,
|
||||
params: { albumId: item.Id! },
|
||||
};
|
||||
}
|
||||
|
||||
if (item.Type === "Audio") {
|
||||
// Navigate to the album if available, otherwise to the item page
|
||||
if (item.AlbumId) {
|
||||
return {
|
||||
pathname: "/music/album/[albumId]" as const,
|
||||
params: { albumId: item.AlbumId },
|
||||
};
|
||||
}
|
||||
return {
|
||||
pathname: "/items/page" as const,
|
||||
params: { id: item.Id! },
|
||||
};
|
||||
}
|
||||
|
||||
if (item.Type === "Playlist") {
|
||||
return {
|
||||
pathname: "/music/playlist/[playlistId]" as const,
|
||||
params: { playlistId: item.Id! },
|
||||
};
|
||||
}
|
||||
|
||||
// Default case - items page
|
||||
return {
|
||||
pathname: "/items/page" as const,
|
||||
params: { id: item.Id! },
|
||||
};
|
||||
};
|
||||
|
||||
export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
item,
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const segments = useSegments();
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
const markAsPlayedStatus = useMarkAsPlayed([item]);
|
||||
const { isFavorite, toggleFavorite } = useFavorite(item);
|
||||
|
||||
const from = segments[2];
|
||||
const router = useRouter();
|
||||
const isOffline = useOfflineMode();
|
||||
const { deleteFile } = useDownload();
|
||||
|
||||
const from = (segments as string[])[2] || "(home)";
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
// Force music libraries to navigate via the explicit string route.
|
||||
// This avoids losing the dynamic [libraryId] param when going through a nested navigator.
|
||||
if ("CollectionType" in item && item.CollectionType === "music") {
|
||||
router.push(itemRouter(item, from) as any);
|
||||
return;
|
||||
}
|
||||
|
||||
const navigation = getItemNavigation(item, from);
|
||||
router.push(navigation as any);
|
||||
}, [from, item, router]);
|
||||
|
||||
const showActionSheet = useCallback(() => {
|
||||
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 cancelButtonIndex = 3;
|
||||
if (
|
||||
!(
|
||||
item.Type === "Movie" ||
|
||||
item.Type === "Episode" ||
|
||||
item.Type === "Series"
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
const options: string[] = [
|
||||
"Mark as Played",
|
||||
"Mark as Not Played",
|
||||
isFavorite ? "Unmark as Favorite" : "Mark as Favorite",
|
||||
...(isOffline ? ["Delete Download"] : []),
|
||||
"Cancel",
|
||||
];
|
||||
const cancelButtonIndex = options.length - 1;
|
||||
const destructiveButtonIndex = isOffline
|
||||
? cancelButtonIndex - 1
|
||||
: undefined;
|
||||
|
||||
showActionSheetWithOptions(
|
||||
{
|
||||
options,
|
||||
cancelButtonIndex,
|
||||
destructiveButtonIndex,
|
||||
},
|
||||
async (selectedIndex) => {
|
||||
if (selectedIndex === 0) {
|
||||
@@ -77,29 +195,38 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
} else if (selectedIndex === 1) {
|
||||
await markAsPlayedStatus(false);
|
||||
} else if (selectedIndex === 2) {
|
||||
toggleFavorite()
|
||||
toggleFavorite();
|
||||
} else if (isOffline && selectedIndex === 3 && item.Id) {
|
||||
deleteFile(item.Id);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}, [showActionSheetWithOptions, isFavorite, markAsPlayedStatus]);
|
||||
}, [
|
||||
showActionSheetWithOptions,
|
||||
isFavorite,
|
||||
markAsPlayedStatus,
|
||||
toggleFavorite,
|
||||
isOffline,
|
||||
deleteFile,
|
||||
item.Id,
|
||||
]);
|
||||
|
||||
if (
|
||||
from === "(home)" ||
|
||||
from === "(search)" ||
|
||||
from === "(libraries)" ||
|
||||
from === "(favorites)"
|
||||
from === "(favorites)" ||
|
||||
from === "(watchlists)"
|
||||
)
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onLongPress={showActionSheet}
|
||||
onPress={() => {
|
||||
const url = itemRouter(item, from);
|
||||
// @ts-expect-error
|
||||
router.push(url);
|
||||
}}
|
||||
onPress={handlePress}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -1,205 +1,40 @@
|
||||
import { t } from "i18next";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { JobStatus } from "@/utils/optimize-server";
|
||||
import { formatTimeString } from "@/utils/time";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useRouter } from "expo-router";
|
||||
import { t } from "i18next";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Platform,
|
||||
TouchableOpacity,
|
||||
TouchableOpacityProps,
|
||||
View,
|
||||
ViewProps,
|
||||
} from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { Button } from "../Button";
|
||||
const BackGroundDownloader = !Platform.isTV
|
||||
? require("@kesha-antonov/react-native-background-downloader")
|
||||
: null;
|
||||
const FFmpegKitProvider = !Platform.isTV
|
||||
? require("ffmpeg-kit-react-native")
|
||||
: null;
|
||||
import { JobStatus } from "@/providers/Downloads/types";
|
||||
import { DownloadCard } from "./DownloadCard";
|
||||
|
||||
interface Props extends ViewProps {}
|
||||
interface ActiveDownloadsProps extends ViewProps {}
|
||||
|
||||
export const ActiveDownloads: React.FC<Props> = ({ ...props }) => {
|
||||
export default function ActiveDownloads({ ...props }: ActiveDownloadsProps) {
|
||||
const { processes } = useDownload();
|
||||
if (processes?.length === 0)
|
||||
|
||||
// Filter out any invalid processes before rendering
|
||||
const validProcesses = processes?.filter((p) => p?.item?.Id) || [];
|
||||
|
||||
if (validProcesses.length === 0)
|
||||
return (
|
||||
<View {...props} className="bg-neutral-900 p-4 rounded-2xl">
|
||||
<Text className="text-lg font-bold">
|
||||
<View {...props} className='bg-neutral-900 p-4 rounded-2xl'>
|
||||
<Text className='text-lg font-bold'>
|
||||
{t("home.downloads.active_download")}
|
||||
</Text>
|
||||
<Text className="opacity-50">
|
||||
<Text className='opacity-50'>
|
||||
{t("home.downloads.no_active_downloads")}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View {...props} className="bg-neutral-900 p-4 rounded-2xl">
|
||||
<Text className="text-lg font-bold mb-2">
|
||||
<View {...props} className='bg-neutral-900 p-4 rounded-2xl'>
|
||||
<Text className='text-lg font-bold mb-2'>
|
||||
{t("home.downloads.active_downloads")}
|
||||
</Text>
|
||||
<View className="space-y-2">
|
||||
{processes?.map((p: JobStatus) => (
|
||||
<DownloadCard key={p.item.Id} process={p} />
|
||||
<View className='gap-y-2'>
|
||||
{validProcesses.map((p: JobStatus) => (
|
||||
<DownloadCard key={p.id} process={p} />
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
interface DownloadCardProps extends TouchableOpacityProps {
|
||||
process: JobStatus;
|
||||
}
|
||||
|
||||
const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||
const { processes, startDownload } = useDownload();
|
||||
const router = useRouter();
|
||||
const { removeProcess, setProcesses } = useDownload();
|
||||
const [settings] = useSettings();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const cancelJobMutation = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
if (!process) throw new Error("No active download");
|
||||
|
||||
if (settings?.downloadMethod === DownloadMethod.Optimized) {
|
||||
try {
|
||||
const tasks = await BackGroundDownloader.checkForExistingDownloads();
|
||||
for (const task of tasks) {
|
||||
if (task.id === id) {
|
||||
task.stop();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
throw e;
|
||||
} finally {
|
||||
await removeProcess(id);
|
||||
await queryClient.refetchQueries({ queryKey: ["jobs"] });
|
||||
}
|
||||
} else {
|
||||
FFmpegKitProvider.FFmpegKit.cancel(Number(id));
|
||||
setProcesses((prev: any[]) =>
|
||||
prev.filter((p: { id: string }) => p.id !== id)
|
||||
);
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t("home.downloads.toasts.download_cancelled"));
|
||||
},
|
||||
onError: (e) => {
|
||||
console.error(e);
|
||||
toast.error(t("home.downloads.toasts.could_not_cancel_download"));
|
||||
},
|
||||
});
|
||||
|
||||
const eta = (p: JobStatus) => {
|
||||
if (!p.speed || !p.progress) return null;
|
||||
|
||||
const length = p?.item?.RunTimeTicks || 0;
|
||||
const timeLeft = (length - length * (p.progress / 100)) / p.speed;
|
||||
return formatTimeString(timeLeft, "tick");
|
||||
};
|
||||
|
||||
const base64Image = useMemo(() => {
|
||||
return storage.getString(process.item.Id!);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push(`/(auth)/items/page?id=${process.item.Id}`)}
|
||||
className="relative bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden"
|
||||
{...props}
|
||||
>
|
||||
{(process.status === "optimizing" ||
|
||||
process.status === "downloading") && (
|
||||
<View
|
||||
className={`
|
||||
bg-purple-600 h-1 absolute bottom-0 left-0
|
||||
`}
|
||||
style={{
|
||||
width: process.progress
|
||||
? `${Math.max(5, process.progress)}%`
|
||||
: "5%",
|
||||
}}
|
||||
></View>
|
||||
)}
|
||||
<View className="px-3 py-1.5 flex flex-col w-full">
|
||||
<View className="flex flex-row items-center w-full">
|
||||
{base64Image && (
|
||||
<View className="w-14 aspect-[10/15] rounded-lg overflow-hidden mr-4">
|
||||
<Image
|
||||
source={{
|
||||
uri: `data:image/jpeg;base64,${base64Image}`,
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
resizeMode: "cover",
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<View className="shrink mb-1">
|
||||
<Text className="text-xs opacity-50">{process.item.Type}</Text>
|
||||
<Text className="font-semibold shrink">{process.item.Name}</Text>
|
||||
<Text className="text-xs opacity-50">
|
||||
{process.item.ProductionYear}
|
||||
</Text>
|
||||
<View className="flex flex-row items-center space-x-2 mt-1 text-purple-600">
|
||||
{process.progress === 0 ? (
|
||||
<ActivityIndicator size={"small"} color={"white"} />
|
||||
) : (
|
||||
<Text className="text-xs">{process.progress.toFixed(0)}%</Text>
|
||||
)}
|
||||
{process.speed && (
|
||||
<Text className="text-xs">{process.speed?.toFixed(2)}x</Text>
|
||||
)}
|
||||
{eta(process) && (
|
||||
<Text className="text-xs">
|
||||
{t("home.downloads.eta", { eta: eta(process) })}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className="flex flex-row items-center space-x-2 mt-1 text-purple-600">
|
||||
<Text className="text-xs capitalize">{process.status}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
disabled={cancelJobMutation.isPending}
|
||||
onPress={() => cancelJobMutation.mutate(process.id)}
|
||||
className="ml-auto"
|
||||
>
|
||||
{cancelJobMutation.isPending ? (
|
||||
<ActivityIndicator size="small" color="white" />
|
||||
) : (
|
||||
<Ionicons name="close" size={24} color="red" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{process.status === "completed" && (
|
||||
<View className="flex flex-row mt-4 space-x-4">
|
||||
<Button
|
||||
onPress={() => {
|
||||
startDownload(process);
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
Download now
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { View, ViewProps } from "react-native";
|
||||
import { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useMemo } from "react";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { uniqBy } from "lodash";
|
||||
import { TmdbRelease } from "@/utils/jellyseerr/server/api/themoviedb/interfaces";
|
||||
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import CountryFlag from "react-native-country-flag";
|
||||
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
|
||||
import { uniqBy } from "lodash";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import CountryFlag from "react-native-country-flag";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
|
||||
import type { TmdbRelease } from "@/utils/jellyseerr/server/api/themoviedb/interfaces";
|
||||
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||
|
||||
interface Release {
|
||||
certification: string;
|
||||
@@ -30,12 +30,12 @@ const Facts: React.FC<
|
||||
> = ({ title, facts, ...props }) =>
|
||||
facts &&
|
||||
facts?.length > 0 && (
|
||||
<View className="flex flex-col justify-between py-2" {...props}>
|
||||
<Text className="font-bold text-start">{title}</Text>
|
||||
<View className='flex flex-col justify-between py-2' {...props}>
|
||||
<Text className='font-bold text-start'>{title}</Text>
|
||||
|
||||
<View className="flex flex-col items-end">
|
||||
<View className='flex flex-col items-end'>
|
||||
{facts.map((f, idx) =>
|
||||
typeof f === "string" ? <Text key={idx}>{f}</Text> : f
|
||||
typeof f === "string" ? <Text key={idx}>{f}</Text> : f,
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
@@ -50,15 +50,16 @@ const Fact: React.FC<{ title: string; fact?: string | null } & ViewProps> = ({
|
||||
const DetailFacts: React.FC<
|
||||
{ details?: MovieDetails | TvDetails } & ViewProps
|
||||
> = ({ details, className, ...props }) => {
|
||||
const { jellyseerrUser, jellyseerrRegion: region, jellyseerrLocale: locale } = useJellyseerr();
|
||||
const { jellyseerrRegion: region, jellyseerrLocale: locale } =
|
||||
useJellyseerr();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const releases = useMemo(
|
||||
() =>
|
||||
(details as MovieDetails)?.releases?.results.find(
|
||||
(r: TmdbRelease) => r.iso_3166_1 === region
|
||||
(r: TmdbRelease) => r.iso_3166_1 === region,
|
||||
)?.release_dates as TmdbRelease["release_dates"],
|
||||
[details]
|
||||
[details],
|
||||
);
|
||||
|
||||
// Release date types:
|
||||
@@ -72,9 +73,9 @@ const DetailFacts: React.FC<
|
||||
() =>
|
||||
uniqBy(
|
||||
releases?.filter((r: Release) => r.type > 2 && r.type < 6),
|
||||
"type"
|
||||
"type",
|
||||
),
|
||||
[releases]
|
||||
[releases],
|
||||
);
|
||||
|
||||
const firstAirDate = useMemo(() => {
|
||||
@@ -82,7 +83,7 @@ const DetailFacts: React.FC<
|
||||
if (firstAirDate) {
|
||||
return new Date(firstAirDate).toLocaleDateString(
|
||||
`${locale}-${region}`,
|
||||
dateOpts
|
||||
dateOpts,
|
||||
);
|
||||
}
|
||||
}, [details]);
|
||||
@@ -93,7 +94,7 @@ const DetailFacts: React.FC<
|
||||
if (nextAirDate && firstAirDate !== nextAirDate) {
|
||||
return new Date(nextAirDate).toLocaleDateString(
|
||||
`${locale}-${region}`,
|
||||
dateOpts
|
||||
dateOpts,
|
||||
);
|
||||
}
|
||||
}, [details]);
|
||||
@@ -102,26 +103,26 @@ const DetailFacts: React.FC<
|
||||
() =>
|
||||
(details as MovieDetails)?.revenue?.toLocaleString?.(
|
||||
`${locale}-${region}`,
|
||||
{ style: "currency", currency: "USD" }
|
||||
{ style: "currency", currency: "USD" },
|
||||
),
|
||||
[details]
|
||||
[details],
|
||||
);
|
||||
|
||||
const budget = useMemo(
|
||||
() =>
|
||||
(details as MovieDetails)?.budget?.toLocaleString?.(
|
||||
`${locale}-${region}`,
|
||||
{ style: "currency", currency: "USD" }
|
||||
{ style: "currency", currency: "USD" },
|
||||
),
|
||||
[details]
|
||||
[details],
|
||||
);
|
||||
|
||||
const streamingProviders = useMemo(
|
||||
() =>
|
||||
details?.watchProviders?.find(
|
||||
(provider) => provider.iso_3166_1 === region
|
||||
(provider) => provider.iso_3166_1 === region,
|
||||
)?.flatrate,
|
||||
[details]
|
||||
[details],
|
||||
);
|
||||
|
||||
const networks = useMemo(() => (details as TvDetails)?.networks, [details]);
|
||||
@@ -129,15 +130,15 @@ const DetailFacts: React.FC<
|
||||
const spokenLanguage = useMemo(
|
||||
() =>
|
||||
details?.spokenLanguages.find(
|
||||
(lng) => lng.iso_639_1 === details.originalLanguage
|
||||
(lng) => lng.iso_639_1 === details.originalLanguage,
|
||||
)?.name,
|
||||
[details]
|
||||
[details],
|
||||
);
|
||||
|
||||
return (
|
||||
details && (
|
||||
<View className="p-4">
|
||||
<Text className="text-lg font-bold">{t("jellyseerr.details")}</Text>
|
||||
<View className='p-4'>
|
||||
<Text className='text-lg font-bold'>{t("jellyseerr.details")}</Text>
|
||||
<View
|
||||
className={`${className} flex flex-col justify-center divide-y-2 divide-neutral-800`}
|
||||
{...props}
|
||||
@@ -148,30 +149,30 @@ const DetailFacts: React.FC<
|
||||
fact={(details as TvDetails)?.originalName}
|
||||
/>
|
||||
{details.keywords.some(
|
||||
(keyword) => keyword.id === ANIME_KEYWORD_ID
|
||||
) && <Fact title={t("jellyseerr.series_type")} fact="Anime" />}
|
||||
(keyword) => keyword.id === ANIME_KEYWORD_ID,
|
||||
) && <Fact title={t("jellyseerr.series_type")} fact='Anime' />}
|
||||
<Facts
|
||||
title={t("jellyseerr.release_dates")}
|
||||
facts={filteredReleases?.map?.((r: Release, idx) => (
|
||||
<View key={idx} className="flex flex-row space-x-2 items-center">
|
||||
<View key={idx} className='flex flex-row space-x-2 items-center'>
|
||||
{r.type === 3 ? (
|
||||
// Theatrical
|
||||
<Ionicons name="ticket" size={16} color="white" />
|
||||
<Ionicons name='ticket' size={16} color='white' />
|
||||
) : r.type === 4 ? (
|
||||
// Digital
|
||||
<Ionicons name="cloud" size={16} color="white" />
|
||||
<Ionicons name='cloud' size={16} color='white' />
|
||||
) : (
|
||||
// Physical
|
||||
<MaterialCommunityIcons
|
||||
name="record-circle-outline"
|
||||
name='record-circle-outline'
|
||||
size={16}
|
||||
color="white"
|
||||
color='white'
|
||||
/>
|
||||
)}
|
||||
<Text>
|
||||
{new Date(r.release_date).toLocaleDateString(
|
||||
`${locale}-${region}`,
|
||||
dateOpts
|
||||
dateOpts,
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -181,11 +182,14 @@ const DetailFacts: React.FC<
|
||||
<Fact title={t("jellyseerr.next_air_date")} fact={nextAirDate} />
|
||||
<Fact title={t("jellyseerr.revenue")} fact={revenue} />
|
||||
<Fact title={t("jellyseerr.budget")} fact={budget} />
|
||||
<Fact title={t("jellyseerr.original_language")} fact={spokenLanguage} />
|
||||
<Fact
|
||||
title={t("jellyseerr.original_language")}
|
||||
fact={spokenLanguage}
|
||||
/>
|
||||
<Facts
|
||||
title={t("jellyseerr.production_country")}
|
||||
facts={details?.productionCountries?.map((n, idx) => (
|
||||
<View key={idx} className="flex flex-row items-center space-x-2">
|
||||
<View key={idx} className='flex flex-row items-center space-x-2'>
|
||||
<CountryFlag isoCode={n.iso_3166_1} size={10} />
|
||||
<Text>{n.name}</Text>
|
||||
</View>
|
||||
@@ -194,10 +198,13 @@ const DetailFacts: React.FC<
|
||||
<Facts
|
||||
title={t("jellyseerr.studios")}
|
||||
facts={uniqBy(details?.productionCompanies, "name")?.map(
|
||||
(n) => n.name
|
||||
(n) => n.name,
|
||||
)}
|
||||
/>
|
||||
<Facts title={t("jellyseerr.network")}facts={networks?.map((n) => n.name)} />
|
||||
<Facts
|
||||
title={t("jellyseerr.network")}
|
||||
facts={networks?.map((n) => n.name)}
|
||||
/>
|
||||
<Facts
|
||||
title={t("jellyseerr.currently_streaming_on")}
|
||||
facts={streamingProviders?.map((s) => s.name)}
|
||||
|
||||
@@ -1,35 +1,38 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useSegments } from "expo-router";
|
||||
import type React from "react";
|
||||
import { useCallback } from "react";
|
||||
import { TouchableOpacity, type ViewProps } from "react-native";
|
||||
import GenericSlideCard from "@/components/jellyseerr/discover/GenericSlideCard";
|
||||
import Slide, { SlideProps } from "@/components/jellyseerr/discover/Slide";
|
||||
import Slide, { type SlideProps } from "@/components/jellyseerr/discover/Slide";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||
import { GenreSliderItem } from "@/utils/jellyseerr/server/interfaces/api/discoverInterfaces";
|
||||
import type { GenreSliderItem } from "@/utils/jellyseerr/server/interfaces/api/discoverInterfaces";
|
||||
import { genreColorMap } from "@/utils/jellyseerr/src/components/Discover/constants";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { router, useSegments } from "expo-router";
|
||||
import React, { useCallback } from "react";
|
||||
import { TouchableOpacity, ViewProps } from "react-native";
|
||||
|
||||
const GenreSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
|
||||
const segments = useSegments();
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
const from = segments[2];
|
||||
const router = useRouter();
|
||||
const from = (segments as string[])[2] || "(home)";
|
||||
|
||||
const navigate = useCallback(
|
||||
(genre: GenreSliderItem) =>
|
||||
router.push({
|
||||
pathname: `/(auth)/(tabs)/${from}/jellyseerr/genre/${genre.id}`,
|
||||
pathname: `/(auth)/(tabs)/${from}/jellyseerr/genre/${genre.id}` as any,
|
||||
params: { type: slide.type, name: genre.name },
|
||||
}),
|
||||
[slide]
|
||||
[slide],
|
||||
);
|
||||
|
||||
const { data, isFetching, isLoading } = useQuery({
|
||||
const { data } = useQuery({
|
||||
queryKey: ["jellyseerr", "discover", slide.type, slide.id],
|
||||
queryFn: async () => {
|
||||
return jellyseerrApi?.getGenreSliders(
|
||||
slide.type == DiscoverSliderType.MOVIE_GENRES
|
||||
slide.type === DiscoverSliderType.MOVIE_GENRES
|
||||
? Endpoints.MOVIE
|
||||
: Endpoints.TV
|
||||
: Endpoints.TV,
|
||||
);
|
||||
},
|
||||
enabled: !!jellyseerrApi,
|
||||
@@ -42,19 +45,19 @@ const GenreSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
|
||||
slide={slide}
|
||||
data={data}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
renderItem={(item, index) => (
|
||||
<TouchableOpacity className="mr-2" onPress={() => navigate(item)}>
|
||||
renderItem={(item, _index) => (
|
||||
<TouchableOpacity className='mr-2' onPress={() => navigate(item)}>
|
||||
<GenericSlideCard
|
||||
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()}
|
||||
title={item.name}
|
||||
colors={['transparent', 'transparent']}
|
||||
colors={["transparent", "transparent"]}
|
||||
contentFit={"cover"}
|
||||
url={jellyseerrApi?.imageProxy(
|
||||
item.backdrops?.[0],
|
||||
`w780_filter(duotone,${
|
||||
genreColorMap[item.id] ?? genreColorMap[0]
|
||||
})`
|
||||
})`,
|
||||
)}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import {
|
||||
PropsWithChildren,
|
||||
Children,
|
||||
isValidElement,
|
||||
cloneElement,
|
||||
ReactElement,
|
||||
isValidElement,
|
||||
type PropsWithChildren,
|
||||
type ReactElement,
|
||||
} from "react";
|
||||
import { StyleSheet, View, ViewProps, ViewStyle } from "react-native";
|
||||
import { ListItem } from "./ListItem";
|
||||
import { StyleSheet, View, type ViewProps, type ViewStyle } from "react-native";
|
||||
import { Text } from "../common/Text";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
@@ -24,12 +23,12 @@ export const ListGroup: React.FC<PropsWithChildren<Props>> = ({
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<Text className="ml-4 mb-1 uppercase text-[#8E8D91] text-xs">
|
||||
<Text className='ml-4 mb-1 uppercase text-[#8E8D91] text-xs'>
|
||||
{title}
|
||||
</Text>
|
||||
<View
|
||||
style={[]}
|
||||
className="flex flex-col rounded-xl overflow-hidden pl-0 bg-neutral-900"
|
||||
className='flex flex-col rounded-xl overflow-hidden pl-0 bg-neutral-900'
|
||||
>
|
||||
{Children.map(childrenArray, (child, index) => {
|
||||
if (isValidElement<{ style?: ViewStyle }>(child)) {
|
||||
@@ -38,14 +37,14 @@ export const ListGroup: React.FC<PropsWithChildren<Props>> = ({
|
||||
child.props.style,
|
||||
index < childrenArray.length - 1
|
||||
? styles.borderBottom
|
||||
: undefined
|
||||
: undefined,
|
||||
),
|
||||
});
|
||||
}
|
||||
return child;
|
||||
})}
|
||||
</View>
|
||||
{description && <View className="pl-4 mt-1">{description}</View>}
|
||||
{description && <View className='pl-4 mt-1'>{description}</View>}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { PropsWithChildren, ReactNode } from "react";
|
||||
import {
|
||||
TouchableOpacity,
|
||||
TouchableOpacityProps,
|
||||
View,
|
||||
ViewProps,
|
||||
} from "react-native";
|
||||
import type { PropsWithChildren, ReactNode } from "react";
|
||||
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
||||
import { Text } from "../common/Text";
|
||||
|
||||
interface Props extends TouchableOpacityProps, ViewProps {
|
||||
interface Props extends ViewProps {
|
||||
title?: string | null | undefined;
|
||||
subtitle?: string | null | undefined;
|
||||
subtitleColor?: "default" | "red";
|
||||
value?: string | null | undefined;
|
||||
children?: ReactNode;
|
||||
iconAfter?: ReactNode;
|
||||
@@ -17,10 +14,13 @@ interface Props extends TouchableOpacityProps, ViewProps {
|
||||
showArrow?: boolean;
|
||||
textColor?: "default" | "blue" | "red";
|
||||
onPress?: () => void;
|
||||
disabled?: boolean;
|
||||
disabledByAdmin?: boolean;
|
||||
}
|
||||
|
||||
export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
||||
title,
|
||||
subtitle,
|
||||
value,
|
||||
iconAfter,
|
||||
children,
|
||||
@@ -29,20 +29,23 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
||||
textColor = "default",
|
||||
onPress,
|
||||
disabled = false,
|
||||
...props
|
||||
disabledByAdmin = false,
|
||||
...viewProps
|
||||
}) => {
|
||||
const effectiveSubtitle = disabledByAdmin ? "Disabled by admin" : subtitle;
|
||||
const isDisabled = disabled || disabledByAdmin;
|
||||
if (onPress)
|
||||
return (
|
||||
<TouchableOpacity
|
||||
disabled={disabled}
|
||||
disabled={isDisabled}
|
||||
onPress={onPress}
|
||||
className={`flex flex-row items-center justify-between bg-neutral-900 h-11 pr-4 pl-4 ${
|
||||
disabled ? "opacity-50" : ""
|
||||
}`}
|
||||
{...props}
|
||||
className={`flex flex-row items-center justify-between bg-neutral-900 min-h-[42px] py-2 pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`}
|
||||
{...(viewProps as any)}
|
||||
>
|
||||
<ListItemContent
|
||||
title={title}
|
||||
subtitle={effectiveSubtitle}
|
||||
subtitleColor={disabledByAdmin ? "red" : undefined}
|
||||
value={value}
|
||||
icon={icon}
|
||||
textColor={textColor}
|
||||
@@ -55,13 +58,13 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
||||
);
|
||||
return (
|
||||
<View
|
||||
className={`flex flex-row items-center justify-between bg-neutral-900 h-11 pr-4 pl-4 ${
|
||||
disabled ? "opacity-50" : ""
|
||||
}`}
|
||||
{...props}
|
||||
className={`flex flex-row items-center justify-between bg-neutral-900 min-h-[42px] py-2 pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`}
|
||||
{...viewProps}
|
||||
>
|
||||
<ListItemContent
|
||||
title={title}
|
||||
subtitle={effectiveSubtitle}
|
||||
subtitleColor={disabledByAdmin ? "red" : undefined}
|
||||
value={value}
|
||||
icon={icon}
|
||||
textColor={textColor}
|
||||
@@ -76,45 +79,56 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
||||
|
||||
const ListItemContent = ({
|
||||
title,
|
||||
subtitle,
|
||||
subtitleColor,
|
||||
textColor,
|
||||
icon,
|
||||
value,
|
||||
showArrow,
|
||||
iconAfter,
|
||||
children,
|
||||
...props
|
||||
}: Props) => {
|
||||
return (
|
||||
<>
|
||||
<View className="flex flex-row items-center w-full">
|
||||
<View className='flex flex-row items-center w-full'>
|
||||
{icon && (
|
||||
<View className="border border-neutral-800 rounded-md h-8 w-8 flex items-center justify-center mr-2">
|
||||
<Ionicons name="person-circle-outline" size={18} color="white" />
|
||||
<View className='border border-neutral-800 rounded-md h-8 w-8 flex items-center justify-center mr-2'>
|
||||
<Ionicons name='person-circle-outline' size={18} color='white' />
|
||||
</View>
|
||||
)}
|
||||
<Text
|
||||
className={
|
||||
textColor === "blue"
|
||||
? "text-[#0584FE]"
|
||||
: textColor === "red"
|
||||
? "text-red-600"
|
||||
: "text-white"
|
||||
}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
<View className='flex-1'>
|
||||
<Text
|
||||
className={
|
||||
textColor === "blue"
|
||||
? "text-[#0584FE]"
|
||||
: textColor === "red"
|
||||
? "text-red-600"
|
||||
: "text-white"
|
||||
}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
{subtitle && (
|
||||
<Text
|
||||
className={`text-[12px] mt-0.5 ${subtitleColor === "red" ? "text-red-600" : "text-[#9899A1]"}`}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{subtitle}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
{value && (
|
||||
<View className="ml-auto items-end">
|
||||
<Text selectable className=" text-[#9899A1]" numberOfLines={1}>
|
||||
<View className='ml-auto items-end'>
|
||||
<Text selectable className=' text-[#9899A1]' numberOfLines={1}>
|
||||
{value}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{children && <View className="ml-auto">{children}</View>}
|
||||
{children && <View className='ml-auto'>{children}</View>}
|
||||
{showArrow && (
|
||||
<View className={children ? "ml-1" : "ml-auto"}>
|
||||
<Ionicons name="chevron-forward" size={18} color="#5A5960" />
|
||||
<Ionicons name='chevron-forward' size={18} color='#5A5960' />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -1,30 +1,34 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { Alert, TouchableOpacity, View } from "react-native";
|
||||
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import {
|
||||
type QueryObserverResult,
|
||||
type RefetchOptions,
|
||||
useQuery,
|
||||
} from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { t } from "i18next";
|
||||
import { orderBy } from "lodash";
|
||||
import type React from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Alert, TouchableOpacity, View } from "react-native";
|
||||
import { HorizontalScroll } from "@/components/common/HorizontalScroll";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Tags } from "@/components/GenreTags";
|
||||
import { dateOpts } from "@/components/jellyseerr/DetailFacts";
|
||||
import { textShadowStyle } from "@/components/jellyseerr/discover/GenericSlideCard";
|
||||
import JellyseerrStatusIcon from "@/components/jellyseerr/JellyseerrStatusIcon";
|
||||
import Season from "@/utils/jellyseerr/server/entity/Season";
|
||||
import { RoundButton } from "@/components/RoundButton";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import {
|
||||
MediaStatus,
|
||||
MediaType,
|
||||
} from "@/utils/jellyseerr/server/constants/media";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { RoundButton } from "@/components/RoundButton";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { TvResult } from "@/utils/jellyseerr/server/models/Search";
|
||||
import {QueryObserverResult, RefetchOptions, useQuery} from "@tanstack/react-query";
|
||||
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
|
||||
import { Image } from "expo-image";
|
||||
import MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
|
||||
import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
|
||||
import type Season from "@/utils/jellyseerr/server/entity/Season";
|
||||
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
||||
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||
import { Loader } from "../Loader";
|
||||
import { t } from "i18next";
|
||||
import {MovieDetails} from "@/utils/jellyseerr/server/models/Movie";
|
||||
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<{
|
||||
details: TvDetails;
|
||||
@@ -43,9 +47,8 @@ const JellyseerrSeasonEpisodes: React.FC<{
|
||||
horizontal
|
||||
loading={isLoading}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
estimatedItemSize={50}
|
||||
data={seasonWithEpisodes?.episodes}
|
||||
keyExtractor={(item) => item.id}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
renderItem={(item, index) => (
|
||||
<RenderItem key={index} item={item} index={index} />
|
||||
)}
|
||||
@@ -53,27 +56,27 @@ const JellyseerrSeasonEpisodes: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
const RenderItem = ({ item, index }: any) => {
|
||||
const { jellyseerrApi, jellyseerrRegion: region, jellyseerrLocale: locale } = useJellyseerr();
|
||||
const RenderItem = ({ item }: any) => {
|
||||
const {
|
||||
jellyseerrApi,
|
||||
jellyseerrRegion: region,
|
||||
jellyseerrLocale: locale,
|
||||
} = useJellyseerr();
|
||||
const [imageError, setImageError] = useState(false);
|
||||
|
||||
const upcomingAirDate = useMemo(() => {
|
||||
const airDate = item.airDate;
|
||||
if (airDate) {
|
||||
let airDateObj = new Date(airDate);
|
||||
|
||||
const airDateObj = new Date(airDate);
|
||||
if (new Date() < airDateObj) {
|
||||
return airDateObj.toLocaleDateString(
|
||||
`${locale}-${region}`,
|
||||
dateOpts
|
||||
);
|
||||
return airDateObj.toLocaleDateString(`${locale}-${region}`, dateOpts);
|
||||
}
|
||||
}
|
||||
}, [item]);
|
||||
}, [item, locale, region]);
|
||||
|
||||
return (
|
||||
<View className="flex flex-col w-44 mt-2">
|
||||
<View className="relative aspect-video rounded-lg overflow-hidden border border-neutral-800">
|
||||
<View className='flex flex-col w-44 mt-2'>
|
||||
<View className='relative aspect-video rounded-lg overflow-hidden border border-neutral-800'>
|
||||
{!imageError ? (
|
||||
<>
|
||||
<Image
|
||||
@@ -83,16 +86,19 @@ const RenderItem = ({ item, index }: any) => {
|
||||
uri: jellyseerrApi?.imageProxy(item.stillPath),
|
||||
}}
|
||||
cachePolicy={"memory-disk"}
|
||||
contentFit="cover"
|
||||
className="w-full h-full"
|
||||
onError={(e) => {
|
||||
contentFit='cover'
|
||||
className='w-full h-full'
|
||||
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}>
|
||||
<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>
|
||||
@@ -100,26 +106,25 @@ const RenderItem = ({ item, index }: any) => {
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<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
|
||||
name="image-outline"
|
||||
name='image-outline'
|
||||
size={24}
|
||||
color="white"
|
||||
color='white'
|
||||
style={{ opacity: 0.4 }}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<View className="shrink mt-1">
|
||||
<Text numberOfLines={2} className="">
|
||||
<View className='shrink mt-1'>
|
||||
<Text numberOfLines={2} className=''>
|
||||
{item.name}
|
||||
</Text>
|
||||
<Text numberOfLines={1} className="text-xs text-neutral-500">
|
||||
<Text numberOfLines={1} className='text-xs text-neutral-500'>
|
||||
{`S${item.seasonNumber}:E${item.episodeNumber}`}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text numberOfLines={3} className="text-xs text-neutral-500 shrink">
|
||||
<Text numberOfLines={3} className='text-xs text-neutral-500 shrink'>
|
||||
{item.overview}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -128,54 +133,51 @@ const RenderItem = ({ item, index }: any) => {
|
||||
|
||||
const JellyseerrSeasons: React.FC<{
|
||||
isLoading: boolean;
|
||||
result?: TvResult;
|
||||
details?: TvDetails;
|
||||
hasAdvancedRequest?: boolean,
|
||||
hasAdvancedRequest?: boolean;
|
||||
onAdvancedRequest?: (data: MediaRequestBody) => void;
|
||||
refetch: (options?: (RefetchOptions | undefined)) => Promise<QueryObserverResult<TvDetails | MovieDetails | undefined, Error>>;
|
||||
refetch: (
|
||||
options?: RefetchOptions | undefined,
|
||||
) => Promise<
|
||||
QueryObserverResult<TvDetails | MovieDetails | undefined, Error>
|
||||
>;
|
||||
}> = ({
|
||||
isLoading,
|
||||
result,
|
||||
details,
|
||||
refetch,
|
||||
hasAdvancedRequest,
|
||||
onAdvancedRequest,
|
||||
}) => {
|
||||
if (!details) return null;
|
||||
|
||||
const { jellyseerrApi, requestMedia } = useJellyseerr();
|
||||
const [seasonStates, setSeasonStates] = useState<{
|
||||
[key: number]: boolean;
|
||||
}>();
|
||||
const [seasonStates, setSeasonStates] = useState<{ [key: number]: boolean }>(
|
||||
{},
|
||||
);
|
||||
const seasons = useMemo(() => {
|
||||
const mediaInfoSeasons = details?.mediaInfo?.seasons?.filter(
|
||||
(s: Season) => s.seasonNumber !== 0
|
||||
if (!details) return [];
|
||||
const mediaInfoSeasons = details.mediaInfo?.seasons?.filter(
|
||||
(s: Season) => s.seasonNumber !== 0,
|
||||
);
|
||||
const requestedSeasons = details?.mediaInfo?.requests?.flatMap(
|
||||
(r: MediaRequest) => r.seasons
|
||||
);
|
||||
return details.seasons?.map((season) => {
|
||||
return {
|
||||
const requestedSeasons =
|
||||
details.mediaInfo?.requests?.flatMap((r: MediaRequest) => r.seasons) ??
|
||||
[];
|
||||
return (
|
||||
details.seasons?.map((season) => ({
|
||||
...season,
|
||||
status:
|
||||
// What our library status is
|
||||
mediaInfoSeasons?.find(
|
||||
(mediaSeason: Season) =>
|
||||
mediaSeason.seasonNumber === season.seasonNumber
|
||||
mediaSeason.seasonNumber === season.seasonNumber,
|
||||
)?.status ??
|
||||
// What our request status is
|
||||
requestedSeasons?.find(
|
||||
(s: Season) => s.seasonNumber === season.seasonNumber
|
||||
(s: Season) => s.seasonNumber === season.seasonNumber,
|
||||
)?.status ??
|
||||
// Otherwise set it as unknown
|
||||
MediaStatus.UNKNOWN,
|
||||
};
|
||||
});
|
||||
})) ?? []
|
||||
);
|
||||
}, [details]);
|
||||
|
||||
const allSeasonsAvailable = useMemo(
|
||||
() => seasons?.every((season) => season.status === MediaStatus.AVAILABLE),
|
||||
[seasons]
|
||||
() => seasons.every((season) => season.status === MediaStatus.AVAILABLE),
|
||||
[seasons],
|
||||
);
|
||||
|
||||
const requestAll = useCallback(() => {
|
||||
@@ -186,59 +188,74 @@ const JellyseerrSeasons: React.FC<{
|
||||
tvdbId: details.externalIds?.tvdbId,
|
||||
seasons: seasons
|
||||
.filter(
|
||||
(s) => s.status === MediaStatus.UNKNOWN && s.seasonNumber !== 0
|
||||
(s) => s.status === MediaStatus.UNKNOWN && s.seasonNumber !== 0,
|
||||
)
|
||||
.map((s) => s.seasonNumber),
|
||||
}
|
||||
|
||||
};
|
||||
if (hasAdvancedRequest) {
|
||||
return onAdvancedRequest?.(body)
|
||||
return onAdvancedRequest?.(body);
|
||||
}
|
||||
|
||||
requestMedia(result?.name!!, body, refetch);
|
||||
requestMedia(details.name, body, refetch);
|
||||
}
|
||||
}, [jellyseerrApi, seasons, details, hasAdvancedRequest, onAdvancedRequest]);
|
||||
}, [
|
||||
jellyseerrApi,
|
||||
seasons,
|
||||
details,
|
||||
hasAdvancedRequest,
|
||||
onAdvancedRequest,
|
||||
requestMedia,
|
||||
refetch,
|
||||
]);
|
||||
|
||||
const promptRequestAll = useCallback(
|
||||
() =>
|
||||
Alert.alert(t("jellyseerr.confirm"), t("jellyseerr.are_you_sure_you_want_to_request_all_seasons"), [
|
||||
{
|
||||
text: t("jellyseerr.cancel"),
|
||||
style: "cancel",
|
||||
},
|
||||
{
|
||||
text: t("jellyseerr.yes"),
|
||||
onPress: requestAll,
|
||||
},
|
||||
]),
|
||||
[requestAll]
|
||||
Alert.alert(
|
||||
t("jellyseerr.confirm"),
|
||||
t("jellyseerr.are_you_sure_you_want_to_request_all_seasons"),
|
||||
[
|
||||
{
|
||||
text: t("jellyseerr.cancel"),
|
||||
style: "cancel",
|
||||
},
|
||||
{
|
||||
text: t("jellyseerr.yes"),
|
||||
onPress: requestAll,
|
||||
},
|
||||
],
|
||||
),
|
||||
[requestAll],
|
||||
);
|
||||
|
||||
const requestSeason = useCallback(async (canRequest: Boolean, seasonNumber: number) => {
|
||||
if (canRequest) {
|
||||
const body: MediaRequestBody = {
|
||||
mediaId: details.id,
|
||||
mediaType: MediaType.TV,
|
||||
tvdbId: details.externalIds?.tvdbId,
|
||||
seasons: [seasonNumber],
|
||||
const requestSeason = useCallback(
|
||||
async (canRequest: boolean, seasonNumber: number) => {
|
||||
if (canRequest && details) {
|
||||
const body: MediaRequestBody = {
|
||||
mediaId: details.id,
|
||||
mediaType: MediaType.TV,
|
||||
tvdbId: details.externalIds?.tvdbId,
|
||||
seasons: [seasonNumber],
|
||||
};
|
||||
if (hasAdvancedRequest) {
|
||||
return onAdvancedRequest?.(body);
|
||||
}
|
||||
requestMedia(`${details.name}, Season ${seasonNumber}`, body, refetch);
|
||||
}
|
||||
},
|
||||
[requestMedia, hasAdvancedRequest, onAdvancedRequest, refetch, details],
|
||||
);
|
||||
|
||||
if (hasAdvancedRequest) {
|
||||
return onAdvancedRequest?.(body)
|
||||
}
|
||||
|
||||
requestMedia(`${result?.name!!}, Season ${seasonNumber}`, body, refetch);
|
||||
}
|
||||
}, [requestMedia, hasAdvancedRequest, onAdvancedRequest]);
|
||||
if (!details) return null;
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<View>
|
||||
<View className="flex flex-row justify-between items-end px-4">
|
||||
<Text className="text-lg font-bold mb-2">{t("item_card.seasons")}</Text>
|
||||
<View className='flex flex-row justify-between items-end px-4'>
|
||||
<Text className='text-lg font-bold mb-2'>
|
||||
{t("item_card.seasons")}
|
||||
</Text>
|
||||
{!allSeasonsAvailable && (
|
||||
<RoundButton className="mb-2 pa-2" onPress={promptRequestAll}>
|
||||
<Ionicons name="bag-add" color="white" size={26} />
|
||||
<RoundButton className='mb-2 pa-2' onPress={promptRequestAll}>
|
||||
<Ionicons name='bag-add' color='white' size={26} />
|
||||
</RoundButton>
|
||||
)}
|
||||
</View>
|
||||
@@ -249,22 +266,23 @@ const JellyseerrSeasons: React.FC<{
|
||||
return (
|
||||
<FlashList
|
||||
data={orderBy(
|
||||
details.seasons.filter((s) => s.seasonNumber !== 0),
|
||||
seasons.filter((s) => s.seasonNumber !== 0),
|
||||
"seasonNumber",
|
||||
"desc"
|
||||
"desc",
|
||||
)}
|
||||
ListHeaderComponent={() => (
|
||||
<View className="flex flex-row justify-between items-end px-4">
|
||||
<Text className="text-lg font-bold mb-2">{t("item_card.seasons")}</Text>
|
||||
<View className='flex flex-row justify-between items-end px-4'>
|
||||
<Text className='text-lg font-bold mb-2'>
|
||||
{t("item_card.seasons")}
|
||||
</Text>
|
||||
{!allSeasonsAvailable && (
|
||||
<RoundButton className="mb-2 pa-2" onPress={promptRequestAll}>
|
||||
<Ionicons name="bag-add" color="white" size={26} />
|
||||
<RoundButton className='mb-2 pa-2' onPress={promptRequestAll}>
|
||||
<Ionicons name='bag-add' color='white' size={26} />
|
||||
</RoundButton>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
ItemSeparatorComponent={() => <View className="h-2" />}
|
||||
estimatedItemSize={250}
|
||||
ItemSeparatorComponent={() => <View className='h-2' />}
|
||||
renderItem={({ item: season }) => (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
@@ -274,33 +292,33 @@ const JellyseerrSeasons: React.FC<{
|
||||
[season.seasonNumber]: !prevState?.[season.seasonNumber],
|
||||
}))
|
||||
}
|
||||
className="px-4"
|
||||
className='px-4'
|
||||
>
|
||||
<View
|
||||
className="flex flex-row justify-between items-center bg-gray-100/10 rounded-xl z-20 h-12 w-full px-4"
|
||||
className='flex flex-row justify-between items-center bg-gray-100/10 rounded-xl z-20 h-12 w-full px-4'
|
||||
key={season.id}
|
||||
>
|
||||
<Tags
|
||||
textClass=""
|
||||
textClass=''
|
||||
tags={[
|
||||
t("jellyseerr.season_number", {season_number: season.seasonNumber}),
|
||||
t("jellyseerr.number_episodes", {episode_number: season.episodeCount}),
|
||||
t("jellyseerr.season_number", {
|
||||
season_number: season.seasonNumber,
|
||||
}),
|
||||
t("jellyseerr.number_episodes", {
|
||||
episode_number: season.episodeCount,
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
{[0].map(() => {
|
||||
const canRequest =
|
||||
seasons?.find((s) => s.seasonNumber === season.seasonNumber)
|
||||
?.status === MediaStatus.UNKNOWN;
|
||||
const canRequest = season.status === MediaStatus.UNKNOWN;
|
||||
return (
|
||||
<JellyseerrStatusIcon
|
||||
key={0}
|
||||
onPress={() => requestSeason(canRequest, season.seasonNumber)}
|
||||
className={canRequest ? "bg-gray-700/40" : undefined}
|
||||
mediaStatus={
|
||||
seasons?.find(
|
||||
(s) => s.seasonNumber === season.seasonNumber
|
||||
)?.status
|
||||
onPress={() =>
|
||||
requestSeason(canRequest, season.seasonNumber)
|
||||
}
|
||||
className={canRequest ? "bg-gray-700/40" : undefined}
|
||||
mediaStatus={season.status}
|
||||
showRequestIcon={canRequest}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Switch, View } from "react-native";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
|
||||
export const ChromecastSettings: React.FC = ({ ...props }) => {
|
||||
const [settings, updateSettings] = useSettings();
|
||||
const { settings, updateSettings } = useSettings();
|
||||
return (
|
||||
<View {...props}>
|
||||
<ListGroup title={"Chromecast"}>
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { useRouter } from "expo-router";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
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 { settings } = useSettings();
|
||||
const { sessions = [] } = useSessions({} as useSessionsProps);
|
||||
const router = useRouter();
|
||||
|
||||
const { t } = useTranslation();
|
||||
@@ -17,9 +16,9 @@ export const Dashboard = () => {
|
||||
if (!settings) return null;
|
||||
return (
|
||||
<View>
|
||||
<ListGroup title={t("home.settings.dashboard.title")} className="mt-4">
|
||||
<ListGroup title={t("home.settings.dashboard.title")} className='mt-4'>
|
||||
<ListItem
|
||||
className={sessions.length != 0 ? "bg-purple-900" : ""}
|
||||
className={sessions.length !== 0 ? "bg-purple-900" : ""}
|
||||
onPress={() => router.push("/settings/dashboard/sessions")}
|
||||
title={t("home.settings.dashboard.sessions_title")}
|
||||
showArrow
|
||||
|
||||
@@ -1,143 +1,3 @@
|
||||
import { Stepper } from "@/components/inputs/Stepper";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { DownloadMethod, Settings, useSettings } from "@/utils/atoms/settings";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useRouter } from "expo-router";
|
||||
import React, { useMemo } from "react";
|
||||
import { Platform, Switch, TouchableOpacity } from "react-native";
|
||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||
import { Text } from "../common/Text";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
|
||||
export default function DownloadSettings({ ...props }) {
|
||||
const [settings, updateSettings, pluginSettings] = useSettings();
|
||||
const { setProcesses } = useDownload();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const allDisabled = useMemo(
|
||||
() =>
|
||||
pluginSettings?.downloadMethod?.locked === true &&
|
||||
pluginSettings?.remuxConcurrentLimit?.locked === true &&
|
||||
pluginSettings?.autoDownload.locked === true,
|
||||
[pluginSettings]
|
||||
);
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
return (
|
||||
<DisabledSetting disabled={allDisabled} {...props} className="mb-4">
|
||||
<ListGroup title={t("home.settings.downloads.downloads_title")}>
|
||||
<ListItem
|
||||
title={t("home.settings.downloads.download_method")}
|
||||
disabled={pluginSettings?.downloadMethod?.locked}
|
||||
>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
|
||||
<Text className="mr-1 text-[#8E8D91]">
|
||||
{settings.downloadMethod === DownloadMethod.Remux
|
||||
? t("home.settings.downloads.default")
|
||||
: t("home.settings.downloads.optimized")}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name="chevron-expand-sharp"
|
||||
size={18}
|
||||
color="#5A5960"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>
|
||||
{t("home.settings.downloads.download_method")}
|
||||
</DropdownMenu.Label>
|
||||
<DropdownMenu.Item
|
||||
key="1"
|
||||
onSelect={() => {
|
||||
updateSettings({ downloadMethod: DownloadMethod.Remux });
|
||||
setProcesses([]);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{t("home.settings.downloads.default")}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
key="2"
|
||||
onSelect={() => {
|
||||
updateSettings({ downloadMethod: DownloadMethod.Optimized });
|
||||
setProcesses([]);
|
||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{t("home.settings.downloads.optimized")}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={t("home.settings.downloads.remux_max_download")}
|
||||
disabled={
|
||||
pluginSettings?.remuxConcurrentLimit?.locked ||
|
||||
settings.downloadMethod !== DownloadMethod.Remux
|
||||
}
|
||||
>
|
||||
<Stepper
|
||||
value={settings.remuxConcurrentLimit}
|
||||
step={1}
|
||||
min={1}
|
||||
max={4}
|
||||
onUpdate={(value) =>
|
||||
updateSettings({
|
||||
remuxConcurrentLimit: value as Settings["remuxConcurrentLimit"],
|
||||
})
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={t("home.settings.downloads.auto_download")}
|
||||
disabled={
|
||||
pluginSettings?.autoDownload?.locked ||
|
||||
settings.downloadMethod !== DownloadMethod.Optimized
|
||||
}
|
||||
>
|
||||
<Switch
|
||||
disabled={
|
||||
pluginSettings?.autoDownload?.locked ||
|
||||
settings.downloadMethod !== DownloadMethod.Optimized
|
||||
}
|
||||
value={settings.autoDownload}
|
||||
onValueChange={(value) => updateSettings({ autoDownload: value })}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
disabled={
|
||||
pluginSettings?.optimizedVersionsServerUrl?.locked ||
|
||||
settings.downloadMethod !== DownloadMethod.Optimized
|
||||
}
|
||||
onPress={() => router.push("/settings/optimized-server/page")}
|
||||
showArrow
|
||||
title={t("home.settings.downloads.optimized_versions_server")}
|
||||
></ListItem>
|
||||
</ListGroup>
|
||||
</DisabledSetting>
|
||||
);
|
||||
export default function DownloadSettings() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import React from "react";
|
||||
|
||||
export default function DownloadSettings({ ...props }) {
|
||||
return <></>;
|
||||
export default function DownloadSettings() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,507 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,453 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAtom } from "jotai";
|
||||
import { useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { Button } from "../Button";
|
||||
import { Input } from "../common/Input";
|
||||
import { Text } from "../common/Text";
|
||||
@@ -14,17 +14,13 @@ import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
|
||||
export const JellyseerrSettings = () => {
|
||||
const {
|
||||
jellyseerrApi,
|
||||
jellyseerrUser,
|
||||
setJellyseerrUser,
|
||||
clearAllJellyseerData,
|
||||
} = useJellyseerr();
|
||||
const { jellyseerrUser, setJellyseerrUser, clearAllJellyseerData } =
|
||||
useJellyseerr();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [user] = useAtom(userAtom);
|
||||
const [settings, updateSettings, pluginSettings] = useSettings();
|
||||
const { settings, updateSettings } = useSettings();
|
||||
|
||||
const [jellyseerrPassword, setJellyseerrPassword] = useState<
|
||||
string | undefined
|
||||
@@ -41,7 +37,7 @@ export const JellyseerrSettings = () => {
|
||||
if (!user?.Name)
|
||||
throw new Error("Missing required information for login");
|
||||
const jellyseerrTempApi = new JellyseerrApi(
|
||||
jellyseerrServerUrl || settings.jellyseerrServerUrl || ""
|
||||
jellyseerrServerUrl || settings.jellyseerrServerUrl || "",
|
||||
);
|
||||
const testResult = await jellyseerrTempApi.test();
|
||||
if (!testResult.isValid) throw new Error("Invalid server url");
|
||||
@@ -68,14 +64,14 @@ export const JellyseerrSettings = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<View className="">
|
||||
<View className=''>
|
||||
<View>
|
||||
{jellyseerrUser ? (
|
||||
<>
|
||||
<ListGroup title={"Jellyseerr"}>
|
||||
<ListItem
|
||||
title={t(
|
||||
"home.settings.plugins.jellyseerr.total_media_requests"
|
||||
"home.settings.plugins.jellyseerr.total_media_requests",
|
||||
)}
|
||||
value={jellyseerrUser?.requestCount?.toString()}
|
||||
/>
|
||||
@@ -109,69 +105,69 @@ export const JellyseerrSettings = () => {
|
||||
/>
|
||||
</ListGroup>
|
||||
|
||||
<View className="p-4">
|
||||
<Button color="red" onPress={clearData}>
|
||||
<View className='p-4'>
|
||||
<Button color='red' onPress={clearData}>
|
||||
{t(
|
||||
"home.settings.plugins.jellyseerr.reset_jellyseerr_config_button"
|
||||
"home.settings.plugins.jellyseerr.reset_jellyseerr_config_button",
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
<View className="flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900">
|
||||
<Text className="text-xs text-red-600 mb-2">
|
||||
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'>
|
||||
<Text className='text-xs text-red-600 mb-2'>
|
||||
{t("home.settings.plugins.jellyseerr.jellyseerr_warning")}
|
||||
</Text>
|
||||
<Text className="font-bold mb-1">
|
||||
<Text className='font-bold mb-1'>
|
||||
{t("home.settings.plugins.jellyseerr.server_url")}
|
||||
</Text>
|
||||
<View className="flex flex-col shrink mb-2">
|
||||
<Text className="text-xs text-gray-600">
|
||||
<View className='flex flex-col shrink mb-2'>
|
||||
<Text className='text-xs text-gray-600'>
|
||||
{t("home.settings.plugins.jellyseerr.server_url_hint")}
|
||||
</Text>
|
||||
</View>
|
||||
<Input
|
||||
className="border border-neutral-800 mb-2"
|
||||
className='border border-neutral-800 mb-2'
|
||||
placeholder={t(
|
||||
"home.settings.plugins.jellyseerr.server_url_placeholder"
|
||||
"home.settings.plugins.jellyseerr.server_url_placeholder",
|
||||
)}
|
||||
value={jellyseerrServerUrl ?? settings?.jellyseerrServerUrl}
|
||||
defaultValue={
|
||||
settings?.jellyseerrServerUrl ?? jellyseerrServerUrl
|
||||
}
|
||||
keyboardType="url"
|
||||
returnKeyType="done"
|
||||
autoCapitalize="none"
|
||||
textContentType="URL"
|
||||
keyboardType='url'
|
||||
returnKeyType='done'
|
||||
autoCapitalize='none'
|
||||
textContentType='URL'
|
||||
onChangeText={setjellyseerrServerUrl}
|
||||
editable={!loginToJellyseerrMutation.isPending}
|
||||
/>
|
||||
<View>
|
||||
<Text className="font-bold mb-2">
|
||||
<Text className='font-bold mb-2'>
|
||||
{t("home.settings.plugins.jellyseerr.password")}
|
||||
</Text>
|
||||
<Input
|
||||
className="border border-neutral-800"
|
||||
className='border border-neutral-800'
|
||||
autoFocus={true}
|
||||
focusable={true}
|
||||
placeholder={t(
|
||||
"home.settings.plugins.jellyseerr.password_placeholder",
|
||||
{ username: user?.Name }
|
||||
{ username: user?.Name },
|
||||
)}
|
||||
value={jellyseerrPassword}
|
||||
keyboardType="default"
|
||||
keyboardType='default'
|
||||
secureTextEntry={true}
|
||||
returnKeyType="done"
|
||||
autoCapitalize="none"
|
||||
textContentType="password"
|
||||
returnKeyType='done'
|
||||
autoCapitalize='none'
|
||||
textContentType='password'
|
||||
onChangeText={setJellyseerrPassword}
|
||||
editable={!loginToJellyseerrMutation.isPending}
|
||||
/>
|
||||
<Button
|
||||
loading={loginToJellyseerrMutation.isPending}
|
||||
disabled={loginToJellyseerrMutation.isPending}
|
||||
color="purple"
|
||||
className="h-12 mt-2"
|
||||
color='purple'
|
||||
className='h-12 mt-2'
|
||||
onPress={() => loginToJellyseerrMutation.mutate()}
|
||||
>
|
||||
{t("home.settings.plugins.jellyseerr.login_button")}
|
||||
|
||||
@@ -1,75 +1,33 @@
|
||||
import { Platform } from "react-native";
|
||||
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
|
||||
import { BitrateSelector, BITRATES } from "@/components/BitrateSelector";
|
||||
import {
|
||||
BACKGROUND_FETCH_TASK,
|
||||
registerBackgroundFetchAsync,
|
||||
unregisterBackgroundFetchAsync,
|
||||
} from "@/utils/background-tasks";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
const BackgroundFetch = !Platform.isTV
|
||||
? require("expo-background-fetch")
|
||||
: null;
|
||||
import { TFunction } from "i18next";
|
||||
import type React from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Linking, Switch, View } from "react-native";
|
||||
import { BITRATES } from "@/components/BitrateSelector";
|
||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
const TaskManager = !Platform.isTV ? require("expo-task-manager") : null;
|
||||
import { useRouter } from "expo-router";
|
||||
import React, { useEffect, useMemo } from "react";
|
||||
import { Linking, Switch, TouchableOpacity } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
|
||||
import { Text } from "../common/Text";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import Dropdown from "@/components/common/Dropdown";
|
||||
|
||||
export const OtherSettings: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const [settings, updateSettings, pluginSettings] = useSettings();
|
||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
/********************
|
||||
* Background task
|
||||
*******************/
|
||||
const checkStatusAsync = async () => {
|
||||
if (Platform.isTV) return;
|
||||
|
||||
await BackgroundFetch.getStatusAsync();
|
||||
return await TaskManager.isTaskRegisteredAsync(BACKGROUND_FETCH_TASK);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const registered = await checkStatusAsync();
|
||||
|
||||
if (settings?.autoDownload === true && !registered) {
|
||||
registerBackgroundFetchAsync();
|
||||
toast.success("Background downloads enabled");
|
||||
} else if (settings?.autoDownload === false && registered) {
|
||||
unregisterBackgroundFetchAsync();
|
||||
toast.info("Background downloads disabled");
|
||||
} else if (settings?.autoDownload === true && registered) {
|
||||
// Don't to anything
|
||||
} else if (settings?.autoDownload === false && !registered) {
|
||||
// Don't to anything
|
||||
} else {
|
||||
updateSettings({ autoDownload: false });
|
||||
}
|
||||
})();
|
||||
}, [settings?.autoDownload]);
|
||||
/**********************
|
||||
*********************/
|
||||
|
||||
const disabled = useMemo(
|
||||
() =>
|
||||
pluginSettings?.autoRotate?.locked === true &&
|
||||
pluginSettings?.defaultVideoOrientation?.locked === true &&
|
||||
pluginSettings?.safeAreaInControlsEnabled?.locked === true &&
|
||||
pluginSettings?.showCustomMenuLinks?.locked === true &&
|
||||
pluginSettings?.hiddenLibraries?.locked === true &&
|
||||
pluginSettings?.disableHapticFeedback?.locked === true,
|
||||
[pluginSettings]
|
||||
[pluginSettings],
|
||||
);
|
||||
|
||||
const orientations = [
|
||||
@@ -79,53 +37,94 @@ export const OtherSettings: React.FC = () => {
|
||||
ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT,
|
||||
];
|
||||
|
||||
const orientationTranslations = useMemo(
|
||||
() => ({
|
||||
[ScreenOrientation.OrientationLock.DEFAULT]:
|
||||
"home.settings.other.orientations.DEFAULT",
|
||||
[ScreenOrientation.OrientationLock.PORTRAIT_UP]:
|
||||
"home.settings.other.orientations.PORTRAIT_UP",
|
||||
[ScreenOrientation.OrientationLock.LANDSCAPE_LEFT]:
|
||||
"home.settings.other.orientations.LANDSCAPE_LEFT",
|
||||
[ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT]:
|
||||
"home.settings.other.orientations.LANDSCAPE_RIGHT",
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const orientationOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
options: orientations.map((orientation) => ({
|
||||
type: "radio" as const,
|
||||
label: t(ScreenOrientationEnum[orientation]),
|
||||
value: String(orientation),
|
||||
selected: orientation === settings?.defaultVideoOrientation,
|
||||
onPress: () =>
|
||||
updateSettings({ defaultVideoOrientation: orientation }),
|
||||
})),
|
||||
},
|
||||
],
|
||||
[orientations, settings?.defaultVideoOrientation, t, updateSettings],
|
||||
);
|
||||
|
||||
const bitrateOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
options: BITRATES.map((bitrate) => ({
|
||||
type: "radio" as const,
|
||||
label: bitrate.key,
|
||||
value: bitrate.key,
|
||||
selected: bitrate.key === settings?.defaultBitrate?.key,
|
||||
onPress: () => updateSettings({ defaultBitrate: bitrate }),
|
||||
})),
|
||||
},
|
||||
],
|
||||
[settings?.defaultBitrate?.key, t, updateSettings],
|
||||
);
|
||||
|
||||
const autoPlayEpisodeOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
options: AUTOPLAY_EPISODES_COUNT(t).map((item) => ({
|
||||
type: "radio" as const,
|
||||
label: item.key,
|
||||
value: item.key,
|
||||
selected: item.key === settings?.maxAutoPlayEpisodeCount?.key,
|
||||
onPress: () => updateSettings({ maxAutoPlayEpisodeCount: item }),
|
||||
})),
|
||||
},
|
||||
],
|
||||
[settings?.maxAutoPlayEpisodeCount?.key, t, updateSettings],
|
||||
);
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
return (
|
||||
<DisabledSetting disabled={disabled}>
|
||||
<ListGroup title={t("home.settings.other.other_title")} className="">
|
||||
<ListItem
|
||||
title={t("home.settings.other.auto_rotate")}
|
||||
disabled={pluginSettings?.autoRotate?.locked}
|
||||
>
|
||||
<Switch
|
||||
value={settings.autoRotate}
|
||||
disabled={pluginSettings?.autoRotate?.locked}
|
||||
onValueChange={(value) => updateSettings({ autoRotate: value })}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListGroup title={t("home.settings.other.other_title")} className=''>
|
||||
<ListItem
|
||||
title={t("home.settings.other.video_orientation")}
|
||||
disabled={
|
||||
pluginSettings?.defaultVideoOrientation?.locked ||
|
||||
settings.autoRotate
|
||||
}
|
||||
disabled={pluginSettings?.defaultVideoOrientation?.locked}
|
||||
>
|
||||
<Dropdown
|
||||
data={orientations}
|
||||
disabled={
|
||||
pluginSettings?.defaultVideoOrientation?.locked ||
|
||||
settings.autoRotate
|
||||
}
|
||||
keyExtractor={String}
|
||||
titleExtractor={(item) => ScreenOrientationEnum[item]}
|
||||
title={
|
||||
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
|
||||
<Text className="mr-1 text-[#8E8D91]">
|
||||
{t(ScreenOrientationEnum[settings.defaultVideoOrientation])}
|
||||
<PlatformDropdown
|
||||
groups={orientationOptions}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{t(
|
||||
orientationTranslations[
|
||||
settings.defaultVideoOrientation as keyof typeof orientationTranslations
|
||||
],
|
||||
) || "Unknown Orientation"}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name="chevron-expand-sharp"
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color="#5A5960"
|
||||
color='#5A5960'
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
label={t("home.settings.other.orientation")}
|
||||
onSelected={(defaultVideoOrientation) =>
|
||||
updateSettings({ defaultVideoOrientation })
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.other.orientation")}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
@@ -147,7 +146,7 @@ export const OtherSettings: React.FC = () => {
|
||||
disabled={pluginSettings?.showCustomMenuLinks?.locked}
|
||||
onPress={() =>
|
||||
Linking.openURL(
|
||||
"https://jellyfin.org/docs/general/clients/web-config/#custom-menu-links"
|
||||
"https://jellyfin.org/docs/general/clients/web-config/#custom-menu-links",
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -159,6 +158,14 @@ export const OtherSettings: React.FC = () => {
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem title={t("home.settings.other.show_large_home_carousel")}>
|
||||
<Switch
|
||||
value={settings.showLargeHomeCarousel}
|
||||
onValueChange={(value) =>
|
||||
updateSettings({ showLargeHomeCarousel: value })
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/hide-libraries/page")}
|
||||
title={t("home.settings.other.hide_libraries")}
|
||||
@@ -168,26 +175,21 @@ export const OtherSettings: React.FC = () => {
|
||||
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]">
|
||||
<PlatformDropdown
|
||||
groups={bitrateOptions}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{settings.defaultBitrate?.key}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name="chevron-expand-sharp"
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color="#5A5960"
|
||||
color='#5A5960'
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
}
|
||||
label={t("home.settings.other.default_quality")}
|
||||
onSelected={(defaultBitrate) => updateSettings({ defaultBitrate })}
|
||||
title={t("home.settings.other.default_quality")}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
@@ -202,7 +204,41 @@ export const OtherSettings: React.FC = () => {
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem title={t("home.settings.other.max_auto_play_episode_count")}>
|
||||
<PlatformDropdown
|
||||
groups={autoPlayEpisodeOptions}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{t(settings?.maxAutoPlayEpisodeCount.key)}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.other.max_auto_play_episode_count")}
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
</DisabledSetting>
|
||||
);
|
||||
};
|
||||
|
||||
const AUTOPLAY_EPISODES_COUNT = (
|
||||
t: TFunction<"translation", undefined>,
|
||||
): {
|
||||
key: string;
|
||||
value: number;
|
||||
}[] => [
|
||||
{ key: t("home.settings.other.disabled"), value: -1 },
|
||||
{ key: "1", value: 1 },
|
||||
{ key: "2", value: 2 },
|
||||
{ key: "3", value: 3 },
|
||||
{ key: "4", value: 4 },
|
||||
{ key: "5", value: 5 },
|
||||
{ key: "6", value: 6 },
|
||||
{ key: "7", value: 7 },
|
||||
];
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,226 +1,361 @@
|
||||
import { TrackInfo } from "@/modules/vlc-player";
|
||||
/**
|
||||
* VideoContext.tsx
|
||||
*
|
||||
* Manages subtitle and audio track state for the video player UI.
|
||||
*
|
||||
* ============================================================================
|
||||
* ARCHITECTURE
|
||||
* ============================================================================
|
||||
*
|
||||
* - Jellyfin is source of truth for subtitle list (embedded + external)
|
||||
* - MPV only knows about:
|
||||
* - Embedded subs it finds in the video stream
|
||||
* - External subs we explicitly add via addSubtitleFile()
|
||||
* - UI shows Jellyfin's complete list
|
||||
* - On selection: either select embedded track or load external URL
|
||||
*
|
||||
* ============================================================================
|
||||
* INDEX TYPES
|
||||
* ============================================================================
|
||||
*
|
||||
* 1. SERVER INDEX (sub.Index / track.index)
|
||||
* - Jellyfin's server-side stream index
|
||||
* - Used to report playback state to Jellyfin server
|
||||
* - Value of -1 means disabled/none
|
||||
*
|
||||
* 2. MPV INDEX (track.mpvIndex)
|
||||
* - MPV's internal track ID
|
||||
* - MPV orders tracks as: [all embedded, then all external]
|
||||
* - IDs: 1..embeddedCount for embedded, embeddedCount+1.. for external
|
||||
* - Value of -1 means track needs replacePlayer() (e.g., burned-in sub)
|
||||
*
|
||||
* ============================================================================
|
||||
* SUBTITLE HANDLING
|
||||
* ============================================================================
|
||||
*
|
||||
* Embedded (DeliveryMethod.Embed):
|
||||
* - Already in MPV's track list
|
||||
* - Select via setSubtitleTrack(mpvId)
|
||||
*
|
||||
* External (DeliveryMethod.External):
|
||||
* - Loaded into MPV on video start
|
||||
* - Select via setSubtitleTrack(embeddedCount + externalPosition + 1)
|
||||
*
|
||||
* Image-based during transcoding:
|
||||
* - Burned into video by Jellyfin, not in MPV
|
||||
* - Requires replacePlayer() to change
|
||||
*/
|
||||
|
||||
import { SubtitleDeliveryMethod } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import type React from "react";
|
||||
import {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import React, {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useContext,
|
||||
useState,
|
||||
ReactNode,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useControlContext } from "./ControlContext";
|
||||
import { Track } from "../types";
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import type { MpvAudioTrack } from "@/modules";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
import { isImageBasedSubtitle } from "@/utils/jellyfin/subtitleUtils";
|
||||
import type { Track } from "../types";
|
||||
import { usePlayerContext, usePlayerControls } from "./PlayerContext";
|
||||
|
||||
interface VideoContextProps {
|
||||
audioTracks: Track[] | null;
|
||||
subtitleTracks: Track[] | null;
|
||||
setAudioTrack: ((index: number) => void) | undefined;
|
||||
setSubtitleTrack: ((index: number) => void) | undefined;
|
||||
setSubtitleURL: ((url: string, customName: string) => void) | undefined;
|
||||
audioTracks: Track[] | null;
|
||||
}
|
||||
|
||||
const VideoContext = createContext<VideoContextProps | undefined>(undefined);
|
||||
|
||||
interface VideoProviderProps {
|
||||
children: ReactNode;
|
||||
getAudioTracks:
|
||||
| (() => Promise<TrackInfo[] | null>)
|
||||
| (() => TrackInfo[])
|
||||
| undefined;
|
||||
getSubtitleTracks:
|
||||
| (() => Promise<TrackInfo[] | null>)
|
||||
| (() => TrackInfo[])
|
||||
| undefined;
|
||||
setAudioTrack: ((index: number) => void) | undefined;
|
||||
setSubtitleTrack: ((index: number) => void) | undefined;
|
||||
setSubtitleURL: ((url: string, customName: string) => void) | undefined;
|
||||
}
|
||||
|
||||
export const VideoProvider: React.FC<VideoProviderProps> = ({
|
||||
export const VideoProvider: React.FC<{ children: ReactNode }> = ({
|
||||
children,
|
||||
getSubtitleTracks,
|
||||
getAudioTracks,
|
||||
setSubtitleTrack,
|
||||
setSubtitleURL,
|
||||
setAudioTrack,
|
||||
}) => {
|
||||
const [audioTracks, setAudioTracks] = useState<Track[] | null>(null);
|
||||
const [subtitleTracks, setSubtitleTracks] = useState<Track[] | null>(null);
|
||||
const [audioTracks, setAudioTracks] = useState<Track[] | null>(null);
|
||||
|
||||
const ControlContext = useControlContext();
|
||||
const isVideoLoaded = ControlContext?.isVideoLoaded;
|
||||
const mediaSource = ControlContext?.mediaSource;
|
||||
const { tracksReady, mediaSource, downloadedItem } = usePlayerContext();
|
||||
const playerControls = usePlayerControls();
|
||||
const offline = useOfflineMode();
|
||||
const router = useRouter();
|
||||
|
||||
const allSubs =
|
||||
mediaSource?.MediaStreams?.filter((s) => s.Type === "Subtitle") || [];
|
||||
|
||||
const { itemId, audioIndex, bitrateValue, subtitleIndex } =
|
||||
const { itemId, audioIndex, bitrateValue, subtitleIndex, playbackPosition } =
|
||||
useLocalSearchParams<{
|
||||
itemId: string;
|
||||
audioIndex: string;
|
||||
subtitleIndex: string;
|
||||
mediaSourceId: string;
|
||||
bitrateValue: string;
|
||||
playbackPosition: string;
|
||||
}>();
|
||||
|
||||
const onTextBasedSubtitle = useMemo(
|
||||
() =>
|
||||
allSubs.find(
|
||||
(s) => s.Index?.toString() === subtitleIndex && s.IsTextSubtitleStream
|
||||
) || subtitleIndex === "-1",
|
||||
[allSubs, subtitleIndex]
|
||||
);
|
||||
const allSubs =
|
||||
mediaSource?.MediaStreams?.filter((s) => s.Type === "Subtitle") || [];
|
||||
const allAudio =
|
||||
mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") || [];
|
||||
|
||||
const setPlayerParams = ({
|
||||
chosenAudioIndex = audioIndex,
|
||||
chosenSubtitleIndex = subtitleIndex,
|
||||
}: {
|
||||
chosenAudioIndex?: string;
|
||||
chosenSubtitleIndex?: string;
|
||||
const isTranscoding = Boolean(mediaSource?.TranscodingUrl);
|
||||
|
||||
/**
|
||||
* Check if the currently selected subtitle is image-based.
|
||||
* Used to determine if we need to refresh the player when changing subs.
|
||||
*/
|
||||
const isCurrentSubImageBased = useMemo(() => {
|
||||
if (subtitleIndex === "-1") return false;
|
||||
const currentSub = allSubs.find(
|
||||
(s) => s.Index?.toString() === subtitleIndex,
|
||||
);
|
||||
return currentSub ? isImageBasedSubtitle(currentSub) : false;
|
||||
}, [allSubs, subtitleIndex]);
|
||||
|
||||
/**
|
||||
* Refresh the player with new parameters.
|
||||
* This triggers Jellyfin to re-process the stream (e.g., burn in image subs).
|
||||
*/
|
||||
const replacePlayer = (params: {
|
||||
audioIndex?: string;
|
||||
subtitleIndex?: string;
|
||||
}) => {
|
||||
console.log("chosenSubtitleIndex", chosenSubtitleIndex);
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: itemId ?? "",
|
||||
audioIndex: chosenAudioIndex,
|
||||
subtitleIndex: chosenSubtitleIndex,
|
||||
audioIndex: params.audioIndex ?? audioIndex,
|
||||
subtitleIndex: params.subtitleIndex ?? subtitleIndex,
|
||||
mediaSourceId: mediaSource?.Id ?? "",
|
||||
bitrateValue: bitrateValue,
|
||||
playbackPosition: playbackPosition,
|
||||
}).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(),
|
||||
});
|
||||
router.replace(`player/direct-player?${queryParams}` as any);
|
||||
};
|
||||
|
||||
// Fetch tracks when ready
|
||||
useEffect(() => {
|
||||
if (!tracksReady) return;
|
||||
|
||||
const fetchTracks = async () => {
|
||||
if (getSubtitleTracks) {
|
||||
const subtitleData = await getSubtitleTracks();
|
||||
// Check if this is offline transcoded content
|
||||
// For transcoded offline content, only ONE audio track exists in the file
|
||||
const isOfflineTranscoded =
|
||||
offline && downloadedItem?.userData?.isTranscoded === true;
|
||||
|
||||
let textSubIndex = 0;
|
||||
const subtitles: Track[] = allSubs?.map((sub) => {
|
||||
// Always increment for non-transcoding subtitles
|
||||
// Only increment for text-based subtitles when transcoding
|
||||
const shouldIncrement =
|
||||
!mediaSource?.TranscodingUrl || sub.IsTextSubtitleStream;
|
||||
if (isOfflineTranscoded) {
|
||||
// Build single audio track entry - only the downloaded track exists
|
||||
const downloadedAudioIndex = downloadedItem.userData.audioStreamIndex;
|
||||
const downloadedTrack = allAudio.find(
|
||||
(a) => a.Index === downloadedAudioIndex,
|
||||
);
|
||||
|
||||
const displayTitle = sub.DisplayTitle || "Undefined Subtitle";
|
||||
const vlcIndex = subtitleData?.at(textSubIndex)?.index ?? -1;
|
||||
if (downloadedTrack) {
|
||||
const audio: Track[] = [
|
||||
{
|
||||
name: downloadedTrack.DisplayTitle || "Audio",
|
||||
index: downloadedTrack.Index ?? 0,
|
||||
mpvIndex: 1, // Only track in file (MPV uses 1-based indexing)
|
||||
setTrack: () => {
|
||||
// Track is already selected (only one available)
|
||||
router.setParams({ audioIndex: String(downloadedTrack.Index) });
|
||||
},
|
||||
},
|
||||
];
|
||||
setAudioTracks(audio);
|
||||
} else {
|
||||
// Fallback: show no audio tracks if the stored track wasn't found
|
||||
setAudioTracks([]);
|
||||
}
|
||||
|
||||
const finalIndex = shouldIncrement ? vlcIndex : sub.Index ?? -1;
|
||||
// For subtitles in transcoded offline content:
|
||||
// - Text-based subs may still be embedded
|
||||
// - Image-based subs were burned in during transcoding
|
||||
const downloadedSubtitleIndex =
|
||||
downloadedItem.userData.subtitleStreamIndex;
|
||||
const subs: Track[] = [];
|
||||
|
||||
if (shouldIncrement) textSubIndex++;
|
||||
return {
|
||||
name: displayTitle,
|
||||
index: sub.Index ?? -1,
|
||||
originalIndex: finalIndex,
|
||||
setTrack: () =>
|
||||
shouldIncrement
|
||||
? setTrackParams("subtitle", finalIndex, sub.Index ?? -1)
|
||||
: setPlayerParams({
|
||||
chosenSubtitleIndex: sub.Index?.toString(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// Add a "Disable Subtitles" option
|
||||
subtitles.unshift({
|
||||
// Add "Disable" option
|
||||
subs.push({
|
||||
name: "Disable",
|
||||
index: -1,
|
||||
setTrack: () =>
|
||||
!mediaSource?.TranscodingUrl || onTextBasedSubtitle
|
||||
? setTrackParams("subtitle", -1, -1)
|
||||
: setPlayerParams({ chosenSubtitleIndex: "-1" }),
|
||||
mpvIndex: -1,
|
||||
setTrack: () => {
|
||||
playerControls.setSubtitleTrack(-1);
|
||||
router.setParams({ subtitleIndex: "-1" });
|
||||
},
|
||||
});
|
||||
|
||||
setSubtitleTracks(subtitles);
|
||||
}
|
||||
if (
|
||||
getAudioTracks &&
|
||||
(audioTracks === null || audioTracks.length === 0)
|
||||
) {
|
||||
const audioData = await getAudioTracks();
|
||||
if (!audioData) return;
|
||||
|
||||
console.log("audioData", audioData);
|
||||
|
||||
const allAudio =
|
||||
mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") || [];
|
||||
|
||||
const audioTracks: Track[] = allAudio?.map((audio, idx) => {
|
||||
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),
|
||||
};
|
||||
// For text-based subs, they should still be available in the file
|
||||
let subIdx = 1;
|
||||
for (const sub of allSubs) {
|
||||
if (sub.IsTextSubtitleStream) {
|
||||
subs.push({
|
||||
name: sub.DisplayTitle || "Unknown",
|
||||
index: sub.Index ?? -1,
|
||||
mpvIndex: subIdx,
|
||||
setTrack: () => {
|
||||
playerControls.setSubtitleTrack(subIdx);
|
||||
router.setParams({ subtitleIndex: String(sub.Index) });
|
||||
},
|
||||
});
|
||||
subIdx++;
|
||||
} else if (sub.Index === downloadedSubtitleIndex) {
|
||||
// This image-based sub was burned in - show it but indicate it's active
|
||||
subs.push({
|
||||
name: `${sub.DisplayTitle || "Unknown"} (burned in)`,
|
||||
index: sub.Index ?? -1,
|
||||
mpvIndex: -1, // Can't be changed
|
||||
setTrack: () => {
|
||||
// Already burned in, just update params
|
||||
router.setParams({ subtitleIndex: String(sub.Index) });
|
||||
},
|
||||
});
|
||||
}
|
||||
return {
|
||||
name: audio.DisplayTitle ?? "Undefined Audio",
|
||||
index: audio.Index ?? -1,
|
||||
setTrack: () =>
|
||||
setPlayerParams({ chosenAudioIndex: audio.Index?.toString() }),
|
||||
};
|
||||
});
|
||||
setAudioTracks(audioTracks);
|
||||
}
|
||||
|
||||
setSubtitleTracks(subs.sort((a, b) => a.index - b.index));
|
||||
return;
|
||||
}
|
||||
|
||||
// MPV track handling
|
||||
const audioData = await playerControls.getAudioTracks().catch(() => null);
|
||||
const playerAudio = (audioData as MpvAudioTrack[]) ?? [];
|
||||
|
||||
// Separate embedded vs external subtitles from Jellyfin's list
|
||||
// MPV orders tracks as: [all embedded, then all external]
|
||||
const embeddedSubs = allSubs.filter(
|
||||
(s) => s.DeliveryMethod === SubtitleDeliveryMethod.Embed,
|
||||
);
|
||||
const externalSubs = allSubs.filter(
|
||||
(s) => s.DeliveryMethod === SubtitleDeliveryMethod.External,
|
||||
);
|
||||
|
||||
// Count embedded subs that will be in MPV
|
||||
// (excludes image-based subs during transcoding as they're burned in)
|
||||
const embeddedInPlayer = embeddedSubs.filter(
|
||||
(s) => !isTranscoding || !isImageBasedSubtitle(s),
|
||||
);
|
||||
|
||||
const subs: Track[] = [];
|
||||
|
||||
// Process all Jellyfin subtitles
|
||||
for (const sub of allSubs) {
|
||||
const isEmbedded = sub.DeliveryMethod === SubtitleDeliveryMethod.Embed;
|
||||
const isExternal =
|
||||
sub.DeliveryMethod === SubtitleDeliveryMethod.External;
|
||||
|
||||
// For image-based subs during transcoding, need to refresh player
|
||||
if (isTranscoding && isImageBasedSubtitle(sub)) {
|
||||
subs.push({
|
||||
name: sub.DisplayTitle || "Unknown",
|
||||
index: sub.Index ?? -1,
|
||||
mpvIndex: -1,
|
||||
setTrack: () => {
|
||||
replacePlayer({ subtitleIndex: String(sub.Index) });
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate MPV track ID based on type
|
||||
// MPV IDs: [1..embeddedCount] for embedded, [embeddedCount+1..] for external
|
||||
let mpvId = -1;
|
||||
|
||||
if (isEmbedded) {
|
||||
// Find position among embedded subs that are in player
|
||||
const embeddedPosition = embeddedInPlayer.findIndex(
|
||||
(s) => s.Index === sub.Index,
|
||||
);
|
||||
if (embeddedPosition !== -1) {
|
||||
mpvId = embeddedPosition + 1; // 1-based ID
|
||||
}
|
||||
} else if (isExternal) {
|
||||
// Find position among external subs, offset by embedded count
|
||||
const externalPosition = externalSubs.findIndex(
|
||||
(s) => s.Index === sub.Index,
|
||||
);
|
||||
if (externalPosition !== -1) {
|
||||
mpvId = embeddedInPlayer.length + externalPosition + 1;
|
||||
}
|
||||
}
|
||||
|
||||
subs.push({
|
||||
name: sub.DisplayTitle || "Unknown",
|
||||
index: sub.Index ?? -1,
|
||||
mpvIndex: mpvId,
|
||||
setTrack: () => {
|
||||
// Transcoding + switching to/from image-based sub
|
||||
if (
|
||||
isTranscoding &&
|
||||
(isImageBasedSubtitle(sub) || isCurrentSubImageBased)
|
||||
) {
|
||||
replacePlayer({ subtitleIndex: String(sub.Index) });
|
||||
return;
|
||||
}
|
||||
|
||||
// Direct switch in player
|
||||
if (mpvId !== -1) {
|
||||
playerControls.setSubtitleTrack(mpvId);
|
||||
router.setParams({ subtitleIndex: String(sub.Index) });
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback - refresh player
|
||||
replacePlayer({ subtitleIndex: String(sub.Index) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Add "Disable" option at the beginning
|
||||
subs.unshift({
|
||||
name: "Disable",
|
||||
index: -1,
|
||||
mpvIndex: -1,
|
||||
setTrack: () => {
|
||||
if (isTranscoding && isCurrentSubImageBased) {
|
||||
replacePlayer({ subtitleIndex: "-1" });
|
||||
} else {
|
||||
playerControls.setSubtitleTrack(-1);
|
||||
router.setParams({ subtitleIndex: "-1" });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Process audio tracks
|
||||
const audio: Track[] = allAudio.map((a, idx) => {
|
||||
const playerTrack = playerAudio[idx];
|
||||
const mpvId = playerTrack?.id ?? idx + 1;
|
||||
|
||||
return {
|
||||
name: a.DisplayTitle || "Unknown",
|
||||
index: a.Index ?? -1,
|
||||
mpvIndex: mpvId,
|
||||
setTrack: () => {
|
||||
if (isTranscoding) {
|
||||
replacePlayer({ audioIndex: String(a.Index) });
|
||||
return;
|
||||
}
|
||||
playerControls.setAudioTrack(mpvId);
|
||||
router.setParams({ audioIndex: String(a.Index) });
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
setSubtitleTracks(subs.sort((a, b) => a.index - b.index));
|
||||
setAudioTracks(audio);
|
||||
};
|
||||
|
||||
fetchTracks();
|
||||
}, [isVideoLoaded, getAudioTracks, getSubtitleTracks]);
|
||||
}, [tracksReady, mediaSource, offline, downloadedItem]);
|
||||
|
||||
return (
|
||||
<VideoContext.Provider
|
||||
value={{
|
||||
audioTracks,
|
||||
subtitleTracks,
|
||||
setSubtitleTrack,
|
||||
setSubtitleURL,
|
||||
setAudioTrack,
|
||||
}}
|
||||
>
|
||||
<VideoContext.Provider value={{ subtitleTracks, audioTracks }}>
|
||||
{children}
|
||||
</VideoContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useVideoContext = () => {
|
||||
const context = useContext(VideoContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useVideoContext must be used within a VideoProvider");
|
||||
}
|
||||
return context;
|
||||
const ctx = useContext(VideoContext);
|
||||
if (!ctx)
|
||||
throw new Error("useVideoContext must be used within VideoProvider");
|
||||
return ctx;
|
||||
};
|
||||
|
||||
@@ -1,96 +1,228 @@
|
||||
import React 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 } from "expo-router";
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import { Platform, View } from "react-native";
|
||||
import { BITRATES } from "@/components/BitrateSelector";
|
||||
import {
|
||||
type OptionGroup,
|
||||
PlatformDropdown,
|
||||
} from "@/components/PlatformDropdown";
|
||||
import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { usePlayerContext } from "../contexts/PlayerContext";
|
||||
import { useVideoContext } from "../contexts/VideoContext";
|
||||
import { PlaybackSpeedScope } from "../utils/playback-speed-settings";
|
||||
|
||||
// Subtitle size presets (stored as scale * 100, so 1.0 = 100)
|
||||
const SUBTITLE_SIZE_PRESETS = [
|
||||
{ label: "0.5", value: 50 },
|
||||
{ label: "0.6", value: 60 },
|
||||
{ label: "0.7", value: 70 },
|
||||
{ label: "0.8", value: 80 },
|
||||
{ label: "0.9", value: 90 },
|
||||
{ label: "1.0", value: 100 },
|
||||
{ label: "1.1", value: 110 },
|
||||
{ label: "1.2", value: 120 },
|
||||
] as const;
|
||||
|
||||
interface DropdownViewProps {
|
||||
showControls: boolean;
|
||||
offline?: boolean; // used to disable external subs for downloads
|
||||
playbackSpeed?: number;
|
||||
setPlaybackSpeed?: (speed: number, scope: PlaybackSpeedScope) => void;
|
||||
showTechnicalInfo?: boolean;
|
||||
onToggleTechnicalInfo?: () => void;
|
||||
}
|
||||
|
||||
const DropdownView: React.FC<DropdownViewProps> = ({
|
||||
showControls,
|
||||
offline = false,
|
||||
}) => {
|
||||
const videoContext = useVideoContext();
|
||||
const { subtitleTracks, audioTracks } = videoContext;
|
||||
const DropdownView = ({
|
||||
playbackSpeed = 1.0,
|
||||
setPlaybackSpeed,
|
||||
showTechnicalInfo = false,
|
||||
onToggleTechnicalInfo,
|
||||
}: DropdownViewProps) => {
|
||||
const { subtitleTracks, audioTracks } = useVideoContext();
|
||||
const { item, mediaSource } = usePlayerContext();
|
||||
const { settings, updateSettings } = useSettings();
|
||||
const router = useRouter();
|
||||
const isOffline = useOfflineMode();
|
||||
|
||||
const { subtitleIndex, audioIndex } = useLocalSearchParams<{
|
||||
itemId: string;
|
||||
audioIndex: string;
|
||||
subtitleIndex: string;
|
||||
mediaSourceId: string;
|
||||
bitrateValue: string;
|
||||
}>();
|
||||
const { subtitleIndex, audioIndex, bitrateValue, playbackPosition } =
|
||||
useLocalSearchParams<{
|
||||
itemId: string;
|
||||
audioIndex: string;
|
||||
subtitleIndex: string;
|
||||
mediaSourceId: string;
|
||||
bitrateValue: string;
|
||||
playbackPosition: string;
|
||||
}>();
|
||||
|
||||
// Use ref to track playbackPosition without causing re-renders
|
||||
const playbackPositionRef = useRef(playbackPosition);
|
||||
playbackPositionRef.current = playbackPosition;
|
||||
|
||||
// Stabilize IDs to prevent unnecessary recalculations
|
||||
const itemIdRef = useRef(item.Id);
|
||||
const mediaSourceIdRef = useRef(mediaSource?.Id);
|
||||
itemIdRef.current = item.Id;
|
||||
mediaSourceIdRef.current = mediaSource?.Id;
|
||||
|
||||
const changeBitrate = useCallback(
|
||||
(bitrate: string) => {
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: itemIdRef.current ?? "",
|
||||
audioIndex: audioIndex?.toString() ?? "",
|
||||
subtitleIndex: subtitleIndex?.toString() ?? "",
|
||||
mediaSourceId: mediaSourceIdRef.current ?? "",
|
||||
bitrateValue: bitrate.toString(),
|
||||
playbackPosition: playbackPositionRef.current,
|
||||
}).toString();
|
||||
router.replace(`player/direct-player?${queryParams}` as any);
|
||||
},
|
||||
[audioIndex, subtitleIndex, router],
|
||||
);
|
||||
|
||||
// Create stable identifiers for tracks
|
||||
const subtitleTracksKey = useMemo(
|
||||
() => subtitleTracks?.map((t) => `${t.index}-${t.name}`).join(",") ?? "",
|
||||
[subtitleTracks],
|
||||
);
|
||||
|
||||
const audioTracksKey = useMemo(
|
||||
() => audioTracks?.map((t) => `${t.index}-${t.name}`).join(",") ?? "",
|
||||
[audioTracks],
|
||||
);
|
||||
|
||||
// Transform sections into OptionGroup format
|
||||
const optionGroups = useMemo<OptionGroup[]>(() => {
|
||||
const groups: OptionGroup[] = [];
|
||||
|
||||
// Quality Section
|
||||
if (!isOffline) {
|
||||
groups.push({
|
||||
title: "Quality",
|
||||
options:
|
||||
BITRATES?.map((bitrate) => ({
|
||||
type: "radio" as const,
|
||||
label: bitrate.key,
|
||||
value: bitrate.value?.toString() ?? "",
|
||||
selected: bitrateValue === (bitrate.value?.toString() ?? ""),
|
||||
onPress: () => changeBitrate(bitrate.value?.toString() ?? ""),
|
||||
})) || [],
|
||||
});
|
||||
}
|
||||
|
||||
// Subtitle Section
|
||||
if (subtitleTracks && subtitleTracks.length > 0) {
|
||||
groups.push({
|
||||
title: "Subtitles",
|
||||
options: subtitleTracks.map((sub) => ({
|
||||
type: "radio" as const,
|
||||
label: sub.name,
|
||||
value: sub.index.toString(),
|
||||
selected: subtitleIndex === sub.index.toString(),
|
||||
onPress: () => sub.setTrack(),
|
||||
})),
|
||||
});
|
||||
|
||||
// Subtitle Size Section
|
||||
groups.push({
|
||||
title: "Subtitle Size",
|
||||
options: SUBTITLE_SIZE_PRESETS.map((preset) => ({
|
||||
type: "radio" as const,
|
||||
label: preset.label,
|
||||
value: preset.value.toString(),
|
||||
selected: settings.subtitleSize === preset.value,
|
||||
onPress: () => updateSettings({ subtitleSize: preset.value }),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// Audio Section
|
||||
if (audioTracks && audioTracks.length > 0) {
|
||||
groups.push({
|
||||
title: "Audio",
|
||||
options: audioTracks.map((track) => ({
|
||||
type: "radio" as const,
|
||||
label: track.name,
|
||||
value: track.index.toString(),
|
||||
selected: audioIndex === track.index.toString(),
|
||||
onPress: () => track.setTrack(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// Speed Section
|
||||
if (setPlaybackSpeed) {
|
||||
groups.push({
|
||||
title: "Speed",
|
||||
options: PLAYBACK_SPEEDS.map((speed) => ({
|
||||
type: "radio" as const,
|
||||
label: speed.label,
|
||||
value: speed.value.toString(),
|
||||
selected: playbackSpeed === speed.value,
|
||||
onPress: () => setPlaybackSpeed(speed.value, PlaybackSpeedScope.All),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// Technical Info (at bottom)
|
||||
if (onToggleTechnicalInfo) {
|
||||
groups.push({
|
||||
options: [
|
||||
{
|
||||
type: "action" as const,
|
||||
label: showTechnicalInfo
|
||||
? "Hide Technical Info"
|
||||
: "Show Technical Info",
|
||||
onPress: onToggleTechnicalInfo,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
isOffline,
|
||||
bitrateValue,
|
||||
changeBitrate,
|
||||
subtitleTracksKey,
|
||||
audioTracksKey,
|
||||
subtitleIndex,
|
||||
audioIndex,
|
||||
settings.subtitleSize,
|
||||
updateSettings,
|
||||
playbackSpeed,
|
||||
setPlaybackSpeed,
|
||||
showTechnicalInfo,
|
||||
onToggleTechnicalInfo,
|
||||
// Note: subtitleTracks and audioTracks are intentionally excluded
|
||||
// because we use subtitleTracksKey and audioTracksKey for stability
|
||||
]);
|
||||
|
||||
// Memoize the trigger to prevent re-renders
|
||||
const trigger = useMemo(
|
||||
() => (
|
||||
<View className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'>
|
||||
<Ionicons name='ellipsis-horizontal' size={24} color={"white"} />
|
||||
</View>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
// Hide on TV platforms
|
||||
if (Platform.isTV) return null;
|
||||
|
||||
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}
|
||||
>
|
||||
{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>
|
||||
<PlatformDropdown
|
||||
title='Playback Options'
|
||||
groups={optionGroups}
|
||||
trigger={trigger}
|
||||
expoUIConfig={{}}
|
||||
bottomSheetConfig={{
|
||||
enablePanDownToClose: true,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -20,7 +20,8 @@ type TranscodedSubtitle = {
|
||||
type Track = {
|
||||
name: string;
|
||||
index: number;
|
||||
mpvIndex?: number;
|
||||
setTrack: () => void;
|
||||
};
|
||||
|
||||
export { EmbeddedSubtitle, ExternalSubtitle, TranscodedSubtitle, Track };
|
||||
export type { EmbeddedSubtitle, ExternalSubtitle, TranscodedSubtitle, Track };
|
||||
|
||||
45
eas.json
45
eas.json
@@ -1,51 +1,66 @@
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 9.1.0",
|
||||
"appVersionSource": "local"
|
||||
"version": ">= 9.1.0"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal",
|
||||
"android": {
|
||||
"buildType": "apk"
|
||||
}
|
||||
},
|
||||
"development_tv": {
|
||||
"environment": "development",
|
||||
"developmentClient": true,
|
||||
"distribution": "internal",
|
||||
"android": {
|
||||
"buildType": "apk"
|
||||
},
|
||||
"env": {
|
||||
"EXPO_TV": "1"
|
||||
"EXPO_PUBLIC_WRITE_DEBUG": "1"
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal"
|
||||
"development_tv": {
|
||||
"environment": "development",
|
||||
"developmentClient": true,
|
||||
"distribution": "internal",
|
||||
"android": {
|
||||
"buildType": "apk"
|
||||
},
|
||||
"env": {
|
||||
"EXPO_TV": "1",
|
||||
"EXPO_PUBLIC_WRITE_DEBUG": "1"
|
||||
}
|
||||
},
|
||||
"development-simulator": {
|
||||
"environment": "development",
|
||||
"developmentClient": true,
|
||||
"distribution": "internal",
|
||||
"ios": {
|
||||
"simulator": true
|
||||
},
|
||||
"env": {
|
||||
"EXPO_PUBLIC_WRITE_DEBUG": "1"
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal",
|
||||
"env": {
|
||||
"EXPO_PUBLIC_WRITE_DEBUG": "1"
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"channel": "0.27.0",
|
||||
"environment": "production",
|
||||
"channel": "0.52.0",
|
||||
"android": {
|
||||
"image": "latest"
|
||||
}
|
||||
},
|
||||
"production-apk": {
|
||||
"channel": "0.27.0",
|
||||
"environment": "production",
|
||||
"channel": "0.52.0",
|
||||
"android": {
|
||||
"buildType": "apk",
|
||||
"image": "latest"
|
||||
}
|
||||
},
|
||||
"production-apk-tv": {
|
||||
"channel": "0.27.0",
|
||||
"environment": "production",
|
||||
"channel": "0.52.0",
|
||||
"android": {
|
||||
"buildType": "apk",
|
||||
"image": "latest"
|
||||
|
||||
@@ -1,50 +1,23 @@
|
||||
import { Bitrate, BITRATES } from "@/components/BitrateSelector";
|
||||
import { Settings } from "@/utils/atoms/settings";
|
||||
import {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { type BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useMemo } from "react";
|
||||
import type { Settings } from "@/utils/atoms/settings";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
|
||||
// Used only for initial play settings.
|
||||
const useDefaultPlaySettings = (
|
||||
item: BaseItemDto,
|
||||
settings: Settings | null
|
||||
) => {
|
||||
const playSettings = useMemo(() => {
|
||||
// 1. Get first media source
|
||||
const mediaSource = item.MediaSources?.[0];
|
||||
|
||||
// 2. Get default or preferred audio
|
||||
const defaultAudioIndex = mediaSource?.DefaultAudioStreamIndex;
|
||||
const preferedAudioIndex = mediaSource?.MediaStreams?.find(
|
||||
(x) =>
|
||||
x.Type === "Audio" &&
|
||||
x.Language ===
|
||||
settings?.defaultAudioLanguage?.ThreeLetterISOLanguageName
|
||||
)?.Index;
|
||||
|
||||
const firstAudioIndex = mediaSource?.MediaStreams?.find(
|
||||
(x) => x.Type === "Audio"
|
||||
)?.Index;
|
||||
|
||||
// 4. Get default bitrate from settings or fallback to max
|
||||
const bitrate = settings?.defaultBitrate ?? BITRATES[0];
|
||||
/**
|
||||
* React hook wrapper for getDefaultPlaySettings.
|
||||
* Used in UI components for initial playback (no previous track state).
|
||||
*/
|
||||
const useDefaultPlaySettings = (item: BaseItemDto, settings: Settings | null) =>
|
||||
useMemo(() => {
|
||||
const { mediaSource, audioIndex, subtitleIndex, bitrate } =
|
||||
getDefaultPlaySettings(item, settings);
|
||||
|
||||
return {
|
||||
defaultAudioIndex:
|
||||
preferedAudioIndex || defaultAudioIndex || firstAudioIndex || undefined,
|
||||
defaultSubtitleIndex: mediaSource?.DefaultSubtitleStreamIndex || -1,
|
||||
defaultMediaSource: mediaSource || undefined,
|
||||
defaultBitrate: bitrate || undefined,
|
||||
defaultMediaSource: mediaSource,
|
||||
defaultAudioIndex: audioIndex,
|
||||
defaultSubtitleIndex: subtitleIndex,
|
||||
defaultBitrate: bitrate,
|
||||
};
|
||||
}, [
|
||||
item.MediaSources,
|
||||
settings?.defaultAudioLanguage,
|
||||
settings?.defaultSubtitleLanguage,
|
||||
]);
|
||||
|
||||
return playSettings;
|
||||
};
|
||||
}, [item, settings]);
|
||||
|
||||
export default useDefaultPlaySettings;
|
||||
|
||||
@@ -1,109 +1,140 @@
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
// Shared atom to store favorite status across all components
|
||||
// Maps itemId -> isFavorite
|
||||
const favoritesAtom = atom<Record<string, boolean>>({});
|
||||
|
||||
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);
|
||||
const [favorites, setFavorites] = useAtom(favoritesAtom);
|
||||
|
||||
const itemId = item.Id ?? "";
|
||||
|
||||
// Get current favorite status from shared state, falling back to item data
|
||||
const isFavorite = itemId
|
||||
? (favorites[itemId] ?? item.UserData?.IsFavorite)
|
||||
: item.UserData?.IsFavorite;
|
||||
|
||||
// Update shared state when item data changes
|
||||
useEffect(() => {
|
||||
if (itemId && item.UserData?.IsFavorite !== undefined) {
|
||||
setFavorites((prev) => ({
|
||||
...prev,
|
||||
[itemId]: item.UserData!.IsFavorite!,
|
||||
}));
|
||||
}
|
||||
}, [itemId, item.UserData?.IsFavorite, setFavorites]);
|
||||
|
||||
// Helper to update favorite status in shared state
|
||||
const setIsFavorite = useCallback(
|
||||
(value: boolean | undefined) => {
|
||||
if (itemId && value !== undefined) {
|
||||
setFavorites((prev) => ({ ...prev, [itemId]: value }));
|
||||
}
|
||||
},
|
||||
[itemId, setFavorites],
|
||||
);
|
||||
|
||||
// Use refs to avoid stale closure issues in mutationFn
|
||||
const itemRef = useRef(item);
|
||||
const apiRef = useRef(api);
|
||||
const userRef = useRef(user);
|
||||
|
||||
// Keep refs updated
|
||||
useEffect(() => {
|
||||
itemRef.current = item;
|
||||
}, [item]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsFavorite(item.UserData?.IsFavorite);
|
||||
}, [item.UserData?.IsFavorite]);
|
||||
apiRef.current = api;
|
||||
}, [api]);
|
||||
|
||||
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 },
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
useEffect(() => {
|
||||
userRef.current = user;
|
||||
}, [user]);
|
||||
|
||||
const markFavoriteMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (api && user) {
|
||||
await getUserLibraryApi(api).markFavoriteItem({
|
||||
userId: user.Id,
|
||||
itemId: item.Id!,
|
||||
});
|
||||
}
|
||||
const itemQueryKeyPrefix = useMemo(
|
||||
() => ["item", item.Id] as const,
|
||||
[item.Id],
|
||||
);
|
||||
|
||||
const updateItemInQueries = useCallback(
|
||||
(newData: Partial<BaseItemDto>) => {
|
||||
queryClient.setQueriesData<BaseItemDto | null | undefined>(
|
||||
{ queryKey: itemQueryKeyPrefix },
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
...newData,
|
||||
UserData: { ...old.UserData, ...newData.UserData },
|
||||
};
|
||||
},
|
||||
);
|
||||
},
|
||||
onMutate: async () => {
|
||||
await queryClient.cancelQueries({ queryKey: [type, item.Id] });
|
||||
const previousItem = queryClient.getQueryData<BaseItemDto>([
|
||||
type,
|
||||
item.Id,
|
||||
]);
|
||||
updateItemInQueries({ UserData: { IsFavorite: true } });
|
||||
[itemQueryKeyPrefix, queryClient],
|
||||
);
|
||||
|
||||
return { previousItem };
|
||||
},
|
||||
onError: (err, variables, context) => {
|
||||
if (context?.previousItem) {
|
||||
queryClient.setQueryData([type, item.Id], context.previousItem);
|
||||
const favoriteMutation = useMutation({
|
||||
mutationFn: async (nextIsFavorite: boolean) => {
|
||||
const currentApi = apiRef.current;
|
||||
const currentUser = userRef.current;
|
||||
const currentItem = itemRef.current;
|
||||
|
||||
if (!currentApi || !currentUser?.Id || !currentItem?.Id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the same endpoint format as the web client:
|
||||
// POST /Users/{userId}/FavoriteItems/{itemId} - add favorite
|
||||
// DELETE /Users/{userId}/FavoriteItems/{itemId} - remove favorite
|
||||
const path = `/Users/${currentUser.Id}/FavoriteItems/${currentItem.Id}`;
|
||||
|
||||
const response = nextIsFavorite
|
||||
? await currentApi.post(path, {}, {})
|
||||
: await currentApi.delete(path, {});
|
||||
return response.data;
|
||||
},
|
||||
onMutate: async (nextIsFavorite: boolean) => {
|
||||
await queryClient.cancelQueries({ queryKey: itemQueryKeyPrefix });
|
||||
|
||||
const previousIsFavorite = isFavorite;
|
||||
const previousQueries = queryClient.getQueriesData<BaseItemDto | null>({
|
||||
queryKey: itemQueryKeyPrefix,
|
||||
});
|
||||
|
||||
setIsFavorite(nextIsFavorite);
|
||||
updateItemInQueries({ UserData: { IsFavorite: nextIsFavorite } });
|
||||
|
||||
return { previousIsFavorite, previousQueries };
|
||||
},
|
||||
onError: (_err, _nextIsFavorite, context) => {
|
||||
if (context?.previousQueries) {
|
||||
for (const [queryKey, data] of context.previousQueries) {
|
||||
queryClient.setQueryData(queryKey, data);
|
||||
}
|
||||
}
|
||||
setIsFavorite(context?.previousIsFavorite);
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [type, item.Id] });
|
||||
queryClient.invalidateQueries({ queryKey: itemQueryKeyPrefix });
|
||||
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();
|
||||
}
|
||||
};
|
||||
const toggleFavorite = useCallback(() => {
|
||||
favoriteMutation.mutate(!isFavorite);
|
||||
}, [favoriteMutation, isFavorite]);
|
||||
|
||||
return {
|
||||
isFavorite,
|
||||
toggleFavorite,
|
||||
markFavoriteMutation,
|
||||
unmarkFavoriteMutation,
|
||||
favoriteMutation,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,50 +1,58 @@
|
||||
import axios, { AxiosError, AxiosInstance } from "axios";
|
||||
import { Results } from "@/utils/jellyseerr/server/models/Search";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { inRange } from "lodash";
|
||||
import { User as JellyseerrUser } from "@/utils/jellyseerr/server/entity/User";
|
||||
import axios, { type AxiosError, type AxiosInstance } from "axios";
|
||||
import { atom } from "jotai";
|
||||
import { useAtom } from "jotai/index";
|
||||
import { inRange } from "lodash";
|
||||
import type { User as JellyseerrUser } from "@/utils/jellyseerr/server/entity/User";
|
||||
import type {
|
||||
MovieResult,
|
||||
Results,
|
||||
TvResult,
|
||||
} from "@/utils/jellyseerr/server/models/Search";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import "@/augmentations";
|
||||
import { t } from "i18next";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { toast } from "sonner-native";
|
||||
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import type { RTRating } from "@/utils/jellyseerr/server/api/rating/rottentomatoes";
|
||||
import {
|
||||
IssueStatus,
|
||||
type IssueType,
|
||||
} from "@/utils/jellyseerr/server/constants/issue";
|
||||
import {
|
||||
MediaRequestStatus,
|
||||
MediaType,
|
||||
} from "@/utils/jellyseerr/server/constants/media";
|
||||
import MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
|
||||
import { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
||||
import { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||
import {
|
||||
import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
|
||||
import type Issue from "@/utils/jellyseerr/server/entity/Issue";
|
||||
import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
|
||||
import type { GenreSliderItem } from "@/utils/jellyseerr/server/interfaces/api/discoverInterfaces";
|
||||
import type {
|
||||
MediaRequestBody,
|
||||
RequestResultsResponse,
|
||||
} from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
||||
import type {
|
||||
ServiceCommonServer,
|
||||
ServiceCommonServerWithDetails,
|
||||
} from "@/utils/jellyseerr/server/interfaces/api/serviceInterfaces";
|
||||
import type { UserResultsResponse } from "@/utils/jellyseerr/server/interfaces/api/userInterfaces";
|
||||
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||
import type {
|
||||
CombinedCredit,
|
||||
PersonCreditCast,
|
||||
PersonDetails,
|
||||
} from "@/utils/jellyseerr/server/models/Person";
|
||||
import type {
|
||||
SeasonWithEpisodes,
|
||||
TvDetails,
|
||||
} from "@/utils/jellyseerr/server/models/Tv";
|
||||
import {
|
||||
IssueStatus,
|
||||
IssueType,
|
||||
} from "@/utils/jellyseerr/server/constants/issue";
|
||||
import Issue from "@/utils/jellyseerr/server/entity/Issue";
|
||||
import { RTRating } from "@/utils/jellyseerr/server/api/rating/rottentomatoes";
|
||||
import { writeErrorLog } from "@/utils/log";
|
||||
import DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
|
||||
import { t } from "i18next";
|
||||
import {
|
||||
CombinedCredit,
|
||||
PersonDetails,
|
||||
} from "@/utils/jellyseerr/server/models/Person";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {GenreSliderItem} from "@/utils/jellyseerr/server/interfaces/api/discoverInterfaces";
|
||||
import {UserResultsResponse} from "@/utils/jellyseerr/server/interfaces/api/userInterfaces";
|
||||
import {
|
||||
ServiceCommonServer,
|
||||
ServiceCommonServerWithDetails
|
||||
} from "@/utils/jellyseerr/server/interfaces/api/serviceInterfaces";
|
||||
|
||||
interface SearchParams {
|
||||
query: string;
|
||||
page: number;
|
||||
language: string;
|
||||
// language: string;
|
||||
}
|
||||
|
||||
interface SearchResults {
|
||||
@@ -58,8 +66,8 @@ const JELLYSEERR_USER = "JELLYSEERR_USER";
|
||||
const JELLYSEERR_COOKIES = "JELLYSEERR_COOKIES";
|
||||
|
||||
export const clearJellyseerrStorageData = () => {
|
||||
storage.delete(JELLYSEERR_USER);
|
||||
storage.delete(JELLYSEERR_COOKIES);
|
||||
storage.remove(JELLYSEERR_USER);
|
||||
storage.remove(JELLYSEERR_COOKIES);
|
||||
};
|
||||
|
||||
export enum Endpoints {
|
||||
@@ -80,11 +88,11 @@ export enum Endpoints {
|
||||
STUDIO = "/studio",
|
||||
GENRE_SLIDER = "/genreslider",
|
||||
DISCOVER = "/discover",
|
||||
DISCOVER_TRENDING = DISCOVER + "/trending",
|
||||
DISCOVER_MOVIES = DISCOVER + "/movies",
|
||||
DISCOVER_TRENDING = `${DISCOVER}/trending`,
|
||||
DISCOVER_MOVIES = `${DISCOVER}/movies`,
|
||||
DISCOVER_TV = DISCOVER + TV,
|
||||
DISCOVER_TV_NETWORK = DISCOVER + TV + NETWORK,
|
||||
DISCOVER_MOVIES_STUDIO = DISCOVER + `${MOVIE}s` + STUDIO,
|
||||
DISCOVER_MOVIES_STUDIO = `${DISCOVER}${MOVIE}s${STUDIO}`,
|
||||
AUTH_JELLYFIN = "/auth/jellyfin",
|
||||
}
|
||||
|
||||
@@ -134,15 +142,16 @@ export class JellyseerrApi {
|
||||
const { status, headers, data } = response;
|
||||
if (inRange(status, 200, 299)) {
|
||||
if (data.version < "2.0.0") {
|
||||
const error =
|
||||
t("jellyseerr.toasts.jellyseer_does_not_meet_requirements");
|
||||
const error = t(
|
||||
"jellyseerr.toasts.jellyseer_does_not_meet_requirements",
|
||||
);
|
||||
toast.error(error);
|
||||
throw Error(error);
|
||||
}
|
||||
|
||||
storage.setAny(
|
||||
JELLYSEERR_COOKIES,
|
||||
headers["set-cookie"]?.flatMap((c) => c.split("; ")) ?? []
|
||||
headers["set-cookie"]?.flatMap((c) => c.split("; ")) ?? [],
|
||||
);
|
||||
return {
|
||||
isValid: true,
|
||||
@@ -151,10 +160,8 @@ export class JellyseerrApi {
|
||||
}
|
||||
toast.error(t("jellyseerr.toasts.jellyseerr_test_failed"));
|
||||
writeErrorLog(
|
||||
`Jellyseerr returned a ${status} for url:\n` +
|
||||
response.config.url +
|
||||
"\n" +
|
||||
JSON.stringify(response.data)
|
||||
`Jellyseerr returned a ${status} for url:\n${response.config.url}`,
|
||||
response.data,
|
||||
);
|
||||
return {
|
||||
isValid: false,
|
||||
@@ -190,14 +197,14 @@ export class JellyseerrApi {
|
||||
async discoverSettings(): Promise<DiscoverSlider[]> {
|
||||
return this.axios
|
||||
?.get<DiscoverSlider[]>(
|
||||
Endpoints.API_V1 + Endpoints.SETTINGS + Endpoints.DISCOVER
|
||||
Endpoints.API_V1 + Endpoints.SETTINGS + Endpoints.DISCOVER,
|
||||
)
|
||||
.then(({ data }) => data);
|
||||
}
|
||||
|
||||
async discover(
|
||||
endpoint: DiscoverEndpoint | string,
|
||||
params: any
|
||||
params: any,
|
||||
): Promise<SearchResults> {
|
||||
return this.axios
|
||||
?.get<SearchResults>(Endpoints.API_V1 + endpoint, { params })
|
||||
@@ -206,19 +213,23 @@ export class JellyseerrApi {
|
||||
|
||||
async getGenreSliders(
|
||||
endpoint: Endpoints.TV | Endpoints.MOVIE,
|
||||
params: any = undefined
|
||||
params: any = undefined,
|
||||
): Promise<GenreSliderItem[]> {
|
||||
return this.axios
|
||||
?.get<GenreSliderItem[]>(Endpoints.API_V1 + Endpoints.DISCOVER + Endpoints.GENRE_SLIDER + endpoint, { params })
|
||||
?.get<GenreSliderItem[]>(
|
||||
Endpoints.API_V1 +
|
||||
Endpoints.DISCOVER +
|
||||
Endpoints.GENRE_SLIDER +
|
||||
endpoint,
|
||||
{ params },
|
||||
)
|
||||
.then(({ data }) => data);
|
||||
}
|
||||
|
||||
async search(params: SearchParams): Promise<SearchResults> {
|
||||
const response = await this.axios?.get<SearchResults>(
|
||||
Endpoints.API_V1 + Endpoints.SEARCH,
|
||||
{ params }
|
||||
);
|
||||
return response?.data;
|
||||
return this.axios
|
||||
?.get<SearchResults>(Endpoints.API_V1 + Endpoints.SEARCH, { params })
|
||||
.then(({ data }) => data);
|
||||
}
|
||||
|
||||
async request(request: MediaRequestBody): Promise<MediaRequest> {
|
||||
@@ -227,9 +238,46 @@ export class JellyseerrApi {
|
||||
.then(({ data }) => data);
|
||||
}
|
||||
|
||||
async getRequest(id: number): Promise<MediaRequest> {
|
||||
return this.axios
|
||||
?.get<MediaRequest>(`${Endpoints.API_V1 + Endpoints.REQUEST}/${id}`)
|
||||
.then(({ data }) => data);
|
||||
}
|
||||
|
||||
async approveRequest(requestId: number): Promise<MediaRequest> {
|
||||
return this.axios
|
||||
?.post<MediaRequest>(
|
||||
`${Endpoints.API_V1 + Endpoints.REQUEST}/${requestId}/approve`,
|
||||
)
|
||||
.then(({ data }) => data);
|
||||
}
|
||||
|
||||
async declineRequest(requestId: number): Promise<MediaRequest> {
|
||||
return this.axios
|
||||
?.post<MediaRequest>(
|
||||
`${Endpoints.API_V1 + Endpoints.REQUEST}/${requestId}/decline`,
|
||||
)
|
||||
.then(({ data }) => data);
|
||||
}
|
||||
|
||||
async requests(
|
||||
params = {
|
||||
filter: "all",
|
||||
take: 10,
|
||||
sort: "modified",
|
||||
skip: 0,
|
||||
},
|
||||
): Promise<RequestResultsResponse> {
|
||||
return this.axios
|
||||
?.get<RequestResultsResponse>(Endpoints.API_V1 + Endpoints.REQUEST, {
|
||||
params,
|
||||
})
|
||||
.then(({ data }) => data);
|
||||
}
|
||||
|
||||
async movieDetails(id: number) {
|
||||
return this.axios
|
||||
?.get<MovieDetails>(Endpoints.API_V1 + Endpoints.MOVIE + `/${id}`)
|
||||
?.get<MovieDetails>(`${Endpoints.API_V1 + Endpoints.MOVIE}/${id}`)
|
||||
.then((response) => {
|
||||
return response?.data;
|
||||
});
|
||||
@@ -237,7 +285,7 @@ export class JellyseerrApi {
|
||||
|
||||
async personDetails(id: number | string): Promise<PersonDetails> {
|
||||
return this.axios
|
||||
?.get<PersonDetails>(Endpoints.API_V1 + Endpoints.PERSON + `/${id}`)
|
||||
?.get<PersonDetails>(`${Endpoints.API_V1 + Endpoints.PERSON}/${id}`)
|
||||
.then((response) => {
|
||||
return response?.data;
|
||||
});
|
||||
@@ -246,10 +294,9 @@ export class JellyseerrApi {
|
||||
async personCombinedCredits(id: number | string): Promise<CombinedCredit> {
|
||||
return this.axios
|
||||
?.get<CombinedCredit>(
|
||||
Endpoints.API_V1 +
|
||||
Endpoints.PERSON +
|
||||
`/${id}` +
|
||||
Endpoints.COMBINED_CREDITS
|
||||
`${
|
||||
Endpoints.API_V1 + Endpoints.PERSON
|
||||
}/${id}${Endpoints.COMBINED_CREDITS}`,
|
||||
)
|
||||
.then((response) => {
|
||||
return response?.data;
|
||||
@@ -259,7 +306,7 @@ export class JellyseerrApi {
|
||||
async movieRatings(id: number) {
|
||||
return this.axios
|
||||
?.get<RTRating>(
|
||||
`${Endpoints.API_V1}${Endpoints.MOVIE}/${id}${Endpoints.RATINGS}`
|
||||
`${Endpoints.API_V1}${Endpoints.MOVIE}/${id}${Endpoints.RATINGS}`,
|
||||
)
|
||||
.then(({ data }) => data);
|
||||
}
|
||||
@@ -275,7 +322,7 @@ export class JellyseerrApi {
|
||||
async tvRatings(id: number) {
|
||||
return this.axios
|
||||
?.get<RTRating>(
|
||||
`${Endpoints.API_V1}${Endpoints.TV}/${id}${Endpoints.RATINGS}`
|
||||
`${Endpoints.API_V1}${Endpoints.TV}/${id}${Endpoints.RATINGS}`,
|
||||
)
|
||||
.then(({ data }) => data);
|
||||
}
|
||||
@@ -283,7 +330,7 @@ export class JellyseerrApi {
|
||||
async tvSeason(id: number, seasonId: number) {
|
||||
return this.axios
|
||||
?.get<SeasonWithEpisodes>(
|
||||
`${Endpoints.API_V1}${Endpoints.TV}/${id}/season/${seasonId}`
|
||||
`${Endpoints.API_V1}${Endpoints.TV}/${id}/season/${seasonId}`,
|
||||
)
|
||||
.then((response) => {
|
||||
return response?.data;
|
||||
@@ -292,24 +339,18 @@ export class JellyseerrApi {
|
||||
|
||||
async user(params: any) {
|
||||
return this.axios
|
||||
?.get<UserResultsResponse>(`${Endpoints.API_V1}${Endpoints.USER}`, { params })
|
||||
.then(({data}) => data.results)
|
||||
?.get<UserResultsResponse>(`${Endpoints.API_V1}${Endpoints.USER}`, {
|
||||
params,
|
||||
})
|
||||
.then(({ data }) => data.results);
|
||||
}
|
||||
|
||||
imageProxy(
|
||||
path?: string,
|
||||
filter: string = "original",
|
||||
width: number = 1920,
|
||||
quality: number = 75
|
||||
) {
|
||||
imageProxy(path?: string, filter = "original", width = 1920, quality = 75) {
|
||||
return path
|
||||
? this.axios.defaults.baseURL +
|
||||
`/_next/image?` +
|
||||
new URLSearchParams(
|
||||
`url=https://image.tmdb.org/t/p/${filter}/${path}&w=${width}&q=${quality}`
|
||||
).toString()
|
||||
: this.axios?.defaults.baseURL +
|
||||
`/images/overseerr_poster_not_found_logo_top.png`;
|
||||
? `${this.axios.defaults.baseURL}/_next/image?${new URLSearchParams(
|
||||
`url=https://image.tmdb.org/t/p/${filter}/${path}&w=${width}&q=${quality}`,
|
||||
).toString()}`
|
||||
: `${this.axios?.defaults.baseURL}/images/overseerr_poster_not_found_logo_top.png`;
|
||||
}
|
||||
|
||||
async submitIssue(mediaId: number, issueType: IssueType, message: string) {
|
||||
@@ -329,16 +370,20 @@ export class JellyseerrApi {
|
||||
});
|
||||
}
|
||||
|
||||
async service(type: 'radarr' | 'sonarr') {
|
||||
async service(type: "radarr" | "sonarr") {
|
||||
return this.axios
|
||||
?.get<ServiceCommonServer[]>(Endpoints.API_V1 + Endpoints.SERVICE + `/${type}`)
|
||||
.then(({data}) => data);
|
||||
?.get<ServiceCommonServer[]>(
|
||||
`${Endpoints.API_V1 + Endpoints.SERVICE}/${type}`,
|
||||
)
|
||||
.then(({ data }) => data);
|
||||
}
|
||||
|
||||
async serviceDetails(type: 'radarr' | 'sonarr', id: number) {
|
||||
async serviceDetails(type: "radarr" | "sonarr", id: number) {
|
||||
return this.axios
|
||||
?.get<ServiceCommonServerWithDetails>(Endpoints.API_V1 + Endpoints.SERVICE + `/${type}` + `/${id}`)
|
||||
.then(({data}) => data);
|
||||
?.get<ServiceCommonServerWithDetails>(
|
||||
`${Endpoints.API_V1 + Endpoints.SERVICE}/${type}/${id}`,
|
||||
)
|
||||
.then(({ data }) => data);
|
||||
}
|
||||
|
||||
private setInterceptors() {
|
||||
@@ -348,37 +393,31 @@ export class JellyseerrApi {
|
||||
if (cookies) {
|
||||
storage.setAny(
|
||||
JELLYSEERR_COOKIES,
|
||||
response.headers["set-cookie"]?.flatMap((c) => c.split("; "))
|
||||
response.headers["set-cookie"]?.flatMap((c) => c.split("; ")),
|
||||
);
|
||||
}
|
||||
return response;
|
||||
},
|
||||
(error: AxiosError) => {
|
||||
const errorMsg = "Jellyseerr response error";
|
||||
console.error(errorMsg, error, error.response?.data);
|
||||
writeErrorLog(
|
||||
errorMsg +
|
||||
`\n` +
|
||||
`error: ${error.toString()}\n` +
|
||||
`url: ${error?.config?.url}\n` +
|
||||
`data:\n` +
|
||||
JSON.stringify(error.response?.data)
|
||||
`Jellyseerr response error\nerror: ${error.toString()}\nurl: ${error?.config?.url}`,
|
||||
error.response?.data,
|
||||
);
|
||||
if (error.status === 403) {
|
||||
if (error.response?.status === 403) {
|
||||
clearJellyseerrStorageData();
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
this.axios.interceptors.request.use(
|
||||
async (config) => {
|
||||
const cookies = storage.get<string[]>(JELLYSEERR_COOKIES);
|
||||
if (cookies) {
|
||||
const headerName = this.axios.defaults.xsrfHeaderName!!;
|
||||
const headerName = this.axios.defaults.xsrfHeaderName!;
|
||||
const xsrfToken = cookies
|
||||
.find((c) => c.includes(headerName))
|
||||
?.split(headerName + "=")?.[1];
|
||||
?.split(`${headerName}=`)?.[1];
|
||||
if (xsrfToken) {
|
||||
config.headers[headerName] = xsrfToken;
|
||||
}
|
||||
@@ -387,7 +426,7 @@ export class JellyseerrApi {
|
||||
},
|
||||
(error) => {
|
||||
console.error("Jellyseerr request error", error);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -395,9 +434,9 @@ export class JellyseerrApi {
|
||||
const jellyseerrUserAtom = atom(storage.get<JellyseerrUser>(JELLYSEERR_USER));
|
||||
|
||||
export const useJellyseerr = () => {
|
||||
const { settings, updateSettings } = useSettings();
|
||||
const [jellyseerrUser, setJellyseerrUser] = useAtom(jellyseerrUserAtom);
|
||||
const [settings, updateSettings] = useSettings();
|
||||
const queryClient = useQueryClient();
|
||||
const queryClient = useNetworkAwareQueryClient();
|
||||
|
||||
const jellyseerrApi = useMemo(() => {
|
||||
const cookies = storage.get<string[]>(JELLYSEERR_COOKIES);
|
||||
@@ -423,35 +462,75 @@ export const useJellyseerr = () => {
|
||||
switch (mediaRequest.status) {
|
||||
case MediaRequestStatus.PENDING:
|
||||
case MediaRequestStatus.APPROVED:
|
||||
toast.success(t("jellyseerr.toasts.requested_item", {item: title}));
|
||||
onSuccess?.()
|
||||
toast.success(
|
||||
t("jellyseerr.toasts.requested_item", { item: title }),
|
||||
);
|
||||
onSuccess?.();
|
||||
break;
|
||||
case MediaRequestStatus.DECLINED:
|
||||
toast.error(t("jellyseerr.toasts.you_dont_have_permission_to_request"));
|
||||
toast.error(
|
||||
t("jellyseerr.toasts.you_dont_have_permission_to_request"),
|
||||
);
|
||||
break;
|
||||
case MediaRequestStatus.FAILED:
|
||||
toast.error(t("jellyseerr.toasts.something_went_wrong_requesting_media"));
|
||||
toast.error(
|
||||
t("jellyseerr.toasts.something_went_wrong_requesting_media"),
|
||||
);
|
||||
break;
|
||||
}
|
||||
});
|
||||
},
|
||||
[jellyseerrApi]
|
||||
[jellyseerrApi],
|
||||
);
|
||||
|
||||
const isJellyseerrResult = (
|
||||
items: any[] | null | undefined
|
||||
): items is Results[] => {
|
||||
const isJellyseerrMovieOrTvResult = (
|
||||
items: any | null | undefined,
|
||||
): items is MovieResult | TvResult => {
|
||||
return (
|
||||
!items ||
|
||||
(items.length >= 0 &&
|
||||
Object.hasOwn(items[0], "mediaType") &&
|
||||
Object.values(MediaType).includes(items[0]["mediaType"]))
|
||||
items &&
|
||||
Object.hasOwn(items, "mediaType") &&
|
||||
(items.mediaType === MediaType.MOVIE || items.mediaType === MediaType.TV)
|
||||
);
|
||||
};
|
||||
|
||||
const getTitle = (
|
||||
item?: TvResult | TvDetails | MovieResult | MovieDetails | PersonCreditCast,
|
||||
) => {
|
||||
return isJellyseerrMovieOrTvResult(item)
|
||||
? item.mediaType === MediaType.MOVIE
|
||||
? item?.title
|
||||
: item?.name
|
||||
: item?.mediaInfo?.mediaType === MediaType.MOVIE
|
||||
? (item as MovieDetails)?.title
|
||||
: (item as TvDetails)?.name;
|
||||
};
|
||||
|
||||
const getYear = (
|
||||
item?: TvResult | TvDetails | MovieResult | MovieDetails | PersonCreditCast,
|
||||
) => {
|
||||
return new Date(
|
||||
(isJellyseerrMovieOrTvResult(item)
|
||||
? item.mediaType === MediaType.MOVIE
|
||||
? item?.releaseDate
|
||||
: item?.firstAirDate
|
||||
: item?.mediaInfo?.mediaType === MediaType.MOVIE
|
||||
? (item as MovieDetails)?.releaseDate
|
||||
: (item as TvDetails)?.firstAirDate) || "",
|
||||
)?.getFullYear?.();
|
||||
};
|
||||
|
||||
const getMediaType = (
|
||||
item?: TvResult | TvDetails | MovieResult | MovieDetails | PersonCreditCast,
|
||||
): MediaType => {
|
||||
return isJellyseerrMovieOrTvResult(item)
|
||||
? (item.mediaType as MediaType)
|
||||
: item?.mediaInfo?.mediaType;
|
||||
};
|
||||
|
||||
const jellyseerrRegion = useMemo(
|
||||
() => jellyseerrUser?.settings?.region || "US",
|
||||
[jellyseerrUser]
|
||||
// streamingRegion and discoverRegion exists. region doesn't
|
||||
() => jellyseerrUser?.settings?.discoverRegion || "US",
|
||||
[jellyseerrUser],
|
||||
);
|
||||
|
||||
const jellyseerrLocale = useMemo(() => {
|
||||
@@ -463,7 +542,10 @@ export const useJellyseerr = () => {
|
||||
jellyseerrUser,
|
||||
setJellyseerrUser,
|
||||
clearAllJellyseerData,
|
||||
isJellyseerrResult,
|
||||
isJellyseerrMovieOrTvResult,
|
||||
getTitle,
|
||||
getYear,
|
||||
getMediaType,
|
||||
jellyseerrRegion,
|
||||
jellyseerrLocale,
|
||||
requestMedia,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
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";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import { Platform } from "react-native";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
const _Notifications = !Platform.isTV ? require("expo-notifications") : null;
|
||||
|
||||
export interface useSessionsProps {
|
||||
refetchInterval: number;
|
||||
@@ -16,7 +18,7 @@ export const useSessions = ({
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["sessions"],
|
||||
queryFn: async () => {
|
||||
if (!api || !user || !user.Policy?.IsAdministrator) {
|
||||
@@ -25,11 +27,43 @@ export const useSessions = ({
|
||||
const response = await getSessionApi(api).getSessions({
|
||||
activeWithinSeconds: activeWithinSeconds,
|
||||
});
|
||||
return response.data.filter((s) => s.NowPlayingItem);
|
||||
|
||||
const result = response.data
|
||||
.filter((s) => s.NowPlayingItem)
|
||||
.sort((a, b) =>
|
||||
(b.NowPlayingItem?.Name ?? "").localeCompare(
|
||||
a.NowPlayingItem?.Name ?? "",
|
||||
),
|
||||
);
|
||||
|
||||
// Notifications.setBadgeCountAsync(result.length);
|
||||
return result;
|
||||
},
|
||||
refetchInterval: refetchInterval,
|
||||
});
|
||||
|
||||
return { sessions: data, isLoading };
|
||||
};
|
||||
|
||||
export const useAllSessions = ({
|
||||
refetchInterval = 5 * 1000,
|
||||
activeWithinSeconds = 360,
|
||||
}: useSessionsProps) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["allSessions"],
|
||||
queryFn: async () => {
|
||||
if (!api || !user || !user.Policy?.IsAdministrator) {
|
||||
return [];
|
||||
}
|
||||
const response = await getSessionApi(api).getSessions({
|
||||
activeWithinSeconds: activeWithinSeconds,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
refetchInterval: refetchInterval,
|
||||
//enabled: !!user || !!user.Policy?.IsAdministrator,
|
||||
//cacheTime: 0
|
||||
});
|
||||
|
||||
return { sessions: data, isLoading };
|
||||
|
||||
58
i18n.ts
58
i18n.ts
@@ -1,42 +1,92 @@
|
||||
import { getLocales } from "expo-localization";
|
||||
import i18n from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
|
||||
import ar from "./translations/ar.json";
|
||||
import ca from "./translations/ca.json";
|
||||
import da from "./translations/da.json";
|
||||
import de from "./translations/de.json";
|
||||
import en from "./translations/en.json";
|
||||
import eo from "./translations/eo.json";
|
||||
import es from "./translations/es.json";
|
||||
import fi from "./translations/fi.json";
|
||||
import fr from "./translations/fr.json";
|
||||
import hu from "./translations/hu.json";
|
||||
import it from "./translations/it.json";
|
||||
import ja from "./translations/ja.json";
|
||||
import nb from "./translations/nb.json";
|
||||
import nl from "./translations/nl.json";
|
||||
import nn from "./translations/nn.json";
|
||||
import pl from "./translations/pl.json";
|
||||
import ptBR from "./translations/pt-BR.json";
|
||||
import ro from "./translations/ro.json";
|
||||
import ru from "./translations/ru.json";
|
||||
import sq from "./translations/sq.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 tlh from "./translations/tlh.json";
|
||||
import tr from "./translations/tr.json";
|
||||
import uk from "./translations/uk.json";
|
||||
import vi from "./translations/vi.json";
|
||||
import zhCN from "./translations/zh-CN.json";
|
||||
import zhTW from "./translations/zh-TW.json";
|
||||
|
||||
export const APP_LANGUAGES = [
|
||||
{ label: "Catalan", value: "ca" },
|
||||
{ label: "العربية", value: "ar" },
|
||||
{ label: "Dansk", value: "da" },
|
||||
{ label: "Deutsch", value: "de" },
|
||||
{ label: "English", value: "en" },
|
||||
{ label: "Español", value: "es" },
|
||||
{ label: "Esperanto", value: "eo" },
|
||||
{ label: "Français", value: "fr" },
|
||||
{ label: "Italiano", value: "it" },
|
||||
{ label: "日本語", value: "ja" },
|
||||
{ label: "Klingon", value: "tlh" },
|
||||
{ label: "Türkçe", value: "tr" },
|
||||
{ label: "Magyar", value: "hu" },
|
||||
{ label: "Nederlands", value: "nl" },
|
||||
{ label: "Polski", value: "pl" },
|
||||
{ label: "Português (Brasil)", value: "pt-BR" },
|
||||
{ label: "Română", value: "ro" },
|
||||
{ label: "Svenska", value: "sv" },
|
||||
{ label: "Norsk Bokmål", value: "nb" },
|
||||
{ label: "Norsk Nynorsk", value: "nn" },
|
||||
{ label: "Suomi", value: "fi" },
|
||||
{ label: "Shqip", value: "sq" },
|
||||
{ label: "Русский", value: "ru" },
|
||||
{ label: "Українська", value: "uk" },
|
||||
{ label: "简体中文", value: "zh-CN" },
|
||||
{ label: "繁體中文", value: "zh-TW" },
|
||||
{ label: "Tiếng Việt", value: "vi" },
|
||||
];
|
||||
|
||||
i18n.use(initReactI18next).init({
|
||||
compatibilityJSON: "v4",
|
||||
resources: {
|
||||
ca: { translation: ca },
|
||||
ar: { translation: ar },
|
||||
da: { translation: da },
|
||||
de: { translation: de },
|
||||
en: { translation: en },
|
||||
es: { translation: es },
|
||||
eo: { translation: eo },
|
||||
fr: { translation: fr },
|
||||
hu: { translation: hu },
|
||||
it: { translation: it },
|
||||
ja: { translation: ja },
|
||||
nl: { translation: nl },
|
||||
pl: { translation: pl },
|
||||
"pt-BR": { translation: ptBR },
|
||||
ro: { translation: ro },
|
||||
sv: { translation: sv },
|
||||
nb: { translation: nb },
|
||||
nn: { translation: nn },
|
||||
fi: { translation: fi },
|
||||
sq: { translation: sq },
|
||||
ru: { translation: ru },
|
||||
tr: { translation: tr },
|
||||
tlh: { translation: tlh },
|
||||
uk: { translation: uk },
|
||||
vi: { translation: vi },
|
||||
"zh-CN": { translation: zhCN },
|
||||
"zh-TW": { translation: zhTW },
|
||||
},
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
# login.yaml
|
||||
|
||||
appId: your.app.id
|
||||
---
|
||||
- launchApp
|
||||
- tapOn: "Text on the screen"
|
||||
@@ -1,38 +0,0 @@
|
||||
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,87 +0,0 @@
|
||||
package expo.modules.vlcplayer
|
||||
|
||||
import androidx.core.os.bundleOf
|
||||
import expo.modules.kotlin.modules.Module
|
||||
import expo.modules.kotlin.modules.ModuleDefinition
|
||||
|
||||
class VlcPlayerModule : Module() {
|
||||
override fun definition() = ModuleDefinition {
|
||||
Name("VlcPlayer")
|
||||
|
||||
OnActivityEntersForeground {
|
||||
VLCManager.listeners.forEach {
|
||||
it.onResume(appContext.currentActivity)
|
||||
}
|
||||
}
|
||||
|
||||
OnActivityEntersBackground {
|
||||
VLCManager.listeners.forEach {
|
||||
it.onPause(appContext.currentActivity)
|
||||
}
|
||||
}
|
||||
|
||||
View(VlcPlayerView::class) {
|
||||
Prop("source") { view: VlcPlayerView, source: Map<String, Any> ->
|
||||
view.setSource(source)
|
||||
}
|
||||
|
||||
Prop("paused") { view: VlcPlayerView, paused: Boolean ->
|
||||
if (paused) {
|
||||
view.pause()
|
||||
} else {
|
||||
view.play()
|
||||
}
|
||||
}
|
||||
|
||||
Events(
|
||||
"onPlaybackStateChanged",
|
||||
"onVideoStateChange",
|
||||
"onVideoLoadStart",
|
||||
"onVideoLoadEnd",
|
||||
"onVideoProgress",
|
||||
"onVideoError",
|
||||
"onPipStarted"
|
||||
)
|
||||
|
||||
AsyncFunction("startPictureInPicture") { view: VlcPlayerView ->
|
||||
view.startPictureInPicture()
|
||||
}
|
||||
|
||||
AsyncFunction("play") { view: VlcPlayerView ->
|
||||
view.play()
|
||||
}
|
||||
|
||||
AsyncFunction("pause") { view: VlcPlayerView ->
|
||||
view.pause()
|
||||
}
|
||||
|
||||
AsyncFunction("stop") { view: VlcPlayerView ->
|
||||
view.stop()
|
||||
}
|
||||
|
||||
AsyncFunction("seekTo") { view: VlcPlayerView, time: Int ->
|
||||
view.seekTo(time)
|
||||
}
|
||||
|
||||
AsyncFunction("setAudioTrack") { view: VlcPlayerView, trackIndex: Int ->
|
||||
view.setAudioTrack(trackIndex)
|
||||
}
|
||||
|
||||
AsyncFunction("getAudioTracks") { view: VlcPlayerView ->
|
||||
view.getAudioTracks()
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleTrack") { view: VlcPlayerView, trackIndex: Int ->
|
||||
view.setSubtitleTrack(trackIndex)
|
||||
}
|
||||
|
||||
AsyncFunction("getSubtitleTracks") { view: VlcPlayerView ->
|
||||
view.getSubtitleTracks()
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleURL") { view: VlcPlayerView, url: String, name: String ->
|
||||
view.setSubtitleURL(url, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,466 +0,0 @@
|
||||
package expo.modules.vlcplayer
|
||||
|
||||
import android.R
|
||||
import android.app.Activity
|
||||
import android.app.PendingIntent
|
||||
import android.app.PendingIntent.FLAG_IMMUTABLE
|
||||
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
|
||||
import android.app.PictureInPictureParams
|
||||
import android.app.RemoteAction
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.graphics.drawable.Icon
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.app.PictureInPictureModeChangedInfo
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleObserver
|
||||
import androidx.lifecycle.OnLifecycleEvent
|
||||
import expo.modules.core.interfaces.ReactActivityLifecycleListener
|
||||
import expo.modules.core.logging.LogHandlers
|
||||
import expo.modules.core.logging.Logger
|
||||
import expo.modules.kotlin.AppContext
|
||||
import expo.modules.kotlin.viewevent.EventDispatcher
|
||||
import expo.modules.kotlin.views.ExpoView
|
||||
import org.videolan.libvlc.LibVLC
|
||||
import org.videolan.libvlc.Media
|
||||
import org.videolan.libvlc.MediaPlayer
|
||||
import org.videolan.libvlc.interfaces.IMedia
|
||||
import org.videolan.libvlc.util.VLCVideoLayout
|
||||
|
||||
|
||||
class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext), LifecycleObserver, MediaPlayer.EventListener, ReactActivityLifecycleListener {
|
||||
private val log = Logger(listOf(LogHandlers.createOSLogHandler(this::class.simpleName!!)))
|
||||
private val PIP_PLAY_PAUSE_ACTION = "PIP_PLAY_PAUSE_ACTION"
|
||||
private val PIP_REWIND_ACTION = "PIP_REWIND_ACTION"
|
||||
private val PIP_FORWARD_ACTION = "PIP_FORWARD_ACTION"
|
||||
|
||||
private var libVLC: LibVLC? = null
|
||||
private var mediaPlayer: MediaPlayer? = null
|
||||
private lateinit var videoLayout: VLCVideoLayout
|
||||
private var isPaused: Boolean = false
|
||||
private var lastReportedState: Int? = null
|
||||
private var lastReportedIsPlaying: Boolean? = null
|
||||
private var media : Media? = null
|
||||
private var timeLeft: Long? = null
|
||||
|
||||
private val onVideoProgress by EventDispatcher()
|
||||
private val onVideoStateChange by EventDispatcher()
|
||||
private val onVideoLoadEnd by EventDispatcher()
|
||||
private val onPipStarted by EventDispatcher()
|
||||
|
||||
private var startPosition: Int? = 0
|
||||
private var isMediaReady: Boolean = false
|
||||
private var externalTrack: Map<String, String>? = null
|
||||
var hasSource: Boolean = false
|
||||
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private val updateInterval = 1000L // 1 second
|
||||
private val updateProgressRunnable = object : Runnable {
|
||||
override fun run() {
|
||||
updateVideoProgress()
|
||||
handler.postDelayed(this, updateInterval)
|
||||
}
|
||||
}
|
||||
private val currentActivity get() = context.findActivity()
|
||||
private val actions: MutableList<RemoteAction> = mutableListOf()
|
||||
private val remoteActionFilter = IntentFilter()
|
||||
private val playPauseIntent: Intent = Intent(PIP_PLAY_PAUSE_ACTION).setPackage(context.packageName)
|
||||
private val forwardIntent: Intent = Intent(PIP_FORWARD_ACTION).setPackage(context.packageName)
|
||||
private val rewindIntent: Intent = Intent(PIP_REWIND_ACTION).setPackage(context.packageName)
|
||||
private var actionReceiver: BroadcastReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
when (intent?.action) {
|
||||
PIP_PLAY_PAUSE_ACTION -> {
|
||||
if (isPaused) play() else pause()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
setupPipActions()
|
||||
currentActivity.setPictureInPictureParams(getPipParams()!!)
|
||||
}
|
||||
}
|
||||
PIP_FORWARD_ACTION -> seekTo((mediaPlayer?.time?.toInt() ?: 0) + 15_000)
|
||||
PIP_REWIND_ACTION -> seekTo((mediaPlayer?.time?.toInt() ?: 0) - 15_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var pipChangeListener: (PictureInPictureModeChangedInfo) -> Unit = { info ->
|
||||
if (!info.isInPictureInPictureMode && mediaPlayer?.isPlaying == true) {
|
||||
log.debug("Exiting PiP")
|
||||
timeLeft = mediaPlayer?.time
|
||||
pause()
|
||||
|
||||
// Setting the media after reattaching the view allows for a fast video view render
|
||||
if (mediaPlayer?.vlcVout?.areViewsAttached() == false) {
|
||||
mediaPlayer?.attachViews(videoLayout, null, false, false)
|
||||
mediaPlayer?.media = media
|
||||
mediaPlayer?.play()
|
||||
timeLeft?.let { mediaPlayer?.time = it }
|
||||
mediaPlayer?.pause()
|
||||
|
||||
}
|
||||
}
|
||||
onPipStarted(mapOf(
|
||||
"pipStarted" to info.isInPictureInPictureMode
|
||||
))
|
||||
}
|
||||
|
||||
init {
|
||||
VLCManager.listeners.add(this)
|
||||
setupView()
|
||||
setupPiP()
|
||||
}
|
||||
|
||||
private fun setupView() {
|
||||
log.debug("Setting up view")
|
||||
setBackgroundColor(android.graphics.Color.WHITE)
|
||||
videoLayout = VLCVideoLayout(context).apply {
|
||||
layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
|
||||
}
|
||||
videoLayout.keepScreenOn = true
|
||||
addView(videoLayout)
|
||||
log.debug("View setup complete")
|
||||
}
|
||||
|
||||
private fun setupPiP() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
remoteActionFilter.addAction(PIP_PLAY_PAUSE_ACTION)
|
||||
remoteActionFilter.addAction(PIP_FORWARD_ACTION)
|
||||
remoteActionFilter.addAction(PIP_REWIND_ACTION)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
currentActivity.registerReceiver(
|
||||
actionReceiver,
|
||||
remoteActionFilter,
|
||||
Context.RECEIVER_NOT_EXPORTED
|
||||
)
|
||||
}
|
||||
setupPipActions()
|
||||
currentActivity.apply {
|
||||
setPictureInPictureParams(getPipParams()!!)
|
||||
addOnPictureInPictureModeChangedListener(pipChangeListener)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
private fun setupPipActions() {
|
||||
actions.clear()
|
||||
actions.addAll(
|
||||
listOf(
|
||||
RemoteAction(
|
||||
Icon.createWithResource(context, R.drawable.ic_media_rew),
|
||||
"Rewind",
|
||||
"Rewind Video",
|
||||
PendingIntent.getBroadcast(
|
||||
context,
|
||||
0,
|
||||
rewindIntent,
|
||||
FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE
|
||||
)
|
||||
),
|
||||
RemoteAction(
|
||||
if (isPaused) Icon.createWithResource(context, R.drawable.ic_media_play)
|
||||
else Icon.createWithResource(context, R.drawable.ic_media_pause),
|
||||
"Play",
|
||||
"Play Video",
|
||||
PendingIntent.getBroadcast(
|
||||
context,
|
||||
if (isPaused) 0 else 1,
|
||||
playPauseIntent,
|
||||
FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE
|
||||
)
|
||||
),
|
||||
RemoteAction(
|
||||
Icon.createWithResource(context, R.drawable.ic_media_ff),
|
||||
"Skip",
|
||||
"Skip Forward",
|
||||
PendingIntent.getBroadcast(
|
||||
context,
|
||||
0,
|
||||
forwardIntent,
|
||||
FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun getPipParams(): PictureInPictureParams? {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
var builder = PictureInPictureParams.Builder()
|
||||
.setActions(actions)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
builder = builder.setAutoEnterEnabled(true)
|
||||
}
|
||||
return builder.build()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun setSource(source: Map<String, Any>) {
|
||||
log.debug("setting source $source")
|
||||
if (hasSource) {
|
||||
log.debug("Source already set. Resuming")
|
||||
mediaPlayer?.attachViews(videoLayout, null, false, false)
|
||||
play()
|
||||
return
|
||||
}
|
||||
val mediaOptions = source["mediaOptions"] as? Map<String, Any> ?: emptyMap()
|
||||
val autoplay = source["autoplay"] as? Boolean ?: false
|
||||
val isNetwork = source["isNetwork"] as? Boolean ?: false
|
||||
externalTrack = source["externalTrack"] as? Map<String, String>
|
||||
startPosition = (source["startPosition"] as? Double)?.toInt() ?: 0
|
||||
|
||||
val initOptions = source["initOptions"] as? MutableList<String> ?: mutableListOf()
|
||||
initOptions.add("--start-time=$startPosition")
|
||||
|
||||
|
||||
val uri = source["uri"] as? String
|
||||
|
||||
// Handle video load start event
|
||||
// onVideoLoadStart?.invoke(mapOf("target" to reactTag ?: "null"))
|
||||
|
||||
libVLC = LibVLC(context, initOptions)
|
||||
mediaPlayer = MediaPlayer(libVLC)
|
||||
mediaPlayer?.attachViews(videoLayout, null, false, false)
|
||||
mediaPlayer?.setEventListener(this)
|
||||
|
||||
log.debug("Loading network file: $uri")
|
||||
media = Media(libVLC, Uri.parse(uri))
|
||||
mediaPlayer?.media = media
|
||||
|
||||
|
||||
log.debug("Debug: Media options: $mediaOptions")
|
||||
// media.addOptions(mediaOptions)
|
||||
|
||||
// Apply subtitle options
|
||||
// val subtitleTrackIndex = source["subtitleTrackIndex"] as? Int ?: -1
|
||||
// Log.d("VlcPlayerView", "Debug: Subtitle track index from source: $subtitleTrackIndex")
|
||||
|
||||
// if (subtitleTrackIndex >= -1) {
|
||||
// setSubtitleTrack(subtitleTrackIndex)
|
||||
// Log.d("VlcPlayerView", "Debug: Set subtitle track to index: $subtitleTrackIndex")
|
||||
// } else {
|
||||
// Log.d("VlcPlayerView", "Debug: Subtitle track index is less than -1, not setting")
|
||||
// }
|
||||
|
||||
hasSource = true
|
||||
|
||||
if (autoplay) {
|
||||
log.debug("Playing...")
|
||||
play()
|
||||
}
|
||||
}
|
||||
|
||||
fun startPictureInPicture() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
currentActivity.enterPictureInPictureMode(getPipParams()!!)
|
||||
}
|
||||
}
|
||||
|
||||
fun play() {
|
||||
mediaPlayer?.play()
|
||||
isPaused = false
|
||||
handler.post(updateProgressRunnable) // Start updating progress
|
||||
}
|
||||
|
||||
fun pause() {
|
||||
mediaPlayer?.pause()
|
||||
isPaused = true
|
||||
handler.removeCallbacks(updateProgressRunnable) // Stop updating progress
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
mediaPlayer?.stop()
|
||||
handler.removeCallbacks(updateProgressRunnable) // Stop updating progress
|
||||
}
|
||||
|
||||
fun seekTo(time: Int) {
|
||||
mediaPlayer?.let { player ->
|
||||
val wasPlaying = player.isPlaying
|
||||
if (wasPlaying) {
|
||||
player.pause()
|
||||
}
|
||||
|
||||
val duration = player.length.toInt()
|
||||
val seekTime = if (time > duration) duration - 1000 else time
|
||||
player.time = seekTime.toLong()
|
||||
|
||||
if (wasPlaying) {
|
||||
player.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setAudioTrack(trackIndex: Int) {
|
||||
mediaPlayer?.setAudioTrack(trackIndex)
|
||||
}
|
||||
|
||||
fun getAudioTracks(): List<Map<String, Any>>? {
|
||||
log.debug("getAudioTracks ${mediaPlayer?.audioTracks}")
|
||||
val trackDescriptions = mediaPlayer?.audioTracks ?: return null
|
||||
|
||||
return trackDescriptions.map { trackDescription ->
|
||||
mapOf("name" to trackDescription.name, "index" to trackDescription.id)
|
||||
}
|
||||
}
|
||||
|
||||
fun setSubtitleTrack(trackIndex: Int) {
|
||||
mediaPlayer?.setSpuTrack(trackIndex)
|
||||
}
|
||||
|
||||
// fun getSubtitleTracks(): List<Map<String, Any>>? {
|
||||
// return mediaPlayer?.getSpuTracks()?.map { trackDescription ->
|
||||
// mapOf("name" to trackDescription.name, "index" to trackDescription.id)
|
||||
// }
|
||||
// }
|
||||
|
||||
fun getSubtitleTracks(): List<Map<String, Any>>? {
|
||||
val subtitleTracks = mediaPlayer?.spuTracks?.map { trackDescription ->
|
||||
mapOf("name" to trackDescription.name, "index" to trackDescription.id)
|
||||
}
|
||||
|
||||
// Debug statement to print the result
|
||||
log.debug("Subtitle Tracks: $subtitleTracks")
|
||||
|
||||
return subtitleTracks
|
||||
}
|
||||
|
||||
fun setSubtitleURL(subtitleURL: String, name: String) {
|
||||
log.debug("Setting subtitle URL: $subtitleURL, name: $name")
|
||||
mediaPlayer?.addSlave(IMedia.Slave.Type.Subtitle, Uri.parse(subtitleURL), true)
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
log.debug("onDetachedFromWindow")
|
||||
super.onDetachedFromWindow()
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
currentActivity.setPictureInPictureParams(
|
||||
PictureInPictureParams.Builder()
|
||||
.setAutoEnterEnabled(false)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
currentActivity.unregisterReceiver(actionReceiver)
|
||||
currentActivity.removeOnPictureInPictureModeChangedListener(pipChangeListener)
|
||||
VLCManager.listeners.clear()
|
||||
|
||||
mediaPlayer?.stop()
|
||||
handler.removeCallbacks(updateProgressRunnable) // Stop updating progress
|
||||
|
||||
media?.release()
|
||||
mediaPlayer?.release()
|
||||
libVLC?.release()
|
||||
mediaPlayer = null
|
||||
media = null
|
||||
libVLC = null
|
||||
}
|
||||
|
||||
override fun onEvent(event: MediaPlayer.Event) {
|
||||
keepScreenOn = event.type == MediaPlayer.Event.Playing || event.type == MediaPlayer.Event.Buffering
|
||||
when (event.type) {
|
||||
MediaPlayer.Event.Playing,
|
||||
MediaPlayer.Event.Paused,
|
||||
MediaPlayer.Event.Stopped,
|
||||
MediaPlayer.Event.Buffering,
|
||||
MediaPlayer.Event.EndReached,
|
||||
MediaPlayer.Event.EncounteredError -> updatePlayerState(event)
|
||||
MediaPlayer.Event.TimeChanged -> {
|
||||
// Do nothing here, as we are updating progress every 1 second
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updatePlayerState(event: MediaPlayer.Event) {
|
||||
val player = mediaPlayer ?: return
|
||||
val currentState = event.type
|
||||
|
||||
val stateInfo = mutableMapOf<String, Any>(
|
||||
"target" to "null", // Replace with actual target if needed
|
||||
"currentTime" to player.time.toInt(),
|
||||
"duration" to (player.media?.duration?.toInt() ?: 0),
|
||||
"error" to false,
|
||||
"isPlaying" to (currentState == MediaPlayer.Event.Playing),
|
||||
"isBuffering" to (!player.isPlaying && currentState == MediaPlayer.Event.Buffering)
|
||||
)
|
||||
|
||||
// Todo: make enum - string to prevent this when statement from becoming exhaustive
|
||||
when (currentState) {
|
||||
MediaPlayer.Event.Playing ->
|
||||
stateInfo["state"] = "Playing"
|
||||
MediaPlayer.Event.Paused ->
|
||||
stateInfo["state"] = "Paused"
|
||||
MediaPlayer.Event.Buffering ->
|
||||
stateInfo["state"] = "Buffering"
|
||||
MediaPlayer.Event.EncounteredError -> {
|
||||
stateInfo["state"] = "Error"
|
||||
onVideoLoadEnd(stateInfo);
|
||||
}
|
||||
MediaPlayer.Event.Opening ->
|
||||
stateInfo["state"] = "Opening"
|
||||
}
|
||||
|
||||
if (lastReportedState != currentState || lastReportedIsPlaying != player.isPlaying) {
|
||||
lastReportedState = currentState
|
||||
lastReportedIsPlaying = player.isPlaying
|
||||
onVideoStateChange(stateInfo)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun updateVideoProgress() {
|
||||
val player = mediaPlayer ?: return
|
||||
|
||||
val currentTimeMs = player.time.toInt()
|
||||
val durationMs = player.media?.duration?.toInt() ?: 0
|
||||
if (currentTimeMs >= 0 && currentTimeMs < durationMs) {
|
||||
// Set subtitle URL if available
|
||||
if (player.isPlaying && !isMediaReady) {
|
||||
isMediaReady = true
|
||||
externalTrack?.let {
|
||||
val name = it["name"]
|
||||
val deliveryUrl = it["DeliveryUrl"] ?: ""
|
||||
if (!name.isNullOrEmpty() && !deliveryUrl.isNullOrEmpty()) {
|
||||
setSubtitleURL(deliveryUrl, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
onVideoProgress(mapOf(
|
||||
"currentTime" to currentTimeMs,
|
||||
"duration" to durationMs
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause(activity: Activity?) {
|
||||
log.debug("Pausing activity...")
|
||||
}
|
||||
|
||||
|
||||
override fun onResume(activity: Activity?) {
|
||||
log.debug("Resuming activity...")
|
||||
if (isPaused) play()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun Context.findActivity(): androidx.activity.ComponentActivity {
|
||||
var context = this
|
||||
while (context is ContextWrapper) {
|
||||
if (context is androidx.activity.ComponentActivity) return context
|
||||
context = context.baseContext
|
||||
}
|
||||
throw IllegalStateException("Failed to find ComponentActivity")
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"platforms": ["ios", "tvos", "android", "web"],
|
||||
"ios": {
|
||||
"modules": ["VlcPlayerModule"],
|
||||
"appDelegateSubscribers": ["AppLifecycleDelegate"]
|
||||
},
|
||||
"android": {
|
||||
"modules": ["expo.modules.vlcplayer.VlcPlayerModule"]
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import {
|
||||
EventEmitter,
|
||||
EventSubscription,
|
||||
} from "expo-modules-core";
|
||||
|
||||
import VlcPlayerModule from "./src/VlcPlayerModule";
|
||||
import VlcPlayerView from "./src/VlcPlayerView";
|
||||
import {
|
||||
PlaybackStatePayload,
|
||||
ProgressUpdatePayload,
|
||||
VideoLoadStartPayload,
|
||||
VideoStateChangePayload,
|
||||
VideoProgressPayload,
|
||||
VlcPlayerSource,
|
||||
TrackInfo,
|
||||
ChapterInfo,
|
||||
VlcPlayerViewProps,
|
||||
VlcPlayerViewRef,
|
||||
} from "./src/VlcPlayer.types";
|
||||
|
||||
const emitter = new EventEmitter(VlcPlayerModule);
|
||||
|
||||
export function addPlaybackStateListener(
|
||||
listener: (event: PlaybackStatePayload) => void
|
||||
): EventSubscription {
|
||||
return emitter.addListener<PlaybackStatePayload>(
|
||||
"onPlaybackStateChanged",
|
||||
listener
|
||||
);
|
||||
}
|
||||
|
||||
export function addVideoLoadStartListener(
|
||||
listener: (event: VideoLoadStartPayload) => void
|
||||
): EventSubscription {
|
||||
return emitter.addListener<VideoLoadStartPayload>(
|
||||
"onVideoLoadStart",
|
||||
listener
|
||||
);
|
||||
}
|
||||
|
||||
export function addVideoStateChangeListener(
|
||||
listener: (event: VideoStateChangePayload) => void
|
||||
): EventSubscription {
|
||||
return emitter.addListener<VideoStateChangePayload>(
|
||||
"onVideoStateChange",
|
||||
listener
|
||||
);
|
||||
}
|
||||
|
||||
export function addVideoProgressListener(
|
||||
listener: (event: VideoProgressPayload) => void
|
||||
): EventSubscription {
|
||||
return emitter.addListener<VideoProgressPayload>("onVideoProgress", listener);
|
||||
}
|
||||
|
||||
export {
|
||||
VlcPlayerView,
|
||||
VlcPlayerViewProps,
|
||||
VlcPlayerViewRef,
|
||||
PlaybackStatePayload,
|
||||
ProgressUpdatePayload,
|
||||
VideoLoadStartPayload,
|
||||
VideoStateChangePayload,
|
||||
VideoProgressPayload,
|
||||
VlcPlayerSource,
|
||||
TrackInfo,
|
||||
ChapterInfo,
|
||||
};
|
||||
@@ -1,71 +0,0 @@
|
||||
import ExpoModulesCore
|
||||
|
||||
public class VlcPlayerModule: Module {
|
||||
public func definition() -> ModuleDefinition {
|
||||
Name("VlcPlayer")
|
||||
View(VlcPlayerView.self) {
|
||||
Prop("source") { (view: VlcPlayerView, source: [String: Any]) in
|
||||
view.setSource(source)
|
||||
}
|
||||
|
||||
Prop("paused") { (view: VlcPlayerView, paused: Bool) in
|
||||
if paused {
|
||||
view.pause()
|
||||
} else {
|
||||
view.play()
|
||||
}
|
||||
}
|
||||
|
||||
Events(
|
||||
"onPlaybackStateChanged",
|
||||
"onVideoStateChange",
|
||||
"onVideoLoadStart",
|
||||
"onVideoLoadEnd",
|
||||
"onVideoProgress",
|
||||
"onVideoError",
|
||||
"onPipStarted"
|
||||
)
|
||||
|
||||
AsyncFunction("startPictureInPicture") { (view: VlcPlayerView) in
|
||||
view.startPictureInPicture()
|
||||
}
|
||||
|
||||
AsyncFunction("play") { (view: VlcPlayerView) in
|
||||
view.play()
|
||||
}
|
||||
|
||||
AsyncFunction("pause") { (view: VlcPlayerView) in
|
||||
view.pause()
|
||||
}
|
||||
|
||||
AsyncFunction("stop") { (view: VlcPlayerView) in
|
||||
view.stop()
|
||||
}
|
||||
|
||||
AsyncFunction("seekTo") { (view: VlcPlayerView, time: Int32) in
|
||||
view.seekTo(time)
|
||||
}
|
||||
|
||||
AsyncFunction("setAudioTrack") { (view: VlcPlayerView, trackIndex: Int) in
|
||||
view.setAudioTrack(trackIndex)
|
||||
}
|
||||
|
||||
AsyncFunction("getAudioTracks") { (view: VlcPlayerView) -> [[String: Any]]? in
|
||||
return view.getAudioTracks()
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleTrack") { (view: VlcPlayerView, trackIndex: Int) in
|
||||
view.setSubtitleTrack(trackIndex)
|
||||
}
|
||||
|
||||
AsyncFunction("getSubtitleTracks") { (view: VlcPlayerView) -> [[String: Any]]? in
|
||||
return view.getSubtitleTracks()
|
||||
}
|
||||
|
||||
AsyncFunction("setSubtitleURL") {
|
||||
(view: VlcPlayerView, url: String, name: String) in
|
||||
view.setSubtitleURL(url, name: name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,483 +0,0 @@
|
||||
import ExpoModulesCore
|
||||
import UIKit
|
||||
import VLCKit
|
||||
import os
|
||||
|
||||
|
||||
public class VLCPlayerView: UIView {
|
||||
func setupView(parent: UIView) {
|
||||
self.backgroundColor = .black
|
||||
self.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
self.leadingAnchor.constraint(equalTo: parent.leadingAnchor),
|
||||
self.trailingAnchor.constraint(equalTo: parent.trailingAnchor),
|
||||
self.topAnchor.constraint(equalTo: parent.topAnchor),
|
||||
self.bottomAnchor.constraint(equalTo: parent.bottomAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
public override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
for subview in subviews {
|
||||
subview.frame = bounds
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class VLCPlayerWrapper: NSObject {
|
||||
private var lastProgressCall = Date().timeIntervalSince1970
|
||||
public var player: VLCMediaPlayer = VLCMediaPlayer()
|
||||
private var updatePlayerState: (() -> Void)?
|
||||
private var updateVideoProgress: (() -> Void)?
|
||||
private var playerView: VLCPlayerView = VLCPlayerView()
|
||||
public weak var pipController: VLCPictureInPictureWindowControlling?
|
||||
|
||||
override public init() {
|
||||
super.init()
|
||||
player.delegate = self
|
||||
player.drawable = self
|
||||
player.scaleFactor = 0
|
||||
}
|
||||
|
||||
public func setup(
|
||||
parent: UIView,
|
||||
updatePlayerState: (() -> Void)?,
|
||||
updateVideoProgress: (() -> Void)?
|
||||
) {
|
||||
self.updatePlayerState = updatePlayerState
|
||||
self.updateVideoProgress = updateVideoProgress
|
||||
|
||||
player.delegate = self
|
||||
parent.addSubview(playerView)
|
||||
playerView.setupView(parent: parent)
|
||||
}
|
||||
|
||||
public func getPlayerView() -> UIView {
|
||||
return playerView
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - VLCPictureInPictureDrawable
|
||||
extension VLCPlayerWrapper: VLCPictureInPictureDrawable {
|
||||
public func mediaController() -> (any VLCPictureInPictureMediaControlling)! {
|
||||
return self
|
||||
}
|
||||
|
||||
public func pictureInPictureReady() -> (((any VLCPictureInPictureWindowControlling)?) -> Void)!
|
||||
{
|
||||
return { [weak self] controller in
|
||||
self?.pipController = controller
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - VLCPictureInPictureMediaControlling
|
||||
extension VLCPlayerWrapper: VLCPictureInPictureMediaControlling {
|
||||
func mediaTime() -> Int64 {
|
||||
return player.time.value?.int64Value ?? 0
|
||||
}
|
||||
|
||||
func mediaLength() -> Int64 {
|
||||
return player.media?.length.value?.int64Value ?? 0
|
||||
}
|
||||
|
||||
func play() {
|
||||
player.play()
|
||||
}
|
||||
|
||||
func pause() {
|
||||
player.pause()
|
||||
}
|
||||
|
||||
func seek(by offset: Int64, completion: @escaping () -> Void) {
|
||||
player.jump(withOffset: Int32(offset), completion: completion)
|
||||
}
|
||||
|
||||
func isMediaSeekable() -> Bool {
|
||||
return player.isSeekable
|
||||
}
|
||||
|
||||
func isMediaPlaying() -> Bool {
|
||||
return player.isPlaying
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - VLCDrawable
|
||||
extension VLCPlayerWrapper: VLCDrawable {
|
||||
public func addSubview(_ view: UIView) {
|
||||
playerView.addSubview(view)
|
||||
}
|
||||
|
||||
public func bounds() -> CGRect {
|
||||
return playerView.bounds
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - VLCMediaPlayerDelegate
|
||||
extension VLCPlayerWrapper: VLCMediaPlayerDelegate {
|
||||
func mediaPlayerTimeChanged(_ aNotification: Notification) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
let timeNow = Date().timeIntervalSince1970
|
||||
if timeNow - self.lastProgressCall >= 1 {
|
||||
self.lastProgressCall = timeNow
|
||||
self.updateVideoProgress?()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mediaPlayerStateChanged(_ state: VLCMediaPlayerState) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.updatePlayerState?()
|
||||
|
||||
guard let pipController = self.pipController else { return }
|
||||
pipController.invalidatePlaybackState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - VLCMediaDelegate
|
||||
extension VLCPlayerWrapper: VLCMediaDelegate {
|
||||
// Implement VLCMediaDelegate methods if needed
|
||||
}
|
||||
|
||||
class VlcPlayerView: ExpoView {
|
||||
let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "VlcPlayerView")
|
||||
|
||||
private var vlc: VLCPlayerWrapper = VLCPlayerWrapper()
|
||||
private var progressUpdateInterval: TimeInterval = 1.0 // Update interval set to 1 second
|
||||
private var isPaused: Bool = false
|
||||
private var customSubtitles: [(internalName: String, originalName: String)] = []
|
||||
private var startPosition: Int32 = 0
|
||||
private var externalTrack: [String: String]?
|
||||
private var isStopping: Bool = false // Define isStopping here
|
||||
private var externalSubtitles: [[String: String]]?
|
||||
var hasSource = false
|
||||
|
||||
// MARK: - Initialization
|
||||
required init(appContext: AppContext? = nil) {
|
||||
super.init(appContext: appContext)
|
||||
setupVLC()
|
||||
setupNotifications()
|
||||
VLCManager.shared.listeners.append(self)
|
||||
}
|
||||
|
||||
// MARK: - Setup
|
||||
private func setupVLC() {
|
||||
vlc.setup(
|
||||
parent: self,
|
||||
updatePlayerState: updatePlayerState,
|
||||
updateVideoProgress: updateVideoProgress
|
||||
)
|
||||
}
|
||||
|
||||
private func setupNotifications() {
|
||||
NotificationCenter.default.addObserver(
|
||||
self, selector: #selector(applicationWillResignActive),
|
||||
name: UIApplication.willResignActiveNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(
|
||||
self, selector: #selector(applicationDidBecomeActive),
|
||||
name: UIApplication.didBecomeActiveNotification, object: nil)
|
||||
}
|
||||
|
||||
// MARK: - Public Methods
|
||||
func startPictureInPicture() {
|
||||
self.vlc.pipController?.stateChangeEventHandler = { (isStarted: Bool) in
|
||||
self.onPipStarted?(["pipStarted": isStarted])
|
||||
}
|
||||
self.vlc.pipController?.startPictureInPicture()
|
||||
}
|
||||
|
||||
@objc func play() {
|
||||
self.vlc.player.play()
|
||||
self.isPaused = false
|
||||
logger.debug("Play")
|
||||
}
|
||||
|
||||
@objc func pause() {
|
||||
self.vlc.player.pause()
|
||||
self.isPaused = true
|
||||
}
|
||||
|
||||
@objc func seekTo(_ time: Int32) {
|
||||
let wasPlaying = vlc.player.isPlaying
|
||||
if wasPlaying {
|
||||
self.pause()
|
||||
}
|
||||
|
||||
if let duration = vlc.player.media?.length.intValue {
|
||||
logger.debug("Seeking to time: \(time) Video Duration \(duration)")
|
||||
|
||||
// If the specified time is greater than the duration, seek to the end
|
||||
let seekTime = time > duration ? duration - 1000 : time
|
||||
vlc.player.time = VLCTime(int: seekTime)
|
||||
self.updatePlayerState()
|
||||
|
||||
// Let mediaPlayerStateChanged handle play state change
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
if wasPlaying {
|
||||
self.play()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.error("Unable to retrieve video duration")
|
||||
}
|
||||
}
|
||||
|
||||
@objc func setSource(_ source: [String: Any]) {
|
||||
logger.debug("Setting source...")
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
if self.hasSource {
|
||||
return
|
||||
}
|
||||
|
||||
var mediaOptions = source["mediaOptions"] as? [String: Any] ?? [:]
|
||||
self.externalTrack = source["externalTrack"] as? [String: String]
|
||||
let initOptions: [String] = source["initOptions"] as? [String] ?? []
|
||||
self.startPosition = source["startPosition"] as? Int32 ?? 0
|
||||
self.externalSubtitles = source["externalSubtitles"] as? [[String: String]]
|
||||
|
||||
for item in initOptions {
|
||||
let option = item.components(separatedBy: "=")
|
||||
mediaOptions.updateValue(
|
||||
option[1], forKey: option[0].replacingOccurrences(of: "--", with: ""))
|
||||
}
|
||||
|
||||
guard let uri = source["uri"] as? String, !uri.isEmpty else {
|
||||
logger.error("Invalid or empty URI")
|
||||
self.onVideoError?(["error": "Invalid or empty URI"])
|
||||
return
|
||||
}
|
||||
|
||||
let autoplay = source["autoplay"] as? Bool ?? false
|
||||
let isNetwork = source["isNetwork"] as? Bool ?? false
|
||||
|
||||
self.onVideoLoadStart?(["target": self.reactTag ?? NSNull()])
|
||||
|
||||
let media: VLCMedia!
|
||||
if isNetwork {
|
||||
logger.debug("Loading network file: \(uri)")
|
||||
media = VLCMedia(url: URL(string: uri)!)
|
||||
} else {
|
||||
logger.debug("Loading local file: \(uri)")
|
||||
if uri.starts(with: "file://"), let url = URL(string: uri) {
|
||||
media = VLCMedia(url: url)
|
||||
} else {
|
||||
media = VLCMedia(path: uri)
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug("Media options: \(mediaOptions)")
|
||||
media.addOptions(mediaOptions)
|
||||
|
||||
self.vlc.player.media = media
|
||||
self.setInitialExternalSubtitles()
|
||||
self.hasSource = true
|
||||
if autoplay {
|
||||
logger.info("Playing...")
|
||||
self.play()
|
||||
self.vlc.player.time = VLCTime(number: NSNumber(value: self.startPosition * 1000))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func setAudioTrack(_ trackIndex: Int) {
|
||||
print("Setting audio track: \(trackIndex)")
|
||||
let track = self.vlc.player.audioTracks[trackIndex]
|
||||
track.isSelectedExclusively = true
|
||||
}
|
||||
|
||||
@objc func getAudioTracks() -> [[String: Any]]? {
|
||||
return vlc.player.audioTracks.enumerated().map {
|
||||
return ["name": $1.trackName, "index": $0]
|
||||
}
|
||||
}
|
||||
|
||||
@objc func setSubtitleTrack(_ trackIndex: Int) {
|
||||
logger.debug("Attempting to set subtitle track to index: \(trackIndex)")
|
||||
if trackIndex == -1 {
|
||||
logger.debug("Disabling all subtitles")
|
||||
for track in self.vlc.player.textTracks {
|
||||
track.isSelected = false
|
||||
}
|
||||
return
|
||||
}
|
||||
let track = self.vlc.player.textTracks[trackIndex]
|
||||
track.isSelectedExclusively = true;
|
||||
logger.debug("Current subtitle track index after setting: \(track.trackName)")
|
||||
}
|
||||
|
||||
@objc func setSubtitleURL(_ subtitleURL: String, name: String) {
|
||||
guard let url = URL(string: subtitleURL) else {
|
||||
logger.error("Invalid subtitle URL")
|
||||
return
|
||||
}
|
||||
let result = self.vlc.player.addPlaybackSlave(url, type: .subtitle, enforce: false)
|
||||
if result == 0 {
|
||||
let internalName = "Track \(self.customSubtitles.count)"
|
||||
self.customSubtitles.append((internalName: internalName, originalName: name))
|
||||
logger.debug("Subtitle added with result: \(result) \(internalName)")
|
||||
} else {
|
||||
logger.debug("Failed to add subtitle")
|
||||
}
|
||||
}
|
||||
|
||||
@objc func getSubtitleTracks() -> [[String: Any]]? {
|
||||
if self.vlc.player.textTracks.count == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.debug("Number of subtitle tracks: \(self.vlc.player.textTracks.count)")
|
||||
|
||||
let tracks = self.vlc.player.textTracks.enumerated().map { (index, track) in
|
||||
if let customSubtitle = customSubtitles.first(where: {
|
||||
$0.internalName == track.trackName
|
||||
}) {
|
||||
return ["name": customSubtitle.originalName, "index": index]
|
||||
} else {
|
||||
return ["name": track.trackName, "index": index]
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug("Subtitle tracks: \(tracks)")
|
||||
return tracks
|
||||
}
|
||||
|
||||
@objc func stop(completion: (() -> Void)? = nil) {
|
||||
logger.debug("Stopping media...")
|
||||
guard !isStopping else {
|
||||
completion?()
|
||||
return
|
||||
}
|
||||
isStopping = true
|
||||
|
||||
// If we're not on the main thread, dispatch to main thread
|
||||
if !Thread.isMainThread {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.performStop(completion: completion)
|
||||
}
|
||||
} else {
|
||||
performStop(completion: completion)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
@objc private func applicationWillResignActive() {
|
||||
|
||||
}
|
||||
|
||||
@objc private func applicationDidBecomeActive() {
|
||||
|
||||
}
|
||||
|
||||
private func setInitialExternalSubtitles() {
|
||||
if let externalSubtitles = self.externalSubtitles {
|
||||
for subtitle in externalSubtitles {
|
||||
if let subtitleName = subtitle["name"],
|
||||
let subtitleURL = subtitle["DeliveryUrl"]
|
||||
{
|
||||
print("Setting external subtitle: \(subtitleName) \(subtitleURL)")
|
||||
self.setSubtitleURL(subtitleURL, name: subtitleName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func performStop(completion: (() -> Void)? = nil) {
|
||||
// Stop the media player
|
||||
vlc.player.stop()
|
||||
|
||||
// Remove observer
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
|
||||
// Clear the video view
|
||||
vlc.getPlayerView().removeFromSuperview()
|
||||
|
||||
isStopping = false
|
||||
completion?()
|
||||
}
|
||||
|
||||
private func updateVideoProgress() {
|
||||
guard let media = self.vlc.player.media else { return }
|
||||
|
||||
let currentTimeMs = self.vlc.player.time.intValue
|
||||
let durationMs = self.vlc.player.media?.length.intValue ?? 0
|
||||
|
||||
logger.debug("Current time: \(currentTimeMs)")
|
||||
self.onVideoProgress?([
|
||||
"currentTime": currentTimeMs,
|
||||
"duration": durationMs,
|
||||
])
|
||||
}
|
||||
|
||||
private func updatePlayerState() {
|
||||
let player = self.vlc.player
|
||||
self.onVideoStateChange?([
|
||||
"target": self.reactTag ?? NSNull(),
|
||||
"currentTime": player.time.intValue,
|
||||
"duration": player.media?.length.intValue ?? 0,
|
||||
"error": false,
|
||||
"isPlaying": player.isPlaying,
|
||||
"isBuffering": !player.isPlaying && player.state == VLCMediaPlayerState.buffering,
|
||||
"state": player.state.description,
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - Expo Events
|
||||
@objc var onPlaybackStateChanged: RCTDirectEventBlock?
|
||||
@objc var onVideoLoadStart: RCTDirectEventBlock?
|
||||
@objc var onVideoStateChange: RCTDirectEventBlock?
|
||||
@objc var onVideoProgress: RCTDirectEventBlock?
|
||||
@objc var onVideoLoadEnd: RCTDirectEventBlock?
|
||||
@objc var onVideoError: RCTDirectEventBlock?
|
||||
@objc var onPipStarted: RCTDirectEventBlock?
|
||||
|
||||
// MARK: - Deinitialization
|
||||
|
||||
deinit {
|
||||
logger.debug("Deinitialization")
|
||||
performStop()
|
||||
VLCManager.shared.listeners.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SimpleAppLifecycleListener
|
||||
extension VlcPlayerView: SimpleAppLifecycleListener {
|
||||
func applicationDidEnterBackground() {
|
||||
logger.debug("Entering background")
|
||||
}
|
||||
|
||||
func applicationDidEnterForeground() {
|
||||
logger.debug("Entering foreground, is player visible? \(self.vlc.getPlayerView().superview != nil)")
|
||||
if !self.vlc.getPlayerView().isDescendant(of: self) {
|
||||
logger.debug("Player view is missing. Adding back as subview")
|
||||
self.addSubview(self.vlc.getPlayerView())
|
||||
}
|
||||
|
||||
// Current solution to fixing black screen when re-entering application
|
||||
if let videoTrack = self.vlc.player.videoTracks.first { $0.isSelected == true }, !self.vlc.isMediaPlaying() {
|
||||
videoTrack.isSelected = false
|
||||
videoTrack.isSelectedExclusively = true
|
||||
self.vlc.player.play()
|
||||
self.vlc.player.pause()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension VLCMediaPlayerState {
|
||||
var description: String {
|
||||
switch self {
|
||||
case .opening: return "Opening"
|
||||
case .buffering: return "Buffering"
|
||||
case .playing: return "Playing"
|
||||
case .paused: return "Paused"
|
||||
case .stopped: return "Stopped"
|
||||
case .error: return "Error"
|
||||
@unknown default: return "Unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
export type PlaybackStatePayload = {
|
||||
nativeEvent: {
|
||||
target: number;
|
||||
state: "Opening" | "Buffering" | "Playing" | "Paused" | "Error";
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
isBuffering: boolean;
|
||||
isPlaying: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type ProgressUpdatePayload = {
|
||||
nativeEvent: {
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
isPlaying: boolean;
|
||||
isBuffering: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type VideoLoadStartPayload = {
|
||||
nativeEvent: {
|
||||
target: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type PipStartedPayload = {
|
||||
nativeEvent: {
|
||||
pipStarted: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type VideoStateChangePayload = PlaybackStatePayload;
|
||||
|
||||
export type VideoProgressPayload = ProgressUpdatePayload;
|
||||
|
||||
export type VlcPlayerSource = {
|
||||
uri: string;
|
||||
type?: string;
|
||||
isNetwork?: boolean;
|
||||
autoplay?: boolean;
|
||||
externalSubtitles: { name: string; DeliveryUrl: string }[];
|
||||
initOptions?: any[];
|
||||
mediaOptions?: { [key: string]: any };
|
||||
startPosition?: number;
|
||||
};
|
||||
|
||||
export type TrackInfo = {
|
||||
name: string;
|
||||
index: number;
|
||||
language?: string;
|
||||
};
|
||||
|
||||
export type ChapterInfo = {
|
||||
name: string;
|
||||
timeOffset: number;
|
||||
duration: number;
|
||||
};
|
||||
|
||||
export type VlcPlayerViewProps = {
|
||||
source: VlcPlayerSource;
|
||||
style?: Object;
|
||||
progressUpdateInterval?: number;
|
||||
paused?: boolean;
|
||||
muted?: boolean;
|
||||
volume?: number;
|
||||
videoAspectRatio?: string;
|
||||
onVideoProgress?: (event: ProgressUpdatePayload) => void;
|
||||
onVideoStateChange?: (event: PlaybackStatePayload) => void;
|
||||
onVideoLoadStart?: (event: VideoLoadStartPayload) => void;
|
||||
onVideoLoadEnd?: (event: VideoLoadStartPayload) => void;
|
||||
onVideoError?: (event: PlaybackStatePayload) => void;
|
||||
onPipStarted?: (event: PipStartedPayload) => void;
|
||||
};
|
||||
|
||||
export interface VlcPlayerViewRef {
|
||||
startPictureInPicture: () => Promise<void>;
|
||||
play: () => Promise<void>;
|
||||
pause: () => Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
seekTo: (time: number) => Promise<void>;
|
||||
setAudioTrack: (trackIndex: number) => Promise<void>;
|
||||
getAudioTracks: () => Promise<TrackInfo[] | null>;
|
||||
setSubtitleTrack: (trackIndex: number) => Promise<void>;
|
||||
getSubtitleTracks: () => Promise<TrackInfo[] | null>;
|
||||
setSubtitleDelay: (delay: number) => Promise<void>;
|
||||
setAudioDelay: (delay: number) => Promise<void>;
|
||||
takeSnapshot: (path: string, width: number, height: number) => Promise<void>;
|
||||
setRate: (rate: number) => Promise<void>;
|
||||
nextChapter: () => Promise<void>;
|
||||
previousChapter: () => Promise<void>;
|
||||
getChapters: () => Promise<ChapterInfo[] | null>;
|
||||
setVideoCropGeometry: (geometry: string | null) => Promise<void>;
|
||||
getVideoCropGeometry: () => Promise<string | null>;
|
||||
setSubtitleURL: (url: string, name: string) => Promise<void>;
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import { requireNativeViewManager } from "expo-modules-core";
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
VlcPlayerViewProps,
|
||||
VlcPlayerViewRef,
|
||||
VlcPlayerSource,
|
||||
} from "./VlcPlayer.types";
|
||||
|
||||
interface NativeViewRef extends VlcPlayerViewRef {
|
||||
setNativeProps?: (props: Partial<VlcPlayerViewProps>) => void;
|
||||
}
|
||||
|
||||
const NativeViewManager = requireNativeViewManager("VlcPlayer");
|
||||
|
||||
// Create a forwarded ref version of the native view
|
||||
const NativeView = React.forwardRef<NativeViewRef, VlcPlayerViewProps>(
|
||||
(props, ref) => <NativeViewManager {...props} ref={ref} />
|
||||
);
|
||||
|
||||
const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(
|
||||
(props, ref) => {
|
||||
const nativeRef = React.useRef<NativeViewRef>(null);
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
startPictureInPicture: async () => {
|
||||
await nativeRef.current?.startPictureInPicture()
|
||||
},
|
||||
play: async () => {
|
||||
await nativeRef.current?.play();
|
||||
},
|
||||
pause: async () => {
|
||||
await nativeRef.current?.pause();
|
||||
},
|
||||
stop: async () => {
|
||||
await nativeRef.current?.stop();
|
||||
},
|
||||
seekTo: async (time: number) => {
|
||||
await nativeRef.current?.seekTo(time);
|
||||
},
|
||||
setAudioTrack: async (trackIndex: number) => {
|
||||
await nativeRef.current?.setAudioTrack(trackIndex);
|
||||
},
|
||||
getAudioTracks: async () => {
|
||||
const tracks = await nativeRef.current?.getAudioTracks();
|
||||
return tracks ?? null;
|
||||
},
|
||||
setSubtitleTrack: async (trackIndex: number) => {
|
||||
await nativeRef.current?.setSubtitleTrack(trackIndex);
|
||||
},
|
||||
getSubtitleTracks: async () => {
|
||||
const tracks = await nativeRef.current?.getSubtitleTracks();
|
||||
return tracks ?? null;
|
||||
},
|
||||
setSubtitleDelay: async (delay: number) => {
|
||||
await nativeRef.current?.setSubtitleDelay(delay);
|
||||
},
|
||||
setAudioDelay: async (delay: number) => {
|
||||
await nativeRef.current?.setAudioDelay(delay);
|
||||
},
|
||||
takeSnapshot: async (path: string, width: number, height: number) => {
|
||||
await nativeRef.current?.takeSnapshot(path, width, height);
|
||||
},
|
||||
setRate: async (rate: number) => {
|
||||
await nativeRef.current?.setRate(rate);
|
||||
},
|
||||
nextChapter: async () => {
|
||||
await nativeRef.current?.nextChapter();
|
||||
},
|
||||
previousChapter: async () => {
|
||||
await nativeRef.current?.previousChapter();
|
||||
},
|
||||
getChapters: async () => {
|
||||
const chapters = await nativeRef.current?.getChapters();
|
||||
return chapters ?? null;
|
||||
},
|
||||
setVideoCropGeometry: async (geometry: string | null) => {
|
||||
await nativeRef.current?.setVideoCropGeometry(geometry);
|
||||
},
|
||||
getVideoCropGeometry: async () => {
|
||||
const geometry = await nativeRef.current?.getVideoCropGeometry();
|
||||
return geometry ?? null;
|
||||
},
|
||||
setSubtitleURL: async (url: string, name: string) => {
|
||||
await nativeRef.current?.setSubtitleURL(url, name);
|
||||
},
|
||||
}));
|
||||
|
||||
const {
|
||||
source,
|
||||
style,
|
||||
progressUpdateInterval = 500,
|
||||
paused,
|
||||
muted,
|
||||
volume,
|
||||
videoAspectRatio,
|
||||
onVideoLoadStart,
|
||||
onVideoStateChange,
|
||||
onVideoProgress,
|
||||
onVideoLoadEnd,
|
||||
onVideoError,
|
||||
onPipStarted,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
const processedSource: VlcPlayerSource =
|
||||
typeof source === "string" ? { uri: source } : source;
|
||||
|
||||
if (processedSource.startPosition !== undefined) {
|
||||
processedSource.startPosition = Math.floor(processedSource.startPosition);
|
||||
}
|
||||
|
||||
return (
|
||||
<NativeView
|
||||
{...otherProps}
|
||||
ref={nativeRef}
|
||||
source={processedSource}
|
||||
style={[{ width: "100%", height: "100%" }, style]}
|
||||
progressUpdateInterval={progressUpdateInterval}
|
||||
paused={paused}
|
||||
muted={muted}
|
||||
volume={volume}
|
||||
videoAspectRatio={videoAspectRatio}
|
||||
onVideoLoadStart={onVideoLoadStart}
|
||||
onVideoLoadEnd={onVideoLoadEnd}
|
||||
onVideoStateChange={onVideoStateChange}
|
||||
onVideoProgress={onVideoProgress}
|
||||
onVideoError={onVideoError}
|
||||
onPipStarted={onPipStarted}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default VlcPlayerView;
|
||||
@@ -1,32 +0,0 @@
|
||||
import ExpoModulesCore
|
||||
|
||||
protocol SimpleAppLifecycleListener {
|
||||
func applicationDidEnterBackground() -> Void
|
||||
func applicationDidEnterForeground() -> Void
|
||||
}
|
||||
|
||||
public class AppLifecycleDelegate: ExpoAppDelegateSubscriber {
|
||||
public func applicationDidBecomeActive(_ application: UIApplication) {
|
||||
// The app has become active.
|
||||
}
|
||||
|
||||
public func applicationWillResignActive(_ application: UIApplication) {
|
||||
// The app is about to become inactive.
|
||||
}
|
||||
|
||||
public func applicationDidEnterBackground(_ application: UIApplication) {
|
||||
VLCManager.shared.listeners.forEach { listener in
|
||||
listener.applicationDidEnterBackground()
|
||||
}
|
||||
}
|
||||
|
||||
public func applicationWillEnterForeground(_ application: UIApplication) {
|
||||
VLCManager.shared.listeners.forEach { listener in
|
||||
listener.applicationDidEnterForeground()
|
||||
}
|
||||
}
|
||||
|
||||
public func applicationWillTerminate(_ application: UIApplication) {
|
||||
// The app is about to terminate.
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
class VLCManager {
|
||||
static let shared = VLCManager()
|
||||
var listeners: [SimpleAppLifecycleListener] = []
|
||||
}
|
||||
@@ -1,19 +1,18 @@
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'VlcPlayer'
|
||||
s.version = '4.0.0a10'
|
||||
s.summary = 'A sample project summary'
|
||||
s.description = 'A sample project description'
|
||||
s.name = 'WifiSsid'
|
||||
s.version = '1.0.0'
|
||||
s.summary = 'Get WiFi SSID on iOS'
|
||||
s.description = 'Native iOS module to get current WiFi SSID using NEHotspotNetwork'
|
||||
s.author = ''
|
||||
s.homepage = 'https://docs.expo.dev/modules/'
|
||||
s.platforms = { :ios => '13.4', :tvos => '16' }
|
||||
s.platforms = { :ios => '15.6', :tvos => '15.0' }
|
||||
s.source = { git: '' }
|
||||
s.static_framework = true
|
||||
|
||||
s.dependency 'ExpoModulesCore'
|
||||
s.ios.dependency 'VLCKit', s.version
|
||||
s.tvos.dependency 'VLCKit', s.version
|
||||
|
||||
# Swift/Objective-C compatibility
|
||||
s.frameworks = 'NetworkExtension', 'SystemConfiguration'
|
||||
|
||||
s.pod_target_xcconfig = {
|
||||
'DEFINES_MODULE' => 'YES',
|
||||
'SWIFT_COMPILATION_MODE' => 'wholemodule'
|
||||
|
||||
233
package.json
233
package.json
@@ -6,131 +6,160 @@
|
||||
"submodule-reload": "git submodule update --init --remote --recursive",
|
||||
"clean": "echo y | expo prebuild --clean",
|
||||
"start": "bun run submodule-reload && expo start",
|
||||
"ios": "EXPO_TV=0 expo run:ios",
|
||||
"ios:tv": "EXPO_TV=1 expo run:ios",
|
||||
"android": "EXPO_TV=0 expo run:android",
|
||||
"android:tv": "EXPO_TV=1 expo run:android",
|
||||
"prebuild": "EXPO_TV=0 bun run clean",
|
||||
"prebuild:tv": "EXPO_TV=1 bun run clean",
|
||||
"prebuild:tv-new": "EXPO_TV=1 node ./scripts/symlink-native-dirs.js; bun run prebuild:tv",
|
||||
"test": "jest --watchAll",
|
||||
"lint": "expo lint",
|
||||
"prebuild": "cross-env EXPO_TV=0 bun run clean",
|
||||
"prebuild:tv": "cross-env EXPO_TV=1 bun run clean",
|
||||
"ios:install-metal-toolchain": "bash scripts/ios/install-metal-toolchain.sh",
|
||||
"ios": "cross-env EXPO_TV=0 expo run:ios",
|
||||
"ios:tv": "cross-env EXPO_TV=1 expo run:ios",
|
||||
"android": "cross-env EXPO_TV=0 expo run:android",
|
||||
"android:tv": "cross-env EXPO_TV=1 expo run:android",
|
||||
"build:android:local": "cd android && cross-env NODE_ENV=production ./gradlew assembleRelease",
|
||||
"ios:unsigned-build": "cross-env EXPO_TV=0 bun scripts/ios/build-ios.ts --production",
|
||||
"prepare": "husky",
|
||||
"typecheck": "node scripts/typecheck.js",
|
||||
"check": "biome check . --max-diagnostics 1000",
|
||||
"lint": "biome check --write --unsafe --max-diagnostics 1000",
|
||||
"format": "biome format --write .",
|
||||
"doctor": "expo-doctor",
|
||||
"test": "bun run typecheck && bun run lint && bun run format && bun run doctor",
|
||||
"postinstall": "patch-package"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bottom-tabs/react-navigation": "0.8.6",
|
||||
"@config-plugins/ffmpeg-kit-react-native": "^9.0.0",
|
||||
"@expo/config-plugins": "~9.0.15",
|
||||
"@expo/react-native-action-sheet": "^4.1.0",
|
||||
"@expo/vector-icons": "^14.0.4",
|
||||
"@futurejj/react-native-visibility-sensor": "^1.3.10",
|
||||
"@gorhom/bottom-sheet": "^5.1.0",
|
||||
"@jellyfin/sdk": "^0.11.0",
|
||||
"@kesha-antonov/react-native-background-downloader": "3.2.6",
|
||||
"@react-native-community/netinfo": "11.4.1",
|
||||
"@react-native-menu/menu": "^1.2.2",
|
||||
"@react-navigation/bottom-tabs": "^7.2.0",
|
||||
"@react-navigation/material-top-tabs": "^7.1.0",
|
||||
"@bottom-tabs/react-navigation": "1.1.0",
|
||||
"@douglowder/expo-av-route-picker-view": "^0.0.5",
|
||||
"@expo/metro-runtime": "~6.1.1",
|
||||
"@expo/react-native-action-sheet": "^4.1.1",
|
||||
"@expo/ui": "0.2.0-beta.9",
|
||||
"@expo/vector-icons": "^15.0.3",
|
||||
"@gorhom/bottom-sheet": "5.2.8",
|
||||
"@jellyfin/sdk": "^0.13.0",
|
||||
"@react-native-community/netinfo": "^11.4.1",
|
||||
"@react-navigation/material-top-tabs": "7.4.9",
|
||||
"@react-navigation/native": "^7.0.14",
|
||||
"@shopify/flash-list": "1.7.3",
|
||||
"@tanstack/react-query": "^5.66.0",
|
||||
"add": "^2.0.6",
|
||||
"@shopify/flash-list": "2.0.2",
|
||||
"@tanstack/query-sync-storage-persister": "^5.90.18",
|
||||
"@tanstack/react-pacer": "^0.19.1",
|
||||
"@tanstack/react-query": "5.90.17",
|
||||
"@tanstack/react-query-persist-client": "^5.90.18",
|
||||
"axios": "^1.7.9",
|
||||
"expo": "^52.0.31",
|
||||
"expo-asset": "~11.0.3",
|
||||
"expo-background-fetch": "~13.0.5",
|
||||
"expo-blur": "~14.0.3",
|
||||
"expo-brightness": "~13.0.3",
|
||||
"expo-build-properties": "~0.13.2",
|
||||
"expo-constants": "~17.0.5",
|
||||
"expo-crypto": "~14.0.2",
|
||||
"expo-dev-client": "~5.0.11",
|
||||
"expo-device": "~7.0.2",
|
||||
"expo-font": "~13.0.3",
|
||||
"expo-haptics": "~14.0.1",
|
||||
"expo-image": "~2.0.4",
|
||||
"expo-keep-awake": "~14.0.2",
|
||||
"expo-linear-gradient": "~14.0.2",
|
||||
"expo-linking": "~7.0.5",
|
||||
"expo-localization": "~16.0.1",
|
||||
"expo-network": "~7.0.5",
|
||||
"expo-notifications": "~0.29.13",
|
||||
"expo-router": "~4.0.17",
|
||||
"expo-screen-orientation": "~8.0.4",
|
||||
"expo-sensors": "~14.0.2",
|
||||
"expo-splash-screen": "~0.29.22",
|
||||
"expo-status-bar": "~2.0.1",
|
||||
"expo-system-ui": "~4.0.8",
|
||||
"expo-task-manager": "~12.0.5",
|
||||
"expo-updates": "~0.26.17",
|
||||
"expo-web-browser": "~14.0.2",
|
||||
"ffmpeg-kit-react-native": "^6.0.2",
|
||||
"i18next": "^24.2.2",
|
||||
"jotai": "^2.11.3",
|
||||
"lodash": "^4.17.21",
|
||||
"expo": "~54.0.31",
|
||||
"expo-application": "~7.0.8",
|
||||
"expo-asset": "~12.0.12",
|
||||
"expo-background-task": "~1.0.10",
|
||||
"expo-blur": "~15.0.8",
|
||||
"expo-brightness": "~14.0.8",
|
||||
"expo-build-properties": "~1.0.10",
|
||||
"expo-constants": "18.0.13",
|
||||
"expo-crypto": "^15.0.8",
|
||||
"expo-dev-client": "~6.0.20",
|
||||
"expo-device": "~8.0.10",
|
||||
"expo-font": "~14.0.10",
|
||||
"expo-haptics": "~15.0.8",
|
||||
"expo-image": "~3.0.11",
|
||||
"expo-linear-gradient": "~15.0.8",
|
||||
"expo-linking": "~8.0.11",
|
||||
"expo-localization": "~17.0.8",
|
||||
"expo-location": "^19.0.8",
|
||||
"expo-notifications": "~0.32.16",
|
||||
"expo-router": "~6.0.21",
|
||||
"expo-screen-orientation": "~9.0.8",
|
||||
"expo-secure-store": "^15.0.8",
|
||||
"expo-sharing": "~14.0.8",
|
||||
"expo-splash-screen": "~31.0.13",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"expo-system-ui": "~6.0.9",
|
||||
"expo-task-manager": "14.0.9",
|
||||
"expo-web-browser": "~15.0.10",
|
||||
"i18next": "^25.0.0",
|
||||
"jotai": "2.16.2",
|
||||
"lodash": "4.17.21",
|
||||
"nativewind": "^2.0.11",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-i18next": "^15.4.0",
|
||||
"react-native": "npm:react-native-tvos@~0.77.0-0",
|
||||
"patch-package": "^8.0.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-i18next": "16.5.3",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-awesome-slider": "^2.9.0",
|
||||
"react-native-bottom-tabs": "0.8.6",
|
||||
"react-native-bottom-tabs": "1.1.0",
|
||||
"react-native-circular-progress": "^1.4.1",
|
||||
"react-native-compressor": "^1.10.3",
|
||||
"react-native-collapsible": "^1.6.2",
|
||||
"react-native-country-flag": "^2.0.2",
|
||||
"react-native-device-info": "^14.0.4",
|
||||
"react-native-edge-to-edge": "^1.4.3",
|
||||
"react-native-gesture-handler": "2.22.0",
|
||||
"react-native-get-random-values": "^1.11.0",
|
||||
"react-native-google-cast": "^4.8.3",
|
||||
"react-native-device-info": "^15.0.0",
|
||||
"react-native-draggable-flatlist": "^4.0.3",
|
||||
"react-native-edge-to-edge": "^1.7.0",
|
||||
"react-native-gesture-handler": "2.28.0",
|
||||
"react-native-glass-effect-view": "^1.0.0",
|
||||
"react-native-google-cast": "^4.9.1",
|
||||
"react-native-image-colors": "^2.4.0",
|
||||
"react-native-ios-context-menu": "^3.1.0",
|
||||
"react-native-ios-utilities": "5.1.1",
|
||||
"react-native-mmkv": "^2.12.2",
|
||||
"react-native-pager-view": "6.5.1",
|
||||
"react-native-progress": "^5.0.1",
|
||||
"react-native-reanimated": "~3.16.7",
|
||||
"react-native-reanimated-carousel": "3.5.1",
|
||||
"react-native-safe-area-context": "5.1.0",
|
||||
"react-native-screens": "~4.5.0",
|
||||
"react-native-svg": "15.11.1",
|
||||
"react-native-tab-view": "^4.0.5",
|
||||
"react-native-ios-context-menu": "^3.2.1",
|
||||
"react-native-ios-utilities": "5.2.0",
|
||||
"react-native-mmkv": "4.1.1",
|
||||
"react-native-nitro-modules": "0.33.1",
|
||||
"react-native-pager-view": "^6.9.1",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
"react-native-reanimated-carousel": "4.0.3",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.18.0",
|
||||
"react-native-svg": "15.12.1",
|
||||
"react-native-text-ticker": "^1.15.0",
|
||||
"react-native-track-player": "github:lovegaoshi/react-native-track-player#APM",
|
||||
"react-native-udp": "^4.1.7",
|
||||
"react-native-uitextview": "^1.4.0",
|
||||
"react-native-url-polyfill": "^2.0.0",
|
||||
"react-native-uuid": "^2.0.3",
|
||||
"react-native-video": "6.10.0",
|
||||
"react-native-volume-manager": "^2.0.8",
|
||||
"react-native-web": "~0.19.13",
|
||||
"react-native-webview": "13.13.2",
|
||||
"sonner-native": "^0.17.0",
|
||||
"react-native-web": "^0.21.0",
|
||||
"react-native-worklets": "0.5.1",
|
||||
"sonner-native": "0.21.2",
|
||||
"tailwindcss": "3.3.2",
|
||||
"use-debounce": "^10.0.4",
|
||||
"uuid": "^11.0.5",
|
||||
"zeego": "^2.0.4",
|
||||
"zod": "^3.24.1"
|
||||
"zod": "4.1.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.26.8",
|
||||
"@react-native-community/cli": "15.1.3",
|
||||
"@react-native-tvos/config-tv": "^0.1.1",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/react": "~18.3.12",
|
||||
"@types/react-test-renderer": "^19.0.0",
|
||||
"patch-package": "^8.0.0",
|
||||
"postinstall-postinstall": "^2.1.0",
|
||||
"react-test-renderer": "19.0.0",
|
||||
"typescript": "~5.7.3",
|
||||
"@types/lodash": "^4.17.15",
|
||||
"@types/react-native-vector-icons": "^6.4.18",
|
||||
"@types/uuid": "^10.0.0"
|
||||
"@babel/core": "7.28.6",
|
||||
"@biomejs/biome": "2.3.11",
|
||||
"@react-native-community/cli": "20.1.0",
|
||||
"@react-native-tvos/config-tv": "0.1.4",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/lodash": "4.17.23",
|
||||
"@types/react": "19.1.17",
|
||||
"@types/react-test-renderer": "19.1.0",
|
||||
"cross-env": "10.1.0",
|
||||
"expo-doctor": "1.17.14",
|
||||
"husky": "9.1.7",
|
||||
"lint-staged": "16.2.7",
|
||||
"react-test-renderer": "19.2.3",
|
||||
"typescript": "5.9.3"
|
||||
},
|
||||
"private": true,
|
||||
"expo": {
|
||||
"doctor": {
|
||||
"reactNativeDirectoryCheck": {
|
||||
"exclude": [
|
||||
"react-native-google-cast",
|
||||
"react-native-udp",
|
||||
"@jellyfin/sdk"
|
||||
],
|
||||
"listUnknownPackages": false
|
||||
}
|
||||
},
|
||||
"install": {
|
||||
"exclude": [
|
||||
"react-native"
|
||||
"react-native-screens"
|
||||
]
|
||||
}
|
||||
},
|
||||
"private": true,
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx}": [
|
||||
"biome check --write --unsafe --no-errors-on-unmatched"
|
||||
],
|
||||
"*.json": [
|
||||
"biome format --write"
|
||||
]
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"unrs-resolver"
|
||||
],
|
||||
"resolutions": {
|
||||
"expo-constants": "18.0.13"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,38 +1,42 @@
|
||||
const { withAndroidManifest: NativeAndroidManifest } = require("@expo/config-plugins");
|
||||
const { withAndroidManifest } = require("expo/config-plugins");
|
||||
|
||||
const withAndroidManifest = (config) =>
|
||||
NativeAndroidManifest(config, async (config) => {
|
||||
const mainApplication = config.modResults.manifest.application[0];
|
||||
const _withGoogleCastAndroidManifest = (config) =>
|
||||
withAndroidManifest(config, async (mod) => {
|
||||
const mainApplication = mod.modResults.manifest.application[0];
|
||||
|
||||
// Initialize activity array if it doesn't exist
|
||||
if (!mainApplication.activity) {
|
||||
mainApplication.activity = [];
|
||||
}
|
||||
|
||||
const googleCastActivityExists = mainApplication.activity.some(activity =>
|
||||
activity.$?.["android:name"] === "com.reactnative.googlecast.RNGCExpandedControllerActivity"
|
||||
const googleCastActivityExists = mainApplication.activity.some(
|
||||
(activity) =>
|
||||
activity.$?.["android:name"] ===
|
||||
"com.reactnative.googlecast.RNGCExpandedControllerActivity",
|
||||
);
|
||||
|
||||
// Only add the activity if it doesn't already exist
|
||||
if (!googleCastActivityExists) {
|
||||
mainApplication.activity.push({
|
||||
$: {
|
||||
"android:name": "com.reactnative.googlecast.RNGCExpandedControllerActivity",
|
||||
"android:name":
|
||||
"com.reactnative.googlecast.RNGCExpandedControllerActivity",
|
||||
"android:theme": "@style/Theme.MaterialComponents.NoActionBar",
|
||||
"android:launchMode": "singleTask",
|
||||
"android:exported": "false",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const mainActivity = mainApplication.activity.find(activity =>
|
||||
activity.$?.["android:name"] === ".MainActivity"
|
||||
const mainActivity = mainApplication.activity.find(
|
||||
(activity) => activity.$?.["android:name"] === ".MainActivity",
|
||||
);
|
||||
|
||||
if (mainActivity) {
|
||||
mainActivity.$["android:supportsPictureInPicture"] = "true"
|
||||
if (mainActivity?.$) {
|
||||
mainActivity.$["android:supportsPictureInPicture"] = "true";
|
||||
}
|
||||
|
||||
return config;
|
||||
return mod;
|
||||
});
|
||||
|
||||
module.exports = withAndroidManifest;
|
||||
module.exports = _withGoogleCastAndroidManifest;
|
||||
|
||||
@@ -1,749 +1,199 @@
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import useImageStorage from "@/hooks/useImageStorage";
|
||||
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
|
||||
import { getOrSetDeviceId } from "@/utils/device";
|
||||
import useDownloadHelper from "@/utils/download";
|
||||
import { getItemImage } from "@/utils/getItemImage";
|
||||
import { useLog, writeToLog } from "@/utils/log";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import {
|
||||
cancelAllJobs,
|
||||
cancelJobById,
|
||||
deleteDownloadItemInfoFromDiskTmp,
|
||||
getAllJobsByDeviceId,
|
||||
getDownloadItemInfoFromDiskTmp,
|
||||
JobStatus,
|
||||
} from "@/utils/optimize-server";
|
||||
import {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import BackGroundDownloader from "@kesha-antonov/react-native-background-downloader";
|
||||
import { focusManager, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
import * as Application from "expo-application";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import { FileInfo } from "expo-file-system";
|
||||
import Notifications from "expo-notifications";
|
||||
import { useRouter } from "expo-router";
|
||||
import { Directory, Paths } from "expo-file-system";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AppState, AppStateStatus, Platform } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { createContext, useCallback, useContext, useMemo, useRef } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import {
|
||||
getAllDownloadedItems,
|
||||
getDownloadedItemById,
|
||||
getDownloadsDatabase,
|
||||
updateDownloadedItem,
|
||||
} from "./Downloads/database";
|
||||
import { getDownloadedItemSize } from "./Downloads/fileOperations";
|
||||
import { useDownloadEventHandlers } from "./Downloads/hooks/useDownloadEventHandlers";
|
||||
import { useDownloadOperations } from "./Downloads/hooks/useDownloadOperations";
|
||||
import type { JobStatus } from "./Downloads/types";
|
||||
import { apiAtom } from "./JellyfinProvider";
|
||||
|
||||
export type DownloadedItem = {
|
||||
item: Partial<BaseItemDto>;
|
||||
mediaSource: MediaSourceInfo;
|
||||
};
|
||||
|
||||
export const processesAtom = atom<JobStatus[]>([]);
|
||||
|
||||
function onAppStateChange(status: AppStateStatus) {
|
||||
focusManager.setFocused(status === "active");
|
||||
}
|
||||
export const downloadsRefreshAtom = atom<number>(0);
|
||||
|
||||
const DownloadContext = createContext<ReturnType<
|
||||
typeof useDownloadProvider
|
||||
> | null>(null);
|
||||
|
||||
function useDownloadProvider() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
const [settings] = useSettings();
|
||||
const router = useRouter();
|
||||
const [api] = useAtom(apiAtom);
|
||||
const { logs } = useLog();
|
||||
|
||||
const { saveSeriesPrimaryImage } = useDownloadHelper();
|
||||
const { saveImage } = useImageStorage();
|
||||
|
||||
const [processes, setProcesses] = useAtom<JobStatus[]>(processesAtom);
|
||||
|
||||
const [refreshKey, setRefreshKey] = useAtom(downloadsRefreshAtom);
|
||||
const successHapticFeedback = useHaptic("success");
|
||||
|
||||
// Track task ID to process ID mapping
|
||||
const taskMapRef = useRef<Map<number | string, string>>(new Map());
|
||||
|
||||
// Reactive downloaded items that updates when refreshKey changes
|
||||
const downloadedItems = useMemo(() => {
|
||||
return getAllDownloadedItems();
|
||||
}, [refreshKey]);
|
||||
|
||||
// Trigger refresh of download lists
|
||||
const triggerRefresh = useCallback(() => {
|
||||
setRefreshKey((prev) => prev + 1);
|
||||
}, [setRefreshKey]);
|
||||
|
||||
const authHeader = useMemo(() => {
|
||||
return api?.accessToken;
|
||||
}, [api]);
|
||||
|
||||
const { data: downloadedFiles, refetch } = useQuery({
|
||||
queryKey: ["downloadedItems"],
|
||||
queryFn: getAllDownloadedItems,
|
||||
staleTime: 0,
|
||||
refetchOnMount: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
const APP_CACHE_DOWNLOAD_DIRECTORY = new Directory(
|
||||
Paths.cache,
|
||||
`${Application.applicationId}/Downloads/`,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = AppState.addEventListener("change", onAppStateChange);
|
||||
const updateProcess = useCallback(
|
||||
(
|
||||
processId: string,
|
||||
updater:
|
||||
| Partial<JobStatus>
|
||||
| ((current: JobStatus) => Partial<JobStatus>),
|
||||
) => {
|
||||
setProcesses((prev) => {
|
||||
const processIndex = prev.findIndex((p) => p.id === processId);
|
||||
if (processIndex === -1) return prev;
|
||||
|
||||
return () => subscription.remove();
|
||||
}, []);
|
||||
const currentProcess = prev[processIndex];
|
||||
if (!currentProcess) return prev;
|
||||
|
||||
useQuery({
|
||||
queryKey: ["jobs"],
|
||||
queryFn: async () => {
|
||||
const deviceId = await getOrSetDeviceId();
|
||||
const url = settings?.optimizedVersionsServerUrl;
|
||||
const newStatus =
|
||||
typeof updater === "function" ? updater(currentProcess) : updater;
|
||||
|
||||
if (
|
||||
settings?.downloadMethod !== DownloadMethod.Optimized ||
|
||||
!url ||
|
||||
!deviceId ||
|
||||
!authHeader
|
||||
)
|
||||
return [];
|
||||
// Create new array with updated process
|
||||
const newProcesses = [...prev];
|
||||
newProcesses[processIndex] = {
|
||||
...currentProcess,
|
||||
...newStatus,
|
||||
};
|
||||
|
||||
const jobs = await getAllJobsByDeviceId({
|
||||
deviceId,
|
||||
authHeader,
|
||||
url,
|
||||
return newProcesses;
|
||||
});
|
||||
|
||||
const downloadingProcesses = processes
|
||||
.filter((p) => p.status === "downloading")
|
||||
.filter((p) => jobs.some((j) => j.id === p.id));
|
||||
|
||||
const updatedProcesses = jobs.filter(
|
||||
(j) => !downloadingProcesses.some((p) => p.id === j.id)
|
||||
);
|
||||
|
||||
setProcesses([...updatedProcesses, ...downloadingProcesses]);
|
||||
|
||||
for (let job of jobs) {
|
||||
const process = processes.find((p) => p.id === job.id);
|
||||
if (
|
||||
process &&
|
||||
process.status === "optimizing" &&
|
||||
job.status === "completed"
|
||||
) {
|
||||
if (settings.autoDownload) {
|
||||
startDownload(job);
|
||||
} else {
|
||||
toast.info(
|
||||
t("home.downloads.toasts.item_is_ready_to_be_downloaded", {
|
||||
item: job.item.Name,
|
||||
}),
|
||||
{
|
||||
action: {
|
||||
label: t("home.downloads.toasts.go_to_downloads"),
|
||||
onClick: () => {
|
||||
router.push("/downloads");
|
||||
toast.dismiss();
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: job.item.Name,
|
||||
body: `${job.item.Name} is ready to be downloaded`,
|
||||
data: {
|
||||
url: `/downloads`,
|
||||
},
|
||||
},
|
||||
trigger: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return jobs;
|
||||
},
|
||||
staleTime: 0,
|
||||
refetchInterval: 2000,
|
||||
enabled: settings?.downloadMethod === DownloadMethod.Optimized,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const checkIfShouldStartDownload = async () => {
|
||||
if (processes.length === 0) return;
|
||||
await BackGroundDownloader?.checkForExistingDownloads();
|
||||
};
|
||||
|
||||
checkIfShouldStartDownload();
|
||||
}, [settings, processes]);
|
||||
[setProcesses],
|
||||
);
|
||||
|
||||
const removeProcess = useCallback(
|
||||
async (id: string) => {
|
||||
const deviceId = await getOrSetDeviceId();
|
||||
if (!deviceId || !authHeader || !settings?.optimizedVersionsServerUrl)
|
||||
return;
|
||||
(id: string) => {
|
||||
// Use setTimeout to defer removal and avoid race conditions during rendering
|
||||
setTimeout(() => {
|
||||
setProcesses((prev) => prev.filter((process) => process.id !== id));
|
||||
|
||||
try {
|
||||
await cancelJobById({
|
||||
authHeader,
|
||||
id,
|
||||
url: settings?.optimizedVersionsServerUrl,
|
||||
// Find and remove from task map
|
||||
taskMapRef.current.forEach((processId, taskId) => {
|
||||
if (processId === id) {
|
||||
taskMapRef.current.delete(taskId);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}, 0);
|
||||
},
|
||||
[settings?.optimizedVersionsServerUrl, authHeader]
|
||||
[setProcesses],
|
||||
);
|
||||
|
||||
const APP_CACHE_DOWNLOAD_DIRECTORY = `${FileSystem.cacheDirectory}${Application.applicationId}/Downloads/`;
|
||||
// Set up download event handlers
|
||||
useDownloadEventHandlers({
|
||||
taskMapRef,
|
||||
processes,
|
||||
updateProcess,
|
||||
removeProcess,
|
||||
onSuccess: successHapticFeedback,
|
||||
onDataChange: triggerRefresh,
|
||||
api: api || undefined,
|
||||
});
|
||||
|
||||
const startDownload = useCallback(
|
||||
async (process: JobStatus) => {
|
||||
if (!process?.item.Id || !authHeader) throw new Error("No item id");
|
||||
|
||||
setProcesses((prev) =>
|
||||
prev.map((p) =>
|
||||
p.id === process.id
|
||||
? {
|
||||
...p,
|
||||
speed: undefined,
|
||||
status: "downloading",
|
||||
progress: 0,
|
||||
}
|
||||
: p
|
||||
)
|
||||
);
|
||||
|
||||
BackGroundDownloader?.setConfig({
|
||||
isLogsEnabled: true,
|
||||
progressInterval: 500,
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
},
|
||||
});
|
||||
|
||||
toast.info(
|
||||
t("home.downloads.toasts.download_stated_for_item", {
|
||||
item: process.item.Name,
|
||||
}),
|
||||
{
|
||||
action: {
|
||||
label: t("home.downloads.toasts.go_to_downloads"),
|
||||
onClick: () => {
|
||||
router.push("/downloads");
|
||||
toast.dismiss();
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const baseDirectory = FileSystem.documentDirectory;
|
||||
|
||||
BackGroundDownloader?.download({
|
||||
id: process.id,
|
||||
url: settings?.optimizedVersionsServerUrl + "download/" + process.id,
|
||||
destination: `${baseDirectory}/${process.item.Id}.mp4`,
|
||||
})
|
||||
.begin(() => {
|
||||
setProcesses((prev) =>
|
||||
prev.map((p) =>
|
||||
p.id === process.id
|
||||
? {
|
||||
...p,
|
||||
speed: undefined,
|
||||
status: "downloading",
|
||||
progress: 0,
|
||||
}
|
||||
: p
|
||||
)
|
||||
);
|
||||
})
|
||||
.progress((data) => {
|
||||
const percent = (data.bytesDownloaded / data.bytesTotal) * 100;
|
||||
setProcesses((prev) =>
|
||||
prev.map((p) =>
|
||||
p.id === process.id
|
||||
? {
|
||||
...p,
|
||||
speed: undefined,
|
||||
status: "downloading",
|
||||
progress: percent,
|
||||
}
|
||||
: p
|
||||
)
|
||||
);
|
||||
})
|
||||
.done(async (doneHandler) => {
|
||||
await saveDownloadedItemInfo(
|
||||
process.item,
|
||||
doneHandler.bytesDownloaded
|
||||
);
|
||||
toast.success(
|
||||
t("home.downloads.toasts.download_completed_for_item", {
|
||||
item: process.item.Name,
|
||||
}),
|
||||
{
|
||||
duration: 3000,
|
||||
action: {
|
||||
label: t("home.downloads.toasts.go_to_downloads"),
|
||||
onClick: () => {
|
||||
router.push("/downloads");
|
||||
toast.dismiss();
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
setTimeout(() => {
|
||||
BackGroundDownloader.completeHandler(process.id);
|
||||
removeProcess(process.id);
|
||||
}, 1000);
|
||||
})
|
||||
.error(async (error) => {
|
||||
removeProcess(process.id);
|
||||
BackGroundDownloader.completeHandler(process.id);
|
||||
let errorMsg = "";
|
||||
if (error.errorCode === 1000) {
|
||||
errorMsg = "No space left";
|
||||
}
|
||||
if (error.errorCode === 404) {
|
||||
errorMsg = "File not found on server";
|
||||
}
|
||||
toast.error(
|
||||
t("home.downloads.toasts.download_failed_for_item", {
|
||||
item: process.item.Name,
|
||||
error: errorMsg,
|
||||
})
|
||||
);
|
||||
writeToLog("ERROR", `Download failed for ${process.item.Name}`, {
|
||||
error,
|
||||
processDetails: {
|
||||
id: process.id,
|
||||
itemName: process.item.Name,
|
||||
itemId: process.item.Id,
|
||||
},
|
||||
});
|
||||
console.error("Error details:", {
|
||||
errorCode: error.errorCode,
|
||||
});
|
||||
});
|
||||
},
|
||||
[queryClient, settings?.optimizedVersionsServerUrl, authHeader]
|
||||
);
|
||||
|
||||
const startBackgroundDownload = useCallback(
|
||||
async (url: string, item: BaseItemDto, mediaSource: MediaSourceInfo) => {
|
||||
if (!api || !item.Id || !authHeader)
|
||||
throw new Error("startBackgroundDownload ~ Missing required params");
|
||||
|
||||
try {
|
||||
const fileExtension = mediaSource.TranscodingContainer;
|
||||
const deviceId = await getOrSetDeviceId();
|
||||
|
||||
await saveSeriesPrimaryImage(item);
|
||||
const itemImage = getItemImage({
|
||||
item,
|
||||
api,
|
||||
variant: "Primary",
|
||||
quality: 90,
|
||||
width: 500,
|
||||
});
|
||||
await saveImage(item.Id, itemImage?.uri);
|
||||
|
||||
const response = await axios.post(
|
||||
settings?.optimizedVersionsServerUrl + "optimize-version",
|
||||
{
|
||||
url,
|
||||
fileExtension,
|
||||
deviceId,
|
||||
itemId: item.Id,
|
||||
item,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: authHeader,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (response.status !== 201) {
|
||||
throw new Error("Failed to start optimization job");
|
||||
}
|
||||
|
||||
toast.success(
|
||||
t("home.downloads.toasts.queued_item_for_optimization", {
|
||||
item: item.Name,
|
||||
}),
|
||||
{
|
||||
action: {
|
||||
label: t("home.downloads.toasts.go_to_downloads"),
|
||||
onClick: () => {
|
||||
router.push("/downloads");
|
||||
toast.dismiss();
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
writeToLog("ERROR", "Error in startBackgroundDownload", error);
|
||||
console.error("Error in startBackgroundDownload:", error);
|
||||
if (axios.isAxiosError(error)) {
|
||||
console.error("Axios error details:", {
|
||||
message: error.message,
|
||||
response: error.response?.data,
|
||||
status: error.response?.status,
|
||||
headers: error.response?.headers,
|
||||
});
|
||||
toast.error(
|
||||
t("home.downloads.toasts.failed_to_start_download_for_item", {
|
||||
item: item.Name,
|
||||
message: error.message,
|
||||
})
|
||||
);
|
||||
if (error.response) {
|
||||
toast.error(
|
||||
t("home.downloads.toasts.server_responded_with_status", {
|
||||
statusCode: error.response.status,
|
||||
})
|
||||
);
|
||||
} else if (error.request) {
|
||||
t("home.downloads.toasts.no_response_received_from_server");
|
||||
} else {
|
||||
toast.error("Error setting up the request");
|
||||
}
|
||||
} else {
|
||||
console.error("Non-Axios error:", error);
|
||||
toast.error(
|
||||
t(
|
||||
"home.downloads.toasts.failed_to_start_download_for_item_unexpected_error",
|
||||
{ item: item.Name }
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
[settings?.optimizedVersionsServerUrl, authHeader]
|
||||
);
|
||||
|
||||
const deleteAllFiles = async (): Promise<void> => {
|
||||
Promise.all([
|
||||
deleteLocalFiles(),
|
||||
removeDownloadedItemsFromStorage(),
|
||||
cancelAllServerJobs(),
|
||||
queryClient.invalidateQueries({ queryKey: ["downloadedItems"] }),
|
||||
])
|
||||
.then(() =>
|
||||
toast.success(
|
||||
t(
|
||||
"home.downloads.toasts.all_files_folders_and_jobs_deleted_successfully"
|
||||
)
|
||||
)
|
||||
)
|
||||
.catch((reason) => {
|
||||
console.error("Failed to delete all files, folders, and jobs:", reason);
|
||||
toast.error(
|
||||
t(
|
||||
"home.downloads.toasts.an_error_occured_while_deleting_files_and_jobs"
|
||||
)
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const forEveryDocumentDirFile = async (
|
||||
includeMMKV: boolean = true,
|
||||
ignoreList: string[] = [],
|
||||
callback: (file: FileInfo) => void
|
||||
) => {
|
||||
const baseDirectory = FileSystem.documentDirectory;
|
||||
if (!baseDirectory) {
|
||||
throw new Error("Base directory not found");
|
||||
}
|
||||
|
||||
const dirContents = await FileSystem.readDirectoryAsync(baseDirectory);
|
||||
for (const item of dirContents) {
|
||||
// Exclude mmkv directory.
|
||||
// Deleting this deletes all user information as well. Logout should handle this.
|
||||
if (
|
||||
(item == "mmkv" && !includeMMKV) ||
|
||||
ignoreList.some((i) => item.includes(i))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
await FileSystem.getInfoAsync(`${baseDirectory}${item}`)
|
||||
.then((itemInfo) => {
|
||||
if (itemInfo.exists && !itemInfo.isDirectory) {
|
||||
callback(itemInfo);
|
||||
}
|
||||
})
|
||||
.catch((e) => console.error(e));
|
||||
}
|
||||
};
|
||||
|
||||
const deleteLocalFiles = async (): Promise<void> => {
|
||||
await forEveryDocumentDirFile(false, [], (file) => {
|
||||
console.warn("Deleting file", file.uri);
|
||||
FileSystem.deleteAsync(file.uri, { idempotent: true });
|
||||
});
|
||||
};
|
||||
|
||||
const removeDownloadedItemsFromStorage = async () => {
|
||||
// delete any saved images first
|
||||
Promise.all([deleteFileByType("Movie"), deleteFileByType("Episode")])
|
||||
.then(() => storage.delete("downloadedItems"))
|
||||
.catch((reason) => {
|
||||
console.error("Failed to remove downloadedItems from storage:", reason);
|
||||
throw reason;
|
||||
});
|
||||
};
|
||||
|
||||
const cancelAllServerJobs = async (): Promise<void> => {
|
||||
if (!authHeader) {
|
||||
throw new Error("No auth header available");
|
||||
}
|
||||
if (!settings?.optimizedVersionsServerUrl) {
|
||||
console.error("No server URL configured");
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceId = await getOrSetDeviceId();
|
||||
if (!deviceId) {
|
||||
throw new Error("Failed to get device ID");
|
||||
}
|
||||
|
||||
try {
|
||||
await cancelAllJobs({
|
||||
authHeader,
|
||||
url: settings.optimizedVersionsServerUrl,
|
||||
deviceId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to cancel all server jobs:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteFile = async (id: string): Promise<void> => {
|
||||
if (!id) {
|
||||
console.error("Invalid file ID");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const directory = FileSystem.documentDirectory;
|
||||
|
||||
if (!directory) {
|
||||
console.error("Document directory not found");
|
||||
return;
|
||||
}
|
||||
const dirContents = await FileSystem.readDirectoryAsync(directory);
|
||||
|
||||
for (const item of dirContents) {
|
||||
const itemNameWithoutExtension = item.split(".")[0];
|
||||
if (itemNameWithoutExtension === id) {
|
||||
const filePath = `${directory}${item}`;
|
||||
await FileSystem.deleteAsync(filePath, { idempotent: true });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const downloadedItems = storage.getString("downloadedItems");
|
||||
if (downloadedItems) {
|
||||
let items = JSON.parse(downloadedItems) as DownloadedItem[];
|
||||
items = items.filter((item) => item.item.Id !== id);
|
||||
storage.set("downloadedItems", JSON.stringify(items));
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ["downloadedItems"] });
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to delete file and storage entry for ID ${id}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteItems = async (items: BaseItemDto[]) => {
|
||||
Promise.all(
|
||||
items.map((i) => {
|
||||
if (i.Id) return deleteFile(i.Id);
|
||||
return;
|
||||
})
|
||||
).then(() => successHapticFeedback());
|
||||
};
|
||||
|
||||
const cleanCacheDirectory = async () => {
|
||||
const cacheDir = await FileSystem.getInfoAsync(
|
||||
APP_CACHE_DOWNLOAD_DIRECTORY
|
||||
);
|
||||
if (cacheDir.exists) {
|
||||
const cachedFiles = await FileSystem.readDirectoryAsync(
|
||||
APP_CACHE_DOWNLOAD_DIRECTORY
|
||||
);
|
||||
let position = 0;
|
||||
const batchSize = 3;
|
||||
|
||||
// batching promise.all to avoid OOM
|
||||
while (position < cachedFiles.length) {
|
||||
const itemsForBatch = cachedFiles.slice(position, position + batchSize);
|
||||
await Promise.all(
|
||||
itemsForBatch.map(async (file) => {
|
||||
const info = await FileSystem.getInfoAsync(
|
||||
`${APP_CACHE_DOWNLOAD_DIRECTORY}${file}`
|
||||
);
|
||||
if (info.exists) {
|
||||
await FileSystem.deleteAsync(info.uri, { idempotent: true });
|
||||
return Promise.resolve(file);
|
||||
}
|
||||
return Promise.reject();
|
||||
})
|
||||
);
|
||||
|
||||
position += batchSize;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const deleteFileByType = async (type: BaseItemDto["Type"]) => {
|
||||
await Promise.all(
|
||||
downloadedFiles
|
||||
?.filter((file) => file.item.Type == type)
|
||||
?.flatMap((file) => {
|
||||
const promises = [];
|
||||
if (type == "Episode" && file.item.SeriesId)
|
||||
promises.push(deleteFile(file.item.SeriesId));
|
||||
promises.push(deleteFile(file.item.Id!));
|
||||
return promises;
|
||||
}) || []
|
||||
);
|
||||
};
|
||||
|
||||
const appSizeUsage = useMemo(async () => {
|
||||
const sizes: number[] =
|
||||
downloadedFiles?.map((d) => {
|
||||
return getDownloadedItemSize(d.item.Id!!);
|
||||
}) || [];
|
||||
|
||||
await forEveryDocumentDirFile(
|
||||
true,
|
||||
getAllDownloadedItems().map((d) => d.item.Id!!),
|
||||
(file) => {
|
||||
if (file.exists) {
|
||||
sizes.push(file.size);
|
||||
}
|
||||
}
|
||||
).catch((e) => {
|
||||
console.error(e);
|
||||
});
|
||||
|
||||
return sizes.reduce((sum, size) => sum + size, 0);
|
||||
}, [logs, downloadedFiles, forEveryDocumentDirFile]);
|
||||
|
||||
function getDownloadedItem(itemId: string): DownloadedItem | null {
|
||||
try {
|
||||
const downloadedItems = storage.getString("downloadedItems");
|
||||
if (downloadedItems) {
|
||||
const items: DownloadedItem[] = JSON.parse(downloadedItems);
|
||||
const item = items.find((i) => i.item.Id === itemId);
|
||||
return item || null;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error(`Failed to retrieve item with ID ${itemId}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getAllDownloadedItems(): DownloadedItem[] {
|
||||
try {
|
||||
const downloadedItems = storage.getString("downloadedItems");
|
||||
if (downloadedItems) {
|
||||
return JSON.parse(downloadedItems) as DownloadedItem[];
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to retrieve downloaded items:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveDownloadedItemInfo(item: BaseItemDto, size: number = 0) {
|
||||
try {
|
||||
const downloadedItems = storage.getString("downloadedItems");
|
||||
let items: DownloadedItem[] = downloadedItems
|
||||
? JSON.parse(downloadedItems)
|
||||
: [];
|
||||
|
||||
const existingItemIndex = items.findIndex((i) => i.item.Id === item.Id);
|
||||
|
||||
const data = getDownloadItemInfoFromDiskTmp(item.Id!);
|
||||
|
||||
if (!data?.mediaSource)
|
||||
throw new Error(
|
||||
"Media source not found in tmp storage. Did you forget to save it before starting download?"
|
||||
);
|
||||
|
||||
const newItem = { item, mediaSource: data.mediaSource };
|
||||
|
||||
if (existingItemIndex !== -1) {
|
||||
items[existingItemIndex] = newItem;
|
||||
} else {
|
||||
items.push(newItem);
|
||||
}
|
||||
|
||||
deleteDownloadItemInfoFromDiskTmp(item.Id!);
|
||||
|
||||
storage.set("downloadedItems", JSON.stringify(items));
|
||||
storage.set("downloadedItemSize-" + item.Id, size.toString());
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ["downloadedItems"] });
|
||||
refetch();
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Failed to save downloaded item information with media source:",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getDownloadedItemSize(itemId: string): number {
|
||||
const size = storage.getString("downloadedItemSize-" + itemId);
|
||||
return size ? parseInt(size) : 0;
|
||||
}
|
||||
// Get download operation functions
|
||||
const {
|
||||
startBackgroundDownload,
|
||||
cancelDownload,
|
||||
deleteFile,
|
||||
deleteItems,
|
||||
deleteAllFiles,
|
||||
deleteFileByType,
|
||||
appSizeUsage,
|
||||
} = useDownloadOperations({
|
||||
taskMapRef,
|
||||
processes,
|
||||
setProcesses,
|
||||
removeProcess,
|
||||
api,
|
||||
authHeader,
|
||||
onDataChange: triggerRefresh,
|
||||
});
|
||||
|
||||
return {
|
||||
processes,
|
||||
startBackgroundDownload,
|
||||
downloadedFiles,
|
||||
downloadedItems, // Reactive value that auto-updates
|
||||
getDownloadedItems: getAllDownloadedItems, // Keep for backward compatibility
|
||||
getDownloadsDatabase,
|
||||
deleteAllFiles,
|
||||
deleteFile,
|
||||
deleteItems,
|
||||
saveDownloadedItemInfo,
|
||||
removeProcess,
|
||||
setProcesses,
|
||||
startDownload,
|
||||
getDownloadedItem,
|
||||
deleteFileByType,
|
||||
appSizeUsage,
|
||||
removeProcess,
|
||||
cancelDownload,
|
||||
getDownloadedItemSize,
|
||||
APP_CACHE_DOWNLOAD_DIRECTORY,
|
||||
cleanCacheDirectory,
|
||||
getDownloadedItemById,
|
||||
updateDownloadedItem,
|
||||
triggerRefresh,
|
||||
APP_CACHE_DOWNLOAD_DIRECTORY: APP_CACHE_DOWNLOAD_DIRECTORY.uri,
|
||||
appSizeUsage,
|
||||
// Deprecated/not implemented in simple version
|
||||
startDownload: async () => {},
|
||||
cleanCacheDirectory: async () => {},
|
||||
dumpDownloadDiagnostics: async () => "",
|
||||
};
|
||||
}
|
||||
|
||||
export function DownloadProvider({ children }: { children: React.ReactNode }) {
|
||||
const downloadProviderValue = useDownloadProvider();
|
||||
|
||||
return (
|
||||
<DownloadContext.Provider value={downloadProviderValue}>
|
||||
{children}
|
||||
</DownloadContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useDownload() {
|
||||
const context = useContext(DownloadContext);
|
||||
|
||||
if (Platform.isTV) {
|
||||
return {
|
||||
processes: [],
|
||||
startBackgroundDownload: async () => {},
|
||||
downloadedItems: [],
|
||||
getDownloadedItems: () => [],
|
||||
getDownloadsDatabase: () => ({ movies: {}, series: {}, other: {} }),
|
||||
deleteAllFiles: async () => {},
|
||||
deleteFile: async () => {},
|
||||
deleteItems: async () => {},
|
||||
deleteFileByType: async () => {},
|
||||
removeProcess: () => {},
|
||||
cancelDownload: async () => {},
|
||||
triggerRefresh: () => {},
|
||||
startDownload: async () => {},
|
||||
getDownloadedItemSize: () => 0,
|
||||
getDownloadedItemById: () => undefined,
|
||||
updateDownloadedItem: () => {},
|
||||
APP_CACHE_DOWNLOAD_DIRECTORY: "",
|
||||
cleanCacheDirectory: async () => {},
|
||||
appSizeUsage: async () => ({ total: 0, remaining: 0, appSize: 0 }),
|
||||
dumpDownloadDiagnostics: async () => "",
|
||||
};
|
||||
}
|
||||
|
||||
if (context === null) {
|
||||
throw new Error("useDownload must be used within a DownloadProvider");
|
||||
}
|
||||
if (Platform.isTV) {
|
||||
throw new Error("useDownload is not supported on TVOS");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
export function DownloadProvider({ children }: { children: React.ReactNode }) {
|
||||
const downloadUtils = useDownloadProvider();
|
||||
|
||||
return (
|
||||
<DownloadContext.Provider value={downloadUtils}>
|
||||
{children}
|
||||
</DownloadContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { JobStatus } from "@/utils/optimize-server";
|
||||
import {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import * as Application from "expo-application";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import React, { createContext, useCallback, useContext, useMemo } from "react";
|
||||
|
||||
export type DownloadedItem = {
|
||||
item: Partial<BaseItemDto>;
|
||||
mediaSource: MediaSourceInfo;
|
||||
};
|
||||
|
||||
export const processesAtom = atom<JobStatus[]>([]);
|
||||
|
||||
const DownloadContext = createContext<ReturnType<
|
||||
typeof useDownloadProvider
|
||||
> | null>(null);
|
||||
|
||||
/**
|
||||
* Dummy download provider for tvOS
|
||||
*/
|
||||
function useDownloadProvider() {
|
||||
const [processes, setProcesses] = useAtom<JobStatus[]>(processesAtom);
|
||||
|
||||
const downloadedFiles: DownloadedItem[] = [];
|
||||
|
||||
const removeProcess = useCallback(async (id: string) => {}, []);
|
||||
|
||||
const startDownload = useCallback(async (process: JobStatus) => {
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
const startBackgroundDownload = useCallback(
|
||||
async (url: string, item: BaseItemDto, mediaSource: MediaSourceInfo) => {
|
||||
return null;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const deleteAllFiles = async (): Promise<void> => {};
|
||||
|
||||
const deleteFile = async (id: string): Promise<void> => {};
|
||||
|
||||
const deleteItems = async (items: BaseItemDto[]) => {};
|
||||
|
||||
const cleanCacheDirectory = async () => {};
|
||||
|
||||
const deleteFileByType = async (type: BaseItemDto["Type"]) => {};
|
||||
|
||||
const appSizeUsage = useMemo(async () => {
|
||||
return 0;
|
||||
}, []);
|
||||
|
||||
function getDownloadedItem(itemId: string): DownloadedItem | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function saveDownloadedItemInfo(item: BaseItemDto, size: number = 0) {}
|
||||
|
||||
function getDownloadedItemSize(itemId: string): number {
|
||||
const size = storage.getString("downloadedItemSize-" + itemId);
|
||||
return size ? parseInt(size) : 0;
|
||||
}
|
||||
|
||||
const APP_CACHE_DOWNLOAD_DIRECTORY = `${FileSystem.cacheDirectory}${Application.applicationId}/Downloads/`;
|
||||
|
||||
return {
|
||||
processes,
|
||||
startBackgroundDownload,
|
||||
downloadedFiles,
|
||||
deleteAllFiles,
|
||||
deleteFile,
|
||||
deleteItems,
|
||||
saveDownloadedItemInfo,
|
||||
removeProcess,
|
||||
setProcesses,
|
||||
startDownload,
|
||||
getDownloadedItem,
|
||||
deleteFileByType,
|
||||
appSizeUsage,
|
||||
getDownloadedItemSize,
|
||||
APP_CACHE_DOWNLOAD_DIRECTORY,
|
||||
cleanCacheDirectory,
|
||||
};
|
||||
}
|
||||
|
||||
export function DownloadProvider({ children }: { children: React.ReactNode }) {
|
||||
const downloadProviderValue = useDownloadProvider();
|
||||
|
||||
return (
|
||||
<DownloadContext.Provider value={downloadProviderValue}>
|
||||
{children}
|
||||
</DownloadContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useDownload() {
|
||||
const context = useContext(DownloadContext);
|
||||
if (context === null) {
|
||||
throw new Error("useDownload must be used within a DownloadProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -1,19 +1,16 @@
|
||||
import "@/augmentations";
|
||||
import { useInterval } from "@/hooks/useInterval";
|
||||
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { Api, Jellyfin } from "@jellyfin/sdk";
|
||||
import { UserDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { type Api, Jellyfin } from "@jellyfin/sdk";
|
||||
import type { UserDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getUserApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import axios, { AxiosError } from "axios";
|
||||
import { router, useSegments } from "expo-router";
|
||||
import { useSegments } from "expo-router";
|
||||
import * as SplashScreen from "expo-splash-screen";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import React, {
|
||||
import type React from "react";
|
||||
import {
|
||||
createContext,
|
||||
ReactNode,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
@@ -21,9 +18,26 @@ import React, {
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform } from "react-native";
|
||||
import { AppState, Platform } from "react-native";
|
||||
import { getDeviceName } from "react-native-device-info";
|
||||
import uuid from "react-native-uuid";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useInterval } from "@/hooks/useInterval";
|
||||
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { writeErrorLog, writeInfoLog } from "@/utils/log";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import {
|
||||
type AccountSecurityType,
|
||||
addServerToList,
|
||||
deleteAccountCredential,
|
||||
getAccountCredential,
|
||||
hashPIN,
|
||||
migrateToMultiAccount,
|
||||
saveAccountCredential,
|
||||
updateAccountToken,
|
||||
} from "@/utils/secureCredentials";
|
||||
import { store } from "@/utils/store";
|
||||
|
||||
interface Server {
|
||||
address: string;
|
||||
@@ -33,17 +47,39 @@ export const apiAtom = atom<Api | null>(null);
|
||||
export const userAtom = atom<UserDto | null>(null);
|
||||
export const wsAtom = atom<WebSocket | null>(null);
|
||||
|
||||
interface LoginOptions {
|
||||
saveAccount?: boolean;
|
||||
securityType?: AccountSecurityType;
|
||||
pinCode?: string;
|
||||
}
|
||||
|
||||
interface JellyfinContextValue {
|
||||
discoverServers: (url: string) => Promise<Server[]>;
|
||||
setServer: (server: Server) => Promise<void>;
|
||||
removeServer: () => void;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
login: (
|
||||
username: string,
|
||||
password: string,
|
||||
serverName?: string,
|
||||
options?: LoginOptions,
|
||||
) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
initiateQuickConnect: () => Promise<string | undefined>;
|
||||
loginWithSavedCredential: (
|
||||
serverUrl: string,
|
||||
userId: string,
|
||||
) => Promise<void>;
|
||||
loginWithPassword: (
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
) => Promise<void>;
|
||||
removeSavedCredential: (serverUrl: string, userId: string) => Promise<void>;
|
||||
switchServerUrl: (newUrl: string) => void;
|
||||
}
|
||||
|
||||
const JellyfinContext = createContext<JellyfinContextValue | undefined>(
|
||||
undefined
|
||||
undefined,
|
||||
);
|
||||
|
||||
export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
@@ -61,12 +97,12 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
setJellyfin(
|
||||
() =>
|
||||
new Jellyfin({
|
||||
clientInfo: { name: "Streamyfin", version: "0.27.0" },
|
||||
clientInfo: { name: "Streamyfin", version: "0.52.0" },
|
||||
deviceInfo: {
|
||||
name: deviceName,
|
||||
id,
|
||||
},
|
||||
})
|
||||
}),
|
||||
);
|
||||
setDeviceId(id);
|
||||
})();
|
||||
@@ -76,13 +112,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
const [user, setUser] = useAtom(userAtom);
|
||||
const [isPolling, setIsPolling] = useState<boolean>(false);
|
||||
const [secret, setSecret] = useState<string | null>(null);
|
||||
const [
|
||||
settings,
|
||||
updateSettings,
|
||||
pluginSettings,
|
||||
setPluginSettings,
|
||||
refreshStreamyfinPluginSettings,
|
||||
] = useSettings();
|
||||
const { setPluginSettings, refreshStreamyfinPluginSettings } = useSettings();
|
||||
const { clearAllJellyseerData, setJellyseerrUser } = useJellyseerr();
|
||||
|
||||
const headers = useMemo(() => {
|
||||
@@ -90,7 +120,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
return {
|
||||
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
||||
Platform.OS === "android" ? "Android" : "iOS"
|
||||
}, DeviceId="${deviceId}", Version="0.27.0"`,
|
||||
}, DeviceId="${deviceId}", Version="0.52.0"`,
|
||||
};
|
||||
}, [deviceId]);
|
||||
|
||||
@@ -98,19 +128,18 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
if (!api || !deviceId) return;
|
||||
try {
|
||||
const response = await api.axiosInstance.post(
|
||||
api.basePath + "/QuickConnect/Initiate",
|
||||
`${api.basePath}/QuickConnect/Initiate`,
|
||||
null,
|
||||
{
|
||||
headers,
|
||||
}
|
||||
},
|
||||
);
|
||||
if (response?.status === 200) {
|
||||
setSecret(response?.data?.Secret);
|
||||
setIsPolling(true);
|
||||
return response.data?.Code;
|
||||
} else {
|
||||
throw new Error("Failed to initiate quick connect");
|
||||
}
|
||||
throw new Error("Failed to initiate quick connect");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw error;
|
||||
@@ -118,11 +147,11 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
}, [api, deviceId, headers]);
|
||||
|
||||
const pollQuickConnect = useCallback(async () => {
|
||||
if (!api || !secret) return;
|
||||
if (!api || !secret || !jellyfin) return;
|
||||
|
||||
try {
|
||||
const response = await api.axiosInstance.get(
|
||||
`${api.basePath}/QuickConnect/Connect?Secret=${secret}`
|
||||
`${api.basePath}/QuickConnect/Connect?Secret=${secret}`,
|
||||
);
|
||||
|
||||
if (response.status === 200) {
|
||||
@@ -130,18 +159,18 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
setIsPolling(false);
|
||||
|
||||
const authResponse = await api.axiosInstance.post(
|
||||
api.basePath + "/Users/AuthenticateWithQuickConnect",
|
||||
`${api.basePath}/Users/AuthenticateWithQuickConnect`,
|
||||
{
|
||||
secret,
|
||||
},
|
||||
{
|
||||
headers,
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const { AccessToken, User } = authResponse.data;
|
||||
api.accessToken = AccessToken;
|
||||
setUser(User);
|
||||
setApi(jellyfin.createApi(api.basePath, AccessToken));
|
||||
storage.set("token", AccessToken);
|
||||
storage.set("user", JSON.stringify(User));
|
||||
return true;
|
||||
@@ -153,12 +182,11 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
setIsPolling(false);
|
||||
setSecret(null);
|
||||
throw new Error("The code has expired. Please try again.");
|
||||
} else {
|
||||
console.error("Error polling Quick Connect:", error);
|
||||
throw error;
|
||||
}
|
||||
console.error("Error polling Quick Connect:", error);
|
||||
throw error;
|
||||
}
|
||||
}, [api, secret, headers]);
|
||||
}, [api, secret, headers, jellyfin]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
@@ -166,13 +194,26 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
store.set(apiAtom, api);
|
||||
}, [api]);
|
||||
|
||||
useInterval(pollQuickConnect, isPolling ? 1000 : null);
|
||||
useInterval(refreshStreamyfinPluginSettings, 60 * 5 * 1000); // 5 min
|
||||
|
||||
// Refresh plugin settings when app comes to foreground
|
||||
useEffect(() => {
|
||||
const subscription = AppState.addEventListener("change", (nextAppState) => {
|
||||
if (nextAppState === "active") {
|
||||
refreshStreamyfinPluginSettings();
|
||||
}
|
||||
});
|
||||
|
||||
return () => subscription.remove();
|
||||
}, []);
|
||||
|
||||
const discoverServers = async (url: string): Promise<Server[]> => {
|
||||
const servers = await jellyfin?.discovery.getRecommendedServerCandidates(
|
||||
url
|
||||
);
|
||||
const servers =
|
||||
await jellyfin?.discovery.getRecommendedServerCandidates(url);
|
||||
return servers?.map((server) => ({ address: server.address })) || [];
|
||||
};
|
||||
|
||||
@@ -185,18 +226,9 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
setApi(apiInstance);
|
||||
storage.set("serverUrl", server.address);
|
||||
},
|
||||
onSuccess: (_, server) => {
|
||||
const previousServers = JSON.parse(
|
||||
storage.getString("previousServers") || "[]"
|
||||
);
|
||||
const updatedServers = [
|
||||
server,
|
||||
...previousServers.filter((s: Server) => s.address !== server.address),
|
||||
];
|
||||
storage.set(
|
||||
"previousServers",
|
||||
JSON.stringify(updatedServers.slice(0, 5))
|
||||
);
|
||||
onSuccess: async (_, server) => {
|
||||
// Add server to the list (will update existing or add new)
|
||||
addServerToList(server.address);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to set server:", error);
|
||||
@@ -205,7 +237,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
|
||||
const removeServerMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
storage.delete("serverUrl");
|
||||
storage.remove("serverUrl");
|
||||
setApi(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -217,9 +249,13 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
mutationFn: async ({
|
||||
username,
|
||||
password,
|
||||
serverName,
|
||||
options,
|
||||
}: {
|
||||
username: string;
|
||||
password: string;
|
||||
serverName?: string;
|
||||
options?: LoginOptions;
|
||||
}) => {
|
||||
if (!api || !jellyfin) throw new Error("API not initialized");
|
||||
|
||||
@@ -232,10 +268,31 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
setApi(jellyfin.createApi(api?.basePath, auth.data?.AccessToken));
|
||||
storage.set("token", auth.data?.AccessToken);
|
||||
|
||||
// Save credentials to secure storage if requested
|
||||
if (api.basePath && options?.saveAccount) {
|
||||
const securityType = options.securityType || "none";
|
||||
let pinHash: string | undefined;
|
||||
|
||||
if (securityType === "pin" && options.pinCode) {
|
||||
pinHash = await hashPIN(options.pinCode);
|
||||
}
|
||||
|
||||
await saveAccountCredential({
|
||||
serverUrl: api.basePath,
|
||||
serverName: serverName || "",
|
||||
token: auth.data.AccessToken,
|
||||
userId: auth.data.User.Id || "",
|
||||
username,
|
||||
savedAt: Date.now(),
|
||||
securityType,
|
||||
pinHash,
|
||||
});
|
||||
}
|
||||
|
||||
const recentPluginSettings = await refreshStreamyfinPluginSettings();
|
||||
if (recentPluginSettings?.jellyseerrServerUrl?.value) {
|
||||
const jellyseerrApi = new JellyseerrApi(
|
||||
recentPluginSettings.jellyseerrServerUrl.value
|
||||
recentPluginSettings.jellyseerrServerUrl.value,
|
||||
);
|
||||
await jellyseerrApi.test().then((result) => {
|
||||
if (result.isValid && result.requiresPass) {
|
||||
@@ -251,23 +308,23 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
throw new Error(t("login.invalid_username_or_password"));
|
||||
case 403:
|
||||
throw new Error(
|
||||
t("login.user_does_not_have_permission_to_log_in")
|
||||
t("login.user_does_not_have_permission_to_log_in"),
|
||||
);
|
||||
case 408:
|
||||
throw new Error(
|
||||
t("login.server_is_taking_too_long_to_respond_try_again_later")
|
||||
t("login.server_is_taking_too_long_to_respond_try_again_later"),
|
||||
);
|
||||
case 429:
|
||||
throw new Error(
|
||||
t("login.server_received_too_many_requests_try_again_later")
|
||||
t("login.server_received_too_many_requests_try_again_later"),
|
||||
);
|
||||
case 500:
|
||||
throw new Error(t("login.there_is_a_server_error"));
|
||||
default:
|
||||
throw new Error(
|
||||
t(
|
||||
"login.an_unexpected_error_occured_did_you_enter_the_correct_url"
|
||||
)
|
||||
"login.an_unexpected_error_occured_did_you_enter_the_correct_url",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -281,17 +338,148 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
|
||||
const logoutMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
storage.delete("token");
|
||||
await api
|
||||
?.delete(`/Streamyfin/device/${deviceId}`)
|
||||
.then((_r) => writeInfoLog("Deleted expo push token for device"))
|
||||
.catch((_e) =>
|
||||
writeErrorLog("Failed to delete expo push token for device"),
|
||||
);
|
||||
|
||||
storage.remove("token");
|
||||
setUser(null);
|
||||
setApi(null);
|
||||
setPluginSettings(undefined);
|
||||
await clearAllJellyseerData();
|
||||
// Note: We keep saved credentials for quick switching back
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Logout failed:", error);
|
||||
},
|
||||
});
|
||||
|
||||
const loginWithSavedCredentialMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
serverUrl,
|
||||
userId,
|
||||
}: {
|
||||
serverUrl: string;
|
||||
userId: string;
|
||||
}) => {
|
||||
if (!jellyfin) throw new Error("Jellyfin not initialized");
|
||||
|
||||
const credential = await getAccountCredential(serverUrl, userId);
|
||||
if (!credential) {
|
||||
throw new Error("No saved credential found");
|
||||
}
|
||||
|
||||
// Create API instance with saved token
|
||||
const apiInstance = jellyfin.createApi(serverUrl, credential.token);
|
||||
if (!apiInstance) {
|
||||
throw new Error("Failed to create API instance");
|
||||
}
|
||||
|
||||
// Validate token by fetching current user
|
||||
try {
|
||||
const response = await getUserApi(apiInstance).getCurrentUser();
|
||||
|
||||
// Token is valid, update state
|
||||
setApi(apiInstance);
|
||||
setUser(response.data);
|
||||
storage.set("serverUrl", serverUrl);
|
||||
storage.set("token", credential.token);
|
||||
storage.set("user", JSON.stringify(response.data));
|
||||
|
||||
// Refresh plugin settings
|
||||
await refreshStreamyfinPluginSettings();
|
||||
} catch (error) {
|
||||
// Token is invalid/expired - remove it
|
||||
if (
|
||||
axios.isAxiosError(error) &&
|
||||
(error.response?.status === 401 || error.response?.status === 403)
|
||||
) {
|
||||
await deleteAccountCredential(serverUrl, userId);
|
||||
throw new Error(t("server.session_expired"));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Quick login failed:", error);
|
||||
},
|
||||
});
|
||||
|
||||
const loginWithPasswordMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
serverUrl,
|
||||
username,
|
||||
password,
|
||||
}: {
|
||||
serverUrl: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}) => {
|
||||
if (!jellyfin) throw new Error("Jellyfin not initialized");
|
||||
|
||||
// Create API instance for the server
|
||||
const apiInstance = jellyfin.createApi(serverUrl);
|
||||
if (!apiInstance) {
|
||||
throw new Error("Failed to create API instance");
|
||||
}
|
||||
|
||||
// Authenticate with password
|
||||
const auth = await apiInstance.authenticateUserByName(username, password);
|
||||
|
||||
if (auth.data.AccessToken && auth.data.User) {
|
||||
setUser(auth.data.User);
|
||||
storage.set("user", JSON.stringify(auth.data.User));
|
||||
setApi(jellyfin.createApi(serverUrl, auth.data.AccessToken));
|
||||
storage.set("serverUrl", serverUrl);
|
||||
storage.set("token", auth.data.AccessToken);
|
||||
|
||||
// Update the saved credential with new token
|
||||
await updateAccountToken(
|
||||
serverUrl,
|
||||
auth.data.User.Id || "",
|
||||
auth.data.AccessToken,
|
||||
);
|
||||
|
||||
// Refresh plugin settings
|
||||
await refreshStreamyfinPluginSettings();
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Password login failed:", error);
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
|
||||
const removeSavedCredentialMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
serverUrl,
|
||||
userId,
|
||||
}: {
|
||||
serverUrl: string;
|
||||
userId: string;
|
||||
}) => {
|
||||
await deleteAccountCredential(serverUrl, userId);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to remove saved credential:", error);
|
||||
},
|
||||
});
|
||||
|
||||
const switchServerUrl = useCallback(
|
||||
(newUrl: string) => {
|
||||
if (!jellyfin || !api?.accessToken) return;
|
||||
|
||||
const newApi = jellyfin.createApi(newUrl, api.accessToken);
|
||||
setApi(newApi);
|
||||
// Note: We don't update storage.set("serverUrl") here
|
||||
// because we want to keep the original remote URL as the "primary" URL
|
||||
},
|
||||
[jellyfin, api?.accessToken],
|
||||
);
|
||||
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [initialLoaded, setInitialLoaded] = useState(false);
|
||||
|
||||
@@ -306,6 +494,9 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
if (!jellyfin) return;
|
||||
|
||||
try {
|
||||
// Run migration to multi-account format (once)
|
||||
await migrateToMultiAccount();
|
||||
|
||||
const token = getTokenFromStorage();
|
||||
const serverUrl = getServerUrlFromStorage();
|
||||
const storedUser = getUserFromStorage();
|
||||
@@ -320,6 +511,25 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
|
||||
const response = await getUserApi(apiInstance).getCurrentUser();
|
||||
setUser(response.data);
|
||||
|
||||
// Migrate current session to secure storage if not already saved
|
||||
if (storedUser?.Id && storedUser?.Name) {
|
||||
const existingCredential = await getAccountCredential(
|
||||
serverUrl,
|
||||
storedUser.Id,
|
||||
);
|
||||
if (!existingCredential) {
|
||||
await saveAccountCredential({
|
||||
serverUrl,
|
||||
serverName: "",
|
||||
token,
|
||||
userId: storedUser.Id,
|
||||
username: storedUser.Name,
|
||||
savedAt: Date.now(),
|
||||
securityType: "none",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
@@ -335,10 +545,17 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
discoverServers,
|
||||
setServer: (server) => setServerMutation.mutateAsync(server),
|
||||
removeServer: () => removeServerMutation.mutateAsync(),
|
||||
login: (username, password) =>
|
||||
loginMutation.mutateAsync({ username, password }),
|
||||
login: (username, password, serverName, options) =>
|
||||
loginMutation.mutateAsync({ username, password, serverName, options }),
|
||||
logout: () => logoutMutation.mutateAsync(),
|
||||
initiateQuickConnect,
|
||||
loginWithSavedCredential: (serverUrl, userId) =>
|
||||
loginWithSavedCredentialMutation.mutateAsync({ serverUrl, userId }),
|
||||
loginWithPassword: (serverUrl, username, password) =>
|
||||
loginWithPasswordMutation.mutateAsync({ serverUrl, username, password }),
|
||||
removeSavedCredential: (serverUrl, userId) =>
|
||||
removeSavedCredentialMutation.mutateAsync({ serverUrl, userId }),
|
||||
switchServerUrl,
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -365,19 +582,16 @@ export const useJellyfin = (): JellyfinContextValue => {
|
||||
|
||||
function useProtectedRoute(user: UserDto | null, loaded = false) {
|
||||
const segments = useSegments();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (loaded === false) return;
|
||||
|
||||
console.log("Loaded", user);
|
||||
|
||||
const inAuthGroup = segments[0] === "(auth)";
|
||||
const inAuthGroup = segments.length > 1 && segments[0] === "(auth)";
|
||||
|
||||
if (!user?.Id && inAuthGroup) {
|
||||
console.log("Redirected to login");
|
||||
router.replace("/login");
|
||||
} else if (user?.Id && !inAuthGroup) {
|
||||
console.log("Redirected to home");
|
||||
router.replace("/(auth)/(tabs)/(home)/");
|
||||
}
|
||||
}, [user, segments, loaded]);
|
||||
|
||||
@@ -17,10 +17,12 @@
|
||||
"change_server": "Server wechseln",
|
||||
"invalid_username_or_password": "Ungültiger Benutzername oder Passwort",
|
||||
"user_does_not_have_permission_to_log_in": "Benutzer hat keine Berechtigung, um sich anzumelden",
|
||||
"server_is_taking_too_long_to_respond_try_again_later": "Der Server benötigt zu lange, um zu antworten. Bitte versuch es später erneut.",
|
||||
"server_is_taking_too_long_to_respond_try_again_later": "Der Server benötigt zu lange, um zu antworten. Bitte versuch es später erneut",
|
||||
"server_received_too_many_requests_try_again_later": "Der Server hat zu viele Anfragen erhalten. Bitte versuch es später erneut.",
|
||||
"there_is_a_server_error": "Es gibt einen Serverfehler",
|
||||
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Ein unerwarteter Fehler ist aufgetreten. Hast du die Server-URL korrekt eingegeben?"
|
||||
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Ein unerwarteter Fehler ist aufgetreten. Hast du die Server-URL korrekt eingegeben?",
|
||||
"too_old_server_text": "Nicht unterstützter Jellyfin Server entdeckt",
|
||||
"too_old_server_description": "Bitte aktualisiere Jellyfin auf die neueste Version"
|
||||
},
|
||||
"server": {
|
||||
"enter_url_to_jellyfin_server": "Gib die URL zu deinem Jellyfin-Server ein",
|
||||
@@ -28,19 +30,64 @@
|
||||
"connect_button": "Verbinden",
|
||||
"previous_servers": "Vorherige Server",
|
||||
"clear_button": "Löschen",
|
||||
"swipe_to_remove": "Swipe to remove",
|
||||
"search_for_local_servers": "Nach lokalen Servern suchen",
|
||||
"searching": "Suche...",
|
||||
"servers": "Server"
|
||||
"servers": "Server",
|
||||
"saved": "Saved",
|
||||
"session_expired": "Session Expired",
|
||||
"please_login_again": "Your saved session has expired. Please log in again.",
|
||||
"remove_saved_login": "Remove Saved Login",
|
||||
"remove_saved_login_description": "This will remove your saved credentials for this server. You'll need to enter your username and password again next time.",
|
||||
"accounts_count": "{{count}} accounts",
|
||||
"select_account": "Select Account",
|
||||
"add_account": "Add Account",
|
||||
"remove_account_description": "This will remove the saved credentials for {{username}}."
|
||||
},
|
||||
"save_account": {
|
||||
"title": "Save Account",
|
||||
"save_for_later": "Save this account",
|
||||
"security_option": "Security Option",
|
||||
"no_protection": "No protection",
|
||||
"no_protection_desc": "Quick login without authentication",
|
||||
"pin_code": "PIN code",
|
||||
"pin_code_desc": "4-digit PIN required when switching",
|
||||
"password": "Re-enter password",
|
||||
"password_desc": "Password required when switching",
|
||||
"save_button": "Save",
|
||||
"cancel_button": "Cancel"
|
||||
},
|
||||
"pin": {
|
||||
"enter_pin": "Enter PIN",
|
||||
"enter_pin_for": "Enter PIN for {{username}}",
|
||||
"enter_4_digits": "Enter 4 digits",
|
||||
"invalid_pin": "Invalid PIN",
|
||||
"setup_pin": "Set Up PIN",
|
||||
"confirm_pin": "Confirm PIN",
|
||||
"pins_dont_match": "PINs don't match",
|
||||
"forgot_pin": "Forgot PIN?",
|
||||
"forgot_pin_desc": "Your saved credentials will be removed"
|
||||
},
|
||||
"password": {
|
||||
"enter_password": "Enter Password",
|
||||
"enter_password_for": "Enter password for {{username}}",
|
||||
"invalid_password": "Invalid password"
|
||||
},
|
||||
"home": {
|
||||
"checking_server_connection": "Überprüfe Serververbindung...",
|
||||
"no_internet": "Kein Internet",
|
||||
"no_items": "Keine Elemente",
|
||||
"no_internet_message": "Keine Sorge, du kannst immer noch heruntergeladene Inhalte ansehen.",
|
||||
"checking_server_connection_message": "Überprüfe Verbindung zum Server",
|
||||
"go_to_downloads": "Gehe zu den Downloads",
|
||||
"retry": "Wiederholen",
|
||||
"server_unreachable": "Server nicht erreichbar",
|
||||
"server_unreachable_message": "Server konnte nicht erreicht werden.\nBitte überprüfe deine Netzwerkverbindung.",
|
||||
"oops": "Ups!",
|
||||
"error_message": "Etwas ist schiefgelaufen.\nBitte melde dich ab und wieder an.",
|
||||
"continue_watching": "Weiterschauen",
|
||||
"next_up": "Als nächstes",
|
||||
"continue_and_next_up": "Continue & Next Up",
|
||||
"recently_added_in": "Kürzlich hinzugefügt in {{libraryName}}",
|
||||
"suggested_movies": "Empfohlene Filme",
|
||||
"suggested_episodes": "Empfohlene Episoden",
|
||||
@@ -53,7 +100,7 @@
|
||||
"downloads_feature_title": "Downloads",
|
||||
"downloads_feature_description": "Lade Filme und Serien herunter, um sie offline anzusehen. Nutze entweder die Standardmethode oder installiere den optimierten Server, um Dateien im Hintergrund herunterzuladen.",
|
||||
"chromecast_feature_description": "Übertrage Filme und Serien auf deine Chromecast-Geräte.",
|
||||
"centralised_settings_plugin_title": "Zentralisiertes Einstellungs-Plugin",
|
||||
"centralised_settings_plugin_title": "Zentralisiertes Einstellungsplugin",
|
||||
"centralised_settings_plugin_description": "Konfiguriere Einstellungen an einem zentralen Ort auf deinem Jellyfin-Server. Alle Client-Einstellungen für alle Benutzer werden automatisch synchronisiert.",
|
||||
"done_button": "Fertig",
|
||||
"go_to_settings_button": "Gehe zu den Einstellungen",
|
||||
@@ -62,6 +109,48 @@
|
||||
"settings": {
|
||||
"settings_title": "Einstellungen",
|
||||
"log_out_button": "Abmelden",
|
||||
"categories": {
|
||||
"title": "Kategorien"
|
||||
},
|
||||
"playback_controls": {
|
||||
"title": "Wiedergabe & Steuerung"
|
||||
},
|
||||
"audio_subtitles": {
|
||||
"title": "Audio & Untertitel "
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Aussehen",
|
||||
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
|
||||
"hide_remote_session_button": "Hide Remote Session Button"
|
||||
},
|
||||
"network": {
|
||||
"title": "Network",
|
||||
"local_network": "Local Network",
|
||||
"auto_switch_enabled": "Auto-switch when at home",
|
||||
"auto_switch_description": "Automatically switch to local URL when connected to home WiFi",
|
||||
"local_url": "Local URL",
|
||||
"local_url_hint": "Enter your local server address (e.g., http://192.168.1.100:8096)",
|
||||
"local_url_placeholder": "http://192.168.1.100:8096",
|
||||
"home_wifi_networks": "Home WiFi Networks",
|
||||
"add_current_network": "Add \"{{ssid}}\"",
|
||||
"not_connected_to_wifi": "Not connected to WiFi",
|
||||
"no_networks_configured": "No networks configured",
|
||||
"add_network_hint": "Add your home WiFi network to enable auto-switching",
|
||||
"current_wifi": "Current WiFi",
|
||||
"using_url": "Using",
|
||||
"local": "Local URL",
|
||||
"remote": "Remote URL",
|
||||
"not_connected": "Not connected",
|
||||
"current_server": "Current Server",
|
||||
"remote_url": "Remote URL",
|
||||
"active_url": "Active URL",
|
||||
"not_configured": "Not configured",
|
||||
"network_added": "Network added",
|
||||
"network_already_added": "Network already added",
|
||||
"no_wifi_connected": "Not connected to WiFi",
|
||||
"permission_denied": "Location permission denied",
|
||||
"permission_denied_explanation": "Location permission is required to detect WiFi network for auto-switching. Please enable it in Settings."
|
||||
},
|
||||
"user_info": {
|
||||
"user_info_title": "Benutzerinformationen",
|
||||
"user": "Benutzer",
|
||||
@@ -85,35 +174,111 @@
|
||||
"rewind_length": "Rückspulzeit",
|
||||
"seconds_unit": "s"
|
||||
},
|
||||
"gesture_controls": {
|
||||
"gesture_controls_title": "Gestensteuerung",
|
||||
"horizontal_swipe_skip": "Horizontales Wischen zum Überspringen",
|
||||
"horizontal_swipe_skip_description": "Wische links/rechts, wenn Steuerelemente ausgeblendet werden um zu überspringen",
|
||||
"left_side_brightness": "Helligkeitskontrolle der linken Seite",
|
||||
"left_side_brightness_description": "Wischen Sie auf der linken Seite nach oben/runter, um die Helligkeit anzupassen",
|
||||
"right_side_volume": "Lautstärkeregelung der rechten Seite",
|
||||
"right_side_volume_description": "Auf der rechten Seite nach oben/unten wischen, um Lautstärke anzupassen",
|
||||
"hide_volume_slider": "Hide Volume Slider",
|
||||
"hide_volume_slider_description": "Hide the volume slider in the video player",
|
||||
"hide_brightness_slider": "Hide Brightness Slider",
|
||||
"hide_brightness_slider_description": "Hide the brightness slider in the video player"
|
||||
},
|
||||
"audio": {
|
||||
"audio_title": "Audio",
|
||||
"set_audio_track": "Audiospur aus dem vorherigen Element festlegen",
|
||||
"audio_language": "Audio-Sprache",
|
||||
"audio_hint": "Wähl die Standardsprache für Audio aus.",
|
||||
"none": "Keine",
|
||||
"language": "Sprache"
|
||||
"language": "Sprache",
|
||||
"transcode_mode": {
|
||||
"title": "Audio Transcoding",
|
||||
"description": "Controls how surround audio (7.1, TrueHD, DTS-HD) is handled",
|
||||
"auto": "Auto",
|
||||
"stereo": "Force Stereo",
|
||||
"5_1": "Allow 5.1",
|
||||
"passthrough": "Passthrough"
|
||||
}
|
||||
},
|
||||
"subtitles": {
|
||||
"subtitle_title": "Untertitel",
|
||||
"subtitle_hint": "Konfigurier die Untertitel-Präferenzen.",
|
||||
"subtitle_language": "Untertitel-Sprache",
|
||||
"subtitle_mode": "Untertitel-Modus",
|
||||
"set_subtitle_track": "Untertitel-Spur aus dem vorherigen Element festlegen",
|
||||
"subtitle_size": "Untertitel-Größe",
|
||||
"subtitle_hint": "Konfigurier die Untertitel-Präferenzen.",
|
||||
"none": "Keine",
|
||||
"language": "Sprache",
|
||||
"loading": "Lädt",
|
||||
"modes": {
|
||||
"Default": "Standard",
|
||||
"Smart": "Smart",
|
||||
"Smart": "Intelligent",
|
||||
"Always": "Immer",
|
||||
"None": "Keine",
|
||||
"OnlyForced": "Nur erzwungen"
|
||||
}
|
||||
},
|
||||
"text_color": "Textfarbe",
|
||||
"background_color": "Hintergrundfarbe",
|
||||
"outline_color": "Konturfarbe",
|
||||
"outline_thickness": "Umriss Dicke",
|
||||
"background_opacity": "Hintergrundtransparenz",
|
||||
"outline_opacity": "Kontur-Deckkraft",
|
||||
"bold_text": "Bold Text",
|
||||
"colors": {
|
||||
"Black": "Schwarz",
|
||||
"Gray": "Grau",
|
||||
"Silver": "Silber",
|
||||
"White": "Weiß",
|
||||
"Maroon": "Marotte",
|
||||
"Red": "Rot",
|
||||
"Fuchsia": "Fuchsia",
|
||||
"Yellow": "Gelb",
|
||||
"Olive": "Olivgrün",
|
||||
"Green": "Grün",
|
||||
"Teal": "Türkis",
|
||||
"Lime": "Hellgrün",
|
||||
"Purple": "Lila",
|
||||
"Navy": "Marineblau",
|
||||
"Blue": "Blau",
|
||||
"Aqua": "Himmelblau"
|
||||
},
|
||||
"thickness": {
|
||||
"None": "Keine",
|
||||
"Thin": "Dünn",
|
||||
"Normal": "Normal",
|
||||
"Thick": "Dick"
|
||||
},
|
||||
"subtitle_color": "Subtitle Color",
|
||||
"subtitle_background_color": "Background Color",
|
||||
"subtitle_font": "Subtitle Font",
|
||||
"ksplayer_title": "KSPlayer Settings",
|
||||
"hardware_decode": "Hardware Decoding",
|
||||
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues."
|
||||
},
|
||||
"vlc_subtitles": {
|
||||
"title": "VLC Subtitle Settings",
|
||||
"hint": "Customize subtitle appearance for VLC player. Changes take effect on next playback.",
|
||||
"text_color": "Text Color",
|
||||
"background_color": "Background Color",
|
||||
"background_opacity": "Background Opacity",
|
||||
"outline_color": "Outline Color",
|
||||
"outline_opacity": "Outline Opacity",
|
||||
"outline_thickness": "Outline Thickness",
|
||||
"bold": "Bold Text",
|
||||
"margin": "Bottom Margin"
|
||||
},
|
||||
"video_player": {
|
||||
"title": "Video Player",
|
||||
"video_player": "Video Player",
|
||||
"video_player_description": "Choose which video player to use on iOS.",
|
||||
"ksplayer": "KSPlayer",
|
||||
"vlc": "VLC"
|
||||
},
|
||||
"other": {
|
||||
"other_title": "Sonstiges",
|
||||
"auto_rotate": "Automatische Drehung",
|
||||
"video_orientation": "Videoausrichtung",
|
||||
"orientation": "Ausrichtung",
|
||||
"orientations": {
|
||||
@@ -129,38 +294,45 @@
|
||||
"UNKNOWN": "Unbekannt"
|
||||
},
|
||||
"safe_area_in_controls": "Sicherer Bereich in den Steuerungen",
|
||||
"video_player": "Video player",
|
||||
"video_players": {
|
||||
"VLC_3": "VLC 3",
|
||||
"VLC_4": "VLC 4 (Experimentell + PiP)"
|
||||
},
|
||||
"show_custom_menu_links": "Benutzerdefinierte Menülinks anzeigen",
|
||||
"show_large_home_carousel": "Zeige Großes Heimkarussell (Beta)",
|
||||
"hide_libraries": "Bibliotheken ausblenden",
|
||||
"select_liraries_you_want_to_hide": "Wähl die Bibliotheken aus, die du im Bibliothekstab und auf der Startseite ausblenden möchtest.",
|
||||
"disable_haptic_feedback": "Haptisches Feedback deaktivieren",
|
||||
"default_quality": "Standardqualität"
|
||||
"default_quality": "Standardqualität",
|
||||
"default_playback_speed": "Default Playback Speed",
|
||||
"auto_play_next_episode": "Auto-play Next Episode",
|
||||
"max_auto_play_episode_count": "Max. automatische Wiedergabe Episodenanzahl",
|
||||
"disabled": "Deaktiviert"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Downloads",
|
||||
"download_method": "Download-Methode",
|
||||
"remux_max_download": "Maximaler Remux-Download",
|
||||
"auto_download": "Automatischer Download",
|
||||
"optimized_versions_server": "Optimierter Versions-Server",
|
||||
"save_button": "Speichern",
|
||||
"optimized_server": "Optimierter Server",
|
||||
"optimized": "Optimiert",
|
||||
"default": "Standard",
|
||||
"optimized_version_hint": "Gib die URL für den optimierten Server ein. Die URL sollte http oder https enthalten und optional den Port.",
|
||||
"read_more_about_optimized_server": "Mehr über den optimierten Server lesen.",
|
||||
"url":"URL",
|
||||
"server_url_placeholder": "http(s)://domain.org:port"
|
||||
"downloads_title": "Downloads"
|
||||
},
|
||||
"music": {
|
||||
"title": "Music",
|
||||
"playback_title": "Playback",
|
||||
"playback_description": "Configure how music is played.",
|
||||
"prefer_downloaded": "Prefer Downloaded Songs",
|
||||
"caching_title": "Caching",
|
||||
"caching_description": "Automatically cache upcoming tracks for smoother playback.",
|
||||
"lookahead_enabled": "Enable Look-Ahead Caching",
|
||||
"lookahead_count": "Tracks to Pre-cache",
|
||||
"max_cache_size": "Max Cache Size"
|
||||
},
|
||||
"plugins": {
|
||||
"plugins_title": "Plugins",
|
||||
"plugins_title": "Erweiterungen",
|
||||
"jellyseerr": {
|
||||
"jellyseerr_warning": "Diese integration ist in einer frühen Entwicklungsphase. Erwarte Veränderungen.",
|
||||
"server_url": "Server URL",
|
||||
"server_url": "Server Adresse",
|
||||
"server_url_hint": "Beispiel: http(s)://your-host.url\n(Portnummer hinzufügen, falls erforderlich)",
|
||||
"server_url_placeholder": "Jellyseerr URL...",
|
||||
"password": "Passwort",
|
||||
"password_placeholder": "Passwort für Jellyfin Benutzer {{username}} eingeben",
|
||||
"save_button": "Speichern",
|
||||
"clear_button": "Löschen",
|
||||
"login_button": "Anmelden",
|
||||
"total_media_requests": "Gesamtanfragen",
|
||||
"movie_quota_limit": "Film-Anfragelimit",
|
||||
@@ -168,7 +340,13 @@
|
||||
"tv_quota_limit": "TV-Anfragelimit",
|
||||
"tv_quota_days": "TV-Anfragetage",
|
||||
"reset_jellyseerr_config_button": "Setze Jellyseerr-Konfiguration zurück",
|
||||
"unlimited": "Unlimitiert"
|
||||
"unlimited": "Unlimitiert",
|
||||
"plus_n_more": "+{{n}} more",
|
||||
"order_by": {
|
||||
"DEFAULT": "Standard",
|
||||
"VOTE_COUNT_AND_AVERAGE": "Stimmenanzahl und Durchschnitt",
|
||||
"POPULARITY": "Beliebtheit"
|
||||
}
|
||||
},
|
||||
"marlin_search": {
|
||||
"enable_marlin_search": "Aktiviere Marlin Search",
|
||||
@@ -178,8 +356,40 @@
|
||||
"read_more_about_marlin": "Erfahre mehr über Marlin.",
|
||||
"save_button": "Speichern",
|
||||
"toasts": {
|
||||
"saved": "Gespeichert"
|
||||
}
|
||||
"saved": "Gespeichert",
|
||||
"refreshed": "Settings refreshed from server"
|
||||
},
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"streamystats": {
|
||||
"enable_streamystats": "Enable Streamystats",
|
||||
"disable_streamystats": "Disable Streamystats",
|
||||
"enable_search": "Use for Search",
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
||||
"streamystats_search_hint": "Enter the URL for your Streamystats server. The URL should include http or https and optionally the port.",
|
||||
"read_more_about_streamystats": "Read More About Streamystats.",
|
||||
"save_button": "Save",
|
||||
"save": "Save",
|
||||
"features_title": "Features",
|
||||
"home_sections_title": "Home Sections",
|
||||
"enable_movie_recommendations": "Movie Recommendations",
|
||||
"enable_series_recommendations": "Series Recommendations",
|
||||
"enable_promoted_watchlists": "Promoted Watchlists",
|
||||
"hide_watchlists_tab": "Hide Watchlists Tab",
|
||||
"home_sections_hint": "Show personalized recommendations and promoted watchlists from Streamystats on the home page.",
|
||||
"recommended_movies": "Recommended Movies",
|
||||
"recommended_series": "Recommended Series",
|
||||
"toasts": {
|
||||
"saved": "Saved",
|
||||
"refreshed": "Settings refreshed from server",
|
||||
"disabled": "Streamystats disabled"
|
||||
},
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"kefinTweaks": {
|
||||
"watchlist_enabler": "Enable our Watchlist integration",
|
||||
"watchlist_button": "Toggle Watchlist integration"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
@@ -187,43 +397,58 @@
|
||||
"app_usage": "App {{usedSpace}}%",
|
||||
"device_usage": "Gerät {{availableSpace}}%",
|
||||
"size_used": "{{used}} von {{total}} benutzt",
|
||||
"delete_all_downloaded_files": "Alle Downloads löschen"
|
||||
"delete_all_downloaded_files": "Alle Downloads löschen",
|
||||
"music_cache_title": "Music Cache",
|
||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||
"enable_music_cache": "Enable Music Cache",
|
||||
"clear_music_cache": "Clear Music Cache",
|
||||
"music_cache_size": "{{size}} cached",
|
||||
"music_cache_cleared": "Music cache cleared",
|
||||
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
|
||||
"downloaded_songs_size": "{{size}} downloaded",
|
||||
"downloaded_songs_deleted": "Downloaded songs deleted"
|
||||
},
|
||||
"intro": {
|
||||
"title": "Intro ",
|
||||
"show_intro": "Show intro",
|
||||
"reset_intro": "Reset intro"
|
||||
},
|
||||
"logs": {
|
||||
"logs_title": "Logs",
|
||||
"export_logs": "Export logs",
|
||||
"click_for_more_info": "Click for more info",
|
||||
"level": "Level",
|
||||
"no_logs_available": "Keine Logs verfügbar",
|
||||
"delete_all_logs": "Alle Logs löschen"
|
||||
},
|
||||
"languages": {
|
||||
"title": "Sprachen",
|
||||
"app_language": "App-Sprache",
|
||||
"app_language_description": "Wähle die Sprache für die App aus.",
|
||||
"system": "System"
|
||||
},
|
||||
"toasts":{
|
||||
"toasts": {
|
||||
"error_deleting_files": "Fehler beim Löschen von Dateien",
|
||||
"background_downloads_enabled": "Hintergrunddownloads aktiviert",
|
||||
"background_downloads_disabled": "Hintergrunddownloads deaktiviert",
|
||||
"connected": "Verbunden",
|
||||
"could_not_connect": "Konnte keine Verbindung herstellen",
|
||||
"invalid_url": "Ungültige URL"
|
||||
"background_downloads_disabled": "Hintergrunddownloads deaktiviert"
|
||||
}
|
||||
},
|
||||
"sessions": {
|
||||
"title": "Sitzungen",
|
||||
"no_active_sessions": "Keine aktiven Sitzungen"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Downloads",
|
||||
"tvseries": "TV-Serien",
|
||||
"movies": "Filme",
|
||||
"queue": "Warteschlange",
|
||||
"other_media": "Andere Medien",
|
||||
"queue_hint": "Warteschlange und aktive Downloads gehen verloren bei App-Neustart",
|
||||
"no_items_in_queue": "Keine Elemente in der Warteschlange",
|
||||
"no_downloaded_items": "Keine heruntergeladenen Elemente",
|
||||
"delete_all_movies_button": "Alle Filme löschen",
|
||||
"delete_all_tvseries_button": "Alle TV-Serien löschen",
|
||||
"delete_all_button": "Alles löschen",
|
||||
"delete_all_other_media_button": "Andere Medien löschen",
|
||||
"active_download": "Aktiver Download",
|
||||
"no_active_downloads": "Keine aktiven Downloads",
|
||||
"active_downloads": "Aktive Downloads",
|
||||
@@ -234,35 +459,57 @@
|
||||
"something_went_wrong": "Etwas ist schiefgelaufen",
|
||||
"could_not_get_stream_url_from_jellyfin": "Konnte keine Stream-URL von Jellyfin erhalten",
|
||||
"eta": "ETA {{eta}}",
|
||||
"methods": "Methoden",
|
||||
"toasts": {
|
||||
"you_are_not_allowed_to_download_files": "Du hast keine Berechtigung, Dateien herunterzuladen",
|
||||
"deleted_all_movies_successfully": "Alle Filme erfolgreich gelöscht!",
|
||||
"failed_to_delete_all_movies": "Fehler beim Löschen aller Filme",
|
||||
"deleted_all_tvseries_successfully": "Alle TV-Serien erfolgreich gelöscht!",
|
||||
"failed_to_delete_all_tvseries": "Fehler beim Löschen aller TV-Serien",
|
||||
"deleted_media_successfully": "Andere Medien erfolgreich gelöscht!",
|
||||
"failed_to_delete_media": "Fehler beim Löschen anderer Medien",
|
||||
"download_deleted": "Download gelöscht",
|
||||
"download_cancelled": "Download abgebrochen",
|
||||
"could_not_cancel_download": "Download konnte nicht abgebrochen werden",
|
||||
"could_not_delete_download": "Download konnte nicht gelöscht werden",
|
||||
"download_paused": "Download pausiert",
|
||||
"could_not_pause_download": "Download konnte nicht angehalten werden",
|
||||
"download_resumed": "Download fortgesetzt",
|
||||
"could_not_resume_download": "Download konnte nicht fortgesetzt werden",
|
||||
"download_completed": "Download abgeschlossen",
|
||||
"download_started_for": "Download für {{item}} gestartet",
|
||||
"item_is_ready_to_be_downloaded": "{{item}} ist bereit zum Herunterladen",
|
||||
"download_stated_for_item": "Download für {{item}} gestartet",
|
||||
"download_failed": "Download fehlgeschlagen",
|
||||
"download_failed_for_item": "Download für {{item}} fehlgeschlagen - {{error}}",
|
||||
"download_completed_for_item": "Download für {{item}} ",
|
||||
"queued_item_for_optimization": "{{item}} für Optimierung in die Warteschlange gestellt",
|
||||
"failed_to_start_download_for_item": "Download konnte für {{item}} nicht gestartet werden: {{message}}",
|
||||
"server_responded_with_status_code": "Server hat mit Status {{statusCode}} geantwortet",
|
||||
"no_response_received_from_server": "Keine Antwort vom Server erhalten",
|
||||
"error_setting_up_the_request": "Fehler beim Einrichten der Anfrage",
|
||||
"failed_to_start_download_for_item_unexpected_error": "Fehler beim Starten des Downloads für {{item}}: Unerwarteter Fehler",
|
||||
"download_started_for_item": "Download für {{item}} gestartet",
|
||||
"failed_to_start_download": "Download konnte nicht gestartet werden",
|
||||
"item_already_downloading": "{{item}} Lädt",
|
||||
"all_files_deleted": "Alle Downloads gelöscht",
|
||||
"files_deleted_by_type": "{{count}} {{type}} gelöscht",
|
||||
"all_files_folders_and_jobs_deleted_successfully": "Alle Dateien, Ordner und Jobs erfolgreich gelöscht",
|
||||
"an_error_occured_while_deleting_files_and_jobs": "Ein Fehler ist beim Löschen von Dateien und Jobs aufgetreten",
|
||||
"go_to_downloads": "Gehe zu den Downloads"
|
||||
"failed_to_clean_cache_directory": "Fehler beim Bereinigen des Cache-Verzeichnisses",
|
||||
"could_not_get_download_url_for_item": "Download-URL für {{itemName}} konnte nicht geladen werden",
|
||||
"go_to_downloads": "Gehe zu den Downloads",
|
||||
"file_deleted": "{{item}} gelöscht"
|
||||
}
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"select": "Auswählen",
|
||||
"no_trailer_available": "Kein Trailer verfügbar",
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
"subtitle": "Untertitel",
|
||||
"play": "Abspielen",
|
||||
"none": "Keine",
|
||||
"track": "Track",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"ok": "OK",
|
||||
"remove": "Remove",
|
||||
"next": "Next",
|
||||
"back": "Back",
|
||||
"continue": "Continue",
|
||||
"verifying": "Verifying..."
|
||||
},
|
||||
"search": {
|
||||
"search_here": "Hier Suchen...",
|
||||
"search": "Suche...",
|
||||
"x_items": "{{count}} Elemente",
|
||||
"library": "Bibliothek",
|
||||
@@ -274,6 +521,10 @@
|
||||
"episodes": "Episoden",
|
||||
"collections": "Sammlungen",
|
||||
"actors": "Schauspieler",
|
||||
"artists": "Artists",
|
||||
"albums": "Albums",
|
||||
"songs": "Songs",
|
||||
"playlists": "Playlists",
|
||||
"request_movies": "Film anfragen",
|
||||
"request_series": "Serie anfragen",
|
||||
"recently_added": "Kürzlich hinzugefügt",
|
||||
@@ -299,7 +550,6 @@
|
||||
"tmdb_tv_streaming_services": "TMDB TV-Serien-Streaming-Dienste"
|
||||
},
|
||||
"library": {
|
||||
"no_items_found": "Keine Elemente gefunden",
|
||||
"no_results": "Keine Ergebnisse",
|
||||
"no_libraries_found": "Keine Bibliotheken gefunden",
|
||||
"item_types": {
|
||||
@@ -309,7 +559,7 @@
|
||||
"items": "Elemente"
|
||||
},
|
||||
"options": {
|
||||
"display": "Display",
|
||||
"display": "Anzeige",
|
||||
"row": "Reihe",
|
||||
"list": "Liste",
|
||||
"image_style": "Bildstil",
|
||||
@@ -322,6 +572,7 @@
|
||||
"genres": "Genres",
|
||||
"years": "Jahre",
|
||||
"sort_by": "Sortieren nach",
|
||||
"filter_by": "Filter By",
|
||||
"sort_order": "Sortierreihenfolge",
|
||||
"tags": "Tags"
|
||||
}
|
||||
@@ -332,7 +583,9 @@
|
||||
"episodes": "Episoden",
|
||||
"videos": "Videos",
|
||||
"boxsets": "Boxsets",
|
||||
"playlists": "Playlists"
|
||||
"playlists": "Wiedergabelisten",
|
||||
"noDataTitle": "Noch keine Favoriten",
|
||||
"noData": "Markiere Elemente als Favoriten, damit sie hier für einen schnellen Zugriff angezeigt werden."
|
||||
},
|
||||
"custom_links": {
|
||||
"no_links": "Keine Links"
|
||||
@@ -344,15 +597,18 @@
|
||||
"client_error": "Client-Fehler",
|
||||
"could_not_create_stream_for_chromecast": "Konnte keinen Stream für Chromecast erstellen",
|
||||
"message_from_server": "Nachricht vom Server: {{message}}",
|
||||
"video_has_finished_playing": "Video wurde fertig abgespielt!",
|
||||
"no_video_source": "Keine Videoquelle...",
|
||||
"next_episode": "Nächste Episode",
|
||||
"refresh_tracks": "Spuren aktualisieren",
|
||||
"subtitle_tracks": "Untertitel-Spuren:",
|
||||
"audio_tracks": "Audiospuren:",
|
||||
"playback_state": "Wiedergabestatus:",
|
||||
"no_data_available": "Keine Daten verfügbar",
|
||||
"index": "Index:"
|
||||
"index": "Index:",
|
||||
"continue_watching": "Weiterschauen",
|
||||
"go_back": "Zurück",
|
||||
"downloaded_file_title": "Diese Datei wurde heruntergeladen",
|
||||
"downloaded_file_message": "Möchten Sie die heruntergeladene Datei abspielen?",
|
||||
"downloaded_file_yes": "Ja",
|
||||
"downloaded_file_no": "Nein",
|
||||
"downloaded_file_cancel": "Abbrechen"
|
||||
},
|
||||
"item_card": {
|
||||
"next_up": "Als Nächstes",
|
||||
@@ -368,6 +624,7 @@
|
||||
"no_similar_items_found": "Keine ähnlichen Elemente gefunden",
|
||||
"video": "Video",
|
||||
"more_details": "Mehr Details",
|
||||
"media_options": "Medienoptionen",
|
||||
"quality": "Qualität",
|
||||
"audio": "Audio",
|
||||
"subtitles": "Untertitel",
|
||||
@@ -382,24 +639,22 @@
|
||||
"download_episode": "Episode herunterladen",
|
||||
"download_movie": "Film herunterladen",
|
||||
"download_x_item": "{{item_count}} Elemente herunterladen",
|
||||
"download_button": "Herunterladen",
|
||||
"using_optimized_server": "Verwende optimierten Server",
|
||||
"using_default_method": "Verwende Standardmethode"
|
||||
"download_unwatched_only": "Nur unbeobachtete",
|
||||
"download_button": "Herunterladen"
|
||||
}
|
||||
},
|
||||
"live_tv": {
|
||||
"next": "Nächster",
|
||||
"previous": "Vorheriger",
|
||||
"live_tv": "Live TV",
|
||||
"coming_soon": "Demnächst",
|
||||
"on_now": "Jetzt",
|
||||
"shows": "Shows",
|
||||
"shows": "Serien",
|
||||
"movies": "Filme",
|
||||
"sports": "Sport",
|
||||
"for_kids": "Für Kinder",
|
||||
"news": "Nachrichten"
|
||||
},
|
||||
"jellyseerr":{
|
||||
"jellyseerr": {
|
||||
"confirm": "Bestätigen",
|
||||
"cancel": "Abbrechen",
|
||||
"yes": "Ja",
|
||||
@@ -433,11 +688,15 @@
|
||||
"tags": "Tags",
|
||||
"quality_profile": "Qualitätsprofil",
|
||||
"root_folder": "Root-Ordner",
|
||||
"season_x": "Staffel {{seasons}}",
|
||||
"season_all": "Season (all)",
|
||||
"season_number": "Staffel {{season_number}}",
|
||||
"number_episodes": "{{episode_number}} Episodes",
|
||||
"number_episodes": "{{episode_number}} Folgen",
|
||||
"born": "Geboren",
|
||||
"appearances": "Auftritte",
|
||||
"approve": "Genehmigen",
|
||||
"decline": "Ablehnen",
|
||||
"requested_by": "Angefragt von {{user}}",
|
||||
"unknown_user": "Unbekannter Nutzer",
|
||||
"toasts": {
|
||||
"jellyseer_does_not_meet_requirements": "Jellyseerr Server erfüllt nicht die Anforderungsversion. Bitte aktualisiere deinen Jellyseerr Server auf mindestens 2.0.0",
|
||||
"jellyseerr_test_failed": "Jellyseerr-Test fehlgeschlagen. Bitte versuche es erneut.",
|
||||
@@ -445,7 +704,11 @@
|
||||
"issue_submitted": "Problem eingereicht!",
|
||||
"requested_item": "{{item}} angefragt!",
|
||||
"you_dont_have_permission_to_request": "Du hast keine Berechtigung Anfragen zu stellen",
|
||||
"something_went_wrong_requesting_media": "Etwas ist schiefgelaufen beim Anfragen von Medien"
|
||||
"something_went_wrong_requesting_media": "Etwas ist schiefgelaufen beim Anfragen von Medien",
|
||||
"request_approved": "Anfrage bestätigt!",
|
||||
"request_declined": "Anfrage abgelehnt!",
|
||||
"failed_to_approve_request": "Fehler beim Bestätigen der Anfrage",
|
||||
"failed_to_decline_request": "Fehler beim Ablehnen der Anfrage"
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
@@ -454,5 +717,129 @@
|
||||
"library": "Bibliothek",
|
||||
"custom_links": "Benutzerdefinierte Links",
|
||||
"favorites": "Favoriten"
|
||||
},
|
||||
"music": {
|
||||
"title": "Music",
|
||||
"tabs": {
|
||||
"suggestions": "Suggestions",
|
||||
"albums": "Albums",
|
||||
"artists": "Artists",
|
||||
"playlists": "Playlists",
|
||||
"tracks": "tracks"
|
||||
},
|
||||
"filters": {
|
||||
"all": "All"
|
||||
},
|
||||
"recently_added": "Recently Added",
|
||||
"recently_played": "Recently Played",
|
||||
"frequently_played": "Frequently Played",
|
||||
"explore": "Explore",
|
||||
"top_tracks": "Top Tracks",
|
||||
"play": "Play",
|
||||
"shuffle": "Shuffle",
|
||||
"play_top_tracks": "Play Top Tracks",
|
||||
"no_suggestions": "No suggestions available",
|
||||
"no_albums": "No albums found",
|
||||
"no_artists": "No artists found",
|
||||
"no_playlists": "No playlists found",
|
||||
"album_not_found": "Album not found",
|
||||
"artist_not_found": "Artist not found",
|
||||
"playlist_not_found": "Playlist not found",
|
||||
"track_options": {
|
||||
"play_next": "Play Next",
|
||||
"add_to_queue": "Add to Queue",
|
||||
"add_to_playlist": "Add to Playlist",
|
||||
"download": "Download",
|
||||
"downloaded": "Downloaded",
|
||||
"downloading": "Downloading...",
|
||||
"cached": "Cached",
|
||||
"delete_download": "Delete Download",
|
||||
"delete_cache": "Remove from Cache",
|
||||
"go_to_artist": "Go to Artist",
|
||||
"go_to_album": "Go to Album",
|
||||
"add_to_favorites": "Add to Favorites",
|
||||
"remove_from_favorites": "Remove from Favorites",
|
||||
"remove_from_playlist": "Remove from Playlist"
|
||||
},
|
||||
"playlists": {
|
||||
"create_playlist": "Create Playlist",
|
||||
"playlist_name": "Playlist Name",
|
||||
"enter_name": "Enter playlist name",
|
||||
"create": "Create",
|
||||
"search_playlists": "Search playlists...",
|
||||
"added_to": "Added to {{name}}",
|
||||
"added": "Added to playlist",
|
||||
"removed_from": "Removed from {{name}}",
|
||||
"removed": "Removed from playlist",
|
||||
"created": "Playlist created",
|
||||
"create_new": "Create New Playlist",
|
||||
"failed_to_add": "Failed to add to playlist",
|
||||
"failed_to_remove": "Failed to remove from playlist",
|
||||
"failed_to_create": "Failed to create playlist",
|
||||
"delete_playlist": "Delete Playlist",
|
||||
"delete_confirm": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
|
||||
"deleted": "Playlist deleted",
|
||||
"failed_to_delete": "Failed to delete playlist"
|
||||
},
|
||||
"sort": {
|
||||
"title": "Sort By",
|
||||
"alphabetical": "Alphabetical",
|
||||
"date_created": "Date Created"
|
||||
}
|
||||
},
|
||||
"watchlists": {
|
||||
"title": "Watchlists",
|
||||
"my_watchlists": "My Watchlists",
|
||||
"public_watchlists": "Public Watchlists",
|
||||
"create_title": "Create Watchlist",
|
||||
"edit_title": "Edit Watchlist",
|
||||
"create_button": "Create Watchlist",
|
||||
"save_button": "Save Changes",
|
||||
"delete_button": "Delete",
|
||||
"remove_button": "Remove",
|
||||
"cancel_button": "Cancel",
|
||||
"name_label": "Name",
|
||||
"name_placeholder": "Enter watchlist name",
|
||||
"description_label": "Description",
|
||||
"description_placeholder": "Enter description (optional)",
|
||||
"is_public_label": "Public Watchlist",
|
||||
"is_public_description": "Allow others to view this watchlist",
|
||||
"allowed_type_label": "Content Type",
|
||||
"sort_order_label": "Default Sort Order",
|
||||
"empty_title": "No Watchlists",
|
||||
"empty_description": "Create your first watchlist to start organizing your media",
|
||||
"empty_watchlist": "This watchlist is empty",
|
||||
"empty_watchlist_hint": "Add items from your library to this watchlist",
|
||||
"not_configured_title": "Streamystats Not Configured",
|
||||
"not_configured_description": "Configure Streamystats in settings to use watchlists",
|
||||
"go_to_settings": "Go to Settings",
|
||||
"add_to_watchlist": "Add to Watchlist",
|
||||
"remove_from_watchlist": "Remove from Watchlist",
|
||||
"select_watchlist": "Select Watchlist",
|
||||
"create_new": "Create New Watchlist",
|
||||
"item": "item",
|
||||
"items": "items",
|
||||
"public": "Public",
|
||||
"private": "Private",
|
||||
"you": "You",
|
||||
"by_owner": "By another user",
|
||||
"not_found": "Watchlist not found",
|
||||
"delete_confirm_title": "Delete Watchlist",
|
||||
"delete_confirm_message": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
|
||||
"remove_item_title": "Remove from Watchlist",
|
||||
"remove_item_message": "Remove \"{{name}}\" from this watchlist?",
|
||||
"loading": "Loading watchlists...",
|
||||
"no_compatible_watchlists": "No compatible watchlists",
|
||||
"create_one_first": "Create a watchlist that accepts this content type"
|
||||
},
|
||||
"playback_speed": {
|
||||
"title": "Playback Speed",
|
||||
"apply_to": "Apply To",
|
||||
"speed": "Speed",
|
||||
"scope": {
|
||||
"media": "This media only",
|
||||
"show": "This show",
|
||||
"all": "All media (default)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,67 +1,156 @@
|
||||
{
|
||||
"login": {
|
||||
"username_required": "Username is required",
|
||||
"username_required": "Username Is Required",
|
||||
"error_title": "Error",
|
||||
"login_title": "Log in",
|
||||
"login_title": "Log In",
|
||||
"login_to_title": "Log in to",
|
||||
"username_placeholder": "Username",
|
||||
"password_placeholder": "Password",
|
||||
"login_button": "Log in",
|
||||
"login_button": "Log In",
|
||||
"quick_connect": "Quick Connect",
|
||||
"enter_code_to_login": "Enter code {{code}} to login",
|
||||
"failed_to_initiate_quick_connect": "Failed to initiate Quick Connect",
|
||||
"got_it": "Got it",
|
||||
"connection_failed": "Connection failed",
|
||||
"got_it": "Got It",
|
||||
"connection_failed": "Connection Failed",
|
||||
"could_not_connect_to_server": "Could not connect to the server. Please check the URL and your network connection.",
|
||||
"an_unexpected_error_occured": "An unexpected error occurred",
|
||||
"change_server": "Change server",
|
||||
"invalid_username_or_password": "Invalid username or password",
|
||||
"an_unexpected_error_occured": "An Unexpected Error Occurred",
|
||||
"change_server": "Change Server",
|
||||
"invalid_username_or_password": "Invalid Username or Password",
|
||||
"user_does_not_have_permission_to_log_in": "User does not have permission to log in",
|
||||
"server_is_taking_too_long_to_respond_try_again_later": "Server is taking too long to respond, try again later",
|
||||
"server_received_too_many_requests_try_again_later": "Server received too many requests, try again later.",
|
||||
"there_is_a_server_error": "There is a server error",
|
||||
"an_unexpected_error_occured_did_you_enter_the_correct_url": "An unexpected error occurred. Did you enter the server URL correctly?"
|
||||
"an_unexpected_error_occured_did_you_enter_the_correct_url": "An unexpected error occurred. Did you enter the server URL correctly?",
|
||||
"too_old_server_text": "Unsupported Jellyfin Server Discovered",
|
||||
"too_old_server_description": "Please update Jellyfin to the latest version"
|
||||
},
|
||||
"server": {
|
||||
"enter_url_to_jellyfin_server": "Enter the URL to your Jellyfin server",
|
||||
"server_url_placeholder": "http(s)://your-server.com",
|
||||
"connect_button": "Connect",
|
||||
"previous_servers": "previous servers",
|
||||
"clear_button": "Clear",
|
||||
"search_for_local_servers": "Search for local servers",
|
||||
"previous_servers": "Previous Servers",
|
||||
"clear_button": "Clear all",
|
||||
"swipe_to_remove": "Swipe to remove",
|
||||
"search_for_local_servers": "Search for Local Servers",
|
||||
"searching": "Searching...",
|
||||
"servers": "Servers"
|
||||
"servers": "Servers",
|
||||
"saved": "Saved",
|
||||
"session_expired": "Session Expired",
|
||||
"please_login_again": "Your saved session has expired. Please log in again.",
|
||||
"remove_saved_login": "Remove Saved Login",
|
||||
"remove_saved_login_description": "This will remove your saved credentials for this server. You'll need to enter your username and password again next time.",
|
||||
"accounts_count": "{{count}} accounts",
|
||||
"select_account": "Select Account",
|
||||
"add_account": "Add Account",
|
||||
"remove_account_description": "This will remove the saved credentials for {{username}}."
|
||||
},
|
||||
"save_account": {
|
||||
"title": "Save Account",
|
||||
"save_for_later": "Save this account",
|
||||
"security_option": "Security Option",
|
||||
"no_protection": "No protection",
|
||||
"no_protection_desc": "Quick login without authentication",
|
||||
"pin_code": "PIN code",
|
||||
"pin_code_desc": "4-digit PIN required when switching",
|
||||
"password": "Re-enter password",
|
||||
"password_desc": "Password required when switching",
|
||||
"save_button": "Save",
|
||||
"cancel_button": "Cancel"
|
||||
},
|
||||
"pin": {
|
||||
"enter_pin": "Enter PIN",
|
||||
"enter_pin_for": "Enter PIN for {{username}}",
|
||||
"enter_4_digits": "Enter 4 digits",
|
||||
"invalid_pin": "Invalid PIN",
|
||||
"setup_pin": "Set Up PIN",
|
||||
"confirm_pin": "Confirm PIN",
|
||||
"pins_dont_match": "PINs don't match",
|
||||
"forgot_pin": "Forgot PIN?",
|
||||
"forgot_pin_desc": "Your saved credentials will be removed"
|
||||
},
|
||||
"password": {
|
||||
"enter_password": "Enter Password",
|
||||
"enter_password_for": "Enter password for {{username}}",
|
||||
"invalid_password": "Invalid password"
|
||||
},
|
||||
"home": {
|
||||
"checking_server_connection": "Checking server connection...",
|
||||
"no_internet": "No Internet",
|
||||
"no_items": "No items",
|
||||
"no_items": "No Items",
|
||||
"no_internet_message": "No worries, you can still watch\ndownloaded content.",
|
||||
"go_to_downloads": "Go to downloads",
|
||||
"checking_server_connection_message": "Checking connection to server",
|
||||
"go_to_downloads": "Go to Downloads",
|
||||
"retry": "Retry",
|
||||
"server_unreachable": "Server Unreachable",
|
||||
"server_unreachable_message": "Could not reach the server.\nPlease check your network connection.",
|
||||
"oops": "Oops!",
|
||||
"error_message": "Something went wrong.\nPlease log out and in again.",
|
||||
"continue_watching": "Continue Watching",
|
||||
"next_up": "Next Up",
|
||||
"continue_and_next_up": "Continue & Next Up",
|
||||
"recently_added_in": "Recently Added in {{libraryName}}",
|
||||
"suggested_movies": "Suggested Movies",
|
||||
"suggested_episodes": "Suggested Episodes",
|
||||
"intro": {
|
||||
"welcome_to_streamyfin": "Welcome to Streamyfin",
|
||||
"a_free_and_open_source_client_for_jellyfin": "A free and open-source client for Jellyfin.",
|
||||
"a_free_and_open_source_client_for_jellyfin": "A Free and Open-Source Client for Jellyfin.",
|
||||
"features_title": "Features",
|
||||
"features_description": "Streamyfin has a bunch of features and integrates with a wide array of software which you can find in the settings menu, these include:",
|
||||
"jellyseerr_feature_description": "Connect to your Jellyseerr instance and request movies directly in the app.",
|
||||
"jellyseerr_feature_description": "Connect to your Seerr instance and request movies directly in the app.",
|
||||
"downloads_feature_title": "Downloads",
|
||||
"downloads_feature_description": "Download movies and tv-shows to view offline. Use either the default method or install the optimize server to download files in the background.",
|
||||
"chromecast_feature_description": "Cast movies and tv-shows to your Chromecast devices.",
|
||||
"centralised_settings_plugin_title": "Centralised Settings Plugin",
|
||||
"centralised_settings_plugin_description": "Configure settings from a centralised location on your Jellyfin server. All client settings for all users will be synced automatically.",
|
||||
"done_button": "Done",
|
||||
"go_to_settings_button": "Go to settings",
|
||||
"read_more": "Read more"
|
||||
"go_to_settings_button": "Go to Settings",
|
||||
"read_more": "Read More"
|
||||
},
|
||||
"settings": {
|
||||
"settings_title": "Settings",
|
||||
"log_out_button": "Log out",
|
||||
"log_out_button": "Log Out",
|
||||
"categories": {
|
||||
"title": "Categories"
|
||||
},
|
||||
"playback_controls": {
|
||||
"title": "Playback & Controls"
|
||||
},
|
||||
"audio_subtitles": {
|
||||
"title": "Audio & Subtitles"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Appearance",
|
||||
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
|
||||
"hide_remote_session_button": "Hide Remote Session Button"
|
||||
},
|
||||
"network": {
|
||||
"title": "Network",
|
||||
"local_network": "Local Network",
|
||||
"auto_switch_enabled": "Auto-switch when at home",
|
||||
"auto_switch_description": "Automatically switch to local URL when connected to home WiFi",
|
||||
"local_url": "Local URL",
|
||||
"local_url_hint": "Enter your local server address (e.g., http://192.168.1.100:8096)",
|
||||
"local_url_placeholder": "http://192.168.1.100:8096",
|
||||
"home_wifi_networks": "Home WiFi Networks",
|
||||
"add_current_network": "Add \"{{ssid}}\"",
|
||||
"not_connected_to_wifi": "Not connected to WiFi",
|
||||
"no_networks_configured": "No networks configured",
|
||||
"add_network_hint": "Add your home WiFi network to enable auto-switching",
|
||||
"current_wifi": "Current WiFi",
|
||||
"using_url": "Using",
|
||||
"local": "Local URL",
|
||||
"remote": "Remote URL",
|
||||
"not_connected": "Not connected",
|
||||
"current_server": "Current Server",
|
||||
"remote_url": "Remote URL",
|
||||
"active_url": "Active URL",
|
||||
"not_configured": "Not configured",
|
||||
"network_added": "Network added",
|
||||
"network_already_added": "Network already added",
|
||||
"no_wifi_connected": "Not connected to WiFi",
|
||||
"permission_denied": "Location permission denied",
|
||||
"permission_denied_explanation": "Location permission is required to detect WiFi network for auto-switching. Please enable it in Settings."
|
||||
},
|
||||
"user_info": {
|
||||
"user_info_title": "User Info",
|
||||
"user": "User",
|
||||
@@ -74,32 +163,53 @@
|
||||
"authorize_button": "Authorize Quick Connect",
|
||||
"enter_the_quick_connect_code": "Enter the quick connect code...",
|
||||
"success": "Success",
|
||||
"quick_connect_autorized": "Quick Connect authorized",
|
||||
"quick_connect_autorized": "Quick Connect Authorized",
|
||||
"error": "Error",
|
||||
"invalid_code": "Invalid code",
|
||||
"invalid_code": "Invalid Code",
|
||||
"authorize": "Authorize"
|
||||
},
|
||||
"media_controls": {
|
||||
"media_controls_title": "Media Controls",
|
||||
"forward_skip_length": "Forward skip length",
|
||||
"rewind_length": "Rewind length",
|
||||
"forward_skip_length": "Forward Skip Length",
|
||||
"rewind_length": "Rewind Length",
|
||||
"seconds_unit": "s"
|
||||
},
|
||||
"gesture_controls": {
|
||||
"gesture_controls_title": "Gesture Controls",
|
||||
"horizontal_swipe_skip": "Horizontal Swipe to Skip",
|
||||
"horizontal_swipe_skip_description": "Swipe left/right when controls are hidden to skip",
|
||||
"left_side_brightness": "Left Side Brightness Control",
|
||||
"left_side_brightness_description": "Swipe up/down on left side to adjust brightness",
|
||||
"right_side_volume": "Right Side Volume Control",
|
||||
"right_side_volume_description": "Swipe up/down on right side to adjust volume",
|
||||
"hide_volume_slider": "Hide Volume Slider",
|
||||
"hide_volume_slider_description": "Hide the volume slider in the video player",
|
||||
"hide_brightness_slider": "Hide Brightness Slider",
|
||||
"hide_brightness_slider_description": "Hide the brightness slider in the video player"
|
||||
},
|
||||
"audio": {
|
||||
"audio_title": "Audio",
|
||||
"set_audio_track": "Set Audio Track From Previous Item",
|
||||
"audio_language": "Audio language",
|
||||
"audio_language": "Audio Language",
|
||||
"audio_hint": "Choose a default audio language.",
|
||||
"none": "None",
|
||||
"language": "Language"
|
||||
"language": "Language",
|
||||
"transcode_mode": {
|
||||
"title": "Audio Transcoding",
|
||||
"description": "Controls how surround audio (7.1, TrueHD, DTS-HD) is handled",
|
||||
"auto": "Auto",
|
||||
"stereo": "Force Stereo",
|
||||
"5_1": "Allow 5.1",
|
||||
"passthrough": "Passthrough"
|
||||
}
|
||||
},
|
||||
"subtitles": {
|
||||
"subtitle_title": "Subtitles",
|
||||
"subtitle_hint": "Configure how subtitles look and behave.",
|
||||
"subtitle_language": "Subtitle language",
|
||||
"subtitle_mode": "Subtitle Mode",
|
||||
"set_subtitle_track": "Set Subtitle Track From Previous Item",
|
||||
"subtitle_size": "Subtitle Size",
|
||||
"subtitle_hint": "Configure subtitle preference.",
|
||||
"none": "None",
|
||||
"language": "Language",
|
||||
"loading": "Loading",
|
||||
@@ -109,46 +219,110 @@
|
||||
"Always": "Always",
|
||||
"None": "None",
|
||||
"OnlyForced": "OnlyForced"
|
||||
}
|
||||
},
|
||||
"text_color": "Text Color",
|
||||
"background_color": "Background Color",
|
||||
"outline_color": "Outline Color",
|
||||
"outline_thickness": "Outline Thickness",
|
||||
"background_opacity": "Background Opacity",
|
||||
"outline_opacity": "Outline Opacity",
|
||||
"bold_text": "Bold Text",
|
||||
"colors": {
|
||||
"Black": "Black",
|
||||
"Gray": "Gray",
|
||||
"Silver": "Silver",
|
||||
"White": "White",
|
||||
"Maroon": "Maroon",
|
||||
"Red": "Red",
|
||||
"Fuchsia": "Fuchsia",
|
||||
"Yellow": "Yellow",
|
||||
"Olive": "Olive",
|
||||
"Green": "Green",
|
||||
"Teal": "Teal",
|
||||
"Lime": "Lime",
|
||||
"Purple": "Purple",
|
||||
"Navy": "Navy",
|
||||
"Blue": "Blue",
|
||||
"Aqua": "Aqua"
|
||||
},
|
||||
"thickness": {
|
||||
"None": "None",
|
||||
"Thin": "Thin",
|
||||
"Normal": "Normal",
|
||||
"Thick": "Thick"
|
||||
},
|
||||
"subtitle_color": "Subtitle Color",
|
||||
"subtitle_background_color": "Background Color",
|
||||
"subtitle_font": "Subtitle Font",
|
||||
"ksplayer_title": "KSPlayer Settings",
|
||||
"hardware_decode": "Hardware Decoding",
|
||||
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues."
|
||||
},
|
||||
"vlc_subtitles": {
|
||||
"title": "VLC Subtitle Settings",
|
||||
"hint": "Customize subtitle appearance for VLC player. Changes take effect on next playback.",
|
||||
"text_color": "Text Color",
|
||||
"background_color": "Background Color",
|
||||
"background_opacity": "Background Opacity",
|
||||
"outline_color": "Outline Color",
|
||||
"outline_opacity": "Outline Opacity",
|
||||
"outline_thickness": "Outline Thickness",
|
||||
"bold": "Bold Text",
|
||||
"margin": "Bottom Margin"
|
||||
},
|
||||
"video_player": {
|
||||
"title": "Video Player",
|
||||
"video_player": "Video Player",
|
||||
"video_player_description": "Choose which video player to use on iOS.",
|
||||
"ksplayer": "KSPlayer",
|
||||
"vlc": "VLC"
|
||||
},
|
||||
"other": {
|
||||
"other_title": "Other",
|
||||
"auto_rotate": "Auto rotate",
|
||||
"video_orientation": "Video orientation",
|
||||
"video_orientation": "Video Orientation",
|
||||
"orientation": "Orientation",
|
||||
"orientations": {
|
||||
"DEFAULT": "Default",
|
||||
"DEFAULT": "Follow Device Orientation",
|
||||
"ALL": "All",
|
||||
"PORTRAIT": "Portrait",
|
||||
"PORTRAIT": "Portrait Auto",
|
||||
"PORTRAIT_UP": "Portrait Up",
|
||||
"PORTRAIT_DOWN": "Portrait Down",
|
||||
"LANDSCAPE": "Landscape",
|
||||
"LANDSCAPE": "Landscape Auto",
|
||||
"LANDSCAPE_LEFT": "Landscape Left",
|
||||
"LANDSCAPE_RIGHT": "Landscape Right",
|
||||
"OTHER": "Other",
|
||||
"UNKNOWN": "Unknown"
|
||||
},
|
||||
"safe_area_in_controls": "Safe area in controls",
|
||||
"safe_area_in_controls": "Safe Area in Controls",
|
||||
"video_player": "Video Player",
|
||||
"video_players": {
|
||||
"VLC_3": "VLC 3",
|
||||
"VLC_4": "VLC 4 (Experimental + PiP)"
|
||||
},
|
||||
"show_custom_menu_links": "Show Custom Menu Links",
|
||||
"show_large_home_carousel": "Show Large Home Carousel (beta)",
|
||||
"hide_libraries": "Hide Libraries",
|
||||
"select_liraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.",
|
||||
"disable_haptic_feedback": "Disable Haptic Feedback",
|
||||
"default_quality": "Default quality"
|
||||
"default_quality": "Default Quality",
|
||||
"default_playback_speed": "Default Playback Speed",
|
||||
"auto_play_next_episode": "Auto-play Next Episode",
|
||||
"max_auto_play_episode_count": "Max Auto Play Episode Count",
|
||||
"disabled": "Disabled"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Downloads",
|
||||
"download_method": "Download method",
|
||||
"remux_max_download": "Remux max download",
|
||||
"auto_download": "Auto download",
|
||||
"optimized_versions_server": "Optimized versions server",
|
||||
"save_button": "Save",
|
||||
"optimized_server": "Optimized Server",
|
||||
"optimized": "Optimized",
|
||||
"default": "Default",
|
||||
"optimized_version_hint": "Enter the URL for the optimize server. The URL should include http or https and optionally the port.",
|
||||
"read_more_about_optimized_server": "Read more about the optimize server.",
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://domain.org:port"
|
||||
"downloads_title": "Downloads"
|
||||
},
|
||||
"music": {
|
||||
"title": "Music",
|
||||
"playback_title": "Playback",
|
||||
"playback_description": "Configure how music is played.",
|
||||
"prefer_downloaded": "Prefer Downloaded Songs",
|
||||
"caching_title": "Caching",
|
||||
"caching_description": "Automatically cache upcoming tracks for smoother playback.",
|
||||
"lookahead_enabled": "Enable Look-Ahead Caching",
|
||||
"lookahead_count": "Tracks to Pre-cache",
|
||||
"max_cache_size": "Max Cache Size"
|
||||
},
|
||||
"plugins": {
|
||||
"plugins_title": "Plugins",
|
||||
@@ -156,128 +330,201 @@
|
||||
"jellyseerr_warning": "This integration is in its early stages. Expect things to change.",
|
||||
"server_url": "Server URL",
|
||||
"server_url_hint": "Example: http(s)://your-host.url\n(add port if required)",
|
||||
"server_url_placeholder": "Jellyseerr URL...",
|
||||
"server_url_placeholder": "Seerr URL",
|
||||
"password": "Password",
|
||||
"password_placeholder": "Enter password for Jellyfin user {{username}}",
|
||||
"save_button": "Save",
|
||||
"clear_button": "Clear",
|
||||
"login_button": "Login",
|
||||
"total_media_requests": "Total media requests",
|
||||
"movie_quota_limit": "Movie quota limit",
|
||||
"movie_quota_days": "Movie quota days",
|
||||
"tv_quota_limit": "TV quota limit",
|
||||
"tv_quota_days": "TV quota days",
|
||||
"reset_jellyseerr_config_button": "Reset Jellyseerr config",
|
||||
"unlimited": "Unlimited"
|
||||
"total_media_requests": "Total Media Requests",
|
||||
"movie_quota_limit": "Movie Quota Limit",
|
||||
"movie_quota_days": "Movie Quota Days",
|
||||
"tv_quota_limit": "TV Quota Limit",
|
||||
"tv_quota_days": "TV Quota Days",
|
||||
"reset_jellyseerr_config_button": "Reset Seerr Config",
|
||||
"unlimited": "Unlimited",
|
||||
"plus_n_more": "+{{n}} More",
|
||||
"order_by": {
|
||||
"DEFAULT": "Default",
|
||||
"VOTE_COUNT_AND_AVERAGE": "Vote count and average",
|
||||
"POPULARITY": "Popularity"
|
||||
}
|
||||
},
|
||||
"marlin_search": {
|
||||
"enable_marlin_search": "Enable Marlin Search ",
|
||||
"enable_marlin_search": "Enable Marlin Search",
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://domain.org:port",
|
||||
"marlin_search_hint": "Enter the URL for the Marlin server. The URL should include http or https and optionally the port.",
|
||||
"read_more_about_marlin": "Read more about Marlin.",
|
||||
"read_more_about_marlin": "Read More About Marlin.",
|
||||
"save_button": "Save",
|
||||
"toasts": {
|
||||
"saved": "Saved"
|
||||
}
|
||||
"saved": "Saved",
|
||||
"refreshed": "Settings refreshed from server"
|
||||
},
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"streamystats": {
|
||||
"enable_streamystats": "Enable Streamystats",
|
||||
"disable_streamystats": "Disable Streamystats",
|
||||
"enable_search": "Use for Search",
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
||||
"streamystats_search_hint": "Enter the URL for your Streamystats server. The URL should include http or https and optionally the port.",
|
||||
"read_more_about_streamystats": "Read More About Streamystats.",
|
||||
"save_button": "Save",
|
||||
"save": "Save",
|
||||
"features_title": "Features",
|
||||
"home_sections_title": "Home Sections",
|
||||
"enable_movie_recommendations": "Movie Recommendations",
|
||||
"enable_series_recommendations": "Series Recommendations",
|
||||
"enable_promoted_watchlists": "Promoted Watchlists",
|
||||
"hide_watchlists_tab": "Hide Watchlists Tab",
|
||||
"home_sections_hint": "Show personalized recommendations and promoted watchlists from Streamystats on the home page.",
|
||||
"recommended_movies": "Recommended Movies",
|
||||
"recommended_series": "Recommended Series",
|
||||
"toasts": {
|
||||
"saved": "Saved",
|
||||
"refreshed": "Settings refreshed from server",
|
||||
"disabled": "Streamystats disabled"
|
||||
},
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"kefinTweaks": {
|
||||
"watchlist_enabler": "Enable our Watchlist integration",
|
||||
"watchlist_button": "Toggle Watchlist integration"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
"storage_title": "Storage",
|
||||
"app_usage": "App {{usedSpace}}%",
|
||||
"device_usage": "Device {{availableSpace}}%",
|
||||
"size_used": "{{used}} of {{total}} used",
|
||||
"delete_all_downloaded_files": "Delete All Downloaded Files"
|
||||
"size_used": "{{used}} of {{total}} Used",
|
||||
"delete_all_downloaded_files": "Delete All Downloaded Files",
|
||||
"music_cache_title": "Music Cache",
|
||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||
"enable_music_cache": "Enable Music Cache",
|
||||
"clear_music_cache": "Clear Music Cache",
|
||||
"music_cache_size": "{{size}} cached",
|
||||
"music_cache_cleared": "Music cache cleared",
|
||||
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
|
||||
"downloaded_songs_size": "{{size}} downloaded",
|
||||
"downloaded_songs_deleted": "Downloaded songs deleted"
|
||||
},
|
||||
"intro": {
|
||||
"show_intro": "Show intro",
|
||||
"reset_intro": "Reset intro"
|
||||
"title": "Intro",
|
||||
"show_intro": "Show Intro",
|
||||
"reset_intro": "Reset Intro"
|
||||
},
|
||||
"logs": {
|
||||
"logs_title": "Logs",
|
||||
"no_logs_available": "No logs available",
|
||||
"delete_all_logs": "Delete all logs"
|
||||
"export_logs": "Export Logs",
|
||||
"click_for_more_info": "Click for More Info",
|
||||
"level": "Level",
|
||||
"no_logs_available": "No Logs Available",
|
||||
"delete_all_logs": "Delete All Logs"
|
||||
},
|
||||
"languages": {
|
||||
"title": "Languages",
|
||||
"app_language": "App language",
|
||||
"app_language_description": "Select the language for the app.",
|
||||
"app_language": "App Language",
|
||||
"system": "System"
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "Error deleting files",
|
||||
"error_deleting_files": "Error Deleting Files",
|
||||
"background_downloads_enabled": "Background downloads enabled",
|
||||
"background_downloads_disabled": "Background downloads disabled",
|
||||
"connected": "Connected",
|
||||
"could_not_connect": "Could not connect",
|
||||
"invalid_url": "Invalid URL"
|
||||
},
|
||||
"background_downloads_disabled": "Background downloads disabled"
|
||||
}
|
||||
},
|
||||
"sessions": {
|
||||
"title": "Sessions",
|
||||
"no_active_sessions": "No active sessions"
|
||||
"no_active_sessions": "No Active Sessions"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Downloads",
|
||||
"tvseries": "TV-Series",
|
||||
"movies": "Movies",
|
||||
"queue": "Queue",
|
||||
"other_media": "Other media",
|
||||
"queue_hint": "Queue and downloads will be lost on app restart",
|
||||
"no_items_in_queue": "No items in queue",
|
||||
"no_downloaded_items": "No downloaded items",
|
||||
"delete_all_movies_button": "Delete all Movies",
|
||||
"delete_all_tvseries_button": "Delete all TV-Series",
|
||||
"delete_all_button": "Delete all",
|
||||
"active_download": "Active download",
|
||||
"no_active_downloads": "No active downloads",
|
||||
"active_downloads": "Active downloads",
|
||||
"no_items_in_queue": "No Items in Queue",
|
||||
"no_downloaded_items": "No Downloaded Items",
|
||||
"delete_all_movies_button": "Delete All Movies",
|
||||
"delete_all_tvseries_button": "Delete All TV-Series",
|
||||
"delete_all_button": "Delete All",
|
||||
"delete_all_other_media_button": "Delete other media",
|
||||
"active_download": "Active Download",
|
||||
"no_active_downloads": "No Active Downloads",
|
||||
"active_downloads": "Active Downloads",
|
||||
"new_app_version_requires_re_download": "New app version requires re-download",
|
||||
"new_app_version_requires_re_download_description": "The new update requires content to be downloaded again. Please remove all downloaded content and try again.",
|
||||
"back": "Back",
|
||||
"delete": "Delete",
|
||||
"something_went_wrong": "Something went wrong",
|
||||
"something_went_wrong": "Something Went Wrong",
|
||||
"could_not_get_stream_url_from_jellyfin": "Could not get the stream URL from Jellyfin",
|
||||
"eta": "ETA {{eta}}",
|
||||
"methods": "Methods",
|
||||
"toasts": {
|
||||
"you_are_not_allowed_to_download_files": "You are not allowed to download files.",
|
||||
"deleted_all_movies_successfully": "Deleted all movies successfully!",
|
||||
"failed_to_delete_all_movies": "Failed to delete all movies",
|
||||
"deleted_all_tvseries_successfully": "Deleted all TV-Series successfully!",
|
||||
"failed_to_delete_all_tvseries": "Failed to delete all TV-Series",
|
||||
"download_cancelled": "Download cancelled",
|
||||
"could_not_cancel_download": "Could not cancel download",
|
||||
"download_completed": "Download completed",
|
||||
"download_started_for": "Download started for {{item}}",
|
||||
"item_is_ready_to_be_downloaded": "{{item}} is ready to be downloaded",
|
||||
"download_stated_for_item": "Download started for {{item}}",
|
||||
"deleted_all_movies_successfully": "Deleted All Movies Successfully!",
|
||||
"failed_to_delete_all_movies": "Failed to Delete All Movies",
|
||||
"deleted_all_tvseries_successfully": "Deleted All TV-Series Successfully!",
|
||||
"failed_to_delete_all_tvseries": "Failed to Delete All TV-Series",
|
||||
"deleted_media_successfully": "Deleted other media Successfully!",
|
||||
"failed_to_delete_media": "Failed to Delete other media",
|
||||
"download_deleted": "Download Deleted",
|
||||
"download_cancelled": "Download Cancelled",
|
||||
"could_not_delete_download": "Could Not Delete Download",
|
||||
"download_paused": "Download Paused",
|
||||
"could_not_pause_download": "Could Not Pause Download",
|
||||
"download_resumed": "Download Resumed",
|
||||
"could_not_resume_download": "Could Not Resume Download",
|
||||
"download_completed": "Download Completed",
|
||||
"download_failed": "Download Failed",
|
||||
"download_failed_for_item": "Download failed for {{item}} - {{error}}",
|
||||
"download_completed_for_item": "Download completed for {{item}}",
|
||||
"queued_item_for_optimization": "Queued {{item}} for optimization",
|
||||
"failed_to_start_download_for_item": "Failed to start downloading for {{item}}: {{message}}",
|
||||
"server_responded_with_status_code": "Server responded with status {{statusCode}}",
|
||||
"no_response_received_from_server": "No response received from the server",
|
||||
"error_setting_up_the_request": "Error setting up the request",
|
||||
"failed_to_start_download_for_item_unexpected_error": "Failed to start downloading for {{item}}: Unexpected error",
|
||||
"download_completed_for_item": "Download Completed for {{item}}",
|
||||
"download_started_for_item": "Download Started for {{item}}",
|
||||
"failed_to_start_download": "Failed to start download",
|
||||
"item_already_downloading": "{{item}} is already downloading",
|
||||
"all_files_deleted": "All Downloads Deleted Successfully",
|
||||
"files_deleted_by_type": "{{count}} {{type}} deleted",
|
||||
"all_files_folders_and_jobs_deleted_successfully": "All files, folders, and jobs deleted successfully",
|
||||
"an_error_occured_while_deleting_files_and_jobs": "An error occurred while deleting files and jobs",
|
||||
"go_to_downloads": "Go to downloads"
|
||||
"failed_to_clean_cache_directory": "Failed to clean cache directory",
|
||||
"could_not_get_download_url_for_item": "Could not get download URL for {{itemName}}",
|
||||
"go_to_downloads": "Go to Downloads",
|
||||
"file_deleted": "{{item}} deleted"
|
||||
}
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"select": "Select",
|
||||
"no_trailer_available": "No trailer available",
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
"subtitle": "Subtitle",
|
||||
"play": "Play",
|
||||
"none": "None",
|
||||
"track": "Track",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"ok": "OK",
|
||||
"remove": "Remove",
|
||||
"next": "Next",
|
||||
"back": "Back",
|
||||
"continue": "Continue",
|
||||
"verifying": "Verifying..."
|
||||
},
|
||||
"search": {
|
||||
"search_here": "Search here...",
|
||||
"search": "Search...",
|
||||
"x_items": "{{count}} items",
|
||||
"x_items": "{{count}} Items",
|
||||
"library": "Library",
|
||||
"discover": "Discover",
|
||||
"no_results": "No results",
|
||||
"no_results_found_for": "No results found for",
|
||||
"no_results": "No Results",
|
||||
"no_results_found_for": "No Results Found For",
|
||||
"movies": "Movies",
|
||||
"series": "Series",
|
||||
"episodes": "Episodes",
|
||||
"collections": "Collections",
|
||||
"actors": "Actors",
|
||||
"artists": "Artists",
|
||||
"albums": "Albums",
|
||||
"songs": "Songs",
|
||||
"playlists": "Playlists",
|
||||
"request_movies": "Request Movies",
|
||||
"request_series": "Request Series",
|
||||
"recently_added": "Recently Added",
|
||||
@@ -303,29 +550,29 @@
|
||||
"tmdb_tv_streaming_services": "TMDB TV Streaming Services"
|
||||
},
|
||||
"library": {
|
||||
"no_items_found": "No items found",
|
||||
"no_results": "No results",
|
||||
"no_libraries_found": "No libraries found",
|
||||
"no_results": "No Results",
|
||||
"no_libraries_found": "No Libraries Found",
|
||||
"item_types": {
|
||||
"movies": "movies",
|
||||
"series": "series",
|
||||
"boxsets": "box sets",
|
||||
"items": "items"
|
||||
"movies": "Movies",
|
||||
"series": "Series",
|
||||
"boxsets": "Box Sets",
|
||||
"items": "Items"
|
||||
},
|
||||
"options": {
|
||||
"display": "Display",
|
||||
"row": "Row",
|
||||
"list": "List",
|
||||
"image_style": "Image style",
|
||||
"image_style": "Image Style",
|
||||
"poster": "Poster",
|
||||
"cover": "Cover",
|
||||
"show_titles": "Show titles",
|
||||
"show_stats": "Show stats"
|
||||
"show_titles": "Show Titles",
|
||||
"show_stats": "Show Stats"
|
||||
},
|
||||
"filters": {
|
||||
"genres": "Genres",
|
||||
"years": "Years",
|
||||
"sort_by": "Sort By",
|
||||
"filter_by": "Filter By",
|
||||
"sort_order": "Sort Order",
|
||||
"tags": "Tags"
|
||||
}
|
||||
@@ -335,32 +582,37 @@
|
||||
"movies": "Movies",
|
||||
"episodes": "Episodes",
|
||||
"videos": "Videos",
|
||||
"boxsets": "Boxsets",
|
||||
"playlists": "Playlists"
|
||||
"boxsets": "Box Sets",
|
||||
"playlists": "Playlists",
|
||||
"noDataTitle": "No Favorites Yet",
|
||||
"noData": "Mark items as favorites to see them appear here for quick access."
|
||||
},
|
||||
"custom_links": {
|
||||
"no_links": "No links"
|
||||
"no_links": "No Links"
|
||||
},
|
||||
"player": {
|
||||
"error": "Error",
|
||||
"failed_to_get_stream_url": "Failed to get the stream URL",
|
||||
"an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
|
||||
"client_error": "Client error",
|
||||
"client_error": "Client Error",
|
||||
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
|
||||
"message_from_server": "Message from server: {{message}}",
|
||||
"video_has_finished_playing": "Video has finished playing!",
|
||||
"no_video_source": "No video source...",
|
||||
"message_from_server": "Message from Server: {{message}}",
|
||||
"next_episode": "Next Episode",
|
||||
"refresh_tracks": "Refresh Tracks",
|
||||
"subtitle_tracks": "Subtitle Tracks:",
|
||||
"audio_tracks": "Audio Tracks:",
|
||||
"playback_state": "Playback State:",
|
||||
"no_data_available": "No data available",
|
||||
"index": "Index:"
|
||||
"index": "Index:",
|
||||
"continue_watching": "Continue Watching",
|
||||
"go_back": "Go Back",
|
||||
"downloaded_file_title": "You have this file downloaded",
|
||||
"downloaded_file_message": "Do you want to play the downloaded file?",
|
||||
"downloaded_file_yes": "Yes",
|
||||
"downloaded_file_no": "No",
|
||||
"downloaded_file_cancel": "Cancel"
|
||||
},
|
||||
"item_card": {
|
||||
"next_up": "Next up",
|
||||
"no_items_to_display": "No items to display",
|
||||
"next_up": "Next Up",
|
||||
"no_items_to_display": "No Items to Display",
|
||||
"cast_and_crew": "Cast & Crew",
|
||||
"series": "Series",
|
||||
"seasons": "Seasons",
|
||||
@@ -368,35 +620,34 @@
|
||||
"no_episodes_for_this_season": "No episodes for this season",
|
||||
"overview": "Overview",
|
||||
"more_with": "More with {{name}}",
|
||||
"similar_items": "Similar items",
|
||||
"no_similar_items_found": "No similar items found",
|
||||
"similar_items": "Similar Items",
|
||||
"no_similar_items_found": "No Similar Items Found",
|
||||
"video": "Video",
|
||||
"more_details": "More details",
|
||||
"more_details": "More Details",
|
||||
"media_options": "Media Options",
|
||||
"quality": "Quality",
|
||||
"audio": "Audio",
|
||||
"subtitles": "Subtitle",
|
||||
"show_more": "Show more",
|
||||
"show_less": "Show less",
|
||||
"appeared_in": "Appeared in",
|
||||
"could_not_load_item": "Could not load item",
|
||||
"show_more": "Show More",
|
||||
"show_less": "Show Less",
|
||||
"appeared_in": "Appeared In",
|
||||
"could_not_load_item": "Could Not Load Item",
|
||||
"none": "None",
|
||||
"download": {
|
||||
"download_season": "Download Season",
|
||||
"download_series": "Download Series",
|
||||
"download_episode": "Download Episode",
|
||||
"download_movie": "Download Movie",
|
||||
"download_x_item": "Download {{item_count}} items",
|
||||
"download_button": "Download",
|
||||
"using_optimized_server": "Using optimized server",
|
||||
"using_default_method": "Using default method"
|
||||
"download_x_item": "Download {{item_count}} Items",
|
||||
"download_unwatched_only": "Unwatched Only",
|
||||
"download_button": "Download"
|
||||
}
|
||||
},
|
||||
"live_tv": {
|
||||
"next": "Next",
|
||||
"previous": "Previous",
|
||||
"live_tv": "Live TV",
|
||||
"coming_soon": "Coming soon",
|
||||
"on_now": "On now",
|
||||
"coming_soon": "Coming Soon",
|
||||
"on_now": "On Now",
|
||||
"shows": "Shows",
|
||||
"movies": "Movies",
|
||||
"sports": "Sports",
|
||||
@@ -407,16 +658,16 @@
|
||||
"confirm": "Confirm",
|
||||
"cancel": "Cancel",
|
||||
"yes": "Yes",
|
||||
"whats_wrong": "What's wrong?",
|
||||
"issue_type": "Issue type",
|
||||
"select_an_issue": "Select an issue",
|
||||
"whats_wrong": "What's Wrong?",
|
||||
"issue_type": "Issue Type",
|
||||
"select_an_issue": "Select an Issue",
|
||||
"types": "Types",
|
||||
"describe_the_issue": "(optional) Describe the issue...",
|
||||
"describe_the_issue": "(Optional) Describe the Issue...",
|
||||
"submit_button": "Submit",
|
||||
"report_issue_button": "Report issue",
|
||||
"report_issue_button": "Report Issue",
|
||||
"request_button": "Request",
|
||||
"are_you_sure_you_want_to_request_all_seasons": "Are you sure you want to request all seasons?",
|
||||
"failed_to_login": "Failed to login",
|
||||
"failed_to_login": "Failed to Login",
|
||||
"cast": "Cast",
|
||||
"details": "Details",
|
||||
"status": "Status",
|
||||
@@ -431,25 +682,33 @@
|
||||
"production_country": "Production Country",
|
||||
"studios": "Studios",
|
||||
"network": "Network",
|
||||
"currently_streaming_on": "Currently Streaming on",
|
||||
"currently_streaming_on": "Currently Streaming On",
|
||||
"advanced": "Advanced",
|
||||
"request_as": "Request As",
|
||||
"tags": "Tags",
|
||||
"quality_profile": "Quality Profile",
|
||||
"root_folder": "Root Folder",
|
||||
"season_x": "Season {{seasons}}",
|
||||
"season_all": "Season (All)",
|
||||
"season_number": "Season {{season_number}}",
|
||||
"number_episodes": "{{episode_number}} Episodes",
|
||||
"born": "Born",
|
||||
"appearances": "Appearances",
|
||||
"approve": "Approve",
|
||||
"decline": "Decline",
|
||||
"requested_by": "Requested by {{user}}",
|
||||
"unknown_user": "Unknown User",
|
||||
"toasts": {
|
||||
"jellyseer_does_not_meet_requirements": "Jellyseerr server does not meet minimum version requirements! Please update to at least 2.0.0",
|
||||
"jellyseerr_test_failed": "Jellyseerr test failed. Please try again.",
|
||||
"failed_to_test_jellyseerr_server_url": "Failed to test jellyseerr server url",
|
||||
"issue_submitted": "Issue submitted!",
|
||||
"jellyseer_does_not_meet_requirements": "Seerr server does not meet minimum version requirements! Please update to at least 2.0.0",
|
||||
"jellyseerr_test_failed": "Seerr test failed. Please try again.",
|
||||
"failed_to_test_jellyseerr_server_url": "Failed to test Seerr server url",
|
||||
"issue_submitted": "Issue Submitted!",
|
||||
"requested_item": "Requested {{item}}!",
|
||||
"you_dont_have_permission_to_request": "You don't have permission to request!",
|
||||
"something_went_wrong_requesting_media": "Something went wrong requesting media!"
|
||||
"something_went_wrong_requesting_media": "Something went wrong requesting media!",
|
||||
"request_approved": "Request Approved!",
|
||||
"request_declined": "Request Declined!",
|
||||
"failed_to_approve_request": "Failed to Approve Request",
|
||||
"failed_to_decline_request": "Failed to Decline Request"
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
@@ -459,16 +718,128 @@
|
||||
"custom_links": "Custom Links",
|
||||
"favorites": "Favorites"
|
||||
},
|
||||
"chromecast": {
|
||||
"no_devices_available": "No Google Cast devices available.",
|
||||
"are_you_on_same_network": "Are you on the same network?",
|
||||
"no_device_selected": "No device selected",
|
||||
"click_icon_to_connect": "Click icon to connect.",
|
||||
"establishing_connection": "Establishing connection...",
|
||||
"no_media_selected": "No media selected.",
|
||||
"start_playing": "Start playing any media",
|
||||
"go_home": "Go Home",
|
||||
"error_loading_item": "An unexpected error occurred while loading the media.",
|
||||
"retry_load_item": "Retry"
|
||||
"music": {
|
||||
"title": "Music",
|
||||
"tabs": {
|
||||
"suggestions": "Suggestions",
|
||||
"albums": "Albums",
|
||||
"artists": "Artists",
|
||||
"playlists": "Playlists",
|
||||
"tracks": "tracks"
|
||||
},
|
||||
"filters": {
|
||||
"all": "All"
|
||||
},
|
||||
"recently_added": "Recently Added",
|
||||
"recently_played": "Recently Played",
|
||||
"frequently_played": "Frequently Played",
|
||||
"explore": "Explore",
|
||||
"top_tracks": "Top Tracks",
|
||||
"play": "Play",
|
||||
"shuffle": "Shuffle",
|
||||
"play_top_tracks": "Play Top Tracks",
|
||||
"no_suggestions": "No suggestions available",
|
||||
"no_albums": "No albums found",
|
||||
"no_artists": "No artists found",
|
||||
"no_playlists": "No playlists found",
|
||||
"album_not_found": "Album not found",
|
||||
"artist_not_found": "Artist not found",
|
||||
"playlist_not_found": "Playlist not found",
|
||||
"track_options": {
|
||||
"play_next": "Play Next",
|
||||
"add_to_queue": "Add to Queue",
|
||||
"add_to_playlist": "Add to Playlist",
|
||||
"download": "Download",
|
||||
"downloaded": "Downloaded",
|
||||
"downloading": "Downloading...",
|
||||
"cached": "Cached",
|
||||
"delete_download": "Delete Download",
|
||||
"delete_cache": "Remove from Cache",
|
||||
"go_to_artist": "Go to Artist",
|
||||
"go_to_album": "Go to Album",
|
||||
"add_to_favorites": "Add to Favorites",
|
||||
"remove_from_favorites": "Remove from Favorites",
|
||||
"remove_from_playlist": "Remove from Playlist"
|
||||
},
|
||||
"playlists": {
|
||||
"create_playlist": "Create Playlist",
|
||||
"playlist_name": "Playlist Name",
|
||||
"enter_name": "Enter playlist name",
|
||||
"create": "Create",
|
||||
"search_playlists": "Search playlists...",
|
||||
"added_to": "Added to {{name}}",
|
||||
"added": "Added to playlist",
|
||||
"removed_from": "Removed from {{name}}",
|
||||
"removed": "Removed from playlist",
|
||||
"created": "Playlist created",
|
||||
"create_new": "Create New Playlist",
|
||||
"failed_to_add": "Failed to add to playlist",
|
||||
"failed_to_remove": "Failed to remove from playlist",
|
||||
"failed_to_create": "Failed to create playlist",
|
||||
"delete_playlist": "Delete Playlist",
|
||||
"delete_confirm": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
|
||||
"deleted": "Playlist deleted",
|
||||
"failed_to_delete": "Failed to delete playlist"
|
||||
},
|
||||
"sort": {
|
||||
"title": "Sort By",
|
||||
"alphabetical": "Alphabetical",
|
||||
"date_created": "Date Created"
|
||||
}
|
||||
},
|
||||
"watchlists": {
|
||||
"title": "Watchlists",
|
||||
"my_watchlists": "My Watchlists",
|
||||
"public_watchlists": "Public Watchlists",
|
||||
"create_title": "Create Watchlist",
|
||||
"edit_title": "Edit Watchlist",
|
||||
"create_button": "Create Watchlist",
|
||||
"save_button": "Save Changes",
|
||||
"delete_button": "Delete",
|
||||
"remove_button": "Remove",
|
||||
"cancel_button": "Cancel",
|
||||
"name_label": "Name",
|
||||
"name_placeholder": "Enter watchlist name",
|
||||
"description_label": "Description",
|
||||
"description_placeholder": "Enter description (optional)",
|
||||
"is_public_label": "Public Watchlist",
|
||||
"is_public_description": "Allow others to view this watchlist",
|
||||
"allowed_type_label": "Content Type",
|
||||
"sort_order_label": "Default Sort Order",
|
||||
"empty_title": "No Watchlists",
|
||||
"empty_description": "Create your first watchlist to start organizing your media",
|
||||
"empty_watchlist": "This watchlist is empty",
|
||||
"empty_watchlist_hint": "Add items from your library to this watchlist",
|
||||
"not_configured_title": "Streamystats Not Configured",
|
||||
"not_configured_description": "Configure Streamystats in settings to use watchlists",
|
||||
"go_to_settings": "Go to Settings",
|
||||
"add_to_watchlist": "Add to Watchlist",
|
||||
"remove_from_watchlist": "Remove from Watchlist",
|
||||
"select_watchlist": "Select Watchlist",
|
||||
"create_new": "Create New Watchlist",
|
||||
"item": "item",
|
||||
"items": "items",
|
||||
"public": "Public",
|
||||
"private": "Private",
|
||||
"you": "You",
|
||||
"by_owner": "By another user",
|
||||
"not_found": "Watchlist not found",
|
||||
"delete_confirm_title": "Delete Watchlist",
|
||||
"delete_confirm_message": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
|
||||
"remove_item_title": "Remove from Watchlist",
|
||||
"remove_item_message": "Remove \"{{name}}\" from this watchlist?",
|
||||
"loading": "Loading watchlists...",
|
||||
"no_compatible_watchlists": "No compatible watchlists",
|
||||
"create_one_first": "Create a watchlist that accepts this content type"
|
||||
},
|
||||
"playback_speed": {
|
||||
"title": "Playback Speed",
|
||||
"apply_to": "Apply To",
|
||||
"speed": "Speed",
|
||||
"scope": {
|
||||
"media": "This media only",
|
||||
"show": "This show",
|
||||
"all": "All media (default)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,9 @@
|
||||
"server_is_taking_too_long_to_respond_try_again_later": "El servidor está tardando mucho en responder, inténtalo de nuevo más tarde.",
|
||||
"server_received_too_many_requests_try_again_later": "El servidor está recibiendo muchas peticiones, inténtalo de nuevo más tarde.",
|
||||
"there_is_a_server_error": "Hay un error en el servidor",
|
||||
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Ha ocurrido un error inesperado. ¿Has introducido la URL correcta?"
|
||||
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Ha ocurrido un error inesperado. ¿Has introducido la URL correcta?",
|
||||
"too_old_server_text": "Servidor Jellyfin no soportado descubierto",
|
||||
"too_old_server_description": "Por favor, actualiza Jellyfin a la última versión"
|
||||
},
|
||||
"server": {
|
||||
"enter_url_to_jellyfin_server": "Introduce la URL de tu servidor Jellyfin",
|
||||
@@ -28,19 +30,64 @@
|
||||
"connect_button": "Conectar",
|
||||
"previous_servers": "Servidores previos",
|
||||
"clear_button": "Limpiar",
|
||||
"swipe_to_remove": "Deslizar para eliminar",
|
||||
"search_for_local_servers": "Buscar servidores locales",
|
||||
"searching": "Buscando...",
|
||||
"servers": "Servidores"
|
||||
"servers": "Servidores",
|
||||
"saved": "Guardado",
|
||||
"session_expired": "Sesión Caducada",
|
||||
"please_login_again": "Su sesión guardada ha caducado. Por favor, inicie sesión de nuevo.",
|
||||
"remove_saved_login": "Eliminar inicio de sesión guardado",
|
||||
"remove_saved_login_description": "Esto eliminará tus credenciales guardadas para este servidor. Tendrás que volver a introducir tu nombre de usuario y contraseña la próxima vez.",
|
||||
"accounts_count": "{{count}} accounts",
|
||||
"select_account": "Select Account",
|
||||
"add_account": "Add Account",
|
||||
"remove_account_description": "This will remove the saved credentials for {{username}}."
|
||||
},
|
||||
"save_account": {
|
||||
"title": "Save Account",
|
||||
"save_for_later": "Save this account",
|
||||
"security_option": "Security Option",
|
||||
"no_protection": "No protection",
|
||||
"no_protection_desc": "Quick login without authentication",
|
||||
"pin_code": "PIN code",
|
||||
"pin_code_desc": "4-digit PIN required when switching",
|
||||
"password": "Re-enter password",
|
||||
"password_desc": "Password required when switching",
|
||||
"save_button": "Save",
|
||||
"cancel_button": "Cancel"
|
||||
},
|
||||
"pin": {
|
||||
"enter_pin": "Enter PIN",
|
||||
"enter_pin_for": "Enter PIN for {{username}}",
|
||||
"enter_4_digits": "Enter 4 digits",
|
||||
"invalid_pin": "Invalid PIN",
|
||||
"setup_pin": "Set Up PIN",
|
||||
"confirm_pin": "Confirm PIN",
|
||||
"pins_dont_match": "PINs don't match",
|
||||
"forgot_pin": "Forgot PIN?",
|
||||
"forgot_pin_desc": "Your saved credentials will be removed"
|
||||
},
|
||||
"password": {
|
||||
"enter_password": "Enter Password",
|
||||
"enter_password_for": "Enter password for {{username}}",
|
||||
"invalid_password": "Invalid password"
|
||||
},
|
||||
"home": {
|
||||
"checking_server_connection": "Comprobando conexión con el servidor...",
|
||||
"no_internet": "Sin internet",
|
||||
"no_items": "No hay ítems",
|
||||
"no_internet_message": "No te preocupes, todavía puedes\nver el contenido descargado.",
|
||||
"checking_server_connection_message": "Comprobando conexión con el servidor",
|
||||
"go_to_downloads": "Ir a descargas",
|
||||
"retry": "Reintentar",
|
||||
"server_unreachable": "Servidor inaccesible",
|
||||
"server_unreachable_message": "No se pudo acceder al servidor.\nCompruebe su conexión de red.",
|
||||
"oops": "¡Vaya!",
|
||||
"error_message": "Algo ha salido mal.\nPor favor, cierra la sesión y vuelve a iniciar.",
|
||||
"continue_watching": "Seguir viendo",
|
||||
"next_up": "A continuación",
|
||||
"continue_and_next_up": "Continuar y siguiente",
|
||||
"recently_added_in": "Recientemente añadido en {{libraryName}}",
|
||||
"suggested_movies": "Películas sugeridas",
|
||||
"suggested_episodes": "Episodios sugeridos",
|
||||
@@ -62,6 +109,48 @@
|
||||
"settings": {
|
||||
"settings_title": "Configuración",
|
||||
"log_out_button": "Cerrar sesión",
|
||||
"categories": {
|
||||
"title": "Categorías"
|
||||
},
|
||||
"playback_controls": {
|
||||
"title": "Reproducción y controles"
|
||||
},
|
||||
"audio_subtitles": {
|
||||
"title": "Audio y subtítulos"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Apariencia",
|
||||
"merge_next_up_continue_watching": "Fusionar continuar viendo y siguiente",
|
||||
"hide_remote_session_button": "Ocultar botón de sesión remota"
|
||||
},
|
||||
"network": {
|
||||
"title": "Network",
|
||||
"local_network": "Local Network",
|
||||
"auto_switch_enabled": "Auto-switch when at home",
|
||||
"auto_switch_description": "Automatically switch to local URL when connected to home WiFi",
|
||||
"local_url": "Local URL",
|
||||
"local_url_hint": "Enter your local server address (e.g., http://192.168.1.100:8096)",
|
||||
"local_url_placeholder": "http://192.168.1.100:8096",
|
||||
"home_wifi_networks": "Home WiFi Networks",
|
||||
"add_current_network": "Add \"{{ssid}}\"",
|
||||
"not_connected_to_wifi": "Not connected to WiFi",
|
||||
"no_networks_configured": "No networks configured",
|
||||
"add_network_hint": "Add your home WiFi network to enable auto-switching",
|
||||
"current_wifi": "Current WiFi",
|
||||
"using_url": "Using",
|
||||
"local": "Local URL",
|
||||
"remote": "Remote URL",
|
||||
"not_connected": "Not connected",
|
||||
"current_server": "Current Server",
|
||||
"remote_url": "Remote URL",
|
||||
"active_url": "Active URL",
|
||||
"not_configured": "Not configured",
|
||||
"network_added": "Network added",
|
||||
"network_already_added": "Network already added",
|
||||
"no_wifi_connected": "Not connected to WiFi",
|
||||
"permission_denied": "Location permission denied",
|
||||
"permission_denied_explanation": "Location permission is required to detect WiFi network for auto-switching. Please enable it in Settings."
|
||||
},
|
||||
"user_info": {
|
||||
"user_info_title": "Información de usuario",
|
||||
"user": "Usuario",
|
||||
@@ -85,21 +174,42 @@
|
||||
"rewind_length": "Longitud de retroceso",
|
||||
"seconds_unit": "s"
|
||||
},
|
||||
"gesture_controls": {
|
||||
"gesture_controls_title": "Controles de gestos",
|
||||
"horizontal_swipe_skip": "Deslizar horizontal para omitir",
|
||||
"horizontal_swipe_skip_description": "Desliza hacia izquierda/derecha cuando los controles están ocultos para omitir",
|
||||
"left_side_brightness": "Control de brillo lateral izquierdo",
|
||||
"left_side_brightness_description": "Desliza hacia arriba/abajo en el lado izquierdo para ajustar el brillo",
|
||||
"right_side_volume": "Control de volumen derecho",
|
||||
"right_side_volume_description": "Desliza hacia arriba/abajo en el lado derecho para ajustar el volumen",
|
||||
"hide_volume_slider": "Ocultar control deslizante de volumen",
|
||||
"hide_volume_slider_description": "Ocultar control deslizante de volumen en el reproductor de vídeo",
|
||||
"hide_brightness_slider": "Ocultar control deslizante de ajuste de brillo",
|
||||
"hide_brightness_slider_description": "Ocultar control deslizante de brillo en el reproductor de vídeo"
|
||||
},
|
||||
"audio": {
|
||||
"audio_title": "Audio",
|
||||
"set_audio_track": "Establecer pista del elemento anterior",
|
||||
"audio_language": "Idioma de audio",
|
||||
"audio_hint": "Elige un idioma de audio por defecto.",
|
||||
"none": "Ninguno",
|
||||
"language": "Idioma"
|
||||
"language": "Idioma",
|
||||
"transcode_mode": {
|
||||
"title": "Audio Transcoding",
|
||||
"description": "Controls how surround audio (7.1, TrueHD, DTS-HD) is handled",
|
||||
"auto": "Auto",
|
||||
"stereo": "Force Stereo",
|
||||
"5_1": "Allow 5.1",
|
||||
"passthrough": "Passthrough"
|
||||
}
|
||||
},
|
||||
"subtitles": {
|
||||
"subtitle_title": "Subtítulos",
|
||||
"subtitle_hint": "Configurar preferencias de subtítulos.",
|
||||
"subtitle_language": "Idioma de subtítulos",
|
||||
"subtitle_mode": "Modo de subtítulos",
|
||||
"set_subtitle_track": "Establecer pista del elemento anterior",
|
||||
"subtitle_size": "Tamaño de subtítulos",
|
||||
"subtitle_hint": "Configurar preferencias de subtítulos.",
|
||||
"none": "Ninguno",
|
||||
"language": "Idioma",
|
||||
"loading": "Cargando",
|
||||
@@ -109,11 +219,66 @@
|
||||
"Always": "Siempre",
|
||||
"None": "Nada",
|
||||
"OnlyForced": "Solo forzados"
|
||||
}
|
||||
},
|
||||
"text_color": "Color del texto",
|
||||
"background_color": "Color de fondo",
|
||||
"outline_color": "Color de salida",
|
||||
"outline_thickness": "Grosor exterior",
|
||||
"background_opacity": "Opacidad de fondo",
|
||||
"outline_opacity": "Opacidad exterior",
|
||||
"bold_text": "Texto en negrita",
|
||||
"colors": {
|
||||
"Black": "Negro",
|
||||
"Gray": "Gris",
|
||||
"Silver": "Plata",
|
||||
"White": "Blanco",
|
||||
"Maroon": "Granate",
|
||||
"Red": "Rojo",
|
||||
"Fuchsia": "Fucsia",
|
||||
"Yellow": "Amarillo",
|
||||
"Olive": "Oliva",
|
||||
"Green": "Verde",
|
||||
"Teal": "Cereal",
|
||||
"Lime": "Lima",
|
||||
"Purple": "Morado",
|
||||
"Navy": "Naval",
|
||||
"Blue": "Azul",
|
||||
"Aqua": "Agua"
|
||||
},
|
||||
"thickness": {
|
||||
"None": "Ninguno",
|
||||
"Thin": "Ligero",
|
||||
"Normal": "Normal",
|
||||
"Thick": "Grosor"
|
||||
},
|
||||
"subtitle_color": "Color de los Subtítulos",
|
||||
"subtitle_background_color": "Color del fondo",
|
||||
"subtitle_font": "Fuente de los subtítulos",
|
||||
"ksplayer_title": "Ajustes de KSPlayer",
|
||||
"hardware_decode": "Decodificación de hardware",
|
||||
"hardware_decode_description": "Utilizar la aceleración de hardware para la decodificación de vídeo. Deshabilite si experimenta problemas de reproducción."
|
||||
},
|
||||
"vlc_subtitles": {
|
||||
"title": "VLC Subtitle Settings",
|
||||
"hint": "Customize subtitle appearance for VLC player. Changes take effect on next playback.",
|
||||
"text_color": "Text Color",
|
||||
"background_color": "Background Color",
|
||||
"background_opacity": "Background Opacity",
|
||||
"outline_color": "Outline Color",
|
||||
"outline_opacity": "Outline Opacity",
|
||||
"outline_thickness": "Outline Thickness",
|
||||
"bold": "Bold Text",
|
||||
"margin": "Bottom Margin"
|
||||
},
|
||||
"video_player": {
|
||||
"title": "Reproductor de vídeo",
|
||||
"video_player": "Reproductor de vídeo",
|
||||
"video_player_description": "Elige qué reproductor de vídeo en iOS",
|
||||
"ksplayer": "KSPlayer",
|
||||
"vlc": "VLC"
|
||||
},
|
||||
"other": {
|
||||
"other_title": "Otros",
|
||||
"auto_rotate": "Rotación automática",
|
||||
"video_orientation": "Orientación de vídeo",
|
||||
"orientation": "Orientación",
|
||||
"orientations": {
|
||||
@@ -129,26 +294,35 @@
|
||||
"UNKNOWN": "Desconocida"
|
||||
},
|
||||
"safe_area_in_controls": "Área segura en controles",
|
||||
"video_player": "Reproductor de vídeo",
|
||||
"video_players": {
|
||||
"VLC_3": "VLC 3",
|
||||
"VLC_4": "VLC 4 (Experimental + PiP)"
|
||||
},
|
||||
"show_custom_menu_links": "Mostrar enlaces de menú personalizados",
|
||||
"show_large_home_carousel": "Show Large Home Carousel (beta)",
|
||||
"hide_libraries": "Ocultar bibliotecas",
|
||||
"select_liraries_you_want_to_hide": "Selecciona las bibliotecas que quieres ocultar de la pestaña Bibliotecas y de Inicio.",
|
||||
"disable_haptic_feedback": "Desactivar feedback háptico",
|
||||
"default_quality": "Calidad por defecto"
|
||||
"default_quality": "Calidad por defecto",
|
||||
"default_playback_speed": "Velocidad de reproducción predeterminada",
|
||||
"auto_play_next_episode": "Auto-play Next Episode",
|
||||
"max_auto_play_episode_count": "Máximo número de episodios de Auto Play",
|
||||
"disabled": "Deshabilitado"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Descargas",
|
||||
"download_method": "Método de descarga",
|
||||
"remux_max_download": "Remux máx. descarga",
|
||||
"auto_download": "Descarga automática",
|
||||
"optimized_versions_server": "Servidor de versiones optimizadas",
|
||||
"save_button": "Guardar",
|
||||
"optimized_server": "Servidor optimizado",
|
||||
"optimized": "Optimizado",
|
||||
"default": "Por defecto",
|
||||
"optimized_version_hint": "Introduce la URL del servidor de versiones optimizadas. La URL debe incluir http o https y opcionalmente el puerto.",
|
||||
"read_more_about_optimized_server": "Leer más sobre el servidor de versiones optimizadas.",
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://dominio.org:puerto"
|
||||
"downloads_title": "Descargas"
|
||||
},
|
||||
"music": {
|
||||
"title": "Música",
|
||||
"playback_title": "Reproducir",
|
||||
"playback_description": "Configurar cómo se reproduce la música.",
|
||||
"prefer_downloaded": "Prefer Downloaded Songs",
|
||||
"caching_title": "Almacenando en caché",
|
||||
"caching_description": "Automatically cache upcoming tracks for smoother playback.",
|
||||
"lookahead_enabled": "Enable Look-Ahead Caching",
|
||||
"lookahead_count": "",
|
||||
"max_cache_size": "Tamaño máximo del caché"
|
||||
},
|
||||
"plugins": {
|
||||
"plugins_title": "Plugins",
|
||||
@@ -157,10 +331,8 @@
|
||||
"server_url": "URL del servidor",
|
||||
"server_url_hint": "Ejemplo: http(s)://tu-dominio.url\n(añade el puerto si es necesario)",
|
||||
"server_url_placeholder": "URL de Jellyseerr...",
|
||||
"password": "Contrasñea",
|
||||
"password": "Contraseña",
|
||||
"password_placeholder": "Introduce la contraseña de Jellyfin de {{username}}",
|
||||
"save_button": "Guardar",
|
||||
"clear_button": "Limpiar",
|
||||
"login_button": "Iniciar sesión",
|
||||
"total_media_requests": "Peticiones totales de medios",
|
||||
"movie_quota_limit": "Límite de cuota de películas",
|
||||
@@ -168,7 +340,13 @@
|
||||
"tv_quota_limit": "Límite de cuota de series",
|
||||
"tv_quota_days": "Días de cuota de series",
|
||||
"reset_jellyseerr_config_button": "Restablecer configuración de Jellyseerr",
|
||||
"unlimited": "Ilimitado"
|
||||
"unlimited": "Ilimitado",
|
||||
"plus_n_more": "+{{n}} more",
|
||||
"order_by": {
|
||||
"DEFAULT": "Por defecto",
|
||||
"VOTE_COUNT_AND_AVERAGE": "Número de votos y promedio",
|
||||
"POPULARITY": "Popularidad"
|
||||
}
|
||||
},
|
||||
"marlin_search": {
|
||||
"enable_marlin_search": "Habilitar búsqueda de Marlin",
|
||||
@@ -178,8 +356,40 @@
|
||||
"read_more_about_marlin": "Leer más sobre Marlin.",
|
||||
"save_button": "Guardar",
|
||||
"toasts": {
|
||||
"saved": "Guardado"
|
||||
}
|
||||
"saved": "Guardado",
|
||||
"refreshed": "Ajustes del servidor actualizados"
|
||||
},
|
||||
"refresh_from_server": "Actualizar ajustes del servidor"
|
||||
},
|
||||
"streamystats": {
|
||||
"enable_streamystats": "Habilitar Streamystats",
|
||||
"disable_streamystats": "Deshabilitar Streamystats",
|
||||
"enable_search": "Usar para la búsqueda",
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://streamystats.ejemplo.com",
|
||||
"streamystats_search_hint": "Introduzca la URL para su servidor Streamystats. La URL debe incluir http o https y opcionalmente el puerto.",
|
||||
"read_more_about_streamystats": "Leer más sobre Streamystats.",
|
||||
"save_button": "Guardar",
|
||||
"save": "Guardar",
|
||||
"features_title": "Características",
|
||||
"home_sections_title": "Secciones de inicio",
|
||||
"enable_movie_recommendations": "Recomendaciones de películas",
|
||||
"enable_series_recommendations": "Recomendaciones de series",
|
||||
"enable_promoted_watchlists": "Listas promocionadas",
|
||||
"hide_watchlists_tab": "Ocultar pestaña de listas",
|
||||
"home_sections_hint": "Mostrar recomendaciones personalizadas y listas promovidas de Streamystats en la página principal.",
|
||||
"recommended_movies": "Películas recomendadas",
|
||||
"recommended_series": "Series recomendadas",
|
||||
"toasts": {
|
||||
"saved": "Guardado",
|
||||
"refreshed": "Ajustes actualizados desde el servidor",
|
||||
"disabled": "Streamystats deshabilitado"
|
||||
},
|
||||
"refresh_from_server": "Actualizar ajustes desde el servidor"
|
||||
},
|
||||
"kefinTweaks": {
|
||||
"watchlist_enabler": "Habilitar la integración de la lista de seguimiento",
|
||||
"watchlist_button": "Activar o desactivar la integración de la lista de seguimiento"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
@@ -187,43 +397,58 @@
|
||||
"app_usage": "App {{usedSpace}}%",
|
||||
"device_usage": "Dispositivo {{availableSpace}}%",
|
||||
"size_used": "{{used}} de {{total}} usado",
|
||||
"delete_all_downloaded_files": "Eliminar todos los archivos descargados"
|
||||
"delete_all_downloaded_files": "Eliminar todos los archivos descargados",
|
||||
"music_cache_title": "Caché de música",
|
||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||
"enable_music_cache": "Activar Caché de Música",
|
||||
"clear_music_cache": "Borrar Caché de Música",
|
||||
"music_cache_size": "Caché {{Tamaño}}",
|
||||
"music_cache_cleared": "Caché de música eliminado",
|
||||
"delete_all_downloaded_songs": "Eliminar todas las descargas",
|
||||
"downloaded_songs_size": "{{tamaño}} descargado",
|
||||
"downloaded_songs_deleted": "Canciones descargadas eliminadas"
|
||||
},
|
||||
"intro": {
|
||||
"title": "Intro",
|
||||
"show_intro": "Mostrar intro",
|
||||
"reset_intro": "Restablecer intro"
|
||||
},
|
||||
"logs": {
|
||||
"logs_title": "Registros",
|
||||
"export_logs": "Export logs",
|
||||
"click_for_more_info": "Click for more info",
|
||||
"level": "Nivel",
|
||||
"no_logs_available": "No hay registros disponibles",
|
||||
"delete_all_logs": "Eliminar todos los registros"
|
||||
},
|
||||
"languages": {
|
||||
"title": "Idiomas",
|
||||
"app_language": "Idioma de la app",
|
||||
"app_language_description": "Selecciona el idioma de la app.",
|
||||
"system": "Sistema"
|
||||
},
|
||||
"toasts":{
|
||||
"toasts": {
|
||||
"error_deleting_files": "Error al eliminar archivos",
|
||||
"background_downloads_enabled": "Descargas en segundo plano habilitadas",
|
||||
"background_downloads_disabled": "Descargas en segundo plano deshabilitadas",
|
||||
"connected": "Conectado",
|
||||
"could_not_connect": "No se pudo conectar",
|
||||
"invalid_url": "URL inválida"
|
||||
"background_downloads_disabled": "Descargas en segundo plano deshabilitadas"
|
||||
}
|
||||
},
|
||||
"sessions": {
|
||||
"title": "Sesiones",
|
||||
"no_active_sessions": "No hay sesiones activas"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Descargas",
|
||||
"tvseries": "Series",
|
||||
"movies": "Películas",
|
||||
"queue": "Cola",
|
||||
"other_media": "Otros medios",
|
||||
"queue_hint": "La cola de series y películas se perderá al reiniciar la app",
|
||||
"no_items_in_queue": "No hay ítems en la cola",
|
||||
"no_downloaded_items": "No hay ítems descargados",
|
||||
"delete_all_movies_button": "Eliminar todas las películas",
|
||||
"delete_all_tvseries_button": "Eliminar todas las series",
|
||||
"delete_all_button": "Eliminar todo",
|
||||
"delete_all_other_media_button": "Eliminar otros medios",
|
||||
"active_download": "Descarga activa",
|
||||
"no_active_downloads": "No hay descargas activas",
|
||||
"active_downloads": "Descargas activas",
|
||||
@@ -234,37 +459,59 @@
|
||||
"something_went_wrong": "Algo ha salido mal",
|
||||
"could_not_get_stream_url_from_jellyfin": "No se pudo obtener la URL del stream de Jellyfin",
|
||||
"eta": "{{eta}} restante",
|
||||
"methods": "Métodos",
|
||||
"toasts": {
|
||||
"you_are_not_allowed_to_download_files": "No tienes permiso para descargar archivos.",
|
||||
"deleted_all_movies_successfully": "¡Todas las películas eliminadas con éxito!",
|
||||
"failed_to_delete_all_movies": "Error al eliminar todas las películas",
|
||||
"deleted_all_tvseries_successfully": "¡Todas las series eliminadas con éxito!",
|
||||
"failed_to_delete_all_tvseries": "Error al eliminar todas las series",
|
||||
"deleted_media_successfully": "¡Otros medios eliminados con éxito!",
|
||||
"failed_to_delete_media": "Error al eliminar otros medios",
|
||||
"download_deleted": "Descarga eliminada",
|
||||
"download_cancelled": "Descarga cancelada",
|
||||
"could_not_cancel_download": "No se pudo cancelar la descarga",
|
||||
"could_not_delete_download": "No se pudo eliminar la descarga",
|
||||
"download_paused": "Descarga pausada",
|
||||
"could_not_pause_download": "No se pudo pausar la descarga",
|
||||
"download_resumed": "Descarga rebatida",
|
||||
"could_not_resume_download": "No se pudo reiniciar la descarga",
|
||||
"download_completed": "Descarga completada",
|
||||
"download_started_for": "Descarga iniciada para {{item}}",
|
||||
"item_is_ready_to_be_downloaded": "{{item}} está listo para ser descargado",
|
||||
"download_stated_for_item": "Descarga iniciada para {{item}}",
|
||||
"download_failed": "Descarga fallida",
|
||||
"download_failed_for_item": "Descarga fallida para {{item}} - {{error}}",
|
||||
"download_completed_for_item": "Descarga completada para {{item}}",
|
||||
"queued_item_for_optimization": "{{item}} en cola para optimización",
|
||||
"failed_to_start_download_for_item": "Error al iniciar la descarga para {{item}}: {{message}}",
|
||||
"server_responded_with_status_code": "El servidor ha respondido con el estado {{statusCode}}",
|
||||
"no_response_received_from_server": "No se ha recibido respuesta del servidor",
|
||||
"error_setting_up_the_request": "Error al configurar la petición",
|
||||
"failed_to_start_download_for_item_unexpected_error": "Error al iniciar la descarga para {{item}}: Error inesperado",
|
||||
"download_started_for_item": "Descarga iniciada para {{item}}",
|
||||
"failed_to_start_download": "Error al iniciar la descarga",
|
||||
"item_already_downloading": "{{item}} ya está descargando",
|
||||
"all_files_deleted": "Todas las descargas eliminadas correctamente",
|
||||
"files_deleted_by_type": "{{count}} {{type}} eliminado",
|
||||
"all_files_folders_and_jobs_deleted_successfully": "Todos los archivos, carpetas y trabajos eliminados con éxito",
|
||||
"an_error_occured_while_deleting_files_and_jobs": "Ha ocurrido un error al eliminar archivos y trabajos",
|
||||
"go_to_downloads": "Ir a descargas"
|
||||
"failed_to_clean_cache_directory": "Error al limpiar el directorio de caché",
|
||||
"could_not_get_download_url_for_item": "No se pudo obtener la URL de descarga para {{itemName}}",
|
||||
"go_to_downloads": "Ir a descargas",
|
||||
"file_deleted": "{{item}} eliminado"
|
||||
}
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"select": "Seleccionar",
|
||||
"no_trailer_available": "No hay tráiler disponible",
|
||||
"video": "Vídeo",
|
||||
"audio": "Audio",
|
||||
"subtitle": "Subtítulos",
|
||||
"play": "Jugar",
|
||||
"none": "Nada",
|
||||
"track": "Pista",
|
||||
"cancel": "Cancelar",
|
||||
"delete": "Borrar",
|
||||
"ok": "Aceptar",
|
||||
"remove": "Eliminar",
|
||||
"next": "Next",
|
||||
"back": "Back",
|
||||
"continue": "Continue",
|
||||
"verifying": "Verifying..."
|
||||
},
|
||||
"search": {
|
||||
"search_here": "Buscar aquí...",
|
||||
"search": "Buscar...",
|
||||
"x_items": "{{count}} ítems",
|
||||
"x_items": "{{count}} Elementos",
|
||||
"library": "Biblioteca",
|
||||
"discover": "Descubrir",
|
||||
"no_results": "Sin resultados",
|
||||
@@ -274,12 +521,16 @@
|
||||
"episodes": "Episodios",
|
||||
"collections": "Colecciones",
|
||||
"actors": "Actores",
|
||||
"artists": "Artistas",
|
||||
"albums": "Álbumes",
|
||||
"songs": "Canciones",
|
||||
"playlists": "Listas de reproducción",
|
||||
"request_movies": "Solicitar películas",
|
||||
"request_series": "Solicitar series",
|
||||
"recently_added": "Recientemente añadido",
|
||||
"recent_requests": "Solicitudes recientes",
|
||||
"plex_watchlist": "Lista de seguimiento de Plex",
|
||||
"trending": "Trending",
|
||||
"trending": "Tendencias",
|
||||
"popular_movies": "Películas populares",
|
||||
"movie_genres": "Géneros de películas",
|
||||
"upcoming_movies": "Próximas películas",
|
||||
@@ -299,21 +550,20 @@
|
||||
"tmdb_tv_streaming_services": "Servicios de streaming de series de TMDB"
|
||||
},
|
||||
"library": {
|
||||
"no_items_found": "No se han encontrado ítems",
|
||||
"no_results": "Sin resultados",
|
||||
"no_libraries_found": "No se han encontrado bibliotecas",
|
||||
"item_types": {
|
||||
"movies": "películas",
|
||||
"series": "series",
|
||||
"boxsets": "colecciones",
|
||||
"items": "ítems"
|
||||
"movies": "Películas",
|
||||
"series": "Series",
|
||||
"boxsets": "Colecciones",
|
||||
"items": "Elementos"
|
||||
},
|
||||
"options": {
|
||||
"display": "Mostrar",
|
||||
"row": "Fila",
|
||||
"list": "Lista",
|
||||
"image_style": "Estilo de imagen",
|
||||
"poster": "Poster",
|
||||
"poster": "Póster",
|
||||
"cover": "Portada",
|
||||
"show_titles": "Mostrar títulos",
|
||||
"show_stats": "Mostrar estadísticas"
|
||||
@@ -322,6 +572,7 @@
|
||||
"genres": "Géneros",
|
||||
"years": "Años",
|
||||
"sort_by": "Ordenar por",
|
||||
"filter_by": "Filtrar por",
|
||||
"sort_order": "Ordenar",
|
||||
"tags": "Etiquetas"
|
||||
}
|
||||
@@ -332,27 +583,32 @@
|
||||
"episodes": "Episodios",
|
||||
"videos": "Vídeos",
|
||||
"boxsets": "Colecciones",
|
||||
"playlists": "Playlists"
|
||||
"playlists": "Listas",
|
||||
"noDataTitle": "Aún no hay favoritos",
|
||||
"noData": "Marca elementos como favoritos para verlos aparecer aquí para un acceso rápido."
|
||||
},
|
||||
"custom_links": {
|
||||
"no_links": "Sin enlaces"
|
||||
},
|
||||
"player": {
|
||||
"error": "Error",
|
||||
"failed_to_get_stream_url": "Error al obtener la URL del stream",
|
||||
"failed_to_get_stream_url": "Error al obtener la URL del Steam",
|
||||
"an_error_occured_while_playing_the_video": "Ha ocurrido un error al reproducir el vídeo. Comprueba los registros en la configuración.",
|
||||
"client_error": "Error del cliente",
|
||||
"could_not_create_stream_for_chromecast": "No se pudo crear el stream para Chromecast",
|
||||
"could_not_create_stream_for_chromecast": "No se pudo crear el Steam para Chromecast",
|
||||
"message_from_server": "Mensaje del servidor: {{message}}",
|
||||
"video_has_finished_playing": "El vídeo ha terminado de reproducirse",
|
||||
"no_video_source": "No hay fuente de vídeo...",
|
||||
"next_episode": "Siguiente episodio",
|
||||
"refresh_tracks": "Refrescar pistas",
|
||||
"subtitle_tracks": "Pistas de subtítulos:",
|
||||
"audio_tracks": "Pistas de audio:",
|
||||
"playback_state": "Estado de la reproducción:",
|
||||
"no_data_available": "No hay datos disponibles",
|
||||
"index": "Índice:"
|
||||
"index": "Índice:",
|
||||
"continue_watching": "Continuar viendo",
|
||||
"go_back": "Volver",
|
||||
"downloaded_file_title": "Ya tienes este archivo descargado",
|
||||
"downloaded_file_message": "¿Quieres reproducir el archivo descargado?",
|
||||
"downloaded_file_yes": "Sí",
|
||||
"downloaded_file_no": "No",
|
||||
"downloaded_file_cancel": "Cancelar"
|
||||
},
|
||||
"item_card": {
|
||||
"next_up": "A continuación",
|
||||
@@ -368,6 +624,7 @@
|
||||
"no_similar_items_found": "No se han encontrado ítems similares",
|
||||
"video": "Vídeo",
|
||||
"more_details": "Más detalles",
|
||||
"media_options": "Opciones de medios",
|
||||
"quality": "Calidad",
|
||||
"audio": "Audio",
|
||||
"subtitles": "Subtítulos",
|
||||
@@ -382,15 +639,13 @@
|
||||
"download_episode": "Descargar episodio",
|
||||
"download_movie": "Descargar película",
|
||||
"download_x_item": "Descargar {{item_count}} ítems",
|
||||
"download_button": "Descargar",
|
||||
"using_optimized_server": "Usando servidor optimizado",
|
||||
"using_default_method": "Usando método por defecto"
|
||||
"download_unwatched_only": "No visto",
|
||||
"download_button": "Descargar"
|
||||
}
|
||||
},
|
||||
"live_tv": {
|
||||
"next": "Siguiente",
|
||||
"previous": "Anterior",
|
||||
"live_tv": "TV en directo",
|
||||
"coming_soon": "Próximamente",
|
||||
"on_now": "En directo",
|
||||
"shows": "Programas",
|
||||
@@ -399,7 +654,7 @@
|
||||
"for_kids": "Para niños",
|
||||
"news": "Noticias"
|
||||
},
|
||||
"jellyseerr":{
|
||||
"jellyseerr": {
|
||||
"confirm": "Confirmar",
|
||||
"cancel": "Cancelar",
|
||||
"yes": "Sí",
|
||||
@@ -433,11 +688,15 @@
|
||||
"tags": "Etiquetas",
|
||||
"quality_profile": "Perfil de calidad",
|
||||
"root_folder": "Carpeta raíz",
|
||||
"season_x": "Temporada {{seasons}}",
|
||||
"season_all": "Temporada (Todas)",
|
||||
"season_number": "Temporada {{season_number}}",
|
||||
"number_episodes": "{{episode_number}} episodios",
|
||||
"number_episodes": "{{episode_number}} Episodios",
|
||||
"born": "Nacido",
|
||||
"appearances": "Apariciones",
|
||||
"approve": "Aprobar",
|
||||
"decline": "Rechazar",
|
||||
"requested_by": "Solicitado por {{user}}",
|
||||
"unknown_user": "Usuario desconocido",
|
||||
"toasts": {
|
||||
"jellyseer_does_not_meet_requirements": "¡Jellyseer no cumple con los requisitos! Por favor, actualízalo al menos a la versión 2.0.0.",
|
||||
"jellyseerr_test_failed": "La prueba de Jellyseerr ha fallado. Por favor inténtalo de nuevo.",
|
||||
@@ -445,7 +704,11 @@
|
||||
"issue_submitted": "¡Problema enviado!",
|
||||
"requested_item": "¡{{item}} solicitado!",
|
||||
"you_dont_have_permission_to_request": "¡No tienes permiso para solicitar!",
|
||||
"something_went_wrong_requesting_media": "¡Algo ha salido mal solicitando los medios!"
|
||||
"something_went_wrong_requesting_media": "¡Algo ha salido mal solicitando los medios!",
|
||||
"request_approved": "¡Solicitud aprobada!",
|
||||
"request_declined": "¡Solicitud rechazada!",
|
||||
"failed_to_approve_request": "Error al aprobar la solicitud",
|
||||
"failed_to_decline_request": "Error al rechazar la solicitud"
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
@@ -454,5 +717,129 @@
|
||||
"library": "Bibliotecas",
|
||||
"custom_links": "Enlaces personalizados",
|
||||
"favorites": "Favoritos"
|
||||
},
|
||||
"music": {
|
||||
"title": "Música",
|
||||
"tabs": {
|
||||
"suggestions": "Sugerencias",
|
||||
"albums": "Álbumes",
|
||||
"artists": "Artistas",
|
||||
"playlists": "Listas de reproducción",
|
||||
"tracks": "Canciones"
|
||||
},
|
||||
"filters": {
|
||||
"all": "Todas"
|
||||
},
|
||||
"recently_added": "Recientemente añadido",
|
||||
"recently_played": "Reproducidos Recientemente",
|
||||
"frequently_played": "Reproducido con frecuencia",
|
||||
"explore": "Explorar",
|
||||
"top_tracks": "Canciones Populares",
|
||||
"play": "Reproducir",
|
||||
"shuffle": "Aleatorio",
|
||||
"play_top_tracks": "Reproducir canciones populares",
|
||||
"no_suggestions": "No hay sugerencias",
|
||||
"no_albums": "No se encontraron álbumes",
|
||||
"no_artists": "No se han encontrado artistas",
|
||||
"no_playlists": "No se han encontrado listas de reproducción",
|
||||
"album_not_found": "Álbum no encontrado.",
|
||||
"artist_not_found": "Artista no encontrado",
|
||||
"playlist_not_found": "Lista no encontrada",
|
||||
"track_options": {
|
||||
"play_next": "Reproducir siguiente",
|
||||
"add_to_queue": "Añadir a la Cola",
|
||||
"add_to_playlist": "Añadir a la Lista de Reproducción",
|
||||
"download": "Descargar",
|
||||
"downloaded": "Descargado",
|
||||
"downloading": "Descargando...",
|
||||
"cached": "En caché",
|
||||
"delete_download": "Delete Download",
|
||||
"delete_cache": "Remove from Cache",
|
||||
"go_to_artist": "Ir al artista",
|
||||
"go_to_album": "Ir al álbum",
|
||||
"add_to_favorites": "Añadir a Favoritos",
|
||||
"remove_from_favorites": "Eliminar de Favoritos",
|
||||
"remove_from_playlist": "Eliminar de la lista de reproducción"
|
||||
},
|
||||
"playlists": {
|
||||
"create_playlist": "Crear una lista de reproducción",
|
||||
"playlist_name": "Nombre de la lista de reproducción",
|
||||
"enter_name": "Introducir nombre de la lista de reproducción",
|
||||
"create": "Crear",
|
||||
"search_playlists": "Buscar listas de reproducción...",
|
||||
"added_to": "Añadido a {{name}}",
|
||||
"added": "Añadido a la lista de reproducción",
|
||||
"removed_from": "Eliminado de {{name}}",
|
||||
"removed": "Quitar de la lista de reproducción",
|
||||
"created": "Lista de reproducción creada",
|
||||
"create_new": "Crear una nueva lista de reproducción",
|
||||
"failed_to_add": "Error al añadir a la lista de reproducción",
|
||||
"failed_to_remove": "Error al eliminar de la lista de reproducción",
|
||||
"failed_to_create": "Error al crear lista de reproducción",
|
||||
"delete_playlist": "Eliminar lista de reproducción",
|
||||
"delete_confirm": "¿Está seguro que desea eliminar \"{{name}}\"? Esta acción no se puede deshacer.",
|
||||
"deleted": "Lista de reproducción eliminada",
|
||||
"failed_to_delete": "Error al eliminar lista de reproducción"
|
||||
},
|
||||
"sort": {
|
||||
"title": "Ordenar por",
|
||||
"alphabetical": "Alfabéticamente",
|
||||
"date_created": "Fecha de creación"
|
||||
}
|
||||
},
|
||||
"watchlists": {
|
||||
"title": "Listas de seguimiento",
|
||||
"my_watchlists": "Mi lista de seguimiento",
|
||||
"public_watchlists": "Listas de seguimiento públicas",
|
||||
"create_title": "Crear lista de seguimiento",
|
||||
"edit_title": "Editar lista de seguimiento",
|
||||
"create_button": "Crear lista de seguimiento",
|
||||
"save_button": "Guardar cambios",
|
||||
"delete_button": "Borrar",
|
||||
"remove_button": "Eliminar",
|
||||
"cancel_button": "Cancelar",
|
||||
"name_label": "Nombre",
|
||||
"name_placeholder": "Introduce el nombre de la lista de seguimiento",
|
||||
"description_label": "Descripción",
|
||||
"description_placeholder": "Introduce una descripción (opcional)",
|
||||
"is_public_label": "Listas de seguimiento públicas",
|
||||
"is_public_description": "Permitir que otros vean esta lista de seguimiento",
|
||||
"allowed_type_label": "Tipo de contenido",
|
||||
"sort_order_label": "Ordenación por defecto",
|
||||
"empty_title": "No hay listas de seguimiento",
|
||||
"empty_description": "Crea tu primera lista de seguimiento para empezar a organizar tus medios",
|
||||
"empty_watchlist": "Esta lista de seguimiento está vacía",
|
||||
"empty_watchlist_hint": "Añadir elementos de tu biblioteca a esta lista de seguimiento",
|
||||
"not_configured_title": "Streamystats no configurado",
|
||||
"not_configured_description": "Configura Streamystats en ajustes para usar listas de seguimiento",
|
||||
"go_to_settings": "Ir a la configuración",
|
||||
"add_to_watchlist": "Añadir a Lista de Seguimiento",
|
||||
"remove_from_watchlist": "Quitar de la lista de seguimiento",
|
||||
"select_watchlist": "Seleccionar lista de seguimiento",
|
||||
"create_new": "Crear nueva lista de seguimiento",
|
||||
"item": "elemento",
|
||||
"items": "elementos",
|
||||
"public": "Público",
|
||||
"private": "Privado",
|
||||
"you": "Tú",
|
||||
"by_owner": "Por otro usuario",
|
||||
"not_found": "Lista de seguimiento no encontrada",
|
||||
"delete_confirm_title": "Eliminar lista de seguimiento",
|
||||
"delete_confirm_message": "¿Está seguro que desea eliminar\"{{name}}\"? Esta acción no se puede deshacer.",
|
||||
"remove_item_title": "Borrar de la lista de seguimiento",
|
||||
"remove_item_message": "¿Eliminar\"{{name}}\" de esta lista de seguimiento?",
|
||||
"loading": "Cargando listas de seguimiento...",
|
||||
"no_compatible_watchlists": "No hay listas de seguimiento compatibles",
|
||||
"create_one_first": "Crear una lista vigilada que acepte este tipo de contenido"
|
||||
},
|
||||
"playback_speed": {
|
||||
"title": "Velocidad de reproducción",
|
||||
"apply_to": "Aplicar a",
|
||||
"speed": "Velocidad",
|
||||
"scope": {
|
||||
"media": "Solo este medio",
|
||||
"show": "Este programa",
|
||||
"all": "Todos los medios (por defecto)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,20 +7,22 @@
|
||||
"username_placeholder": "Nom d'utilisateur",
|
||||
"password_placeholder": "Mot de passe",
|
||||
"login_button": "Se connecter",
|
||||
"quick_connect": "Connexion Rapide",
|
||||
"quick_connect": "Connexion rapide",
|
||||
"enter_code_to_login": "Entrez le code {{code}} pour vous connecter",
|
||||
"failed_to_initiate_quick_connect": "Échec de l'initialisation de Connexion Rapide",
|
||||
"failed_to_initiate_quick_connect": "Échec de l'initialisation de Connexion rapide",
|
||||
"got_it": "D'accord",
|
||||
"connection_failed": "La connexion a échoué",
|
||||
"could_not_connect_to_server": "Impossible de se connecter au serveur. Veuillez vérifier l'URL et votre connection réseau.",
|
||||
"could_not_connect_to_server": "Impossible de se connecter au serveur. Veuillez vérifier l’URL et votre connexion réseau.",
|
||||
"an_unexpected_error_occured": "Une erreur inattendue s'est produite",
|
||||
"change_server": "Changer de serveur",
|
||||
"invalid_username_or_password": "Nom d'utilisateur ou mot de passe invalide",
|
||||
"user_does_not_have_permission_to_log_in": "L'utilisateur n'a pas la permission de se connecter",
|
||||
"server_is_taking_too_long_to_respond_try_again_later": "Le serveur prend trop de temps à répondre, réessayez plus tard",
|
||||
"server_received_too_many_requests_try_again_later": "Le serveur a reçu trop de demandes, réessayez plus tard",
|
||||
"server_received_too_many_requests_try_again_later": "Le serveur a reçu trop de demandes, réessayez plus tard.",
|
||||
"there_is_a_server_error": "Il y a une erreur de serveur",
|
||||
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Une erreur inattendue s'est produite. Avez-vous entré la bonne URL?"
|
||||
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Une erreur inattendue s’est produite. Avez-vous correctement saisi l’URL du serveur ?",
|
||||
"too_old_server_text": "Serveur Jellyfin non pris en charge découvert",
|
||||
"too_old_server_description": "Veuillez mettre à jour Jellyfin vers la dernière version"
|
||||
},
|
||||
"server": {
|
||||
"enter_url_to_jellyfin_server": "Entrez l'URL du serveur Jellyfin",
|
||||
@@ -28,40 +30,127 @@
|
||||
"connect_button": "Connexion",
|
||||
"previous_servers": "Serveurs précédents",
|
||||
"clear_button": "Effacer",
|
||||
"swipe_to_remove": "Glisser pour supprimer",
|
||||
"search_for_local_servers": "Rechercher des serveurs locaux",
|
||||
"searching": "Recherche...",
|
||||
"servers": "Serveurs"
|
||||
"servers": "Serveurs",
|
||||
"saved": "Enregistré",
|
||||
"session_expired": "Session expirée",
|
||||
"please_login_again": "Votre session enregistrée a expiré. Veuillez vous connecter à nouveau.",
|
||||
"remove_saved_login": "Supprimer l'identifiant enregistré",
|
||||
"remove_saved_login_description": "Cela supprimera vos identifiants enregistrés pour ce serveur. Vous devrez saisir à nouveau votre nom d’utilisateur et votre mot de passe la prochaine fois.",
|
||||
"accounts_count": "Comptes {{count}}",
|
||||
"select_account": "Sélectionnez un compte",
|
||||
"add_account": "Ajouter un compte",
|
||||
"remove_account_description": "Cela supprimera les identifiants enregistrés pour {{username}}."
|
||||
},
|
||||
"save_account": {
|
||||
"title": "Sauvegarder le compte",
|
||||
"save_for_later": "Enregistrer ce compte",
|
||||
"security_option": "Options de sécurité",
|
||||
"no_protection": "Aucune protection",
|
||||
"no_protection_desc": "Connexion rapide sans authentification",
|
||||
"pin_code": "Code PIN",
|
||||
"pin_code_desc": "Code PIN à 4 chiffres requis lors du changement",
|
||||
"password": "Confirmer le mot de passe",
|
||||
"password_desc": "Mot de passe requis lors du changement",
|
||||
"save_button": "Enregistrer",
|
||||
"cancel_button": "Annuler"
|
||||
},
|
||||
"pin": {
|
||||
"enter_pin": "Saisir le code PIN",
|
||||
"enter_pin_for": "Saisissez le code PIN pour {{username}}",
|
||||
"enter_4_digits": "Saisissez 4 chiffres",
|
||||
"invalid_pin": "Code PIN incorrect",
|
||||
"setup_pin": "Configurer le code PIN",
|
||||
"confirm_pin": "Confirmer le code PIN",
|
||||
"pins_dont_match": "Les codes PIN ne correspondent pas",
|
||||
"forgot_pin": "Code PIN oublié ?",
|
||||
"forgot_pin_desc": "Vos identifiants enregistrés seront supprimés"
|
||||
},
|
||||
"password": {
|
||||
"enter_password": "Veuillez saisir le mot de passe",
|
||||
"enter_password_for": "Entrez le mot de passe pour {{username}}",
|
||||
"invalid_password": "Mot de passe incorrect"
|
||||
},
|
||||
"home": {
|
||||
"checking_server_connection": "Vérification de la connexion au serveur...",
|
||||
"no_internet": "Pas d'Internet",
|
||||
"no_items": "Aucun média",
|
||||
"no_internet_message": "Aucun problème, vous pouvez toujours regarder\nle contenu téléchargé.",
|
||||
"go_to_downloads": "Aller aux téléchargements",
|
||||
"oops": "Oups!",
|
||||
"checking_server_connection_message": "Vérification de la connexion au serveur",
|
||||
"go_to_downloads": "Accédez aux téléchargements",
|
||||
"retry": "Réessayer",
|
||||
"server_unreachable": "Serveur injoignable",
|
||||
"server_unreachable_message": "Impossible d'accéder au serveur.\nVeuillez vérifier votre connexion réseau.",
|
||||
"oops": "Oups !",
|
||||
"error_message": "Quelque chose s'est mal passé.\nVeuillez vous reconnecter à nouveau.",
|
||||
"continue_watching": "Continuer à regarder",
|
||||
"next_up": "À suivre",
|
||||
"continue_and_next_up": "Continuer de regarder et à suivre",
|
||||
"recently_added_in": "Ajoutés récemment dans {{libraryName}}",
|
||||
"suggested_movies": "Films suggérés",
|
||||
"suggested_episodes": "Épisodes suggérés",
|
||||
"intro": {
|
||||
"welcome_to_streamyfin": "Bienvenue sur Streamyfin",
|
||||
"a_free_and_open_source_client_for_jellyfin": "Un client gratuit et open source pour Jellyfin",
|
||||
"a_free_and_open_source_client_for_jellyfin": "Un client gratuit et open-source pour Jellyfin.",
|
||||
"features_title": "Fonctionnalités",
|
||||
"features_description": "Streamyfin possède de nombreuses fonctionnalités et s'intègre à un large éventail de logiciels que vous pouvez trouver dans le menu des paramètres, notamment:",
|
||||
"jellyseerr_feature_description": "Connectez-vous à votre instance Jellyseerr et demandez des films directement dans l'application.",
|
||||
"features_description": "Streamyfin offre de nombreuses fonctionnalités et s'intègre à de nombreux logiciels disponibles dans le menu des paramètres, notamment :",
|
||||
"jellyseerr_feature_description": "Connectez-vous à votre instance Seerr et demandez des films directement dans l'application.",
|
||||
"downloads_feature_title": "Téléchargements",
|
||||
"downloads_feature_description": "Téléchargez des films et des émissions de télévision pour les regarder hors ligne. Utilisez la méthode par défaut ou installez le serveur d'optimisation pour télécharger les fichiers en arrière-plan.",
|
||||
"chromecast_feature_description": "Diffusez des films et des émissions de télévision sur vos appareils Chromecast.",
|
||||
"downloads_feature_description": "Téléchargez des films et des séries pour les regarder hors ligne. Utilisez la méthode par défaut ou installez le serveur d'optimisation pour télécharger les fichiers en arrière-plan.",
|
||||
"chromecast_feature_description": "Diffusez des films et des séries sur vos appareils Chromecast.",
|
||||
"centralised_settings_plugin_title": "Plugin de paramètres centralisés",
|
||||
"centralised_settings_plugin_description": "Configuration des paramètres d'un emplacement centralisé sur votre serveur Jellyfin. Tous les paramètres clients pour tous les utilisateurs seront synchronisés automatiquement.",
|
||||
"done_button": "Terminé",
|
||||
"go_to_settings_button": "Allez dans les paramètres",
|
||||
"read_more": "Lisez-en plus"
|
||||
"read_more": "En savoir plus"
|
||||
},
|
||||
"settings": {
|
||||
"settings_title": "Paramètres",
|
||||
"log_out_button": "Déconnexion",
|
||||
"categories": {
|
||||
"title": "Catégories"
|
||||
},
|
||||
"playback_controls": {
|
||||
"title": "Lecture et commandes"
|
||||
},
|
||||
"audio_subtitles": {
|
||||
"title": "Audio et sous-titres"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Apparence",
|
||||
"merge_next_up_continue_watching": "Fusionner, continuer à regarder et à suivre",
|
||||
"hide_remote_session_button": "Masquer le bouton de session distante"
|
||||
},
|
||||
"network": {
|
||||
"title": "Réseau",
|
||||
"local_network": "Réseau local",
|
||||
"auto_switch_enabled": "Basculement automatique quand à la maison",
|
||||
"auto_switch_description": "Basculer automatiquement vers l'URL locale lorsque vous êtes connecté au Wi-Fi de la maison",
|
||||
"local_url": "URL locale",
|
||||
"local_url_hint": "Entrez l'adresse de votre serveur local (exemple, http://192.168.1.100:8096)",
|
||||
"local_url_placeholder": "http://192.168.1.100:8096",
|
||||
"home_wifi_networks": "Réseaux Wi-Fi domestiques",
|
||||
"add_current_network": "Ajouter \"{{ssid}}\"",
|
||||
"not_connected_to_wifi": "Non connecté au WiFi",
|
||||
"no_networks_configured": "Pas de réseau configuré",
|
||||
"add_network_hint": "Ajouter votre réseau Wi-Fi domestique pour activer la commutation automatique",
|
||||
"current_wifi": "WiFi actuel",
|
||||
"using_url": "Utilisant",
|
||||
"local": "URL locale",
|
||||
"remote": "URL à distance",
|
||||
"not_connected": "Non connecté",
|
||||
"current_server": "Serveur actuel",
|
||||
"remote_url": "URL à distance",
|
||||
"active_url": "URL active",
|
||||
"not_configured": "Non configuré",
|
||||
"network_added": "Réseau ajouté",
|
||||
"network_already_added": "Réseau déjà ajouté",
|
||||
"no_wifi_connected": "Non connecté au WiFi",
|
||||
"permission_denied": "Autorisation de localisation refusée",
|
||||
"permission_denied_explanation": "Une autorisation de localisation est requise pour détecter le réseau Wifi afin de changer automatiquement. Veuillez l’activer dans les paramètres."
|
||||
},
|
||||
"user_info": {
|
||||
"user_info_title": "Informations utilisateur",
|
||||
"user": "Utilisateur",
|
||||
@@ -71,8 +160,8 @@
|
||||
},
|
||||
"quick_connect": {
|
||||
"quick_connect_title": "Connexion Rapide",
|
||||
"authorize_button": "Autoriser Connexion Rapide",
|
||||
"enter_the_quick_connect_code": "Entrez le code Connexion Rapide...",
|
||||
"authorize_button": "Autoriser une Connexion Rapide",
|
||||
"enter_the_quick_connect_code": "Entrez le code de Connexion Rapide...",
|
||||
"success": "Succès",
|
||||
"quick_connect_autorized": "Connexion Rapide autorisé",
|
||||
"error": "Erreur",
|
||||
@@ -85,21 +174,42 @@
|
||||
"rewind_length": "Durée de retour en arrière",
|
||||
"seconds_unit": "s"
|
||||
},
|
||||
"gesture_controls": {
|
||||
"gesture_controls_title": "Commandes gestuelles",
|
||||
"horizontal_swipe_skip": "Glisser horizontalement pour passer",
|
||||
"horizontal_swipe_skip_description": "Glisser vers la gauche/droite lorsque les contrôles sont masqués pour passer",
|
||||
"left_side_brightness": "Contrôle de la luminosité du côté gauche",
|
||||
"left_side_brightness_description": "Glisser vers le haut/bas sur le côté gauche pour ajuster la luminosité",
|
||||
"right_side_volume": "Contrôle du volume du côté droit",
|
||||
"right_side_volume_description": "Glisser vers le haut/bas sur le côté droit pour ajuster le volume",
|
||||
"hide_volume_slider": "Masquer le curseur de volume",
|
||||
"hide_volume_slider_description": "Masquer le curseur de volume dans le lecteur vidéo",
|
||||
"hide_brightness_slider": "Cacher le curseur de luminosité",
|
||||
"hide_brightness_slider_description": "Masquer le curseur de volume dans le lecteur vidéo"
|
||||
},
|
||||
"audio": {
|
||||
"audio_title": "Audio",
|
||||
"set_audio_track": "Piste audio de l'élément précédent",
|
||||
"audio_language": "Langue audio",
|
||||
"audio_hint": "Choisissez une langue audio par défaut.",
|
||||
"none": "Aucune",
|
||||
"language": "Langage"
|
||||
"language": "Langage",
|
||||
"transcode_mode": {
|
||||
"title": "Transcodage audio",
|
||||
"description": "Contrôle la gestion de l'audio surround (7.1, TrueHD, DTS-HD)",
|
||||
"auto": "Auto",
|
||||
"stereo": "Forcer la stéréo",
|
||||
"5_1": "Autoriser 5.1",
|
||||
"passthrough": "Intercommunication"
|
||||
}
|
||||
},
|
||||
"subtitles": {
|
||||
"subtitle_title": "Sous-titres",
|
||||
"subtitle_hint": "Configurez les préférences des sous-titres.",
|
||||
"subtitle_language": "Langue des sous-titres",
|
||||
"subtitle_mode": "Mode des sous-titres",
|
||||
"set_subtitle_track": "Piste de sous-titres de l'élément précédent",
|
||||
"subtitle_size": "Taille des sous-titres",
|
||||
"subtitle_hint": "Configurez les préférences des sous-titres.",
|
||||
"none": "Aucune",
|
||||
"language": "Langage",
|
||||
"loading": "Chargement",
|
||||
@@ -109,11 +219,66 @@
|
||||
"Always": "Toujours",
|
||||
"None": "Aucun",
|
||||
"OnlyForced": "Forcés seulement"
|
||||
}
|
||||
},
|
||||
"text_color": "Couleur du texte",
|
||||
"background_color": "Couleur d'arrière-plan",
|
||||
"outline_color": "Couleur du contour",
|
||||
"outline_thickness": "Épaisseur du contour",
|
||||
"background_opacity": "Opacité de l'arrière-plan",
|
||||
"outline_opacity": "Opacité du contour",
|
||||
"bold_text": "Texte en gras",
|
||||
"colors": {
|
||||
"Black": "Noir",
|
||||
"Gray": "Gris",
|
||||
"Silver": "Argent",
|
||||
"White": "Blanc",
|
||||
"Maroon": "Marron",
|
||||
"Red": "Rouge",
|
||||
"Fuchsia": "Fuchsia",
|
||||
"Yellow": "Jaune",
|
||||
"Olive": "Olive",
|
||||
"Green": "Vert",
|
||||
"Teal": "Bleu canard",
|
||||
"Lime": "Citron vert",
|
||||
"Purple": "Violet",
|
||||
"Navy": "Bleu marine",
|
||||
"Blue": "Bleu",
|
||||
"Aqua": "Bleu turquoise"
|
||||
},
|
||||
"thickness": {
|
||||
"None": "Aucun",
|
||||
"Thin": "Maigre",
|
||||
"Normal": "Normale",
|
||||
"Thick": "Épais"
|
||||
},
|
||||
"subtitle_color": "Couleur des sous-titres",
|
||||
"subtitle_background_color": "Couleur d'arrière-plan",
|
||||
"subtitle_font": "Police des sous-titres",
|
||||
"ksplayer_title": "Paramètres de KSPlayer",
|
||||
"hardware_decode": "Décodage matériel",
|
||||
"hardware_decode_description": "Utilisez l’accélération matérielle pour le décodage vidéo. Désactivez si vous rencontrez des problèmes de lecture."
|
||||
},
|
||||
"vlc_subtitles": {
|
||||
"title": "Paramètres des sous-titres VLC",
|
||||
"hint": "Personnaliser l'apparence des sous-titres pour le lecteur VLC. Les changements prennent effet lors de la lecture suivante.",
|
||||
"text_color": "Couleur du texte",
|
||||
"background_color": "Couleur d'arrière-plan",
|
||||
"background_opacity": "Opacité de l'arrière-plan",
|
||||
"outline_color": "Couleur du contour",
|
||||
"outline_opacity": "Opacité du contour",
|
||||
"outline_thickness": "Épaisseur du contour",
|
||||
"bold": "Texte en gras",
|
||||
"margin": "Marge inférieure"
|
||||
},
|
||||
"video_player": {
|
||||
"title": "Lecteur vidéo",
|
||||
"video_player": "Lecteur vidéo",
|
||||
"video_player_description": "Choisissez le lecteur vidéo à utiliser sur iOS.",
|
||||
"ksplayer": "KSPlayer",
|
||||
"vlc": "VLC"
|
||||
},
|
||||
"other": {
|
||||
"other_title": "Autres",
|
||||
"auto_rotate": "Rotation automatique",
|
||||
"video_orientation": "Orientation vidéo",
|
||||
"orientation": "Orientation",
|
||||
"orientations": {
|
||||
@@ -129,58 +294,102 @@
|
||||
"UNKNOWN": "Inconnu"
|
||||
},
|
||||
"safe_area_in_controls": "Zone de sécurité dans les contrôles",
|
||||
"video_player": "Lecteur vidéo",
|
||||
"video_players": {
|
||||
"VLC_3": "VLC 3",
|
||||
"VLC_4": "VLC 4 (Expérimental + PiP)"
|
||||
},
|
||||
"show_custom_menu_links": "Afficher les liens personnalisés",
|
||||
"show_large_home_carousel": "Afficher le grand carrousel d’accueil (bêta)",
|
||||
"hide_libraries": "Cacher des bibliothèques",
|
||||
"select_liraries_you_want_to_hide": "Sélectionnez les bibliothèques que vous souhaitez masquer dans l’onglet Bibliothèque et les sections de la page d’accueil.",
|
||||
"select_liraries_you_want_to_hide": "Sélectionnez les bibliothèques que vous souhaitez masquer dans l'onglet Bibliothèque et les sections de la page d'accueil.",
|
||||
"disable_haptic_feedback": "Désactiver le retour haptique",
|
||||
"default_quality": "Qualité par défaut"
|
||||
|
||||
"default_quality": "Qualité par défaut",
|
||||
"default_playback_speed": "Default Playback Speed",
|
||||
"auto_play_next_episode": "Auto-play Next Episode",
|
||||
"max_auto_play_episode_count": "Nombre d'épisodes en lecture automatique max",
|
||||
"disabled": "Désactivé"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Téléchargements",
|
||||
"download_method": "Méthode de téléchargement",
|
||||
"remux_max_download": "Téléchargement max remux",
|
||||
"auto_download": "Téléchargement automatique",
|
||||
"optimized_versions_server": "Serveur de versions optimisées",
|
||||
"save_button": "Enregistrer",
|
||||
"optimized_server": "Serveur optimisé",
|
||||
"optimized": "Optimisé",
|
||||
"default": "Par défaut",
|
||||
"optimized_version_hint": "Entrez l'URL du serveur de versions optimisées. L'URL devrait inclure http ou https et optionnellement le port.",
|
||||
"read_more_about_optimized_server": "Lisez-en plus sur le serveur de versions optimisées.",
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://domaine.org:port"
|
||||
"downloads_title": "Téléchargements"
|
||||
},
|
||||
"music": {
|
||||
"title": "Music",
|
||||
"playback_title": "Playback",
|
||||
"playback_description": "Configure how music is played.",
|
||||
"prefer_downloaded": "Prefer Downloaded Songs",
|
||||
"caching_title": "Caching",
|
||||
"caching_description": "Automatically cache upcoming tracks for smoother playback.",
|
||||
"lookahead_enabled": "Enable Look-Ahead Caching",
|
||||
"lookahead_count": "Tracks to Pre-cache",
|
||||
"max_cache_size": "Max Cache Size"
|
||||
},
|
||||
"plugins": {
|
||||
"plugins_title": "Plugins",
|
||||
"jellyseerr": {
|
||||
"jellyseerr_warning": "Cette intégration est dans ses débuts. Attendez-vous à ce que des choses changent.",
|
||||
"server_url": "URL du serveur",
|
||||
"server_url_hint": "Exemple: http(s)://votre-domaine.url\n(ajouter le port si nécessaire)",
|
||||
"server_url_placeholder": "URL de Jellyseerr...",
|
||||
"server_url_hint": "Exemple : http(s)://votre-domaine.url\n(ajouter le port si nécessaire)",
|
||||
"server_url_placeholder": "URL de Seerr...",
|
||||
"password": "Mot de passe",
|
||||
"password_placeholder": "Entrez le mot de passe pour l'utilisateur Jellyfin {{username}}",
|
||||
"save_button": "Enregistrer",
|
||||
"clear_button": "Effacer",
|
||||
"login_button": "Connexion",
|
||||
"total_media_requests": "Total de demandes de médias",
|
||||
"movie_quota_limit": "Limite de quota de film",
|
||||
"movie_quota_days": "Jours de quota de film",
|
||||
"tv_quota_limit": "Limite de quota TV",
|
||||
"tv_quota_days": "Jours de quota TV",
|
||||
"reset_jellyseerr_config_button": "Réinitialiser la configuration Jellyseerr",
|
||||
"unlimited": "Illimité"
|
||||
"tv_quota_limit": "Limite de quota de séries",
|
||||
"tv_quota_days": "Jours de quota de séries",
|
||||
"reset_jellyseerr_config_button": "Réinitialiser la configuration Seerr",
|
||||
"unlimited": "Illimité",
|
||||
"plus_n_more": "+{{n}} Plus",
|
||||
"order_by": {
|
||||
"DEFAULT": "Par défaut",
|
||||
"VOTE_COUNT_AND_AVERAGE": "Nombre de votes et moyenne",
|
||||
"POPULARITY": "Popularité"
|
||||
}
|
||||
},
|
||||
"marlin_search": {
|
||||
"enable_marlin_search": "Activer Marlin Search ",
|
||||
"enable_marlin_search": "Activer Marlin Search",
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://domaine.org:port",
|
||||
"marlin_search_hint": "Entrez l'URL du serveur Marlin. L'URL devrait inclure http ou https et optionnellement le port.",
|
||||
"read_more_about_marlin": "Lisez-en plus sur Marlin.",
|
||||
"read_more_about_marlin": "En savoir plus sur Marlin.",
|
||||
"save_button": "Enregistrer",
|
||||
"toasts": {
|
||||
"saved": "Enregistré"
|
||||
}
|
||||
"saved": "Enregistré",
|
||||
"refreshed": "Settings refreshed from server"
|
||||
},
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"streamystats": {
|
||||
"enable_streamystats": "Enable Streamystats",
|
||||
"disable_streamystats": "Disable Streamystats",
|
||||
"enable_search": "Use for Search",
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
||||
"streamystats_search_hint": "Enter the URL for your Streamystats server. The URL should include http or https and optionally the port.",
|
||||
"read_more_about_streamystats": "Read More About Streamystats.",
|
||||
"save_button": "Save",
|
||||
"save": "Enregistrer",
|
||||
"features_title": "Fonctionnalités",
|
||||
"home_sections_title": "Sections de la page d´accueil",
|
||||
"enable_movie_recommendations": "Recommandations de films",
|
||||
"enable_series_recommendations": "Recommandations de séries",
|
||||
"enable_promoted_watchlists": "Listes de lecture promues",
|
||||
"hide_watchlists_tab": "Masquer l'onglet des listes de lecture",
|
||||
"home_sections_hint": "Afficher des recommandations personnalisées et des listes de lecture promues de Streamystats sur la page d’accueil.",
|
||||
"recommended_movies": "Films Recommandés",
|
||||
"recommended_series": "Séries recommandées",
|
||||
"toasts": {
|
||||
"saved": "Enregistré",
|
||||
"refreshed": "Paramètres actualisés depuis le serveur",
|
||||
"disabled": "Streamystats désactivé"
|
||||
},
|
||||
"refresh_from_server": "Rafraîchir les paramètres depuis le serveur"
|
||||
},
|
||||
"kefinTweaks": {
|
||||
"watchlist_enabler": "Activer l'intégration de notre liste de lecture",
|
||||
"watchlist_button": "Activer l'intégration de notre liste de lecture"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
@@ -188,84 +397,121 @@
|
||||
"app_usage": "App {{usedSpace}}%",
|
||||
"device_usage": "Appareil {{availableSpace}}%",
|
||||
"size_used": "{{used}} de {{total}} utilisés",
|
||||
"delete_all_downloaded_files": "Supprimer tous les fichiers téléchargés"
|
||||
"delete_all_downloaded_files": "Supprimer tous les fichiers téléchargés",
|
||||
"music_cache_title": "Mise en cache de la musique",
|
||||
"music_cache_description": "Mettez automatiquement en cache les chansons au fur et à mesure que vous écoutez pour une lecture plus fluide et une prise en charge hors ligne",
|
||||
"enable_music_cache": "Activer le cache sur la musique",
|
||||
"clear_music_cache": "Vider le cache de la musique",
|
||||
"music_cache_size": "{{size}} mis en cache",
|
||||
"music_cache_cleared": "Cache de musique effacé",
|
||||
"delete_all_downloaded_songs": "Supprimer toutes les musiques téléchargées",
|
||||
"downloaded_songs_size": "{{size}} téléchargé",
|
||||
"downloaded_songs_deleted": "Chansons téléchargées supprimées"
|
||||
},
|
||||
"intro": {
|
||||
"title": "Introduction",
|
||||
"show_intro": "Afficher l'intro",
|
||||
"reset_intro": "Réinitialiser l'intro"
|
||||
},
|
||||
"logs": {
|
||||
"logs_title": "Journaux",
|
||||
"no_logs_available": "Aucun journal disponible",
|
||||
"export_logs": "Exporter les journaux",
|
||||
"click_for_more_info": "Cliquez pour plus d'informations",
|
||||
"level": "Niveau",
|
||||
"no_logs_available": "Pas de journaux disponibles",
|
||||
"delete_all_logs": "Supprimer tous les journaux"
|
||||
},
|
||||
"languages": {
|
||||
"title": "Langues",
|
||||
"app_language": "Langue de l'application",
|
||||
"app_language_description": "Sélectionnez la langue de l'application",
|
||||
"system": "Système"
|
||||
},
|
||||
"toasts":{
|
||||
"toasts": {
|
||||
"error_deleting_files": "Erreur lors de la suppression des fichiers",
|
||||
"background_downloads_enabled": "Téléchargements en arrière-plan activés",
|
||||
"background_downloads_disabled": "Téléchargements en arrière-plan désactivés",
|
||||
"connected": "Connecté",
|
||||
"could_not_connect": "Impossible de se connecter",
|
||||
"invalid_url": "URL invalide"
|
||||
"background_downloads_disabled": "Téléchargements en arrière-plan désactivés"
|
||||
}
|
||||
},
|
||||
"sessions": {
|
||||
"title": "Appareils actifs",
|
||||
"no_active_sessions": "Aucun appareil actif"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Téléchargements",
|
||||
"tvseries": "Séries TV",
|
||||
"tvseries": "Séries",
|
||||
"movies": "Films",
|
||||
"queue": "File d'attente",
|
||||
"other_media": "Autres médias",
|
||||
"queue_hint": "La file d'attente et les téléchargements seront perdus au redémarrage de l'application",
|
||||
"no_items_in_queue": "Aucun téléchargement de média dans la file d'attente",
|
||||
"no_downloaded_items": "Aucun média téléchargé",
|
||||
"delete_all_movies_button": "Supprimer tous les films",
|
||||
"delete_all_tvseries_button": "Supprimer toutes les séries",
|
||||
"delete_all_button": "Supprimer tout les médias",
|
||||
"delete_all_button": "Supprimer tous les médias",
|
||||
"delete_all_other_media_button": "Supprimer un autre média",
|
||||
"active_download": "Téléchargement actif",
|
||||
"no_active_downloads": "Aucun téléchargements actifs",
|
||||
"no_active_downloads": "Pas de téléchargements actifs",
|
||||
"active_downloads": "Téléchargements actifs",
|
||||
"new_app_version_requires_re_download": "La nouvelle version de l'application nécessite un nouveau téléchargement",
|
||||
"new_app_version_requires_re_download_description": "Une nouvelle version de l'application est disponible. Veuillez supprimer tous les téléchargements et redémarrer l'application pour télécharger à nouveau",
|
||||
"new_app_version_requires_re_download_description": "La nouvelle mise à jour nécessite que le contenu soit téléchargé à nouveau. Veuillez supprimer tout le contenu téléchargé et réessayer.",
|
||||
"back": "Retour",
|
||||
"delete": "Supprimer",
|
||||
"something_went_wrong": "Quelque chose s'est mal passé",
|
||||
"could_not_get_stream_url_from_jellyfin": "Impossible d'obtenir l'URL du flux depuis Jellyfin",
|
||||
"eta": "ETA {{eta}}",
|
||||
"methods": "Méthodes",
|
||||
"toasts": {
|
||||
"you_are_not_allowed_to_download_files": "Vous n'êtes pas autorisé à télécharger des fichiers",
|
||||
"deleted_all_movies_successfully": "Tous les films ont été supprimés avec succès!",
|
||||
"you_are_not_allowed_to_download_files": "Vous n'êtes pas autorisé à télécharger des fichiers.",
|
||||
"deleted_all_movies_successfully": "Tous les films ont été supprimés avec succès !",
|
||||
"failed_to_delete_all_movies": "Échec de la suppression de tous les films",
|
||||
"deleted_all_tvseries_successfully": "Toutes les séries ont été supprimées avec succès!",
|
||||
"deleted_all_tvseries_successfully": "Toutes les séries ont été supprimées avec succès !",
|
||||
"failed_to_delete_all_tvseries": "Échec de la suppression de toutes les séries",
|
||||
"deleted_media_successfully": "Les autres médias ont été supprimés avec succès !",
|
||||
"failed_to_delete_media": "Échec de la suppression d'un autre média",
|
||||
"download_deleted": "Téléchargement supprimé",
|
||||
"download_cancelled": "Téléchargement annulé",
|
||||
"could_not_cancel_download": "Impossible d'annuler le téléchargement",
|
||||
"could_not_delete_download": "Impossible de supprimer le téléchargement",
|
||||
"download_paused": "Téléchargement en pause",
|
||||
"could_not_pause_download": "Impossible de mettre en pause le téléchargement",
|
||||
"download_resumed": "Reprise du téléchargement",
|
||||
"could_not_resume_download": "Impossible de reprendre le téléchargement",
|
||||
"download_completed": "Téléchargement terminé",
|
||||
"download_started_for": "Téléchargement démarré pour {{item}}",
|
||||
"item_is_ready_to_be_downloaded": "{{item}} est prêt à être téléchargé",
|
||||
"download_stated_for_item": "Téléchargement démarré pour {{item}}",
|
||||
"download_failed": "Échec du téléchargement",
|
||||
"download_failed_for_item": "Échec du téléchargement pour {{item}} - {{error}}",
|
||||
"download_completed_for_item": "Téléchargement terminé pour {{item}}",
|
||||
"queued_item_for_optimization": "{{item}} mis en file d'attente pour l'optimisation",
|
||||
"failed_to_start_download_for_item": "Échec du démarrage du téléchargement pour {{item}}: {{message}}",
|
||||
"server_responded_with_status_code": "Le serveur a répondu avec le code de statut {{statusCode}}",
|
||||
"no_response_received_from_server": "Aucune réponse reçue du serveur",
|
||||
"error_setting_up_the_request": "Erreur lors de la configuration de la demande",
|
||||
"failed_to_start_download_for_item_unexpected_error": "Échec du démarrage du téléchargement pour {{item}}: Erreur inattendue",
|
||||
"download_started_for_item": "Téléchargement démarré pour {{item}}",
|
||||
"failed_to_start_download": "Impossible de démarrer le téléchargement",
|
||||
"item_already_downloading": "{{item}} est déjà en cours de téléchargement",
|
||||
"all_files_deleted": "Tous les téléchargements supprimés avec succès",
|
||||
"files_deleted_by_type": "{{count}} {{type}} supprimé",
|
||||
"all_files_folders_and_jobs_deleted_successfully": "Tous les fichiers, dossiers et tâches ont été supprimés avec succès",
|
||||
"an_error_occured_while_deleting_files_and_jobs": "Une erreur s'est produite lors de la suppression des fichiers et des tâches",
|
||||
"go_to_downloads": "Aller aux téléchargements"
|
||||
"failed_to_clean_cache_directory": "Échec du nettoyage du répertoire de cache",
|
||||
"could_not_get_download_url_for_item": "Échec d'obtention de l'URL de téléchargement pour {{itemName}}",
|
||||
"go_to_downloads": "Aller aux téléchargements",
|
||||
"file_deleted": "{{item}} supprimé"
|
||||
}
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"select": "Sélectionner",
|
||||
"no_trailer_available": "Aucune bande-annonce disponible",
|
||||
"video": "Vidéo",
|
||||
"audio": "Audio",
|
||||
"subtitle": "Sous-titres",
|
||||
"play": "Lecture",
|
||||
"none": "Aucun",
|
||||
"track": "Suivre",
|
||||
"cancel": "Annuler",
|
||||
"delete": "Supprimer",
|
||||
"ok": "Ok",
|
||||
"remove": "Retirer",
|
||||
"next": "Suivant",
|
||||
"back": "Précédent",
|
||||
"continue": "Continuer",
|
||||
"verifying": "Vérification..."
|
||||
},
|
||||
"search": {
|
||||
"search_here": "Rechercher ici...",
|
||||
"search": "Rechercher...",
|
||||
"x_items": "{{count}} médias",
|
||||
"x_items": "{{count}} Médias",
|
||||
"library": "Bibliothèque",
|
||||
"discover": "Découvrir",
|
||||
"no_results": "Aucun résultat",
|
||||
@@ -275,6 +521,10 @@
|
||||
"episodes": "Épisodes",
|
||||
"collections": "Collections",
|
||||
"actors": "Acteurs",
|
||||
"artists": "Artistes",
|
||||
"albums": "Albums",
|
||||
"songs": "Chansons",
|
||||
"playlists": "Playlists",
|
||||
"request_movies": "Demander un film",
|
||||
"request_series": "Demander une série",
|
||||
"recently_added": "Ajoutés récemment",
|
||||
@@ -285,29 +535,28 @@
|
||||
"movie_genres": "Genres de films",
|
||||
"upcoming_movies": "Films à venir",
|
||||
"studios": "Studios",
|
||||
"popular_tv": "TV populaire",
|
||||
"tv_genres": "Genres TV",
|
||||
"upcoming_tv": "TV à venir",
|
||||
"networks": "Réseaux",
|
||||
"tmdb_movie_keyword": "Mot(s)-clé(s) Films TMDB",
|
||||
"popular_tv": "Séries populaires",
|
||||
"tv_genres": "Genres des séries",
|
||||
"upcoming_tv": "Séries à venir",
|
||||
"networks": "Studios",
|
||||
"tmdb_movie_keyword": "Mots-clés de film TMDB",
|
||||
"tmdb_movie_genre": "Genre de film TMDB",
|
||||
"tmdb_tv_keyword": "Mot(s)-clé(s) TV TMDB",
|
||||
"tmdb_tv_genre": "Genre TV TMDB",
|
||||
"tmdb_tv_keyword": "Mots-clés de séries TMDB",
|
||||
"tmdb_tv_genre": "Genre de séries TMDB",
|
||||
"tmdb_search": "Recherche TMDB",
|
||||
"tmdb_studio": "Studio TMDB",
|
||||
"tmdb_network": "Réseau TMDB",
|
||||
"tmdb_movie_streaming_services": "Services de streaming de films TMDB",
|
||||
"tmdb_tv_streaming_services": "Services de streaming TV TMDB"
|
||||
"tmdb_tv_streaming_services": "Services de streaming de séries TMDB"
|
||||
},
|
||||
"library": {
|
||||
"no_items_found": "Aucun média trouvé",
|
||||
"no_results": "Aucun résultat",
|
||||
"no_libraries_found": "Aucune bibliothèque trouvée",
|
||||
"item_types": {
|
||||
"movies": "films",
|
||||
"series": "séries",
|
||||
"boxsets": "coffrets",
|
||||
"items": "médias"
|
||||
"movies": "Films",
|
||||
"series": "Séries",
|
||||
"boxsets": "Coffrets ",
|
||||
"items": "Médias"
|
||||
},
|
||||
"options": {
|
||||
"display": "Affichage",
|
||||
@@ -323,6 +572,7 @@
|
||||
"genres": "Genres",
|
||||
"years": "Années",
|
||||
"sort_by": "Trier par",
|
||||
"filter_by": "Filter By",
|
||||
"sort_order": "Ordre de tri",
|
||||
"tags": "Tags"
|
||||
}
|
||||
@@ -333,7 +583,9 @@
|
||||
"episodes": "Épisodes",
|
||||
"videos": "Vidéos",
|
||||
"boxsets": "Coffrets",
|
||||
"playlists": "Listes de lecture"
|
||||
"playlists": "Playlists",
|
||||
"noDataTitle": "Pas encore de favoris",
|
||||
"noData": "Marquez des éléments comme favoris pour les voir apparaître ici pour un accès rapide."
|
||||
},
|
||||
"custom_links": {
|
||||
"no_links": "Aucuns liens"
|
||||
@@ -341,23 +593,26 @@
|
||||
"player": {
|
||||
"error": "Erreur",
|
||||
"failed_to_get_stream_url": "Échec de l'obtention de l'URL du flux",
|
||||
"an_error_occured_while_playing_the_video": "Une erreur s'est produite lors de la lecture de la vidéo",
|
||||
"client_error": "Erreur client",
|
||||
"an_error_occured_while_playing_the_video": "Une erreur s’est produite lors de la lecture de la vidéo. Vérifiez les journaux dans les paramètres.",
|
||||
"client_error": "Erreur du client",
|
||||
"could_not_create_stream_for_chromecast": "Impossible de créer un flux sur la Chromecast",
|
||||
"message_from_server": "Message du serveur: {{message}}",
|
||||
"video_has_finished_playing": "La vidéo a fini de jouer!",
|
||||
"no_video_source": "Aucune source vidéo...",
|
||||
"message_from_server": "Message du serveur : {{message}}",
|
||||
"next_episode": "Épisode suivant",
|
||||
"refresh_tracks": "Rafraîchir les pistes",
|
||||
"subtitle_tracks": "Pistes de sous-titres:",
|
||||
"audio_tracks": "Pistes audio:",
|
||||
"playback_state": "État de lecture:",
|
||||
"no_data_available": "Aucune donnée disponible",
|
||||
"index": "Index:"
|
||||
"audio_tracks": "Pistes audio :",
|
||||
"playback_state": "État de lecture :",
|
||||
"index": "Index :",
|
||||
"continue_watching": "Continuer à regarder",
|
||||
"go_back": "Retour",
|
||||
"downloaded_file_title": "Ce fichier est téléchargé",
|
||||
"downloaded_file_message": "Voulez-vous lire le fichier téléchargé ?",
|
||||
"downloaded_file_yes": "Oui",
|
||||
"downloaded_file_no": "Non",
|
||||
"downloaded_file_cancel": "Annuler"
|
||||
},
|
||||
"item_card": {
|
||||
"next_up": "À suivre",
|
||||
"no_items_to_display": "Aucun médias à afficher",
|
||||
"no_items_to_display": "Aucuns médias à afficher",
|
||||
"cast_and_crew": "Distribution et équipe",
|
||||
"series": "Séries",
|
||||
"seasons": "Saisons",
|
||||
@@ -366,9 +621,10 @@
|
||||
"overview": "Aperçu",
|
||||
"more_with": "Plus avec {{name}}",
|
||||
"similar_items": "Médias similaires",
|
||||
"no_similar_items_found": "Aucun média similaire trouvé",
|
||||
"no_similar_items_found": "Aucuns médias similaires trouvés",
|
||||
"video": "Vidéo",
|
||||
"more_details": "Plus de détails",
|
||||
"media_options": "Options média",
|
||||
"quality": "Qualité",
|
||||
"audio": "Audio",
|
||||
"subtitles": "Sous-titres",
|
||||
@@ -383,15 +639,13 @@
|
||||
"download_episode": "Télécharger l'épisode",
|
||||
"download_movie": "Télécharger le film",
|
||||
"download_x_item": "Télécharger {{item_count}} médias",
|
||||
"download_button": "Télécharger",
|
||||
"using_optimized_server": "Avec le serveur optimisées",
|
||||
"using_default_method": "Avec la méthode par défaut"
|
||||
"download_unwatched_only": "Non visionné uniquement",
|
||||
"download_button": "Télécharger"
|
||||
}
|
||||
},
|
||||
"live_tv": {
|
||||
"next": "Suivant",
|
||||
"previous": "Précédent",
|
||||
"live_tv": "TV en direct",
|
||||
"coming_soon": "Bientôt",
|
||||
"on_now": "En ce moment",
|
||||
"shows": "Émissions",
|
||||
@@ -400,23 +654,23 @@
|
||||
"for_kids": "Pour enfants",
|
||||
"news": "Actualités"
|
||||
},
|
||||
"jellyseerr":{
|
||||
"jellyseerr": {
|
||||
"confirm": "Confirmer",
|
||||
"cancel": "Annuler",
|
||||
"yes": "Oui",
|
||||
"whats_wrong": "Quel est le problème?",
|
||||
"whats_wrong": "Quel est le problème ?",
|
||||
"issue_type": "Type de problème",
|
||||
"select_an_issue": "Sélectionnez un problème",
|
||||
"types": "Types",
|
||||
"types": "Types de fichiers",
|
||||
"describe_the_issue": "(optionnel) Décrivez le problème...",
|
||||
"submit_button": "Soumettre",
|
||||
"report_issue_button": "Signaler un problème",
|
||||
"request_button": "Demander",
|
||||
"are_you_sure_you_want_to_request_all_seasons": "Êtes-vous sûr de vouloir demander toutes les saisons?",
|
||||
"are_you_sure_you_want_to_request_all_seasons": "Êtes-vous sûr de vouloir demander toutes les saisons ?",
|
||||
"failed_to_login": "Échec de la connexion",
|
||||
"cast": "Distribution",
|
||||
"details": "Détails",
|
||||
"status": "Statut",
|
||||
"status": "Statuts",
|
||||
"original_title": "Titre original",
|
||||
"series_type": "Type de série",
|
||||
"release_dates": "Dates de sortie",
|
||||
@@ -434,19 +688,27 @@
|
||||
"tags": "Tags",
|
||||
"quality_profile": "Profil de qualité",
|
||||
"root_folder": "Dossier racine",
|
||||
"season_x": "Saison {{seasons}}",
|
||||
"season_all": "Saison (Tous)",
|
||||
"season_number": "Saison {{season_number}}",
|
||||
"number_episodes": "{{episode_number}} épisodes",
|
||||
"number_episodes": "{{episode_number}} Épisodes",
|
||||
"born": "Né(e) le",
|
||||
"appearances": "Apparences",
|
||||
"approve": "Valider",
|
||||
"decline": "Refuser",
|
||||
"requested_by": "Demandé par {{user}}",
|
||||
"unknown_user": "Utilisateur inconnu",
|
||||
"toasts": {
|
||||
"jellyseer_does_not_meet_requirements": "Jellyseer ne répond pas aux exigences! Veuillez mettre à jour au moins vers la version 2.0.0.",
|
||||
"jellyseerr_test_failed": "Échec du test de Jellyseerr",
|
||||
"failed_to_test_jellyseerr_server_url": "Échec du test de l'URL du serveur Jellyseerr",
|
||||
"issue_submitted": "Problème soumis!",
|
||||
"requested_item": "{{item}}} demandé!",
|
||||
"you_dont_have_permission_to_request": "Vous n'avez pas la permission de demander {{item}}",
|
||||
"something_went_wrong_requesting_media": "Quelque chose s'est mal passé en demandant le média!"
|
||||
"jellyseer_does_not_meet_requirements": "Seerr ne répond pas aux exigences ! Veuillez mettre à jour au moins vers la version 2.0.0.",
|
||||
"jellyseerr_test_failed": "Le test Seerr a échoué. Veuillez réessayer.",
|
||||
"failed_to_test_jellyseerr_server_url": "Échec du test de l'URL du serveur Seerr",
|
||||
"issue_submitted": "Problème soumis !",
|
||||
"requested_item": "{{item}}} demandé !",
|
||||
"you_dont_have_permission_to_request": "Vous n'avez pas la permission de demander !",
|
||||
"something_went_wrong_requesting_media": "Quelque chose s'est mal passé en demandant le média !",
|
||||
"request_approved": "Demande approuvée !",
|
||||
"request_declined": "Demande déclinée !",
|
||||
"failed_to_approve_request": "Échec d'approbation de la demande",
|
||||
"failed_to_decline_request": "Échec du refus de la demande"
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
@@ -455,5 +717,129 @@
|
||||
"library": "Bibliothèque",
|
||||
"custom_links": "Liens personnalisés",
|
||||
"favorites": "Favoris"
|
||||
},
|
||||
"music": {
|
||||
"title": "Music",
|
||||
"tabs": {
|
||||
"suggestions": "Suggestions",
|
||||
"albums": "Albums",
|
||||
"artists": "Artists",
|
||||
"playlists": "Playlists",
|
||||
"tracks": "tracks"
|
||||
},
|
||||
"filters": {
|
||||
"all": "All"
|
||||
},
|
||||
"recently_added": "Recently Added",
|
||||
"recently_played": "Recently Played",
|
||||
"frequently_played": "Frequently Played",
|
||||
"explore": "Explore",
|
||||
"top_tracks": "Top Tracks",
|
||||
"play": "Play",
|
||||
"shuffle": "Shuffle",
|
||||
"play_top_tracks": "Play Top Tracks",
|
||||
"no_suggestions": "No suggestions available",
|
||||
"no_albums": "No albums found",
|
||||
"no_artists": "No artists found",
|
||||
"no_playlists": "No playlists found",
|
||||
"album_not_found": "Album not found",
|
||||
"artist_not_found": "Artist not found",
|
||||
"playlist_not_found": "Playlist not found",
|
||||
"track_options": {
|
||||
"play_next": "Play Next",
|
||||
"add_to_queue": "Add to Queue",
|
||||
"add_to_playlist": "Add to Playlist",
|
||||
"download": "Download",
|
||||
"downloaded": "Downloaded",
|
||||
"downloading": "Downloading...",
|
||||
"cached": "Cached",
|
||||
"delete_download": "Delete Download",
|
||||
"delete_cache": "Remove from Cache",
|
||||
"go_to_artist": "Go to Artist",
|
||||
"go_to_album": "Go to Album",
|
||||
"add_to_favorites": "Add to Favorites",
|
||||
"remove_from_favorites": "Remove from Favorites",
|
||||
"remove_from_playlist": "Remove from Playlist"
|
||||
},
|
||||
"playlists": {
|
||||
"create_playlist": "Create Playlist",
|
||||
"playlist_name": "Playlist Name",
|
||||
"enter_name": "Enter playlist name",
|
||||
"create": "Create",
|
||||
"search_playlists": "Search playlists...",
|
||||
"added_to": "Added to {{name}}",
|
||||
"added": "Added to playlist",
|
||||
"removed_from": "Removed from {{name}}",
|
||||
"removed": "Removed from playlist",
|
||||
"created": "Playlist created",
|
||||
"create_new": "Create New Playlist",
|
||||
"failed_to_add": "Failed to add to playlist",
|
||||
"failed_to_remove": "Failed to remove from playlist",
|
||||
"failed_to_create": "Failed to create playlist",
|
||||
"delete_playlist": "Delete Playlist",
|
||||
"delete_confirm": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
|
||||
"deleted": "Playlist deleted",
|
||||
"failed_to_delete": "Failed to delete playlist"
|
||||
},
|
||||
"sort": {
|
||||
"title": "Sort By",
|
||||
"alphabetical": "Alphabetical",
|
||||
"date_created": "Date Created"
|
||||
}
|
||||
},
|
||||
"watchlists": {
|
||||
"title": "Watchlists",
|
||||
"my_watchlists": "My Watchlists",
|
||||
"public_watchlists": "Public Watchlists",
|
||||
"create_title": "Create Watchlist",
|
||||
"edit_title": "Edit Watchlist",
|
||||
"create_button": "Create Watchlist",
|
||||
"save_button": "Save Changes",
|
||||
"delete_button": "Delete",
|
||||
"remove_button": "Remove",
|
||||
"cancel_button": "Cancel",
|
||||
"name_label": "Name",
|
||||
"name_placeholder": "Enter watchlist name",
|
||||
"description_label": "Description",
|
||||
"description_placeholder": "Enter description (optional)",
|
||||
"is_public_label": "Public Watchlist",
|
||||
"is_public_description": "Allow others to view this watchlist",
|
||||
"allowed_type_label": "Content Type",
|
||||
"sort_order_label": "Default Sort Order",
|
||||
"empty_title": "No Watchlists",
|
||||
"empty_description": "Create your first watchlist to start organizing your media",
|
||||
"empty_watchlist": "This watchlist is empty",
|
||||
"empty_watchlist_hint": "Add items from your library to this watchlist",
|
||||
"not_configured_title": "Streamystats Not Configured",
|
||||
"not_configured_description": "Configure Streamystats in settings to use watchlists",
|
||||
"go_to_settings": "Go to Settings",
|
||||
"add_to_watchlist": "Add to Watchlist",
|
||||
"remove_from_watchlist": "Remove from Watchlist",
|
||||
"select_watchlist": "Select Watchlist",
|
||||
"create_new": "Create New Watchlist",
|
||||
"item": "item",
|
||||
"items": "items",
|
||||
"public": "Public",
|
||||
"private": "Private",
|
||||
"you": "You",
|
||||
"by_owner": "By another user",
|
||||
"not_found": "Watchlist not found",
|
||||
"delete_confirm_title": "Delete Watchlist",
|
||||
"delete_confirm_message": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
|
||||
"remove_item_title": "Remove from Watchlist",
|
||||
"remove_item_message": "Remove \"{{name}}\" from this watchlist?",
|
||||
"loading": "Loading watchlists...",
|
||||
"no_compatible_watchlists": "No compatible watchlists",
|
||||
"create_one_first": "Create a watchlist that accepts this content type"
|
||||
},
|
||||
"playback_speed": {
|
||||
"title": "Playback Speed",
|
||||
"apply_to": "Apply To",
|
||||
"speed": "Speed",
|
||||
"scope": {
|
||||
"media": "This media only",
|
||||
"show": "This show",
|
||||
"all": "All media (default)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1277
translations/it.json
1277
translations/it.json
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,9 @@
|
||||
"server_is_taking_too_long_to_respond_try_again_later": "サーバーの応答に時間がかかりすぎています。しばらくしてからもう一度お試しください。",
|
||||
"server_received_too_many_requests_try_again_later": "サーバーにリクエストが多すぎます。後でもう一度お試しください。",
|
||||
"there_is_a_server_error": "サーバーエラーが発生しました",
|
||||
"an_unexpected_error_occured_did_you_enter_the_correct_url": "予期しないエラーが発生しました。サーバーのURLを正しく入力しましたか?"
|
||||
"an_unexpected_error_occured_did_you_enter_the_correct_url": "予期しないエラーが発生しました。サーバーのURLを正しく入力しましたか?",
|
||||
"too_old_server_text": "サポートされていないJellyfinサーバー発見",
|
||||
"too_old_server_description": "Jellyfinを最新バージョンにアップデートしてください"
|
||||
},
|
||||
"server": {
|
||||
"enter_url_to_jellyfin_server": "JellyfinサーバーのURLを入力してください",
|
||||
@@ -28,19 +30,64 @@
|
||||
"connect_button": "接続",
|
||||
"previous_servers": "前のサーバー",
|
||||
"clear_button": "クリア",
|
||||
"swipe_to_remove": "Swipe to remove",
|
||||
"search_for_local_servers": "ローカルサーバーを検索",
|
||||
"searching": "検索中...",
|
||||
"servers": "サーバー"
|
||||
"servers": "サーバー",
|
||||
"saved": "Saved",
|
||||
"session_expired": "Session Expired",
|
||||
"please_login_again": "Your saved session has expired. Please log in again.",
|
||||
"remove_saved_login": "Remove Saved Login",
|
||||
"remove_saved_login_description": "This will remove your saved credentials for this server. You'll need to enter your username and password again next time.",
|
||||
"accounts_count": "{{count}} accounts",
|
||||
"select_account": "Select Account",
|
||||
"add_account": "Add Account",
|
||||
"remove_account_description": "This will remove the saved credentials for {{username}}."
|
||||
},
|
||||
"save_account": {
|
||||
"title": "Save Account",
|
||||
"save_for_later": "Save this account",
|
||||
"security_option": "Security Option",
|
||||
"no_protection": "No protection",
|
||||
"no_protection_desc": "Quick login without authentication",
|
||||
"pin_code": "PIN code",
|
||||
"pin_code_desc": "4-digit PIN required when switching",
|
||||
"password": "Re-enter password",
|
||||
"password_desc": "Password required when switching",
|
||||
"save_button": "Save",
|
||||
"cancel_button": "Cancel"
|
||||
},
|
||||
"pin": {
|
||||
"enter_pin": "Enter PIN",
|
||||
"enter_pin_for": "Enter PIN for {{username}}",
|
||||
"enter_4_digits": "Enter 4 digits",
|
||||
"invalid_pin": "Invalid PIN",
|
||||
"setup_pin": "Set Up PIN",
|
||||
"confirm_pin": "Confirm PIN",
|
||||
"pins_dont_match": "PINs don't match",
|
||||
"forgot_pin": "Forgot PIN?",
|
||||
"forgot_pin_desc": "Your saved credentials will be removed"
|
||||
},
|
||||
"password": {
|
||||
"enter_password": "Enter Password",
|
||||
"enter_password_for": "Enter password for {{username}}",
|
||||
"invalid_password": "Invalid password"
|
||||
},
|
||||
"home": {
|
||||
"checking_server_connection": "サーバー接続を確認しています...",
|
||||
"no_internet": "インターネット接続がありません",
|
||||
"no_items": "アイテムはありません",
|
||||
"no_internet_message": "心配しないでください。\nダウンロードしたコンテンツは引き続き視聴できます。",
|
||||
"checking_server_connection_message": "サーバーへの接続を確認しています",
|
||||
"go_to_downloads": "ダウンロードに移動",
|
||||
"retry": "再試行する",
|
||||
"server_unreachable": "サーバーに到達できません",
|
||||
"server_unreachable_message": "サーバーに接続できませんでした。\nネットワーク接続を確認してください。",
|
||||
"oops": "おっと!",
|
||||
"error_message": "何か問題が発生しました。\nログアウトして再度ログインしてください。",
|
||||
"continue_watching": "続きを見る",
|
||||
"next_up": "次の動画",
|
||||
"continue_and_next_up": "Continue & Next Up",
|
||||
"recently_added_in": "{{libraryName}}に最近追加された",
|
||||
"suggested_movies": "おすすめ映画",
|
||||
"suggested_episodes": "おすすめエピソード",
|
||||
@@ -62,6 +109,48 @@
|
||||
"settings": {
|
||||
"settings_title": "設定",
|
||||
"log_out_button": "ログアウト",
|
||||
"categories": {
|
||||
"title": "カテゴリ"
|
||||
},
|
||||
"playback_controls": {
|
||||
"title": "再生と操作"
|
||||
},
|
||||
"audio_subtitles": {
|
||||
"title": "音声と字幕"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Appearance",
|
||||
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
|
||||
"hide_remote_session_button": "Hide Remote Session Button"
|
||||
},
|
||||
"network": {
|
||||
"title": "Network",
|
||||
"local_network": "Local Network",
|
||||
"auto_switch_enabled": "Auto-switch when at home",
|
||||
"auto_switch_description": "Automatically switch to local URL when connected to home WiFi",
|
||||
"local_url": "Local URL",
|
||||
"local_url_hint": "Enter your local server address (e.g., http://192.168.1.100:8096)",
|
||||
"local_url_placeholder": "http://192.168.1.100:8096",
|
||||
"home_wifi_networks": "Home WiFi Networks",
|
||||
"add_current_network": "Add \"{{ssid}}\"",
|
||||
"not_connected_to_wifi": "Not connected to WiFi",
|
||||
"no_networks_configured": "No networks configured",
|
||||
"add_network_hint": "Add your home WiFi network to enable auto-switching",
|
||||
"current_wifi": "Current WiFi",
|
||||
"using_url": "Using",
|
||||
"local": "Local URL",
|
||||
"remote": "Remote URL",
|
||||
"not_connected": "Not connected",
|
||||
"current_server": "Current Server",
|
||||
"remote_url": "Remote URL",
|
||||
"active_url": "Active URL",
|
||||
"not_configured": "Not configured",
|
||||
"network_added": "Network added",
|
||||
"network_already_added": "Network already added",
|
||||
"no_wifi_connected": "Not connected to WiFi",
|
||||
"permission_denied": "Location permission denied",
|
||||
"permission_denied_explanation": "Location permission is required to detect WiFi network for auto-switching. Please enable it in Settings."
|
||||
},
|
||||
"user_info": {
|
||||
"user_info_title": "ユーザー情報",
|
||||
"user": "ユーザー",
|
||||
@@ -85,21 +174,42 @@
|
||||
"rewind_length": "巻き戻しの長さ",
|
||||
"seconds_unit": "s"
|
||||
},
|
||||
"gesture_controls": {
|
||||
"gesture_controls_title": "ジェスチャーコントロール",
|
||||
"horizontal_swipe_skip": "水平方向にスワイプしてスキップ",
|
||||
"horizontal_swipe_skip_description": "コントロールが非表示の場合は左右にスワイプしてスキップします",
|
||||
"left_side_brightness": "左側の明るさコントロール",
|
||||
"left_side_brightness_description": "左側を上下にスワイプして明るさを調整する",
|
||||
"right_side_volume": "右側の音量制御",
|
||||
"right_side_volume_description": "右側の上下にスワイプして音量を調整します",
|
||||
"hide_volume_slider": "Hide Volume Slider",
|
||||
"hide_volume_slider_description": "Hide the volume slider in the video player",
|
||||
"hide_brightness_slider": "Hide Brightness Slider",
|
||||
"hide_brightness_slider_description": "Hide the brightness slider in the video player"
|
||||
},
|
||||
"audio": {
|
||||
"audio_title": "オーディオ",
|
||||
"set_audio_track": "前のアイテムからオーディオトラックを設定",
|
||||
"audio_language": "オーディオ言語",
|
||||
"audio_hint": "デフォルトのオーディオ言語を選択します。",
|
||||
"none": "なし",
|
||||
"language": "言語"
|
||||
"language": "言語",
|
||||
"transcode_mode": {
|
||||
"title": "Audio Transcoding",
|
||||
"description": "Controls how surround audio (7.1, TrueHD, DTS-HD) is handled",
|
||||
"auto": "Auto",
|
||||
"stereo": "Force Stereo",
|
||||
"5_1": "Allow 5.1",
|
||||
"passthrough": "Passthrough"
|
||||
}
|
||||
},
|
||||
"subtitles": {
|
||||
"subtitle_title": "字幕",
|
||||
"subtitle_hint": "字幕設定を構成します。",
|
||||
"subtitle_language": "字幕の言語",
|
||||
"subtitle_mode": "字幕モード",
|
||||
"set_subtitle_track": "前のアイテムから字幕トラックを設定",
|
||||
"subtitle_size": "字幕サイズ",
|
||||
"subtitle_hint": "字幕設定を構成します。",
|
||||
"none": "なし",
|
||||
"language": "言語",
|
||||
"loading": "ロード中",
|
||||
@@ -109,11 +219,66 @@
|
||||
"Always": "常に",
|
||||
"None": "なし",
|
||||
"OnlyForced": "強制のみ"
|
||||
}
|
||||
},
|
||||
"text_color": "テキストの色",
|
||||
"background_color": "背景色",
|
||||
"outline_color": "アウトラインの色",
|
||||
"outline_thickness": "概要 厚さ",
|
||||
"background_opacity": "背景の透明度",
|
||||
"outline_opacity": "アウトラインの透明度",
|
||||
"bold_text": "Bold Text",
|
||||
"colors": {
|
||||
"Black": "ブラック",
|
||||
"Gray": "グレー",
|
||||
"Silver": "シルバー",
|
||||
"White": "白",
|
||||
"Maroon": "Maroon",
|
||||
"Red": "赤",
|
||||
"Fuchsia": "Fuchsia",
|
||||
"Yellow": "黄色",
|
||||
"Olive": "オリーブ",
|
||||
"Green": "緑",
|
||||
"Teal": "ティール",
|
||||
"Lime": "黄緑",
|
||||
"Purple": "パープル",
|
||||
"Navy": "海軍format@@0",
|
||||
"Blue": "青",
|
||||
"Aqua": "Aqua"
|
||||
},
|
||||
"thickness": {
|
||||
"None": "なし",
|
||||
"Thin": "細いです",
|
||||
"Normal": "標準",
|
||||
"Thick": "濃厚な"
|
||||
},
|
||||
"subtitle_color": "Subtitle Color",
|
||||
"subtitle_background_color": "Background Color",
|
||||
"subtitle_font": "Subtitle Font",
|
||||
"ksplayer_title": "KSPlayer Settings",
|
||||
"hardware_decode": "Hardware Decoding",
|
||||
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues."
|
||||
},
|
||||
"vlc_subtitles": {
|
||||
"title": "VLC Subtitle Settings",
|
||||
"hint": "Customize subtitle appearance for VLC player. Changes take effect on next playback.",
|
||||
"text_color": "Text Color",
|
||||
"background_color": "Background Color",
|
||||
"background_opacity": "Background Opacity",
|
||||
"outline_color": "Outline Color",
|
||||
"outline_opacity": "Outline Opacity",
|
||||
"outline_thickness": "Outline Thickness",
|
||||
"bold": "Bold Text",
|
||||
"margin": "Bottom Margin"
|
||||
},
|
||||
"video_player": {
|
||||
"title": "Video Player",
|
||||
"video_player": "Video Player",
|
||||
"video_player_description": "Choose which video player to use on iOS.",
|
||||
"ksplayer": "KSPlayer",
|
||||
"vlc": "VLC"
|
||||
},
|
||||
"other": {
|
||||
"other_title": "その他",
|
||||
"auto_rotate": "画面の自動回転",
|
||||
"video_orientation": "動画の向き",
|
||||
"orientation": "向き",
|
||||
"orientations": {
|
||||
@@ -129,25 +294,35 @@
|
||||
"UNKNOWN": "不明"
|
||||
},
|
||||
"safe_area_in_controls": "コントロールの安全エリア",
|
||||
"video_player": "Video player",
|
||||
"video_players": {
|
||||
"VLC_3": "VLC 3",
|
||||
"VLC_4": "VLC 4 (Experimental + PiP)"
|
||||
},
|
||||
"show_custom_menu_links": "カスタムメニューのリンクを表示",
|
||||
"show_large_home_carousel": "大きなヒーロー(Beta)",
|
||||
"hide_libraries": "ライブラリを非表示",
|
||||
"select_liraries_you_want_to_hide": "ライブラリタブとホームページセクションから非表示にするライブラリを選択します。",
|
||||
"disable_haptic_feedback": "触覚フィードバックを無効にする"
|
||||
"disable_haptic_feedback": "触覚フィードバックを無効にする",
|
||||
"default_quality": "デフォルトの品質",
|
||||
"default_playback_speed": "Default Playback Speed",
|
||||
"auto_play_next_episode": "Auto-play Next Episode",
|
||||
"max_auto_play_episode_count": "自動再生エピソードの最大数",
|
||||
"disabled": "無効"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "ダウンロード",
|
||||
"download_method": "ダウンロード方法",
|
||||
"remux_max_download": "Remux最大ダウンロード数",
|
||||
"auto_download": "自動ダウンロード",
|
||||
"optimized_versions_server": "Optimized versionsサーバー",
|
||||
"save_button": "保存",
|
||||
"optimized_server": "Optimizedサーバー",
|
||||
"optimized": "最適化",
|
||||
"default": "デフォルト",
|
||||
"optimized_version_hint": "OptimizeサーバーのURLを入力します。URLにはhttpまたはhttpsを含め、オプションでポートを指定します。",
|
||||
"read_more_about_optimized_server": "Optimizeサーバーの詳細をご覧ください。",
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://domain.org:ポート"
|
||||
"downloads_title": "ダウンロード"
|
||||
},
|
||||
"music": {
|
||||
"title": "Music",
|
||||
"playback_title": "Playback",
|
||||
"playback_description": "Configure how music is played.",
|
||||
"prefer_downloaded": "Prefer Downloaded Songs",
|
||||
"caching_title": "Caching",
|
||||
"caching_description": "Automatically cache upcoming tracks for smoother playback.",
|
||||
"lookahead_enabled": "Enable Look-Ahead Caching",
|
||||
"lookahead_count": "Tracks to Pre-cache",
|
||||
"max_cache_size": "Max Cache Size"
|
||||
},
|
||||
"plugins": {
|
||||
"plugins_title": "プラグイン",
|
||||
@@ -158,8 +333,6 @@
|
||||
"server_url_placeholder": "Jellyseerr URL...",
|
||||
"password": "パスワード",
|
||||
"password_placeholder": "Jellyfinユーザー {{username}} のパスワードを入力してください",
|
||||
"save_button": "保存",
|
||||
"clear_button": "クリア",
|
||||
"login_button": "ログイン",
|
||||
"total_media_requests": "メディアリクエストの合計",
|
||||
"movie_quota_limit": "映画のクオータ制限",
|
||||
@@ -167,7 +340,13 @@
|
||||
"tv_quota_limit": "テレビのクオータ制限",
|
||||
"tv_quota_days": "テレビのクオータ日数",
|
||||
"reset_jellyseerr_config_button": "Jellyseerrの設定をリセット",
|
||||
"unlimited": "無制限"
|
||||
"unlimited": "無制限",
|
||||
"plus_n_more": "+{{n}} more",
|
||||
"order_by": {
|
||||
"DEFAULT": "デフォルト",
|
||||
"VOTE_COUNT_AND_AVERAGE": "投票数と平均",
|
||||
"POPULARITY": "人気"
|
||||
}
|
||||
},
|
||||
"marlin_search": {
|
||||
"enable_marlin_search": "マーリン検索を有効にする ",
|
||||
@@ -177,52 +356,99 @@
|
||||
"read_more_about_marlin": "Marlinについて詳しく読む。",
|
||||
"save_button": "保存",
|
||||
"toasts": {
|
||||
"saved": "保存しました"
|
||||
}
|
||||
"saved": "保存しました",
|
||||
"refreshed": "Settings refreshed from server"
|
||||
},
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"streamystats": {
|
||||
"enable_streamystats": "Enable Streamystats",
|
||||
"disable_streamystats": "Disable Streamystats",
|
||||
"enable_search": "Use for Search",
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
||||
"streamystats_search_hint": "Enter the URL for your Streamystats server. The URL should include http or https and optionally the port.",
|
||||
"read_more_about_streamystats": "Read More About Streamystats.",
|
||||
"save_button": "Save",
|
||||
"save": "Save",
|
||||
"features_title": "Features",
|
||||
"home_sections_title": "Home Sections",
|
||||
"enable_movie_recommendations": "Movie Recommendations",
|
||||
"enable_series_recommendations": "Series Recommendations",
|
||||
"enable_promoted_watchlists": "Promoted Watchlists",
|
||||
"hide_watchlists_tab": "Hide Watchlists Tab",
|
||||
"home_sections_hint": "Show personalized recommendations and promoted watchlists from Streamystats on the home page.",
|
||||
"recommended_movies": "Recommended Movies",
|
||||
"recommended_series": "Recommended Series",
|
||||
"toasts": {
|
||||
"saved": "Saved",
|
||||
"refreshed": "Settings refreshed from server",
|
||||
"disabled": "Streamystats disabled"
|
||||
},
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"kefinTweaks": {
|
||||
"watchlist_enabler": "Enable our Watchlist integration",
|
||||
"watchlist_button": "Toggle Watchlist integration"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
"storage_title": "ストレージ",
|
||||
"app_usage": "アプリ {{usedSpace}}%",
|
||||
"phone_usage": "電話 {{availableSpace}}%",
|
||||
"device_usage": "デバイス {{availableSpace}}%",
|
||||
"size_used": "{{used}} / {{total}} 使用済み",
|
||||
"delete_all_downloaded_files": "すべてのダウンロードファイルを削除"
|
||||
"delete_all_downloaded_files": "すべてのダウンロードファイルを削除",
|
||||
"music_cache_title": "Music Cache",
|
||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||
"enable_music_cache": "Enable Music Cache",
|
||||
"clear_music_cache": "Clear Music Cache",
|
||||
"music_cache_size": "{{size}} cached",
|
||||
"music_cache_cleared": "Music cache cleared",
|
||||
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
|
||||
"downloaded_songs_size": "{{size}} downloaded",
|
||||
"downloaded_songs_deleted": "Downloaded songs deleted"
|
||||
},
|
||||
"intro": {
|
||||
"title": "イントロ",
|
||||
"show_intro": "イントロを表示",
|
||||
"reset_intro": "イントロをリセット"
|
||||
},
|
||||
"logs": {
|
||||
"logs_title": "ログ",
|
||||
"export_logs": "Export logs",
|
||||
"click_for_more_info": "Click for more info",
|
||||
"level": "レベル",
|
||||
"no_logs_available": "ログがありません",
|
||||
"delete_all_logs": "すべてのログを削除"
|
||||
},
|
||||
"languages": {
|
||||
"title": "言語",
|
||||
"app_language": "アプリの言語",
|
||||
"app_language_description": "アプリの言語を選択。",
|
||||
"system": "システム"
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "ファイルの削除エラー",
|
||||
"background_downloads_enabled": "バックグラウンドでのダウンロードは有効です",
|
||||
"background_downloads_disabled": "バックグラウンドでのダウンロードは無効です",
|
||||
"connected": "接続済み",
|
||||
"could_not_connect": "接続できません",
|
||||
"invalid_url": "無効なURL"
|
||||
"background_downloads_disabled": "バックグラウンドでのダウンロードは無効です"
|
||||
}
|
||||
},
|
||||
"sessions": {
|
||||
"title": "セッション",
|
||||
"no_active_sessions": "アクティブなセッションはありません"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "ダウンロード",
|
||||
"tvseries": "TVシリーズ",
|
||||
"movies": "映画",
|
||||
"queue": "キュー",
|
||||
"other_media": "その他のメディア",
|
||||
"queue_hint": "アプリを再起動するとキューとダウンロードは失われます",
|
||||
"no_items_in_queue": "キューにアイテムがありません",
|
||||
"no_downloaded_items": "ダウンロードしたアイテムはありません",
|
||||
"delete_all_movies_button": "すべての映画を削除",
|
||||
"delete_all_tvseries_button": "すべてのシリーズを削除",
|
||||
"delete_all_button": "すべて削除",
|
||||
"delete_all_other_media_button": "他のメディアを削除する",
|
||||
"active_download": "アクティブなダウンロード",
|
||||
"no_active_downloads": "アクティブなダウンロードはありません",
|
||||
"active_downloads": "アクティブなダウンロード",
|
||||
@@ -233,35 +459,57 @@
|
||||
"something_went_wrong": "問題が発生しました",
|
||||
"could_not_get_stream_url_from_jellyfin": "JellyfinからストリームURLを取得できませんでした",
|
||||
"eta": "ETA {{eta}}",
|
||||
"methods": "方法",
|
||||
"toasts": {
|
||||
"you_are_not_allowed_to_download_files": "ファイルをダウンロードする権限がありません。",
|
||||
"deleted_all_movies_successfully": "すべての映画を正常に削除しました!",
|
||||
"failed_to_delete_all_movies": "すべての映画を削除できませんでした",
|
||||
"deleted_all_tvseries_successfully": "すべてのシリーズを正常に削除しました!",
|
||||
"failed_to_delete_all_tvseries": "すべてのシリーズを削除できませんでした",
|
||||
"deleted_media_successfully": "他のメディアを削除しました!",
|
||||
"failed_to_delete_media": "他のメディアの削除に失敗しました",
|
||||
"download_deleted": "ダウンロードが削除されました",
|
||||
"download_cancelled": "ダウンロードをキャンセルしました",
|
||||
"could_not_cancel_download": "ダウンロードをキャンセルできませんでした",
|
||||
"could_not_delete_download": "ダウンロードを削除できませんでした",
|
||||
"download_paused": "ダウンロードを一時停止しました",
|
||||
"could_not_pause_download": "ダウンロードを一時停止できませんでした",
|
||||
"download_resumed": "ダウンロード再開",
|
||||
"could_not_resume_download": "ダウンロードを再開できませんでした",
|
||||
"download_completed": "ダウンロードが完了しました",
|
||||
"download_started_for": "{{item}}のダウンロードが開始されました",
|
||||
"item_is_ready_to_be_downloaded": "{{item}}をダウンロードする準備ができました",
|
||||
"download_stated_for_item": "{{item}}のダウンロードが開始されました",
|
||||
"download_failed": "ダウンロードに失敗しました",
|
||||
"download_failed_for_item": "{{item}}のダウンロードに失敗しました - {{error}}",
|
||||
"download_completed_for_item": "{{item}}のダウンロードが完了しました",
|
||||
"queued_item_for_optimization": "{{item}}をoptimizeのキューに追加しました",
|
||||
"failed_to_start_download_for_item": "{{item}}のダウンロードを開始できませんでした: {{message}}",
|
||||
"server_responded_with_status_code": "サーバーはステータス{{statusCode}}で応答しました",
|
||||
"no_response_received_from_server": "サーバーからの応答がありません",
|
||||
"error_setting_up_the_request": "リクエストの設定中にエラーが発生しました",
|
||||
"failed_to_start_download_for_item_unexpected_error": "{{item}}のダウンロードを開始できませんでした: 予期しないエラーが発生しました",
|
||||
"download_started_for_item": "{{item}} のダウンロードを開始しました",
|
||||
"failed_to_start_download": "ダウンロードの開始に失敗しました",
|
||||
"item_already_downloading": "{{item}} is already downloading",
|
||||
"all_files_deleted": "All Downloads Deleted Successfully",
|
||||
"files_deleted_by_type": "{{count}} {{type}} deleted",
|
||||
"all_files_folders_and_jobs_deleted_successfully": "すべてのファイル、フォルダ、ジョブが正常に削除されました",
|
||||
"an_error_occured_while_deleting_files_and_jobs": "ファイルとジョブの削除中にエラーが発生しました",
|
||||
"go_to_downloads": "ダウンロードに移動"
|
||||
"failed_to_clean_cache_directory": "キャッシュディレクトリのクリーンアップに失敗しました",
|
||||
"could_not_get_download_url_for_item": "{{itemName}} のダウンロードURLを取得できませんでした",
|
||||
"go_to_downloads": "ダウンロードに移動",
|
||||
"file_deleted": "{{item}} deleted"
|
||||
}
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"select": "選択",
|
||||
"no_trailer_available": "トレーラーがありません",
|
||||
"video": "映像",
|
||||
"audio": "音声",
|
||||
"subtitle": "字幕",
|
||||
"play": "再生",
|
||||
"none": "None",
|
||||
"track": "Track",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"ok": "OK",
|
||||
"remove": "Remove",
|
||||
"next": "Next",
|
||||
"back": "Back",
|
||||
"continue": "Continue",
|
||||
"verifying": "Verifying..."
|
||||
},
|
||||
"search": {
|
||||
"search_here": "ここを検索...",
|
||||
"search": "検索...",
|
||||
"x_items": "{{count}}のアイテム",
|
||||
"library": "ライブラリ",
|
||||
@@ -273,6 +521,10 @@
|
||||
"episodes": "エピソード",
|
||||
"collections": "コレクション",
|
||||
"actors": "俳優",
|
||||
"artists": "Artists",
|
||||
"albums": "Albums",
|
||||
"songs": "Songs",
|
||||
"playlists": "Playlists",
|
||||
"request_movies": "映画をリクエスト",
|
||||
"request_series": "シリーズをリクエスト",
|
||||
"recently_added": "最近の追加",
|
||||
@@ -298,7 +550,6 @@
|
||||
"tmdb_tv_streaming_services": "TMDBシリーズストリーミングサービス"
|
||||
},
|
||||
"library": {
|
||||
"no_items_found": "アイテムが見つかりません",
|
||||
"no_results": "検索結果はありません",
|
||||
"no_libraries_found": "ライブラリが見つかりません",
|
||||
"item_types": {
|
||||
@@ -321,6 +572,7 @@
|
||||
"genres": "ジャンル",
|
||||
"years": "年",
|
||||
"sort_by": "ソート",
|
||||
"filter_by": "Filter By",
|
||||
"sort_order": "ソート順",
|
||||
"tags": "タグ"
|
||||
}
|
||||
@@ -331,7 +583,9 @@
|
||||
"episodes": "エピソード",
|
||||
"videos": "ビデオ",
|
||||
"boxsets": "ボックスセット",
|
||||
"playlists": "プレイリスト"
|
||||
"playlists": "プレイリスト",
|
||||
"noDataTitle": "お気に入りはまだありません",
|
||||
"noData": "アイテムをお気に入りとしてマークすると、ここに表示されクイックアクセスできるようになります。"
|
||||
},
|
||||
"custom_links": {
|
||||
"no_links": "リンクがありません"
|
||||
@@ -342,16 +596,19 @@
|
||||
"an_error_occured_while_playing_the_video": "動画の再生中にエラーが発生しました。設定でログを確認してください。",
|
||||
"client_error": "クライアントエラー",
|
||||
"could_not_create_stream_for_chromecast": "Chromecastのストリームを作成できませんでした",
|
||||
"message_from_server": "サーバーからのメッセージ: {{message}}",
|
||||
"video_has_finished_playing": "ビデオの再生が終了しました!",
|
||||
"no_video_source": "動画ソースがありません...",
|
||||
"message_from_server": "サーバーからのメッセージ",
|
||||
"next_episode": "次のエピソード",
|
||||
"refresh_tracks": "トラックを更新",
|
||||
"subtitle_tracks": "字幕トラック:",
|
||||
"audio_tracks": "音声トラック:",
|
||||
"playback_state": "再生状態:",
|
||||
"no_data_available": "データなし",
|
||||
"index": "インデックス:"
|
||||
"index": "インデックス:",
|
||||
"continue_watching": "視聴を続ける",
|
||||
"go_back": "戻る",
|
||||
"downloaded_file_title": "You have this file downloaded",
|
||||
"downloaded_file_message": "Do you want to play the downloaded file?",
|
||||
"downloaded_file_yes": "Yes",
|
||||
"downloaded_file_no": "No",
|
||||
"downloaded_file_cancel": "Cancel"
|
||||
},
|
||||
"item_card": {
|
||||
"next_up": "次",
|
||||
@@ -367,6 +624,7 @@
|
||||
"no_similar_items_found": "類似のアイテムは見つかりませんでした",
|
||||
"video": "映像",
|
||||
"more_details": "さらに詳細を表示",
|
||||
"media_options": "Media Options",
|
||||
"quality": "画質",
|
||||
"audio": "音声",
|
||||
"subtitles": "字幕",
|
||||
@@ -381,15 +639,13 @@
|
||||
"download_episode": "エピソードをダウンロード",
|
||||
"download_movie": "映画をダウンロード",
|
||||
"download_x_item": "{{item_count}}のアイテムをダウンロード",
|
||||
"download_button": "ダウンロード",
|
||||
"using_optimized_server": "Optimizeサーバーを使用する",
|
||||
"using_default_method": "デフォルトの方法を使用"
|
||||
"download_unwatched_only": "未視聴のみ",
|
||||
"download_button": "ダウンロード"
|
||||
}
|
||||
},
|
||||
"live_tv": {
|
||||
"next": "次",
|
||||
"previous": "前",
|
||||
"live_tv": "ライブTV",
|
||||
"coming_soon": "近日公開",
|
||||
"on_now": "現在",
|
||||
"shows": "表示",
|
||||
@@ -432,11 +688,15 @@
|
||||
"tags": "タグ",
|
||||
"quality_profile": "画質プロファイル",
|
||||
"root_folder": "ルートフォルダ",
|
||||
"season_x": "シーズン{{seasons}}",
|
||||
"season_all": "Season (all)",
|
||||
"season_number": "シーズン{{season_number}}",
|
||||
"number_episodes": "エピソード{{episode_number}}",
|
||||
"born": "生まれ",
|
||||
"appearances": "出演",
|
||||
"approve": "Approve",
|
||||
"decline": "Decline",
|
||||
"requested_by": "Requested by {{user}}",
|
||||
"unknown_user": "Unknown User",
|
||||
"toasts": {
|
||||
"jellyseer_does_not_meet_requirements": "Jellyseerrサーバーは最小バージョン要件を満たしていません。少なくとも 2.0.0 に更新してください。",
|
||||
"jellyseerr_test_failed": "Jellyseerrテストに失敗しました。もう一度お試しください。",
|
||||
@@ -444,7 +704,11 @@
|
||||
"issue_submitted": "チケットを送信しました!",
|
||||
"requested_item": "{{item}}をリクエスト!",
|
||||
"you_dont_have_permission_to_request": "リクエストする権限がありません!",
|
||||
"something_went_wrong_requesting_media": "メディアのリクエスト中に問題が発生しました。"
|
||||
"something_went_wrong_requesting_media": "メディアのリクエスト中に問題が発生しました。",
|
||||
"request_approved": "Request Approved!",
|
||||
"request_declined": "Request Declined!",
|
||||
"failed_to_approve_request": "Failed to Approve Request",
|
||||
"failed_to_decline_request": "Failed to Decline Request"
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
@@ -453,5 +717,129 @@
|
||||
"library": "ライブラリ",
|
||||
"custom_links": "カスタムリンク",
|
||||
"favorites": "お気に入り"
|
||||
},
|
||||
"music": {
|
||||
"title": "Music",
|
||||
"tabs": {
|
||||
"suggestions": "Suggestions",
|
||||
"albums": "Albums",
|
||||
"artists": "Artists",
|
||||
"playlists": "Playlists",
|
||||
"tracks": "tracks"
|
||||
},
|
||||
"filters": {
|
||||
"all": "All"
|
||||
},
|
||||
"recently_added": "Recently Added",
|
||||
"recently_played": "Recently Played",
|
||||
"frequently_played": "Frequently Played",
|
||||
"explore": "Explore",
|
||||
"top_tracks": "Top Tracks",
|
||||
"play": "Play",
|
||||
"shuffle": "Shuffle",
|
||||
"play_top_tracks": "Play Top Tracks",
|
||||
"no_suggestions": "No suggestions available",
|
||||
"no_albums": "No albums found",
|
||||
"no_artists": "No artists found",
|
||||
"no_playlists": "No playlists found",
|
||||
"album_not_found": "Album not found",
|
||||
"artist_not_found": "Artist not found",
|
||||
"playlist_not_found": "Playlist not found",
|
||||
"track_options": {
|
||||
"play_next": "Play Next",
|
||||
"add_to_queue": "Add to Queue",
|
||||
"add_to_playlist": "Add to Playlist",
|
||||
"download": "Download",
|
||||
"downloaded": "Downloaded",
|
||||
"downloading": "Downloading...",
|
||||
"cached": "Cached",
|
||||
"delete_download": "Delete Download",
|
||||
"delete_cache": "Remove from Cache",
|
||||
"go_to_artist": "Go to Artist",
|
||||
"go_to_album": "Go to Album",
|
||||
"add_to_favorites": "Add to Favorites",
|
||||
"remove_from_favorites": "Remove from Favorites",
|
||||
"remove_from_playlist": "Remove from Playlist"
|
||||
},
|
||||
"playlists": {
|
||||
"create_playlist": "Create Playlist",
|
||||
"playlist_name": "Playlist Name",
|
||||
"enter_name": "Enter playlist name",
|
||||
"create": "Create",
|
||||
"search_playlists": "Search playlists...",
|
||||
"added_to": "Added to {{name}}",
|
||||
"added": "Added to playlist",
|
||||
"removed_from": "Removed from {{name}}",
|
||||
"removed": "Removed from playlist",
|
||||
"created": "Playlist created",
|
||||
"create_new": "Create New Playlist",
|
||||
"failed_to_add": "Failed to add to playlist",
|
||||
"failed_to_remove": "Failed to remove from playlist",
|
||||
"failed_to_create": "Failed to create playlist",
|
||||
"delete_playlist": "Delete Playlist",
|
||||
"delete_confirm": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
|
||||
"deleted": "Playlist deleted",
|
||||
"failed_to_delete": "Failed to delete playlist"
|
||||
},
|
||||
"sort": {
|
||||
"title": "Sort By",
|
||||
"alphabetical": "Alphabetical",
|
||||
"date_created": "Date Created"
|
||||
}
|
||||
},
|
||||
"watchlists": {
|
||||
"title": "Watchlists",
|
||||
"my_watchlists": "My Watchlists",
|
||||
"public_watchlists": "Public Watchlists",
|
||||
"create_title": "Create Watchlist",
|
||||
"edit_title": "Edit Watchlist",
|
||||
"create_button": "Create Watchlist",
|
||||
"save_button": "Save Changes",
|
||||
"delete_button": "Delete",
|
||||
"remove_button": "Remove",
|
||||
"cancel_button": "Cancel",
|
||||
"name_label": "Name",
|
||||
"name_placeholder": "Enter watchlist name",
|
||||
"description_label": "Description",
|
||||
"description_placeholder": "Enter description (optional)",
|
||||
"is_public_label": "Public Watchlist",
|
||||
"is_public_description": "Allow others to view this watchlist",
|
||||
"allowed_type_label": "Content Type",
|
||||
"sort_order_label": "Default Sort Order",
|
||||
"empty_title": "No Watchlists",
|
||||
"empty_description": "Create your first watchlist to start organizing your media",
|
||||
"empty_watchlist": "This watchlist is empty",
|
||||
"empty_watchlist_hint": "Add items from your library to this watchlist",
|
||||
"not_configured_title": "Streamystats Not Configured",
|
||||
"not_configured_description": "Configure Streamystats in settings to use watchlists",
|
||||
"go_to_settings": "Go to Settings",
|
||||
"add_to_watchlist": "Add to Watchlist",
|
||||
"remove_from_watchlist": "Remove from Watchlist",
|
||||
"select_watchlist": "Select Watchlist",
|
||||
"create_new": "Create New Watchlist",
|
||||
"item": "item",
|
||||
"items": "items",
|
||||
"public": "Public",
|
||||
"private": "Private",
|
||||
"you": "You",
|
||||
"by_owner": "By another user",
|
||||
"not_found": "Watchlist not found",
|
||||
"delete_confirm_title": "Delete Watchlist",
|
||||
"delete_confirm_message": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
|
||||
"remove_item_title": "Remove from Watchlist",
|
||||
"remove_item_message": "Remove \"{{name}}\" from this watchlist?",
|
||||
"loading": "Loading watchlists...",
|
||||
"no_compatible_watchlists": "No compatible watchlists",
|
||||
"create_one_first": "Create a watchlist that accepts this content type"
|
||||
},
|
||||
"playback_speed": {
|
||||
"title": "Playback Speed",
|
||||
"apply_to": "Apply To",
|
||||
"speed": "Speed",
|
||||
"scope": {
|
||||
"media": "This media only",
|
||||
"show": "This show",
|
||||
"all": "All media (default)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,18 +9,20 @@
|
||||
"login_button": "Aanmelden",
|
||||
"quick_connect": "Snel Verbinden",
|
||||
"enter_code_to_login": "Vul code {{code}} in om aan te melden",
|
||||
"failed_to_initiate_quick_connect": "Gefaald om Snel Verbinden op te starten",
|
||||
"failed_to_initiate_quick_connect": "Mislukt om Snel Verbinden op te starten",
|
||||
"got_it": "Begrepen",
|
||||
"connection_failed": "Verbinding gefaald",
|
||||
"connection_failed": "Verbinding mislukt",
|
||||
"could_not_connect_to_server": "Kon niet verbinden met de server. Controleer de URL en je netwerkverbinding.",
|
||||
"an_unexpected_error_occured": "Er is een onverwachte fout opgetreden",
|
||||
"change_server": "Verander server",
|
||||
"invalid_username_or_password": "Ongeldige gebruikersnaam of wachtwoord",
|
||||
"change_server": "Server wijzigen",
|
||||
"invalid_username_or_password": "Onjuiste gebruikersnaam of wachtwoord",
|
||||
"user_does_not_have_permission_to_log_in": "Gebruiker heeft geen rechten om aan te melden",
|
||||
"server_is_taking_too_long_to_respond_try_again_later": "De server doet er te lang over om te antwoorden, probeer later opnieuw",
|
||||
"server_received_too_many_requests_try_again_later": "De server heeft te veel aanvragen ontvangen, probeer later opnieuw",
|
||||
"there_is_a_server_error": "Er is een serverfout",
|
||||
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Er is een onverwachte fout opgetreden. Heb je de server URL correct ingegeven?"
|
||||
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Er is een onverwachte fout opgetreden. Heb je de server URL correct ingegeven?",
|
||||
"too_old_server_text": "Niet-ondersteunde Jellyfin Server Ontdekt",
|
||||
"too_old_server_description": "Werk Jellyfin bij naar de laatste versie"
|
||||
},
|
||||
"server": {
|
||||
"enter_url_to_jellyfin_server": "Geef de URL van je Jellyfin server in",
|
||||
@@ -28,21 +30,66 @@
|
||||
"connect_button": "Verbinden",
|
||||
"previous_servers": "vorige servers",
|
||||
"clear_button": "Wissen",
|
||||
"swipe_to_remove": "Swipe to remove",
|
||||
"search_for_local_servers": "Zoek naar lokale servers",
|
||||
"searching": "Zoeken...",
|
||||
"servers": "Servers"
|
||||
"servers": "Servers",
|
||||
"saved": "Opgeslagen",
|
||||
"session_expired": "Sessie verlopen",
|
||||
"please_login_again": "Uw gebruikerssessie is verlopen. U moet opnieuw inloggen.",
|
||||
"remove_saved_login": "Opgeslagen login verwijderen",
|
||||
"remove_saved_login_description": "Hiermee worden uw opgeslagen gegevens voor deze server verwijderd. U moet uw gebruikersnaam en wachtwoord de volgende keer opnieuw invoeren.",
|
||||
"accounts_count": "{{count}} accounts",
|
||||
"select_account": "Select Account",
|
||||
"add_account": "Add Account",
|
||||
"remove_account_description": "This will remove the saved credentials for {{username}}."
|
||||
},
|
||||
"save_account": {
|
||||
"title": "Save Account",
|
||||
"save_for_later": "Save this account",
|
||||
"security_option": "Security Option",
|
||||
"no_protection": "No protection",
|
||||
"no_protection_desc": "Quick login without authentication",
|
||||
"pin_code": "PIN code",
|
||||
"pin_code_desc": "4-digit PIN required when switching",
|
||||
"password": "Re-enter password",
|
||||
"password_desc": "Password required when switching",
|
||||
"save_button": "Save",
|
||||
"cancel_button": "Cancel"
|
||||
},
|
||||
"pin": {
|
||||
"enter_pin": "Enter PIN",
|
||||
"enter_pin_for": "Enter PIN for {{username}}",
|
||||
"enter_4_digits": "Enter 4 digits",
|
||||
"invalid_pin": "Invalid PIN",
|
||||
"setup_pin": "Set Up PIN",
|
||||
"confirm_pin": "Confirm PIN",
|
||||
"pins_dont_match": "PINs don't match",
|
||||
"forgot_pin": "Forgot PIN?",
|
||||
"forgot_pin_desc": "Your saved credentials will be removed"
|
||||
},
|
||||
"password": {
|
||||
"enter_password": "Enter Password",
|
||||
"enter_password_for": "Enter password for {{username}}",
|
||||
"invalid_password": "Invalid password"
|
||||
},
|
||||
"home": {
|
||||
"checking_server_connection": "Serververbinding controleren...",
|
||||
"no_internet": "Geen Internet",
|
||||
"no_items": "Geen items",
|
||||
"no_internet_message": "Geen zorgen, je kan nog steeds\ngedownloade content bekijken",
|
||||
"checking_server_connection_message": "Verbinding met server controleren",
|
||||
"go_to_downloads": "Ga naar downloads",
|
||||
"retry": "Opnieuw",
|
||||
"server_unreachable": "Server onbereikbaar",
|
||||
"server_unreachable_message": "Kon de server niet bereiken.\nControleer uw netwerkverbinding.",
|
||||
"oops": "Oeps!",
|
||||
"error_message": "Er ging iets fout\nGelieve af en aan te melden.",
|
||||
"continue_watching": "Verder Kijken",
|
||||
"next_up": "Volgende",
|
||||
"continue_and_next_up": "Doorgaan & Volgende",
|
||||
"recently_added_in": "Recent toegevoegd in {{libraryName}}",
|
||||
"suggested_movies": "Voorgestelde Films",
|
||||
"suggested_movies": "Voorgestelde films",
|
||||
"suggested_episodes": "Voorgestelde Afleveringen",
|
||||
"intro": {
|
||||
"welcome_to_streamyfin": "Welkom bij Streamyfin",
|
||||
@@ -56,12 +103,54 @@
|
||||
"centralised_settings_plugin_title": "Plugin voor gecentraliseerde instellingen",
|
||||
"centralised_settings_plugin_description": "Configureer instellingen vanaf een centrale locatie op je Jellyfin server. Alle clientinstellingen voor alle gebruikers worden automatisch gesynchroniseerd.",
|
||||
"done_button": "Gedaan",
|
||||
"go_to_settings_button": "Go naar instellingen",
|
||||
"go_to_settings_button": "Ga naar instellingen",
|
||||
"read_more": "Lees meer"
|
||||
},
|
||||
"settings": {
|
||||
"settings_title": "Instellingen",
|
||||
"log_out_button": "Afmelden",
|
||||
"categories": {
|
||||
"title": "Categorieën"
|
||||
},
|
||||
"playback_controls": {
|
||||
"title": "Afspelen & Bediening"
|
||||
},
|
||||
"audio_subtitles": {
|
||||
"title": "Geluid & Ondertiteling"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Weergave",
|
||||
"merge_next_up_continue_watching": "Doorgaan met kijken & Volgende samenvoegen",
|
||||
"hide_remote_session_button": "Verberg Knop voor Externe Sessie"
|
||||
},
|
||||
"network": {
|
||||
"title": "Network",
|
||||
"local_network": "Local Network",
|
||||
"auto_switch_enabled": "Auto-switch when at home",
|
||||
"auto_switch_description": "Automatically switch to local URL when connected to home WiFi",
|
||||
"local_url": "Local URL",
|
||||
"local_url_hint": "Enter your local server address (e.g., http://192.168.1.100:8096)",
|
||||
"local_url_placeholder": "http://192.168.1.100:8096",
|
||||
"home_wifi_networks": "Home WiFi Networks",
|
||||
"add_current_network": "Add \"{{ssid}}\"",
|
||||
"not_connected_to_wifi": "Not connected to WiFi",
|
||||
"no_networks_configured": "No networks configured",
|
||||
"add_network_hint": "Add your home WiFi network to enable auto-switching",
|
||||
"current_wifi": "Current WiFi",
|
||||
"using_url": "Using",
|
||||
"local": "Local URL",
|
||||
"remote": "Remote URL",
|
||||
"not_connected": "Not connected",
|
||||
"current_server": "Current Server",
|
||||
"remote_url": "Remote URL",
|
||||
"active_url": "Active URL",
|
||||
"not_configured": "Not configured",
|
||||
"network_added": "Network added",
|
||||
"network_already_added": "Network already added",
|
||||
"no_wifi_connected": "Not connected to WiFi",
|
||||
"permission_denied": "Location permission denied",
|
||||
"permission_denied_explanation": "Location permission is required to detect WiFi network for auto-switching. Please enable it in Settings."
|
||||
},
|
||||
"user_info": {
|
||||
"user_info_title": "Gebruiker Info",
|
||||
"user": "Gebruiker",
|
||||
@@ -82,24 +171,45 @@
|
||||
"media_controls": {
|
||||
"media_controls_title": "Media Bedieningen",
|
||||
"forward_skip_length": "Duur voorwaarts overslaan",
|
||||
"rewind_length": "Duur terugspeolen",
|
||||
"rewind_length": "Duur terugspoelen",
|
||||
"seconds_unit": "s"
|
||||
},
|
||||
"gesture_controls": {
|
||||
"gesture_controls_title": "Gebaar Bediening",
|
||||
"horizontal_swipe_skip": "Horizontale Swipe om over te slaan",
|
||||
"horizontal_swipe_skip_description": "Veeg naar links/rechts wanneer de knoppen verborgen zijn om over te slaan",
|
||||
"left_side_brightness": "Linker Zijkant Helderheidscontrole",
|
||||
"left_side_brightness_description": "Veeg omhoog/omlaag aan linker kant om helderheid aan te passen",
|
||||
"right_side_volume": "Rechterkant Volume Controle",
|
||||
"right_side_volume_description": "Veeg aan rechterzijde omhoog/omlaag om volume aan te passen",
|
||||
"hide_volume_slider": "Verberg Volumeschuifbalk",
|
||||
"hide_volume_slider_description": "Volumeschuifbalk in de videospeler verbergen",
|
||||
"hide_brightness_slider": "Verberg Helderheidsschuifbalk",
|
||||
"hide_brightness_slider_description": "Helderheidsschuifbalk in de videospeler verbergen"
|
||||
},
|
||||
"audio": {
|
||||
"audio_title": "Audio",
|
||||
"audio_title": "Geluid",
|
||||
"set_audio_track": "Gebruik Audio Track Van Vorig Item",
|
||||
"audio_language": "Audio taal",
|
||||
"audio_hint": "Kies een standaard audio taal.",
|
||||
"none": "Geen",
|
||||
"language": "Taal"
|
||||
"language": "Taal",
|
||||
"transcode_mode": {
|
||||
"title": "Audio Transcoding",
|
||||
"description": "Controls how surround audio (7.1, TrueHD, DTS-HD) is handled",
|
||||
"auto": "Auto",
|
||||
"stereo": "Force Stereo",
|
||||
"5_1": "Allow 5.1",
|
||||
"passthrough": "Passthrough"
|
||||
}
|
||||
},
|
||||
"subtitles": {
|
||||
"subtitle_title": "Ondertitels",
|
||||
"subtitle_hint": "Stel ondertitel voorkeuren in.",
|
||||
"subtitle_language": "Ondertitel taal",
|
||||
"subtitle_mode": "Ondertitle Modus",
|
||||
"subtitle_mode": "Ondertitelmodus",
|
||||
"set_subtitle_track": "Gebruik Ondertitel Track Van Vorig Item",
|
||||
"subtitle_size": "Ondertitel Grootte",
|
||||
"subtitle_hint": "Stel ondertitel voorkeuren in.",
|
||||
"none": "Geen",
|
||||
"language": "Taal",
|
||||
"loading": "Laden",
|
||||
@@ -108,12 +218,67 @@
|
||||
"Smart": "Slim",
|
||||
"Always": "Altijd",
|
||||
"None": "Geen",
|
||||
"OnlyForced": "Alleen Geforceeerd"
|
||||
}
|
||||
"OnlyForced": "Alleen Geforceerd"
|
||||
},
|
||||
"text_color": "Tekst kleur",
|
||||
"background_color": "Achtergrond Kleur",
|
||||
"outline_color": "Kleur omlijning",
|
||||
"outline_thickness": "Dikte omlijning",
|
||||
"background_opacity": "Transparantie achtergrond",
|
||||
"outline_opacity": "Doorzichtigheid omlijning",
|
||||
"bold_text": "Bold Text",
|
||||
"colors": {
|
||||
"Black": "Zwart",
|
||||
"Gray": "Grijs",
|
||||
"Silver": "Zilver",
|
||||
"White": "wit",
|
||||
"Maroon": "Kastanjebruin",
|
||||
"Red": "Rood",
|
||||
"Fuchsia": "Fuchsia",
|
||||
"Yellow": "Geel",
|
||||
"Olive": "Olijf",
|
||||
"Green": "Groen",
|
||||
"Teal": "Groenblauw",
|
||||
"Lime": "Lichtgroen",
|
||||
"Purple": "Paars",
|
||||
"Navy": "Marine",
|
||||
"Blue": "Blauw",
|
||||
"Aqua": "Aqua"
|
||||
},
|
||||
"thickness": {
|
||||
"None": "Geen",
|
||||
"Thin": "Dun",
|
||||
"Normal": "normaal",
|
||||
"Thick": "Dikke"
|
||||
},
|
||||
"subtitle_color": "Kleur ondertiteling",
|
||||
"subtitle_background_color": "Achtergrondkleur",
|
||||
"subtitle_font": "Lettertype ondertitels",
|
||||
"ksplayer_title": "KSPlayer Instellingen",
|
||||
"hardware_decode": "Hardware Acceleratie",
|
||||
"hardware_decode_description": "Gebruik hardware acceleratie voor video-decodering. Uitschakelen als u problemen met afspelen ondervindt."
|
||||
},
|
||||
"vlc_subtitles": {
|
||||
"title": "VLC Subtitle Settings",
|
||||
"hint": "Customize subtitle appearance for VLC player. Changes take effect on next playback.",
|
||||
"text_color": "Text Color",
|
||||
"background_color": "Background Color",
|
||||
"background_opacity": "Background Opacity",
|
||||
"outline_color": "Outline Color",
|
||||
"outline_opacity": "Outline Opacity",
|
||||
"outline_thickness": "Outline Thickness",
|
||||
"bold": "Bold Text",
|
||||
"margin": "Bottom Margin"
|
||||
},
|
||||
"video_player": {
|
||||
"title": "Videospeler",
|
||||
"video_player": "Videospeler",
|
||||
"video_player_description": "Kies welke videospeler gebruikt moet worden op iOS.",
|
||||
"ksplayer": "KSPlayer",
|
||||
"vlc": "VLC"
|
||||
},
|
||||
"other": {
|
||||
"other_title": "Andere",
|
||||
"auto_rotate": "Automatisch draaien",
|
||||
"video_orientation": "Video oriëntatie",
|
||||
"orientation": "Oriëntatie",
|
||||
"orientations": {
|
||||
@@ -129,57 +294,102 @@
|
||||
"UNKNOWN": "Onbekend"
|
||||
},
|
||||
"safe_area_in_controls": "Veilig gebied in bedieningen",
|
||||
"video_player": "Video player",
|
||||
"video_players": {
|
||||
"VLC_3": "VLC 3",
|
||||
"VLC_4": "VLC 4 (Experimentele + PiP)"
|
||||
},
|
||||
"show_custom_menu_links": "Aangepaste menulinks tonen",
|
||||
"show_large_home_carousel": "Toon grote carrousel op startpagina (bèta)",
|
||||
"hide_libraries": "Verberg Bibliotheken",
|
||||
"select_liraries_you_want_to_hide": "Selecteer de bibliotheken die je wil verbergen van de Bibliotheek tab en hoofdpagina onderdelen.",
|
||||
"select_liraries_you_want_to_hide": "Selecteer de bibliotheken die je wil verbergen van de Bibliotheektab en hoofdpagina onderdelen.",
|
||||
"disable_haptic_feedback": "Haptische feedback uitschakelen",
|
||||
"default_quality": "Standaard kwaliteit"
|
||||
"default_quality": "Standaard kwaliteit",
|
||||
"default_playback_speed": "Standaard Afspeelsnelheid",
|
||||
"auto_play_next_episode": "Auto-play Next Episode",
|
||||
"max_auto_play_episode_count": "Max Automatisch Aflevering Aantal",
|
||||
"disabled": "Uitgeschakeld"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Downloads",
|
||||
"download_method": "Download methode",
|
||||
"remux_max_download": "Remux max download",
|
||||
"auto_download": "Auto download",
|
||||
"optimized_versions_server": "Geoptimaliseerde server versies",
|
||||
"save_button": "Opslaan",
|
||||
"optimized_server": "Geoptimailseerde Server",
|
||||
"optimized": "Geoptimaliseerd",
|
||||
"default": "Standaard",
|
||||
"optimized_version_hint": "Vul de URL van de optimalisatieserver in. De URL moet http of https bevatten en eventueel de poort.",
|
||||
"read_more_about_optimized_server": "Lees meer over de optimalisatieserver.",
|
||||
"url":"URL",
|
||||
"server_url_placeholder": "http(s)://domein.org:poort"
|
||||
"downloads_title": "Downloads"
|
||||
},
|
||||
"music": {
|
||||
"title": "Muziek",
|
||||
"playback_title": "Afspelen",
|
||||
"playback_description": "Configureer hoe muziek wordt afgespeeld.",
|
||||
"prefer_downloaded": "Voorkeur voor Gedownloade Nummers",
|
||||
"caching_title": "Cachen",
|
||||
"caching_description": "Automatisch aankomende nummers cachen om soepeler af te spelen.",
|
||||
"lookahead_enabled": "Look-Ahead Caching inschakelen",
|
||||
"lookahead_count": "Nummers om te Pre-cachen",
|
||||
"max_cache_size": "Max Cache Grootte"
|
||||
},
|
||||
"plugins": {
|
||||
"plugins_title": "Plugins",
|
||||
"plugins_title": "Uitbreidingen",
|
||||
"jellyseerr": {
|
||||
"jellyseerr_warning": "Deze integratie is nog in een vroeg stadium. Verwacht dat zaken nog veranderen.",
|
||||
"server_url": "Server URL",
|
||||
"server_url": "Server-URL",
|
||||
"server_url_hint": "Voorbeeld: http(s)://je-host.url\n(indien nodig: voeg de poort toe)",
|
||||
"server_url_placeholder": "Jellyseerr URL...",
|
||||
"password": "Wachtwoord",
|
||||
"password_placeholder": "Voeg het wachtwoord in voor de Jellyfin gebruiker {{username}}",
|
||||
"save_button": "Opslaan",
|
||||
"clear_button": "Wissen",
|
||||
"login_button": "Aannmelden",
|
||||
"login_button": "Aanmelden",
|
||||
"total_media_requests": "Totaal aantal mediaverzoeken",
|
||||
"movie_quota_limit": "Limiet filmquota",
|
||||
"movie_quota_days": "Filmquota dagen",
|
||||
"tv_quota_limit": "Limiet serie quota",
|
||||
"tv_quota_days": "Serie Quota dagen",
|
||||
"reset_jellyseerr_config_button": "Jellyseerr opnieuw instellen",
|
||||
"unlimited": "Ongelimiteerd"
|
||||
"unlimited": "Ongelimiteerd",
|
||||
"plus_n_more": "+{{n}} more",
|
||||
"order_by": {
|
||||
"DEFAULT": "Standaard",
|
||||
"VOTE_COUNT_AND_AVERAGE": "Aantal stemmen en gemiddeld",
|
||||
"POPULARITY": "Populariteit"
|
||||
}
|
||||
},
|
||||
"marlin_search": {
|
||||
"enable_marlin_search": "Marlin Search inschakeln ",
|
||||
"enable_marlin_search": "Marlin Search inschakelen ",
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://domein.org:poort",
|
||||
"marlin_search_hint": "Vul de URL van de Marlin Search server in. De URL moet http of https bevatten en eventueel de poort.",
|
||||
"read_more_about_marlin": "Lees meer over Marlin.",
|
||||
"save_button": "Opslaan",
|
||||
"toasts": {
|
||||
"saved": "Opgeslagen"
|
||||
}
|
||||
"saved": "Opgeslagen",
|
||||
"refreshed": "Instellingen zijn vernieuwd vanaf server"
|
||||
},
|
||||
"refresh_from_server": "Ververs Instellingen van Server"
|
||||
},
|
||||
"streamystats": {
|
||||
"enable_streamystats": "Streamystats inschakelen",
|
||||
"disable_streamystats": "Streamystats Uitschakelen",
|
||||
"enable_search": "Gebruik voor Zoeken",
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
||||
"streamystats_search_hint": "Vul de URL van de Streamystats server in. De URL moet http of https bevatten en optioneel de poort.",
|
||||
"read_more_about_streamystats": "Lees Meer over Streamystats.",
|
||||
"save_button": "Opslaan",
|
||||
"save": "Opslaan",
|
||||
"features_title": "Functies",
|
||||
"home_sections_title": "Thuis Secties",
|
||||
"enable_movie_recommendations": "Film Aanbevelingen",
|
||||
"enable_series_recommendations": "Series Aanbevelingen",
|
||||
"enable_promoted_watchlists": "Gepromote Kijklijst",
|
||||
"hide_watchlists_tab": "Hide Watchlists Tab",
|
||||
"home_sections_hint": "Show personalized recommendations and promoted watchlists from Streamystats on the home page.",
|
||||
"recommended_movies": "Recommended Movies",
|
||||
"recommended_series": "Recommended Series",
|
||||
"toasts": {
|
||||
"saved": "Saved",
|
||||
"refreshed": "Settings refreshed from server",
|
||||
"disabled": "Streamystats disabled"
|
||||
},
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"kefinTweaks": {
|
||||
"watchlist_enabler": "Enable our Watchlist integration",
|
||||
"watchlist_button": "Toggle Watchlist integration"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
@@ -187,43 +397,58 @@
|
||||
"app_usage": "App {{usedSpace}}%",
|
||||
"device_usage": "Toestel {{availableSpace}}%",
|
||||
"size_used": "{{used}} van {{total}} gebruikt",
|
||||
"delete_all_downloaded_files": "Verwijder alle gedownloade bestanden"
|
||||
"delete_all_downloaded_files": "Verwijder alle gedownloade bestanden",
|
||||
"music_cache_title": "Music Cache",
|
||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||
"enable_music_cache": "Enable Music Cache",
|
||||
"clear_music_cache": "Clear Music Cache",
|
||||
"music_cache_size": "{{size}} cached",
|
||||
"music_cache_cleared": "Music cache cleared",
|
||||
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
|
||||
"downloaded_songs_size": "{{size}} downloaded",
|
||||
"downloaded_songs_deleted": "Downloaded songs deleted"
|
||||
},
|
||||
"intro": {
|
||||
"title": "Intro",
|
||||
"show_intro": "Toon intro",
|
||||
"reset_intro": "intro opnieuw instellen"
|
||||
},
|
||||
"logs": {
|
||||
"logs_title": "Logs",
|
||||
"logs_title": "Logboek",
|
||||
"export_logs": "Export logs",
|
||||
"click_for_more_info": "Click for more info",
|
||||
"level": "Niveau",
|
||||
"no_logs_available": "Geen logs beschikbaar",
|
||||
"delete_all_logs": "Verwijder alle logs"
|
||||
},
|
||||
"languages": {
|
||||
"title": "Talen",
|
||||
"app_language": "App taal",
|
||||
"app_language_description": "Selecteer een taal voor de app.",
|
||||
"system": "Systeem"
|
||||
},
|
||||
"toasts":{
|
||||
"error_deleting_files": "Fout bij het verwijden van bestanden",
|
||||
"toasts": {
|
||||
"error_deleting_files": "Fout bij het verwijderen van bestanden",
|
||||
"background_downloads_enabled": "Downloads op de achtergrond ingeschakeld",
|
||||
"background_downloads_disabled": "Downloads op de achtergrond uitgeschakeld",
|
||||
"connected": "Verbonden",
|
||||
"could_not_connect": "Kon niet verbinden",
|
||||
"invalid_url": "Ongeldige URL"
|
||||
"background_downloads_disabled": "Downloads op de achtergrond uitgeschakeld"
|
||||
}
|
||||
},
|
||||
"sessions": {
|
||||
"title": "Sessies",
|
||||
"no_active_sessions": "Geen actieve sessies"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "Downloads",
|
||||
"tvseries": "Series",
|
||||
"movies": "Films",
|
||||
"queue": "Wachtrij",
|
||||
"other_media": "Andere media",
|
||||
"queue_hint": "Wachtrij en downloads verdwijnen bij een herstart van de app",
|
||||
"no_items_in_queue": "Geen items in wachtrij",
|
||||
"no_downloaded_items": "Geen gedownloade items",
|
||||
"delete_all_movies_button": "Verwijder alle films",
|
||||
"delete_all_tvseries_button": "Verwijder alle Series",
|
||||
"delete_all_button": "Verwijder alles",
|
||||
"delete_all_other_media_button": "Andere media verwijderen",
|
||||
"active_download": "Actieve download",
|
||||
"no_active_downloads": "Geen actieve downloads",
|
||||
"active_downloads": "Actieve downloads",
|
||||
@@ -234,35 +459,57 @@
|
||||
"something_went_wrong": "Er ging iets mis",
|
||||
"could_not_get_stream_url_from_jellyfin": "Kon de URL van de stream niet krijgen van Jellyfin",
|
||||
"eta": "ETA {{eta}}",
|
||||
"methods": "Methoden",
|
||||
"toasts": {
|
||||
"you_are_not_allowed_to_download_files": "Je mag geen bestanden downloaden.",
|
||||
"deleted_all_movies_successfully": "Alle filns succesvol verwijderd!",
|
||||
"deleted_all_movies_successfully": "Alle films succesvol verwijderd!",
|
||||
"failed_to_delete_all_movies": "Alle films zijn niet verwijderd",
|
||||
"deleted_all_tvseries_successfully": "Alle series succesvol verwijderd!",
|
||||
"failed_to_delete_all_tvseries": "Alle series zijn niet verwijderd",
|
||||
"deleted_media_successfully": "Andere media succesvol verwijderd!",
|
||||
"failed_to_delete_media": "Verwijderen van andere media mislukt",
|
||||
"download_deleted": "Download verwijderd",
|
||||
"download_cancelled": "Download geannuleerd",
|
||||
"could_not_cancel_download": "Kon de download niet annuleren",
|
||||
"could_not_delete_download": "Kon download niet verwijderen",
|
||||
"download_paused": "Download gepauzeerd",
|
||||
"could_not_pause_download": "Kan niet pauzeren download",
|
||||
"download_resumed": "Download hervat",
|
||||
"could_not_resume_download": "Kon de download niet hervatten",
|
||||
"download_completed": "Download afgerond",
|
||||
"download_started_for": "Download gestart voor {{item}}",
|
||||
"item_is_ready_to_be_downloaded": "{{item}} is klaar op te downloaden",
|
||||
"download_stated_for_item": "Download gestart voor {{item}}",
|
||||
"download_failed": "Download Mislukt",
|
||||
"download_failed_for_item": "Download gefaald voor {{item}} - {{error}}",
|
||||
"download_completed_for_item": "Download afgerond voor {{item}}",
|
||||
"queued_item_for_optimization": "{{item}} in de wachtrij gezet voor optimalisatie",
|
||||
"failed_to_start_download_for_item": "Kon de download voor {{item}} niet starten: {{message}}",
|
||||
"server_responded_with_status_code": "Server heeft geantwoord met {{statusCode}}",
|
||||
"no_response_received_from_server": "Geen antwoord gekregen van de server",
|
||||
"error_setting_up_the_request": "Fout bij het opstellen van de aanvraag",
|
||||
"failed_to_start_download_for_item_unexpected_error": "Kon de download voor {{item}} niet starten: Onverwachte fout",
|
||||
"download_started_for_item": "Download gestart voor {{item}}",
|
||||
"failed_to_start_download": "Kan download niet starten",
|
||||
"item_already_downloading": "{{item}} wordt al gedownload",
|
||||
"all_files_deleted": "Alle Bestanden Succesvol Gedownload",
|
||||
"files_deleted_by_type": "{{count}} {{type}} verwijderd",
|
||||
"all_files_folders_and_jobs_deleted_successfully": "Alle bestanden, mappen en taken succesvol verwijderd",
|
||||
"an_error_occured_while_deleting_files_and_jobs": "Er is een fout opgetreden tijdens het verwijderen van bestanden en taken",
|
||||
"go_to_downloads": "Ga naar downloads"
|
||||
"failed_to_clean_cache_directory": "Opschonen cachemap mislukt",
|
||||
"could_not_get_download_url_for_item": "Kan download-URL voor {{itemName}} niet ophalen",
|
||||
"go_to_downloads": "Ga naar downloads",
|
||||
"file_deleted": "{{item}} verwijderd"
|
||||
}
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"select": "Selecteren",
|
||||
"no_trailer_available": "Geen trailer beschikbaar",
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
"subtitle": "Ondertitel",
|
||||
"play": "Afspelen",
|
||||
"none": "Geen",
|
||||
"track": "Spoor",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"ok": "OK",
|
||||
"remove": "Remove",
|
||||
"next": "Next",
|
||||
"back": "Back",
|
||||
"continue": "Continue",
|
||||
"verifying": "Verifying..."
|
||||
},
|
||||
"search": {
|
||||
"search_here": "Zoek hier...",
|
||||
"search": "Zoek...",
|
||||
"x_items": "{{count}} items",
|
||||
"library": "Bibliotheek",
|
||||
@@ -274,24 +521,28 @@
|
||||
"episodes": "Afleveringen",
|
||||
"collections": "Collecties",
|
||||
"actors": "Acteurs",
|
||||
"artists": "Artists",
|
||||
"albums": "Albums",
|
||||
"songs": "Songs",
|
||||
"playlists": "Playlists",
|
||||
"request_movies": "Vraag films aan",
|
||||
"request_series": "Vraag series aan",
|
||||
"recently_added": "Recent Toegevoegd",
|
||||
"recent_requests": "Recent Aangevraagd",
|
||||
"plex_watchlist": "Plex Kijklijst",
|
||||
"trending": "Trending",
|
||||
"popular_movies": "Populaire Films",
|
||||
"trending": "Populair",
|
||||
"popular_movies": "Populaire films",
|
||||
"movie_genres": "Film Genres",
|
||||
"upcoming_movies": "Aankomende Movies",
|
||||
"studios": "Studios",
|
||||
"upcoming_movies": "Aankomende films",
|
||||
"studios": "Studio's",
|
||||
"popular_tv": "Populaire TV",
|
||||
"tv_genres": "TV Genres",
|
||||
"upcoming_tv": "Opkomend TV",
|
||||
"upcoming_tv": "Aankomende TV",
|
||||
"networks": "Netwerken",
|
||||
"tmdb_movie_keyword": "TMDB Film Trefwoord",
|
||||
"tmdb_movie_genre": "TMDB Film Genre",
|
||||
"tmdb_movie_genre": "TMDB Filmgenres",
|
||||
"tmdb_tv_keyword": "TMDB TV Trefwoord",
|
||||
"tmdb_tv_genre": "TMDB TV Genre",
|
||||
"tmdb_tv_genre": "TMDB TV-Genres",
|
||||
"tmdb_search": "TMDB Zoeken",
|
||||
"tmdb_studio": "TMDB Studio",
|
||||
"tmdb_network": "TMDB Netwerk",
|
||||
@@ -299,13 +550,12 @@
|
||||
"tmdb_tv_streaming_services": "TMDB TV Streaming Diensten"
|
||||
},
|
||||
"library": {
|
||||
"no_items_found": "Geen items gevonden",
|
||||
"no_results": "Geen resultaten",
|
||||
"no_libraries_found": "Geen bibliotheken gevonden",
|
||||
"item_types": {
|
||||
"movies": "films",
|
||||
"series": "series",
|
||||
"boxsets": "box sets",
|
||||
"movies": "Films",
|
||||
"series": "Series",
|
||||
"boxsets": "Boxsets",
|
||||
"items": "items"
|
||||
},
|
||||
"options": {
|
||||
@@ -314,7 +564,7 @@
|
||||
"list": "Lijst",
|
||||
"image_style": "Stijl van afbeelding",
|
||||
"poster": "Poster",
|
||||
"cover": "Cover",
|
||||
"cover": "Omslag",
|
||||
"show_titles": "Toon titels",
|
||||
"show_stats": "Toon statistieken"
|
||||
},
|
||||
@@ -322,6 +572,7 @@
|
||||
"genres": "Genres",
|
||||
"years": "Jaren",
|
||||
"sort_by": "Sorteren op",
|
||||
"filter_by": "Filter By",
|
||||
"sort_order": "Sorteer volgorde",
|
||||
"tags": "Labels"
|
||||
}
|
||||
@@ -332,7 +583,9 @@
|
||||
"episodes": "Afleveringen",
|
||||
"videos": "Videos",
|
||||
"boxsets": "Boxsets",
|
||||
"playlists": "Afspeellijsten"
|
||||
"playlists": "Afspeellijsten",
|
||||
"noDataTitle": "Nog geen favorieten",
|
||||
"noData": "Markeer items als favoriet om ze hier te laten verschijnen voor snelle toegang."
|
||||
},
|
||||
"custom_links": {
|
||||
"no_links": "Geen links"
|
||||
@@ -343,21 +596,24 @@
|
||||
"an_error_occured_while_playing_the_video": "Er is een fout opgetreden tijdens het afspelen van de video. Controleer de logs in de instellingen.",
|
||||
"client_error": "Fout van de client",
|
||||
"could_not_create_stream_for_chromecast": "Kon geen stream maken voor Chromecast",
|
||||
"message_from_server": "Bericht van de server: {{message}}",
|
||||
"video_has_finished_playing": "Video is gedaan met spelen!",
|
||||
"no_video_source": "Geen video bron...",
|
||||
"message_from_server": "Bericht van de server",
|
||||
"next_episode": "Volgende Aflevering",
|
||||
"refresh_tracks": "Tracks verversen",
|
||||
"subtitle_tracks": "Ondertitel Tracks:",
|
||||
"audio_tracks": "Audio Tracks:",
|
||||
"playback_state": "Afspeelstatus:",
|
||||
"no_data_available": "Geen data beschikbaar",
|
||||
"index": "Index:"
|
||||
"index": "Index:",
|
||||
"continue_watching": "Verder kijken",
|
||||
"go_back": "Terug",
|
||||
"downloaded_file_title": "Je hebt dit bestand gedownload",
|
||||
"downloaded_file_message": "Wil je het gedownloade bestand afspelen?",
|
||||
"downloaded_file_yes": "Ja",
|
||||
"downloaded_file_no": "Nee",
|
||||
"downloaded_file_cancel": "Annuleren"
|
||||
},
|
||||
"item_card": {
|
||||
"next_up": "Volgende",
|
||||
"no_items_to_display": "Geen items om te tonen",
|
||||
"cast_and_crew": "Cast & Crew",
|
||||
"cast_and_crew": "Cast & bemanning",
|
||||
"series": "Series",
|
||||
"seasons": "Seizoenen",
|
||||
"season": "Seizoen",
|
||||
@@ -368,11 +624,12 @@
|
||||
"no_similar_items_found": "Geen gelijkaardige items gevonden",
|
||||
"video": "Video",
|
||||
"more_details": "Meer details",
|
||||
"media_options": "Media opties",
|
||||
"quality": "Kwaliteit",
|
||||
"audio": "Audio",
|
||||
"subtitles": "Ondertitel",
|
||||
"show_more": "Toon meer",
|
||||
"show_less": "Toon minden",
|
||||
"show_less": "Toon minder",
|
||||
"appeared_in": "Verschenen in",
|
||||
"could_not_load_item": "Kon item niet laden",
|
||||
"none": "Geen",
|
||||
@@ -382,24 +639,22 @@
|
||||
"download_episode": "Download Aflevering",
|
||||
"download_movie": "Download Film",
|
||||
"download_x_item": "Download {{item_count}} items",
|
||||
"download_button": "Download",
|
||||
"using_optimized_server": "Geoptimaliseerde server gebruiken",
|
||||
"using_default_method": "Standaard methode gebruiken"
|
||||
"download_unwatched_only": "Alleen niet bekeken",
|
||||
"download_button": "Downloaden"
|
||||
}
|
||||
},
|
||||
"live_tv": {
|
||||
"next": "Volgende ",
|
||||
"previous": "Vorige",
|
||||
"live_tv": "Live TV",
|
||||
"coming_soon": "Binnenkort beschikbaar",
|
||||
"on_now": "Nu op",
|
||||
"shows": "Shows",
|
||||
"shows": "Series",
|
||||
"movies": "Films",
|
||||
"sports": "Sport",
|
||||
"for_kids": "Voor kinderen",
|
||||
"news": "Nieuws"
|
||||
},
|
||||
"jellyseerr":{
|
||||
"jellyseerr": {
|
||||
"confirm": "Bevestig",
|
||||
"cancel": "Annuleer",
|
||||
"yes": "Ja",
|
||||
@@ -417,7 +672,7 @@
|
||||
"details": "Details",
|
||||
"status": "Status",
|
||||
"original_title": "Originele titel",
|
||||
"series_type": "Serie Type",
|
||||
"series_type": "Serietype",
|
||||
"release_dates": "Verschijningsdatums",
|
||||
"first_air_date": "Eerste uitzenddatum",
|
||||
"next_air_date": "Volgende uitzenddatum",
|
||||
@@ -433,19 +688,27 @@
|
||||
"tags": "Labels",
|
||||
"quality_profile": "Kwaliteitsprofiel",
|
||||
"root_folder": "Hoofdmap",
|
||||
"season_x": "Seizoen {{seasons}}",
|
||||
"season_all": "Season (all)",
|
||||
"season_number": "Seizoen {{season_number}}",
|
||||
"number_episodes": "{{episode_number}} Afleveringen",
|
||||
"born": "Geboren",
|
||||
"appearances": "Verschijningen",
|
||||
"approve": "Goedkeuren",
|
||||
"decline": "Weigeren",
|
||||
"requested_by": "Aangevraagd door {{user}}",
|
||||
"unknown_user": "Onbekende gebruiker",
|
||||
"toasts": {
|
||||
"jellyseer_does_not_meet_requirements": "Jellyseerr server voldoet niet aan de minimale versievereisten! Update naar minimaal 2.0.0",
|
||||
"jellyseerr_test_failed": "Jellyseerr test gefaald. Probeer opnieuw.",
|
||||
"jellyseerr_test_failed": "Jellyseerr test mislukt. Probeer opnieuw.",
|
||||
"failed_to_test_jellyseerr_server_url": "Mislukt bij het testen van jellyseerr server url",
|
||||
"issue_submitted": "Probleem ingediend!",
|
||||
"requested_item": "{{item}} aangevraagd!",
|
||||
"you_dont_have_permission_to_request": "Je hebt geen toestemming om aanvragen te doen!",
|
||||
"something_went_wrong_requesting_media": "Er ging iets iets mis met het aavragen van media!"
|
||||
"something_went_wrong_requesting_media": "Er ging iets mis met het aanvragen van media!",
|
||||
"request_approved": "Verzoek goedgekeurd!",
|
||||
"request_declined": "Verzoek geweigerd!",
|
||||
"failed_to_approve_request": "Aanvraag goedkeuren mislukt",
|
||||
"failed_to_decline_request": "Aanvraag weigeren mislukt"
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
@@ -454,5 +717,129 @@
|
||||
"library": "Bibliotheek",
|
||||
"custom_links": "Aangepaste links",
|
||||
"favorites": "Favorieten"
|
||||
},
|
||||
"music": {
|
||||
"title": "Music",
|
||||
"tabs": {
|
||||
"suggestions": "Suggestions",
|
||||
"albums": "Albums",
|
||||
"artists": "Artists",
|
||||
"playlists": "Playlists",
|
||||
"tracks": "tracks"
|
||||
},
|
||||
"filters": {
|
||||
"all": "All"
|
||||
},
|
||||
"recently_added": "Recently Added",
|
||||
"recently_played": "Recently Played",
|
||||
"frequently_played": "Frequently Played",
|
||||
"explore": "Explore",
|
||||
"top_tracks": "Top Tracks",
|
||||
"play": "Play",
|
||||
"shuffle": "Shuffle",
|
||||
"play_top_tracks": "Play Top Tracks",
|
||||
"no_suggestions": "No suggestions available",
|
||||
"no_albums": "No albums found",
|
||||
"no_artists": "No artists found",
|
||||
"no_playlists": "No playlists found",
|
||||
"album_not_found": "Album not found",
|
||||
"artist_not_found": "Artist not found",
|
||||
"playlist_not_found": "Playlist not found",
|
||||
"track_options": {
|
||||
"play_next": "Play Next",
|
||||
"add_to_queue": "Add to Queue",
|
||||
"add_to_playlist": "Add to Playlist",
|
||||
"download": "Download",
|
||||
"downloaded": "Downloaded",
|
||||
"downloading": "Downloading...",
|
||||
"cached": "Cached",
|
||||
"delete_download": "Delete Download",
|
||||
"delete_cache": "Remove from Cache",
|
||||
"go_to_artist": "Go to Artist",
|
||||
"go_to_album": "Go to Album",
|
||||
"add_to_favorites": "Add to Favorites",
|
||||
"remove_from_favorites": "Remove from Favorites",
|
||||
"remove_from_playlist": "Remove from Playlist"
|
||||
},
|
||||
"playlists": {
|
||||
"create_playlist": "Create Playlist",
|
||||
"playlist_name": "Playlist Name",
|
||||
"enter_name": "Enter playlist name",
|
||||
"create": "Create",
|
||||
"search_playlists": "Search playlists...",
|
||||
"added_to": "Added to {{name}}",
|
||||
"added": "Added to playlist",
|
||||
"removed_from": "Removed from {{name}}",
|
||||
"removed": "Removed from playlist",
|
||||
"created": "Playlist created",
|
||||
"create_new": "Create New Playlist",
|
||||
"failed_to_add": "Failed to add to playlist",
|
||||
"failed_to_remove": "Failed to remove from playlist",
|
||||
"failed_to_create": "Failed to create playlist",
|
||||
"delete_playlist": "Delete Playlist",
|
||||
"delete_confirm": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
|
||||
"deleted": "Playlist deleted",
|
||||
"failed_to_delete": "Failed to delete playlist"
|
||||
},
|
||||
"sort": {
|
||||
"title": "Sort By",
|
||||
"alphabetical": "Alphabetical",
|
||||
"date_created": "Date Created"
|
||||
}
|
||||
},
|
||||
"watchlists": {
|
||||
"title": "Watchlists",
|
||||
"my_watchlists": "My Watchlists",
|
||||
"public_watchlists": "Public Watchlists",
|
||||
"create_title": "Create Watchlist",
|
||||
"edit_title": "Edit Watchlist",
|
||||
"create_button": "Create Watchlist",
|
||||
"save_button": "Save Changes",
|
||||
"delete_button": "Delete",
|
||||
"remove_button": "Remove",
|
||||
"cancel_button": "Cancel",
|
||||
"name_label": "Name",
|
||||
"name_placeholder": "Enter watchlist name",
|
||||
"description_label": "Description",
|
||||
"description_placeholder": "Enter description (optional)",
|
||||
"is_public_label": "Public Watchlist",
|
||||
"is_public_description": "Allow others to view this watchlist",
|
||||
"allowed_type_label": "Content Type",
|
||||
"sort_order_label": "Default Sort Order",
|
||||
"empty_title": "No Watchlists",
|
||||
"empty_description": "Create your first watchlist to start organizing your media",
|
||||
"empty_watchlist": "This watchlist is empty",
|
||||
"empty_watchlist_hint": "Add items from your library to this watchlist",
|
||||
"not_configured_title": "Streamystats Not Configured",
|
||||
"not_configured_description": "Configure Streamystats in settings to use watchlists",
|
||||
"go_to_settings": "Go to Settings",
|
||||
"add_to_watchlist": "Add to Watchlist",
|
||||
"remove_from_watchlist": "Remove from Watchlist",
|
||||
"select_watchlist": "Select Watchlist",
|
||||
"create_new": "Create New Watchlist",
|
||||
"item": "item",
|
||||
"items": "items",
|
||||
"public": "Public",
|
||||
"private": "Private",
|
||||
"you": "You",
|
||||
"by_owner": "By another user",
|
||||
"not_found": "Watchlist not found",
|
||||
"delete_confirm_title": "Delete Watchlist",
|
||||
"delete_confirm_message": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
|
||||
"remove_item_title": "Remove from Watchlist",
|
||||
"remove_item_message": "Remove \"{{name}}\" from this watchlist?",
|
||||
"loading": "Loading watchlists...",
|
||||
"no_compatible_watchlists": "No compatible watchlists",
|
||||
"create_one_first": "Create a watchlist that accepts this content type"
|
||||
},
|
||||
"playback_speed": {
|
||||
"title": "Playback Speed",
|
||||
"apply_to": "Apply To",
|
||||
"speed": "Speed",
|
||||
"scope": {
|
||||
"media": "This media only",
|
||||
"show": "This show",
|
||||
"all": "All media (default)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"username_placeholder": "Kullanıcı adı",
|
||||
"password_placeholder": "Şifre",
|
||||
"login_button": "Giriş yap",
|
||||
"quick_connect": "Quick Connect",
|
||||
"quick_connect": "Hızlı Bağlantı",
|
||||
"enter_code_to_login": "Giriş yapmak için {{code}} kodunu girin",
|
||||
"failed_to_initiate_quick_connect": "Quick Connect başlatılamadı",
|
||||
"got_it": "Anlaşıldı",
|
||||
@@ -20,7 +20,9 @@
|
||||
"server_is_taking_too_long_to_respond_try_again_later": "Sunucu yanıt vermekte çok uzun sürüyor, lütfen tekrar deneyin",
|
||||
"server_received_too_many_requests_try_again_later": "Sunucu çok fazla istek aldı, lütfen tekrar deneyin.",
|
||||
"there_is_a_server_error": "Sunucu hatası var",
|
||||
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Beklenmedik bir hata oluştu. Sunucu URL'sini doğru girdiğinizden emin oldunuz mu?"
|
||||
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Beklenmedik bir hata oluştu. Sunucu URL'sini doğru girdiğinizden emin oldunuz mu?",
|
||||
"too_old_server_text": "Unsupported Jellyfin Server Discovered",
|
||||
"too_old_server_description": "Please update Jellyfin to the latest version"
|
||||
},
|
||||
"server": {
|
||||
"enter_url_to_jellyfin_server": "Jellyfin sunucunusun URL'sini girin",
|
||||
@@ -28,19 +30,64 @@
|
||||
"connect_button": "Bağlan",
|
||||
"previous_servers": "Önceki sunucular",
|
||||
"clear_button": "Temizle",
|
||||
"swipe_to_remove": "Swipe to remove",
|
||||
"search_for_local_servers": "Yerel sunucuları ara",
|
||||
"searching": "Aranıyor...",
|
||||
"servers": "Sunucular"
|
||||
"servers": "Sunucular",
|
||||
"saved": "Saved",
|
||||
"session_expired": "Session Expired",
|
||||
"please_login_again": "Your saved session has expired. Please log in again.",
|
||||
"remove_saved_login": "Remove Saved Login",
|
||||
"remove_saved_login_description": "This will remove your saved credentials for this server. You'll need to enter your username and password again next time.",
|
||||
"accounts_count": "{{count}} accounts",
|
||||
"select_account": "Select Account",
|
||||
"add_account": "Add Account",
|
||||
"remove_account_description": "This will remove the saved credentials for {{username}}."
|
||||
},
|
||||
"save_account": {
|
||||
"title": "Save Account",
|
||||
"save_for_later": "Save this account",
|
||||
"security_option": "Security Option",
|
||||
"no_protection": "No protection",
|
||||
"no_protection_desc": "Quick login without authentication",
|
||||
"pin_code": "PIN code",
|
||||
"pin_code_desc": "4-digit PIN required when switching",
|
||||
"password": "Re-enter password",
|
||||
"password_desc": "Password required when switching",
|
||||
"save_button": "Save",
|
||||
"cancel_button": "Cancel"
|
||||
},
|
||||
"pin": {
|
||||
"enter_pin": "Enter PIN",
|
||||
"enter_pin_for": "Enter PIN for {{username}}",
|
||||
"enter_4_digits": "Enter 4 digits",
|
||||
"invalid_pin": "Invalid PIN",
|
||||
"setup_pin": "Set Up PIN",
|
||||
"confirm_pin": "Confirm PIN",
|
||||
"pins_dont_match": "PINs don't match",
|
||||
"forgot_pin": "Forgot PIN?",
|
||||
"forgot_pin_desc": "Your saved credentials will be removed"
|
||||
},
|
||||
"password": {
|
||||
"enter_password": "Enter Password",
|
||||
"enter_password_for": "Enter password for {{username}}",
|
||||
"invalid_password": "Invalid password"
|
||||
},
|
||||
"home": {
|
||||
"checking_server_connection": "Checking server connection...",
|
||||
"no_internet": "İnternet Yok",
|
||||
"no_items": "Öge Yok",
|
||||
"no_internet_message": "Endişelenmeyin, hala\ndownloaded içerik izleyebilirsiniz.",
|
||||
"checking_server_connection_message": "Checking connection to server",
|
||||
"go_to_downloads": "İndirmelere Git",
|
||||
"retry": "Retry",
|
||||
"server_unreachable": "Server Unreachable",
|
||||
"server_unreachable_message": "Could not reach the server.\nPlease check your network connection.",
|
||||
"oops": "Hups!",
|
||||
"error_message": "Bir şeyler ters gitti.\nLütfen çıkış yapın ve tekrar giriş yapın.",
|
||||
"continue_watching": "İzlemeye Devam Et",
|
||||
"next_up": "Sonraki",
|
||||
"continue_and_next_up": "Continue & Next Up",
|
||||
"recently_added_in": "{{libraryName}}'de Yakınlarda Eklendi",
|
||||
"suggested_movies": "Önerilen Filmler",
|
||||
"suggested_episodes": "Önerilen Bölümler",
|
||||
@@ -62,6 +109,48 @@
|
||||
"settings": {
|
||||
"settings_title": "Ayarlar",
|
||||
"log_out_button": "Çıkış Yap",
|
||||
"categories": {
|
||||
"title": "Categories"
|
||||
},
|
||||
"playback_controls": {
|
||||
"title": "Playback & Controls"
|
||||
},
|
||||
"audio_subtitles": {
|
||||
"title": "Audio & Subtitles"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Appearance",
|
||||
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
|
||||
"hide_remote_session_button": "Hide Remote Session Button"
|
||||
},
|
||||
"network": {
|
||||
"title": "Network",
|
||||
"local_network": "Local Network",
|
||||
"auto_switch_enabled": "Auto-switch when at home",
|
||||
"auto_switch_description": "Automatically switch to local URL when connected to home WiFi",
|
||||
"local_url": "Local URL",
|
||||
"local_url_hint": "Enter your local server address (e.g., http://192.168.1.100:8096)",
|
||||
"local_url_placeholder": "http://192.168.1.100:8096",
|
||||
"home_wifi_networks": "Home WiFi Networks",
|
||||
"add_current_network": "Add \"{{ssid}}\"",
|
||||
"not_connected_to_wifi": "Not connected to WiFi",
|
||||
"no_networks_configured": "No networks configured",
|
||||
"add_network_hint": "Add your home WiFi network to enable auto-switching",
|
||||
"current_wifi": "Current WiFi",
|
||||
"using_url": "Using",
|
||||
"local": "Local URL",
|
||||
"remote": "Remote URL",
|
||||
"not_connected": "Not connected",
|
||||
"current_server": "Current Server",
|
||||
"remote_url": "Remote URL",
|
||||
"active_url": "Active URL",
|
||||
"not_configured": "Not configured",
|
||||
"network_added": "Network added",
|
||||
"network_already_added": "Network already added",
|
||||
"no_wifi_connected": "Not connected to WiFi",
|
||||
"permission_denied": "Location permission denied",
|
||||
"permission_denied_explanation": "Location permission is required to detect WiFi network for auto-switching. Please enable it in Settings."
|
||||
},
|
||||
"user_info": {
|
||||
"user_info_title": "Kullanıcı Bilgisi",
|
||||
"user": "Kullanıcı",
|
||||
@@ -85,21 +174,42 @@
|
||||
"rewind_length": "Geri Sarma Uzunluğu",
|
||||
"seconds_unit": "s"
|
||||
},
|
||||
"gesture_controls": {
|
||||
"gesture_controls_title": "Gesture Controls",
|
||||
"horizontal_swipe_skip": "Horizontal Swipe to Skip",
|
||||
"horizontal_swipe_skip_description": "Swipe left/right when controls are hidden to skip",
|
||||
"left_side_brightness": "Left Side Brightness Control",
|
||||
"left_side_brightness_description": "Swipe up/down on left side to adjust brightness",
|
||||
"right_side_volume": "Right Side Volume Control",
|
||||
"right_side_volume_description": "Swipe up/down on right side to adjust volume",
|
||||
"hide_volume_slider": "Hide Volume Slider",
|
||||
"hide_volume_slider_description": "Hide the volume slider in the video player",
|
||||
"hide_brightness_slider": "Hide Brightness Slider",
|
||||
"hide_brightness_slider_description": "Hide the brightness slider in the video player"
|
||||
},
|
||||
"audio": {
|
||||
"audio_title": "Ses",
|
||||
"set_audio_track": "Önceki Öğeden Ses Parçası Ayarla",
|
||||
"audio_language": "Ses Dili",
|
||||
"audio_hint": "Varsayılan ses dilini seçin.",
|
||||
"none": "Yok",
|
||||
"language": "Dil"
|
||||
"language": "Dil",
|
||||
"transcode_mode": {
|
||||
"title": "Audio Transcoding",
|
||||
"description": "Controls how surround audio (7.1, TrueHD, DTS-HD) is handled",
|
||||
"auto": "Auto",
|
||||
"stereo": "Force Stereo",
|
||||
"5_1": "Allow 5.1",
|
||||
"passthrough": "Passthrough"
|
||||
}
|
||||
},
|
||||
"subtitles": {
|
||||
"subtitle_title": "Altyazılar",
|
||||
"subtitle_hint": "Altyazı tercihini yapılandırın.",
|
||||
"subtitle_language": "Altyazı Dili",
|
||||
"subtitle_mode": "Altyazı Modu",
|
||||
"set_subtitle_track": "Önceki Öğeden Altyazı Parçası Ayarla",
|
||||
"subtitle_size": "Altyazı Boyutu",
|
||||
"subtitle_hint": "Altyazı tercihini yapılandırın.",
|
||||
"none": "Yok",
|
||||
"language": "Dil",
|
||||
"loading": "Yükleniyor",
|
||||
@@ -109,11 +219,66 @@
|
||||
"Always": "Her Zaman",
|
||||
"None": "Yok",
|
||||
"OnlyForced": "Sadece Zorunlu"
|
||||
}
|
||||
},
|
||||
"text_color": "Text Color",
|
||||
"background_color": "Background Color",
|
||||
"outline_color": "Outline Color",
|
||||
"outline_thickness": "Outline Thickness",
|
||||
"background_opacity": "Background Opacity",
|
||||
"outline_opacity": "Outline Opacity",
|
||||
"bold_text": "Bold Text",
|
||||
"colors": {
|
||||
"Black": "Black",
|
||||
"Gray": "Gray",
|
||||
"Silver": "Silver",
|
||||
"White": "White",
|
||||
"Maroon": "Maroon",
|
||||
"Red": "Red",
|
||||
"Fuchsia": "Fuchsia",
|
||||
"Yellow": "Yellow",
|
||||
"Olive": "Olive",
|
||||
"Green": "Green",
|
||||
"Teal": "Teal",
|
||||
"Lime": "Lime",
|
||||
"Purple": "Purple",
|
||||
"Navy": "Navy",
|
||||
"Blue": "Blue",
|
||||
"Aqua": "Aqua"
|
||||
},
|
||||
"thickness": {
|
||||
"None": "Hiçbiri",
|
||||
"Thin": "Thin",
|
||||
"Normal": "Normal",
|
||||
"Thick": "Thick"
|
||||
},
|
||||
"subtitle_color": "Subtitle Color",
|
||||
"subtitle_background_color": "Background Color",
|
||||
"subtitle_font": "Subtitle Font",
|
||||
"ksplayer_title": "KSPlayer Settings",
|
||||
"hardware_decode": "Hardware Decoding",
|
||||
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues."
|
||||
},
|
||||
"vlc_subtitles": {
|
||||
"title": "VLC Subtitle Settings",
|
||||
"hint": "Customize subtitle appearance for VLC player. Changes take effect on next playback.",
|
||||
"text_color": "Text Color",
|
||||
"background_color": "Background Color",
|
||||
"background_opacity": "Background Opacity",
|
||||
"outline_color": "Outline Color",
|
||||
"outline_opacity": "Outline Opacity",
|
||||
"outline_thickness": "Outline Thickness",
|
||||
"bold": "Bold Text",
|
||||
"margin": "Bottom Margin"
|
||||
},
|
||||
"video_player": {
|
||||
"title": "Video Player",
|
||||
"video_player": "Video Player",
|
||||
"video_player_description": "Choose which video player to use on iOS.",
|
||||
"ksplayer": "KSPlayer",
|
||||
"vlc": "VLC"
|
||||
},
|
||||
"other": {
|
||||
"other_title": "Diğer",
|
||||
"auto_rotate": "Otomatik Döndürme",
|
||||
"video_orientation": "Video Yönü",
|
||||
"orientation": "Yön",
|
||||
"orientations": {
|
||||
@@ -129,25 +294,35 @@
|
||||
"UNKNOWN": "Bilinmeyen"
|
||||
},
|
||||
"safe_area_in_controls": "Kontrollerde Güvenli Alan",
|
||||
"video_player": "Video player",
|
||||
"video_players": {
|
||||
"VLC_3": "VLC 3",
|
||||
"VLC_4": "VLC 4 (Experimental + PiP)"
|
||||
},
|
||||
"show_custom_menu_links": "Özel Menü Bağlantılarını Göster",
|
||||
"show_large_home_carousel": "Show Large Home Carousel (beta)",
|
||||
"hide_libraries": "Kütüphaneleri Gizle",
|
||||
"select_liraries_you_want_to_hide": "Kütüphane sekmesinden ve ana sayfa bölümlerinden gizlemek istediğiniz kütüphaneleri seçin.",
|
||||
"disable_haptic_feedback": "Dokunsal Geri Bildirimi Devre Dışı Bırak"
|
||||
"disable_haptic_feedback": "Dokunsal Geri Bildirimi Devre Dışı Bırak",
|
||||
"default_quality": "Varsayılan kalite",
|
||||
"default_playback_speed": "Default Playback Speed",
|
||||
"auto_play_next_episode": "Auto-play Next Episode",
|
||||
"max_auto_play_episode_count": "Max Auto Play Episode Count",
|
||||
"disabled": "Devre dışı"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "İndirmeler",
|
||||
"download_method": "İndirme Yöntemi",
|
||||
"remux_max_download": "Remux max indirme",
|
||||
"auto_download": "Otomatik İndirme",
|
||||
"optimized_versions_server": "Optimize edilmiş sürümler sunucusu",
|
||||
"save_button": "Kaydet",
|
||||
"optimized_server": "Optimize Sunucu",
|
||||
"optimized": "Optimize",
|
||||
"default": "Varsayılan",
|
||||
"optimized_version_hint": "Optimize sunucusu için URL girin. URL, http veya https içermeli ve isteğe bağlı olarak portu içerebilir.",
|
||||
"read_more_about_optimized_server": "Optimize sunucusu hakkında daha fazla oku.",
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://domain.org:port"
|
||||
"downloads_title": "İndirmeler"
|
||||
},
|
||||
"music": {
|
||||
"title": "Music",
|
||||
"playback_title": "Playback",
|
||||
"playback_description": "Configure how music is played.",
|
||||
"prefer_downloaded": "Prefer Downloaded Songs",
|
||||
"caching_title": "Caching",
|
||||
"caching_description": "Automatically cache upcoming tracks for smoother playback.",
|
||||
"lookahead_enabled": "Enable Look-Ahead Caching",
|
||||
"lookahead_count": "Tracks to Pre-cache",
|
||||
"max_cache_size": "Max Cache Size"
|
||||
},
|
||||
"plugins": {
|
||||
"plugins_title": "Eklentiler",
|
||||
@@ -158,8 +333,6 @@
|
||||
"server_url_placeholder": "Jellyseerr URL...",
|
||||
"password": "Şifre",
|
||||
"password_placeholder": "Jellyfin kullanıcısı {{username}} için şifre girin",
|
||||
"save_button": "Kaydet",
|
||||
"clear_button": "Temizle",
|
||||
"login_button": "Giriş Yap",
|
||||
"total_media_requests": "Toplam medya istekleri",
|
||||
"movie_quota_limit": "Film kota limiti",
|
||||
@@ -167,7 +340,13 @@
|
||||
"tv_quota_limit": "TV kota limiti",
|
||||
"tv_quota_days": "TV kota günleri",
|
||||
"reset_jellyseerr_config_button": "Jellyseerr yapılandırmasını sıfırla",
|
||||
"unlimited": "Sınırsız"
|
||||
"unlimited": "Sınırsız",
|
||||
"plus_n_more": "+{{n}} more",
|
||||
"order_by": {
|
||||
"DEFAULT": "Varsayılan",
|
||||
"VOTE_COUNT_AND_AVERAGE": "Vote count and average",
|
||||
"POPULARITY": "Popularity"
|
||||
}
|
||||
},
|
||||
"marlin_search": {
|
||||
"enable_marlin_search": "Marlin Aramasını Etkinleştir ",
|
||||
@@ -177,8 +356,40 @@
|
||||
"read_more_about_marlin": "Marlin hakkında daha fazla oku.",
|
||||
"save_button": "Kaydet",
|
||||
"toasts": {
|
||||
"saved": "Kaydedildi"
|
||||
}
|
||||
"saved": "Kaydedildi",
|
||||
"refreshed": "Settings refreshed from server"
|
||||
},
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"streamystats": {
|
||||
"enable_streamystats": "Enable Streamystats",
|
||||
"disable_streamystats": "Disable Streamystats",
|
||||
"enable_search": "Use for Search",
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
||||
"streamystats_search_hint": "Enter the URL for your Streamystats server. The URL should include http or https and optionally the port.",
|
||||
"read_more_about_streamystats": "Read More About Streamystats.",
|
||||
"save_button": "Save",
|
||||
"save": "Save",
|
||||
"features_title": "Features",
|
||||
"home_sections_title": "Home Sections",
|
||||
"enable_movie_recommendations": "Movie Recommendations",
|
||||
"enable_series_recommendations": "Series Recommendations",
|
||||
"enable_promoted_watchlists": "Promoted Watchlists",
|
||||
"hide_watchlists_tab": "Hide Watchlists Tab",
|
||||
"home_sections_hint": "Show personalized recommendations and promoted watchlists from Streamystats on the home page.",
|
||||
"recommended_movies": "Recommended Movies",
|
||||
"recommended_series": "Recommended Series",
|
||||
"toasts": {
|
||||
"saved": "Saved",
|
||||
"refreshed": "Settings refreshed from server",
|
||||
"disabled": "Streamystats disabled"
|
||||
},
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
"kefinTweaks": {
|
||||
"watchlist_enabler": "Enable our Watchlist integration",
|
||||
"watchlist_button": "Toggle Watchlist integration"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
@@ -186,43 +397,58 @@
|
||||
"app_usage": "Uygulama {{usedSpace}}%",
|
||||
"device_usage": "Cihaz {{availableSpace}}%",
|
||||
"size_used": "{{used}} / {{total}} kullanıldı",
|
||||
"delete_all_downloaded_files": "Tüm indirilen dosyaları sil"
|
||||
"delete_all_downloaded_files": "Tüm indirilen dosyaları sil",
|
||||
"music_cache_title": "Music Cache",
|
||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||
"enable_music_cache": "Enable Music Cache",
|
||||
"clear_music_cache": "Clear Music Cache",
|
||||
"music_cache_size": "{{size}} cached",
|
||||
"music_cache_cleared": "Music cache cleared",
|
||||
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
|
||||
"downloaded_songs_size": "{{size}} downloaded",
|
||||
"downloaded_songs_deleted": "Downloaded songs deleted"
|
||||
},
|
||||
"intro": {
|
||||
"title": "Intro",
|
||||
"show_intro": "Tanıtımı Göster",
|
||||
"reset_intro": "Tanıtımı Sıfırla"
|
||||
},
|
||||
"logs": {
|
||||
"logs_title": "Günlükler",
|
||||
"export_logs": "Export logs",
|
||||
"click_for_more_info": "Click for more info",
|
||||
"level": "Level",
|
||||
"no_logs_available": "Günlükler mevcut değil",
|
||||
"delete_all_logs": "Tüm günlükleri sil"
|
||||
},
|
||||
"languages": {
|
||||
"title": "Diller",
|
||||
"app_language": "Uygulama dili",
|
||||
"app_language_description": "Uygulama dilini seçin.",
|
||||
"system": "Sistem"
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "Dosyalar silinirken hata oluştu",
|
||||
"background_downloads_enabled": "Arka plan indirmeleri etkinleştirildi",
|
||||
"background_downloads_disabled": "Arka plan indirmeleri devre dışı bırakıldı",
|
||||
"connected": "Bağlandı",
|
||||
"could_not_connect": "Bağlanılamadı",
|
||||
"invalid_url": "Geçersiz URL"
|
||||
"background_downloads_disabled": "Arka plan indirmeleri devre dışı bırakıldı"
|
||||
}
|
||||
},
|
||||
"sessions": {
|
||||
"title": "Sessions",
|
||||
"no_active_sessions": "No Active Sessions"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "İndirilenler",
|
||||
"tvseries": "Diziler",
|
||||
"movies": "Filmler",
|
||||
"queue": "Sıra",
|
||||
"other_media": "Other media",
|
||||
"queue_hint": "Sıra ve indirmeler uygulama yeniden başlatıldığında kaybolacaktır",
|
||||
"no_items_in_queue": "Sırada öğe yok",
|
||||
"no_downloaded_items": "İndirilen öğe yok",
|
||||
"delete_all_movies_button": "Tüm Filmleri Sil",
|
||||
"delete_all_tvseries_button": "Tüm Dizileri Sil",
|
||||
"delete_all_button": "Tümünü Sil",
|
||||
"delete_all_other_media_button": "Delete other media",
|
||||
"active_download": "Aktif indirme",
|
||||
"no_active_downloads": "Aktif indirme yok",
|
||||
"active_downloads": "Aktif indirmeler",
|
||||
@@ -233,35 +459,57 @@
|
||||
"something_went_wrong": "Bir şeyler ters gitti",
|
||||
"could_not_get_stream_url_from_jellyfin": "Jellyfin'den yayın URL'si alınamadı",
|
||||
"eta": "Tahmini Süre {{eta}}",
|
||||
"methods": "Yöntemler",
|
||||
"toasts": {
|
||||
"you_are_not_allowed_to_download_files": "Dosyaları indirme izniniz yok.",
|
||||
"deleted_all_movies_successfully": "Tüm filmler başarıyla silindi!",
|
||||
"failed_to_delete_all_movies": "Filmler silinemedi",
|
||||
"deleted_all_tvseries_successfully": "Tüm diziler başarıyla silindi!",
|
||||
"failed_to_delete_all_tvseries": "Diziler silinemedi",
|
||||
"deleted_media_successfully": "Deleted other media Successfully!",
|
||||
"failed_to_delete_media": "Failed to Delete other media",
|
||||
"download_deleted": "Download Deleted",
|
||||
"download_cancelled": "İndirme iptal edildi",
|
||||
"could_not_cancel_download": "İndirme iptal edilemedi",
|
||||
"could_not_delete_download": "Could Not Delete Download",
|
||||
"download_paused": "Download Paused",
|
||||
"could_not_pause_download": "Could Not Pause Download",
|
||||
"download_resumed": "Download Resumed",
|
||||
"could_not_resume_download": "Could Not Resume Download",
|
||||
"download_completed": "İndirme tamamlandı",
|
||||
"download_started_for": "{{item}} için indirme başlatıldı",
|
||||
"item_is_ready_to_be_downloaded": "{{item}} indirmeye hazır",
|
||||
"download_stated_for_item": "{{item}} için indirme başlatıldı",
|
||||
"download_failed": "Download Failed",
|
||||
"download_failed_for_item": "{{item}} için indirme başarısız oldu - {{error}}",
|
||||
"download_completed_for_item": "{{item}} için indirme tamamlandı",
|
||||
"queued_item_for_optimization": "{{item}} optimizasyon için sıraya alındı",
|
||||
"failed_to_start_download_for_item": "{{item}} için indirme başlatılamadı: {{message}}",
|
||||
"server_responded_with_status_code": "Sunucu şu durum koduyla yanıt verdi: {{statusCode}}",
|
||||
"no_response_received_from_server": "Sunucudan yanıt alınamadı",
|
||||
"error_setting_up_the_request": "İstek ayarlanırken hata oluştu",
|
||||
"failed_to_start_download_for_item_unexpected_error": "{{item}} için indirme başlatılamadı: Beklenmeyen hata",
|
||||
"download_started_for_item": "Download Started for {{item}}",
|
||||
"failed_to_start_download": "Failed to start download",
|
||||
"item_already_downloading": "{{item}} is already downloading",
|
||||
"all_files_deleted": "All Downloads Deleted Successfully",
|
||||
"files_deleted_by_type": "{{count}} {{type}} deleted",
|
||||
"all_files_folders_and_jobs_deleted_successfully": "Tüm dosyalar, klasörler ve işler başarıyla silindi",
|
||||
"an_error_occured_while_deleting_files_and_jobs": "Dosyalar ve işler silinirken hata oluştu",
|
||||
"go_to_downloads": "İndirmelere git"
|
||||
"failed_to_clean_cache_directory": "Failed to clean cache directory",
|
||||
"could_not_get_download_url_for_item": "Could not get download URL for {{itemName}}",
|
||||
"go_to_downloads": "İndirmelere git",
|
||||
"file_deleted": "{{item}} deleted"
|
||||
}
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"select": "Select",
|
||||
"no_trailer_available": "No trailer available",
|
||||
"video": "Video",
|
||||
"audio": "Ses",
|
||||
"subtitle": "Altyazı",
|
||||
"play": "Play",
|
||||
"none": "None",
|
||||
"track": "Track",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"ok": "OK",
|
||||
"remove": "Remove",
|
||||
"next": "Next",
|
||||
"back": "Back",
|
||||
"continue": "Continue",
|
||||
"verifying": "Verifying..."
|
||||
},
|
||||
"search": {
|
||||
"search_here": "Burada ara...",
|
||||
"search": "Ara...",
|
||||
"x_items": "{{count}} öge(ler)",
|
||||
"library": "Kütüphane",
|
||||
@@ -273,6 +521,10 @@
|
||||
"episodes": "Bölümler",
|
||||
"collections": "Koleksiyonlar",
|
||||
"actors": "Oyuncular",
|
||||
"artists": "Artists",
|
||||
"albums": "Albums",
|
||||
"songs": "Songs",
|
||||
"playlists": "Playlists",
|
||||
"request_movies": "Film Talep Et",
|
||||
"request_series": "Dizi Talep Et",
|
||||
"recently_added": "Son Eklenenler",
|
||||
@@ -298,7 +550,6 @@
|
||||
"tmdb_tv_streaming_services": "TMDB Dizi Yayın Servisleri"
|
||||
},
|
||||
"library": {
|
||||
"no_items_found": "Öğe bulunamadı",
|
||||
"no_results": "Sonuç bulunamadı",
|
||||
"no_libraries_found": "Kütüphane bulunamadı",
|
||||
"item_types": {
|
||||
@@ -321,6 +572,7 @@
|
||||
"genres": "Türler",
|
||||
"years": "Yıllar",
|
||||
"sort_by": "Sırala",
|
||||
"filter_by": "Filter By",
|
||||
"sort_order": "Sıralama düzeni",
|
||||
"tags": "Etiketler"
|
||||
}
|
||||
@@ -331,7 +583,9 @@
|
||||
"episodes": "Bölümler",
|
||||
"videos": "Videolar",
|
||||
"boxsets": "Koleksiyonlar",
|
||||
"playlists": "Çalma listeleri"
|
||||
"playlists": "Çalma listeleri",
|
||||
"noDataTitle": "Henüz favori yok",
|
||||
"noData": "Hızlı erişim için öğeleri favori olarak işaretleyin ve burada görünmelerini sağlayın."
|
||||
},
|
||||
"custom_links": {
|
||||
"no_links": "Bağlantı yok"
|
||||
@@ -343,15 +597,18 @@
|
||||
"client_error": "İstemci hatası",
|
||||
"could_not_create_stream_for_chromecast": "Chromecast için yayın oluşturulamadı",
|
||||
"message_from_server": "Sunucudan mesaj: {{message}}",
|
||||
"video_has_finished_playing": "Video oynatıldı!",
|
||||
"no_video_source": "Video kaynağı yok...",
|
||||
"next_episode": "Sonraki bölüm",
|
||||
"refresh_tracks": "Parçaları yenile",
|
||||
"subtitle_tracks": "Altyazı Parçaları:",
|
||||
"audio_tracks": "Ses Parçaları:",
|
||||
"playback_state": "Oynatma Durumu:",
|
||||
"no_data_available": "Veri bulunamadı",
|
||||
"index": "İndeks:"
|
||||
"index": "İndeks:",
|
||||
"continue_watching": "İzlemeye devam et",
|
||||
"go_back": "Geri",
|
||||
"downloaded_file_title": "You have this file downloaded",
|
||||
"downloaded_file_message": "Do you want to play the downloaded file?",
|
||||
"downloaded_file_yes": "Yes",
|
||||
"downloaded_file_no": "No",
|
||||
"downloaded_file_cancel": "Cancel"
|
||||
},
|
||||
"item_card": {
|
||||
"next_up": "Sıradaki",
|
||||
@@ -367,6 +624,7 @@
|
||||
"no_similar_items_found": "Benzer öge bulunamadı",
|
||||
"video": "Video",
|
||||
"more_details": "Daha fazla detay",
|
||||
"media_options": "Media Options",
|
||||
"quality": "Kalite",
|
||||
"audio": "Ses",
|
||||
"subtitles": "Altyazı",
|
||||
@@ -381,15 +639,13 @@
|
||||
"download_episode": "Bölümü indir",
|
||||
"download_movie": "Filmi indir",
|
||||
"download_x_item": "{{item_count}} tane ögeyi indir",
|
||||
"download_button": "İndir",
|
||||
"using_optimized_server": "Optimize edilmiş sunucu kullanılıyor",
|
||||
"using_default_method": "Varsayılan yöntem kullanılıyor"
|
||||
"download_unwatched_only": "Unwatched Only",
|
||||
"download_button": "İndir"
|
||||
}
|
||||
},
|
||||
"live_tv": {
|
||||
"next": "Sonraki",
|
||||
"previous": "Önceki",
|
||||
"live_tv": "Canlı TV",
|
||||
"coming_soon": "Yakında",
|
||||
"on_now": "Şu anda yayında",
|
||||
"shows": "Programlar",
|
||||
@@ -432,11 +688,15 @@
|
||||
"tags": "Etiketler",
|
||||
"quality_profile": "Kalite Profili",
|
||||
"root_folder": "Kök Klasör",
|
||||
"season_x": "Sezon {{seasons}}",
|
||||
"season_all": "Season (all)",
|
||||
"season_number": "Sezon {{season_number}}",
|
||||
"number_episodes": "Bölüm {{episode_number}}",
|
||||
"born": "Doğum",
|
||||
"appearances": "Görünmeler",
|
||||
"approve": "Approve",
|
||||
"decline": "Decline",
|
||||
"requested_by": "Requested by {{user}}",
|
||||
"unknown_user": "Unknown User",
|
||||
"toasts": {
|
||||
"jellyseer_does_not_meet_requirements": "Jellyseerr sunucusu minimum sürüm gereksinimlerini karşılamıyor! Lütfen en az 2.0.0 sürümüne güncelleyin",
|
||||
"jellyseerr_test_failed": "Jellyseerr testi başarısız oldu. Lütfen tekrar deneyin.",
|
||||
@@ -444,7 +704,11 @@
|
||||
"issue_submitted": "Sorun gönderildi!",
|
||||
"requested_item": "{{item}} talep edildi!",
|
||||
"you_dont_have_permission_to_request": "İstek göndermeye izniniz yok!",
|
||||
"something_went_wrong_requesting_media": "Medya talep edilirken bir şeyler ters gitti!"
|
||||
"something_went_wrong_requesting_media": "Medya talep edilirken bir şeyler ters gitti!",
|
||||
"request_approved": "Request Approved!",
|
||||
"request_declined": "Request Declined!",
|
||||
"failed_to_approve_request": "Failed to Approve Request",
|
||||
"failed_to_decline_request": "Failed to Decline Request"
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
@@ -453,5 +717,129 @@
|
||||
"library": "Kütüphane",
|
||||
"custom_links": "Özel Bağlantılar",
|
||||
"favorites": "Favoriler"
|
||||
},
|
||||
"music": {
|
||||
"title": "Music",
|
||||
"tabs": {
|
||||
"suggestions": "Suggestions",
|
||||
"albums": "Albums",
|
||||
"artists": "Artists",
|
||||
"playlists": "Playlists",
|
||||
"tracks": "tracks"
|
||||
},
|
||||
"filters": {
|
||||
"all": "All"
|
||||
},
|
||||
"recently_added": "Recently Added",
|
||||
"recently_played": "Recently Played",
|
||||
"frequently_played": "Frequently Played",
|
||||
"explore": "Explore",
|
||||
"top_tracks": "Top Tracks",
|
||||
"play": "Play",
|
||||
"shuffle": "Shuffle",
|
||||
"play_top_tracks": "Play Top Tracks",
|
||||
"no_suggestions": "No suggestions available",
|
||||
"no_albums": "No albums found",
|
||||
"no_artists": "No artists found",
|
||||
"no_playlists": "No playlists found",
|
||||
"album_not_found": "Album not found",
|
||||
"artist_not_found": "Artist not found",
|
||||
"playlist_not_found": "Playlist not found",
|
||||
"track_options": {
|
||||
"play_next": "Play Next",
|
||||
"add_to_queue": "Add to Queue",
|
||||
"add_to_playlist": "Add to Playlist",
|
||||
"download": "Download",
|
||||
"downloaded": "Downloaded",
|
||||
"downloading": "Downloading...",
|
||||
"cached": "Cached",
|
||||
"delete_download": "Delete Download",
|
||||
"delete_cache": "Remove from Cache",
|
||||
"go_to_artist": "Go to Artist",
|
||||
"go_to_album": "Go to Album",
|
||||
"add_to_favorites": "Add to Favorites",
|
||||
"remove_from_favorites": "Remove from Favorites",
|
||||
"remove_from_playlist": "Remove from Playlist"
|
||||
},
|
||||
"playlists": {
|
||||
"create_playlist": "Create Playlist",
|
||||
"playlist_name": "Playlist Name",
|
||||
"enter_name": "Enter playlist name",
|
||||
"create": "Create",
|
||||
"search_playlists": "Search playlists...",
|
||||
"added_to": "Added to {{name}}",
|
||||
"added": "Added to playlist",
|
||||
"removed_from": "Removed from {{name}}",
|
||||
"removed": "Removed from playlist",
|
||||
"created": "Playlist created",
|
||||
"create_new": "Create New Playlist",
|
||||
"failed_to_add": "Failed to add to playlist",
|
||||
"failed_to_remove": "Failed to remove from playlist",
|
||||
"failed_to_create": "Failed to create playlist",
|
||||
"delete_playlist": "Delete Playlist",
|
||||
"delete_confirm": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
|
||||
"deleted": "Playlist deleted",
|
||||
"failed_to_delete": "Failed to delete playlist"
|
||||
},
|
||||
"sort": {
|
||||
"title": "Sort By",
|
||||
"alphabetical": "Alphabetical",
|
||||
"date_created": "Date Created"
|
||||
}
|
||||
},
|
||||
"watchlists": {
|
||||
"title": "Watchlists",
|
||||
"my_watchlists": "My Watchlists",
|
||||
"public_watchlists": "Public Watchlists",
|
||||
"create_title": "Create Watchlist",
|
||||
"edit_title": "Edit Watchlist",
|
||||
"create_button": "Create Watchlist",
|
||||
"save_button": "Save Changes",
|
||||
"delete_button": "Delete",
|
||||
"remove_button": "Remove",
|
||||
"cancel_button": "Cancel",
|
||||
"name_label": "Name",
|
||||
"name_placeholder": "Enter watchlist name",
|
||||
"description_label": "Description",
|
||||
"description_placeholder": "Enter description (optional)",
|
||||
"is_public_label": "Public Watchlist",
|
||||
"is_public_description": "Allow others to view this watchlist",
|
||||
"allowed_type_label": "Content Type",
|
||||
"sort_order_label": "Default Sort Order",
|
||||
"empty_title": "No Watchlists",
|
||||
"empty_description": "Create your first watchlist to start organizing your media",
|
||||
"empty_watchlist": "This watchlist is empty",
|
||||
"empty_watchlist_hint": "Add items from your library to this watchlist",
|
||||
"not_configured_title": "Streamystats Not Configured",
|
||||
"not_configured_description": "Configure Streamystats in settings to use watchlists",
|
||||
"go_to_settings": "Go to Settings",
|
||||
"add_to_watchlist": "Add to Watchlist",
|
||||
"remove_from_watchlist": "Remove from Watchlist",
|
||||
"select_watchlist": "Select Watchlist",
|
||||
"create_new": "Create New Watchlist",
|
||||
"item": "item",
|
||||
"items": "items",
|
||||
"public": "Public",
|
||||
"private": "Private",
|
||||
"you": "You",
|
||||
"by_owner": "By another user",
|
||||
"not_found": "Watchlist not found",
|
||||
"delete_confirm_title": "Delete Watchlist",
|
||||
"delete_confirm_message": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
|
||||
"remove_item_title": "Remove from Watchlist",
|
||||
"remove_item_message": "Remove \"{{name}}\" from this watchlist?",
|
||||
"loading": "Loading watchlists...",
|
||||
"no_compatible_watchlists": "No compatible watchlists",
|
||||
"create_one_first": "Create a watchlist that accepts this content type"
|
||||
},
|
||||
"playback_speed": {
|
||||
"title": "Playback Speed",
|
||||
"apply_to": "Apply To",
|
||||
"speed": "Speed",
|
||||
"scope": {
|
||||
"media": "This media only",
|
||||
"show": "This show",
|
||||
"all": "All media (default)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,7 +113,6 @@
|
||||
},
|
||||
"other": {
|
||||
"other_title": "其他",
|
||||
"auto_rotate": "自动旋转",
|
||||
"video_orientation": "视频方向",
|
||||
"orientation": "方向",
|
||||
"orientations": {
|
||||
@@ -129,6 +128,11 @@
|
||||
"UNKNOWN": "未知"
|
||||
},
|
||||
"safe_area_in_controls": "控制中的安全区域",
|
||||
"video_player": "Video player",
|
||||
"video_players": {
|
||||
"VLC_3": "VLC 3",
|
||||
"VLC_4": "VLC 4 (Experimental + PiP)"
|
||||
},
|
||||
"show_custom_menu_links": "显示自定义菜单链接",
|
||||
"hide_libraries": "隐藏媒体库",
|
||||
"select_liraries_you_want_to_hide": "选择您想从媒体库页面和主页隐藏的媒体库。",
|
||||
@@ -136,9 +140,6 @@
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "下载",
|
||||
"download_method": "下载方法",
|
||||
"remux_max_download": "Remux 最大下载",
|
||||
"auto_download": "自动下载",
|
||||
"optimized_versions_server": "Optimized Version 服务器",
|
||||
"save_button": "保存",
|
||||
"optimized_server": "Optimized Server",
|
||||
@@ -167,7 +168,13 @@
|
||||
"tv_quota_limit": "剧集配额限制",
|
||||
"tv_quota_days": "剧集配额天数",
|
||||
"reset_jellyseerr_config_button": "重置 Jellyseerr 设置",
|
||||
"unlimited": "无限制"
|
||||
"unlimited": "无限制",
|
||||
"plus_n_more": "+{{n}} more",
|
||||
"order_by": {
|
||||
"DEFAULT": "Default",
|
||||
"VOTE_COUNT_AND_AVERAGE": "Vote count and average",
|
||||
"POPULARITY": "Popularity"
|
||||
}
|
||||
},
|
||||
"marlin_search": {
|
||||
"enable_marlin_search": "启用 Marlin 搜索",
|
||||
@@ -194,6 +201,9 @@
|
||||
},
|
||||
"logs": {
|
||||
"logs_title": "日志",
|
||||
"export_logs": "Export logs",
|
||||
"click_for_more_info": "Click for more info",
|
||||
"level": "Level",
|
||||
"no_logs_available": "无可用日志",
|
||||
"delete_all_logs": "删除所有日志"
|
||||
},
|
||||
@@ -322,6 +332,8 @@
|
||||
"years": "年份",
|
||||
"sort_by": "排序依据",
|
||||
"sort_order": "排序顺序",
|
||||
"asc": "Ascending",
|
||||
"desc": "Descending",
|
||||
"tags": "标签"
|
||||
}
|
||||
},
|
||||
@@ -331,7 +343,9 @@
|
||||
"episodes": "单集",
|
||||
"videos": "视频",
|
||||
"boxsets": "套装",
|
||||
"playlists": "播放列表"
|
||||
"playlists": "播放列表",
|
||||
"noDataTitle": "暂无收藏",
|
||||
"noData": "将项目标记为收藏,它们将显示在此处以便快速访问。"
|
||||
},
|
||||
"custom_links": {
|
||||
"no_links": "无链接"
|
||||
@@ -342,7 +356,7 @@
|
||||
"an_error_occured_while_playing_the_video": "播放视频时发生错误。请检查设置中的日志。",
|
||||
"client_error": "客户端错误",
|
||||
"could_not_create_stream_for_chromecast": "无法为 Chromecast 建立串流",
|
||||
"message_from_server": "来自服务器的消息:{{message}}",
|
||||
"message_from_server": "来自服务器的消息",
|
||||
"video_has_finished_playing": "视频播放完成!",
|
||||
"no_video_source": "无视频来源...",
|
||||
"next_episode": "下一集",
|
||||
@@ -351,7 +365,9 @@
|
||||
"audio_tracks": "音频轨道:",
|
||||
"playback_state": "播放状态:",
|
||||
"no_data_available": "无可用数据",
|
||||
"index": "索引:"
|
||||
"index": "索引:",
|
||||
"continue_watching": "继续观看",
|
||||
"go_back": "返回"
|
||||
},
|
||||
"item_card": {
|
||||
"next_up": "下一个",
|
||||
@@ -432,7 +448,7 @@
|
||||
"tags": "标签",
|
||||
"quality_profile": "质量配置文件",
|
||||
"root_folder": "根文件夹",
|
||||
"season_x": "第 {{seasons}} 季",
|
||||
"season_all": "Season (all)",
|
||||
"season_number": "第 {{season_number}} 季",
|
||||
"number_episodes": "{{episode_number}} 集",
|
||||
"born": "出生",
|
||||
|
||||
@@ -113,7 +113,6 @@
|
||||
},
|
||||
"other": {
|
||||
"other_title": "其他",
|
||||
"auto_rotate": "自動旋轉",
|
||||
"video_orientation": "影片方向",
|
||||
"orientation": "方向",
|
||||
"orientations": {
|
||||
@@ -129,16 +128,21 @@
|
||||
"UNKNOWN": "未知"
|
||||
},
|
||||
"safe_area_in_controls": "控制中的安全區域",
|
||||
"video_player": "Video player",
|
||||
"video_players": {
|
||||
"VLC_3": "VLC 3",
|
||||
"VLC_4": "VLC 4 (Experimental + PiP)"
|
||||
},
|
||||
"show_custom_menu_links": "顯示自定義菜單鏈接",
|
||||
"hide_libraries": "隱藏媒體庫",
|
||||
"select_liraries_you_want_to_hide": "選擇您想從媒體庫頁面和主頁隱藏的媒體庫。",
|
||||
"disable_haptic_feedback": "禁用觸覺回饋"
|
||||
"disable_haptic_feedback": "禁用觸覺回饋",
|
||||
"default_quality": "預設品質",
|
||||
"max_auto_play_episode_count": "自動播放劇集的最大次數",
|
||||
"disabled": "已停用"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "下載",
|
||||
"download_method": "下載方法",
|
||||
"remux_max_download": "Remux 最大下載",
|
||||
"auto_download": "自動下載",
|
||||
"optimized_versions_server": "Optimized Version 伺服器",
|
||||
"save_button": "保存",
|
||||
"optimized_server": "Optimized Server",
|
||||
@@ -167,7 +171,13 @@
|
||||
"tv_quota_limit": "電視配額限制",
|
||||
"tv_quota_days": "電視配額天數",
|
||||
"reset_jellyseerr_config_button": "重置 Jellyseerr 配置",
|
||||
"unlimited": "無限制"
|
||||
"unlimited": "無限制",
|
||||
"plus_n_more": "+{{n}} more",
|
||||
"order_by": {
|
||||
"DEFAULT": "Default",
|
||||
"VOTE_COUNT_AND_AVERAGE": "Vote count and average",
|
||||
"POPULARITY": "Popularity"
|
||||
}
|
||||
},
|
||||
"marlin_search": {
|
||||
"enable_marlin_search": "啟用 Marlin 搜索",
|
||||
@@ -194,6 +204,9 @@
|
||||
},
|
||||
"logs": {
|
||||
"logs_title": "日誌",
|
||||
"export_logs": "Export logs",
|
||||
"click_for_more_info": "Click for more info",
|
||||
"level": "Level",
|
||||
"no_logs_available": "無可用日誌",
|
||||
"delete_all_logs": "刪除所有日誌"
|
||||
},
|
||||
@@ -212,6 +225,10 @@
|
||||
"invalid_url": "無效的 URL"
|
||||
}
|
||||
},
|
||||
"sessions": {
|
||||
"title": "會話",
|
||||
"no_active_sessions": "沒有使用中的會話"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "下載",
|
||||
"tvseries": "電視劇",
|
||||
@@ -322,6 +339,8 @@
|
||||
"years": "年份",
|
||||
"sort_by": "排序依據",
|
||||
"sort_order": "排序順序",
|
||||
"asc": "Ascending",
|
||||
"desc": "Descending",
|
||||
"tags": "標籤"
|
||||
}
|
||||
},
|
||||
@@ -331,7 +350,9 @@
|
||||
"episodes": "劇集",
|
||||
"videos": "影片",
|
||||
"boxsets": "套裝",
|
||||
"playlists": "播放列表"
|
||||
"playlists": "播放列表",
|
||||
"noDataTitle": "尚無收藏",
|
||||
"noData": "將項目標記為收藏,它們將顯示在此處以便快速訪問。"
|
||||
},
|
||||
"custom_links": {
|
||||
"no_links": "無鏈接"
|
||||
@@ -342,7 +363,7 @@
|
||||
"an_error_occured_while_playing_the_video": "播放影片時發生錯誤。請檢查設置中的日誌。",
|
||||
"client_error": "客戶端錯誤",
|
||||
"could_not_create_stream_for_chromecast": "無法為 Chromecast 建立串流",
|
||||
"message_from_server": "來自伺服器的消息:{{message}}",
|
||||
"message_from_server": "來自伺服器的消息",
|
||||
"video_has_finished_playing": "影片播放完畢!",
|
||||
"no_video_source": "無影片來源...",
|
||||
"next_episode": "下一集",
|
||||
@@ -351,7 +372,9 @@
|
||||
"audio_tracks": "音頻軌道:",
|
||||
"playback_state": "播放狀態:",
|
||||
"no_data_available": "無可用數據",
|
||||
"index": "索引:"
|
||||
"index": "索引:",
|
||||
"continue_watching": "繼續觀看",
|
||||
"go_back": "返回"
|
||||
},
|
||||
"item_card": {
|
||||
"next_up": "下一個",
|
||||
@@ -432,7 +455,7 @@
|
||||
"tags": "標籤",
|
||||
"quality_profile": "質量配置文件",
|
||||
"root_folder": "根文件夾",
|
||||
"season_x": "第 {{seasons}} 季",
|
||||
"season_all": "Season (all)",
|
||||
"season_number": "第 {{season_number}} 季",
|
||||
"number_episodes": "{{episode_number}} 集",
|
||||
"born": "出生",
|
||||
|
||||
@@ -2,9 +2,43 @@
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "react",
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"incremental": true,
|
||||
"tsBuildInfoFile": ".tsbuildinfo",
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
|
||||
"include": [
|
||||
"app/**/*",
|
||||
"assets/**/*",
|
||||
"components/**/*",
|
||||
"constants/**/*",
|
||||
"hooks/**/*",
|
||||
"modules/**/*",
|
||||
"packages/**/*",
|
||||
"plugins/**/*",
|
||||
"providers/**/*",
|
||||
"scripts/**/*",
|
||||
"translations/**/*",
|
||||
"*.ts",
|
||||
"*.tsx",
|
||||
".expo/types/**/*.ts",
|
||||
"expo-env.d.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"babel.config.js",
|
||||
"metro.config.js",
|
||||
"utils/jellyseerr/**/*"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { storage } from "../mmkv";
|
||||
import { Platform } from "react-native";
|
||||
import {
|
||||
CultureDto,
|
||||
type BaseItemKind,
|
||||
type CultureDto,
|
||||
type ItemFilter,
|
||||
type ItemSortBy,
|
||||
type SortOrder,
|
||||
SubtitlePlaybackMode,
|
||||
ItemSortBy,
|
||||
SortOrder,
|
||||
BaseItemKind,
|
||||
ItemFilter,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { Bitrate, BITRATES } from "@/components/BitrateSelector";
|
||||
import { atom, useAtom, useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { BITRATES, type Bitrate } from "@/components/BitrateSelector";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { writeInfoLog } from "@/utils/log";
|
||||
import { storage } from "../mmkv";
|
||||
|
||||
const STREAMYFIN_PLUGIN_ID = "1e9e5d386e6746158719e98a5c34f004";
|
||||
const _STREAMYFIN_PLUGIN_ID = "1e9e5d386e6746158719e98a5c34f004";
|
||||
const STREAMYFIN_PLUGIN_SETTINGS = "STREAMYFIN_PLUGIN_SETTINGS";
|
||||
|
||||
export type DownloadQuality = "original" | "high" | "low";
|
||||
@@ -26,7 +25,7 @@ export type DownloadOption = {
|
||||
};
|
||||
|
||||
export const ScreenOrientationEnum: Record<
|
||||
ScreenOrientation.OrientationLock,
|
||||
(typeof ScreenOrientation.OrientationLock)[keyof typeof ScreenOrientation.OrientationLock],
|
||||
string
|
||||
> = {
|
||||
[ScreenOrientation.OrientationLock.DEFAULT]:
|
||||
@@ -79,19 +78,17 @@ export type DefaultLanguageOption = {
|
||||
label: string;
|
||||
};
|
||||
|
||||
export enum DownloadMethod {
|
||||
Remux = "remux",
|
||||
Optimized = "optimized",
|
||||
}
|
||||
|
||||
export type Home = {
|
||||
sections: Array<HomeSection>;
|
||||
};
|
||||
|
||||
export type HomeSection = {
|
||||
title?: string;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
items?: HomeSectionItemResolver;
|
||||
nextUp?: HomeSectionNextUpResolver;
|
||||
latest?: HomeSectionLatestResolver;
|
||||
custom?: HomeSectionCustomEndpointResolver;
|
||||
};
|
||||
|
||||
export type HomeSectionItemResolver = {
|
||||
@@ -105,6 +102,13 @@ export type HomeSectionItemResolver = {
|
||||
filters?: Array<ItemFilter>;
|
||||
};
|
||||
|
||||
export type HomeSectionCustomEndpointResolver = {
|
||||
title?: string;
|
||||
endpoint: string;
|
||||
headers?: any;
|
||||
query?: any;
|
||||
};
|
||||
|
||||
export type HomeSectionNextUpResolver = {
|
||||
parentId?: string;
|
||||
limit?: number;
|
||||
@@ -112,16 +116,47 @@ export type HomeSectionNextUpResolver = {
|
||||
enableRewatching?: boolean;
|
||||
};
|
||||
|
||||
export interface MaxAutoPlayEpisodeCount {
|
||||
key: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export type HomeSectionLatestResolver = {
|
||||
parentId?: string;
|
||||
limit?: number;
|
||||
groupItems?: boolean;
|
||||
isPlayed?: boolean;
|
||||
includeItemTypes?: Array<BaseItemKind>;
|
||||
};
|
||||
|
||||
// Video player enum - currently only MPV is supported
|
||||
export enum VideoPlayer {
|
||||
MPV = 0,
|
||||
}
|
||||
|
||||
// Audio transcoding mode - controls how surround audio is handled
|
||||
// This controls server-side transcoding behavior for audio streams.
|
||||
// MPV decodes via FFmpeg and supports most formats, but mobile devices
|
||||
// can't passthrough to external receivers, so this primarily affects
|
||||
// bandwidth usage and server load.
|
||||
export enum AudioTranscodeMode {
|
||||
Auto = "auto", // Platform defaults (recommended)
|
||||
ForceStereo = "stereo", // Always transcode to stereo
|
||||
Allow51 = "5.1", // Allow up to 5.1, transcode 7.1+
|
||||
AllowAll = "passthrough", // Direct play all audio formats
|
||||
}
|
||||
|
||||
export type Settings = {
|
||||
home?: Home | null;
|
||||
autoRotate?: boolean;
|
||||
forceLandscapeInVideoPlayer?: boolean;
|
||||
deviceProfile?: "Expo" | "Native" | "Old";
|
||||
mediaListCollectionIds?: string[];
|
||||
preferedLanguage?: string;
|
||||
searchEngine: "Marlin" | "Jellyfin";
|
||||
searchEngine: "Marlin" | "Jellyfin" | "Streamystats";
|
||||
marlinServerUrl?: string;
|
||||
openInVLC?: boolean;
|
||||
streamyStatsServerUrl?: string;
|
||||
streamyStatsMovieRecommendations?: boolean;
|
||||
streamyStatsSeriesRecommendations?: boolean;
|
||||
streamyStatsPromotedWatchlists?: boolean;
|
||||
downloadQuality?: DownloadOption;
|
||||
defaultBitrate?: Bitrate;
|
||||
libraryOptions: LibraryOptions;
|
||||
@@ -132,20 +167,50 @@ export type Settings = {
|
||||
subtitleMode: SubtitlePlaybackMode;
|
||||
rememberSubtitleSelections: boolean;
|
||||
showHomeTitles: boolean;
|
||||
defaultVideoOrientation: ScreenOrientation.OrientationLock;
|
||||
defaultVideoOrientation: (typeof ScreenOrientation.OrientationLock)[keyof typeof ScreenOrientation.OrientationLock];
|
||||
forwardSkipTime: number;
|
||||
rewindSkipTime: number;
|
||||
optimizedVersionsServerUrl?: string | null;
|
||||
downloadMethod: DownloadMethod;
|
||||
autoDownload: boolean;
|
||||
showCustomMenuLinks: boolean;
|
||||
disableHapticFeedback: boolean;
|
||||
subtitleSize: number;
|
||||
remuxConcurrentLimit: 1 | 2 | 3 | 4;
|
||||
safeAreaInControlsEnabled: boolean;
|
||||
jellyseerrServerUrl?: string;
|
||||
useKefinTweaks: boolean;
|
||||
hiddenLibraries?: string[];
|
||||
enableH265ForChromecast: boolean;
|
||||
maxAutoPlayEpisodeCount: MaxAutoPlayEpisodeCount;
|
||||
autoPlayEpisodeCount: number;
|
||||
autoPlayNextEpisode: boolean;
|
||||
// Playback speed settings
|
||||
defaultPlaybackSpeed: number;
|
||||
playbackSpeedPerMedia: Record<string, number>;
|
||||
playbackSpeedPerShow: Record<string, number>;
|
||||
// MPV subtitle settings
|
||||
mpvSubtitleScale?: number;
|
||||
mpvSubtitleMarginY?: number;
|
||||
mpvSubtitleAlignX?: "left" | "center" | "right";
|
||||
mpvSubtitleAlignY?: "top" | "center" | "bottom";
|
||||
mpvSubtitleFontSize?: number;
|
||||
// Gesture controls
|
||||
enableHorizontalSwipeSkip: boolean;
|
||||
enableLeftSideBrightnessSwipe: boolean;
|
||||
enableRightSideVolumeSwipe: boolean;
|
||||
hideVolumeSlider: boolean;
|
||||
hideBrightnessSlider: boolean;
|
||||
usePopularPlugin: boolean;
|
||||
showLargeHomeCarousel: boolean;
|
||||
mergeNextUpAndContinueWatching: boolean;
|
||||
// Appearance
|
||||
hideRemoteSessionButton: boolean;
|
||||
hideWatchlistsTab: boolean;
|
||||
// Audio look-ahead caching
|
||||
audioLookaheadEnabled: boolean;
|
||||
audioLookaheadCount: number;
|
||||
audioMaxCacheSizeMB: number;
|
||||
// Music playback
|
||||
preferLocalAudio: boolean;
|
||||
// Audio transcoding mode
|
||||
audioTranscodeMode: AudioTranscodeMode;
|
||||
};
|
||||
|
||||
export interface Lockable<T> {
|
||||
@@ -160,16 +225,17 @@ export type StreamyfinPluginConfig = {
|
||||
settings: PluginLockableSettings;
|
||||
};
|
||||
|
||||
const defaultValues: Settings = {
|
||||
export const defaultValues: Settings = {
|
||||
home: null,
|
||||
autoRotate: true,
|
||||
forceLandscapeInVideoPlayer: false,
|
||||
deviceProfile: "Expo",
|
||||
mediaListCollectionIds: [],
|
||||
preferedLanguage: undefined,
|
||||
searchEngine: "Jellyfin",
|
||||
marlinServerUrl: "",
|
||||
openInVLC: false,
|
||||
streamyStatsServerUrl: "",
|
||||
streamyStatsMovieRecommendations: false,
|
||||
streamyStatsSeriesRecommendations: false,
|
||||
streamyStatsPromotedWatchlists: false,
|
||||
downloadQuality: DownloadOptions[0],
|
||||
defaultBitrate: BITRATES[0],
|
||||
libraryOptions: {
|
||||
@@ -189,17 +255,47 @@ const defaultValues: Settings = {
|
||||
defaultVideoOrientation: ScreenOrientation.OrientationLock.DEFAULT,
|
||||
forwardSkipTime: 30,
|
||||
rewindSkipTime: 10,
|
||||
optimizedVersionsServerUrl: null,
|
||||
downloadMethod: DownloadMethod.Remux,
|
||||
autoDownload: false,
|
||||
showCustomMenuLinks: false,
|
||||
disableHapticFeedback: false,
|
||||
subtitleSize: Platform.OS === "ios" ? 60 : 100,
|
||||
remuxConcurrentLimit: 1,
|
||||
subtitleSize: 100, // Scale value * 100, so 100 = 1.0x
|
||||
safeAreaInControlsEnabled: true,
|
||||
jellyseerrServerUrl: undefined,
|
||||
useKefinTweaks: false,
|
||||
hiddenLibraries: [],
|
||||
enableH265ForChromecast: false,
|
||||
maxAutoPlayEpisodeCount: { key: "3", value: 3 },
|
||||
autoPlayEpisodeCount: 0,
|
||||
autoPlayNextEpisode: true,
|
||||
// Playback speed defaults
|
||||
defaultPlaybackSpeed: 1.0,
|
||||
playbackSpeedPerMedia: {},
|
||||
playbackSpeedPerShow: {},
|
||||
// MPV subtitle defaults
|
||||
mpvSubtitleScale: undefined,
|
||||
mpvSubtitleMarginY: undefined,
|
||||
mpvSubtitleAlignX: undefined,
|
||||
mpvSubtitleAlignY: undefined,
|
||||
mpvSubtitleFontSize: undefined,
|
||||
// Gesture controls
|
||||
enableHorizontalSwipeSkip: true,
|
||||
enableLeftSideBrightnessSwipe: true,
|
||||
enableRightSideVolumeSwipe: true,
|
||||
hideVolumeSlider: false,
|
||||
hideBrightnessSlider: false,
|
||||
usePopularPlugin: true,
|
||||
showLargeHomeCarousel: false,
|
||||
mergeNextUpAndContinueWatching: false,
|
||||
// Appearance
|
||||
hideRemoteSessionButton: false,
|
||||
hideWatchlistsTab: false,
|
||||
// Audio look-ahead caching defaults
|
||||
audioLookaheadEnabled: true,
|
||||
audioLookaheadCount: 1,
|
||||
audioMaxCacheSizeMB: 500,
|
||||
// Music playback
|
||||
preferLocalAudio: true,
|
||||
// Audio transcoding mode
|
||||
audioTranscodeMode: AudioTranscodeMode.Auto,
|
||||
};
|
||||
|
||||
const loadSettings = (): Partial<Settings> => {
|
||||
@@ -211,29 +307,42 @@ const loadSettings = (): Partial<Settings> => {
|
||||
return loadedValues;
|
||||
} catch (error) {
|
||||
console.error("Failed to load settings:", error);
|
||||
return defaultValues;
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const EXCLUDE_FROM_SAVE = ["home"];
|
||||
|
||||
const saveSettings = (settings: Settings) => {
|
||||
Object.keys(settings).forEach((key) => {
|
||||
if (EXCLUDE_FROM_SAVE.includes(key)) {
|
||||
delete settings[key as keyof Settings];
|
||||
try {
|
||||
for (const key of Object.keys(settings)) {
|
||||
if (EXCLUDE_FROM_SAVE.includes(key)) {
|
||||
delete settings[key as keyof Settings];
|
||||
}
|
||||
}
|
||||
});
|
||||
const jsonValue = JSON.stringify(settings);
|
||||
storage.set("settings", jsonValue);
|
||||
const jsonValue = JSON.stringify(settings);
|
||||
storage.set("settings", jsonValue);
|
||||
} catch (error) {
|
||||
console.error("Failed to save settings:", error);
|
||||
}
|
||||
};
|
||||
|
||||
export const settingsAtom = atom<Partial<Settings> | null>(null);
|
||||
export const pluginSettingsAtom = atom(
|
||||
storage.get<PluginLockableSettings>(STREAMYFIN_PLUGIN_SETTINGS)
|
||||
const loadPluginSettings = () => {
|
||||
try {
|
||||
return storage.get<PluginLockableSettings>(STREAMYFIN_PLUGIN_SETTINGS);
|
||||
} catch (error) {
|
||||
console.error("Failed to load plugin settings:", error);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export const pluginSettingsAtom = atom<PluginLockableSettings | undefined>(
|
||||
loadPluginSettings(),
|
||||
);
|
||||
|
||||
export const useSettings = () => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const api = useAtomValue(apiAtom);
|
||||
const [_settings, setSettings] = useAtom(settingsAtom);
|
||||
const [pluginSettings, _setPluginSettings] = useAtom(pluginSettingsAtom);
|
||||
|
||||
@@ -249,29 +358,80 @@ export const useSettings = () => {
|
||||
storage.setAny(STREAMYFIN_PLUGIN_SETTINGS, settings);
|
||||
_setPluginSettings(settings);
|
||||
},
|
||||
[_setPluginSettings]
|
||||
[_setPluginSettings],
|
||||
);
|
||||
|
||||
const refreshStreamyfinPluginSettings = useCallback(async () => {
|
||||
if (!api) return;
|
||||
const settings = await api.getStreamyfinPluginConfig().then(
|
||||
({ data }) => {
|
||||
writeInfoLog(`Got remote settings: ${data?.settings}`);
|
||||
return data?.settings;
|
||||
},
|
||||
(err) => undefined
|
||||
);
|
||||
setPluginSettings(settings);
|
||||
return settings;
|
||||
}, [api]);
|
||||
const refreshStreamyfinPluginSettings = useCallback(
|
||||
async (forceOverride = false) => {
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
const newPluginSettings = await api.getStreamyfinPluginConfig().then(
|
||||
({ data }) => {
|
||||
writeInfoLog("Got plugin settings", data?.settings);
|
||||
return data?.settings;
|
||||
},
|
||||
(_err) => undefined,
|
||||
);
|
||||
setPluginSettings(newPluginSettings);
|
||||
|
||||
// Apply plugin values to settings
|
||||
if (newPluginSettings && _settings) {
|
||||
const updates: Partial<Settings> = {};
|
||||
for (const [key, setting] of Object.entries(newPluginSettings)) {
|
||||
if (setting && !setting.locked && setting.value !== undefined) {
|
||||
const settingsKey = key as keyof Settings;
|
||||
// Apply if forceOverride is true, or if user hasn't explicitly set this value
|
||||
if (
|
||||
forceOverride ||
|
||||
_settings[settingsKey] === undefined ||
|
||||
_settings[settingsKey] === ""
|
||||
) {
|
||||
(updates as any)[settingsKey] = setting.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-enable Streamystats if server URL is provided
|
||||
const streamyStatsUrl = newPluginSettings.streamyStatsServerUrl;
|
||||
if (
|
||||
streamyStatsUrl?.value &&
|
||||
_settings.searchEngine !== "Streamystats"
|
||||
) {
|
||||
updates.searchEngine = "Streamystats";
|
||||
}
|
||||
if (Object.keys(updates).length > 0) {
|
||||
const newSettings = {
|
||||
...defaultValues,
|
||||
..._settings,
|
||||
...updates,
|
||||
} as Settings;
|
||||
setSettings(newSettings);
|
||||
saveSettings(newSettings);
|
||||
}
|
||||
}
|
||||
|
||||
return newPluginSettings;
|
||||
},
|
||||
[api, _settings],
|
||||
);
|
||||
|
||||
const updateSettings = (update: Partial<Settings>) => {
|
||||
if (settings) {
|
||||
const newSettings = { ..._settings, ...update };
|
||||
if (!_settings) {
|
||||
return;
|
||||
}
|
||||
const hasChanges = Object.entries(update).some(
|
||||
([key, value]) => _settings[key as keyof Settings] !== value,
|
||||
);
|
||||
|
||||
if (hasChanges) {
|
||||
// Merge default settings, current settings, and updates to ensure all required properties exist
|
||||
const newSettings = {
|
||||
...defaultValues,
|
||||
..._settings,
|
||||
...update,
|
||||
} as Settings;
|
||||
setSettings(newSettings);
|
||||
|
||||
// @ts-expect-error
|
||||
saveSettings(newSettings);
|
||||
}
|
||||
};
|
||||
@@ -280,32 +440,29 @@ export const useSettings = () => {
|
||||
// If admin sets locked to false but provides a value,
|
||||
// use user settings first and fallback on admin setting if required.
|
||||
const settings: Settings = useMemo(() => {
|
||||
let unlockedPluginDefaults = {} as Settings;
|
||||
const overrideSettings = Object.entries(pluginSettings || {}).reduce(
|
||||
(acc, [key, setting]) => {
|
||||
if (setting) {
|
||||
const { value, locked } = setting;
|
||||
const unlockedPluginDefaults: Partial<Settings> = {};
|
||||
const overrideSettings = Object.entries(pluginSettings ?? {}).reduce<
|
||||
Partial<Settings>
|
||||
>((acc, [key, setting]) => {
|
||||
if (setting) {
|
||||
const { value, locked } = setting;
|
||||
const settingsKey = key as keyof Settings;
|
||||
|
||||
// Make sure we override default settings with plugin settings when they are not locked.
|
||||
// Admin decided what users defaults should be and grants them the ability to change them too.
|
||||
if (
|
||||
locked === false &&
|
||||
value &&
|
||||
_settings?.[key as keyof Settings] !== value
|
||||
) {
|
||||
unlockedPluginDefaults = Object.assign(unlockedPluginDefaults, {
|
||||
[key as keyof Settings]: value,
|
||||
});
|
||||
}
|
||||
|
||||
acc = Object.assign(acc, {
|
||||
[key]: locked ? value : _settings?.[key as keyof Settings] ?? value,
|
||||
});
|
||||
// Make sure we override default settings with plugin settings when they are not locked.
|
||||
if (
|
||||
!locked &&
|
||||
value !== undefined &&
|
||||
_settings?.[settingsKey] !== value
|
||||
) {
|
||||
(unlockedPluginDefaults as any)[settingsKey] = value;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Settings
|
||||
);
|
||||
|
||||
(acc as any)[settingsKey] = locked
|
||||
? value
|
||||
: (_settings?.[settingsKey] ?? value);
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return {
|
||||
...defaultValues,
|
||||
@@ -314,11 +471,11 @@ export const useSettings = () => {
|
||||
};
|
||||
}, [_settings, pluginSettings]);
|
||||
|
||||
return [
|
||||
return {
|
||||
settings,
|
||||
updateSettings,
|
||||
pluginSettings,
|
||||
setPluginSettings,
|
||||
refreshStreamyfinPluginSettings,
|
||||
] as const;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -3,6 +3,9 @@ export const formatBitrate = (bitrate?: number | null) => {
|
||||
|
||||
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];
|
||||
const i = Number.parseInt(
|
||||
Math.floor(Math.log(bitrate) / Math.log(1000)).toString(),
|
||||
10,
|
||||
);
|
||||
return `${Math.round((bitrate / 1000 ** i) * 100) / 100} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import { SelectedOptions } from "@/components/ItemContent";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { RemoteMediaClient, WebImage } from "react-native-google-cast";
|
||||
import { ticksToSeconds } from "./time";
|
||||
|
||||
export function chromecastLoadMedia({
|
||||
client,
|
||||
item,
|
||||
contentUrl,
|
||||
sessionId,
|
||||
mediaSourceId,
|
||||
images,
|
||||
playbackOptions,
|
||||
}: {
|
||||
client: RemoteMediaClient;
|
||||
item: BaseItemDto;
|
||||
contentUrl: string;
|
||||
sessionId?: string;
|
||||
mediaSourceId?: string;
|
||||
images: WebImage[];
|
||||
playbackOptions?: SelectedOptions;
|
||||
}) {
|
||||
return client.loadMedia({
|
||||
mediaInfo: {
|
||||
contentId: item.Id,
|
||||
contentUrl,
|
||||
contentType: "video/mp4",
|
||||
customData: {
|
||||
playbackOptions,
|
||||
sessionId,
|
||||
mediaSourceId,
|
||||
},
|
||||
metadata:
|
||||
item.Type === "Episode"
|
||||
? {
|
||||
type: "tvShow",
|
||||
title: item.Name || "",
|
||||
episodeNumber: item.IndexNumber || 0,
|
||||
seasonNumber: item.ParentIndexNumber || 0,
|
||||
seriesTitle: item.SeriesName || "",
|
||||
images,
|
||||
}
|
||||
: item.Type === "Movie"
|
||||
? {
|
||||
type: "movie",
|
||||
title: item.Name || "",
|
||||
subtitle: item.Overview || "",
|
||||
images,
|
||||
}
|
||||
: {
|
||||
type: "generic",
|
||||
title: item.Name || "",
|
||||
subtitle: item.Overview || "",
|
||||
images,
|
||||
},
|
||||
},
|
||||
startTime: ticksToSeconds(item.UserData?.PlaybackPositionTicks) || 0,
|
||||
});
|
||||
}
|
||||
@@ -14,12 +14,14 @@ class EventBus {
|
||||
off<T = void>(event: string, callback: Listener<T>): void {
|
||||
if (!this.listeners[event]) return;
|
||||
this.listeners[event] = this.listeners[event].filter(
|
||||
(fn) => fn !== callback
|
||||
(fn) => fn !== callback,
|
||||
);
|
||||
}
|
||||
|
||||
emit<T = void>(event: string, data?: T): void {
|
||||
this.listeners[event]?.forEach((callback) => callback(data));
|
||||
this.listeners[event]?.forEach((callback) => {
|
||||
callback(data);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,105 +1,103 @@
|
||||
// utils/getDefaultPlaySettings.ts
|
||||
import { BITRATES } from "@/components/BitrateSelector";
|
||||
import {
|
||||
/**
|
||||
* getDefaultPlaySettings.ts
|
||||
*
|
||||
* Determines default audio/subtitle tracks and bitrate for playback.
|
||||
*
|
||||
* Two use cases:
|
||||
* 1. INITIAL PLAY: No previous state, uses media defaults + user language preferences
|
||||
* 2. SEQUENTIAL PLAY: Has previous state (e.g., next episode), uses StreamRanker
|
||||
* to find matching tracks in the new media
|
||||
*/
|
||||
|
||||
import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { Settings, useSettings } from "../atoms/settings";
|
||||
import { BITRATES } from "@/components/BitrateSelector";
|
||||
import { type Settings } from "../atoms/settings";
|
||||
import {
|
||||
AudioStreamRanker,
|
||||
StreamRanker,
|
||||
SubtitleStreamRanker,
|
||||
} from "../streamRanker";
|
||||
|
||||
interface PlaySettings {
|
||||
export interface PlaySettings {
|
||||
item: BaseItemDto;
|
||||
bitrate: (typeof BITRATES)[0];
|
||||
mediaSource?: MediaSourceInfo | null;
|
||||
audioIndex?: number | undefined;
|
||||
subtitleIndex?: number | undefined;
|
||||
}
|
||||
|
||||
export interface previousIndexes {
|
||||
audioIndex?: number;
|
||||
subtitleIndex?: number;
|
||||
}
|
||||
|
||||
interface TrackOptions {
|
||||
DefaultAudioStreamIndex: number | undefined;
|
||||
DefaultSubtitleStreamIndex: number | undefined;
|
||||
export interface PreviousIndexes {
|
||||
audioIndex?: number;
|
||||
subtitleIndex?: number;
|
||||
}
|
||||
|
||||
// Used getting default values for the next player.
|
||||
/**
|
||||
* Get default play settings for an item.
|
||||
*
|
||||
* @param item - The media item to play
|
||||
* @param settings - User settings (language preferences, bitrate, etc.)
|
||||
* @param previous - Optional previous track selections to carry over (for sequential play)
|
||||
*/
|
||||
export function getDefaultPlaySettings(
|
||||
item: BaseItemDto,
|
||||
settings: Settings,
|
||||
previousIndexes?: previousIndexes,
|
||||
previousSource?: MediaSourceInfo
|
||||
settings: Settings | null,
|
||||
previous?: { indexes?: PreviousIndexes; source?: MediaSourceInfo },
|
||||
): PlaySettings {
|
||||
if (item.Type === "Program") {
|
||||
return {
|
||||
item,
|
||||
bitrate: BITRATES[0],
|
||||
mediaSource: undefined,
|
||||
audioIndex: undefined,
|
||||
subtitleIndex: undefined,
|
||||
};
|
||||
}
|
||||
const bitrate = settings?.defaultBitrate ?? BITRATES[0];
|
||||
|
||||
// 1. Get first media source
|
||||
// Live TV programs don't have media sources
|
||||
if (item.Type === "Program") {
|
||||
return { item, bitrate };
|
||||
}
|
||||
|
||||
const mediaSource = item.MediaSources?.[0];
|
||||
const streams = mediaSource?.MediaStreams ?? [];
|
||||
|
||||
// 2. Get default or preferred audio
|
||||
const defaultAudioIndex = mediaSource?.DefaultAudioStreamIndex;
|
||||
const preferedAudioIndex = mediaSource?.MediaStreams?.find(
|
||||
(x) => x.Type === "Audio" && x.Language === settings?.defaultAudioLanguage
|
||||
)?.Index;
|
||||
const firstAudioIndex = mediaSource?.MediaStreams?.find(
|
||||
(x) => x.Type === "Audio"
|
||||
)?.Index;
|
||||
// Start with media source defaults
|
||||
let audioIndex = mediaSource?.DefaultAudioStreamIndex;
|
||||
let subtitleIndex = mediaSource?.DefaultSubtitleStreamIndex ?? -1;
|
||||
|
||||
// We prefer the previous track over the default track.
|
||||
let trackOptions: TrackOptions = {
|
||||
DefaultAudioStreamIndex: defaultAudioIndex ?? -1,
|
||||
DefaultSubtitleStreamIndex: mediaSource?.DefaultSubtitleStreamIndex ?? -1,
|
||||
};
|
||||
|
||||
const mediaStreams = mediaSource?.MediaStreams ?? [];
|
||||
if (settings?.rememberSubtitleSelections && previousIndexes) {
|
||||
if (previousIndexes.subtitleIndex !== undefined && previousSource) {
|
||||
const subtitleRanker = new SubtitleStreamRanker();
|
||||
const ranker = new StreamRanker(subtitleRanker);
|
||||
// Try to match previous selections (sequential play)
|
||||
if (previous?.indexes && previous?.source && settings) {
|
||||
if (
|
||||
settings.rememberSubtitleSelections &&
|
||||
previous.indexes.subtitleIndex !== undefined
|
||||
) {
|
||||
const ranker = new StreamRanker(new SubtitleStreamRanker());
|
||||
const result = { DefaultSubtitleStreamIndex: subtitleIndex };
|
||||
ranker.rankStream(
|
||||
previousIndexes.subtitleIndex,
|
||||
previousSource,
|
||||
mediaStreams,
|
||||
trackOptions
|
||||
previous.indexes.subtitleIndex,
|
||||
previous.source,
|
||||
streams,
|
||||
result,
|
||||
);
|
||||
subtitleIndex = result.DefaultSubtitleStreamIndex;
|
||||
}
|
||||
|
||||
if (
|
||||
settings.rememberAudioSelections &&
|
||||
previous.indexes.audioIndex !== undefined
|
||||
) {
|
||||
const ranker = new StreamRanker(new AudioStreamRanker());
|
||||
const result = { DefaultAudioStreamIndex: audioIndex };
|
||||
ranker.rankStream(
|
||||
previous.indexes.audioIndex,
|
||||
previous.source,
|
||||
streams,
|
||||
result,
|
||||
);
|
||||
audioIndex = result.DefaultAudioStreamIndex;
|
||||
}
|
||||
}
|
||||
|
||||
if (settings?.rememberAudioSelections && previousIndexes) {
|
||||
if (previousIndexes.audioIndex !== undefined && previousSource) {
|
||||
const audioRanker = new AudioStreamRanker();
|
||||
const ranker = new StreamRanker(audioRanker);
|
||||
ranker.rankStream(
|
||||
previousIndexes.audioIndex,
|
||||
previousSource,
|
||||
mediaStreams,
|
||||
trackOptions
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Get default bitrate from settings or fallback to max
|
||||
const bitrate = settings.defaultBitrate ?? BITRATES[0];
|
||||
|
||||
return {
|
||||
item,
|
||||
bitrate,
|
||||
mediaSource,
|
||||
audioIndex: trackOptions.DefaultAudioStreamIndex,
|
||||
subtitleIndex: trackOptions.DefaultSubtitleStreamIndex,
|
||||
audioIndex: audioIndex ?? undefined,
|
||||
subtitleIndex: subtitleIndex ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,12 +1,138 @@
|
||||
import native from "@/utils/profiles/native";
|
||||
import { Api } from "@jellyfin/sdk";
|
||||
import {
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
PlaybackInfoResponse,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client/models/base-item-kind";
|
||||
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { Alert } from "react-native";
|
||||
import { generateDownloadProfile } from "@/utils/profiles/download";
|
||||
import type { AudioTranscodeModeType } from "@/utils/profiles/native";
|
||||
|
||||
interface StreamResult {
|
||||
url: string;
|
||||
sessionId: string | null;
|
||||
mediaSource: MediaSourceInfo | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the actual streaming URL - handles both transcoded and direct play logic
|
||||
* Returns only the URL string
|
||||
*/
|
||||
const getPlaybackUrl = (
|
||||
api: Api,
|
||||
itemId: string,
|
||||
mediaSource: MediaSourceInfo | undefined,
|
||||
params: {
|
||||
subtitleStreamIndex?: number;
|
||||
audioStreamIndex?: number;
|
||||
deviceId?: string | null;
|
||||
startTimeTicks?: number;
|
||||
maxStreamingBitrate?: number;
|
||||
userId: string;
|
||||
playSessionId?: string | null;
|
||||
container?: string;
|
||||
static?: string;
|
||||
},
|
||||
): string => {
|
||||
let transcodeUrl = mediaSource?.TranscodingUrl;
|
||||
|
||||
// Handle transcoded URL if available
|
||||
if (transcodeUrl) {
|
||||
// For regular streaming, change subtitle method to HLS for transcoded URL
|
||||
if (params.subtitleStreamIndex === -1) {
|
||||
transcodeUrl = transcodeUrl.replace(
|
||||
"SubtitleMethod=Encode",
|
||||
"SubtitleMethod=Hls",
|
||||
);
|
||||
}
|
||||
|
||||
console.log("Video is being transcoded:", transcodeUrl);
|
||||
return `${api.basePath}${transcodeUrl}`;
|
||||
}
|
||||
|
||||
// Fall back to direct play
|
||||
const streamParams = new URLSearchParams({
|
||||
static: params.static || "true",
|
||||
container: params.container || "mp4",
|
||||
mediaSourceId: mediaSource?.Id || "",
|
||||
subtitleStreamIndex: params.subtitleStreamIndex?.toString() || "",
|
||||
audioStreamIndex: params.audioStreamIndex?.toString() || "",
|
||||
deviceId: params.deviceId || api.deviceInfo.id,
|
||||
api_key: api.accessToken,
|
||||
startTimeTicks: params.startTimeTicks?.toString() || "0",
|
||||
maxStreamingBitrate: params.maxStreamingBitrate?.toString() || "",
|
||||
userId: params.userId,
|
||||
});
|
||||
|
||||
// Add additional parameters if provided
|
||||
if (params.playSessionId) {
|
||||
streamParams.append("playSessionId", params.playSessionId);
|
||||
}
|
||||
|
||||
const directPlayUrl = `${api.basePath}/Videos/${itemId}/stream?${streamParams.toString()}`;
|
||||
|
||||
console.log("Video is being direct played:", directPlayUrl);
|
||||
return directPlayUrl;
|
||||
};
|
||||
|
||||
/** Wrapper around {@link getPlaybackUrl} that applies download-specific transformations */
|
||||
const getDownloadUrl = (
|
||||
api: Api,
|
||||
itemId: string,
|
||||
mediaSource: MediaSourceInfo | undefined,
|
||||
sessionId: string | null | undefined,
|
||||
params: {
|
||||
subtitleStreamIndex?: number;
|
||||
audioStreamIndex?: number;
|
||||
deviceId?: string | null;
|
||||
startTimeTicks?: number;
|
||||
maxStreamingBitrate?: number;
|
||||
userId: string;
|
||||
playSessionId?: string | null;
|
||||
},
|
||||
): StreamResult => {
|
||||
// First, handle download-specific transcoding modifications
|
||||
let downloadMediaSource = mediaSource;
|
||||
if (mediaSource?.TranscodingUrl) {
|
||||
downloadMediaSource = {
|
||||
...mediaSource,
|
||||
TranscodingUrl: mediaSource.TranscodingUrl.replace(
|
||||
"master.m3u8",
|
||||
"stream",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// Get the base URL with download-specific parameters
|
||||
let url = getPlaybackUrl(api, itemId, downloadMediaSource, {
|
||||
...params,
|
||||
container: "ts",
|
||||
static: "false",
|
||||
});
|
||||
|
||||
// If it's a direct play URL, add download-specific parameters
|
||||
if (!mediaSource?.TranscodingUrl) {
|
||||
const urlObj = new URL(url);
|
||||
const downloadParams = {
|
||||
subtitleMethod: "Embed",
|
||||
enableSubtitlesInManifest: "true",
|
||||
allowVideoStreamCopy: "true",
|
||||
allowAudioStreamCopy: "true",
|
||||
};
|
||||
|
||||
Object.entries(downloadParams).forEach(([key, value]) => {
|
||||
urlObj.searchParams.append(key, value);
|
||||
});
|
||||
|
||||
url = urlObj.toString();
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
sessionId: sessionId || null,
|
||||
mediaSource,
|
||||
};
|
||||
};
|
||||
|
||||
export const getStreamUrl = async ({
|
||||
api,
|
||||
@@ -14,23 +140,25 @@ export const getStreamUrl = async ({
|
||||
userId,
|
||||
startTimeTicks = 0,
|
||||
maxStreamingBitrate,
|
||||
sessionData,
|
||||
deviceProfile = native,
|
||||
playSessionId,
|
||||
deviceProfile,
|
||||
audioStreamIndex = 0,
|
||||
subtitleStreamIndex = undefined,
|
||||
mediaSourceId,
|
||||
deviceId,
|
||||
}: {
|
||||
api: Api | null | undefined;
|
||||
item: BaseItemDto | null | undefined;
|
||||
userId: string | null | undefined;
|
||||
startTimeTicks: number;
|
||||
maxStreamingBitrate?: number;
|
||||
sessionData?: PlaybackInfoResponse | null;
|
||||
deviceProfile?: any;
|
||||
playSessionId?: string | null;
|
||||
deviceProfile: any;
|
||||
audioStreamIndex?: number;
|
||||
subtitleStreamIndex?: number;
|
||||
height?: number;
|
||||
mediaSourceId?: string | null;
|
||||
deviceId?: string | null;
|
||||
}): Promise<{
|
||||
url: string | null;
|
||||
sessionId: string | null;
|
||||
@@ -44,9 +172,10 @@ export const getStreamUrl = async ({
|
||||
let mediaSource: MediaSourceInfo | undefined;
|
||||
let sessionId: string | null | undefined;
|
||||
|
||||
if (item.Type === "Program") {
|
||||
// Please do not remove this we need this for live TV to be working correctly.
|
||||
if (item.Type === BaseItemKind.Program) {
|
||||
console.log("Item is of type program...");
|
||||
const res0 = await getMediaInfoApi(api).getPlaybackInfo(
|
||||
const res = await getMediaInfoApi(api).getPlaybackInfo(
|
||||
{
|
||||
userId,
|
||||
itemId: item.ChannelId!,
|
||||
@@ -63,93 +192,135 @@ export const getStreamUrl = async ({
|
||||
data: {
|
||||
deviceProfile,
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
const transcodeUrl = res0.data.MediaSources?.[0].TranscodingUrl;
|
||||
sessionId = res0.data.PlaySessionId || null;
|
||||
|
||||
if (transcodeUrl) {
|
||||
return {
|
||||
url: `${api.basePath}${transcodeUrl}`,
|
||||
sessionId,
|
||||
mediaSource: res0.data.MediaSources?.[0],
|
||||
};
|
||||
}
|
||||
sessionId = res.data.PlaySessionId || null;
|
||||
mediaSource = res.data.MediaSources?.[0];
|
||||
const url = getPlaybackUrl(api, item.ChannelId!, mediaSource, {
|
||||
subtitleStreamIndex,
|
||||
audioStreamIndex,
|
||||
deviceId,
|
||||
startTimeTicks: 0,
|
||||
maxStreamingBitrate,
|
||||
userId,
|
||||
});
|
||||
|
||||
return {
|
||||
url,
|
||||
sessionId: sessionId || null,
|
||||
mediaSource,
|
||||
};
|
||||
}
|
||||
|
||||
const itemId = item.Id;
|
||||
|
||||
const res2 = await getMediaInfoApi(api).getPlaybackInfo(
|
||||
const res = await getMediaInfoApi(api).getPlaybackInfo(
|
||||
{
|
||||
itemId: item.Id!,
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
data: {
|
||||
deviceProfile,
|
||||
userId,
|
||||
maxStreamingBitrate,
|
||||
startTimeTicks,
|
||||
autoOpenLiveStream: true,
|
||||
mediaSourceId,
|
||||
audioStreamIndex,
|
||||
deviceProfile,
|
||||
subtitleStreamIndex,
|
||||
startTimeTicks,
|
||||
isPlayback: true,
|
||||
autoOpenLiveStream: true,
|
||||
maxStreamingBitrate,
|
||||
audioStreamIndex,
|
||||
mediaSourceId,
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (res2.status !== 200) {
|
||||
console.error("Error getting playback info:", res2.status, res2.statusText);
|
||||
if (res.status !== 200) {
|
||||
console.error("Error getting playback info:", res.status, res.statusText);
|
||||
}
|
||||
|
||||
sessionId = res2.data.PlaySessionId || null;
|
||||
sessionId = res.data.PlaySessionId || null;
|
||||
mediaSource = res.data.MediaSources?.[0];
|
||||
|
||||
mediaSource = res2.data.MediaSources?.find(
|
||||
(source: MediaSourceInfo) => source.Id === mediaSourceId
|
||||
);
|
||||
const url = getPlaybackUrl(api, item.Id!, mediaSource, {
|
||||
subtitleStreamIndex,
|
||||
audioStreamIndex,
|
||||
deviceId,
|
||||
startTimeTicks,
|
||||
maxStreamingBitrate,
|
||||
userId,
|
||||
playSessionId: playSessionId || undefined,
|
||||
});
|
||||
|
||||
if (item.MediaType === "Video") {
|
||||
if (mediaSource?.TranscodingUrl) {
|
||||
const urlObj = new URL(api.basePath + mediaSource?.TranscodingUrl); // Create a URL object
|
||||
|
||||
// Get the updated URL
|
||||
const transcodeUrl = urlObj.toString();
|
||||
|
||||
console.log("Video has transcoding URL:", `${transcodeUrl}`);
|
||||
return {
|
||||
url: transcodeUrl,
|
||||
sessionId: sessionId,
|
||||
mediaSource,
|
||||
};
|
||||
} else {
|
||||
const searchParams = new URLSearchParams({
|
||||
playSessionId: sessionData?.PlaySessionId || "",
|
||||
mediaSourceId: mediaSource?.Id || "",
|
||||
static: "true",
|
||||
subtitleStreamIndex: subtitleStreamIndex?.toString() || "",
|
||||
audioStreamIndex: audioStreamIndex?.toString() || "",
|
||||
deviceId: api.deviceInfo.id,
|
||||
api_key: api.accessToken,
|
||||
startTimeTicks: startTimeTicks.toString(),
|
||||
maxStreamingBitrate: maxStreamingBitrate?.toString() || "",
|
||||
userId: userId || "",
|
||||
});
|
||||
|
||||
const directPlayUrl = `${
|
||||
api.basePath
|
||||
}/Videos/${itemId}/stream.mp4?${searchParams.toString()}`;
|
||||
|
||||
console.log("Video is being direct played:", directPlayUrl);
|
||||
|
||||
return {
|
||||
url: directPlayUrl,
|
||||
sessionId: sessionId,
|
||||
mediaSource,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Alert.alert("Error", "Could not play this item");
|
||||
|
||||
return null;
|
||||
return {
|
||||
url,
|
||||
sessionId: sessionId || null,
|
||||
mediaSource,
|
||||
};
|
||||
};
|
||||
|
||||
export const getDownloadStreamUrl = async ({
|
||||
api,
|
||||
item,
|
||||
userId,
|
||||
maxStreamingBitrate,
|
||||
audioStreamIndex = 0,
|
||||
subtitleStreamIndex = undefined,
|
||||
mediaSourceId,
|
||||
deviceId,
|
||||
audioMode = "auto",
|
||||
}: {
|
||||
api: Api | null | undefined;
|
||||
item: BaseItemDto | null | undefined;
|
||||
userId: string | null | undefined;
|
||||
maxStreamingBitrate?: number;
|
||||
audioStreamIndex?: number;
|
||||
subtitleStreamIndex?: number;
|
||||
mediaSourceId?: string | null;
|
||||
deviceId?: string | null;
|
||||
audioMode?: AudioTranscodeModeType;
|
||||
}): Promise<{
|
||||
url: string | null;
|
||||
sessionId: string | null;
|
||||
mediaSource: MediaSourceInfo | undefined;
|
||||
} | null> => {
|
||||
if (!api || !userId || !item?.Id) {
|
||||
console.warn("Missing required parameters for getStreamUrl");
|
||||
return null;
|
||||
}
|
||||
|
||||
const res = await getMediaInfoApi(api).getPlaybackInfo(
|
||||
{
|
||||
itemId: item.Id!,
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
data: {
|
||||
userId,
|
||||
deviceProfile: generateDownloadProfile(audioMode),
|
||||
subtitleStreamIndex,
|
||||
startTimeTicks: 0,
|
||||
isPlayback: true,
|
||||
autoOpenLiveStream: true,
|
||||
maxStreamingBitrate,
|
||||
audioStreamIndex,
|
||||
mediaSourceId,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (res.status !== 200) {
|
||||
console.error("Error getting playback info:", res.status, res.statusText);
|
||||
}
|
||||
|
||||
const sessionId = res.data.PlaySessionId || null;
|
||||
const mediaSource = res.data.MediaSources?.[0];
|
||||
|
||||
return getDownloadUrl(api, item.Id!, mediaSource, sessionId, {
|
||||
subtitleStreamIndex,
|
||||
audioStreamIndex,
|
||||
deviceId,
|
||||
startTimeTicks: 0,
|
||||
maxStreamingBitrate,
|
||||
userId,
|
||||
playSessionId: sessionId || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DeviceProfile } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import type { DeviceProfile } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
|
||||
export const chromecasth265: DeviceProfile = {
|
||||
Name: "Chromecast Video Profile",
|
||||
|
||||
@@ -3,134 +3,202 @@
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
import { Platform } from "react-native";
|
||||
import MediaTypes from "../../constants/MediaTypes";
|
||||
import { getSubtitleProfiles } from "./subtitles";
|
||||
|
||||
/**
|
||||
* Device profile for Native video player
|
||||
* @typedef {"ios" | "android"} PlatformType
|
||||
* @typedef {"mpv"} PlayerType
|
||||
* @typedef {"auto" | "stereo" | "5.1" | "passthrough"} AudioTranscodeModeType
|
||||
*
|
||||
* @typedef {Object} ProfileOptions
|
||||
* @property {PlatformType} [platform] - Target platform
|
||||
* @property {PlayerType} [player] - Video player being used (MPV only)
|
||||
* @property {AudioTranscodeModeType} [audioMode] - Audio transcoding mode
|
||||
*/
|
||||
export default {
|
||||
Name: "1. Vlc Player",
|
||||
MaxStaticBitrate: 999_999_999,
|
||||
MaxStreamingBitrate: 999_999_999,
|
||||
CodecProfiles: [
|
||||
{
|
||||
Type: MediaTypes.Video,
|
||||
Codec: "h264,h265,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1",
|
||||
},
|
||||
{
|
||||
|
||||
/**
|
||||
* Audio direct play profiles for standalone audio items in MPV player.
|
||||
* These define which audio file formats can be played directly without transcoding.
|
||||
*/
|
||||
const getAudioDirectPlayProfile = (platform) => {
|
||||
if (platform === "ios") {
|
||||
// iOS audio formats supported by MPV
|
||||
return {
|
||||
Type: MediaTypes.Audio,
|
||||
Codec: "aac,ac3,eac3,mp3,flac,alac,opus,vorbis,pcm,wma",
|
||||
},
|
||||
],
|
||||
DirectPlayProfiles: [
|
||||
{
|
||||
Type: MediaTypes.Video,
|
||||
Container: "mp4,mkv,avi,mov,flv,ts,m2ts,webm,ogv,3gp,hls",
|
||||
VideoCodec:
|
||||
"h264,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1,avi,mpeg,mpeg2video",
|
||||
AudioCodec: "aac,ac3,eac3,mp3,flac,alac,opus,vorbis,wma",
|
||||
},
|
||||
{
|
||||
Type: MediaTypes.Audio,
|
||||
Container: "mp3,aac,flac,alac,wav,ogg,wma",
|
||||
AudioCodec:
|
||||
"mp3,aac,flac,alac,opus,vorbis,wma,pcm,mpa,wav,ogg,oga,webma,ape",
|
||||
},
|
||||
],
|
||||
TranscodingProfiles: [
|
||||
{
|
||||
Type: MediaTypes.Video,
|
||||
Context: "Streaming",
|
||||
Protocol: "hls",
|
||||
Container: "fmp4",
|
||||
VideoCodec: "h264, hevc",
|
||||
AudioCodec: "aac,mp3,ac3,dts",
|
||||
},
|
||||
{
|
||||
Type: MediaTypes.Audio,
|
||||
Context: "Streaming",
|
||||
Protocol: "http",
|
||||
Container: "mp3",
|
||||
AudioCodec: "mp3",
|
||||
MaxAudioChannels: "2",
|
||||
},
|
||||
],
|
||||
SubtitleProfiles: [
|
||||
// Official formats
|
||||
{ Format: "vtt", Method: "Embed" },
|
||||
{ Format: "vtt", Method: "External" },
|
||||
Container: "mp3,m4a,aac,flac,alac,wav,aiff,caf",
|
||||
AudioCodec: "mp3,aac,alac,flac,opus,pcm",
|
||||
};
|
||||
}
|
||||
|
||||
{ Format: "webvtt", Method: "Embed" },
|
||||
{ Format: "webvtt", Method: "External" },
|
||||
|
||||
{ Format: "srt", Method: "Embed" },
|
||||
{ Format: "srt", Method: "External" },
|
||||
|
||||
{ Format: "subrip", Method: "Embed" },
|
||||
{ Format: "subrip", Method: "External" },
|
||||
|
||||
{ Format: "ttml", Method: "Embed" },
|
||||
{ Format: "ttml", Method: "External" },
|
||||
|
||||
{ Format: "dvbsub", Method: "Embed" },
|
||||
{ Format: "dvdsub", Method: "Encode" },
|
||||
|
||||
{ Format: "ass", Method: "Embed" },
|
||||
{ Format: "ass", Method: "External" },
|
||||
|
||||
{ Format: "idx", Method: "Embed" },
|
||||
{ Format: "idx", Method: "Encode" },
|
||||
|
||||
{ Format: "pgs", Method: "Embed" },
|
||||
{ Format: "pgs", Method: "Encode" },
|
||||
|
||||
{ Format: "pgssub", Method: "Embed" },
|
||||
{ Format: "pgssub", Method: "Encode" },
|
||||
|
||||
{ Format: "ssa", Method: "Embed" },
|
||||
{ Format: "ssa", Method: "External" },
|
||||
|
||||
// Other formats
|
||||
{ Format: "microdvd", Method: "Embed" },
|
||||
{ Format: "microdvd", Method: "External" },
|
||||
|
||||
{ Format: "mov_text", Method: "Embed" },
|
||||
{ Format: "mov_text", Method: "External" },
|
||||
|
||||
{ Format: "mpl2", Method: "Embed" },
|
||||
{ Format: "mpl2", Method: "External" },
|
||||
|
||||
{ Format: "pjs", Method: "Embed" },
|
||||
{ Format: "pjs", Method: "External" },
|
||||
|
||||
{ Format: "realtext", Method: "Embed" },
|
||||
{ Format: "realtext", Method: "External" },
|
||||
|
||||
{ Format: "scc", Method: "Embed" },
|
||||
{ Format: "scc", Method: "External" },
|
||||
|
||||
{ Format: "smi", Method: "Embed" },
|
||||
{ Format: "smi", Method: "External" },
|
||||
|
||||
{ Format: "stl", Method: "Embed" },
|
||||
{ Format: "stl", Method: "External" },
|
||||
|
||||
{ Format: "sub", Method: "Embed" },
|
||||
{ Format: "sub", Method: "External" },
|
||||
|
||||
{ Format: "subviewer", Method: "Embed" },
|
||||
{ Format: "subviewer", Method: "External" },
|
||||
|
||||
{ Format: "teletext", Method: "Embed" },
|
||||
{ Format: "teletext", Method: "Encode" },
|
||||
|
||||
{ Format: "text", Method: "Embed" },
|
||||
{ Format: "text", Method: "External" },
|
||||
|
||||
{ Format: "vplayer", Method: "Embed" },
|
||||
{ Format: "vplayer", Method: "External" },
|
||||
|
||||
{ Format: "xsub", Method: "Embed" },
|
||||
{ Format: "xsub", Method: "External" },
|
||||
],
|
||||
// Android audio formats supported by MPV
|
||||
return {
|
||||
Type: MediaTypes.Audio,
|
||||
Container: "mp3,m4a,aac,ogg,flac,wav,webm,mka",
|
||||
AudioCodec: "mp3,aac,flac,vorbis,opus,pcm",
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Audio codec profiles for standalone audio items in MPV player.
|
||||
* These define codec constraints for audio file playback.
|
||||
*/
|
||||
const getAudioCodecProfile = (platform) => {
|
||||
if (platform === "ios") {
|
||||
// iOS audio codec constraints for MPV
|
||||
return {
|
||||
Type: MediaTypes.Audio,
|
||||
Codec: "aac,ac3,eac3,mp3,flac,alac,opus,pcm",
|
||||
};
|
||||
}
|
||||
|
||||
// Android audio codec constraints for MPV
|
||||
return {
|
||||
Type: MediaTypes.Audio,
|
||||
Codec: "aac,ac3,eac3,mp3,flac,vorbis,opus,pcm",
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the video audio codec configuration based on platform and audio mode.
|
||||
*
|
||||
* MPV (via FFmpeg) can decode all audio codecs including TrueHD and DTS-HD MA.
|
||||
* The audioMode setting only controls the maximum channel count - MPV will
|
||||
* decode and downmix as needed.
|
||||
*
|
||||
* @param {PlatformType} platform
|
||||
* @param {AudioTranscodeModeType} audioMode
|
||||
* @returns {{ directPlayCodec: string, maxAudioChannels: string }}
|
||||
*/
|
||||
const getVideoAudioCodecs = (platform, audioMode) => {
|
||||
// Base codecs
|
||||
const baseCodecs = "aac,mp3,flac,opus,vorbis";
|
||||
|
||||
// Surround codecs
|
||||
const surroundCodecs = "ac3,eac3,dts";
|
||||
|
||||
// Lossless HD codecs - MPV decodes these and downmixes as needed
|
||||
const losslessHdCodecs = "truehd";
|
||||
|
||||
// Platform-specific codecs
|
||||
const platformCodecs = platform === "ios" ? "alac,wma" : "wma";
|
||||
|
||||
// MPV can decode all codecs - only channel count varies by mode
|
||||
const allCodecs = `${baseCodecs},${surroundCodecs},${losslessHdCodecs},${platformCodecs}`;
|
||||
|
||||
switch (audioMode) {
|
||||
case "stereo":
|
||||
// Limit to 2 channels - MPV will decode and downmix
|
||||
return {
|
||||
directPlayCodec: allCodecs,
|
||||
maxAudioChannels: "2",
|
||||
};
|
||||
|
||||
case "5.1":
|
||||
// Limit to 6 channels
|
||||
return {
|
||||
directPlayCodec: allCodecs,
|
||||
maxAudioChannels: "6",
|
||||
};
|
||||
|
||||
case "passthrough":
|
||||
// Allow up to 8 channels - for external DAC/receiver setups
|
||||
return {
|
||||
directPlayCodec: allCodecs,
|
||||
maxAudioChannels: "8",
|
||||
};
|
||||
|
||||
default:
|
||||
// Auto mode: default to 5.1 (6 channels)
|
||||
return {
|
||||
directPlayCodec: allCodecs,
|
||||
maxAudioChannels: "6",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates a device profile for Jellyfin playback.
|
||||
*
|
||||
* @param {ProfileOptions} [options] - Profile configuration options
|
||||
* @returns {Object} Jellyfin device profile
|
||||
*/
|
||||
export const generateDeviceProfile = (options = {}) => {
|
||||
const platform = options.platform || Platform.OS;
|
||||
const audioMode = options.audioMode || "auto";
|
||||
|
||||
const { directPlayCodec, maxAudioChannels } = getVideoAudioCodecs(
|
||||
platform,
|
||||
audioMode,
|
||||
);
|
||||
|
||||
/**
|
||||
* Device profile for MPV player
|
||||
*/
|
||||
const profile = {
|
||||
Name: "1. MPV",
|
||||
MaxStaticBitrate: 999_999_999,
|
||||
MaxStreamingBitrate: 999_999_999,
|
||||
CodecProfiles: [
|
||||
{
|
||||
Type: MediaTypes.Video,
|
||||
Codec: "h264,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1",
|
||||
},
|
||||
{
|
||||
Type: MediaTypes.Video,
|
||||
Codec: "hevc,h265",
|
||||
Conditions: [
|
||||
{
|
||||
Condition: "LessThanEqual",
|
||||
Property: "VideoLevel",
|
||||
Value: "153",
|
||||
IsRequired: false,
|
||||
},
|
||||
{
|
||||
Condition: "NotEquals",
|
||||
Property: "VideoRangeType",
|
||||
Value: "DOVI", //no dolby vision at all
|
||||
IsRequired: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
getAudioCodecProfile(platform),
|
||||
],
|
||||
DirectPlayProfiles: [
|
||||
{
|
||||
Type: MediaTypes.Video,
|
||||
Container: "mp4,mkv,avi,mov,flv,ts,m2ts,webm,ogv,3gp,hls",
|
||||
VideoCodec:
|
||||
"h264,hevc,mpeg4,divx,xvid,wmv,vc1,vp8,vp9,av1,avi,mpeg,mpeg2video",
|
||||
AudioCodec: directPlayCodec,
|
||||
},
|
||||
getAudioDirectPlayProfile(platform),
|
||||
],
|
||||
TranscodingProfiles: [
|
||||
{
|
||||
Type: MediaTypes.Video,
|
||||
Context: "Streaming",
|
||||
Protocol: "hls",
|
||||
Container: "ts",
|
||||
VideoCodec: "h264, hevc",
|
||||
AudioCodec: "aac,mp3,ac3,dts",
|
||||
MaxAudioChannels: maxAudioChannels,
|
||||
},
|
||||
{
|
||||
Type: MediaTypes.Audio,
|
||||
Context: "Streaming",
|
||||
Protocol: "http",
|
||||
Container: "mp3",
|
||||
AudioCodec: "mp3",
|
||||
MaxAudioChannels: "2",
|
||||
},
|
||||
],
|
||||
SubtitleProfiles: getSubtitleProfiles(),
|
||||
};
|
||||
|
||||
return profile;
|
||||
};
|
||||
|
||||
// Default export for backward compatibility
|
||||
export default generateDeviceProfile();
|
||||
|
||||
Reference in New Issue
Block a user