From 304ccd844acc856072b4efa82878f48362c59202 Mon Sep 17 00:00:00 2001 From: Uruk Date: Sat, 17 Jan 2026 19:32:06 +0100 Subject: [PATCH] feat: merge PR #448 - Custom Chromecast Controls - Add custom Chromecast player UI with full controls - Add trickplay support for Chromecast - Add sessions dashboard for administrators - Add progress tracking (supersedes chromecast-fix changes) - Add continue playback from last position - Add skip to next/previous episodes - Add Chromecast settings (H.265 support option) - Add translations (Italian, Japanese, Dutch, Chinese, Turkish) - Improve video player controls and context management --- .github/workflows/main.yml | 39 + .gitignore | 81 +- README.md | 359 +-- app.json | 80 +- app/(auth)/(tabs)/(home)/_layout.tsx | 343 +-- app/(auth)/(tabs)/(home)/index.tsx | 19 +- app/(auth)/(tabs)/(home)/sessions/index.tsx | 387 +-- app/(auth)/(tabs)/(home)/settings.tsx | 129 +- .../jellyseerr/person/[personId].tsx | 94 +- app/(auth)/(tabs)/(search)/_layout.tsx | 34 +- app/(auth)/(tabs)/(search)/index.tsx | 832 ++---- app/(auth)/(tabs)/_layout.tsx | 144 +- app/(auth)/player/_layout.tsx | 56 +- app/(auth)/player/direct-player.tsx | 1186 +++----- app/(auth)/player/google-cast-player.tsx | 184 ++ app/_layout.tsx | 593 ++-- app/login.tsx | 644 ++--- assets/images/adaptive_icon.png | Bin 0 -> 132372 bytes bun.lock | 2474 ++++++++--------- components/AddToFavorites.tsx | 18 +- components/Chromecast.tsx | 7 +- components/ChromecastControls.tsx | 897 ++++++ components/DownloadItem.tsx | 446 ++- components/ItemContent.tsx | 318 ++- components/ItemTechnicalDetails.tsx | 163 +- components/PlayButton.tsx | 398 +-- components/PlayButton.tv.tsx | 95 +- components/SubtitleTrackSelector.tsx | 147 +- components/common/Text.tsx | 27 +- components/common/TouchableItemRouter.tsx | 187 +- components/downloads/ActiveDownloads.tsx | 203 +- components/jellyseerr/DetailFacts.tsx | 91 +- components/jellyseerr/discover/GenreSlide.tsx | 37 +- components/list/ListGroup.tsx | 17 +- components/list/ListItem.tsx | 90 +- components/series/JellyseerrSeasons.tsx | 280 +- components/settings/ChromecastSettings.tsx | 4 +- components/settings/Dashboard.tsx | 17 +- components/settings/DownloadSettings.tsx | 144 +- components/settings/DownloadSettings.tv.tsx | 6 +- components/settings/HomeIndex.tsx | 507 ++++ components/settings/HomeIndex.tv.tsx | 453 +++ components/settings/Jellyseerr.tsx | 74 +- components/settings/OtherSettings.tsx | 246 +- components/video-player/controls/Controls.tsx | 1178 +++++--- .../controls/contexts/VideoContext.tsx | 461 ++- .../controls/dropdown/DropdownView.tsx | 300 +- components/video-player/controls/types.ts | 3 +- eas.json | 33 +- hooks/useDefaultPlaySettings.ts | 59 +- hooks/useFavorite.ts | 197 +- hooks/useJellyseerr.ts | 306 +- hooks/useSessions.ts | 48 +- i18n.ts | 58 +- login.yaml | 6 + .../java/expo/modules/vlcplayer/VLCManager.kt | 38 + .../expo/modules/vlcplayer/VlcPlayerModule.kt | 87 + .../expo/modules/vlcplayer/VlcPlayerView.kt | 466 ++++ modules/vlc-player/expo-module.config.json | 10 + modules/vlc-player/index.ts | 68 + modules/vlc-player/ios/VlcPlayerModule.swift | 71 + modules/vlc-player/ios/VlcPlayerView.swift | 483 ++++ modules/vlc-player/src/VlcPlayer.types.ts | 96 + modules/vlc-player/src/VlcPlayerView.tsx | 135 + .../wifi-ssid/ios/AppLifecycleDelegate.swift | 32 + modules/wifi-ssid/ios/VLCManager.swift | 4 + modules/wifi-ssid/ios/WifiSsid.podspec | 15 +- package.json | 241 +- ...expo+react-native-action-sheet+4.1.0.patch | 18 + plugins/withAndroidManifest.js | 30 +- providers/DownloadProvider.tsx | 842 +++++- providers/DownloadProvider.tv.tsx | 107 + providers/JellyfinProvider.tsx | 348 +-- translations/de.json | 523 +--- translations/en.json | 713 ++--- translations/es.json | 529 +--- translations/fr.json | 630 +---- translations/it.json | 1257 +++------ translations/ja.json | 508 +--- translations/nl.json | 577 +--- translations/tr.json | 506 +--- translations/zh-CN.json | 34 +- translations/zh-TW.json | 43 +- tsconfig.json | 38 +- utils/atoms/settings.ts | 337 +-- utils/bitrate.ts | 7 +- utils/chromecastLoadMedia.ts | 59 + utils/eventBus.ts | 6 +- utils/jellyfin/getDefaultPlaySettings.ts | 132 +- utils/jellyfin/media/getStreamUrl.ts | 331 +-- utils/profiles/chromecasth265.ts | 2 +- utils/profiles/native.js | 314 +-- 92 files changed, 11459 insertions(+), 13377 deletions(-) create mode 100644 .github/workflows/main.yml create mode 100644 app/(auth)/player/google-cast-player.tsx create mode 100644 assets/images/adaptive_icon.png create mode 100644 components/ChromecastControls.tsx create mode 100644 components/settings/HomeIndex.tsx create mode 100644 components/settings/HomeIndex.tv.tsx create mode 100644 login.yaml create mode 100644 modules/vlc-player/android/src/main/java/expo/modules/vlcplayer/VLCManager.kt create mode 100644 modules/vlc-player/android/src/main/java/expo/modules/vlcplayer/VlcPlayerModule.kt create mode 100644 modules/vlc-player/android/src/main/java/expo/modules/vlcplayer/VlcPlayerView.kt create mode 100644 modules/vlc-player/expo-module.config.json create mode 100644 modules/vlc-player/index.ts create mode 100644 modules/vlc-player/ios/VlcPlayerModule.swift create mode 100644 modules/vlc-player/ios/VlcPlayerView.swift create mode 100644 modules/vlc-player/src/VlcPlayer.types.ts create mode 100644 modules/vlc-player/src/VlcPlayerView.tsx create mode 100644 modules/wifi-ssid/ios/AppLifecycleDelegate.swift create mode 100644 modules/wifi-ssid/ios/VLCManager.swift create mode 100644 patches/@expo+react-native-action-sheet+4.1.0.patch create mode 100644 providers/DownloadProvider.tv.tsx create mode 100644 utils/chromecastLoadMedia.ts diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 00000000..474eef39 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,39 @@ +name: Handle Stale Issues +on: + schedule: + - cron: "30 1 * * *" # Runs at 1:30 UTC every day + +jobs: + stale: + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + + steps: + - uses: actions/stale@v9 + with: + # Issue specific settings + days-before-issue-stale: 90 + days-before-issue-close: 7 + stale-issue-label: "stale" + stale-issue-message: | + This issue has been automatically marked as stale because it has had no activity in the last 30 days. + + If this issue is still relevant, please leave a comment to keep it open. + Otherwise, it will be closed in 7 days if no further activity occurs. + + Thank you for your contributions! + close-issue-message: | + This issue has been automatically closed because it has been inactive for 7 days since being marked as stale. + + If you believe this issue is still relevant, please feel free to reopen it and add a comment explaining the current status. + + # Pull request settings (disabled) + days-before-pr-stale: -1 + days-before-pr-close: -1 + + # Other settings + repo-token: ${{ secrets.GITHUB_TOKEN }} + operations-per-run: 100 + exempt-issue-labels: "Roadmap v1,help needed,enhancement" diff --git a/.gitignore b/.gitignore index 2b3bd6ed..2a0ce8db 100644 --- a/.gitignore +++ b/.gitignore @@ -1,16 +1,30 @@ -# Dependencies and Package Managers node_modules/ -bun.lock -bun.lockb -package-lock.json - -# Expo and React Native Build Artifacts .expo/ dist/ +npm-debug.* +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision +*.orig.* web-build/ -.tsbuildinfo +modules/vlc-player/android/build +bun.lockb + +# macOS +.DS_Store + +expo-env.d.ts + +Streamyfin.app + +build-* +*.mp4 +build-* +Streamyfin.app +package-lock.json -# Platform-specific Build Directories /ios /android /iostv @@ -18,58 +32,15 @@ web-build/ /androidmobile /androidtv -# Module-specific Builds -modules/mpv-player/android/build modules/player/android -modules/hls-downloader/android/build -# Generated Applications -Streamyfin.app +pc-api-7079014811501811218-719-3b9f15aeccf8.json +credentials.json *.apk *.ipa -*.aab +.continuerc.json -# 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 -.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/ \ No newline at end of file +modules/hls-downloader/android/build diff --git a/README.md b/README.md index b5b418d2..da4f1eb5 100644 --- a/README.md +++ b/README.md @@ -1,256 +1,99 @@ +# 📺 Streamyfin + Buy Me A Coffee +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. -

- Streamyfin -

- -

- - Streamyfin Discord - -

- -**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.** - ---- - -

- -   - -   - -   - -

- +
+ + + + +
## 🌟 Features -- 🚀 **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 +- 🚀 **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. ## 🧪 Experimental Features -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. +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. -### 📥 Downloading +### Downloading -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. +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. -### 🧩 Streamyfin Plugin +### Chromecast -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: +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. -- 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 + +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... [Streamyfin Plugin](https://github.com/streamyfin/jellyfin-plugin-streamyfin) -### 📡 Chromecast +### Jellysearch -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 +[Jellysearch](https://gitlab.com/DomiStyle/jellysearch) now works with Streamyfin! 🚀 > A fast full-text search proxy for Jellyfin. Integrates seamlessly with most Jellyfin clients. -## 🛣️ Roadmap +## Roadmap for V1 -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. +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. -## 📥 Download Streamyfin +## Get it now
Get Streamyfin on App Store - Get Streamyfin on Google Play Store - Get Streamyfin on Github - Add Streamyfin to Obtainium + Get the beta on Google Play
-### 🧪 Beta Testing +Or download the APKs [here on GitHub](https://github.com/streamyfin/streamyfin/releases) for Android. -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. +### Beta testing -**Note**: Anyone actively contributing to Streamyfin’s source code will receive automatic access to beta releases. +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. ## 🚀 Getting Started -### ⚙️ Prerequisites +### Prerequisites -- 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 +- Ensure you have an active Jellyfin server. +- Make sure your device is connected to the same network as your Jellyfin server. ## 🙌 Contributing -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. +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. -## 🌍 Translations - -[![Crowdin Translation Status](https://badges.crowdin.net/streamyfin/localized.svg)](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 +### 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: - -
- - - - - - - - - - - - - - - - - -
- - -
@Alexk2309 -
-
- - -
@herrrta -
-
- - -
@lostb1t -
-
- - -
@Simon-Eklundh -
-
- - -
@topiga -
-
- - -
@lancechant -
-
- - -
@simoncaron -
-
- - -
@jakequade -
-
- - -
@Ryan0204 -
-
- - -
@retardgerman -
-
- - -
@whoopsi-daisy -
-
- - -
@Gauvino -
-
-
- -## ✨ 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 - -[![Star History Chart](https://api.star-history.com/svg?repos=streamyfin/streamyfin&type=Date)](https://star-history.com/#streamyfin/streamyfin&Date) - ## 📄 License Streamyfin is licensed under the Mozilla Public License 2.0 (MPL-2.0). @@ -263,10 +106,108 @@ 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. -## ⚠️ 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. +## 🌐 Connect with Us -## 🤝 Sponsorship -VPS hosting generously provided by [Hexabyte](https://hexabyte.se/en/vps/?currency=eur) and [SweHosting](https://swehosting.se/en/#tj%C3%A4nster) +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: + + + + + + + + + + + + + + +
+ + +
@Alexk2309 +
+
+ + +
@herrrta +
+
+ + +
@lostb1t +
+
+ + +
@Simon-Eklundh +
+
+ + +
@topiga +
+
+ + +
@simoncaron +
+
+ + +
@jakequade +
+
+ + +
@Ryan0204 +
+
+ + +
@retardgerman +
+
+ + +
@whoopsi-daisy +
+
+ +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 + +[![Star History Chart](https://api.star-history.com/svg?repos=streamyfin/streamyfin&type=Date)](https://star-history.com/#streamyfin/streamyfin&Date) diff --git a/app.json b/app.json index 288f3e3a..140a7f9a 100644 --- a/app.json +++ b/app.json @@ -2,13 +2,12 @@ "expo": { "name": "Streamyfin", "slug": "streamyfin", - "version": "0.52.0", + "version": "0.27.0", "orientation": "default", "icon": "./assets/images/icon.png", "scheme": "streamyfin", "userInterfaceStyle": "dark", "jsEngine": "hermes", - "newArchEnabled": true, "assetBundlePatterns": ["**/*"], "ios": { "requireFullScreen": true, @@ -17,7 +16,6 @@ "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 }, @@ -29,36 +27,39 @@ "usesNonExemptEncryption": false }, "supportsTablet": true, - "entitlements": { - "com.apple.developer.networking.wifi-info": true - }, - "bundleIdentifier": "com.fredrikburmester.streamyfin", - "icon": "./assets/images/icon-ios-liquid-glass.icon", - "appleTeamId": "MWD5K362T8" + "bundleIdentifier": "com.fredrikburmester.streamyfin" }, "android": { "jsEngine": "hermes", - "versionCode": 92, + "versionCode": 53, "adaptiveIcon": { - "foregroundImage": "./assets/images/icon-android-plain.png", - "monochromeImage": "./assets/images/icon-android-themed.png", - "backgroundColor": "#2E2E2E" + "foregroundImage": "./assets/images/adaptive_icon.png" }, "package": "com.fredrikburmester.streamyfin", "permissions": [ "android.permission.FOREGROUND_SERVICE", "android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK", - "android.permission.WRITE_SETTINGS", - "android.permission.ACCESS_FINE_LOCATION" - ], - "blockedPermissions": ["android.permission.ACTIVITY_RECOGNITION"], - "googleServicesFile": "./google-services.json" + "android.permission.WRITE_SETTINGS" + ] }, "plugins": [ "@react-native-tvos/config-tv", "expo-router", "expo-font", - "./plugins/withExcludeMedia3Dash.js", + "@config-plugins/ffmpeg-kit-react-native", + [ + "react-native-video", + { + "enableNotificationControls": true, + "enableBackgroundAudio": true, + "androidExtensions": { + "useExoplayerRtsp": false, + "useExoplayerSmoothStreaming": false, + "useExoplayerHls": true, + "useExoplayerDash": false + } + } + ], [ "expo-build-properties", { @@ -67,12 +68,11 @@ "useFrameworks": "static" }, "android": { - "buildArchs": ["arm64-v8a", "x86_64"], - "compileSdkVersion": 36, + "compileSdkVersion": 35, "targetSdkVersion": 35, "buildToolsVersion": "35.0.0", "kotlinVersion": "2.0.21", - "minSdkVersion": 26, + "minSdkVersion": 24, "usesCleartextTraffic": true, "packagingOptions": { "jniLibs": { @@ -90,6 +90,12 @@ "initialOrientation": "DEFAULT" } ], + [ + "expo-sensors", + { + "motionPermission": "Allow Streamyfin to access your device motion for landscape video watching." + } + ], "expo-localization", "expo-asset", [ @@ -100,32 +106,17 @@ } } ], - [ - "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"], + ["react-native-bottom-tabs"], ["./plugins/withChangeNativeAndroidTextToWhite.js"], ["./plugins/withAndroidManifest.js"], ["./plugins/withTrustLocalCerts.js"], ["./plugins/withGradleProperties.js"], [ - "./plugins/withGitPod.js", + "expo-splash-screen", { - "podName": "MPVKit-GPL", - "podspecUrl": "https://raw.githubusercontent.com/streamyfin/MPVKit/0.40.0-av/MPVKit-GPL.podspec" + "backgroundColor": "#2e2e2e", + "image": "./assets/images/StreamyFinFinal.png", + "imageWidth": 100 } ] ], @@ -140,12 +131,13 @@ "projectId": "e79219d1-797f-4fbe-9fa1-cfd360690a68" } }, - "owner": "streamyfin", + "owner": "fredrikburmester", "runtimeVersion": { "policy": "appVersion" }, "updates": { "url": "https://u.expo.dev/e79219d1-797f-4fbe-9fa1-cfd360690a68" - } + }, + "newArchEnabled": false } } diff --git a/app/(auth)/(tabs)/(home)/_layout.tsx b/app/(auth)/(tabs)/(home)/_layout.tsx index a7e059ed..ddcd6f7b 100644 --- a/app/(auth)/(tabs)/(home)/_layout.tsx +++ b/app/(auth)/(tabs)/(home)/_layout.tsx @@ -1,38 +1,40 @@ -import { Feather, Ionicons } from "@expo/vector-icons"; -import { Stack } from "expo-router"; -import { useTranslation } from "react-i18next"; -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 { Ionicons, Feather } from "@expo/vector-icons"; +import { Stack, useRouter } from "expo-router"; +import { Platform, TouchableOpacity, View } from "react-native"; +import { useTranslation } from "react-i18next"; +const Chromecast = !Platform.isTV ? require("@/components/Chromecast") : null; 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 ( ( - + {!Platform.isTV && ( <> - - {user?.Policy?.IsAdministrator && } + + {user && user.Policy?.IsAdministrator && ( + + )} )} @@ -41,309 +43,83 @@ export default function IndexLayout() { }} /> ( - _router.back()} - className='pl-0.5' - style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} - > - - - ), }} /> + ( - _router.back()} - className='pl-0.5' - style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} - > - - - ), }} /> ( - _router.back()} - className='pl-0.5' - style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} - > - - - ), }} /> ( - _router.back()} - className='pl-0.5' - style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} - > - - - ), + title: "", }} /> ( - _router.back()} - className='pl-0.5' - style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} - > - - - ), + title: "", }} /> ( - _router.back()} - className='pl-0.5' - style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} - > - - - ), + title: t("home.settings.dashboard.sessions_title"), }} /> ( - _router.back()} - className='pl-0.5' - style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} - > - - - ), + title: "", }} /> ( - _router.back()} - className='pl-0.5' - style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} - > - - - ), + title: "", }} /> ( - _router.back()} - className='pl-0.5' - style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} - > - - - ), + title: "", }} /> ( - _router.back()} - className='pl-0.5' - style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} - > - - - ), - }} - /> - ( - _router.back()} - className='pl-0.5' - style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} - > - - - ), - }} - /> - ( - _router.back()} - className='pl-0.5' - style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} - > - - - ), - }} - /> - ( - _router.back()} - className='pl-0.5' - style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} - > - - - ), - }} - /> - ( - _router.back()} - className='pl-0.5' - style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} - > - - - ), - }} - /> - ( - _router.back()} - className='pl-0.5' - style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} - > - - - ), - }} - /> - ( - _router.back()} - className='pl-0.5' - style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} - > - - - ), + headerShown: false, + title: "", + presentation: "modal", }} /> {Object.entries(nestedTabPageScreenOptions).map(([name, options]) => ( ))} ( - _router.back()} className='pl-0.5'> - - - ), headerShown: true, headerBlurEffect: "prominent", - headerTransparent: Platform.OS === "ios", + headerTransparent: Platform.OS === "ios" ? true : false, headerShadowVisible: false, }} /> @@ -355,32 +131,33 @@ const SettingsButton = () => { const router = useRouter(); return ( - { router.push("/(auth)/settings"); }} > - - + + ); }; const SessionsButton = () => { const router = useRouter(); - const { sessions = [] } = useSessions({} as useSessionsProps); + const { sessions = [], _ } = useSessions({} as useSessionsProps); return ( - { router.push("/(auth)/sessions"); }} - className='mr-4' > - - + + + + ); }; diff --git a/app/(auth)/(tabs)/(home)/index.tsx b/app/(auth)/(tabs)/(home)/index.tsx index ad951c36..dc04e43b 100644 --- a/app/(auth)/(tabs)/(home)/index.tsx +++ b/app/(auth)/(tabs)/(home)/index.tsx @@ -1,16 +1,5 @@ -import { useSettings } from "@/utils/atoms/settings"; -import { Home } from "../../../../components/home/Home"; -import { HomeWithCarousel } from "../../../../components/home/HomeWithCarousel"; +import { HomeIndex } from "@/components/settings/HomeIndex"; -const Index = () => { - const { settings } = useSettings(); - const showLargeHomeCarousel = settings.showLargeHomeCarousel ?? false; - - if (showLargeHomeCarousel) { - return ; - } - - return ; -}; - -export default Index; +export default function page() { + return ; +} diff --git a/app/(auth)/(tabs)/(home)/sessions/index.tsx b/app/(auth)/(tabs)/(home)/sessions/index.tsx index 0ed8fc94..8da32bce 100644 --- a/app/(auth)/(tabs)/(home)/sessions/index.tsx +++ b/app/(auth)/(tabs)/(home)/sessions/index.tsx @@ -1,27 +1,25 @@ -import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"; -import { HardwareAccelerationType } from "@jellyfin/sdk/lib/generated-client"; -import { - 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 { 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 Poster from "@/components/posters/Poster"; -import { useInterval } from "@/hooks/useInterval"; -import { useSessions, type useSessionsProps } from "@/hooks/useSessions"; +import { SessionInfoDto } from "@jellyfin/sdk/lib/generated-client"; +import { useAtomValue } from "jotai"; import { apiAtom } from "@/providers/JellyfinProvider"; -import { formatBitrate } from "@/utils/bitrate"; +import Poster from "@/components/posters/Poster"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; +import { useInterval } from "@/hooks/useInterval"; +import React, { useEffect, useMemo, useState } from "react"; import { formatTimeString } from "@/utils/time"; +import { formatBitrate } from "@/utils/bitrate"; +import { + Ionicons, + Entypo, + AntDesign, + MaterialCommunityIcons, +} from "@expo/vector-icons"; +import { Badge } from "@/components/Badge"; export default function page() { const { sessions, isLoading } = useSessions({} as useSessionsProps); @@ -29,15 +27,15 @@ export default function page() { if (isLoading) return ( - + ); - if (!sessions || sessions.length === 0) + if (!sessions || sessions.length == 0) return ( - - + + {t("home.sessions.no_active_sessions")} @@ -45,15 +43,16 @@ export default function page() { return ( } keyExtractor={(item) => item.Id || ""} + estimatedItemSize={200} /> ); } @@ -78,7 +77,7 @@ const SessionCard = ({ session }: SessionCardProps) => { return Math.round( (100 / session.NowPlayingItem?.RunTimeTicks) * - (session.NowPlayingItem?.RunTimeTicks - remainingTicks), + (session.NowPlayingItem?.RunTimeTicks - remainingTicks) ); }; @@ -96,112 +95,26 @@ 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 - >({}); - - 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 ( - - - + + + - - - + + + {session.NowPlayingItem?.Type === "Episode" ? ( <> - + {session.NowPlayingItem?.Name} - + {`S${session.NowPlayingItem.ParentIndexNumber?.toString()}:E${session.NowPlayingItem.IndexNumber?.toString()}`} {" - "} {session.NowPlayingItem.SeriesName} @@ -209,151 +122,48 @@ const SessionCard = ({ session }: SessionCardProps) => { ) : ( <> - + {session.NowPlayingItem?.Name} - + {session.NowPlayingItem?.ProductionYear} - + {session.NowPlayingItem?.SeriesName} )} - + {session.UserName} {"\n"} {session.Client} {"\n"} {session.DeviceName} - {"\n"} - {ipInfo?.cityName} {ipInfo?.countryCode} - - - - + + + + {!session.PlayState?.IsPaused ? ( - + ) : ( - + )} - + {formatTimeString(remainingTicks, "tick")} left - + - - {/* Session controls */} - - - - - - - {session.PlayState?.IsPaused ? ( - - ) : ( - - )} - - - - - - - - - - - - - - - - - - - - - - @@ -368,21 +178,20 @@ interface TranscodingBadgesProps { const TranscodingBadges = ({ properties }: TranscodingBadgesProps) => { const iconMap = { - bitrate: , - codec: , + bitrate: , + codec: , videoRange: ( - + ), - resolution: , - language: , - audioChannels: , - hwType: , + resolution: , + language: , + audioChannels: , } as const; const icon = (val: string) => { return ( iconMap[val as keyof typeof iconMap] ?? ( - + ) ); }; @@ -391,8 +200,6 @@ const TranscodingBadges = ({ properties }: TranscodingBadgesProps) => { switch (key) { case "bitrate": return formatBitrate(val); - case "hwType": - return val === HardwareAccelerationType.None ? "sw" : "hw"; default: return val; } @@ -403,8 +210,8 @@ const TranscodingBadges = ({ properties }: TranscodingBadgesProps) => { .map(([key]) => ( @@ -412,7 +219,6 @@ const TranscodingBadges = ({ properties }: TranscodingBadgesProps) => { }; interface StreamProps { - hwType?: HardwareAccelerationType | null | undefined; resolution?: string | null | undefined; language?: string | null | undefined; codec?: string | null | undefined; @@ -424,7 +230,7 @@ interface StreamProps { interface TranscodingStreamViewProps { title: string | undefined; value?: string; - isTranscoding: boolean; + isTranscoding: Boolean; transcodeValue?: string | undefined | null; properties: StreamProps; transcodeProperties?: StreamProps; @@ -435,40 +241,43 @@ const TranscodingStreamView = ({ isTranscoding, properties, transcodeProperties, + value, + transcodeValue, }: TranscodingStreamViewProps) => { return ( - - - + + + {title} - + {isTranscoding && transcodeProperties ? ( - - - - - - - - + <> + + + + + + + + + ) : null} ); }; 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]); @@ -487,36 +296,35 @@ const TranscodingView = ({ session }: SessionCardProps) => { }, [session.PlayState?.SubtitleStreamIndex]); const isTranscoding = useMemo(() => { - return ( - session.PlayState?.PlayMethod === "Transcode" && session.TranscodingInfo - ); - }, [session.PlayState?.PlayMethod, session.TranscodingInfo]); + return session.PlayState?.PlayMethod == "Transcode"; + }, [session.PlayState?.PlayMethod]); const videoStreamTitle = () => { return videoStream?.DisplayTitle?.split(" ")[0]; }; return ( - + { audioChannels: audioStream?.ChannelLayout, }} transcodeProperties={{ + bitrate: session.TranscodingInfo?.Bitrate, codec: session.TranscodingInfo?.AudioCodec, audioChannels: session.TranscodingInfo?.AudioChannels?.toString(), }} isTranscoding={ - !!(isTranscoding && !session.TranscodingInfo?.IsVideoDirect) + isTranscoding && !session.TranscodingInfo?.IsVideoDirect + ? true + : false } /> {subtitleStream && ( - + <> + + )} ); diff --git a/app/(auth)/(tabs)/(home)/settings.tsx b/app/(auth)/(tabs)/(home)/settings.tsx index 76675ae8..4fe3b0cb 100644 --- a/app/(auth)/(tabs)/(home)/settings.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tsx @@ -1,24 +1,41 @@ -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 useRouter from "@/hooks/useAppRouter"; -import { useJellyfin, userAtom } from "@/providers/JellyfinProvider"; +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"; 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(() => { @@ -29,7 +46,7 @@ export default function settings() { logout(); }} > - + {t("home.settings.log_out_button")} @@ -39,72 +56,64 @@ export default function settings() { return ( - - - - + + - + - - - + + + + + - - - router.push("/settings/playback-controls/page")} - showArrow - title={t("home.settings.playback_controls.title")} - /> - router.push("/settings/audio-subtitles/page")} - showArrow - title={t("home.settings.audio_subtitles.title")} - /> - router.push("/settings/music/page")} - showArrow - title={t("home.settings.music.title")} - /> - router.push("/settings/appearance/page")} - showArrow - title={t("home.settings.appearance.title")} - /> - router.push("/settings/plugins/page")} - showArrow - title={t("home.settings.plugins.plugins_title")} - /> - router.push("/settings/intro/page")} - showArrow - title={t("home.settings.intro.title")} - /> - router.push("/settings/network/page")} - showArrow - title={t("home.settings.network.title")} - /> + + + + + + + + + + + + { + router.push("/intro/page"); + }} + title={t("home.settings.intro.show_intro")} + /> + { + storage.set("hasShownIntro", false); + }} + title={t("home.settings.intro.reset_intro")} + /> + + + + router.push("/settings/logs/page")} showArrow title={t("home.settings.logs.logs_title")} /> + - {!Platform.isTV && } + ); diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/person/[personId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/person/[personId].tsx index a29e1280..bd0fc216 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/person/[personId].tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/jellyseerr/person/[personId].tsx @@ -1,29 +1,29 @@ +import { + useLocalSearchParams, + useSegments, +} from "expo-router"; +import React, { useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; -import { Image } from "expo-image"; -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"; +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 { useTranslation } from "react-i18next"; export default function page() { const local = useLocalSearchParams(); const { t } = useTranslation(); - const { - jellyseerrApi, - jellyseerrRegion: region, - jellyseerrLocale: locale, - } = useJellyseerr(); + const { jellyseerrApi, jellyseerrUser, jellyseerrRegion: region, jellyseerrLocale: locale } = useJellyseerr(); const { personId } = local as { personId: string }; - const { data } = useQuery({ + const { data, isLoading, isFetching } = useQuery({ queryKey: ["jellyseerr", "person", personId], queryFn: async () => ({ details: await jellyseerrApi?.personDetails(personId), @@ -34,27 +34,18 @@ 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 ( @@ -67,15 +58,15 @@ export default function page() { ( <> - {data?.details?.name} - + + {data?.details?.name} + + {t("jellyseerr.born")}{" "} - {data?.details?.birthday && - new Date(data.details.birthday).toLocaleDateString( - `${locale}-${region}`, - { - year: "numeric", - month: "long", - day: "numeric", - }, - )}{" "} + {new Date(data?.details?.birthday!!).toLocaleDateString( + `${locale}-${region}`, + { + year: "numeric", + month: "long", + day: "numeric", + } + )}{" "} | {data?.details?.placeOfBirth} )} MainContent={() => ( - + )} - renderItem={(item, _index) => } + renderItem={(item, index) => } /> ); } diff --git a/app/(auth)/(tabs)/(search)/_layout.tsx b/app/(auth)/(tabs)/(search)/_layout.tsx index f4d34978..ce787316 100644 --- a/app/(auth)/(tabs)/(search)/_layout.tsx +++ b/app/(auth)/(tabs)/(search)/_layout.tsx @@ -1,22 +1,26 @@ -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 ( @@ -24,26 +28,26 @@ export default function SearchLayout() { ))} - + diff --git a/app/(auth)/(tabs)/(search)/index.tsx b/app/(auth)/(tabs)/(search)/index.tsx index 751b1df1..6d1ac344 100644 --- a/app/(auth)/(tabs)/(search)/index.tsx +++ b/app/(auth)/(tabs)/(search)/index.tsx @@ -1,48 +1,39 @@ -import type { +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 { BaseItemDto, BaseItemKind, } from "@jellyfin/sdk/lib/generated-client/models"; -import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; -import { useAsyncDebouncer } from "@tanstack/react-pacer"; +import { getItemsApi, getSearchApi } from "@jellyfin/sdk/lib/utils/api"; import { useQuery } from "@tanstack/react-query"; import axios from "axios"; -import { Image } from "expo-image"; -import { useLocalSearchParams, useNavigation } from "expo-router"; +import { Href, router, useLocalSearchParams, useNavigation } from "expo-router"; import { useAtom } from "jotai"; -import { +import React, { 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 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 { useDebounce } from "use-debounce"; +import { useTranslation } from "react-i18next"; import { eventBus } from "@/utils/eventBus"; -import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; -import { createStreamystatsApi } from "@/utils/streamystats"; type SearchType = "Library" | "Discover"; @@ -58,218 +49,77 @@ 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("Library"); const [search, setSearch] = useState(""); - const [debouncedSearch, setDebouncedSearch] = useState(""); - const abortControllerRef = useRef(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 [debouncedSearch] = useDebounce(search, 500); const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); - const { settings } = useSettings(); + const [settings] = useSettings(); const { jellyseerrApi } = useJellyseerr(); - const [jellyseerrOrderBy, setJellyseerrOrderBy] = - useState( - 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 => { - if (!api || !query) { - return []; - } + if (!api || !query) return []; try { if (searchEngine === "Jellyfin") { - 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 = { - Movie: "movies", - Series: "series", - Episode: "episodes", - Person: "actors", - BoxSet: "movies", - Audio: "audio", - } as Record; - - 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 => { - if (!api || !query) { - return []; - } - - try { - const searchApi = await getItemsApi(api).getItems( - { + const searchApi = await getSearchApi(api).getSearchHints({ searchTerm: query, limit: 10, includeItemTypes: types, - recursive: true, - userId: user?.Id, - }, - { signal }, - ); + }); - return (searchApi.data.Items as BaseItemDto[]) || []; - } catch (error) { - // Silently handle aborted requests - if (error instanceof Error && error.name === "AbortError") { - return []; + 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 []; + } catch (error) { + console.error("Error during search:", error); + return []; // Ensure an empty array is returned in case of an error } }, - [api, user?.Id], + [api, searchEngine, settings] ); type HeaderSearchBarRef = { @@ -299,10 +149,8 @@ export default function search() { useEffect(() => { const unsubscribe = eventBus.on("searchTabPressed", () => { - // Screen not active - if (!searchBarRef.current) { - return; - } + // Screen not actuve + if (!searchBarRef.current) return; // Screen is active, focus search bar searchBarRef.current?.focus(); }); @@ -318,7 +166,6 @@ export default function search() { searchFn({ query: debouncedSearch, types: ["Movie"], - signal: abortControllerRef.current?.signal, }), enabled: searchType === "Library" && debouncedSearch.length > 0, }); @@ -329,7 +176,6 @@ export default function search() { searchFn({ query: debouncedSearch, types: ["Series"], - signal: abortControllerRef.current?.signal, }), enabled: searchType === "Library" && debouncedSearch.length > 0, }); @@ -340,7 +186,6 @@ export default function search() { searchFn({ query: debouncedSearch, types: ["Episode"], - signal: abortControllerRef.current?.signal, }), enabled: searchType === "Library" && debouncedSearch.length > 0, }); @@ -351,7 +196,6 @@ export default function search() { searchFn({ query: debouncedSearch, types: ["BoxSet"], - signal: abortControllerRef.current?.signal, }), enabled: searchType === "Library" && debouncedSearch.length > 0, }); @@ -362,52 +206,6 @@ 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, }); @@ -418,376 +216,174 @@ export default function search() { episodes?.length || series?.length || collections?.length || - actors?.length || - artists?.length || - albums?.length || - songs?.length || - playlists?.length + actors?.length ); - }, [ - episodes, - movies, - series, - collections, - actors, - artists, - albums, - songs, - playlists, - ]); + }, [episodes, movies, series, collections, actors]); const loading = useMemo(() => { - return l1 || l2 || l3 || l7 || l8 || l9 || l10 || l11 || l12; - }, [l1, l2, l3, l7, l8, l9, l10, l11, l12]); + return l1 || l2 || l3 || l7 || l8; + }, [l1, l2, l3, l7, l8]); return ( - - {/* + */} - {Platform.isTV && ( - { - router.setParams({ q: "" }); - setSearch(text); - }} - keyboardType='default' - returnKeyType='done' - autoCapitalize='none' - clearButtonMode='while-editing' - maxLength={500} - /> - )} - - {jellyseerrApi && ( - - - {searchType === "Discover" && - !loading && - noResults && - debouncedSearch.length > 0 && ( - + {jellyseerrApi && ( + + setSearchType("Library")}> + - )} - - )} + + setSearchType("Discover")}> + + + + )} - - + + + + + {searchType === "Library" ? ( + + m.Id!)} + renderItem={(item: BaseItemDto) => ( + + + + {item.Name} + + + {item.ProductionYear} + + + )} + /> + m.Id!)} + header={t("search.series")} + renderItem={(item: BaseItemDto) => ( + + + + {item.Name} + + + {item.ProductionYear} + + + )} + /> + m.Id!)} + header={t("search.episodes")} + renderItem={(item: BaseItemDto) => ( + + + + + )} + /> + m.Id!)} + header={t("search.collections")} + renderItem={(item: BaseItemDto) => ( + + + + {item.Name} + + + )} + /> + m.Id!)} + header={t("search.actors")} + renderItem={(item: BaseItemDto) => ( + + + + + )} + /> + + ) : ( + + )} + + {searchType === "Library" && ( + <> + {!loading && noResults && debouncedSearch.length > 0 ? ( + + + {t("search.no_results_found_for")} + + + "{debouncedSearch}" + + + ) : debouncedSearch.length === 0 ? ( + + {exampleSearches.map((e) => ( + setSearch(e)} + key={e} + className="mb-2" + > + {e} + + ))} + + ) : null} + + )} - - {searchType === "Library" ? ( - - ( - - - - {item.Name} - - - {item.ProductionYear} - - - )} - /> - ( - - - - {item.Name} - - - {item.ProductionYear} - - - )} - /> - ( - - - - - )} - /> - ( - - - - {item.Name} - - - )} - /> - ( - - - - - )} - /> - {/* Music search results */} - { - const imageUrl = getPrimaryImageUrl({ api, item }); - return ( - - - {imageUrl ? ( - - ) : ( - - 👤 - - )} - - - {item.Name} - - - ); - }} - /> - { - const imageUrl = getPrimaryImageUrl({ api, item }); - return ( - - - {imageUrl ? ( - - ) : ( - - 🎵 - - )} - - - {item.Name} - - - {item.AlbumArtist || item.Artists?.join(", ")} - - - ); - }} - /> - { - const imageUrl = getPrimaryImageUrl({ api, item }); - return ( - - - {imageUrl ? ( - - ) : ( - - 🎵 - - )} - - - {item.Name} - - - {item.Artists?.join(", ") || item.AlbumArtist} - - - ); - }} - /> - { - const imageUrl = getPrimaryImageUrl({ api, item }); - return ( - - - {imageUrl ? ( - - ) : ( - - 🎶 - - )} - - - {item.Name} - - - {item.ChildCount} tracks - - - ); - }} - /> - - ) : ( - - )} - - {searchType === "Library" && - (!loading && noResults && debouncedSearch.length > 0 ? ( - - - {t("search.no_results_found_for")} - - - "{debouncedSearch}" - - - ) : debouncedSearch.length === 0 ? ( - - {exampleSearches.map((e) => ( - { - setSearch(e); - searchBarRef.current?.setText(e); - }} - key={e} - className='mb-2' - > - {e} - - ))} - - ) : null)} - - + + ); } diff --git a/app/(auth)/(tabs)/_layout.tsx b/app/(auth)/(tabs)/_layout.tsx index df1ed986..6f581ae0 100644 --- a/app/(auth)/(tabs)/_layout.tsx +++ b/app/(auth)/(tabs)/_layout.tsx @@ -1,125 +1,151 @@ +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, - type NativeBottomTabNavigationEventMap, - type NativeBottomTabNavigationOptions, + NativeBottomTabNavigationEventMap, } 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< - NativeBottomTabNavigationOptions, + BottomTabNavigationOptions, typeof Navigator, TabNavigationState, 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 ( - - + ); } diff --git a/app/(auth)/player/_layout.tsx b/app/(auth)/player/_layout.tsx index 5604160e..318322ec 100644 --- a/app/(auth)/player/_layout.tsx +++ b/app/(auth)/player/_layout.tsx @@ -1,39 +1,57 @@ import { Stack } from "expo-router"; -import { useEffect } from "react"; -import { AppState } from "react-native"; +import React, { useEffect } from "react"; import { SystemBars } from "react-native-edge-to-edge"; - -import { useOrientation } from "@/hooks/useOrientation"; +import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import { useSettings } from "@/utils/atoms/settings"; +import { Platform } from "react-native"; export default function Layout() { - const { settings } = useSettings(); - const { lockOrientation, unlockOrientation } = useOrientation(); + const [settings] = useSettings(); useEffect(() => { - if (settings?.defaultVideoOrientation) { - lockOrientation(settings.defaultVideoOrientation); + if (Platform.isTV) return; + + if (settings.defaultVideoOrientation) { + ScreenOrientation.lockAsync(settings.defaultVideoOrientation); } - // 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(); + if (Platform.isTV) return; + + if (settings.autoRotate === true) { + ScreenOrientation.unlockAsync(); + } else { + ScreenOrientation.lockAsync( + ScreenOrientation.OrientationLock.PORTRAIT_UP + ); + } }; - }, [settings?.defaultVideoOrientation, lockOrientation, unlockOrientation]); + }, [settings]); return ( <> + ); +} + +type TrickplaySliderProps = { + item?: BaseItemDto; + progress: SharedValue; + wasPlayingRef: React.MutableRefObject; + isPlaying: boolean; + isSeeking: SharedValue; + range: { min?: SharedValue; max: SharedValue }; + 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(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 ( + + + + + + {`${time.hours > 0 ? `${time.hours}:` : ""}${ + time.minutes < 10 ? `0${time.minutes}` : time.minutes + }:${time.seconds < 10 ? `0${time.seconds}` : time.seconds}`} + + + ); + }, [trickPlayUrl, trickplayInfo, time]); + + return ( + null} + onSlidingStart={handleSliderStart} + onSlidingComplete={handleSliderComplete} + onValueChange={handleSliderChange} + containerStyle={{ + borderRadius: 100, + }} + renderBubble={() => isSliding && memoizedRenderBubble()} + sliderHeight={10} + thumbWidth={0} + progress={progress} + minimumValue={min} + maximumValue={range.max} + /> + ); +} diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx index e50b4efc..e7286023 100644 --- a/components/DownloadItem.tsx +++ b/components/DownloadItem.tsx @@ -1,31 +1,30 @@ +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, - type BottomSheetBackdropProps, + BottomSheetBackdropProps, BottomSheetModal, BottomSheetView, } from "@gorhom/bottom-sheet"; -import type { +import { BaseItemDto, MediaSourceInfo, } from "@jellyfin/sdk/lib/generated-client/models"; -import { type Href } from "expo-router"; -import { t } from "i18next"; +import { Href, router, useFocusEffect } from "expo-router"; import { useAtom } from "jotai"; -import type React from "react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Alert, Platform, Switch, View, type ViewProps } from "react-native"; +import React, { useCallback, useMemo, useRef, useState } from "react"; +import { Alert, Platform, View, 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 { type Bitrate, BitrateSelector } from "./BitrateSelector"; +import { Bitrate, BitrateSelector } from "./BitrateSelector"; import { Button } from "./Button"; import { Text } from "./common/Text"; import { Loader } from "./Loader"; @@ -33,13 +32,7 @@ import { MediaSourceSelector } from "./MediaSourceSelector"; import ProgressCircle from "./ProgressCircle"; import { RoundButton } from "./RoundButton"; import { SubtitleTrackSelector } from "./SubtitleTrackSelector"; - -export type SelectedOptions = { - bitrate: Bitrate; - mediaSource: MediaSourceInfo | undefined; - audioIndex: number | undefined; - subtitleIndex: number; -}; +import { t } from "i18next"; interface DownloadProps extends ViewProps { items: BaseItemDto[]; @@ -61,28 +54,32 @@ export const DownloadItems: React.FC = ({ }) => { const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); - const [queue, _setQueue] = useAtom(queueAtom); - const { settings } = useSettings(); - const router = useRouter(); - const [downloadUnwatchedOnly, setDownloadUnwatchedOnly] = useState(false); + const [queue, setQueue] = useAtom(queueAtom); + const [settings] = useSettings(); - const { processes, startBackgroundDownload, downloadedItems } = useDownload(); - const downloadedFiles = downloadedItems; + const { processes, startBackgroundDownload, downloadedFiles } = useDownload(); + const { startRemuxing } = useRemuxHlsToMp4(); - const [selectedOptions, setSelectedOptions] = useState< - SelectedOptions | undefined + const [selectedMediaSource, setSelectedMediaSource] = useState< + MediaSourceInfo | undefined | null >(undefined); - - const { - defaultAudioIndex, - defaultBitrate, - defaultMediaSource, - defaultSubtitleIndex, - } = useDefaultPlaySettings(items[0], settings); + const [selectedAudioStream, setSelectedAudioStream] = useState(-1); + const [selectedSubtitleStream, setSelectedSubtitleStream] = + useState(0); + const [maxBitrate, setMaxBitrate] = useState( + settings?.defaultBitrate ?? { + key: "Max", + value: undefined, + } + ); const userCanDownload = useMemo( () => user?.Policy?.EnableContentDownloading, - [user], + [user] + ); + const usingOptimizedServer = useMemo( + () => settings?.downloadMethod === DownloadMethod.Optimized, + [settings] ); const bottomSheetModalRef = useRef(null); @@ -91,9 +88,7 @@ export const DownloadItems: React.FC = ({ bottomSheetModalRef.current?.present(); }, []); - const handleSheetChanges = useCallback((_index: number) => { - // Modal state tracking handled by BottomSheetModal - }, []); + const handleSheetChanges = useCallback((index: number) => {}, []); const closeModal = useCallback(() => { bottomSheetModalRef.current?.dismiss(); @@ -104,45 +99,21 @@ export const DownloadItems: React.FC = ({ 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) => p?.item?.Id && itemIds.includes(p.item.Id)) || - [], - [processes, itemIds], + () => processes?.filter((p) => itemIds.includes(p.item.Id)), + [processes, itemIds] ); const progress = useMemo(() => { - if (itemIds.length === 1) - return itemsProcesses.reduce((acc, p) => acc + (p.progress || 0), 0); + if (itemIds.length == 1) + return itemsProcesses.reduce((acc, p) => acc + p.progress, 0); return ( ((itemIds.length - queue.filter((q) => itemIds.includes(q.item.Id)).length) / @@ -154,16 +125,9 @@ export const DownloadItems: React.FC = ({ 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 = () => { @@ -172,125 +136,123 @@ export const DownloadItems: React.FC = ({ firstItem.Type !== "Episode" ? "/downloads" : ({ - pathname: "/series/[id]", + pathname: `/downloads/${firstItem.SeriesId}`, params: { - id: firstItem.SeriesId!, - seasonIndex: firstItem.ParentIndexNumber?.toString(), - offline: "true", + episodeSeasonIndex: firstItem.ParentIndexNumber, }, - } 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 && !selectedOptions?.mediaSource?.Id) + (itemsNotDownloaded.length === 1 && !selectedMediaSource?.Id) ) { throw new Error( - "DownloadItem ~ initiateDownload: No api or user or item", + "DownloadItem ~ initiateDownload: No api or user or item" ); } - 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, - }; + let mediaSource = selectedMediaSource; + let audioIndex: number | undefined = selectedAudioStream; + let subtitleIndex: number | undefined = selectedSubtitleStream; - const downloadDetails = await getDownloadUrl({ + 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({ api, item, - userId: user.Id!, - mediaSource: mediaSource!, - audioStreamIndex: audioIndex ?? -1, - subtitleStreamIndex: subtitleIndex ?? -1, - maxBitrate: selectedOptions?.bitrate || defaultBitrate, - deviceId: api.deviceInfo.id, - audioMode: settings?.audioTranscodeMode, + startTimeTicks: 0, + userId: user?.Id, + audioStreamIndex: audioIndex, + maxStreamingBitrate: maxBitrate.value, + mediaSourceId: mediaSource?.Id, + subtitleStreamIndex: subtitleIndex, + deviceProfile: download, }); - return { - url: downloadDetails?.url, - item, - mediaSource: downloadDetails?.mediaSource, - }; - }); - - const downloadDetails = await Promise.all(downloadDetailsPromises); - for (const { url, item, mediaSource } of downloadDetails) { - if (!url) { + if (!res) { 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; } - 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, - ); + 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); + } } }, [ api, user?.Id, itemsNotDownloaded, - selectedOptions, + selectedMediaSource, + selectedAudioStream, + selectedSubtitleStream, settings, - defaultBitrate, + maxBitrate, + usingOptimizedServer, 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) => ( = ({ 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 = () => { - // 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) { + if (processes && itemsProcesses.length > 0) { return progress === 0 ? ( ) : ( - + ); - } - - if (itemsQueued) { - return ; - } - - if (allItemsDownloaded) { + } else if (itemsQueued) { + return ; + } else if (allItemsDownloaded) { return ; + } else { + return ; } - - return ; }; const onButtonPress = () => { @@ -365,100 +329,64 @@ export const DownloadItems: React.FC = ({ }} onChange={handleSheetChanges} backdropComponent={renderBackdrop} - enablePanDownToClose - enableDismissOnClose - android_keyboardInputMode='adjustResize' - keyboardBehavior='interactive' - keyboardBlurBehavior='restore' > - + - + {title} - + {subtitle || t("item_card.download.download_x_item", { - item_count: itemsToDownload.length, + item_count: itemsNotDownloaded.length, })} - - - - setSelectedOptions( - (prev) => prev && { ...prev, bitrate: val }, - ) - } - selected={selectedOptions?.bitrate} - /> - - {itemsNotDownloaded.length > 1 && ( - - {t("item_card.download.download_unwatched_only")} - - - )} + + {itemsNotDownloaded.length === 1 && ( - - - - setSelectedOptions( - (prev) => - prev && { - ...prev, - mediaSource: val, - }, - ) - } - selected={selectedOptions?.mediaSource} - /> - - {selectedOptions?.mediaSource && ( - + <> + + {selectedMediaSource && ( + { - setSelectedOptions( - (prev) => - prev && { - ...prev, - audioIndex: val, - }, - ); - }} - selected={selectedOptions.audioIndex} + source={selectedMediaSource} + onChange={setSelectedAudioStream} + selected={selectedAudioStream} /> { - setSelectedOptions( - (prev) => - prev && { - ...prev, - subtitleIndex: val, - }, - ); - }} - selected={selectedOptions.subtitleIndex} + source={selectedMediaSource} + onChange={setSelectedSubtitleStream} + selected={selectedSubtitleStream} /> )} - + )} - - + + + {usingOptimizedServer + ? t("item_card.download.using_optimized_server") + : t("item_card.download.using_default_method")} + + @@ -476,17 +404,17 @@ export const DownloadSingleItem: React.FC<{ ( - + )} DownloadedIconComponent={() => ( - + )} /> ); diff --git a/components/ItemContent.tsx b/components/ItemContent.tsx index 1b2cbbac..1f9077f8 100644 --- a/components/ItemContent.tsx +++ b/components/ItemContent.tsx @@ -1,4 +1,25 @@ -import type { +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 { BaseItemDto, MediaSourceInfo, } from "@jellyfin/sdk/lib/generated-client/models"; @@ -8,33 +29,11 @@ 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 { PlayInRemoteSessionButton } from "./PlayInRemoteSession"; - +import { MediaSourceSelector } from "./MediaSourceSelector"; +import { MoreMoviesWithActor } from "./MoreMoviesWithActor"; const Chromecast = !Platform.isTV ? require("./Chromecast") : null; export type SelectedOptions = { @@ -44,22 +43,14 @@ export type SelectedOptions = { subtitleIndex: number; }; -interface ItemContentProps { - item: BaseItemDto; - itemWithSources?: BaseItemDto | null; -} - -export const ItemContent: React.FC = React.memo( - ({ item, itemWithSources }) => { +export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo( + ({ item }) => { const [api] = useAtom(apiAtom); - const isOffline = useOfflineMode(); - const { settings } = useSettings(); + const [settings] = useSettings(); const { orientation } = useOrientation(); const navigation = useNavigation(); const insets = useSafeAreaInsets(); - const [user] = useAtom(userAtom); - - const itemColors = useImageColorsReturn({ item }); + useImageColors({ item }); const [loadingLogo, setLoadingLogo] = useState(true); const [headerHeight, setHeaderHeight] = useState(350); @@ -68,32 +59,18 @@ export const ItemContent: React.FC = React.memo( SelectedOptions | undefined >(undefined); - // Use itemWithSources for play settings since it has MediaSources data const { defaultAudioIndex, defaultBitrate, defaultMediaSource, defaultSubtitleIndex, - } = 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]); + } = useDefaultPlaySettings(item, settings); // Needs to automatically change the selected to the default values for default indexes. useEffect(() => { setSelectedOptions(() => ({ bitrate: defaultBitrate, - mediaSource: defaultMediaSource ?? undefined, + mediaSource: defaultMediaSource, subtitleIndex: defaultSubtitleIndex ?? -1, audioIndex: defaultAudioIndex, })); @@ -104,89 +81,56 @@ export const ItemContent: React.FC = React.memo( defaultMediaSource, ]); - useEffect(() => { - if (!Platform.isTV && itemWithSources) { + if (!Platform.isTV) { + useEffect(() => { navigation.setOptions({ headerRight: () => - item && - (Platform.OS === "ios" ? ( - - + item && ( + + {item.Type !== "Program" && ( - + {!Platform.isTV && ( - + )} - {user?.Policy?.IsAdministrator && - !settings.hideRemoteSessionButton && ( - - )} - - + - {settings.streamyStatsServerUrl && - !settings.hideWatchlistsTab && ( - - )} )} - ) : ( - - - {item.Type !== "Program" && ( - - {!Platform.isTV && ( - - )} - {user?.Policy?.IsAdministrator && - !settings.hideRemoteSessionButton && ( - - )} - - - - {settings.streamyStatsServerUrl && - !settings.hideWatchlistsTab && ( - - )} - - )} - - )), + ), }); - } - }, [ - item, - navigation, - user, - itemWithSources, - settings.hideRemoteSessionButton, - settings.streamyStatsServerUrl, - settings.hideWatchlistsTab, - ]); + }, [item]); + } useEffect(() => { - if (item) { - if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP) - setHeaderHeight(230); - else if (item.Type === "Movie") setHeaderHeight(500); - else setHeaderHeight(350); - } - }, [item, orientation]); + if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP) + setHeaderHeight(230); + else if (item.Type === "Movie") setHeaderHeight(500); + else setHeaderHeight(350); + }, [item.Type, orientation]); - if (!item || !selectedOptions) return null; + const logoUrl = useMemo(() => getLogoImageUrlById({ api, item }), [item]); + + const loading = useMemo(() => { + return Boolean(logoUrl && loadingLogo); + }, [loadingLogo, logoUrl]); + if (!selectedOptions) return null; return ( @@ -203,71 +147,123 @@ export const ItemContent: React.FC = React.memo( } logo={ - logoUrl ? ( - - ) : ( - - ) + <> + {logoUrl ? ( + setLoadingLogo(false)} + onError={() => setLoadingLogo(false)} + /> + ) : null} + } > - - - - - - - - {!isOffline && ( - + + + {item.Type !== "Program" && !Platform.isTV && ( + + + setSelectedOptions( + (prev) => prev && { ...prev, bitrate: val } + ) + } + selected={selectedOptions.bitrate} /> - )} - + + setSelectedOptions( + (prev) => + prev && { + ...prev, + mediaSource: val, + } + ) + } + selected={selectedOptions.mediaSource} + /> + { + setSelectedOptions( + (prev) => + prev && { + ...prev, + audioIndex: val, + } + ); + }} + selected={selectedOptions.audioIndex} + /> + + setSelectedOptions( + (prev) => + prev && { + ...prev, + subtitleIndex: val, + } + ) + } + selected={selectedOptions.subtitleIndex} + /> + + )} + + + {item.Type === "Episode" && ( )} - {!isOffline && - selectedOptions.mediaSource?.MediaStreams && - selectedOptions.mediaSource.MediaStreams.length > 0 && ( - - )} - - + + {item.Type !== "Program" && ( <> - {item.Type === "Episode" && !isOffline && ( - + {item.Type === "Episode" && ( + )} - + - {!isOffline && } + {item.People && item.People.length > 0 && ( + + {item.People.slice(0, 3).map((person, idx) => ( + + ))} + + )} + + )} ); - }, + } ); diff --git a/components/ItemTechnicalDetails.tsx b/components/ItemTechnicalDetails.tsx index 49614a2c..e2570dc8 100644 --- a/components/ItemTechnicalDetails.tsx +++ b/components/ItemTechnicalDetails.tsx @@ -1,38 +1,39 @@ import { Ionicons } from "@expo/vector-icons"; import { - BottomSheetBackdrop, - type BottomSheetBackdropProps, - BottomSheetModal, - BottomSheetScrollView, -} from "@gorhom/bottom-sheet"; -import type { MediaSourceInfo, - MediaStream, + type MediaStream, } from "@jellyfin/sdk/lib/generated-client"; -import type React from "react"; -import { useMemo, useRef } from "react"; -import { useTranslation } from "react-i18next"; +import React, { useMemo, useRef } from "react"; import { TouchableOpacity, View } from "react-native"; -import { formatBitrate } from "@/utils/bitrate"; import { Badge } from "./Badge"; import { Text } from "./common/Text"; +import { + BottomSheetModal, + BottomSheetBackdropProps, + BottomSheetBackdrop, + BottomSheetView, + BottomSheetScrollView, +} from "@gorhom/bottom-sheet"; +import { Button } from "./Button"; +import { useTranslation } from "react-i18next"; +import { formatBitrate } from "@/utils/bitrate"; interface Props { source?: MediaSourceInfo; } -export const ItemTechnicalDetails: React.FC = ({ source }) => { +export const ItemTechnicalDetails: React.FC = ({ source, ...props }) => { const bottomSheetModalRef = useRef(null); const { t } = useTranslation(); return ( - - {t("item_card.video")} + + {t("item_card.video")} bottomSheetModalRef.current?.present()}> - + - {t("item_card.more_details")} + {t("item_card.more_details")} = ({ source }) => { )} > - - - + + + {t("item_card.video")} - + - - + + {t("item_card.audio")} stream.Type === "Audio", + (stream) => stream.Type === "Audio" ) || [] } /> - - + + {t("item_card.subtitles")} stream.Type === "Subtitle", + (stream) => stream.Type === "Subtitle" ) || [] } /> @@ -100,25 +101,25 @@ const SubtitleStreamInfo = ({ subtitleStreams: MediaStream[]; }) => { return ( - - {subtitleStreams.map((stream, _index) => ( - - + + {subtitleStreams.map((stream, index) => ( + + {stream.DisplayTitle} - + + } text={stream.Language} /> + } /> @@ -130,40 +131,40 @@ const SubtitleStreamInfo = ({ const AudioStreamInfo = ({ audioStreams }: { audioStreams: MediaStream[] }) => { return ( - + {audioStreams.map((audioStreams, index) => ( - - + + {audioStreams.DisplayTitle} - + + } text={audioStreams.Language} /> } text={audioStreams.Codec} /> } + variant="gray" + iconLeft={} text={audioStreams.ChannelLayout} /> + } text={formatBitrate(audioStreams.BitRate)} /> @@ -175,70 +176,53 @@ 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 - | undefined; - }, [source?.MediaStreams]); + return source.MediaStreams?.find( + (stream) => stream.Type === "Video" + ) as MediaStream; + }, [source.MediaStreams]); - if (!source || !videoStream) return null; - - // Dolby Vision video check - const isDolbyVision = - videoStream.VideoRangeType === "DOVI" || - videoStream.DvVersionMajor != null || - videoStream.DvVersionMinor != null; + if (!videoStream) return null; return ( - + } + variant="gray" + iconLeft={} text={formatFileSize(source.Size)} /> } + variant="gray" + iconLeft={} text={`${videoStream.Width}x${videoStream.Height}`} /> - {isDolbyVision && ( - - } - text={"DV"} - /> - )} + } text={videoStream.VideoRange} /> + } text={videoStream.Codec} /> + } text={formatBitrate(videoStream.BitRate)} /> } - text={ - videoStream.AverageFrameRate != null - ? `${videoStream.AverageFrameRate.toFixed(0)} fps` - : "" - } + variant="gray" + iconLeft={} + text={`${videoStream.AverageFrameRate?.toFixed(0)} fps`} /> ); @@ -249,9 +233,6 @@ const formatFileSize = (bytes?: number | null) => { const sizes = ["Bytes", "KB", "MB", "GB", "TB"]; if (bytes === 0) return "0 Byte"; - const i = Number.parseInt( - Math.floor(Math.log(bytes) / Math.log(1024)).toString(), - 10, - ); - return `${Math.round((bytes / 1024 ** i) * 100) / 100} ${sizes[i]}`; + const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)).toString()); + return Math.round((bytes / Math.pow(1024, i)) * 100) / 100 + " " + sizes[i]; }; diff --git a/components/PlayButton.tsx b/components/PlayButton.tsx index 1c3fd46f..ce4733f3 100644 --- a/components/PlayButton.tsx +++ b/components/PlayButton.tsx @@ -1,14 +1,21 @@ +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 } from "@expo/vector-icons"; -import { BottomSheetView } from "@gorhom/bottom-sheet"; -import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; +import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"; +import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; +import { useRouter } from "expo-router"; import { useAtom, useAtomValue } from "jotai"; import { useCallback, useEffect } from "react"; -import { useTranslation } from "react-i18next"; -import { Alert, Platform, TouchableOpacity, View } from "react-native"; +import { Alert, TouchableOpacity, View } from "react-native"; import CastContext, { CastButton, - MediaStreamType, PlayServicesState, useMediaStatus, useRemoteMediaClient, @@ -23,29 +30,17 @@ import Animated, { useSharedValue, withTiming, } from "react-native-reanimated"; -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 { Button } from "./Button"; +import { SelectedOptions } from "./ItemContent"; import { chromecast } from "@/utils/profiles/chromecast"; import { chromecasth265 } from "@/utils/profiles/chromecasth265"; -import { runtimeTicksToMinutes } from "@/utils/time"; -import { Button } from "./Button"; -import { Text } from "./common/Text"; -import type { SelectedOptions } from "./ItemContent"; +import { useTranslation } from "react-i18next"; +import { useHaptic } from "@/hooks/useHaptic"; +import { chromecastLoadMedia } from "@/utils/chromecastLoadMedia"; -interface Props extends React.ComponentProps { +interface Props extends React.ComponentProps { item: BaseItemDto; selectedOptions: SelectedOptions; - colors?: ThemeColors; } const ANIMATION_DURATION = 500; @@ -54,60 +49,53 @@ const MIN_PLAYBACK_WIDTH = 15; export const PlayButton: React.FC = ({ item, selectedOptions, - colors, + ...props }: Props) => { - const isOffline = useOfflineMode(); const { showActionSheetWithOptions } = useActionSheet(); const client = useRemoteMediaClient(); const mediaStatus = useMediaStatus(); const { t } = useTranslation(); - const { showModal, hideModal } = useGlobalModal(); - 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(effectiveColors); - const startColor = useSharedValue(effectiveColors); + const endColor = useSharedValue(colorAtom); + const startColor = useSharedValue(colorAtom); const widthProgress = useSharedValue(0); const colorChangeProgress = useSharedValue(0); - const { settings, updateSettings } = useSettings(); + const [settings] = useSettings(); const lightHapticFeedback = useHaptic("light"); const goToPlayer = useCallback( - (q: string) => { - if (settings.maxAutoPlayEpisodeCount.value !== -1) { - updateSettings({ autoPlayEpisodeCount: 0 }); - } + (q: string, bitrateValue: number | undefined) => { router.push(`/player/direct-player?${q}`); }, - [router, isOffline], + [router] ); - const handleNormalPlayFlow = useCallback(async () => { + const onPress = useCallback(async () => { + console.log("onPress"); if (!item) return; + lightHapticFeedback(); + const queryParams = new URLSearchParams({ itemId: item.Id!, audioIndex: selectedOptions.audioIndex?.toString() ?? "", subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "", mediaSourceId: selectedOptions.mediaSource?.Id ?? "", bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "", - playbackPosition: item.UserData?.PlaybackPositionTicks?.toString() ?? "0", - offline: isOffline ? "true" : "false", }); const queryString = queryParams.toString(); if (!client) { - goToPlayer(queryString); + goToPlayer(queryString, selectedOptions.bitrate?.value); return; } @@ -133,42 +121,14 @@ export const PlayButton: React.FC = ({ // 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 ?? 0, - userId: user.Id, + startTimeTicks: item?.UserData?.PlaybackPositionTicks!, + userId: user?.Id, audioStreamIndex: selectedOptions.audioIndex, maxStreamingBitrate: selectedOptions.bitrate?.value, mediaSourceId: selectedOptions.mediaSource?.Id, @@ -181,88 +141,35 @@ export const PlayButton: React.FC = ({ 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; } - // 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, - })!, - }, - ], - }, + 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, + })!, }, - startTime: startTimeSeconds, - }) - .then(() => { - // state is already set when reopening current media, so skip it here. - if (isOpeningCurrentlyPlayingMedia) { - return; - } - CastContext.showExpandedControls(); - }); + ], + }).then(() => { + // state is already set when reopening current media, so skip it here. + if (isOpeningCurrentlyPlayingMedia) { + return; + } + router.push("/player/google-cast-player"); + }); } catch (e) { console.log(e); } @@ -270,12 +177,12 @@ export const PlayButton: React.FC = ({ }); break; case 1: - goToPlayer(queryString); + goToPlayer(queryString, selectedOptions.bitrate?.value); break; case cancelButtonIndex: break; } - }, + } ); }, [ item, @@ -287,140 +194,16 @@ export const PlayButton: React.FC = ({ 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( - - - - - {t("player.downloaded_file_title")} - - - {t("player.downloaded_file_message")} - - - - - - - - , - { - 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?.PlaybackPositionTicks) { + if (userData && userData.PlaybackPositionTicks) { return userData.PlaybackPositionTicks > 0 ? Math.max( (userData.PlaybackPositionTicks / item.RunTimeTicks) * 100, - MIN_PLAYBACK_WIDTH, + MIN_PLAYBACK_WIDTH ) : 0; } @@ -437,11 +220,11 @@ export const PlayButton: React.FC = ({ easing: Easing.bezier(0.7, 0, 0.3, 1.0), }); }, - [item], + [item] ); useAnimatedReaction( - () => effectiveColors, + () => colorAtom, (newColor) => { endColor.value = newColor; colorChangeProgress.value = 0; @@ -450,19 +233,19 @@ export const PlayButton: React.FC = ({ easing: Easing.bezier(0.9, 0, 0.31, 0.99), }); }, - [effectiveColors], + [colorAtom] ); useEffect(() => { const timeout_2 = setTimeout(() => { - startColor.value = effectiveColors; + startColor.value = colorAtom; startWidth.value = targetWidth.value; }, ANIMATION_DURATION); return () => { clearTimeout(timeout_2); }; - }, [effectiveColors, item]); + }, [colorAtom, item]); /** * ANIMATED STYLES @@ -471,7 +254,7 @@ export const PlayButton: React.FC = ({ backgroundColor: interpolateColor( colorChangeProgress.value, [0, 1], - [startColor.value.primary, endColor.value.primary], + [startColor.value.primary, endColor.value.primary] ), })); @@ -479,7 +262,7 @@ export const PlayButton: React.FC = ({ backgroundColor: interpolateColor( colorChangeProgress.value, [0, 1], - [startColor.value.primary, endColor.value.primary], + [startColor.value.primary, endColor.value.primary] ), })); @@ -487,7 +270,7 @@ export const PlayButton: React.FC = ({ width: `${interpolate( widthProgress.value, [0, 1], - [startWidth.value, targetWidth.value], + [startWidth.value, targetWidth.value] )}%`, })); @@ -495,19 +278,23 @@ export const PlayButton: React.FC = ({ color: interpolateColor( colorChangeProgress.value, [0, 1], - [startColor.value.text, endColor.value.text], + [startColor.value.text, endColor.value.text] ), })); + /** + * ********************* + */ return ( - + = ({ - + - {runtimeTicksToMinutes( - (item?.RunTimeTicks || 0) - - (item?.UserData?.PlaybackPositionTicks || 0), - )} - {(item?.UserData?.PlaybackPositionTicks || 0) > 0 && " left"} + {runtimeTicksToMinutes(item?.RunTimeTicks)} - + {client && ( - - + + + + )} + {!client && settings?.openInVLC && ( + + )} diff --git a/components/PlayButton.tv.tsx b/components/PlayButton.tv.tsx index f79a3174..1fb0563c 100644 --- a/components/PlayButton.tv.tsx +++ b/components/PlayButton.tv.tsx @@ -1,8 +1,15 @@ -import { Ionicons } from "@expo/vector-icons"; -import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; -import { useAtom } from "jotai"; +import { Platform } from "react-native"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { itemThemeColorAtom } from "@/utils/atoms/primaryColor"; +import { useSettings } from "@/utils/atoms/settings"; +import { runtimeTicksToMinutes } from "@/utils/time"; +import { useActionSheet } from "@expo/react-native-action-sheet"; +import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"; +import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; +import { useRouter } from "expo-router"; +import { useAtom, useAtomValue } from "jotai"; import { useCallback, useEffect } from "react"; -import { TouchableOpacity, View } from "react-native"; +import { Alert, TouchableOpacity, View } from "react-native"; import Animated, { Easing, interpolate, @@ -13,18 +20,14 @@ import Animated, { useSharedValue, withTiming, } from "react-native-reanimated"; -import useRouter from "@/hooks/useAppRouter"; +import { Button } from "./Button"; +import { SelectedOptions } from "./ItemContent"; +import { useTranslation } from "react-i18next"; 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 { item: BaseItemDto; selectedOptions: SelectedOptions; - colors?: ThemeColors; } const ANIMATION_DURATION = 500; @@ -33,32 +36,35 @@ const MIN_PLAYBACK_WIDTH = 15; export const PlayButton: React.FC = ({ item, selectedOptions, - colors, ...props }: Props) => { - const [globalColorAtom] = useAtom(itemThemeColorAtom); + const { showActionSheetWithOptions } = useActionSheet(); + const { t } = useTranslation(); - // Use colors prop if provided, otherwise fallback to global atom - const effectiveColors = colors || globalColorAtom; + const [colorAtom] = useAtom(itemThemeColorAtom); + const api = useAtomValue(apiAtom); + const user = useAtomValue(userAtom); const router = useRouter(); const startWidth = useSharedValue(0); const targetWidth = useSharedValue(0); - const endColor = useSharedValue(effectiveColors); - const startColor = useSharedValue(effectiveColors); + const endColor = useSharedValue(colorAtom); + const startColor = useSharedValue(colorAtom); const widthProgress = useSharedValue(0); const colorChangeProgress = useSharedValue(0); + const [settings] = useSettings(); const lightHapticFeedback = useHaptic("light"); const goToPlayer = useCallback( - (q: string) => { + (q: string, bitrateValue: number | undefined) => { router.push(`/player/direct-player?${q}`); }, - [router], + [router] ); const onPress = () => { + console.log("onpress"); if (!item) return; lightHapticFeedback(); @@ -72,18 +78,18 @@ export const PlayButton: React.FC = ({ }); const queryString = queryParams.toString(); - goToPlayer(queryString); + goToPlayer(queryString, selectedOptions.bitrate?.value); return; }; const derivedTargetWidth = useDerivedValue(() => { if (!item || !item.RunTimeTicks) return 0; const userData = item.UserData; - if (userData?.PlaybackPositionTicks) { + if (userData && userData.PlaybackPositionTicks) { return userData.PlaybackPositionTicks > 0 ? Math.max( (userData.PlaybackPositionTicks / item.RunTimeTicks) * 100, - MIN_PLAYBACK_WIDTH, + MIN_PLAYBACK_WIDTH ) : 0; } @@ -100,11 +106,11 @@ export const PlayButton: React.FC = ({ easing: Easing.bezier(0.7, 0, 0.3, 1.0), }); }, - [item], + [item] ); useAnimatedReaction( - () => effectiveColors, + () => colorAtom, (newColor) => { endColor.value = newColor; colorChangeProgress.value = 0; @@ -113,19 +119,19 @@ export const PlayButton: React.FC = ({ easing: Easing.bezier(0.9, 0, 0.31, 0.99), }); }, - [effectiveColors], + [colorAtom] ); useEffect(() => { const timeout_2 = setTimeout(() => { - startColor.value = effectiveColors; + startColor.value = colorAtom; startWidth.value = targetWidth.value; }, ANIMATION_DURATION); return () => { clearTimeout(timeout_2); }; - }, [effectiveColors, item]); + }, [colorAtom, item]); /** * ANIMATED STYLES @@ -134,7 +140,7 @@ export const PlayButton: React.FC = ({ backgroundColor: interpolateColor( colorChangeProgress.value, [0, 1], - [startColor.value.primary, endColor.value.primary], + [startColor.value.primary, endColor.value.primary] ), })); @@ -142,7 +148,7 @@ export const PlayButton: React.FC = ({ backgroundColor: interpolateColor( colorChangeProgress.value, [0, 1], - [startColor.value.primary, endColor.value.primary], + [startColor.value.primary, endColor.value.primary] ), })); @@ -150,7 +156,7 @@ export const PlayButton: React.FC = ({ width: `${interpolate( widthProgress.value, [0, 1], - [startWidth.value, targetWidth.value], + [startWidth.value, targetWidth.value] )}%`, })); @@ -158,7 +164,7 @@ export const PlayButton: React.FC = ({ color: interpolateColor( colorChangeProgress.value, [0, 1], - [startColor.value.text, endColor.value.text], + [startColor.value.text, endColor.value.text] ), })); /** @@ -167,13 +173,13 @@ export const PlayButton: React.FC = ({ return ( - + = ({ - + {runtimeTicksToMinutes(item?.RunTimeTicks)} - + + {settings?.openInVLC && ( + + + + )} diff --git a/components/SubtitleTrackSelector.tsx b/components/SubtitleTrackSelector.tsx index 6fca1955..ac5051e1 100644 --- a/components/SubtitleTrackSelector.tsx +++ b/components/SubtitleTrackSelector.tsx @@ -1,10 +1,10 @@ -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 { 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 OptionGroup, PlatformDropdown } from "./PlatformDropdown"; +import { useTranslation } from "react-i18next"; interface Props extends React.ComponentProps { source?: MediaSourceInfo; @@ -18,95 +18,76 @@ export const SubtitleTrackSelector: React.FC = ({ selected, ...props }) => { - const { t } = useTranslation(); - const [open, setOpen] = useState(false); - + if (Platform.isTV) return null; 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] ); - 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), - })) || []), - ]; + if (subtitleStreams?.length === 0) return null; - return [ - { - options, - }, - ]; - }, [subtitleStreams, selected, t, onChange]); - - 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 = ( - - - {t("item_card.subtitles")} - - setOpen(true)} - > - - {selectedSubtitleSteam - ? tc(selectedSubtitleSteam?.DisplayTitle, 7) - : t("item_card.none")} - - - - ); - - if (Platform.isTV || subtitleStreams?.length === 0) return null; + const { t } = useTranslation(); return ( - + > + + + + + {t("item_card.subtitles")} + + + + {selectedSubtitleSteam + ? tc(selectedSubtitleSteam?.DisplayTitle, 7) + : t("item_card.none")} + + + + + + Subtitle tracks + { + onChange(-1); + }} + > + None + + {subtitleStreams?.map((subtitle, idx: number) => ( + { + if (subtitle.Index !== undefined && subtitle.Index !== null) + onChange(subtitle.Index); + }} + > + + {subtitle.DisplayTitle} + + + ))} + + + ); }; diff --git a/components/common/Text.tsx b/components/common/Text.tsx index 739177d7..624b9da6 100644 --- a/components/common/Text.tsx +++ b/components/common/Text.tsx @@ -1,5 +1,12 @@ -import { Platform, Text as RNText, type TextProps } from "react-native"; -export function Text(props: TextProps) { +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; + } +) { const { style, ...otherProps } = props; if (Platform.isTV) return ( @@ -9,12 +16,12 @@ export function Text(props: TextProps) { {...otherProps} /> ); - - return ( - - ); + else + return ( + + ); } diff --git a/components/common/TouchableItemRouter.tsx b/components/common/TouchableItemRouter.tsx index 2c85e094..db89e52d 100644 --- a/components/common/TouchableItemRouter.tsx +++ b/components/common/TouchableItemRouter.tsx @@ -1,33 +1,32 @@ -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"; +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"; interface Props extends TouchableOpacityProps { item: BaseItemDto; } -export const itemRouter = (item: BaseItemDto, from: string) => { +export const itemRouter = ( + item: BaseItemDto | BaseItemPerson, + 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") { - return `/(auth)/(tabs)/${from}/persons/${item.Id}`; + if (item.Type === "Person" || item.Type === "Actor") { + return `/(auth)/(tabs)/${from}/actors/${item.Id}`; } if (item.Type === "BoxSet") { @@ -49,145 +48,28 @@ export const itemRouter = (item: BaseItemDto, from: string) => { 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> = ({ item, children, ...props }) => { + const router = useRouter(); const segments = useSegments(); const { showActionSheetWithOptions } = useActionSheet(); const markAsPlayedStatus = useMarkAsPlayed([item]); const { isFavorite, toggleFavorite } = useFavorite(item); - 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 from = segments[2]; const showActionSheet = useCallback(() => { - 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; + 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; showActionSheetWithOptions( { options, cancelButtonIndex, - destructiveButtonIndex, }, async (selectedIndex) => { if (selectedIndex === 0) { @@ -195,38 +77,29 @@ export const TouchableItemRouter: React.FC> = ({ } else if (selectedIndex === 1) { await markAsPlayedStatus(false); } else if (selectedIndex === 2) { - toggleFavorite(); - } else if (isOffline && selectedIndex === 3 && item.Id) { - deleteFile(item.Id); + toggleFavorite() } - }, + } ); - }, [ - showActionSheetWithOptions, - isFavorite, - markAsPlayedStatus, - toggleFavorite, - isOffline, - deleteFile, - item.Id, - ]); + }, [showActionSheetWithOptions, isFavorite, markAsPlayedStatus]); if ( from === "(home)" || from === "(search)" || from === "(libraries)" || - from === "(favorites)" || - from === "(watchlists)" + from === "(favorites)" ) return ( { + const url = itemRouter(item, from); + // @ts-expect-error + router.push(url); + }} {...props} > {children} ); - - return null; }; diff --git a/components/downloads/ActiveDownloads.tsx b/components/downloads/ActiveDownloads.tsx index 7835cdf1..773efab4 100644 --- a/components/downloads/ActiveDownloads.tsx +++ b/components/downloads/ActiveDownloads.tsx @@ -1,40 +1,205 @@ -import { t } from "i18next"; -import { View, type ViewProps } from "react-native"; import { Text } from "@/components/common/Text"; import { useDownload } from "@/providers/DownloadProvider"; -import { JobStatus } from "@/providers/Downloads/types"; -import { DownloadCard } from "./DownloadCard"; +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; -interface ActiveDownloadsProps extends ViewProps {} +interface Props extends ViewProps {} -export default function ActiveDownloads({ ...props }: ActiveDownloadsProps) { +export const ActiveDownloads: React.FC = ({ ...props }) => { const { processes } = useDownload(); - - // Filter out any invalid processes before rendering - const validProcesses = processes?.filter((p) => p?.item?.Id) || []; - - if (validProcesses.length === 0) + if (processes?.length === 0) return ( - - + + {t("home.downloads.active_download")} - + {t("home.downloads.no_active_downloads")} ); return ( - - + + {t("home.downloads.active_downloads")} - - {validProcesses.map((p: JobStatus) => ( - + + {processes?.map((p: JobStatus) => ( + ))} ); +}; + +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 ( + 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") && ( + + )} + + + {base64Image && ( + + + + )} + + {process.item.Type} + {process.item.Name} + + {process.item.ProductionYear} + + + {process.progress === 0 ? ( + + ) : ( + {process.progress.toFixed(0)}% + )} + {process.speed && ( + {process.speed?.toFixed(2)}x + )} + {eta(process) && ( + + {t("home.downloads.eta", { eta: eta(process) })} + + )} + + + + {process.status} + + + cancelJobMutation.mutate(process.id)} + className="ml-auto" + > + {cancelJobMutation.isPending ? ( + + ) : ( + + )} + + + {process.status === "completed" && ( + + + + )} + + + ); +}; diff --git a/components/jellyseerr/DetailFacts.tsx b/components/jellyseerr/DetailFacts.tsx index 0250920f..e6ef013a 100644 --- a/components/jellyseerr/DetailFacts.tsx +++ b/components/jellyseerr/DetailFacts.tsx @@ -1,15 +1,15 @@ -import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"; -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 { 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 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"; +import { useTranslation } from "react-i18next"; interface Release { certification: string; @@ -30,12 +30,12 @@ const Facts: React.FC< > = ({ title, facts, ...props }) => facts && facts?.length > 0 && ( - - {title} + + {title} - + {facts.map((f, idx) => - typeof f === "string" ? {f} : f, + typeof f === "string" ? {f} : f )} @@ -50,16 +50,15 @@ const Fact: React.FC<{ title: string; fact?: string | null } & ViewProps> = ({ const DetailFacts: React.FC< { details?: MovieDetails | TvDetails } & ViewProps > = ({ details, className, ...props }) => { - const { jellyseerrRegion: region, jellyseerrLocale: locale } = - useJellyseerr(); + const { jellyseerrUser, 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: @@ -73,9 +72,9 @@ const DetailFacts: React.FC< () => uniqBy( releases?.filter((r: Release) => r.type > 2 && r.type < 6), - "type", + "type" ), - [releases], + [releases] ); const firstAirDate = useMemo(() => { @@ -83,7 +82,7 @@ const DetailFacts: React.FC< if (firstAirDate) { return new Date(firstAirDate).toLocaleDateString( `${locale}-${region}`, - dateOpts, + dateOpts ); } }, [details]); @@ -94,7 +93,7 @@ const DetailFacts: React.FC< if (nextAirDate && firstAirDate !== nextAirDate) { return new Date(nextAirDate).toLocaleDateString( `${locale}-${region}`, - dateOpts, + dateOpts ); } }, [details]); @@ -103,26 +102,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]); @@ -130,15 +129,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 && ( - - {t("jellyseerr.details")} + + {t("jellyseerr.details")} {details.keywords.some( - (keyword) => keyword.id === ANIME_KEYWORD_ID, - ) && } + (keyword) => keyword.id === ANIME_KEYWORD_ID + ) && } ( - + {r.type === 3 ? ( // Theatrical - + ) : r.type === 4 ? ( // Digital - + ) : ( // Physical )} {new Date(r.release_date).toLocaleDateString( `${locale}-${region}`, - dateOpts, + dateOpts )} @@ -182,14 +181,11 @@ const DetailFacts: React.FC< - + ( - + {n.name} @@ -198,13 +194,10 @@ const DetailFacts: React.FC< n.name, + (n) => n.name )} /> - n.name)} - /> + n.name)} /> s.name)} diff --git a/components/jellyseerr/discover/GenreSlide.tsx b/components/jellyseerr/discover/GenreSlide.tsx index 31248540..3ec7f733 100644 --- a/components/jellyseerr/discover/GenreSlide.tsx +++ b/components/jellyseerr/discover/GenreSlide.tsx @@ -1,38 +1,35 @@ -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, { type SlideProps } from "@/components/jellyseerr/discover/Slide"; -import useRouter from "@/hooks/useAppRouter"; +import Slide, { SlideProps } from "@/components/jellyseerr/discover/Slide"; import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr"; import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover"; -import type { GenreSliderItem } from "@/utils/jellyseerr/server/interfaces/api/discoverInterfaces"; +import { 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 = ({ slide, ...props }) => { const segments = useSegments(); const { jellyseerrApi } = useJellyseerr(); - const router = useRouter(); - const from = (segments as string[])[2] || "(home)"; + const from = segments[2]; const navigate = useCallback( (genre: GenreSliderItem) => router.push({ - pathname: `/(auth)/(tabs)/${from}/jellyseerr/genre/${genre.id}` as any, + pathname: `/(auth)/(tabs)/${from}/jellyseerr/genre/${genre.id}`, params: { type: slide.type, name: genre.name }, }), - [slide], + [slide] ); - const { data } = useQuery({ + const { data, isFetching, isLoading } = 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, @@ -45,19 +42,19 @@ const GenreSlide: React.FC = ({ slide, ...props }) => { slide={slide} data={data} keyExtractor={(item) => item.id.toString()} - renderItem={(item, _index) => ( - navigate(item)}> + renderItem={(item, index) => ( + navigate(item)}> diff --git a/components/list/ListGroup.tsx b/components/list/ListGroup.tsx index b9752bac..03f218d1 100644 --- a/components/list/ListGroup.tsx +++ b/components/list/ListGroup.tsx @@ -1,11 +1,12 @@ import { + PropsWithChildren, Children, - cloneElement, isValidElement, - type PropsWithChildren, - type ReactElement, + cloneElement, + ReactElement, } from "react"; -import { StyleSheet, View, type ViewProps, type ViewStyle } from "react-native"; +import { StyleSheet, View, ViewProps, ViewStyle } from "react-native"; +import { ListItem } from "./ListItem"; import { Text } from "../common/Text"; interface Props extends ViewProps { @@ -23,12 +24,12 @@ export const ListGroup: React.FC> = ({ return ( - + {title} {Children.map(childrenArray, (child, index) => { if (isValidElement<{ style?: ViewStyle }>(child)) { @@ -37,14 +38,14 @@ export const ListGroup: React.FC> = ({ child.props.style, index < childrenArray.length - 1 ? styles.borderBottom - : undefined, + : undefined ), }); } return child; })} - {description && {description}} + {description && {description}} ); }; diff --git a/components/list/ListItem.tsx b/components/list/ListItem.tsx index fed62315..ea7774a4 100644 --- a/components/list/ListItem.tsx +++ b/components/list/ListItem.tsx @@ -1,12 +1,15 @@ import { Ionicons } from "@expo/vector-icons"; -import type { PropsWithChildren, ReactNode } from "react"; -import { TouchableOpacity, View, type ViewProps } from "react-native"; +import { PropsWithChildren, ReactNode } from "react"; +import { + TouchableOpacity, + TouchableOpacityProps, + View, + ViewProps, +} from "react-native"; import { Text } from "../common/Text"; -interface Props extends ViewProps { +interface Props extends TouchableOpacityProps, ViewProps { title?: string | null | undefined; - subtitle?: string | null | undefined; - subtitleColor?: "default" | "red"; value?: string | null | undefined; children?: ReactNode; iconAfter?: ReactNode; @@ -14,13 +17,10 @@ interface Props extends ViewProps { showArrow?: boolean; textColor?: "default" | "blue" | "red"; onPress?: () => void; - disabled?: boolean; - disabledByAdmin?: boolean; } export const ListItem: React.FC> = ({ title, - subtitle, value, iconAfter, children, @@ -29,23 +29,20 @@ export const ListItem: React.FC> = ({ textColor = "default", onPress, disabled = false, - disabledByAdmin = false, - ...viewProps + ...props }) => { - const effectiveSubtitle = disabledByAdmin ? "Disabled by admin" : subtitle; - const isDisabled = disabled || disabledByAdmin; if (onPress) return ( > = ({ ); return ( > = ({ const ListItemContent = ({ title, - subtitle, - subtitleColor, textColor, icon, value, showArrow, iconAfter, children, + ...props }: Props) => { return ( <> - + {icon && ( - - + + )} - - - {title} - - {subtitle && ( - - {subtitle} - - )} - + + {title} + {value && ( - - + + {value} )} - {children && {children}} + {children && {children}} {showArrow && ( - + )} diff --git a/components/series/JellyseerrSeasons.tsx b/components/series/JellyseerrSeasons.tsx index 3b130683..a1a1fb7c 100644 --- a/components/series/JellyseerrSeasons.tsx +++ b/components/series/JellyseerrSeasons.tsx @@ -1,34 +1,30 @@ -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 React, { useCallback, useMemo, useState } from "react"; +import { Alert, TouchableOpacity, View } from "react-native"; +import { TvDetails } from "@/utils/jellyseerr/server/models/Tv"; +import { FlashList } from "@shopify/flash-list"; +import { orderBy } from "lodash"; import { Tags } from "@/components/GenreTags"; -import { dateOpts } from "@/components/jellyseerr/DetailFacts"; -import { textShadowStyle } from "@/components/jellyseerr/discover/GenericSlideCard"; import JellyseerrStatusIcon from "@/components/jellyseerr/JellyseerrStatusIcon"; -import { RoundButton } from "@/components/RoundButton"; -import { useJellyseerr } from "@/hooks/useJellyseerr"; +import Season from "@/utils/jellyseerr/server/entity/Season"; import { MediaStatus, MediaType, } from "@/utils/jellyseerr/server/constants/media"; -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 { 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 { 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; @@ -47,8 +43,9 @@ const JellyseerrSeasonEpisodes: React.FC<{ horizontal loading={isLoading} showsHorizontalScrollIndicator={false} + estimatedItemSize={50} data={seasonWithEpisodes?.episodes} - keyExtractor={(item) => item.id.toString()} + keyExtractor={(item) => item.id} renderItem={(item, index) => ( )} @@ -56,27 +53,27 @@ const JellyseerrSeasonEpisodes: React.FC<{ ); }; -const RenderItem = ({ item }: any) => { - const { - jellyseerrApi, - jellyseerrRegion: region, - jellyseerrLocale: locale, - } = useJellyseerr(); +const RenderItem = ({ item, index }: any) => { + const { jellyseerrApi, jellyseerrRegion: region, jellyseerrLocale: locale } = useJellyseerr(); const [imageError, setImageError] = useState(false); const upcomingAirDate = useMemo(() => { const airDate = item.airDate; if (airDate) { - const airDateObj = new Date(airDate); + let airDateObj = new Date(airDate); + if (new Date() < airDateObj) { - return airDateObj.toLocaleDateString(`${locale}-${region}`, dateOpts); + return airDateObj.toLocaleDateString( + `${locale}-${region}`, + dateOpts + ); } } - }, [item, locale, region]); + }, [item]); return ( - - + + {!imageError ? ( <> { 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 && ( - - - + + + {upcomingAirDate} @@ -106,25 +100,26 @@ const RenderItem = ({ item }: any) => { )} ) : ( - + )} - - + + {item.name} - + {`S${item.seasonNumber}:E${item.episodeNumber}`} - + + {item.overview} @@ -133,51 +128,54 @@ const RenderItem = ({ item }: 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 - >; + refetch: (options?: (RefetchOptions | undefined)) => Promise>; }> = ({ 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(() => { - if (!details) return []; - const mediaInfoSeasons = details.mediaInfo?.seasons?.filter( - (s: Season) => s.seasonNumber !== 0, + 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) => ({ + const requestedSeasons = details?.mediaInfo?.requests?.flatMap( + (r: MediaRequest) => r.seasons + ); + return details.seasons?.map((season) => { + return { ...season, status: + // What our library status is mediaInfoSeasons?.find( (mediaSeason: Season) => - mediaSeason.seasonNumber === season.seasonNumber, + 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(() => { @@ -188,74 +186,59 @@ 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); } - requestMedia(details.name, body, refetch); + + if (hasAdvancedRequest) { + return onAdvancedRequest?.(body) + } + + requestMedia(result?.name!!, body, refetch); } - }, [ - jellyseerrApi, - seasons, - details, - hasAdvancedRequest, - onAdvancedRequest, - requestMedia, - refetch, - ]); + }, [jellyseerrApi, seasons, details, hasAdvancedRequest, onAdvancedRequest]); 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 && 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); + 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], } - }, - [requestMedia, hasAdvancedRequest, onAdvancedRequest, refetch, details], - ); - if (!details) return null; + if (hasAdvancedRequest) { + return onAdvancedRequest?.(body) + } + + requestMedia(`${result?.name!!}, Season ${seasonNumber}`, body, refetch); + } + }, [requestMedia, hasAdvancedRequest, onAdvancedRequest]); if (isLoading) return ( - - - {t("item_card.seasons")} - + + {t("item_card.seasons")} {!allSeasonsAvailable && ( - - + + )} @@ -266,23 +249,22 @@ const JellyseerrSeasons: React.FC<{ return ( s.seasonNumber !== 0), + details.seasons.filter((s) => s.seasonNumber !== 0), "seasonNumber", - "desc", + "desc" )} ListHeaderComponent={() => ( - - - {t("item_card.seasons")} - + + {t("item_card.seasons")} {!allSeasonsAvailable && ( - - + + )} )} - ItemSeparatorComponent={() => } + ItemSeparatorComponent={() => } + estimatedItemSize={250} renderItem={({ item: season }) => ( <> {[0].map(() => { - const canRequest = season.status === MediaStatus.UNKNOWN; + const canRequest = + seasons?.find((s) => s.seasonNumber === season.seasonNumber) + ?.status === MediaStatus.UNKNOWN; return ( - requestSeason(canRequest, season.seasonNumber) - } + onPress={() => requestSeason(canRequest, season.seasonNumber)} className={canRequest ? "bg-gray-700/40" : undefined} - mediaStatus={season.status} + mediaStatus={ + seasons?.find( + (s) => s.seasonNumber === season.seasonNumber + )?.status + } showRequestIcon={canRequest} /> ); diff --git a/components/settings/ChromecastSettings.tsx b/components/settings/ChromecastSettings.tsx index 4da06332..63cddac9 100644 --- a/components/settings/ChromecastSettings.tsx +++ b/components/settings/ChromecastSettings.tsx @@ -1,10 +1,10 @@ import { Switch, View } from "react-native"; -import { useSettings } from "@/utils/atoms/settings"; import { ListGroup } from "../list/ListGroup"; +import { useSettings } from "@/utils/atoms/settings"; import { ListItem } from "../list/ListItem"; export const ChromecastSettings: React.FC = ({ ...props }) => { - const { settings, updateSettings } = useSettings(); + const [settings, updateSettings] = useSettings(); return ( diff --git a/components/settings/Dashboard.tsx b/components/settings/Dashboard.tsx index d41de008..1ffe57a1 100644 --- a/components/settings/Dashboard.tsx +++ b/components/settings/Dashboard.tsx @@ -1,14 +1,15 @@ -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 { useRouter } from "expo-router"; +import React from "react"; +import { View } from "react-native"; import { ListGroup } from "../list/ListGroup"; import { ListItem } from "../list/ListItem"; +import { useTranslation } from "react-i18next"; +import { useSessions, useSessionsProps } from "@/hooks/useSessions"; export const Dashboard = () => { - const { settings } = useSettings(); - const { sessions = [] } = useSessions({} as useSessionsProps); + const [settings, updateSettings] = useSettings(); + const { sessions = [], isLoading } = useSessions({} as useSessionsProps); const router = useRouter(); const { t } = useTranslation(); @@ -16,9 +17,9 @@ export const Dashboard = () => { if (!settings) return null; return ( - + router.push("/settings/dashboard/sessions")} title={t("home.settings.dashboard.sessions_title")} showArrow diff --git a/components/settings/DownloadSettings.tsx b/components/settings/DownloadSettings.tsx index 3a0017ac..0d1df837 100644 --- a/components/settings/DownloadSettings.tsx +++ b/components/settings/DownloadSettings.tsx @@ -1,3 +1,143 @@ -export default function DownloadSettings() { - return null; +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 ( + + + + + + + + {settings.downloadMethod === DownloadMethod.Remux + ? t("home.settings.downloads.default") + : t("home.settings.downloads.optimized")} + + + + + + + {t("home.settings.downloads.download_method")} + + { + updateSettings({ downloadMethod: DownloadMethod.Remux }); + setProcesses([]); + }} + > + + {t("home.settings.downloads.default")} + + + { + updateSettings({ downloadMethod: DownloadMethod.Optimized }); + setProcesses([]); + queryClient.invalidateQueries({ queryKey: ["search"] }); + }} + > + + {t("home.settings.downloads.optimized")} + + + + + + + + + updateSettings({ + remuxConcurrentLimit: value as Settings["remuxConcurrentLimit"], + }) + } + /> + + + + updateSettings({ autoDownload: value })} + /> + + + router.push("/settings/optimized-server/page")} + showArrow + title={t("home.settings.downloads.optimized_versions_server")} + > + + + ); } diff --git a/components/settings/DownloadSettings.tv.tsx b/components/settings/DownloadSettings.tv.tsx index 3a0017ac..8cd6fa73 100644 --- a/components/settings/DownloadSettings.tv.tsx +++ b/components/settings/DownloadSettings.tv.tsx @@ -1,3 +1,5 @@ -export default function DownloadSettings() { - return null; +import React from "react"; + +export default function DownloadSettings({ ...props }) { + return <>; } diff --git a/components/settings/HomeIndex.tsx b/components/settings/HomeIndex.tsx new file mode 100644 index 00000000..9c0919f5 --- /dev/null +++ b/components/settings/HomeIndex.tsx @@ -0,0 +1,507 @@ +import { Button } from "@/components/Button"; +import { Text } from "@/components/common/Text"; +import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel"; +import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList"; +import { Loader } from "@/components/Loader"; +import { MediaListSection } from "@/components/medialists/MediaListSection"; +import { Colors } from "@/constants/Colors"; +import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; +import { useDownload } from "@/providers/DownloadProvider"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { useSettings } from "@/utils/atoms/settings"; +import { eventBus } from "@/utils/eventBus"; +import { Feather, Ionicons } from "@expo/vector-icons"; +import { Api } from "@jellyfin/sdk"; +import { + BaseItemDto, + BaseItemKind, +} from "@jellyfin/sdk/lib/generated-client/models"; +import { + getItemsApi, + getSuggestionsApi, + getTvShowsApi, + getUserLibraryApi, + getUserViewsApi, +} from "@jellyfin/sdk/lib/utils/api"; +import NetInfo from "@react-native-community/netinfo"; +import { QueryFunction, useQuery } from "@tanstack/react-query"; +import { + useNavigation, + usePathname, + useRouter, + useSegments, +} from "expo-router"; +import { useAtomValue } from "jotai"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + ActivityIndicator, + RefreshControl, + ScrollView, + TouchableOpacity, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +type ScrollingCollectionListSection = { + type: "ScrollingCollectionList"; + title?: string; + queryKey: (string | undefined | null)[]; + queryFn: QueryFunction; + orientation?: "horizontal" | "vertical"; +}; + +type MediaListSection = { + type: "MediaListSection"; + queryKey: (string | undefined)[]; + queryFn: QueryFunction; +}; + +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(null); + const [loadingRetry, setLoadingRetry] = useState(false); + + const navigation = useNavigation(); + + const insets = useSafeAreaInsets(); + + const scrollViewRef = useRef(null); + + const { downloadedFiles, cleanCacheDirectory } = useDownload(); + useEffect(() => { + const hasDownloads = downloadedFiles && downloadedFiles.length > 0; + navigation.setOptions({ + headerLeft: () => ( + { + router.push("/(auth)/downloads"); + }} + className="p-2" + > + + + ), + }); + }, [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 ( + + {t("home.no_internet")} + + {t("home.no_internet_message")} + + + + + + + ); + } + + if (e1) + return ( + + {t("home.oops")} + + {t("home.error_message")} + + + ); + + if (l1) + return ( + + + + ); + + return ( + + } + contentContainerStyle={{ + paddingLeft: insets.left, + paddingRight: insets.right, + paddingBottom: 16, + }} + > + + + + {sections.map((section, index) => { + if (section.type === "ScrollingCollectionList") { + return ( + + ); + } else if (section.type === "MediaListSection") { + return ( + + ); + } + return null; + })} + + + ); +}; + +// 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; +} diff --git a/components/settings/HomeIndex.tv.tsx b/components/settings/HomeIndex.tv.tsx new file mode 100644 index 00000000..b7a8633c --- /dev/null +++ b/components/settings/HomeIndex.tv.tsx @@ -0,0 +1,453 @@ +import { Button } from "@/components/Button"; +import { Text } from "@/components/common/Text"; +import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel"; +import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList"; +import { Loader } from "@/components/Loader"; +import { MediaListSection } from "@/components/medialists/MediaListSection"; +import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { useSettings } from "@/utils/atoms/settings"; +import { Ionicons } from "@expo/vector-icons"; +import { Api } from "@jellyfin/sdk"; +import { + BaseItemDto, + BaseItemKind, +} from "@jellyfin/sdk/lib/generated-client/models"; +import { + getItemsApi, + getSuggestionsApi, + getTvShowsApi, + getUserLibraryApi, + getUserViewsApi, +} from "@jellyfin/sdk/lib/utils/api"; +import NetInfo from "@react-native-community/netinfo"; +import { QueryFunction, useQuery } from "@tanstack/react-query"; +import { useRouter } from "expo-router"; +import { useAtomValue } from "jotai"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + ActivityIndicator, + RefreshControl, + ScrollView, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +type ScrollingCollectionListSection = { + type: "ScrollingCollectionList"; + title?: string; + queryKey: (string | undefined | null)[]; + queryFn: QueryFunction; + orientation?: "horizontal" | "vertical"; +}; + +type MediaListSection = { + type: "MediaListSection"; + queryKey: (string | undefined)[]; + queryFn: QueryFunction; +}; + +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(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 ( + + {t("home.no_internet")} + + {t("home.no_internet_message")} + + + + + + + ); + } + + if (e1) + return ( + + {t("home.oops")} + + {t("home.error_message")} + + + ); + + if (l1) + return ( + + + + ); + + return ( + + } + contentContainerStyle={{ + paddingLeft: insets.left, + paddingRight: insets.right, + paddingBottom: 16, + }} + > + + + + {sections.map((section, index) => { + if (section.type === "ScrollingCollectionList") { + return ( + + ); + } else if (section.type === "MediaListSection") { + return ( + + ); + } + return null; + })} + + + ); +}; + +// 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; +} diff --git a/components/settings/Jellyseerr.tsx b/components/settings/Jellyseerr.tsx index 470d40a2..bd381c11 100644 --- a/components/settings/Jellyseerr.tsx +++ b/components/settings/Jellyseerr.tsx @@ -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,13 +14,17 @@ import { ListGroup } from "../list/ListGroup"; import { ListItem } from "../list/ListItem"; export const JellyseerrSettings = () => { - const { jellyseerrUser, setJellyseerrUser, clearAllJellyseerData } = - useJellyseerr(); + const { + jellyseerrApi, + jellyseerrUser, + setJellyseerrUser, + clearAllJellyseerData, + } = useJellyseerr(); const { t } = useTranslation(); const [user] = useAtom(userAtom); - const { settings, updateSettings } = useSettings(); + const [settings, updateSettings, pluginSettings] = useSettings(); const [jellyseerrPassword, setJellyseerrPassword] = useState< string | undefined @@ -37,7 +41,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"); @@ -64,14 +68,14 @@ export const JellyseerrSettings = () => { }; return ( - + {jellyseerrUser ? ( <> @@ -105,69 +109,69 @@ export const JellyseerrSettings = () => { /> - - ) : ( - - + + {t("home.settings.plugins.jellyseerr.jellyseerr_warning")} - + {t("home.settings.plugins.jellyseerr.server_url")} - - + + {t("home.settings.plugins.jellyseerr.server_url_hint")} - + {t("home.settings.plugins.jellyseerr.password")}