Compare commits

...

29 Commits

Author SHA1 Message Date
Uruk
9a17e36882 fix: normalize base URL in SeerrApi constructor to ensure protocol prefix 2026-01-14 21:50:18 +01:00
Uruk
391ca897a5 docs: update internationalization guidelines for translation management 2026-01-14 20:31:21 +01:00
Gauvain
c8fefdf4a1 Merge branch 'develop' into remove-optimized-server 2026-01-14 20:08:05 +01:00
Uruk
956eea8848 docs: enhance TypeScript standards and remove type suppressions
Strengthens code quality guidelines by establishing strict TypeScript practices that prohibit `any` types and minimize type suppressions.

Updates Copilot instructions to emphasize production-ready code with comprehensive type safety rules, error handling requirements, and reliability standards. Explicitly discourages type escape hatches in favor of proper type definitions.

Refactors navigation implementation to use URLSearchParams instead of object-based params, eliminating the need for type suppression while maintaining functionality.

Removes unnecessary type error suppressions and unused properties throughout codebase, aligning with new standards.
2026-01-14 16:27:37 +01:00
Uruk
895c245254 chore: add translation cleanup tooling and remove unused keys
- Add check-unused-translations.js script for detecting unused i18n keys
- Remove unused player keys: refresh_tracks, audio_tracks, playback_state, index
- Remove unused aspect_ratio section (7 keys)
- Update copilot-instructions.md with i18n guidelines (only edit en.json, Crowdin handles other languages)
2026-01-14 14:51:42 +01:00
Uruk
376d2e84da docs: add internationalization guidelines to Copilot instructions 2026-01-14 14:16:53 +01:00
Uruk
12047cbe12 fix: correct dependency arrays and add null checks
Fixes missing dependencies in useMemo and useCallback hooks to prevent stale closures and potential bugs.

Adds null/undefined guards before navigation in music components to prevent crashes when attempting to navigate with missing IDs.

Corrects query key from "company" to "genre" in genre page to ensure proper cache invalidation.

Updates Jellyseerr references to Seerr throughout documentation and error messages for consistency.

Improves type safety by adding error rejection handling in SeerrApi and memoizing components to optimize re-renders.
2026-01-14 10:24:57 +01:00
Gauvain
32f4bbcc7d Merge branch 'develop' into remove-optimized-server 2026-01-14 10:13:15 +01:00
Uruk
138a86f473 chore: remove unused region variable in person page 2026-01-12 11:43:12 +01:00
Uruk
6dd111defe fix: resolve merge conflict in useSeerr.ts - keep improved BCP 47 locale logic 2026-01-12 11:40:13 +01:00
Uruk
4ba03b669e docs: restructure README to improve clarity and organization
Reorganizes the README documentation to better communicate features and setup instructions.

Renames "Experimental Features" section to "How It Works" to better reflect the maturity and purpose of the features. Removes emphasis on experimental status of downloads and Chromecast, consolidating information under clearer headings.

Improves formatting and consistency throughout:
- Standardizes heading levels and removes emoji prefixes from subsections
- Enhances development setup instructions with better formatting and clearer steps
- Improves license section readability with proper paragraph spacing
- Adds consistent punctuation to section endings

Removes detailed feature descriptions for music library and consolidates search providers information under a dedicated subsection, making the document more scannable and focused on how the app works rather than listing every feature.
2026-01-12 11:35:05 +01:00
Gauvain
aacf5327d1 Merge branch 'develop' into remove-optimized-server 2026-01-12 11:06:24 +01:00
Uruk
f543fa9e3e refactor: remove unused code and simplify implementations
Removes extensive dead code including unused components, utilities, and augmentations that were no longer referenced in the codebase.

Simplifies play settings logic by removing complex stream ranking algorithm in favor of direct previous index matching for audio and subtitle selections.

Removes aspectRatio prop from video player as it was set to a constant "default" value and never changed.

Inlines POSTER_CAROUSEL_HEIGHT constant directly where used instead of importing from centralized constants file.

Eliminates unused features including image color extraction for TV platforms, M3U8 subtitle parsing, and various Jellyfin API helpers that were no longer needed.

Cleans up credential management by making internal helper functions private that should not be exposed to external consumers.
2026-01-12 11:04:23 +01:00
Uruk
4385fe5502 fix: correct capitalization in UI messages
Standardizes capitalization in user-facing messages to follow proper sentence casing conventions.

Changes "Downloaded" to "downloaded" in the no internet message and "No Trailer Available" to "No trailer available" for consistency with standard UI text formatting guidelines.
2026-01-12 10:09:01 +01:00
Uruk
3d72c9c783 Merge branch 'remove-optimized-server' of https://github.com/streamyfin/streamyfin into remove-optimized-server 2026-01-12 09:44:11 +01:00
Uruk
e41d1b4818 fix: hide tags dropdown when no tags are available
Prevents the tags selection UI from rendering when the service has no tags configured.

Wraps the tags dropdown component in a conditional check to only display when tags exist in the default service details, avoiding empty or broken UI states.
2026-01-12 09:41:24 +01:00
Uruk
fd3766fc23 fix: add missing React keys to streamystats sections
Resolves React warning about missing keys in list rendering by converting fragment to array with unique key props for each streamystats component.

Also wraps promoted watchlists in a View container to properly handle props spreading, preventing props from being passed through to individual watchlist sections.
2026-01-12 09:41:24 +01:00
Uruk
f211a9ce7a refactor: rename Jellyseerr to Seerr throughout codebase
Updates branding and naming conventions to use "Seerr" instead of "Jellyseerr" across all files, components, hooks, and translations.

Renames files, functions, classes, variables, and UI text to reflect the new naming convention while maintaining identical functionality. Updates asset references including logo and screenshot images.

Changes API class name, storage keys, atom names, and all related utilities to use "Seerr" prefix. Modifies translation keys and user-facing text to match the rebrand.
2026-01-12 09:41:24 +01:00
Uruk
b7db06f53d docs: update README and fix typos across codebase
Enhances README with comprehensive feature categorization including Media Playback, Media Management, and Advanced Features sections

Expands documentation for music library support, search providers (Marlin, Streamystats, Jellysearch), and plugin functionality

Updates FAQ section with more detailed answers about library visibility, downloads, subtitles, TV platform support, and Seerr integration

Corrects typos throughout the application:
- Fixes "liraries" to "libraries" in hide libraries settings
- Changes "centralised" to "centralized" for consistency
- Updates "Jellyseerr" references to "Seerr" throughout codebase

Adds missing translations for player settings including aspect ratio options, alignment controls, and MPV subtitle customization

Improves consistency in capitalization and punctuation across translation strings
2026-01-12 09:41:24 +01:00
Uruk
ceac74dbfa refactor: remove unused download settings components
Removes empty download settings component files and simplifies download feature description in translations.

The download settings components contained only placeholder implementations that returned null, indicating they were not in use. Updates the feature description to remove references to optimized server and background downloads, providing a clearer and more straightforward explanation of the download functionality.
2026-01-12 09:41:24 +01:00
Uruk
b6bd427e19 style: standardizes capitalization and punctuation in English translations
Improves consistency across user-facing text by:

- Normalizing sentence case in error messages and labels (e.g., "Username is required" instead of "Username Is Required")
- Fixing inconsistent capitalization in feature descriptions and UI text
- Standardizing punctuation, particularly replacing semicolons where appropriate and ensuring consistent usage of "log in" vs "login"
- Correcting article usage ("the left side" instead of "left side")
- Updating terminology consistency (e.g., "tv shows" instead of "tv-shows", "optimized" instead of "optimize")

These changes enhance readability and professionalism throughout the application interface.
2026-01-12 09:41:24 +01:00
Uruk
47bf1c9201 fix: hide tags dropdown when no tags are available
Prevents the tags selection UI from rendering when the service has no tags configured.

Wraps the tags dropdown component in a conditional check to only display when tags exist in the default service details, avoiding empty or broken UI states.
2026-01-12 09:40:50 +01:00
Uruk
4aaddd2104 fix: add missing React keys to streamystats sections
Resolves React warning about missing keys in list rendering by converting fragment to array with unique key props for each streamystats component.

Also wraps promoted watchlists in a View container to properly handle props spreading, preventing props from being passed through to individual watchlist sections.
2026-01-12 09:37:49 +01:00
Uruk
4a75e8f551 refactor: rename Jellyseerr to Seerr throughout codebase
Updates branding and naming conventions to use "Seerr" instead of "Jellyseerr" across all files, components, hooks, and translations.

Renames files, functions, classes, variables, and UI text to reflect the new naming convention while maintaining identical functionality. Updates asset references including logo and screenshot images.

Changes API class name, storage keys, atom names, and all related utilities to use "Seerr" prefix. Modifies translation keys and user-facing text to match the rebrand.
2026-01-12 09:26:19 +01:00
Uruk
0c8c27bfc0 docs: update README and fix typos across codebase
Enhances README with comprehensive feature categorization including Media Playback, Media Management, and Advanced Features sections

Expands documentation for music library support, search providers (Marlin, Streamystats, Jellysearch), and plugin functionality

Updates FAQ section with more detailed answers about library visibility, downloads, subtitles, TV platform support, and Seerr integration

Corrects typos throughout the application:
- Fixes "liraries" to "libraries" in hide libraries settings
- Changes "centralised" to "centralized" for consistency
- Updates "Jellyseerr" references to "Seerr" throughout codebase

Adds missing translations for player settings including aspect ratio options, alignment controls, and MPV subtitle customization

Improves consistency in capitalization and punctuation across translation strings
2026-01-11 22:15:43 +01:00
Uruk
67e61f3ab8 refactor: remove unused download settings components
Removes empty download settings component files and simplifies download feature description in translations.

The download settings components contained only placeholder implementations that returned null, indicating they were not in use. Updates the feature description to remove references to optimized server and background downloads, providing a clearer and more straightforward explanation of the download functionality.
2026-01-11 20:09:48 +01:00
Uruk
c66541ce4d style: standardizes capitalization and punctuation in English translations
Improves consistency across user-facing text by:

- Normalizing sentence case in error messages and labels (e.g., "Username is required" instead of "Username Is Required")
- Fixing inconsistent capitalization in feature descriptions and UI text
- Standardizing punctuation, particularly replacing semicolons where appropriate and ensuring consistent usage of "log in" vs "login"
- Correcting article usage ("the left side" instead of "left side")
- Updating terminology consistency (e.g., "tv shows" instead of "tv-shows", "optimized" instead of "optimize")

These changes enhance readability and professionalism throughout the application interface.
2026-01-11 20:04:07 +01:00
zhangchuang
07a0a48613 refactor: improve locale handling with regex-based region detection
Address reviewer feedback by implementing regex check to detect if locale already contains region code. This prevents invalid BCP 47 tags like "zh-CN-CN" while maintaining backward compatibility.

- Use /^[a-z]{2,3}-/i regex to detect existing region in locale
- Only append region if locale doesn't already contain it
- Centralize logic in useJellyseerr hook for DRY principle
- All 7 usage points automatically benefit from this fix

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 13:00:51 +08:00
bot2
b630e0784b fix: correct invalid BCP 47 language tag in Jellyseerr components
Fix RangeError caused by invalid locale tags (e.g., zh-CN-US) in
multiple Jellyseerr components. The locale variable already contains
a complete BCP 47 language tag (e.g., zh-CN), so concatenating it
with region (e.g., US) creates an invalid tag.

Changes:
- DetailFacts.tsx: Fix 5 occurrences in date and currency formatting
- JellyseerrSeasons.tsx: Fix 1 occurrence in upcoming air date
- person/[personId].tsx: Fix 1 occurrence in birthday formatting

Total: 3 files, 7 fixes

Closes #1130

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 13:00:51 +08:00
97 changed files with 1121 additions and 2221 deletions

View File

@@ -3,7 +3,7 @@
## Project Overview
Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native).
It supports mobile (iOS/Android) and TV platforms, integrates with Jellyfin and Jellyseerr APIs,
It supports mobile (iOS/Android) and TV platforms, integrates with Jellyfin and Seerr APIs,
and provides seamless media streaming with offline capabilities and Chromecast support.
## Main Technologies
@@ -40,9 +40,30 @@ and provides seamless media streaming with offline capabilities and Chromecast s
- `scripts/` Automation scripts (Node.js, Bash)
- `plugins/` Expo/Metro plugins
## Coding Standards
## Code Quality Standards
**CRITICAL: Code must be production-ready, reliable, and maintainable**
### Type Safety
- Use TypeScript for ALL files (no .js files)
- **NEVER use `any` type** - use proper types, generics, or `unknown` with type guards
- Use `@ts-expect-error` with detailed comments only when necessary (e.g., library limitations)
- When facing type issues, create proper type definitions and helper functions instead of using `any`
- Use type assertions (`as`) only as a last resort with clear documentation explaining why
- For Expo Router navigation: prefer string URLs with `URLSearchParams` over object syntax to avoid type conflicts
- Enable and respect strict TypeScript compiler options
- Define explicit return types for functions
- Use discriminated unions for complex state
### Code Reliability
- Implement comprehensive error handling with try-catch blocks
- Validate all external inputs (API responses, user input, query params)
- Handle edge cases explicitly (empty arrays, null, undefined)
- Use optional chaining (`?.`) and nullish coalescing (`??`) appropriately
- Add runtime checks for critical operations
- Implement proper loading and error states in components
### Best Practices
- Use descriptive English names for variables, functions, and components
- Prefer functional React components with hooks
- Use Jotai atoms for global state management
@@ -50,8 +71,10 @@ and provides seamless media streaming with offline capabilities and Chromecast s
- Follow BiomeJS formatting and linting rules
- Use `const` over `let`, avoid `var` entirely
- Implement proper error boundaries
- Use React.memo() for performance optimization
- Use React.memo() for performance optimization when needed
- Handle both mobile and TV navigation patterns
- Write self-documenting code with clear intent
- Add comments only when code complexity requires explanation
## API Integration
@@ -85,6 +108,18 @@ Exemples:
- `fix(auth): handle expired JWT tokens`
- `chore(deps): update Jellyfin SDK`
## Internationalization (i18n)
- **Primary workflow**: Always edit `translations/en.json` for new translation keys or updates
- **Translation files** (ar.json, ca.json, cs.json, de.json, etc.):
- **NEVER add or remove keys** - Crowdin manages the key structure
- **Editing translation values is safe** - Bidirectional sync handles merges
- Prefer letting Crowdin translators update values, but direct edits work if needed
- **Crowdin workflow**:
- New keys added to `en.json` sync to Crowdin automatically
- Approved translations sync back to language files via GitHub integration
- The source of truth is `en.json` for structure, Crowdin for translations
## Special Instructions
- Prioritize cross-platform compatibility (mobile + TV)

View File

@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native). It supports mobile (iOS/Android) and TV platforms, with features including offline downloads, Chromecast support, and Jellyseerr integration.
Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native). It supports mobile (iOS/Android) and TV platforms, with features including offline downloads, Chromecast support, and Seerr integration.
## Development Commands

100
README.md
View File

@@ -22,58 +22,75 @@
&nbsp;
<img src="./assets/images/screenshots/screenshot2.png" width="20%">
&nbsp;
<img src="./assets/images/jellyseerr.PNG" width="21%">
<img src="./assets/images/seerr.PNG" width="21%">
</p>
## 🌟 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
### 🎬 Media Playback
- 🚀 **Skip Intro / Credits**: Automatically skip intros and credits during playback
- 🖼️ **Trickplay Images**: Chapter previews with thumbnails when seeking
- 🎵 **Music Library**: Full support for music playback with playlists and queue management
- 📺 **Live TV**: Watch and record live television streams
- 📡 **Chromecast**: Cast your media to any Chromecast-enabled device
- 🎥 **MPV Player**: Powerful open-source player with wide format support
## 🧪 Experimental Features
### 📱 Media Management
- 📥 **Download Media**: Save movies, shows, and music locally for offline viewing
-**Favorites**: Quick access to your favorite content
- 📋 **Watchlists**: Create and manage custom watchlists with Streamystats integration
- 🔖 **Continue Watching**: Pick up right where you left off
- 🎯 **Next Up**: Smart suggestions for your next episode
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.
### ⚙️ Advanced Features
- 🤖 **Seerr Integration**: Request new media directly in the app
- 🔍 **Smart Search**: Powerful search with Marlin Search and Streamystats support
- 👁️ **Active Sessions**: View all active streams on your server
- 🌐 **Multi-Language**: Available in 20+ languages with Crowdin integration
- 🎨 **Customizable**: Personalize your home screen and settings
- 🔌 **Plugin System**: Centralized settings sync across all devices via Jellyfin plugin
### 📥 Downloading
## 🧩 How It Works
### 📥 Downloads
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.
### 🧩 Streamyfin Plugin
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:
The Jellyfin Plugin for Streamyfin synchronizes settings across all your devices and users. Install it on your Jellyfin server to enable:
- Automatic Seerr login with no user input required
- Set your preferred default languages
- Configure download method and search provider
- Personalize your home screen
- Default language preferences for audio and subtitles
- Configure download settings and search providers (Marlin, Streamystats)
- Customize your home screen layout and sections
- Centralized configuration management
- And much more
[Streamyfin Plugin](https://github.com/streamyfin/jellyfin-plugin-streamyfin)
### 📡 Chromecast
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
### 🎵 Music Library
[Jellysearch](https://gitlab.com/DomiStyle/jellysearch) works with Streamyfin
Full music library support with playlists, queue management, background playback, and offline downloads.
> A fast full-text search proxy for Jellyfin. Integrates seamlessly with most Jellyfin clients.
### 🔍 Search Providers
Streamyfin supports multiple search providers:
- **Marlin Search**: Fast semantic search for your Jellyfin library
- **Streamystats**: Advanced statistics and personalized recommendations
- **Jellysearch**: Fast full-text search proxy ([Jellysearch](https://gitlab.com/DomiStyle/jellysearch))
## 🛣️ Roadmap
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 to feedback and suggestions. Please let us know if you have any ideas or feature requests.
## 📥 Download Streamyfin
@@ -113,13 +130,13 @@ You can contribute translations directly on our [Crowdin project page](https://c
### 👨‍💻 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/)
1. Use Node.js `>20`
2. Install dependencies: `bun i && bun run submodule-reload`
3. Make sure you have Xcode and/or Android Studio installed ([Expo setup guide](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
4. Install the [BiomeJS extension](https://biomejs.dev/) in your IDE
5. Run `npm run prebuild`
6. 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`.
@@ -137,10 +154,20 @@ Need assistance or have any questions?
## ❓ 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
1. **Q: Why can't I see my libraries in Streamyfin?**
A: Ensure your Jellyfin server is running a recent version (10.10.0+) and that you have proper permissions to access the libraries.
2. **Q: How do I enable downloads?**
A: Downloads use FFmpeg to convert HLS streams. Ensure your server has transcoding enabled and sufficient resources.
3. **Q: Does Streamyfin support subtitles?**
A: Yes, with full customization including size, color, position, and automatic language selection.
4. **Q: Can I use Streamyfin on Apple TV or Android TV?**
A: Yes, Streamyfin has dedicated TV builds optimized for remote control navigation. Please note that TV platforms are currently in early development and not very stable. Android TV is currently the most reliable platform for testing.
5. **Q: How do I set up Seerr integration?**
A: Go to Settings → Plugins → Seerr, enter your server URL and Jellyfin credentials.
## 📝 Credits
@@ -254,7 +281,9 @@ A special mention to the following people and projects for their contributions:
## 📄 License
Streamyfin is licensed under the Mozilla Public License 2.0 (MPL-2.0).
This means you are free to use, modify, and distribute this software. The MPL-2.0 is a copyleft license that allows for more flexibility in combining the software with proprietary code.
Key points of the MPL-2.0:
- You can use the software for any purpose
@@ -263,10 +292,13 @@ 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.
## 🤝 Sponsorship
VPS hosting generously provided by [Hexabyte](https://hexabyte.se/en/vps/?currency=eur) and [SweHosting](https://swehosting.se/en/#tj%C3%A4nster)
VPS hosting generously provided by [Hexabyte](https://hexabyte.se/en/vps/?currency=eur) and [SweHosting](https://swehosting.se/en/#tj%C3%A4nster).

View File

@@ -222,9 +222,9 @@ export default function IndexLayout() {
}}
/>
<Stack.Screen
name='settings/plugins/jellyseerr/page'
name='settings/plugins/seerr/page'
options={{
title: "Jellyseerr",
title: "Seerr",
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,

View File

@@ -71,7 +71,7 @@ export default function page() {
))}
</ListGroup>
<Text className='px-4 text-xs text-neutral-500 mt-1'>
{t("home.settings.other.select_liraries_you_want_to_hide")}
{t("home.settings.other.select_libraries_you_want_to_hide")}
</Text>
</DisabledSetting>
</ScrollView>

View File

@@ -60,7 +60,7 @@ export default function page() {
))}
</ListGroup>
<Text className='px-4 text-xs text-neutral-500 mt-1'>
{t("home.settings.other.select_liraries_you_want_to_hide")}
{t("home.settings.other.select_libraries_you_want_to_hide")}
</Text>
</DisabledSetting>
);

View File

@@ -1,10 +1,10 @@
import { ScrollView } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import DisabledSetting from "@/components/settings/DisabledSetting";
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
import { SeerrSettings } from "@/components/settings/Seerr";
import { useSettings } from "@/utils/atoms/settings";
export default function page() {
export default function Page() {
const { pluginSettings } = useSettings();
const insets = useSafeAreaInsets();
@@ -17,10 +17,10 @@ export default function page() {
}}
>
<DisabledSetting
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
disabled={pluginSettings?.seerrServerUrl?.locked === true}
className='px-4'
>
<JellyseerrSettings />
<SeerrSettings />
</DisabledSetting>
</ScrollView>
);

View File

@@ -3,9 +3,9 @@ import { Image } from "expo-image";
import { useLocalSearchParams } from "expo-router";
import { uniqBy } from "lodash";
import { useMemo } from "react";
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
import SeerrPoster from "@/components/posters/SeerrPoster";
import ParallaxSlideShow from "@/components/seerr/ParallaxSlideShow";
import { Endpoints, useSeerr } from "@/hooks/useSeerr";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
import {
type MovieResult,
@@ -13,9 +13,9 @@ import {
} from "@/utils/jellyseerr/server/models/Search";
import { COMPANY_LOGO_IMAGE_FILTER } from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
export default function page() {
export default function CompanyPage() {
const local = useLocalSearchParams();
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
const { seerrApi, isSeerrMovieOrTvResult } = useSeerr();
const { companyId, image, type } = local as unknown as {
companyId: string;
@@ -25,12 +25,12 @@ export default function page() {
};
const { data, fetchNextPage, hasNextPage, isLoading } = useInfiniteQuery({
queryKey: ["jellyseerr", "company", type, companyId],
queryKey: ["seerr", "company", type, companyId],
queryFn: async ({ pageParam }) => {
const params: any = {
page: Number(pageParam),
};
return jellyseerrApi?.discover(
return seerrApi?.discover(
`${
Number(type) === DiscoverSliderType.NETWORKS
? Endpoints.DISCOVER_TV_NETWORK
@@ -39,7 +39,7 @@ export default function page() {
params,
);
},
enabled: !!jellyseerrApi && !!companyId,
enabled: !!seerrApi && !!companyId,
initialPageParam: 1,
getNextPageParam: (lastPage, pages) =>
(lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
@@ -53,25 +53,24 @@ export default function page() {
data?.pages
?.filter((p) => p?.results.length)
.flatMap(
(p) =>
p?.results.filter((r) => isJellyseerrMovieOrTvResult(r)) ?? [],
(p) => p?.results.filter((r) => isSeerrMovieOrTvResult(r)) ?? [],
),
"id",
) ?? [],
[data],
[data, isSeerrMovieOrTvResult],
);
const backdrops = useMemo(
() =>
jellyseerrApi
seerrApi
? flatData.map((r) =>
jellyseerrApi.imageProxy(
seerrApi.imageProxy(
(r as TvResult | MovieResult).backdropPath,
"w1920_and_h800_multi_faces",
),
)
: [],
[jellyseerrApi, flatData],
[seerrApi, flatData],
);
return (
@@ -92,7 +91,7 @@ export default function page() {
key={companyId}
className='bottom-1 w-1/2'
source={{
uri: jellyseerrApi?.imageProxy(image, COMPANY_LOGO_IMAGE_FILTER),
uri: seerrApi?.imageProxy(image, COMPANY_LOGO_IMAGE_FILTER),
}}
cachePolicy={"memory-disk"}
contentFit='contain'
@@ -101,7 +100,7 @@ export default function page() {
}}
/>
}
renderItem={(item, _index) => <JellyseerrPoster item={item} />}
renderItem={(item, _index) => <SeerrPoster item={item} />}
/>
);
}

View File

@@ -3,15 +3,15 @@ import { useLocalSearchParams } from "expo-router";
import { uniqBy } from "lodash";
import { useMemo } from "react";
import { Text } from "@/components/common/Text";
import { textShadowStyle } from "@/components/jellyseerr/discover/GenericSlideCard";
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
import SeerrPoster from "@/components/posters/SeerrPoster";
import { textShadowStyle } from "@/components/seerr/discover/GenericSlideCard";
import ParallaxSlideShow from "@/components/seerr/ParallaxSlideShow";
import { Endpoints, useSeerr } from "@/hooks/useSeerr";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
export default function page() {
export default function GenrePage() {
const local = useLocalSearchParams();
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
const { seerrApi, isSeerrMovieOrTvResult } = useSeerr();
const { genreId, name, type } = local as unknown as {
genreId: string;
@@ -20,21 +20,21 @@ export default function page() {
};
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ["jellyseerr", "company", type, genreId],
queryKey: ["seerr", "genre", type, genreId],
queryFn: async ({ pageParam }) => {
const params: any = {
page: Number(pageParam),
genre: genreId,
};
return jellyseerrApi?.discover(
return seerrApi?.discover(
type === DiscoverSliderType.MOVIE_GENRES
? Endpoints.DISCOVER_MOVIES
: Endpoints.DISCOVER_TV,
params,
);
},
enabled: !!jellyseerrApi && !!genreId,
enabled: !!seerrApi && !!genreId,
initialPageParam: 1,
getNextPageParam: (lastPage, pages) =>
(lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
@@ -48,8 +48,7 @@ export default function page() {
data?.pages
?.filter((p) => p?.results.length)
.flatMap(
(p) =>
p?.results.filter((r) => isJellyseerrMovieOrTvResult(r)) ?? [],
(p) => p?.results.filter((r) => isSeerrMovieOrTvResult(r)) ?? [],
),
"id",
) ?? [],
@@ -58,15 +57,12 @@ export default function page() {
const backdrops = useMemo(
() =>
jellyseerrApi
seerrApi
? flatData.map((r) =>
jellyseerrApi.imageProxy(
r.backdropPath,
"w1920_and_h800_multi_faces",
),
seerrApi.imageProxy(r.backdropPath, "w1920_and_h800_multi_faces"),
)
: [],
[jellyseerrApi, flatData],
[seerrApi, flatData],
);
return (
@@ -91,7 +87,7 @@ export default function page() {
{name}
</Text>
}
renderItem={(item, _index) => <JellyseerrPoster item={item} />}
renderItem={(item, _index) => <SeerrPoster item={item} />}
/>
);
}

View File

@@ -18,18 +18,18 @@ import { toast } from "sonner-native";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { GenreTags } from "@/components/GenreTags";
import Cast from "@/components/jellyseerr/Cast";
import DetailFacts from "@/components/jellyseerr/DetailFacts";
import RequestModal from "@/components/jellyseerr/RequestModal";
import { OverviewText } from "@/components/OverviewText";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import { PlatformDropdown } from "@/components/PlatformDropdown";
import { JellyserrRatings } from "@/components/Ratings";
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
import { SeerrRatings } from "@/components/Ratings";
import Cast from "@/components/seerr/Cast";
import DetailFacts from "@/components/seerr/DetailFacts";
import RequestModal from "@/components/seerr/RequestModal";
import SeerrSeasons from "@/components/series/SeerrSeasons";
import { ItemActions } from "@/components/series/SeriesActions";
import useRouter from "@/hooks/useAppRouter";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
import { useSeerr } from "@/hooks/useSeerr";
import { useSeerrCanRequest } from "@/utils/_seerr/useSeerrCanRequest";
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
import {
type IssueType,
@@ -68,7 +68,7 @@ const Page: React.FC = () => {
} & Partial<MovieResult | TvResult | MovieDetails | TvDetails>;
const navigation = useNavigation();
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
const { seerrApi, seerrUser, requestMedia } = useSeerr();
const [issueType, setIssueType] = useState<IssueType>();
const [issueMessage, setIssueMessage] = useState<string>();
@@ -83,8 +83,8 @@ const Page: React.FC = () => {
isLoading,
refetch,
} = useQuery({
enabled: !!jellyseerrApi && !!result && !!result.id,
queryKey: ["jellyseerr", "detail", mediaType, result.id],
enabled: !!seerrApi && !!result && !!result.id,
queryKey: ["seerr", "detail", mediaType, result.id],
staleTime: 0,
refetchOnMount: true,
refetchOnReconnect: true,
@@ -93,21 +93,18 @@ const Page: React.FC = () => {
refetchInterval: 0,
queryFn: async () => {
return mediaType === MediaType.MOVIE
? jellyseerrApi?.movieDetails(result.id!)
: jellyseerrApi?.tvDetails(result.id!);
? seerrApi?.movieDetails(result.id!)
: seerrApi?.tvDetails(result.id!);
},
});
const [canRequest, hasAdvancedRequestPermission] =
useJellyseerrCanRequest(details);
useSeerrCanRequest(details);
const canManageRequests = useMemo(() => {
if (!jellyseerrUser) return false;
return hasPermission(
Permission.MANAGE_REQUESTS,
jellyseerrUser.permissions,
);
}, [jellyseerrUser]);
if (!seerrUser) return false;
return hasPermission(Permission.MANAGE_REQUESTS, seerrUser.permissions);
}, [seerrUser]);
const pendingRequest = useMemo(() => {
return details?.mediaInfo?.requests?.find(
@@ -119,27 +116,27 @@ const Page: React.FC = () => {
if (!pendingRequest?.id) return;
try {
await jellyseerrApi?.approveRequest(pendingRequest.id);
toast.success(t("jellyseerr.toasts.request_approved"));
await seerrApi?.approveRequest(pendingRequest.id);
toast.success(t("seerr.toasts.request_approved"));
refetch();
} catch (error) {
toast.error(t("jellyseerr.toasts.failed_to_approve_request"));
toast.error(t("seerr.toasts.failed_to_approve_request"));
console.error("Failed to approve request:", error);
}
}, [jellyseerrApi, pendingRequest, refetch, t]);
}, [seerrApi, pendingRequest, refetch, t]);
const handleDeclineRequest = useCallback(async () => {
if (!pendingRequest?.id) return;
try {
await jellyseerrApi?.declineRequest(pendingRequest.id);
toast.success(t("jellyseerr.toasts.request_declined"));
await seerrApi?.declineRequest(pendingRequest.id);
toast.success(t("seerr.toasts.request_declined"));
refetch();
} catch (error) {
toast.error(t("jellyseerr.toasts.failed_to_decline_request"));
toast.error(t("seerr.toasts.failed_to_decline_request"));
console.error("Failed to decline request:", error);
}
}, [jellyseerrApi, pendingRequest, refetch, t]);
}, [seerrApi, pendingRequest, refetch, t]);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
@@ -154,7 +151,7 @@ const Page: React.FC = () => {
const submitIssue = useCallback(() => {
if (result.id && issueType && issueMessage && details) {
jellyseerrApi
seerrApi
?.submitIssue(details.mediaInfo.id, Number(issueType), issueMessage)
.then(() => {
setIssueType(undefined);
@@ -162,7 +159,7 @@ const Page: React.FC = () => {
bottomSheetModalRef?.current?.close();
});
}
}, [jellyseerrApi, details, result, issueType, issueMessage]);
}, [seerrApi, details, result, issueType, issueMessage]);
const handleIssueModalDismiss = useCallback(() => {
setIssueTypeDropdownOpen(false);
@@ -214,7 +211,7 @@ const Page: React.FC = () => {
const issueTypeOptionGroups = useMemo(
() => [
{
title: t("jellyseerr.types"),
title: t("seerr.types"),
options: Object.entries(IssueTypeName)
.reverse()
.map(([key, value]) => ({
@@ -265,7 +262,7 @@ const Page: React.FC = () => {
height: "100%",
}}
source={{
uri: jellyseerrApi?.imageProxy(
uri: seerrApi?.imageProxy(
result.backdropPath,
"w1920_and_h800_multi_faces",
),
@@ -295,7 +292,7 @@ const Page: React.FC = () => {
<View className='px-4'>
<View className='flex flex-row justify-between w-full'>
<View className='flex flex-col w-56'>
<JellyserrRatings
<SeerrRatings
result={
result as
| MovieResult
@@ -330,7 +327,7 @@ const Page: React.FC = () => {
/>
) : canRequest ? (
<Button color='purple' onPress={request} className='mt-4'>
{t("jellyseerr.request_button")}
{t("seerr.request_button")}
</Button>
) : (
details?.mediaInfo?.jellyfinMediaId && (
@@ -353,7 +350,7 @@ const Page: React.FC = () => {
}}
>
<Text className='text-sm'>
{t("jellyseerr.report_issue_button")}
{t("seerr.report_issue_button")}
</Text>
</Button>
)}
@@ -389,12 +386,12 @@ const Page: React.FC = () => {
<View className='flex flex-row items-center space-x-2'>
<Ionicons name='person-outline' size={16} color='#9CA3AF' />
<Text className='text-sm text-neutral-400'>
{t("jellyseerr.requested_by", {
{t("seerr.requested_by", {
user:
pendingRequest.requestedBy?.displayName ||
pendingRequest.requestedBy?.username ||
pendingRequest.requestedBy?.jellyfinUsername ||
t("jellyseerr.unknown_user"),
t("seerr.unknown_user"),
})}
</Text>
</View>
@@ -415,7 +412,7 @@ const Page: React.FC = () => {
borderStyle: "solid",
}}
>
<Text className='text-sm'>{t("jellyseerr.approve")}</Text>
<Text className='text-sm'>{t("seerr.approve")}</Text>
</Button>
<Button
className='flex-1 bg-red-600/50 border-red-400 ring-red-400 text-red-100'
@@ -433,7 +430,7 @@ const Page: React.FC = () => {
borderStyle: "solid",
}}
>
<Text className='text-sm'>{t("jellyseerr.decline")}</Text>
<Text className='text-sm'>{t("seerr.decline")}</Text>
</Button>
</View>
</View>
@@ -442,7 +439,7 @@ const Page: React.FC = () => {
</View>
{mediaType === MediaType.TV && (
<JellyseerrSeasons
<SeerrSeasons
isLoading={isLoading || isFetching}
details={details as TvDetails}
refetch={refetch}
@@ -491,13 +488,13 @@ const Page: React.FC = () => {
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
<View>
<Text className='font-bold text-2xl text-neutral-100'>
{t("jellyseerr.whats_wrong")}
{t("seerr.whats_wrong")}
</Text>
</View>
<View className='flex flex-col space-y-2 items-start'>
<View className='flex flex-col w-full'>
<Text className='opacity-50 mb-1 text-xs'>
{t("jellyseerr.issue_type")}
{t("seerr.issue_type")}
</Text>
<PlatformDropdown
groups={issueTypeOptionGroups}
@@ -506,11 +503,11 @@ const Page: React.FC = () => {
<Text numberOfLines={1}>
{issueType
? IssueTypeName[issueType]
: t("jellyseerr.select_an_issue")}
: t("seerr.select_an_issue")}
</Text>
</View>
}
title={t("jellyseerr.types")}
title={t("seerr.types")}
open={issueTypeDropdownOpen}
onOpenChange={setIssueTypeDropdownOpen}
/>
@@ -522,7 +519,7 @@ const Page: React.FC = () => {
maxLength={254}
style={{ color: "white" }}
clearButtonMode='always'
placeholder={t("jellyseerr.describe_the_issue")}
placeholder={t("seerr.describe_the_issue")}
placeholderTextColor='#9CA3AF'
// Issue with multiline + Textinput inside a portal
// https://github.com/callstack/react-native-paper/issues/1668
@@ -532,7 +529,7 @@ const Page: React.FC = () => {
</View>
</View>
<Button className='mt-auto' onPress={submitIssue} color='purple'>
{t("jellyseerr.submit_button")}
{t("seerr.submit_button")}
</Button>
</View>
</BottomSheetView>

View File

@@ -5,31 +5,27 @@ 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 SeerrPoster from "@/components/posters/SeerrPoster";
import ParallaxSlideShow from "@/components/seerr/ParallaxSlideShow";
import { useSeerr } from "@/hooks/useSeerr";
import type { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
export default function page() {
export default function PersonPage() {
const local = useLocalSearchParams();
const { t } = useTranslation();
const {
jellyseerrApi,
jellyseerrRegion: region,
jellyseerrLocale: locale,
} = useJellyseerr();
const { seerrApi, seerrLocale: locale } = useSeerr();
const { personId } = local as { personId: string };
const { data } = useQuery({
queryKey: ["jellyseerr", "person", personId],
queryKey: ["seerr", "person", personId],
queryFn: async () => ({
details: await jellyseerrApi?.personDetails(personId),
combinedCredits: await jellyseerrApi?.personCombinedCredits(personId),
details: await seerrApi?.personDetails(personId),
combinedCredits: await seerrApi?.personCombinedCredits(personId),
}),
enabled: !!jellyseerrApi && !!personId,
enabled: !!seerrApi && !!personId,
});
const castedRoles: PersonCreditCast[] = useMemo(
@@ -46,22 +42,19 @@ export default function page() {
);
const backdrops = useMemo(
() =>
jellyseerrApi
seerrApi
? castedRoles.map((c) =>
jellyseerrApi.imageProxy(
c.backdropPath,
"w1920_and_h800_multi_faces",
),
seerrApi.imageProxy(c.backdropPath, "w1920_and_h800_multi_faces"),
)
: [],
[jellyseerrApi, data?.combinedCredits],
[seerrApi, data?.combinedCredits],
);
return (
<ParallaxSlideShow
data={castedRoles}
images={backdrops}
listHeader={t("jellyseerr.appearances")}
listHeader={t("seerr.appearances")}
keyExtractor={(item) => item.id.toString()}
logo={
<Image
@@ -69,7 +62,7 @@ export default function page() {
id={data?.details?.id.toString()}
className='rounded-full bottom-1'
source={{
uri: jellyseerrApi?.imageProxy(
uri: seerrApi?.imageProxy(
data?.details?.profilePath,
"w600_and_h600_bestv2",
),
@@ -86,16 +79,13 @@ export default function page() {
<>
<Text className='font-bold text-2xl mb-1'>{data?.details?.name}</Text>
<Text className='opacity-50'>
{t("jellyseerr.born")}{" "}
{t("seerr.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, {
year: "numeric",
month: "long",
day: "numeric",
})}{" "}
| {data?.details?.placeOfBirth}
</Text>
</>
@@ -103,7 +93,7 @@ export default function page() {
MainContent={() => (
<OverviewText text={data?.details?.biography} className='mt-4' />
)}
renderItem={(item, _index) => <JellyseerrPoster item={item} />}
renderItem={(item, _index) => <SeerrPoster item={item} />}
/>
);
}

View File

@@ -33,17 +33,17 @@ export default function SearchLayout() {
headerShadowVisible: false,
}}
/>
<Stack.Screen name='jellyseerr/page' options={commonScreenOptions} />
<Stack.Screen name='seerr/page' options={commonScreenOptions} />
<Stack.Screen
name='jellyseerr/person/[personId]'
name='seerr/person/[personId]'
options={commonScreenOptions}
/>
<Stack.Screen
name='jellyseerr/company/[companyId]'
name='seerr/company/[companyId]'
options={commonScreenOptions}
/>
<Stack.Screen
name='jellyseerr/genre/[genreId]'
name='seerr/genre/[genreId]'
options={commonScreenOptions}
/>
</Stack>

View File

@@ -26,18 +26,18 @@ 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 {
SeerrIndexPage,
SeerrSearchSort,
} from "@/components/seerr/SeerrIndexPage";
import useRouter from "@/hooks/useAppRouter";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useSeerr } from "@/hooks/useSeerr";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus";
@@ -55,7 +55,7 @@ const exampleSearches = [
"The Mandalorian",
];
export default function search() {
export default function SearchPage() {
const params = useLocalSearchParams();
const insets = useSafeAreaInsets();
const router = useRouter();
@@ -93,16 +93,11 @@ export default function search() {
const [api] = useAtom(apiAtom);
const { settings } = useSettings();
const { jellyseerrApi } = useJellyseerr();
const [jellyseerrOrderBy, setJellyseerrOrderBy] =
useState<JellyseerrSearchSort>(
JellyseerrSearchSort[
JellyseerrSearchSort.DEFAULT
] as unknown as JellyseerrSearchSort,
);
const [jellyseerrSortOrder, setJellyseerrSortOrder] = useState<
"asc" | "desc"
>("desc");
const { seerrApi } = useSeerr();
const [seerrOrderBy, setSeerrOrderBy] = useState<SeerrSearchSort>(
SeerrSearchSort[SeerrSearchSort.DEFAULT] as unknown as SeerrSearchSort,
);
const [seerrSortOrder, setSeerrSortOrder] = useState<"asc" | "desc">("desc");
const searchEngine = useMemo(() => {
return settings?.searchEngine || "Jellyfin";
@@ -474,7 +469,7 @@ export default function search() {
className='flex flex-col'
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
>
{jellyseerrApi && (
{seerrApi && (
<View className='pl-4 pr-4 flex flex-row'>
<SearchTabButtons
searchType={searchType}
@@ -488,10 +483,10 @@ export default function search() {
<DiscoverFilters
searchFilterId={searchFilterId}
orderFilterId={orderFilterId}
jellyseerrOrderBy={jellyseerrOrderBy}
setJellyseerrOrderBy={setJellyseerrOrderBy}
jellyseerrSortOrder={jellyseerrSortOrder}
setJellyseerrSortOrder={setJellyseerrSortOrder}
seerrOrderBy={seerrOrderBy}
setSeerrOrderBy={setSeerrOrderBy}
seerrSortOrder={seerrSortOrder}
setSeerrSortOrder={setSeerrSortOrder}
t={t}
/>
)}
@@ -754,10 +749,10 @@ export default function search() {
/>
</View>
) : (
<JellyserrIndexPage
<SeerrIndexPage
searchQuery={debouncedSearch}
sortType={jellyseerrOrderBy}
order={jellyseerrSortOrder}
sortType={seerrOrderBy}
order={seerrSortOrder}
/>
)}

View File

@@ -71,9 +71,6 @@ export default function page() {
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
const [showControls, _setShowControls] = useState(true);
const [isPipMode, setIsPipMode] = useState(false);
const [aspectRatio] = useState<"default" | "16:9" | "4:3" | "1:1" | "21:9">(
"default",
);
const [isZoomedToFill, setIsZoomedToFill] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(false);
@@ -971,7 +968,6 @@ export default function page() {
pause={pause}
seek={seek}
enableTrickplay={true}
aspectRatio={aspectRatio}
isZoomedToFill={isZoomedToFill}
onZoomToggle={handleZoomToggle}
api={api}

View File

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

@@ -1,4 +1,3 @@
export * from "./api";
export * from "./mmkv";
export * from "./number";
export * from "./string";

View File

@@ -3,7 +3,6 @@ declare global {
bytesToReadable(decimals?: number): string;
secondsToMilliseconds(): number;
minutesToMilliseconds(): number;
hoursToMilliseconds(): number;
}
}
@@ -28,8 +27,4 @@ Number.prototype.minutesToMilliseconds = function () {
return this.valueOf() * (60).secondsToMilliseconds();
};
Number.prototype.hoursToMilliseconds = function () {
return this.valueOf() * (60).minutesToMilliseconds();
};
export {};

View File

@@ -1,14 +0,0 @@
declare global {
interface String {
toTitle(): string;
}
}
String.prototype.toTitle = function () {
return this.replaceAll("_", " ").replace(
/\w\S*/g,
(text) => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase(),
);
};
export {};

View File

@@ -1,203 +0,0 @@
/**
* Example Usage of Global Modal
*
* This file demonstrates how to use the global modal system from anywhere in your app.
* You can delete this file after understanding how it works.
*/
import { Ionicons } from "@expo/vector-icons";
import { TouchableOpacity, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useGlobalModal } from "@/providers/GlobalModalProvider";
/**
* Example 1: Simple Content Modal
*/
export const SimpleModalExample = () => {
const { showModal } = useGlobalModal();
const handleOpenModal = () => {
showModal(
<View className='p-6'>
<Text className='text-2xl font-bold mb-4 text-white'>Simple Modal</Text>
<Text className='text-white mb-4'>
This is a simple modal with just some text content.
</Text>
<Text className='text-neutral-400'>
Swipe down or tap outside to close.
</Text>
</View>,
);
};
return (
<TouchableOpacity
onPress={handleOpenModal}
className='bg-purple-600 px-4 py-2 rounded-lg'
>
<Text className='text-white font-semibold'>Open Simple Modal</Text>
</TouchableOpacity>
);
};
/**
* Example 2: Modal with Custom Snap Points
*/
export const CustomSnapPointsExample = () => {
const { showModal } = useGlobalModal();
const handleOpenModal = () => {
showModal(
<View className='p-6' style={{ minHeight: 400 }}>
<Text className='text-2xl font-bold mb-4 text-white'>
Custom Snap Points
</Text>
<Text className='text-white mb-4'>
This modal has custom snap points (25%, 50%, 90%).
</Text>
<View className='bg-neutral-800 p-4 rounded-lg'>
<Text className='text-white'>
Try dragging the modal to different heights!
</Text>
</View>
</View>,
{
snapPoints: ["25%", "50%", "90%"],
enableDynamicSizing: false,
},
);
};
return (
<TouchableOpacity
onPress={handleOpenModal}
className='bg-blue-600 px-4 py-2 rounded-lg'
>
<Text className='text-white font-semibold'>Custom Snap Points</Text>
</TouchableOpacity>
);
};
/**
* Example 3: Complex Component in Modal
*/
const SettingsModalContent = () => {
const { hideModal } = useGlobalModal();
const settings = [
{
id: 1,
title: "Notifications",
icon: "notifications-outline" as const,
enabled: true,
},
{ id: 2, title: "Dark Mode", icon: "moon-outline" as const, enabled: true },
{
id: 3,
title: "Auto-play",
icon: "play-outline" as const,
enabled: false,
},
];
return (
<View className='p-6'>
<Text className='text-2xl font-bold mb-6 text-white'>Settings</Text>
{settings.map((setting, index) => (
<View
key={setting.id}
className={`flex-row items-center justify-between py-4 ${
index !== settings.length - 1 ? "border-b border-neutral-700" : ""
}`}
>
<View className='flex-row items-center gap-3'>
<Ionicons name={setting.icon} size={24} color='white' />
<Text className='text-white text-lg'>{setting.title}</Text>
</View>
<View
className={`w-12 h-7 rounded-full ${
setting.enabled ? "bg-purple-600" : "bg-neutral-600"
}`}
>
<View
className={`w-5 h-5 rounded-full bg-white shadow-md transform ${
setting.enabled ? "translate-x-6" : "translate-x-1"
}`}
style={{ marginTop: 4 }}
/>
</View>
</View>
))}
<TouchableOpacity
onPress={hideModal}
className='bg-purple-600 px-4 py-3 rounded-lg mt-6'
>
<Text className='text-white font-semibold text-center'>Close</Text>
</TouchableOpacity>
</View>
);
};
export const ComplexModalExample = () => {
const { showModal } = useGlobalModal();
const handleOpenModal = () => {
showModal(<SettingsModalContent />);
};
return (
<TouchableOpacity
onPress={handleOpenModal}
className='bg-green-600 px-4 py-2 rounded-lg'
>
<Text className='text-white font-semibold'>Complex Component</Text>
</TouchableOpacity>
);
};
/**
* Example 4: Modal Triggered from Function (e.g., API response)
*/
export const useShowSuccessModal = () => {
const { showModal } = useGlobalModal();
return (message: string) => {
showModal(
<View className='p-6 items-center'>
<View className='bg-green-500 rounded-full p-4 mb-4'>
<Ionicons name='checkmark' size={48} color='white' />
</View>
<Text className='text-2xl font-bold mb-2 text-white'>Success!</Text>
<Text className='text-white text-center'>{message}</Text>
</View>,
);
};
};
/**
* Main Demo Component
*/
export const GlobalModalDemo = () => {
const showSuccess = useShowSuccessModal();
return (
<View className='p-6 gap-4'>
<Text className='text-2xl font-bold mb-4 text-white'>
Global Modal Examples
</Text>
<SimpleModalExample />
<CustomSnapPointsExample />
<ComplexModalExample />
<TouchableOpacity
onPress={() => showSuccess("Operation completed successfully!")}
className='bg-orange-600 px-4 py-2 rounded-lg'
>
<Text className='text-white font-semibold'>Show Success Modal</Text>
</TouchableOpacity>
</View>
);
};

View File

@@ -89,16 +89,16 @@ export const IntroSheet = forwardRef<IntroSheetRef>((_, ref) => {
</Text>
<View className='flex flex-row items-center mt-4'>
<Image
source={require("@/assets/icons/jellyseerr-logo.svg")}
source={require("@/assets/icons/seerr-logo.svg")}
style={{
width: 50,
height: 50,
}}
/>
<View className='shrink ml-2'>
<Text className='font-bold mb-1'>Jellyseerr</Text>
<Text className='font-bold mb-1'>Seerr</Text>
<Text className='shrink text-xs'>
{t("home.intro.jellyseerr_feature_description")}
{t("home.intro.seerr_feature_description")}
</Text>
</View>
</View>
@@ -158,12 +158,12 @@ export const IntroSheet = forwardRef<IntroSheetRef>((_, ref) => {
</View>
<View className='shrink ml-2'>
<Text className='font-bold mb-1'>
{t("home.intro.centralised_settings_plugin_title")}
{t("home.intro.centralized_settings_plugin_title")}
</Text>
<View className='flex-row flex-wrap items-baseline'>
<Text className='shrink text-xs'>
{t(
"home.intro.centralised_settings_plugin_description",
"home.intro.centralized_settings_plugin_description",
)}{" "}
</Text>
<TouchableOpacity

View File

@@ -11,9 +11,11 @@ import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { ItemCardText } from "@/components/ItemCardText";
import MoviePoster from "@/components/posters/MoviePoster";
import { POSTER_CAROUSEL_HEIGHT } from "@/constants/Values";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
// Matches `w-28` poster cards (approx 112px wide, 10/15 aspect ratio) + 2 lines of text.
const POSTER_CAROUSEL_HEIGHT = 220;
interface Props extends ViewProps {
actorId: string;
actorName?: string | null;

View File

@@ -4,7 +4,7 @@ import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useMemo } from "react";
import { View, type ViewProps } from "react-native";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useSeerr } from "@/hooks/useSeerr";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
import type {
@@ -55,23 +55,23 @@ export const Ratings: React.FC<Props> = ({ item, ...props }) => {
);
};
export const JellyserrRatings: React.FC<{
export const SeerrRatings: React.FC<{
result: MovieResult | TvResult | TvDetails | MovieDetails;
}> = ({ result }) => {
const { jellyseerrApi, getMediaType } = useJellyseerr();
const { seerrApi, getMediaType } = useSeerr();
const mediaType = useMemo(() => getMediaType(result), [result]);
const { data, isLoading } = useQuery({
queryKey: ["jellyseerr", result.id, mediaType, "ratings"],
queryKey: ["seerr", result.id, mediaType, "ratings"],
queryFn: async () => {
return mediaType === MediaType.MOVIE
? jellyseerrApi?.movieRatings(result.id)
: jellyseerrApi?.tvRatings(result.id);
? seerrApi?.movieRatings(result.id)
: seerrApi?.tvRatings(result.id);
},
staleTime: (5).minutesToMilliseconds(),
retry: false,
enabled: !!jellyseerrApi,
enabled: !!seerrApi,
});
return (

View File

@@ -6,8 +6,11 @@ import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { View, type ViewProps } from "react-native";
import MoviePoster from "@/components/posters/MoviePoster";
import { POSTER_CAROUSEL_HEIGHT } from "@/constants/Values";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
// Matches `w-28` poster cards (approx 112px wide, 10/15 aspect ratio) + 2 lines of text.
const POSTER_CAROUSEL_HEIGHT = 220;
import { HorizontalScroll } from "./common/HorizontalScroll";
import { Text } from "./common/Text";
import { TouchableItemRouter } from "./common/TouchableItemRouter";

View File

@@ -26,7 +26,7 @@ export const TrackSheet: React.FC<Props> = ({
const streams = useMemo(
() => source?.MediaStreams?.filter((x) => x.Type === streamType),
[source],
[source, streamType],
);
const selectedSteam = useMemo(

View File

@@ -1,20 +0,0 @@
import { Image } from "expo-image";
import { View } from "react-native";
export const LargePoster: React.FC<{ url?: string | null }> = ({ url }) => {
if (!url)
return (
<View className='p-4 rounded-xl overflow-hidden '>
<View className='w-full aspect-video rounded-xl overflow-hidden border border-neutral-800' />
</View>
);
return (
<View className='p-4 rounded-xl overflow-hidden '>
<Image
source={{ uri: url }}
className='w-full aspect-video rounded-xl overflow-hidden border border-neutral-800'
/>
</View>
);
};

View File

@@ -21,7 +21,7 @@ interface Props extends TouchableOpacityProps {
mediaType: MediaType;
}
export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
export const TouchableSeerrRouter: React.FC<PropsWithChildren<Props>> = ({
result,
mediaTitle,
releaseYear,
@@ -42,18 +42,24 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
onPress={() => {
if (!result) return;
router.push({
pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`,
// @ts-expect-error
params: {
...result,
mediaTitle,
releaseYear,
canRequest: canRequest.toString(),
posterSrc,
mediaType,
},
// Build URL with query params - avoids Expo Router's strict type checking
const params = new URLSearchParams({
...Object.fromEntries(
Object.entries(result).map(([key, value]) => [
key,
String(value ?? ""),
]),
),
mediaTitle,
releaseYear: releaseYear.toString(),
canRequest: canRequest.toString(),
posterSrc,
mediaType: mediaType.toString(),
});
router.push(
`/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/seerr/page?${params.toString()}`,
);
}}
{...props}
>

View File

@@ -1,28 +0,0 @@
import { View, type ViewProps } from "react-native";
interface Props extends ViewProps {
index: number;
}
export const VerticalSkeleton: React.FC<Props> = ({ index, ...props }) => {
return (
<View
key={index}
style={{
width: "32%",
}}
className='flex flex-col'
{...props}
>
<View
style={{
aspectRatio: "10/15",
}}
className='w-full bg-neutral-800 mb-2 rounded-lg'
/>
<View className='h-2 bg-neutral-800 rounded-full mb-1' />
<View className='h-2 bg-neutral-800 rounded-full mb-1' />
<View className='h-2 bg-neutral-800 rounded-full mb-2 w-1/2' />
</View>
);
};

View File

@@ -569,29 +569,31 @@ export const HomeWithCarousel = () => {
settings.streamyStatsSeriesRecommendations ||
settings.streamyStatsPromotedWatchlists;
const streamystatsSections =
index === streamystatsIndex && hasStreamystatsContent ? (
<>
{settings.streamyStatsMovieRecommendations && (
<StreamystatsRecommendations
title={t(
"home.settings.plugins.streamystats.recommended_movies",
)}
type='Movie'
/>
)}
{settings.streamyStatsSeriesRecommendations && (
<StreamystatsRecommendations
title={t(
"home.settings.plugins.streamystats.recommended_series",
)}
type='Series'
/>
)}
{settings.streamyStatsPromotedWatchlists && (
<StreamystatsPromotedWatchlists />
)}
</>
) : null;
index === streamystatsIndex && hasStreamystatsContent
? [
settings.streamyStatsMovieRecommendations && (
<StreamystatsRecommendations
key='movie-recommendations'
title={t(
"home.settings.plugins.streamystats.recommended_movies",
)}
type='Movie'
/>
),
settings.streamyStatsSeriesRecommendations && (
<StreamystatsRecommendations
key='series-recommendations'
title={t(
"home.settings.plugins.streamystats.recommended_series",
)}
type='Series'
/>
),
settings.streamyStatsPromotedWatchlists && (
<StreamystatsPromotedWatchlists key='promoted-watchlists' />
),
].filter(Boolean)
: null;
if (section.type === "InfiniteScrollingCollectionList") {
return (

View File

@@ -247,15 +247,14 @@ export const StreamystatsPromotedWatchlists: React.FC<
}
return (
<>
<View {...props}>
{watchlists?.map((watchlist) => (
<WatchlistSection
key={watchlist.id}
watchlist={watchlist}
jellyfinServerId={jellyfinServerId!}
{...props}
/>
))}
</>
</View>
);
};

View File

@@ -51,7 +51,7 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
api,
item: library,
}),
[library],
[api, library],
);
const itemType = useMemo(() => {

View File

@@ -23,10 +23,8 @@ export const MusicAlbumCard: React.FC<Props> = ({ album, width = 130 }) => {
);
const handlePress = useCallback(() => {
router.push({
pathname: "/music/album/[albumId]",
params: { albumId: album.Id! },
});
if (!album.Id) return;
router.push(`/music/album/${album.Id}`);
}, [router, album.Id]);
return (

View File

@@ -24,10 +24,8 @@ export const MusicAlbumRowCard: React.FC<Props> = ({ album }) => {
);
const handlePress = useCallback(() => {
router.push({
pathname: "/music/album/[albumId]",
params: { albumId: album.Id! },
});
if (!album.Id) return;
router.push(`/music/album/${album.Id}`);
}, [router, album.Id]);
return (

View File

@@ -25,10 +25,8 @@ export const MusicArtistCard: React.FC<Props> = ({ artist }) => {
);
const handlePress = useCallback(() => {
router.push({
pathname: "/music/artist/[artistId]",
params: { artistId: artist.Id! },
});
if (!artist.Id) return;
router.push(`/music/artist/${artist.Id}`);
}, [router, artist.Id]);
return (

View File

@@ -61,10 +61,7 @@ export const MusicPlaylistCard: React.FC<Props> = ({ playlist }) => {
const hasDownloads = downloadStatus.downloaded > 0;
const handlePress = useCallback(() => {
router.push({
pathname: "/music/playlist/[playlistId]",
params: { playlistId: playlist.Id! },
});
router.push(`/music/playlist/${playlist.Id}`);
}, [router, playlist.Id]);
return (

View File

@@ -197,10 +197,7 @@ export const TrackOptionsSheet: React.FC<Props> = ({
const artistId = track?.ArtistItems?.[0]?.Id;
if (artistId) {
setOpen(false);
router.push({
pathname: "/music/artist/[artistId]",
params: { artistId },
});
router.push(`/music/artist/${artistId}`);
}
}, [track?.ArtistItems, router, setOpen]);
@@ -208,10 +205,7 @@ export const TrackOptionsSheet: React.FC<Props> = ({
const albumId = track?.AlbumId || track?.ParentId;
if (albumId) {
setOpen(false);
router.push({
pathname: "/music/album/[albumId]",
params: { albumId },
});
router.push(`/music/album/${albumId}`);
}
}, [track?.AlbumId, track?.ParentId, router, setOpen]);

View File

@@ -1,12 +0,0 @@
// You can explore the built-in icon families and icons on the web at https://icons.expo.fyi/
import type { IconProps } from "@expo/vector-icons/build/createIconSet";
import Ionicons from "@expo/vector-icons/Ionicons";
import type { ComponentProps } from "react";
export function TabBarIcon({
style,
...rest
}: IconProps<ComponentProps<typeof Ionicons>["name"]>) {
return <Ionicons size={26} style={[{ marginBottom: -3 }, style]} {...rest} />;
}

View File

@@ -1,63 +0,0 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useMemo, useState } from "react";
import { View } from "react-native";
import { WatchedIndicator } from "@/components/WatchedIndicator";
import { apiAtom } from "@/providers/JellyfinProvider";
type MoviePosterProps = {
item: BaseItemDto;
showProgress?: boolean;
};
export const EpisodePoster: React.FC<MoviePosterProps> = ({
item,
showProgress = false,
}) => {
const [api] = useAtom(apiAtom);
const url = useMemo(() => {
if (item.Type === "Episode") {
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
}
}, [item]);
const [progress, _setProgress] = useState(
item.UserData?.PlayedPercentage || 0,
);
const blurhash = useMemo(() => {
const key = item.ImageTags?.Primary as string;
return item.ImageBlurHashes?.Primary?.[key];
}, [item]);
return (
<View className='relative rounded-lg overflow-hidden border border-neutral-900'>
<Image
placeholder={{
blurhash,
}}
key={item.Id}
id={item.Id}
source={
url
? {
uri: url,
}
: null
}
cachePolicy={"memory-disk"}
contentFit='cover'
style={{
aspectRatio: "10/15",
width: "100%",
}}
/>
<WatchedIndicator item={item} />
{showProgress && progress > 0 && (
<View className='h-1 bg-red-600 w-full' />
)}
</View>
);
};

View File

@@ -1,48 +0,0 @@
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { View } from "react-native";
import { apiAtom } from "@/providers/JellyfinProvider";
type PosterProps = {
id?: string;
showProgress?: boolean;
};
const ParentPoster: React.FC<PosterProps> = ({ id }) => {
const [api] = useAtom(apiAtom);
const url = useMemo(
() => `${api?.basePath}/Items/${id}/Images/Primary`,
[id],
);
if (!url || !id)
return (
<View
className='border border-neutral-900'
style={{
aspectRatio: "10/15",
}}
/>
);
return (
<View className='rounded-lg overflow-hidden border border-neutral-900'>
<Image
key={id}
id={id}
source={{
uri: url,
}}
cachePolicy={"memory-disk"}
contentFit='cover'
style={{
aspectRatio: "10/15",
}}
/>
</View>
);
};
export default ParentPoster;

View File

@@ -7,15 +7,15 @@ import Animated, {
useSharedValue,
withTiming,
} from "react-native-reanimated";
import { TouchableJellyseerrRouter } from "@/components/common/JellyseerrItemRouter";
import { TouchableSeerrRouter } from "@/components/common/SeerrItemRouter";
import { Text } from "@/components/common/Text";
import { Tag, Tags } from "@/components/GenreTags";
import { textShadowStyle } from "@/components/jellyseerr/discover/GenericSlideCard";
import JellyseerrMediaIcon from "@/components/jellyseerr/JellyseerrMediaIcon";
import JellyseerrStatusIcon from "@/components/jellyseerr/JellyseerrStatusIcon";
import { textShadowStyle } from "@/components/seerr/discover/GenericSlideCard";
import SeerrMediaIcon from "@/components/seerr/SeerrMediaIcon";
import SeerrStatusIcon from "@/components/seerr/SeerrStatusIcon";
import { Colors } from "@/constants/Colors";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
import { useSeerr } from "@/hooks/useSeerr";
import { useSeerrCanRequest } from "@/utils/_seerr/useSeerrCanRequest";
import { MediaStatus } from "@/utils/jellyseerr/server/constants/media";
import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
import type { DownloadingItem } from "@/utils/jellyseerr/server/lib/downloadtracker";
@@ -34,13 +34,13 @@ interface Props extends ViewProps {
mediaRequest?: MediaRequest;
}
const JellyseerrPoster: React.FC<Props> = ({
const SeerrPoster: React.FC<Props> = ({
item,
horizontal,
showDownloadInfo,
mediaRequest,
}) => {
const { jellyseerrApi, getTitle, getYear, getMediaType } = useJellyseerr();
const { seerrApi, getTitle, getYear, getMediaType } = useSeerr();
const loadingOpacity = useSharedValue(1);
const imageOpacity = useSharedValue(0);
const { t } = useTranslation();
@@ -56,16 +56,13 @@ const JellyseerrPoster: React.FC<Props> = ({
const backdropSrc = useMemo(
() =>
jellyseerrApi?.imageProxy(
item?.backdropPath,
"w1920_and_h800_multi_faces",
),
[item, jellyseerrApi, horizontal],
seerrApi?.imageProxy(item?.backdropPath, "w1920_and_h800_multi_faces"),
[item, seerrApi, horizontal],
);
const posterSrc = useMemo(
() => jellyseerrApi?.imageProxy(item?.posterPath, "w300_and_h450_face"),
[item, jellyseerrApi, horizontal],
() => seerrApi?.imageProxy(item?.posterPath, "w300_and_h450_face"),
[item, seerrApi, horizontal],
);
const title = useMemo(() => getTitle(item), [item]);
@@ -75,7 +72,7 @@ const JellyseerrPoster: React.FC<Props> = ({
const size = useMemo(() => (horizontal ? "h-28" : "w-28"), [horizontal]);
const ratio = useMemo(() => (horizontal ? "15/10" : "10/15"), [horizontal]);
const [canRequest] = useJellyseerrCanRequest(item);
const [canRequest] = useSeerrCanRequest(item);
const is4k = useMemo(() => mediaRequest?.is4k === true, [mediaRequest]);
@@ -109,7 +106,7 @@ const JellyseerrPoster: React.FC<Props> = ({
second,
third,
fourth,
t("home.settings.plugins.jellyseerr.plus_n_more", { n: rest.length }),
t("home.settings.plugins.seerr.plus_n_more", { n: rest.length }),
];
}
return seasons;
@@ -121,7 +118,7 @@ const JellyseerrPoster: React.FC<Props> = ({
}, [mediaRequest, is4k]);
return (
<TouchableJellyseerrRouter
<TouchableSeerrRouter
result={item}
mediaTitle={title}
releaseYear={releaseYear}
@@ -173,7 +170,7 @@ const JellyseerrPoster: React.FC<Props> = ({
className='absolute right-1 top-1 text-right bg-black border border-neutral-800/50'
text={mediaRequest?.requestedBy.displayName}
/>
{requestedSeasons.length > 0 && (
{(requestedSeasons?.length ?? 0) > 0 && (
<Tags
className='absolute bottom-1 left-0.5 w-32'
tagProps={{
@@ -184,12 +181,12 @@ const JellyseerrPoster: React.FC<Props> = ({
)}
</>
)}
<JellyseerrStatusIcon
<SeerrStatusIcon
className='absolute bottom-1 right-1'
showRequestIcon={canRequest}
mediaStatus={mediaRequest?.media?.status || item?.mediaInfo?.status}
/>
<JellyseerrMediaIcon
<SeerrMediaIcon
className='absolute top-1 left-1'
mediaType={mediaType}
/>
@@ -201,8 +198,8 @@ const JellyseerrPoster: React.FC<Props> = ({
{releaseYear || ""}
</Text>
</View>
</TouchableJellyseerrRouter>
</TouchableSeerrRouter>
);
};
export default JellyseerrPoster;
export default SeerrPoster;

View File

@@ -1,19 +1,19 @@
import { Button, ContextMenu, Host, Picker } from "@expo/ui/swift-ui";
import { Platform, View } from "react-native";
import { FilterButton } from "@/components/filters/FilterButton";
import { JellyseerrSearchSort } from "@/components/jellyseerr/JellyseerrIndexPage";
import { SeerrSearchSort } from "@/components/seerr/SeerrIndexPage";
interface DiscoverFiltersProps {
searchFilterId: string;
orderFilterId: string;
jellyseerrOrderBy: JellyseerrSearchSort;
setJellyseerrOrderBy: (value: JellyseerrSearchSort) => void;
jellyseerrSortOrder: "asc" | "desc";
setJellyseerrSortOrder: (value: "asc" | "desc") => void;
seerrOrderBy: SeerrSearchSort;
setSeerrOrderBy: (value: SeerrSearchSort) => void;
seerrSortOrder: "asc" | "desc";
setSeerrSortOrder: (value: "asc" | "desc") => void;
t: (key: string) => string;
}
const sortOptions = Object.keys(JellyseerrSearchSort).filter((v) =>
const sortOptions = Object.keys(SeerrSearchSort).filter((v) =>
Number.isNaN(Number(v)),
);
@@ -22,10 +22,10 @@ const orderOptions = ["asc", "desc"] as const;
export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
searchFilterId,
orderFilterId,
jellyseerrOrderBy,
setJellyseerrOrderBy,
jellyseerrSortOrder,
setJellyseerrSortOrder,
seerrOrderBy,
setSeerrOrderBy,
seerrSortOrder,
setSeerrSortOrder,
t,
}) => {
if (Platform.OS === "ios") {
@@ -52,16 +52,16 @@ export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
<Picker
label={t("library.filters.sort_by")}
options={sortOptions.map((item) =>
t(`home.settings.plugins.jellyseerr.order_by.${item}`),
t(`home.settings.plugins.seerr.order_by.${item}`),
)}
variant='menu'
selectedIndex={sortOptions.indexOf(
jellyseerrOrderBy as unknown as string,
seerrOrderBy as unknown as string,
)}
onOptionSelected={(event: any) => {
const index = event.nativeEvent.index;
setJellyseerrOrderBy(
sortOptions[index] as unknown as JellyseerrSearchSort,
setSeerrOrderBy(
sortOptions[index] as unknown as SeerrSearchSort,
);
}}
/>
@@ -69,10 +69,10 @@ export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
label={t("library.filters.sort_order")}
options={orderOptions.map((item) => t(`library.filters.${item}`))}
variant='menu'
selectedIndex={orderOptions.indexOf(jellyseerrSortOrder)}
selectedIndex={orderOptions.indexOf(seerrSortOrder)}
onOptionSelected={(event: any) => {
const index = event.nativeEvent.index;
setJellyseerrSortOrder(orderOptions[index]);
setSeerrSortOrder(orderOptions[index]);
}}
/>
</ContextMenu.Items>
@@ -86,17 +86,15 @@ export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
<View className='flex flex-row justify-end items-center space-x-1'>
<FilterButton
id={searchFilterId}
queryKey='jellyseerr_search'
queryKey='seerr_search'
queryFn={async () =>
Object.keys(JellyseerrSearchSort).filter((v) =>
Number.isNaN(Number(v)),
)
Object.keys(SeerrSearchSort).filter((v) => Number.isNaN(Number(v)))
}
set={(value) => setJellyseerrOrderBy(value[0])}
values={[jellyseerrOrderBy]}
set={(value) => setSeerrOrderBy(value[0])}
values={[seerrOrderBy]}
title={t("library.filters.sort_by")}
renderItemLabel={(item) =>
t(`home.settings.plugins.jellyseerr.order_by.${item}`)
t(`home.settings.plugins.seerr.order_by.${item}`)
}
disableSearch={true}
/>
@@ -104,8 +102,8 @@ export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
id={orderFilterId}
queryKey='jellysearr_search'
queryFn={async () => ["asc", "desc"]}
set={(value) => setJellyseerrSortOrder(value[0])}
values={[jellyseerrSortOrder]}
set={(value) => setSeerrSortOrder(value[0])}
values={[seerrSortOrder]}
title={t("library.filters.sort_order")}
renderItemLabel={(item) => t(`library.filters.${item}`)}
disableSearch={true}

View File

@@ -3,7 +3,7 @@ import type React from "react";
import { useTranslation } from "react-i18next";
import { View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import PersonPoster from "@/components/jellyseerr/PersonPoster";
import PersonPoster from "@/components/seerr/PersonPoster";
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
@@ -15,19 +15,17 @@ const CastSlide: React.FC<
details?.credits?.cast &&
details?.credits?.cast?.length > 0 && (
<View {...props}>
<Text className='text-lg font-bold mb-2 px-4'>
{t("jellyseerr.cast")}
</Text>
<Text className='text-lg font-bold mb-2 px-4'>{t("seerr.cast")}</Text>
<FlashList
horizontal
showsHorizontalScrollIndicator={false}
data={details?.credits.cast}
ItemSeparatorComponent={() => <View className='w-2' />}
keyExtractor={(item) => item?.id?.toString()}
keyExtractor={(item) => item?.id?.toString() ?? ""}
contentContainerStyle={{ paddingHorizontal: 16 }}
renderItem={({ item }) => (
<PersonPoster
id={item.id.toString()}
id={item?.id?.toString() ?? ""}
posterPath={item.profilePath}
name={item.name}
subName={item.character}

View File

@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
import { View, type ViewProps } from "react-native";
import CountryFlag from "react-native-country-flag";
import { Text } from "@/components/common/Text";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useSeerr } from "@/hooks/useSeerr";
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";
@@ -50,8 +50,7 @@ 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 { seerrRegion: region, seerrLocale: locale } = useSeerr();
const { t } = useTranslation();
const releases = useMemo(
@@ -59,7 +58,7 @@ const DetailFacts: React.FC<
(details as MovieDetails)?.releases?.results.find(
(r: TmdbRelease) => r.iso_3166_1 === region,
)?.release_dates as TmdbRelease["release_dates"],
[details],
[details, region],
);
// Release date types:
@@ -81,40 +80,34 @@ const DetailFacts: React.FC<
const firstAirDate = useMemo(() => {
const firstAirDate = (details as TvDetails)?.firstAirDate;
if (firstAirDate) {
return new Date(firstAirDate).toLocaleDateString(
`${locale}-${region}`,
dateOpts,
);
return new Date(firstAirDate).toLocaleDateString(locale, dateOpts);
}
}, [details]);
}, [details, locale]);
const nextAirDate = useMemo(() => {
const firstAirDate = (details as TvDetails)?.firstAirDate;
const nextAirDate = (details as TvDetails)?.nextEpisodeToAir?.airDate;
if (nextAirDate && firstAirDate !== nextAirDate) {
return new Date(nextAirDate).toLocaleDateString(
`${locale}-${region}`,
dateOpts,
);
return new Date(nextAirDate).toLocaleDateString(locale, dateOpts);
}
}, [details]);
}, [details, locale]);
const revenue = useMemo(
() =>
(details as MovieDetails)?.revenue?.toLocaleString?.(
`${locale}-${region}`,
{ style: "currency", currency: "USD" },
),
[details],
(details as MovieDetails)?.revenue?.toLocaleString?.(locale, {
style: "currency",
currency: "USD",
}),
[details, locale],
);
const budget = useMemo(
() =>
(details as MovieDetails)?.budget?.toLocaleString?.(
`${locale}-${region}`,
{ style: "currency", currency: "USD" },
),
[details],
(details as MovieDetails)?.budget?.toLocaleString?.(locale, {
style: "currency",
currency: "USD",
}),
[details, locale],
);
const streamingProviders = useMemo(
@@ -122,7 +115,7 @@ const DetailFacts: React.FC<
details?.watchProviders?.find(
(provider) => provider.iso_3166_1 === region,
)?.flatrate,
[details],
[details, region],
);
const networks = useMemo(() => (details as TvDetails)?.networks, [details]);
@@ -138,21 +131,21 @@ const DetailFacts: React.FC<
return (
details && (
<View className='p-4'>
<Text className='text-lg font-bold'>{t("jellyseerr.details")}</Text>
<Text className='text-lg font-bold'>{t("seerr.details")}</Text>
<View
className={`${className} flex flex-col justify-center divide-y-2 divide-neutral-800`}
{...props}
>
<Fact title={t("jellyseerr.status")} fact={details?.status} />
<Fact title={t("seerr.status")} fact={details?.status} />
<Fact
title={t("jellyseerr.original_title")}
title={t("seerr.original_title")}
fact={(details as TvDetails)?.originalName}
/>
{details.keywords.some(
(keyword) => keyword.id === ANIME_KEYWORD_ID,
) && <Fact title={t("jellyseerr.series_type")} fact='Anime' />}
) && <Fact title={t("seerr.series_type")} fact='Anime' />}
<Facts
title={t("jellyseerr.release_dates")}
title={t("seerr.release_dates")}
facts={filteredReleases?.map?.((r: Release, idx) => (
<View key={idx} className='flex flex-row space-x-2 items-center'>
{r.type === 3 ? (
@@ -171,23 +164,20 @@ const DetailFacts: React.FC<
)}
<Text>
{new Date(r.release_date).toLocaleDateString(
`${locale}-${region}`,
locale,
dateOpts,
)}
</Text>
</View>
))}
/>
<Fact title={t("jellyseerr.first_air_date")} fact={firstAirDate} />
<Fact title={t("jellyseerr.next_air_date")} fact={nextAirDate} />
<Fact title={t("jellyseerr.revenue")} fact={revenue} />
<Fact title={t("jellyseerr.budget")} fact={budget} />
<Fact
title={t("jellyseerr.original_language")}
fact={spokenLanguage}
/>
<Fact title={t("seerr.first_air_date")} fact={firstAirDate} />
<Fact title={t("seerr.next_air_date")} fact={nextAirDate} />
<Fact title={t("seerr.revenue")} fact={revenue} />
<Fact title={t("seerr.budget")} fact={budget} />
<Fact title={t("seerr.original_language")} fact={spokenLanguage} />
<Facts
title={t("jellyseerr.production_country")}
title={t("seerr.production_country")}
facts={details?.productionCountries?.map((n, idx) => (
<View key={idx} className='flex flex-row items-center space-x-2'>
<CountryFlag isoCode={n.iso_3166_1} size={10} />
@@ -196,17 +186,17 @@ const DetailFacts: React.FC<
))}
/>
<Facts
title={t("jellyseerr.studios")}
title={t("seerr.studios")}
facts={uniqBy(details?.productionCompanies, "name")?.map(
(n) => n.name,
)}
/>
<Facts
title={t("jellyseerr.network")}
title={t("seerr.network")}
facts={networks?.map((n) => n.name)}
/>
<Facts
title={t("jellyseerr.currently_streaming_on")}
title={t("seerr.currently_streaming_on")}
facts={streamingProviders?.map((s) => s.name)}
/>
</View>

View File

@@ -1,16 +1,10 @@
import React from "react";
import { View } from "react-native";
interface Props {
index: number;
}
// Dev note might be a good idea to standardize skeletons across the app and have one "file" for it.
export const GridSkeleton: React.FC<Props> = ({ index }) => {
export const GridSkeleton = React.memo(() => {
return (
<View
key={index}
className='flex flex-col mr-2 h-auto'
style={{ width: "30.5%" }}
>
<View className='flex flex-col mr-2 h-auto' style={{ width: "30.5%" }}>
<View className='relative rounded-lg overflow-hidden border border-neutral-900 w-full mt-4 aspect-[10/15] bg-neutral-800' />
<View className='mt-2 flex flex-col w-full'>
<View className='h-4 bg-neutral-800 rounded mb-1' />
@@ -18,4 +12,4 @@ export const GridSkeleton: React.FC<Props> = ({ index }) => {
</View>
</View>
);
};
});

View File

@@ -133,7 +133,7 @@ const ParallaxSlideShow = <T,>({
<View className='px-4'>
<View className='flex flex-row flex-wrap'>
{Array.from({ length: 9 }, (_, i) => (
<GridSkeleton key={i} index={i} />
<GridSkeleton key={i} />
))}
</View>
</View>

View File

@@ -4,7 +4,7 @@ import { TouchableOpacity, View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import Poster from "@/components/posters/Poster";
import useRouter from "@/hooks/useAppRouter";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useSeerr } from "@/hooks/useSeerr";
interface Props {
id: string;
@@ -20,7 +20,7 @@ const PersonPoster: React.FC<Props & ViewProps> = ({
subName,
...props
}) => {
const { jellyseerrApi } = useJellyseerr();
const { seerrApi } = useSeerr();
const router = useRouter();
const segments = useSegments();
const from = (segments as string[])[2] || "(home)";
@@ -28,20 +28,20 @@ const PersonPoster: React.FC<Props & ViewProps> = ({
if (from === "(home)" || from === "(search)" || from === "(libraries)")
return (
<TouchableOpacity
onPress={() =>
router.push(`/(auth)/(tabs)/${from}/jellyseerr/person/${id}`)
}
onPress={() => router.push(`/(auth)/(tabs)/${from}/seerr/person/${id}`)}
>
<View className='flex flex-col w-28' {...props}>
<Poster
id={id}
url={jellyseerrApi?.imageProxy(posterPath, "w600_and_h900_bestv2")}
url={seerrApi?.imageProxy(posterPath, "w600_and_h900_bestv2")}
/>
<Text className='mt-2'>{name}</Text>
{subName && <Text className='text-xs opacity-50'>{subName}</Text>}
</View>
</TouchableOpacity>
);
return null;
};
export default PersonPoster;

View File

@@ -12,7 +12,7 @@ import { View, type ViewProps } from "react-native";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { PlatformDropdown } from "@/components/PlatformDropdown";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useSeerr } from "@/hooks/useSeerr";
import type {
QualityProfile,
RootFolder,
@@ -38,14 +38,23 @@ const RequestModal = forwardRef<
Props & Omit<ViewProps, "id">
>(
(
{ id, title, requestBody, type, isAnime = false, onRequested, onDismiss },
{
id,
title,
requestBody,
type,
isAnime = false,
is4k,
onRequested,
onDismiss,
},
ref,
) => {
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
const { seerrApi, seerrUser, requestMedia } = useSeerr();
const [requestOverrides, setRequestOverrides] = useState<MediaRequestBody>({
mediaId: Number(id),
mediaType: type,
userId: jellyseerrUser?.id,
userId: seerrUser?.id,
});
const [qualityProfileOpen, setQualityProfileOpen] = useState(false);
@@ -65,18 +74,17 @@ const RequestModal = forwardRef<
}, [onDismiss]);
const { data: serviceSettings } = useQuery({
queryKey: ["jellyseerr", "request", type, "service"],
queryKey: ["seerr", "request", type, "service"],
queryFn: async () =>
jellyseerrApi?.service(type === "movie" ? "radarr" : "sonarr"),
enabled: !!jellyseerrApi && !!jellyseerrUser,
seerrApi?.service(type === "movie" ? "radarr" : "sonarr"),
enabled: !!seerrApi && !!seerrUser,
refetchOnMount: "always",
});
const { data: users } = useQuery({
queryKey: ["jellyseerr", "users"],
queryFn: async () =>
jellyseerrApi?.user({ take: 1000, sort: "displayname" }),
enabled: !!jellyseerrApi && !!jellyseerrUser,
queryKey: ["seerr", "users"],
queryFn: async () => seerrApi?.user({ take: 1000, sort: "displayname" }),
enabled: !!seerrApi && !!seerrUser,
refetchOnMount: "always",
});
@@ -87,7 +95,7 @@ const RequestModal = forwardRef<
const { data: defaultServiceDetails } = useQuery({
queryKey: [
"jellyseerr",
"seerr",
"request",
type,
"service",
@@ -99,12 +107,12 @@ const RequestModal = forwardRef<
...prev,
serverId: defaultService?.id,
}));
return jellyseerrApi?.serviceDetails(
return seerrApi?.serviceDetails(
type === "movie" ? "radarr" : "sonarr",
defaultService!.id,
);
},
enabled: !!jellyseerrApi && !!jellyseerrUser && !!defaultService,
enabled: !!seerrApi && !!seerrUser && !!defaultService,
refetchOnMount: "always",
});
@@ -148,9 +156,9 @@ const RequestModal = forwardRef<
return undefined;
}
if (requestBody.seasons.length > 1) {
return t("jellyseerr.season_all");
return t("seerr.season_all");
}
return t("jellyseerr.season_number", {
return t("seerr.season_number", {
season_number: requestBody.seasons[0],
});
}, [requestBody?.seasons]);
@@ -245,8 +253,7 @@ const RequestModal = forwardRef<
type: "radio" as const,
label: user.displayName,
value: user.id.toString(),
selected:
(requestOverrides.userId || jellyseerrUser?.id) === user.id,
selected: (requestOverrides.userId || seerrUser?.id) === user.id,
onPress: () =>
setRequestOverrides((prev) => ({
...prev,
@@ -255,12 +262,13 @@ const RequestModal = forwardRef<
})) || [],
},
],
[users, jellyseerrUser, requestOverrides.userId],
[users, seerrUser, requestOverrides.userId],
);
const request = useCallback(() => {
const body = {
is4k: defaultService?.is4k || defaultServiceDetails?.server.is4k,
is4k:
is4k ?? defaultService?.is4k ?? defaultServiceDetails?.server.is4k,
profileId: defaultProfile?.id,
rootFolder: defaultFolder?.path,
tags: defaultTags.map((t) => t.id),
@@ -268,7 +276,7 @@ const RequestModal = forwardRef<
...requestOverrides,
};
writeDebugLog("Sending Jellyseerr advanced request", body);
writeDebugLog("Sending Seerr advanced request", body);
requestMedia(
seasonTitle ? `${title}, ${seasonTitle}` : title,
@@ -276,11 +284,18 @@ const RequestModal = forwardRef<
onRequested,
);
}, [
is4k,
defaultService?.is4k,
defaultServiceDetails?.server.is4k,
requestBody,
requestOverrides,
defaultProfile,
defaultFolder,
defaultTags,
requestMedia,
seasonTitle,
title,
onRequested,
]);
return (
@@ -308,7 +323,7 @@ const RequestModal = forwardRef<
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
<View>
<Text className='font-bold text-2xl text-neutral-100'>
{t("jellyseerr.advanced")}
{t("seerr.advanced")}
</Text>
{seasonTitle && (
<Text className='text-neutral-300'>{seasonTitle}</Text>
@@ -319,7 +334,7 @@ const RequestModal = forwardRef<
<>
<View className='flex flex-col'>
<Text className='opacity-50 mb-1 text-xs'>
{t("jellyseerr.quality_profile")}
{t("seerr.quality_profile")}
</Text>
<PlatformDropdown
groups={qualityProfileOptions}
@@ -335,7 +350,7 @@ const RequestModal = forwardRef<
</Text>
</View>
}
title={t("jellyseerr.quality_profile")}
title={t("seerr.quality_profile")}
open={qualityProfileOpen}
onOpenChange={setQualityProfileOpen}
/>
@@ -343,7 +358,7 @@ const RequestModal = forwardRef<
<View className='flex flex-col'>
<Text className='opacity-50 mb-1 text-xs'>
{t("jellyseerr.root_folder")}
{t("seerr.root_folder")}
</Text>
<PlatformDropdown
groups={rootFolderOptions}
@@ -368,42 +383,45 @@ const RequestModal = forwardRef<
</Text>
</View>
}
title={t("jellyseerr.root_folder")}
title={t("seerr.root_folder")}
open={rootFolderOpen}
onOpenChange={setRootFolderOpen}
/>
</View>
<View className='flex flex-col'>
<Text className='opacity-50 mb-1 text-xs'>
{t("jellyseerr.tags")}
</Text>
<PlatformDropdown
groups={tagsOptions}
trigger={
<View className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
<Text numberOfLines={1}>
{requestOverrides.tags
? defaultServiceDetails.tags
.filter((t) =>
requestOverrides.tags!.includes(t.id),
)
.map((t) => t.label)
.join(", ") ||
defaultTags.map((t) => t.label).join(", ")
: defaultTags.map((t) => t.label).join(", ")}
</Text>
</View>
}
title={t("jellyseerr.tags")}
open={tagsOpen}
onOpenChange={setTagsOpen}
/>
</View>
{defaultServiceDetails?.tags &&
defaultServiceDetails.tags.length > 0 && (
<View className='flex flex-col'>
<Text className='opacity-50 mb-1 text-xs'>
{t("seerr.tags")}
</Text>
<PlatformDropdown
groups={tagsOptions}
trigger={
<View className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
<Text numberOfLines={1}>
{requestOverrides.tags
? defaultServiceDetails.tags
.filter((t) =>
requestOverrides.tags!.includes(t.id),
)
.map((t) => t.label)
.join(", ") ||
defaultTags.map((t) => t.label).join(", ")
: defaultTags.map((t) => t.label).join(", ")}
</Text>
</View>
}
title={t("seerr.tags")}
open={tagsOpen}
onOpenChange={setTagsOpen}
/>
</View>
)}
<View className='flex flex-col'>
<Text className='opacity-50 mb-1 text-xs'>
{t("jellyseerr.request_as")}
{t("seerr.request_as")}
</Text>
<PlatformDropdown
groups={usersOptions}
@@ -413,12 +431,12 @@ const RequestModal = forwardRef<
{users.find(
(u) =>
u.id ===
(requestOverrides.userId || jellyseerrUser?.id),
)?.displayName || jellyseerrUser!.displayName}
(requestOverrides.userId || seerrUser?.id),
)?.displayName || seerrUser!.displayName}
</Text>
</View>
}
title={t("jellyseerr.request_as")}
title={t("seerr.request_as")}
open={usersOpen}
onOpenChange={setUsersOpen}
/>
@@ -427,7 +445,7 @@ const RequestModal = forwardRef<
)}
</View>
<Button className='mt-auto' onPress={request} color='purple'>
{t("jellyseerr.request_button")}
{t("seerr.request_button")}
</Button>
</View>
</BottomSheetView>

View File

@@ -8,8 +8,8 @@ import {
useSharedValue,
withTiming,
} from "react-native-reanimated";
import Discover from "@/components/jellyseerr/discover/Discover";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import Discover from "@/components/seerr/discover/Discover";
import { useSeerr } from "@/hooks/useSeerr";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import type {
MovieResult,
@@ -18,57 +18,57 @@ import type {
} from "@/utils/jellyseerr/server/models/Search";
import { useReactNavigationQuery } from "@/utils/useReactNavigationQuery";
import { Text } from "../common/Text";
import JellyseerrPoster from "../posters/JellyseerrPoster";
import SeerrPoster from "../posters/SeerrPoster";
import { LoadingSkeleton } from "../search/LoadingSkeleton";
import { SearchItemWrapper } from "../search/SearchItemWrapper";
import PersonPoster from "./PersonPoster";
interface Props extends ViewProps {
searchQuery: string;
sortType?: JellyseerrSearchSort;
sortType?: SeerrSearchSort;
order?: "asc" | "desc";
}
export enum JellyseerrSearchSort {
export enum SeerrSearchSort {
DEFAULT = 0,
VOTE_COUNT_AND_AVERAGE = 1,
POPULARITY = 2,
}
export const JellyserrIndexPage: React.FC<Props> = ({
export const SeerrIndexPage: React.FC<Props> = ({
searchQuery,
sortType,
order,
}) => {
const { jellyseerrApi } = useJellyseerr();
const { seerrApi } = useSeerr();
const opacity = useSharedValue(1);
const { t } = useTranslation();
const {
data: jellyseerrDiscoverSettings,
data: seerrDiscoverSettings,
isFetching: f1,
isLoading: l1,
} = useReactNavigationQuery({
queryKey: ["search", "jellyseerr", "discoverSettings", searchQuery],
queryFn: async () => jellyseerrApi?.discoverSettings(),
enabled: !!jellyseerrApi && searchQuery.length === 0,
queryKey: ["search", "seerr", "discoverSettings", searchQuery],
queryFn: async () => seerrApi?.discoverSettings(),
enabled: !!seerrApi && searchQuery.length === 0,
});
const {
data: jellyseerrResults,
data: seerrResults,
isFetching: f2,
isLoading: l2,
} = useReactNavigationQuery({
queryKey: ["search", "jellyseerr", "results", searchQuery],
queryKey: ["search", "seerr", "results", searchQuery],
queryFn: async () => {
const params = {
query: new URLSearchParams(searchQuery || "").toString(),
};
return await Promise.all([
jellyseerrApi?.search({ ...params, page: 1 }),
jellyseerrApi?.search({ ...params, page: 2 }),
jellyseerrApi?.search({ ...params, page: 3 }),
jellyseerrApi?.search({ ...params, page: 4 }),
seerrApi?.search({ ...params, page: 1 }),
seerrApi?.search({ ...params, page: 2 }),
seerrApi?.search({ ...params, page: 3 }),
seerrApi?.search({ ...params, page: 4 }),
]).then((all) =>
uniqBy(
all.flatMap((v) => v?.results || []),
@@ -76,7 +76,7 @@ export const JellyserrIndexPage: React.FC<Props> = ({
),
);
},
enabled: !!jellyseerrApi && searchQuery.length > 0,
enabled: !!seerrApi && searchQuery.length > 0,
});
useAnimatedReaction(
@@ -92,20 +92,20 @@ export const JellyserrIndexPage: React.FC<Props> = ({
const sortingType = useMemo(() => {
if (!sortType) return;
switch (Number(JellyseerrSearchSort[sortType])) {
case JellyseerrSearchSort.VOTE_COUNT_AND_AVERAGE:
switch (Number(SeerrSearchSort[sortType])) {
case SeerrSearchSort.VOTE_COUNT_AND_AVERAGE:
return ["voteCount", "voteAverage"];
case JellyseerrSearchSort.POPULARITY:
case SeerrSearchSort.POPULARITY:
return ["voteCount", "popularity"];
default:
return undefined;
}
}, [sortType, order]);
const jellyseerrMovieResults = useMemo(
const seerrMovieResults = useMemo(
() =>
orderBy(
jellyseerrResults?.filter(
seerrResults?.filter(
(r) => r.mediaType === MediaType.MOVIE,
) as MovieResult[],
sortingType || [
@@ -113,41 +113,37 @@ export const JellyserrIndexPage: React.FC<Props> = ({
],
order || "desc",
),
[jellyseerrResults, sortingType, order],
[seerrResults, sortingType, order, searchQuery],
);
const jellyseerrTvResults = useMemo(
const seerrTvResults = useMemo(
() =>
orderBy(
jellyseerrResults?.filter(
(r) => r.mediaType === MediaType.TV,
) as TvResult[],
seerrResults?.filter((r) => r.mediaType === MediaType.TV) as TvResult[],
sortingType || [
(t) => t.name.toLowerCase() === searchQuery.toLowerCase(),
],
order || "desc",
),
[jellyseerrResults, sortingType, order],
[seerrResults, sortingType, order, searchQuery],
);
const jellyseerrPersonResults = useMemo(
const seerrPersonResults = useMemo(
() =>
orderBy(
jellyseerrResults?.filter(
(r) => r.mediaType === "person",
) as PersonResult[],
seerrResults?.filter((r) => r.mediaType === "person") as PersonResult[],
sortingType || [
(p) => p.name.toLowerCase() === searchQuery.toLowerCase(),
],
order || "desc",
),
[jellyseerrResults, sortingType, order],
[seerrResults, sortingType, order, searchQuery],
);
if (!searchQuery.length)
return (
<View className='flex flex-col'>
<Discover sliders={jellyseerrDiscoverSettings} />
<Discover sliders={seerrDiscoverSettings} />
</View>
);
@@ -155,9 +151,9 @@ export const JellyserrIndexPage: React.FC<Props> = ({
<View>
<LoadingSkeleton isLoading={f1 || f2 || l1 || l2} />
{!jellyseerrMovieResults?.length &&
!jellyseerrTvResults?.length &&
!jellyseerrPersonResults?.length &&
{!seerrMovieResults?.length &&
!seerrTvResults?.length &&
!seerrPersonResults?.length &&
!f1 &&
!f2 &&
!l1 &&
@@ -175,21 +171,21 @@ export const JellyserrIndexPage: React.FC<Props> = ({
<View className={f1 || f2 || l1 || l2 ? "opacity-0" : "opacity-100"}>
<SearchItemWrapper
header={t("search.request_movies")}
items={jellyseerrMovieResults}
items={seerrMovieResults}
renderItem={(item: MovieResult) => (
<JellyseerrPoster item={item} key={item.id} />
<SeerrPoster item={item} key={item.id} />
)}
/>
<SearchItemWrapper
header={t("search.request_series")}
items={jellyseerrTvResults}
items={seerrTvResults}
renderItem={(item: TvResult) => (
<JellyseerrPoster item={item} key={item.id} />
<SeerrPoster item={item} key={item.id} />
)}
/>
<SearchItemWrapper
header={t("search.actors")}
items={jellyseerrPersonResults}
items={seerrPersonResults}
renderItem={(item: PersonResult) => (
<PersonPoster
className='mr-2'

View File

@@ -3,9 +3,11 @@ import { useMemo } from "react";
import { View, type ViewProps } from "react-native";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
const JellyseerrMediaIcon: React.FC<
{ mediaType: "tv" | "movie" } & ViewProps
> = ({ mediaType, className, ...props }) => {
const SeerrMediaIcon: React.FC<{ mediaType: "tv" | "movie" } & ViewProps> = ({
mediaType,
className,
...props
}) => {
const style = useMemo(
() =>
mediaType === MediaType.MOVIE
@@ -29,4 +31,4 @@ const JellyseerrMediaIcon: React.FC<
);
};
export default JellyseerrMediaIcon;
export default SeerrMediaIcon;

View File

@@ -9,7 +9,7 @@ interface Props {
onPress?: () => void;
}
const JellyseerrStatusIcon: React.FC<Props & ViewProps> = ({
const SeerrStatusIcon: React.FC<Props & ViewProps> = ({
mediaStatus,
showRequestIcon,
onPress,
@@ -74,4 +74,4 @@ const JellyseerrStatusIcon: React.FC<Props & ViewProps> = ({
);
};
export default JellyseerrStatusIcon;
export default SeerrStatusIcon;

View File

@@ -2,10 +2,10 @@ 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 GenericSlideCard from "@/components/seerr/discover/GenericSlideCard";
import Slide, { type SlideProps } from "@/components/seerr/discover/Slide";
import useRouter from "@/hooks/useAppRouter";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useSeerr } from "@/hooks/useSeerr";
import {
COMPANY_LOGO_IMAGE_FILTER,
type Network,
@@ -16,17 +16,17 @@ const CompanySlide: React.FC<
{ data: Network[] | Studio[] } & SlideProps & ViewProps
> = ({ slide, data, ...props }) => {
const segments = useSegments();
const { jellyseerrApi } = useJellyseerr();
const { seerrApi } = useSeerr();
const router = useRouter();
const from = (segments as string[])[2] || "(home)";
const navigate = useCallback(
({ id, image, name }: Network | Studio) =>
router.push({
pathname: `/(auth)/(tabs)/${from}/jellyseerr/company/${id}` as any,
pathname: `/(auth)/(tabs)/${from}/seerr/company/${id}` as any,
params: { id, image, name, type: slide.type },
}),
[slide],
[router, from, slide.type],
);
return (
@@ -40,10 +40,7 @@ const CompanySlide: React.FC<
<GenericSlideCard
className='w-28 rounded-lg overflow-hidden border border-neutral-900 p-4'
id={item.id.toString()}
url={jellyseerrApi?.imageProxy(
item.image,
COMPANY_LOGO_IMAGE_FILTER,
)}
url={seerrApi?.imageProxy(item.image, COMPANY_LOGO_IMAGE_FILTER)}
/>
</TouchableOpacity>
)}

View File

@@ -2,10 +2,10 @@ import { sortBy } from "lodash";
import type React from "react";
import { useMemo } from "react";
import { View } from "react-native";
import CompanySlide from "@/components/jellyseerr/discover/CompanySlide";
import GenreSlide from "@/components/jellyseerr/discover/GenreSlide";
import MovieTvSlide from "@/components/jellyseerr/discover/MovieTvSlide";
import RecentRequestsSlide from "@/components/jellyseerr/discover/RecentRequestsSlide";
import CompanySlide from "@/components/seerr/discover/CompanySlide";
import GenreSlide from "@/components/seerr/discover/GenreSlide";
import MovieTvSlide from "@/components/seerr/discover/MovieTvSlide";
import RecentRequestsSlide from "@/components/seerr/discover/RecentRequestsSlide";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
import { networks } from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
@@ -23,7 +23,6 @@ const Discover: React.FC<Props> = ({ sliders }) => {
sortBy(
(sliders ?? []).filter((s) => s.enabled),
"order",
"asc",
),
[sliders],
);

View File

@@ -1,6 +1,6 @@
import { Image, type ImageContentFit } from "expo-image";
import { LinearGradient } from "expo-linear-gradient";
import type React from "react";
import React from "react";
import { StyleSheet, View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
@@ -67,4 +67,4 @@ const GenericSlideCard: React.FC<
</>
);
export default GenericSlideCard;
export default React.memo(GenericSlideCard);

View File

@@ -3,39 +3,38 @@ 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 GenericSlideCard from "@/components/seerr/discover/GenericSlideCard";
import Slide, { type SlideProps } from "@/components/seerr/discover/Slide";
import useRouter from "@/hooks/useAppRouter";
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
import { Endpoints, useSeerr } from "@/hooks/useSeerr";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
import type { GenreSliderItem } from "@/utils/jellyseerr/server/interfaces/api/discoverInterfaces";
import { genreColorMap } from "@/utils/jellyseerr/src/components/Discover/constants";
const GenreSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
const segments = useSegments();
const { jellyseerrApi } = useJellyseerr();
const { seerrApi } = useSeerr();
const router = useRouter();
const from = (segments as string[])[2] || "(home)";
const navigate = useCallback(
(genre: GenreSliderItem) =>
router.push({
pathname: `/(auth)/(tabs)/${from}/jellyseerr/genre/${genre.id}` as any,
pathname: `/(auth)/(tabs)/${from}/seerr/genre/${genre.id}` as any,
params: { type: slide.type, name: genre.name },
}),
[slide],
[router, from, slide.type],
);
const { data } = useQuery({
queryKey: ["jellyseerr", "discover", slide.type, slide.id],
queryKey: ["seerr", "discover", slide.type, slide.id],
queryFn: async () => {
return jellyseerrApi?.getGenreSliders(
return seerrApi?.getGenreSliders(
slide.type === DiscoverSliderType.MOVIE_GENRES
? Endpoints.MOVIE
: Endpoints.TV,
);
},
enabled: !!jellyseerrApi,
enabled: !!seerrApi,
});
return (
@@ -53,7 +52,7 @@ const GenreSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
title={item.name}
colors={["transparent", "transparent"]}
contentFit={"cover"}
url={jellyseerrApi?.imageProxy(
url={seerrApi?.imageProxy(
item.backdrops?.[0],
`w780_filter(duotone,${
genreColorMap[item.id] ?? genreColorMap[0]

View File

@@ -3,23 +3,19 @@ import { uniqBy } from "lodash";
import type React from "react";
import { useMemo } from "react";
import type { ViewProps } from "react-native";
import Slide, { type SlideProps } from "@/components/jellyseerr/discover/Slide";
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import {
type DiscoverEndpoint,
Endpoints,
useJellyseerr,
} from "@/hooks/useJellyseerr";
import SeerrPoster from "@/components/posters/SeerrPoster";
import Slide, { type SlideProps } from "@/components/seerr/discover/Slide";
import { type DiscoverEndpoint, Endpoints, useSeerr } from "@/hooks/useSeerr";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({
slide,
...props
}) => {
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
const { seerrApi, isSeerrMovieOrTvResult } = useSeerr();
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ["jellyseerr", "discover", slide.id],
queryKey: ["seerr", "discover", slide.id],
queryFn: async ({ pageParam }) => {
let endpoint: DiscoverEndpoint | undefined;
let params: any = {
@@ -50,13 +46,13 @@ const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({
break;
}
return endpoint ? jellyseerrApi?.discover(endpoint, params) : null;
return endpoint ? seerrApi?.discover(endpoint, params) : null;
},
initialPageParam: 1,
getNextPageParam: (lastPage, pages) =>
(lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
1,
enabled: !!jellyseerrApi,
enabled: !!seerrApi,
staleTime: 0,
});
@@ -65,12 +61,10 @@ const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({
uniqBy(
data?.pages
?.filter((p) => p?.results.length)
.flatMap((p) =>
p?.results.filter((r) => isJellyseerrMovieOrTvResult(r)),
),
.flatMap((p) => p?.results.filter((r) => isSeerrMovieOrTvResult(r))),
"id",
),
[data],
[data, isSeerrMovieOrTvResult],
);
return (
@@ -84,7 +78,7 @@ const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({
onEndReached={() => {
if (hasNextPage) fetchNextPage();
}}
renderItem={(item) => <JellyseerrPoster item={item} key={item?.id} />}
renderItem={(item) => <SeerrPoster item={item} key={item?.id} />}
/>
)
);

View File

@@ -1,9 +1,9 @@
import { useQuery } from "@tanstack/react-query";
import type React from "react";
import type { ViewProps } from "react-native";
import Slide, { type SlideProps } from "@/components/jellyseerr/discover/Slide";
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import SeerrPoster from "@/components/posters/SeerrPoster";
import Slide, { type SlideProps } from "@/components/seerr/discover/Slide";
import { useSeerr } from "@/hooks/useSeerr";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
import type { NonFunctionProperties } from "@/utils/jellyseerr/server/interfaces/api/common";
@@ -16,36 +16,36 @@ type ExtendedMediaRequest = NonFunctionProperties<MediaRequest> & {
const RequestCard: React.FC<{ request: ExtendedMediaRequest }> = ({
request,
}) => {
const { jellyseerrApi } = useJellyseerr();
const { seerrApi } = useSeerr();
const { data: details } = useQuery({
queryKey: [
"jellyseerr",
"seerr",
"detail",
request.media.mediaType,
request.media.tmdbId,
],
queryFn: async () => {
return request.media.mediaType === MediaType.MOVIE
? jellyseerrApi?.movieDetails(request.media.tmdbId)
: jellyseerrApi?.tvDetails(request.media.tmdbId);
? seerrApi?.movieDetails(request.media.tmdbId)
: seerrApi?.tvDetails(request.media.tmdbId);
},
enabled: !!jellyseerrApi,
enabled: !!seerrApi,
refetchOnMount: true,
staleTime: 0,
});
const { data: refreshedRequest } = useQuery({
queryKey: ["jellyseerr", "requests", request.media.mediaType, request.id],
queryFn: async () => jellyseerrApi?.getRequest(request.id),
enabled: !!jellyseerrApi,
queryKey: ["seerr", "requests", request.media.mediaType, request.id],
queryFn: async () => seerrApi?.getRequest(request.id),
enabled: !!seerrApi,
refetchOnMount: true,
refetchInterval: 5000,
staleTime: 0,
});
return (
<JellyseerrPoster
<SeerrPoster
horizontal
showDownloadInfo
item={details}
@@ -58,12 +58,12 @@ const RecentRequestsSlide: React.FC<SlideProps & ViewProps> = ({
slide,
...props
}) => {
const { jellyseerrApi } = useJellyseerr();
const { seerrApi } = useSeerr();
const { data: requests } = useQuery({
queryKey: ["jellyseerr", "recent_requests"],
queryFn: async () => jellyseerrApi?.requests(),
enabled: !!jellyseerrApi,
queryKey: ["seerr", "recent_requests"],
queryFn: async () => seerrApi?.requests(),
enabled: !!seerrApi,
refetchOnMount: true,
staleTime: 0,
});

View File

@@ -14,10 +14,7 @@ export interface SlideProps {
interface Props<T> extends SlideProps {
data: T[];
renderItem: (
item: T,
index: number,
) => React.ComponentType<any> | React.ReactElement | null | undefined;
renderItem: (item: T, index: number) => React.ReactElement | null;
keyExtractor: (item: T) => string;
onEndReached?: (() => void) | null | undefined;
}
@@ -47,7 +44,6 @@ const Slide = <T,>({
data={data}
onEndReachedThreshold={1}
onEndReached={onEndReached}
//@ts-expect-error
renderItem={({ item, index }) =>
item ? renderItem(item, index) : null
}

View File

@@ -8,8 +8,11 @@ import type React from "react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { TouchableOpacity, View, type ViewProps } from "react-native";
import { POSTER_CAROUSEL_HEIGHT } from "@/constants/Values";
import useRouter from "@/hooks/useAppRouter";
// Matches `w-28` poster cards (approx 112px wide, 10/15 aspect ratio) + 2 lines of text.
const POSTER_CAROUSEL_HEIGHT = 220;
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { HorizontalScroll } from "../common/HorizontalScroll";

View File

@@ -3,8 +3,11 @@ import { useAtom } from "jotai";
import type React from "react";
import { useTranslation } from "react-i18next";
import { TouchableOpacity, View, type ViewProps } from "react-native";
import { POSTER_CAROUSEL_HEIGHT } from "@/constants/Values";
import useRouter from "@/hooks/useAppRouter";
// Matches `w-28` poster cards (approx 112px wide, 10/15 aspect ratio) + 2 lines of text.
const POSTER_CAROUSEL_HEIGHT = 220;
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
import { HorizontalScroll } from "../common/HorizontalScroll";

View File

@@ -14,11 +14,11 @@ import { Alert, TouchableOpacity, View } from "react-native";
import { HorizontalScroll } from "@/components/common/HorizontalScroll";
import { Text } from "@/components/common/Text";
import { Tags } from "@/components/GenreTags";
import { dateOpts } from "@/components/jellyseerr/DetailFacts";
import { textShadowStyle } from "@/components/jellyseerr/discover/GenericSlideCard";
import JellyseerrStatusIcon from "@/components/jellyseerr/JellyseerrStatusIcon";
import { RoundButton } from "@/components/RoundButton";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { dateOpts } from "@/components/seerr/DetailFacts";
import { textShadowStyle } from "@/components/seerr/discover/GenericSlideCard";
import SeerrStatusIcon from "@/components/seerr/SeerrStatusIcon";
import { useSeerr } from "@/hooks/useSeerr";
import {
MediaStatus,
MediaType,
@@ -30,15 +30,15 @@ import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
import { Loader } from "../Loader";
const JellyseerrSeasonEpisodes: React.FC<{
const SeerrSeasonEpisodes: React.FC<{
details: TvDetails;
seasonNumber: number;
}> = ({ details, seasonNumber }) => {
const { jellyseerrApi } = useJellyseerr();
const { seerrApi } = useSeerr();
const { data: seasonWithEpisodes, isLoading } = useQuery({
queryKey: ["jellyseerr", details.id, "season", seasonNumber],
queryFn: async () => jellyseerrApi?.tvSeason(details.id, seasonNumber),
queryKey: ["seerr", details.id, "season", seasonNumber],
queryFn: async () => seerrApi?.tvSeason(details.id, seasonNumber),
enabled: details.seasons.filter((s) => s.seasonNumber !== 0).length > 0,
});
@@ -57,11 +57,7 @@ const JellyseerrSeasonEpisodes: React.FC<{
};
const RenderItem = ({ item }: any) => {
const {
jellyseerrApi,
jellyseerrRegion: region,
jellyseerrLocale: locale,
} = useJellyseerr();
const { seerrApi, seerrRegion: region, seerrLocale: locale } = useSeerr();
const [imageError, setImageError] = useState(false);
const upcomingAirDate = useMemo(() => {
@@ -69,7 +65,7 @@ const RenderItem = ({ item }: any) => {
if (airDate) {
const airDateObj = new Date(airDate);
if (new Date() < airDateObj) {
return airDateObj.toLocaleDateString(`${locale}-${region}`, dateOpts);
return airDateObj.toLocaleDateString(locale, dateOpts);
}
}
}, [item, locale, region]);
@@ -83,7 +79,7 @@ const RenderItem = ({ item }: any) => {
key={item.id}
id={item.id}
source={{
uri: jellyseerrApi?.imageProxy(item.stillPath),
uri: seerrApi?.imageProxy(item.stillPath),
}}
cachePolicy={"memory-disk"}
contentFit='cover'
@@ -131,7 +127,7 @@ const RenderItem = ({ item }: any) => {
);
};
const JellyseerrSeasons: React.FC<{
const SeerrSeasons: React.FC<{
isLoading: boolean;
details?: TvDetails;
hasAdvancedRequest?: boolean;
@@ -148,7 +144,7 @@ const JellyseerrSeasons: React.FC<{
hasAdvancedRequest,
onAdvancedRequest,
}) => {
const { jellyseerrApi, requestMedia } = useJellyseerr();
const { seerrApi, requestMedia } = useSeerr();
const [seasonStates, setSeasonStates] = useState<{ [key: number]: boolean }>(
{},
);
@@ -181,7 +177,7 @@ const JellyseerrSeasons: React.FC<{
);
const requestAll = useCallback(() => {
if (details && jellyseerrApi) {
if (details && seerrApi) {
const body: MediaRequestBody = {
mediaId: details.id,
mediaType: MediaType.TV,
@@ -198,7 +194,7 @@ const JellyseerrSeasons: React.FC<{
requestMedia(details.name, body, refetch);
}
}, [
jellyseerrApi,
seerrApi,
seasons,
details,
hasAdvancedRequest,
@@ -210,15 +206,15 @@ const JellyseerrSeasons: React.FC<{
const promptRequestAll = useCallback(
() =>
Alert.alert(
t("jellyseerr.confirm"),
t("jellyseerr.are_you_sure_you_want_to_request_all_seasons"),
t("seerr.confirm"),
t("seerr.are_you_sure_you_want_to_request_all_seasons"),
[
{
text: t("jellyseerr.cancel"),
text: t("seerr.cancel"),
style: "cancel",
},
{
text: t("jellyseerr.yes"),
text: t("seerr.yes"),
onPress: requestAll,
},
],
@@ -301,10 +297,10 @@ const JellyseerrSeasons: React.FC<{
<Tags
textClass=''
tags={[
t("jellyseerr.season_number", {
t("seerr.season_number", {
season_number: season.seasonNumber,
}),
t("jellyseerr.number_episodes", {
t("seerr.number_episodes", {
episode_number: season.episodeCount,
}),
]}
@@ -312,7 +308,7 @@ const JellyseerrSeasons: React.FC<{
{[0].map(() => {
const canRequest = season.status === MediaStatus.UNKNOWN;
return (
<JellyseerrStatusIcon
<SeerrStatusIcon
key={0}
onPress={() =>
requestSeason(canRequest, season.seasonNumber)
@@ -326,7 +322,7 @@ const JellyseerrSeasons: React.FC<{
</View>
</TouchableOpacity>
{seasonStates?.[season.seasonNumber] && (
<JellyseerrSeasonEpisodes
<SeerrSeasonEpisodes
key={season.seasonNumber}
details={details}
seasonNumber={season.seasonNumber}
@@ -338,4 +334,4 @@ const JellyseerrSeasons: React.FC<{
);
};
export default JellyseerrSeasons;
export default SeerrSeasons;

View File

@@ -1,29 +0,0 @@
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import useRouter from "@/hooks/useAppRouter";
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
import { useSettings } from "@/utils/atoms/settings";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
export const Dashboard = () => {
const { settings } = useSettings();
const { sessions = [] } = useSessions({} as useSessionsProps);
const router = useRouter();
const { t } = useTranslation();
if (!settings) return null;
return (
<View>
<ListGroup title={t("home.settings.dashboard.title")} className='mt-4'>
<ListItem
className={sessions.length !== 0 ? "bg-purple-900" : ""}
onPress={() => router.push("/settings/dashboard/sessions")}
title={t("home.settings.dashboard.sessions_title")}
showArrow
/>
</ListGroup>
</View>
);
};

View File

@@ -1,3 +0,0 @@
export default function DownloadSettings() {
return null;
}

View File

@@ -1,3 +0,0 @@
export default function DownloadSettings() {
return null;
}

View File

@@ -1,181 +0,0 @@
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 { Button } from "../Button";
import { Input } from "../common/Input";
import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
export const JellyseerrSettings = () => {
const { jellyseerrUser, setJellyseerrUser, clearAllJellyseerData } =
useJellyseerr();
const { t } = useTranslation();
const [user] = useAtom(userAtom);
const { settings, updateSettings } = useSettings();
const [jellyseerrPassword, setJellyseerrPassword] = useState<
string | undefined
>(undefined);
const [jellyseerrServerUrl, setjellyseerrServerUrl] = useState<
string | undefined
>(settings?.jellyseerrServerUrl || undefined);
const loginToJellyseerrMutation = useMutation({
mutationFn: async () => {
if (!jellyseerrServerUrl && !settings?.jellyseerrServerUrl)
throw new Error("Missing server url");
if (!user?.Name)
throw new Error("Missing required information for login");
const jellyseerrTempApi = new JellyseerrApi(
jellyseerrServerUrl || settings.jellyseerrServerUrl || "",
);
const testResult = await jellyseerrTempApi.test();
if (!testResult.isValid) throw new Error("Invalid server url");
return jellyseerrTempApi.login(user.Name, jellyseerrPassword || "");
},
onSuccess: (user) => {
setJellyseerrUser(user);
updateSettings({ jellyseerrServerUrl });
},
onError: () => {
toast.error(t("jellyseerr.failed_to_login"));
},
onSettled: () => {
setJellyseerrPassword(undefined);
},
});
const clearData = () => {
clearAllJellyseerData().finally(() => {
setJellyseerrUser(undefined);
setJellyseerrPassword(undefined);
setjellyseerrServerUrl(undefined);
});
};
return (
<View className=''>
<View>
{jellyseerrUser ? (
<>
<ListGroup title={"Jellyseerr"}>
<ListItem
title={t(
"home.settings.plugins.jellyseerr.total_media_requests",
)}
value={jellyseerrUser?.requestCount?.toString()}
/>
<ListItem
title={t("home.settings.plugins.jellyseerr.movie_quota_limit")}
value={
jellyseerrUser?.movieQuotaLimit?.toString() ??
t("home.settings.plugins.jellyseerr.unlimited")
}
/>
<ListItem
title={t("home.settings.plugins.jellyseerr.movie_quota_days")}
value={
jellyseerrUser?.movieQuotaDays?.toString() ??
t("home.settings.plugins.jellyseerr.unlimited")
}
/>
<ListItem
title={t("home.settings.plugins.jellyseerr.tv_quota_limit")}
value={
jellyseerrUser?.tvQuotaLimit?.toString() ??
t("home.settings.plugins.jellyseerr.unlimited")
}
/>
<ListItem
title={t("home.settings.plugins.jellyseerr.tv_quota_days")}
value={
jellyseerrUser?.tvQuotaDays?.toString() ??
t("home.settings.plugins.jellyseerr.unlimited")
}
/>
</ListGroup>
<View className='p-4'>
<Button color='red' onPress={clearData}>
{t(
"home.settings.plugins.jellyseerr.reset_jellyseerr_config_button",
)}
</Button>
</View>
</>
) : (
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'>
<Text className='text-xs text-red-600 mb-2'>
{t("home.settings.plugins.jellyseerr.jellyseerr_warning")}
</Text>
<Text className='font-bold mb-1'>
{t("home.settings.plugins.jellyseerr.server_url")}
</Text>
<View className='flex flex-col shrink mb-2'>
<Text className='text-xs text-gray-600'>
{t("home.settings.plugins.jellyseerr.server_url_hint")}
</Text>
</View>
<Input
className='border border-neutral-800 mb-2'
placeholder={t(
"home.settings.plugins.jellyseerr.server_url_placeholder",
)}
value={jellyseerrServerUrl ?? settings?.jellyseerrServerUrl}
defaultValue={
settings?.jellyseerrServerUrl ?? jellyseerrServerUrl
}
keyboardType='url'
returnKeyType='done'
autoCapitalize='none'
textContentType='URL'
onChangeText={setjellyseerrServerUrl}
editable={!loginToJellyseerrMutation.isPending}
/>
<View>
<Text className='font-bold mb-2'>
{t("home.settings.plugins.jellyseerr.password")}
</Text>
<Input
className='border border-neutral-800'
autoFocus={true}
focusable={true}
placeholder={t(
"home.settings.plugins.jellyseerr.password_placeholder",
{ username: user?.Name },
)}
value={jellyseerrPassword}
keyboardType='default'
secureTextEntry={true}
returnKeyType='done'
autoCapitalize='none'
textContentType='password'
onChangeText={setJellyseerrPassword}
editable={!loginToJellyseerrMutation.isPending}
/>
<Button
loading={loginToJellyseerrMutation.isPending}
disabled={loginToJellyseerrMutation.isPending}
color='purple'
className='h-12 mt-2'
onPress={() => loginToJellyseerrMutation.mutate()}
>
{t("home.settings.plugins.jellyseerr.login_button")}
</Button>
</View>
</View>
)}
</View>
</View>
);
};

View File

@@ -229,7 +229,7 @@ export const LibraryOptionsSheet: React.FC<Props> = ({
/>
</OptionGroup>
<OptionGroup title='Options'>
<OptionGroup title={t("library.options.options_title")}>
<ToggleItem
label={t("library.options.show_titles")}
value={settings.showTitles}

View File

@@ -1,5 +1,6 @@
import { Ionicons } from "@expo/vector-icons";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Platform, View, type ViewProps } from "react-native";
import { Stepper } from "@/components/inputs/Stepper";
import { Text } from "../common/Text";
@@ -17,20 +18,21 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
const isTv = Platform.isTV;
const media = useMedia();
const { settings, updateSettings } = media;
const { t } = useTranslation();
const alignXOptions: AlignX[] = ["left", "center", "right"];
const alignYOptions: AlignY[] = ["top", "center", "bottom"];
const alignXLabels: Record<AlignX, string> = {
left: "Left",
center: "Center",
right: "Right",
left: t("player.alignment_left"),
center: t("player.alignment_center"),
right: t("player.alignment_right"),
};
const alignYLabels: Record<AlignY, string> = {
top: "Top",
center: "Center",
bottom: "Bottom",
top: t("player.alignment_top"),
center: t("player.alignment_center"),
bottom: t("player.alignment_bottom"),
};
const alignXOptionGroups = useMemo(() => {
@@ -61,14 +63,14 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
return (
<View {...props}>
<ListGroup
title='MPV Subtitle Settings'
title={t("player.mpv_subtitle_settings_title")}
description={
<Text className='text-[#8E8D91] text-xs'>
Advanced subtitle customization for MPV player
{t("player.mpv_subtitle_settings_description")}
</Text>
}
>
<ListItem title='Subtitle Scale'>
<ListItem title={t("player.subtitle_scale")}>
<Stepper
value={settings.mpvSubtitleScale ?? 1.0}
step={0.1}
@@ -80,7 +82,7 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
/>
</ListItem>
<ListItem title='Vertical Margin'>
<ListItem title={t("player.vertical_margin")}>
<Stepper
value={settings.mpvSubtitleMarginY ?? 0}
step={5}
@@ -90,7 +92,7 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
/>
</ListItem>
<ListItem title='Horizontal Alignment'>
<ListItem title={t("player.horizontal_alignment")}>
<PlatformDropdown
groups={alignXOptionGroups}
trigger={
@@ -105,11 +107,11 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
/>
</View>
}
title='Horizontal Alignment'
title={t("player.horizontal_alignment")}
/>
</ListItem>
<ListItem title='Vertical Alignment'>
<ListItem title={t("player.vertical_alignment")}>
<PlatformDropdown
groups={alignYOptionGroups}
trigger={
@@ -124,7 +126,7 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
/>
</View>
}
title='Vertical Alignment'
title={t("player.vertical_alignment")}
/>
</ListItem>
</ListGroup>

View File

@@ -19,23 +19,23 @@ export const PluginSettings = () => {
className='mb-4'
>
<ListItem
onPress={() => router.push("/settings/plugins/jellyseerr/page")}
title={"Jellyseerr"}
showArrow
/>
<ListItem
onPress={() => router.push("/settings/plugins/marlin-search/page")}
title='Marlin Search'
onPress={() => router.push("/settings/plugins/seerr/page")}
title={"Seerr"}
showArrow
/>
<ListItem
onPress={() => router.push("/settings/plugins/streamystats/page")}
title='Streamystats'
title={"Streamystats"}
showArrow
/>
<ListItem
onPress={() => router.push("/settings/plugins/marlin-search/page")}
title={"Marlin Search"}
showArrow
/>
<ListItem
onPress={() => router.push("/settings/plugins/kefinTweaks/page")}
title='KefinTweaks'
title={"KefinTweaks"}
showArrow
/>
</ListGroup>

View File

@@ -0,0 +1,174 @@
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 { SeerrApi, useSeerr } from "@/hooks/useSeerr";
import { userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { Button } from "../Button";
import { Input } from "../common/Input";
import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
export const SeerrSettings = () => {
const { seerrUser, setSeerrUser, clearAllSeerrData } = useSeerr();
const { t } = useTranslation();
const [user] = useAtom(userAtom);
const { settings, updateSettings } = useSettings();
const [seerrPassword, setSeerrPassword] = useState<string | undefined>(
undefined,
);
const [seerrServerUrl, setSeerrServerUrl] = useState<string | undefined>(
settings?.seerrServerUrl || undefined,
);
const loginToSeerrMutation = useMutation({
mutationFn: async () => {
if (!seerrServerUrl && !settings?.seerrServerUrl)
throw new Error("Missing server url");
if (!user?.Name)
throw new Error("Missing required information for login");
const seerrTempApi = new SeerrApi(
seerrServerUrl || settings.seerrServerUrl || "",
);
const testResult = await seerrTempApi.test();
if (!testResult.isValid) throw new Error("Invalid server url");
return seerrTempApi.login(user.Name, seerrPassword || "");
},
onSuccess: (user) => {
setSeerrUser(user);
updateSettings({ seerrServerUrl });
},
onError: () => {
toast.error(t("seerr.failed_to_login"));
},
onSettled: () => {
setSeerrPassword(undefined);
},
});
const clearData = () => {
clearAllSeerrData().finally(() => {
setSeerrUser(undefined);
setSeerrPassword(undefined);
setSeerrServerUrl(undefined);
});
};
return (
<View className=''>
<View>
{seerrUser ? (
<>
<ListGroup title={"Seerr"}>
<ListItem
title={t("home.settings.plugins.seerr.total_media_requests")}
value={seerrUser?.requestCount?.toString()}
/>
<ListItem
title={t("home.settings.plugins.seerr.movie_quota_limit")}
value={
seerrUser?.movieQuotaLimit?.toString() ??
t("home.settings.plugins.seerr.unlimited")
}
/>
<ListItem
title={t("home.settings.plugins.seerr.movie_quota_days")}
value={
seerrUser?.movieQuotaDays?.toString() ??
t("home.settings.plugins.seerr.unlimited")
}
/>
<ListItem
title={t("home.settings.plugins.seerr.tv_quota_limit")}
value={
seerrUser?.tvQuotaLimit?.toString() ??
t("home.settings.plugins.seerr.unlimited")
}
/>
<ListItem
title={t("home.settings.plugins.seerr.tv_quota_days")}
value={
seerrUser?.tvQuotaDays?.toString() ??
t("home.settings.plugins.seerr.unlimited")
}
/>
</ListGroup>
<View className='p-4'>
<Button color='red' onPress={clearData}>
{t("home.settings.plugins.seerr.reset_seerr_config_button")}
</Button>
</View>
</>
) : (
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'>
<Text className='text-xs text-red-600 mb-2'>
{t("home.settings.plugins.seerr.seerr_warning")}
</Text>
<Text className='font-bold mb-1'>
{t("home.settings.plugins.seerr.server_url")}
</Text>
<View className='flex flex-col shrink mb-2'>
<Text className='text-xs text-gray-600'>
{t("home.settings.plugins.seerr.server_url_hint")}
</Text>
</View>
<Input
className='border border-neutral-800 mb-2'
placeholder={t(
"home.settings.plugins.seerr.server_url_placeholder",
)}
value={seerrServerUrl ?? settings?.seerrServerUrl}
defaultValue={settings?.seerrServerUrl ?? seerrServerUrl}
keyboardType='url'
returnKeyType='done'
autoCapitalize='none'
textContentType='URL'
onChangeText={setSeerrServerUrl}
editable={!loginToSeerrMutation.isPending}
/>
<View>
<Text className='font-bold mb-2'>
{t("home.settings.plugins.seerr.password")}
</Text>
<Input
className='border border-neutral-800'
autoFocus={true}
focusable={true}
placeholder={t(
"home.settings.plugins.seerr.password_placeholder",
{ username: user?.Name },
)}
value={seerrPassword}
keyboardType='default'
secureTextEntry={true}
returnKeyType='done'
autoCapitalize='none'
textContentType='password'
onChangeText={setSeerrPassword}
editable={!loginToSeerrMutation.isPending}
/>
<Button
loading={loginToSeerrMutation.isPending}
disabled={loginToSeerrMutation.isPending}
color='purple'
className='h-12 mt-2'
onPress={() => loginToSeerrMutation.mutate()}
>
{t("home.settings.plugins.seerr.login_button")}
</Button>
</View>
</View>
)}
</View>
</View>
);
};

View File

@@ -40,7 +40,6 @@ import { useVideoTime } from "./hooks/useVideoTime";
import { TechnicalInfoOverlay } from "./TechnicalInfoOverlay";
import { useControlsTimeout } from "./useControlsTimeout";
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
import { type AspectRatio } from "./VideoScalingModeSelector";
interface Props {
item: BaseItemDto;
@@ -58,7 +57,6 @@ interface Props {
startPictureInPicture?: () => Promise<void>;
play: () => void;
pause: () => void;
aspectRatio?: AspectRatio;
isZoomedToFill?: boolean;
onZoomToggle?: () => void;
api?: Api | null;
@@ -89,7 +87,6 @@ export const Controls: FC<Props> = ({
showControls,
setShowControls,
mediaSource,
aspectRatio = "default",
isZoomedToFill = false,
onZoomToggle,
api = null,
@@ -498,7 +495,6 @@ export const Controls: FC<Props> = ({
goToNextItem={goToNextItem}
previousItem={previousItem}
nextItem={nextItem}
aspectRatio={aspectRatio}
isZoomedToFill={isZoomedToFill}
onZoomToggle={onZoomToggle}
playbackSpeed={playbackSpeed}

View File

@@ -14,7 +14,6 @@ import { useSettings } from "@/utils/atoms/settings";
import { HEADER_LAYOUT, ICON_SIZES } from "./constants";
import DropdownView from "./dropdown/DropdownView";
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
import { type AspectRatio } from "./VideoScalingModeSelector";
import { ZoomToggle } from "./ZoomToggle";
interface HeaderControlsProps {
@@ -28,7 +27,6 @@ interface HeaderControlsProps {
goToNextItem: (options: { isAutoPlay?: boolean }) => void;
previousItem?: BaseItemDto | null;
nextItem?: BaseItemDto | null;
aspectRatio?: AspectRatio;
isZoomedToFill?: boolean;
onZoomToggle?: () => void;
// Playback speed props
@@ -50,7 +48,6 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
goToNextItem,
previousItem,
nextItem,
aspectRatio: _aspectRatio = "default",
isZoomedToFill = false,
onZoomToggle,
playbackSpeed = 1.0,

View File

@@ -1,105 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import React, { useMemo } from "react";
import { Platform, View } from "react-native";
import {
type OptionGroup,
PlatformDropdown,
} from "@/components/PlatformDropdown";
import { useHaptic } from "@/hooks/useHaptic";
export type AspectRatio = "default" | "16:9" | "4:3" | "1:1" | "21:9";
interface AspectRatioSelectorProps {
currentRatio: AspectRatio;
onRatioChange: (ratio: AspectRatio) => void;
disabled?: boolean;
}
interface AspectRatioOption {
id: AspectRatio;
label: string;
description: string;
}
const ASPECT_RATIO_OPTIONS: AspectRatioOption[] = [
{
id: "default",
label: "Original",
description: "Use video's original aspect ratio",
},
{
id: "16:9",
label: "16:9",
description: "Widescreen (most common)",
},
{
id: "4:3",
label: "4:3",
description: "Traditional TV format",
},
{
id: "1:1",
label: "1:1",
description: "Square format",
},
{
id: "21:9",
label: "21:9",
description: "Ultra-wide cinematic",
},
];
export const AspectRatioSelector: React.FC<AspectRatioSelectorProps> = ({
currentRatio,
onRatioChange,
disabled = false,
}) => {
const lightHapticFeedback = useHaptic("light");
const handleRatioSelect = (ratio: AspectRatio) => {
onRatioChange(ratio);
lightHapticFeedback();
};
const optionGroups = useMemo<OptionGroup[]>(() => {
return [
{
options: ASPECT_RATIO_OPTIONS.map((option) => ({
type: "radio" as const,
label: option.label,
value: option.id,
selected: option.id === currentRatio,
onPress: () => handleRatioSelect(option.id),
disabled,
})),
},
];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentRatio, disabled]);
const trigger = useMemo(
() => (
<View
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
style={{ opacity: disabled ? 0.5 : 1 }}
>
<Ionicons name='crop-outline' size={24} color='white' />
</View>
),
[disabled],
);
// Hide on TV platforms
if (Platform.isTV) return null;
return (
<PlatformDropdown
title='Aspect Ratio'
groups={optionGroups}
trigger={trigger}
bottomSheetConfig={{
enablePanDownToClose: true,
}}
/>
);
};

View File

@@ -1,6 +1,7 @@
import { Ionicons } from "@expo/vector-icons";
import { useLocalSearchParams } from "expo-router";
import { useCallback, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native";
import { BITRATES } from "@/components/BitrateSelector";
import {
@@ -45,6 +46,7 @@ const DropdownView = ({
const { settings, updateSettings } = useSettings();
const router = useRouter();
const isOffline = useOfflineMode();
const { t } = useTranslation();
const { subtitleIndex, audioIndex, bitrateValue, playbackPosition } =
useLocalSearchParams<{
@@ -215,7 +217,7 @@ const DropdownView = ({
return (
<PlatformDropdown
title='Playback Options'
title={t("player.playback_options_title")}
groups={optionGroups}
trigger={trigger}
expoUIConfig={{}}

View File

@@ -1,39 +0,0 @@
import type { DefaultLanguageOption } from "@/utils/atoms/settings";
export const LANGUAGES: DefaultLanguageOption[] = [
{ label: "English", value: "eng" },
{ label: "Spanish", value: "spa" },
{ label: "Chinese (Mandarin)", value: "cmn" },
{ label: "Hindi", value: "hin" },
{ label: "Arabic", value: "ara" },
{ label: "French", value: "fra" },
{ label: "Russian", value: "rus" },
{ label: "Portuguese", value: "por" },
{ label: "Japanese", value: "jpn" },
{ label: "German", value: "deu" },
{ label: "Italian", value: "ita" },
{ label: "Korean", value: "kor" },
{ label: "Turkish", value: "tur" },
{ label: "Dutch", value: "nld" },
{ label: "Polish", value: "pol" },
{ label: "Vietnamese", value: "vie" },
{ label: "Thai", value: "tha" },
{ label: "Indonesian", value: "ind" },
{ label: "Greek", value: "ell" },
{ label: "Swedish", value: "swe" },
{ label: "Danish", value: "dan" },
{ label: "Norwegian", value: "nor" },
{ label: "Finnish", value: "fin" },
{ label: "Czech", value: "ces" },
{ label: "Hungarian", value: "hun" },
{ label: "Romanian", value: "ron" },
{ label: "Ukrainian", value: "ukr" },
{ label: "Hebrew", value: "heb" },
{ label: "Bengali", value: "ben" },
{ label: "Punjabi", value: "pan" },
{ label: "Tagalog", value: "tgl" },
{ label: "Swahili", value: "swa" },
{ label: "Malay", value: "msa" },
{ label: "Persian", value: "fas" },
{ label: "Urdu", value: "urd" },
];

View File

@@ -1,6 +0,0 @@
import { Platform } from "react-native";
export const TAB_HEIGHT = Platform.OS === "android" ? 58 : 74;
// Matches `w-28` poster cards (approx 112px wide, 10/15 aspect ratio) + 2 lines of text.
export const POSTER_CAROUSEL_HEIGHT = 220;

View File

@@ -1,37 +0,0 @@
import { useCallback, useEffect, useRef } from "react";
import { useSharedValue } from "react-native-reanimated";
export const useControlsVisibility = (timeout = 3000) => {
const opacity = useSharedValue(1);
const hideControlsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const showControls = useCallback(() => {
opacity.value = 1;
if (hideControlsTimerRef.current) {
clearTimeout(hideControlsTimerRef.current);
}
hideControlsTimerRef.current = setTimeout(() => {
opacity.value = 0;
}, timeout);
}, [timeout]);
const hideControls = useCallback(() => {
opacity.value = 0;
if (hideControlsTimerRef.current) {
clearTimeout(hideControlsTimerRef.current);
}
}, []);
useEffect(() => {
return () => {
if (hideControlsTimerRef.current) {
clearTimeout(hideControlsTimerRef.current);
}
};
}, []);
return { opacity, showControls, hideControls };
};

View File

@@ -1,35 +0,0 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useCallback } from "react";
import useRouter from "@/hooks/useAppRouter";
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
import { writeToLog } from "@/utils/log";
export const useDownloadedFileOpener = () => {
const router = useRouter();
const { setPlayUrl, setOfflineSettings } = usePlaySettings();
const openFile = useCallback(
async (item: BaseItemDto) => {
if (!item.Id) {
writeToLog("ERROR", "Attempted to open a file without an ID.");
console.error("Attempted to open a file without an ID.");
return;
}
const queryParams = new URLSearchParams({
itemId: item.Id,
offline: "true",
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
});
try {
router.push(`/player/direct-player?${queryParams.toString()}`);
} catch (error) {
writeToLog("ERROR", "Error opening file", error);
console.error("Error opening file:", error);
}
},
[setOfflineSettings, setPlayUrl, router],
);
return { openFile };
};

View File

@@ -1,120 +0,0 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useAtom, useAtomValue } from "jotai";
import { useEffect, useMemo } from "react";
import { Platform } from "react-native";
import type * as ImageColorsType from "react-native-image-colors";
import { apiAtom } from "@/providers/JellyfinProvider";
// Conditionally import react-native-image-colors only on non-TV platforms
const ImageColors = Platform.isTV
? null
: (require("react-native-image-colors") as typeof ImageColorsType);
import {
adjustToNearBlack,
calculateTextColor,
isCloseToBlack,
itemThemeColorAtom,
} from "@/utils/atoms/primaryColor";
import { getItemImage } from "@/utils/getItemImage";
import { storage } from "@/utils/mmkv";
/**
* Custom hook to extract and manage image colors for a given item.
*
* @param item - The BaseItemDto object representing the item.
* @param disabled - A boolean flag to disable color extraction.
*
*/
export const useImageColors = ({
item,
url,
disabled,
}: {
item?: BaseItemDto | null;
url?: string | null;
disabled?: boolean;
}) => {
const api = useAtomValue(apiAtom);
const [, setPrimaryColor] = useAtom(itemThemeColorAtom);
const isTv = Platform.isTV;
const source = useMemo(() => {
if (!api) return;
if (url) return { uri: url };
if (item)
return getItemImage({
item,
api,
variant: "Primary",
quality: 80,
width: 300,
});
return null;
}, [api, item, url]);
useEffect(() => {
if (isTv) return;
if (disabled) return;
if (source?.uri) {
const _primary = storage.getString(`${source.uri}-primary`);
const _text = storage.getString(`${source.uri}-text`);
if (_primary && _text) {
setPrimaryColor({
primary: _primary,
text: _text,
});
return;
}
// Extract colors from the image
if (!ImageColors?.getColors) return;
ImageColors.getColors(source.uri, {
fallback: "#fff",
cache: false,
})
.then((colors: ImageColorsType.ImageColorsResult) => {
let primary = "#fff";
let text = "#000";
let backup = "#fff";
// Select the appropriate color based on the platform
if (colors.platform === "android") {
primary = colors.dominant;
backup = colors.vibrant;
} else if (colors.platform === "ios") {
primary = colors.detail;
backup = colors.primary;
}
// Adjust the primary color if it's too close to black
if (primary && isCloseToBlack(primary)) {
if (backup && !isCloseToBlack(backup)) primary = backup;
primary = adjustToNearBlack(primary);
}
// Calculate the text color based on the primary color
if (primary) text = calculateTextColor(primary);
setPrimaryColor({
primary,
text,
});
// Cache the colors in storage
if (source.uri && primary) {
storage.set(`${source.uri}-primary`, primary);
storage.set(`${source.uri}-text`, text);
}
})
.catch((error: any) => {
console.error("Error getting colors", error);
});
}
}, [isTv, source?.uri, setPrimaryColor, disabled]);
if (isTv) return;
};

View File

@@ -2,7 +2,7 @@ import axios, { type AxiosError, type AxiosInstance } from "axios";
import { atom } from "jotai";
import { useAtom } from "jotai/index";
import { inRange } from "lodash";
import type { User as JellyseerrUser } from "@/utils/jellyseerr/server/entity/User";
import type { User as SeerrUser } from "@/utils/jellyseerr/server/entity/User";
import type {
MovieResult,
Results,
@@ -62,12 +62,12 @@ interface SearchResults {
results: Results[];
}
const JELLYSEERR_USER = "JELLYSEERR_USER";
const JELLYSEERR_COOKIES = "JELLYSEERR_COOKIES";
const SEERR_USER = "SEERR_USER";
const SEERR_COOKIES = "SEERR_COOKIES";
export const clearJellyseerrStorageData = () => {
storage.remove(JELLYSEERR_USER);
storage.remove(JELLYSEERR_COOKIES);
export const clearSeerrStorageData = () => {
storage.remove(SEERR_USER);
storage.remove(SEERR_COOKIES);
};
export enum Endpoints {
@@ -111,12 +111,27 @@ export type TestResult =
isValid: false;
};
export class JellyseerrApi {
/**
* Normalizes a URL by ensuring it has a protocol prefix (https:// or http://)
* @param url - The URL to normalize
* @returns The normalized URL with protocol prefix
*/
function normalizeUrl(url: string): string {
const trimmed = url.trim().replace(/\/+$/, ""); // Remove trailing slashes
if (trimmed.match(/^https?:\/\//i)) {
return trimmed;
}
// Default to https if no protocol is specified
return `https://${trimmed}`;
}
export class SeerrApi {
axios: AxiosInstance;
constructor(baseUrl: string) {
const normalizedUrl = normalizeUrl(baseUrl);
this.axios = axios.create({
baseURL: baseUrl,
baseURL: normalizedUrl,
withCredentials: true,
withXSRFToken: true,
xsrfHeaderName: "XSRF-TOKEN",
@@ -126,8 +141,8 @@ export class JellyseerrApi {
}
async test(): Promise<TestResult> {
const user = storage.get<JellyseerrUser>(JELLYSEERR_USER);
const cookies = storage.get<string[]>(JELLYSEERR_COOKIES);
const user = storage.get<SeerrUser>(SEERR_USER);
const cookies = storage.get<string[]>(SEERR_COOKIES);
if (user && cookies) {
return Promise.resolve({
@@ -142,15 +157,13 @@ export class JellyseerrApi {
const { status, headers, data } = response;
if (inRange(status, 200, 299)) {
if (data.version < "2.0.0") {
const error = t(
"jellyseerr.toasts.jellyseer_does_not_meet_requirements",
);
const error = t("seerr.toasts.seer_does_not_meet_requirements");
toast.error(error);
throw Error(error);
}
storage.setAny(
JELLYSEERR_COOKIES,
SEERR_COOKIES,
headers["set-cookie"]?.flatMap((c) => c.split("; ")) ?? [],
);
return {
@@ -158,9 +171,9 @@ export class JellyseerrApi {
requiresPass: true,
};
}
toast.error(t("jellyseerr.toasts.jellyseerr_test_failed"));
toast.error(t("seerr.toasts.seerr_test_failed"));
writeErrorLog(
`Jellyseerr returned a ${status} for url:\n${response.config.url}`,
`Seerr returned a ${status} for url:\n${response.config.url}`,
response.data,
);
return {
@@ -169,7 +182,7 @@ export class JellyseerrApi {
};
})
.catch((e) => {
const msg = t("jellyseerr.toasts.failed_to_test_jellyseerr_server_url");
const msg = t("seerr.toasts.failed_to_test_seerr_server_url");
toast.error(msg);
console.error(msg, e);
return {
@@ -179,9 +192,9 @@ export class JellyseerrApi {
});
}
async login(username: string, password: string): Promise<JellyseerrUser> {
async login(username: string, password: string): Promise<SeerrUser> {
return this.axios
?.post<JellyseerrUser>(Endpoints.API_V1 + Endpoints.AUTH_JELLYFIN, {
?.post<SeerrUser>(Endpoints.API_V1 + Endpoints.AUTH_JELLYFIN, {
username,
password,
email: username,
@@ -189,7 +202,7 @@ export class JellyseerrApi {
.then((response) => {
const user = response?.data;
if (!user) throw Error("Login failed");
storage.setAny(JELLYSEERR_USER, user);
storage.setAny(SEERR_USER, user);
return user;
});
}
@@ -364,7 +377,7 @@ export class JellyseerrApi {
const issue = response.data;
if (issue.status === IssueStatus.OPEN) {
toast.success(t("jellyseerr.toasts.issue_submitted"));
toast.success(t("seerr.toasts.issue_submitted"));
}
return issue;
});
@@ -392,7 +405,7 @@ export class JellyseerrApi {
const cookies = response.headers["set-cookie"];
if (cookies) {
storage.setAny(
JELLYSEERR_COOKIES,
SEERR_COOKIES,
response.headers["set-cookie"]?.flatMap((c) => c.split("; ")),
);
}
@@ -400,11 +413,11 @@ export class JellyseerrApi {
},
(error: AxiosError) => {
writeErrorLog(
`Jellyseerr response error\nerror: ${error.toString()}\nurl: ${error?.config?.url}`,
`Seerr response error\nerror: ${error.toString()}\nurl: ${error?.config?.url}`,
error.response?.data,
);
if (error.response?.status === 403) {
clearJellyseerrStorageData();
clearSeerrStorageData();
}
return Promise.reject(error);
},
@@ -412,7 +425,7 @@ export class JellyseerrApi {
this.axios.interceptors.request.use(
async (config) => {
const cookies = storage.get<string[]>(JELLYSEERR_COOKIES);
const cookies = storage.get<string[]>(SEERR_COOKIES);
if (cookies) {
const headerName = this.axios.defaults.xsrfHeaderName!;
const xsrfToken = cookies
@@ -425,78 +438,77 @@ export class JellyseerrApi {
return config;
},
(error) => {
console.error("Jellyseerr request error", error);
console.error("Seerr request error", error);
return Promise.reject(error);
},
);
}
}
const jellyseerrUserAtom = atom(storage.get<JellyseerrUser>(JELLYSEERR_USER));
const seerrUserAtom = atom(storage.get<SeerrUser>(SEERR_USER));
export const useJellyseerr = () => {
export const useSeerr = () => {
const { settings, updateSettings } = useSettings();
const [jellyseerrUser, setJellyseerrUser] = useAtom(jellyseerrUserAtom);
const [seerrUser, setSeerrUser] = useAtom(seerrUserAtom);
const queryClient = useNetworkAwareQueryClient();
const jellyseerrApi = useMemo(() => {
const cookies = storage.get<string[]>(JELLYSEERR_COOKIES);
if (settings?.jellyseerrServerUrl && cookies && jellyseerrUser) {
return new JellyseerrApi(settings?.jellyseerrServerUrl);
const seerrApi = useMemo(() => {
const cookies = storage.get<string[]>(SEERR_COOKIES);
if (settings?.seerrServerUrl && cookies && seerrUser) {
return new SeerrApi(settings?.seerrServerUrl);
}
return undefined;
}, [settings?.jellyseerrServerUrl, jellyseerrUser]);
}, [settings?.seerrServerUrl, seerrUser]);
const clearAllJellyseerData = useCallback(async () => {
clearJellyseerrStorageData();
setJellyseerrUser(undefined);
updateSettings({ jellyseerrServerUrl: undefined });
}, []);
const clearAllSeerrData = useCallback(async () => {
clearSeerrStorageData();
setSeerrUser(undefined);
updateSettings({ seerrServerUrl: undefined });
}, [setSeerrUser, updateSettings]);
const requestMedia = useCallback(
(title: string, request: MediaRequestBody, onSuccess?: () => void) => {
jellyseerrApi?.request?.(request)?.then(async (mediaRequest) => {
seerrApi?.request?.(request)?.then(async (mediaRequest) => {
await queryClient.invalidateQueries({
queryKey: ["search", "jellyseerr"],
queryKey: ["search", "seerr"],
});
switch (mediaRequest.status) {
case MediaRequestStatus.PENDING:
case MediaRequestStatus.APPROVED:
toast.success(
t("jellyseerr.toasts.requested_item", { item: title }),
);
toast.success(t("seerr.toasts.requested_item", { item: title }));
onSuccess?.();
break;
case MediaRequestStatus.DECLINED:
toast.error(
t("jellyseerr.toasts.you_dont_have_permission_to_request"),
);
toast.error(t("seerr.toasts.you_dont_have_permission_to_request"));
break;
case MediaRequestStatus.FAILED:
toast.error(
t("jellyseerr.toasts.something_went_wrong_requesting_media"),
t("seerr.toasts.something_went_wrong_requesting_media"),
);
break;
}
});
},
[jellyseerrApi],
[seerrApi, queryClient],
);
const isJellyseerrMovieOrTvResult = (
items: any | null | undefined,
): items is MovieResult | TvResult => {
return (
items &&
Object.hasOwn(items, "mediaType") &&
(items.mediaType === MediaType.MOVIE || items.mediaType === MediaType.TV)
);
};
const isSeerrMovieOrTvResult = useCallback(
(items: any | null | undefined): items is MovieResult | TvResult => {
return (
items &&
Object.hasOwn(items, "mediaType") &&
(items.mediaType === MediaType.MOVIE ||
items.mediaType === MediaType.TV)
);
},
[],
);
const getTitle = (
item?: TvResult | TvDetails | MovieResult | MovieDetails | PersonCreditCast,
) => {
return isJellyseerrMovieOrTvResult(item)
return isSeerrMovieOrTvResult(item)
? item.mediaType === MediaType.MOVIE
? item?.title
: item?.name
@@ -509,7 +521,7 @@ export const useJellyseerr = () => {
item?: TvResult | TvDetails | MovieResult | MovieDetails | PersonCreditCast,
) => {
return new Date(
(isJellyseerrMovieOrTvResult(item)
(isSeerrMovieOrTvResult(item)
? item.mediaType === MediaType.MOVIE
? item?.releaseDate
: item?.firstAirDate
@@ -522,32 +534,35 @@ export const useJellyseerr = () => {
const getMediaType = (
item?: TvResult | TvDetails | MovieResult | MovieDetails | PersonCreditCast,
): MediaType => {
return isJellyseerrMovieOrTvResult(item)
return isSeerrMovieOrTvResult(item)
? (item.mediaType as MediaType)
: item?.mediaInfo?.mediaType;
};
const jellyseerrRegion = useMemo(
const seerrRegion = useMemo(
// streamingRegion and discoverRegion exists. region doesn't
() => jellyseerrUser?.settings?.discoverRegion || "US",
[jellyseerrUser],
() => seerrUser?.settings?.discoverRegion || "US",
[seerrUser],
);
const jellyseerrLocale = useMemo(() => {
return jellyseerrUser?.settings?.locale || "en";
}, [jellyseerrUser]);
const seerrLocale = useMemo(() => {
const locale = seerrUser?.settings?.locale || "en";
// Use regex to check if locale already contains region code (e.g., zh-CN, pt-BR)
// If not, append the region to create a valid BCP 47 locale string
return /^[a-z]{2,3}-/i.test(locale) ? locale : `${locale}-${seerrRegion}`;
}, [seerrUser, seerrRegion]);
return {
jellyseerrApi,
jellyseerrUser,
setJellyseerrUser,
clearAllJellyseerData,
isJellyseerrMovieOrTvResult,
seerrApi,
seerrUser,
setSeerrUser,
clearAllSeerrData,
isSeerrMovieOrTvResult,
getTitle,
getYear,
getMediaType,
jellyseerrRegion,
jellyseerrLocale,
seerrRegion,
seerrLocale,
requestMedia,
};
};

View File

@@ -1,11 +1,13 @@
import { useTranslation } from "react-i18next";
import { MpvPlayerViewProps } from "./MpvPlayer.types";
export default function MpvPlayerView(props: MpvPlayerViewProps) {
const url = props.source?.url ?? "";
const { t } = useTranslation();
return (
<div>
<iframe
title='MPV Player'
title={t("player.mpv_player_title")}
style={{ flex: 1 }}
src={url}
onLoad={() => props.onLoad?.({ nativeEvent: { url } })}

View File

@@ -23,7 +23,7 @@ import { getDeviceName } from "react-native-device-info";
import uuid from "react-native-uuid";
import useRouter from "@/hooks/useAppRouter";
import { useInterval } from "@/hooks/useInterval";
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
import { SeerrApi, useSeerr } from "@/hooks/useSeerr";
import { useSettings } from "@/utils/atoms/settings";
import { writeErrorLog, writeInfoLog } from "@/utils/log";
import { storage } from "@/utils/mmkv";
@@ -113,7 +113,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const [isPolling, setIsPolling] = useState<boolean>(false);
const [secret, setSecret] = useState<string | null>(null);
const { setPluginSettings, refreshStreamyfinPluginSettings } = useSettings();
const { clearAllJellyseerData, setJellyseerrUser } = useJellyseerr();
const { clearAllSeerrData, setSeerrUser } = useSeerr();
const headers = useMemo(() => {
if (!deviceId) return {};
@@ -290,13 +290,13 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
}
const recentPluginSettings = await refreshStreamyfinPluginSettings();
if (recentPluginSettings?.jellyseerrServerUrl?.value) {
const jellyseerrApi = new JellyseerrApi(
recentPluginSettings.jellyseerrServerUrl.value,
if (recentPluginSettings?.seerrServerUrl?.value) {
const seerrApi = new SeerrApi(
recentPluginSettings.seerrServerUrl.value,
);
await jellyseerrApi.test().then((result) => {
await seerrApi.test().then((result) => {
if (result.isValid && result.requiresPass) {
jellyseerrApi.login(username, password).then(setJellyseerrUser);
seerrApi.login(username, password).then(setSeerrUser);
}
});
}
@@ -349,7 +349,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setUser(null);
setApi(null);
setPluginSettings(undefined);
await clearAllJellyseerData();
await clearAllSeerrData();
// Note: We keep saved credentials for quick switching back
},
onError: (error) => {

View File

@@ -0,0 +1,121 @@
#!/usr/bin/env node
/**
* Check for unused translation keys in en.json
* Usage: bun run scripts/check-unused-translations.js [--remove]
*/
const fs = require("node:fs");
const path = require("node:path");
const { execSync } = require("node:child_process");
const TRANSLATION_FILE = path.join(__dirname, "../translations/en.json");
const REMOVE_UNUSED = process.argv.includes("--remove");
// Read translation file
const translations = JSON.parse(fs.readFileSync(TRANSLATION_FILE, "utf8"));
// Flatten nested keys
function flattenKeys(obj, prefix = "") {
let keys = [];
for (const [key, value] of Object.entries(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
keys = keys.concat(flattenKeys(value, fullKey));
} else {
keys.push(fullKey);
}
}
return keys;
}
// Search for key usage in codebase
function isKeyUsed(key) {
try {
// Escape special regex characters in the key
const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
// Search in TypeScript/TSX files
const result = execSync(
`git grep -l "${escapedKey}" -- "*.ts" "*.tsx" 2>nul || echo ""`,
{
encoding: "utf8",
cwd: path.join(__dirname, ".."),
maxBuffer: 10 * 1024 * 1024,
},
).trim();
return result.length > 0;
} catch (_error) {
// If grep fails, assume key is used to be safe
return true;
}
}
// Remove nested key from object
function removeNestedKey(obj, keyPath) {
const keys = keyPath.split(".");
const lastKey = keys.pop();
let current = obj;
for (const key of keys) {
if (!current[key]) return false;
current = current[key];
}
if (current[lastKey] !== undefined) {
delete current[lastKey];
// Clean up empty parent objects
if (Object.keys(current).length === 0 && keys.length > 0) {
removeNestedKey(obj, keys.join("."));
}
return true;
}
return false;
}
console.log("🔍 Checking for unused translation keys...\n");
const allKeys = flattenKeys(translations);
const unusedKeys = [];
for (const key of allKeys) {
if (!isKeyUsed(key)) {
unusedKeys.push(key);
}
}
if (unusedKeys.length === 0) {
console.log("✅ All translation keys are being used!");
process.exit(0);
}
console.log(`Found ${unusedKeys.length} unused translation keys:\n`);
for (const key of unusedKeys) {
console.log(`${key}`);
}
if (REMOVE_UNUSED) {
console.log("\n🗑 Removing unused keys...");
let removed = 0;
for (const key of unusedKeys) {
if (removeNestedKey(translations, key)) {
removed++;
}
}
// Write back to file
fs.writeFileSync(
TRANSLATION_FILE,
`${JSON.stringify(translations, null, 2)}\n`,
"utf8",
);
console.log(`✅ Removed ${removed} unused translation keys from en.json`);
} else {
console.log("\n💡 Run with --remove flag to remove these keys from en.json");
console.log(
" Example: bun run scripts/check-unused-translations.js --remove",
);
}

View File

@@ -176,7 +176,7 @@ function runTypeCheck() {
} catch (error) {
const errorOutput = (error && (error.stderr || error.stdout)) || "";
// Filter out jellyseerr utils errors - this is a third-party git submodule
// Filter out seerr utils errors - this is a third-party git submodule
// that generates a large volume of known type errors
const filteredLines = errorOutput.split("\n").filter((line) => {
const trimmedLine = line.trim();
@@ -227,7 +227,7 @@ function runTypeCheck() {
}
log(
`${colors.bold}TypeScript check passed${colors.reset} ${colors.gray}(jellyseerr utils errors ignored)${colors.reset}`,
`${colors.bold}TypeScript check passed${colors.reset} ${colors.gray}(seerr utils errors ignored)${colors.reset}`,
colors.green,
);
return { ok: true };

View File

@@ -1,19 +1,19 @@
{
"login": {
"username_required": "Username Is Required",
"username_required": "Username is required",
"error_title": "Error",
"login_title": "Log In",
"login_title": "Log in",
"login_to_title": "Log in to",
"username_placeholder": "Username",
"password_placeholder": "Password",
"login_button": "Log In",
"login_button": "Log in",
"quick_connect": "Quick Connect",
"enter_code_to_login": "Enter code {{code}} to login",
"enter_code_to_login": "Enter code {{code}} to log in",
"failed_to_initiate_quick_connect": "Failed to initiate Quick Connect",
"got_it": "Got It",
"connection_failed": "Connection Failed",
"could_not_connect_to_server": "Could not connect to the server. Please check the URL and your network connection.",
"an_unexpected_error_occured": "An Unexpected Error Occurred",
"an_unexpected_error_occured": "An unexpected error occurred",
"change_server": "Change Server",
"invalid_username_or_password": "Invalid Username or Password",
"user_does_not_have_permission_to_log_in": "User does not have permission to log in",
@@ -22,7 +22,7 @@
"there_is_a_server_error": "There is a server error",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "An unexpected error occurred. Did you enter the server URL correctly?",
"too_old_server_text": "Unsupported Jellyfin Server Discovered",
"too_old_server_description": "Please update Jellyfin to the latest version"
"too_old_server_description": "Please update Jellyfin to the latest version."
},
"server": {
"enter_url_to_jellyfin_server": "Enter the URL to your Jellyfin server",
@@ -88,27 +88,27 @@
"continue_watching": "Continue Watching",
"next_up": "Next Up",
"continue_and_next_up": "Continue & Next Up",
"recently_added_in": "Recently Added in {{libraryName}}",
"recently_added_in": "Recently added in {{libraryName}}",
"suggested_movies": "Suggested Movies",
"suggested_episodes": "Suggested Episodes",
"intro": {
"welcome_to_streamyfin": "Welcome to Streamyfin",
"a_free_and_open_source_client_for_jellyfin": "A Free and Open-Source Client for Jellyfin.",
"a_free_and_open_source_client_for_jellyfin": "A Free and Open-Source Client for Jellyfin",
"features_title": "Features",
"features_description": "Streamyfin has a bunch of features and integrates with a wide array of software which you can find in the settings menu, these include:",
"jellyseerr_feature_description": "Connect to your Seerr instance and request movies directly in the app.",
"features_description": "Streamyfin offers many features and integrates with a wide array of software which you can find in the settings menu, including:",
"seerr_feature_description": "Connect to your Seerr instance and request movies directly in the app.",
"downloads_feature_title": "Downloads",
"downloads_feature_description": "Download movies and tv-shows to view offline. Use either the default method or install the optimize server to download files in the background.",
"chromecast_feature_description": "Cast movies and tv-shows to your Chromecast devices.",
"centralised_settings_plugin_title": "Centralised Settings Plugin",
"centralised_settings_plugin_description": "Configure settings from a centralised location on your Jellyfin server. All client settings for all users will be synced automatically.",
"downloads_feature_description": "Download movies and TV shows to view offline.",
"chromecast_feature_description": "Cast movies and TV shows to your Chromecast devices.",
"centralized_settings_plugin_title": "Centralized Settings Plugin",
"centralized_settings_plugin_description": "Configure settings from a centralized location on your Jellyfin server. All client settings for all users will be synced automatically.",
"done_button": "Done",
"go_to_settings_button": "Go to Settings",
"read_more": "Read More"
},
"settings": {
"settings_title": "Settings",
"log_out_button": "Log Out",
"log_out_button": "Log out",
"categories": {
"title": "Categories"
},
@@ -179,9 +179,9 @@
"horizontal_swipe_skip": "Horizontal Swipe to Skip",
"horizontal_swipe_skip_description": "Swipe left/right when controls are hidden to skip",
"left_side_brightness": "Left Side Brightness Control",
"left_side_brightness_description": "Swipe up/down on left side to adjust brightness",
"left_side_brightness_description": "Swipe up/down on the left side to adjust brightness",
"right_side_volume": "Right Side Volume Control",
"right_side_volume_description": "Swipe up/down on right side to adjust volume",
"right_side_volume_description": "Swipe up/down on the right side to adjust volume",
"hide_volume_slider": "Hide Volume Slider",
"hide_volume_slider_description": "Hide the volume slider in the video player",
"hide_brightness_slider": "Hide Brightness Slider",
@@ -218,7 +218,7 @@
"Smart": "Smart",
"Always": "Always",
"None": "None",
"OnlyForced": "OnlyForced"
"OnlyForced": "Only Forced"
},
"text_color": "Text Color",
"background_color": "Background Color",
@@ -253,29 +253,7 @@
},
"subtitle_color": "Subtitle Color",
"subtitle_background_color": "Background Color",
"subtitle_font": "Subtitle Font",
"ksplayer_title": "KSPlayer Settings",
"hardware_decode": "Hardware Decoding",
"hardware_decode_description": "Use hardware acceleration for video decoding. Disable if you experience playback issues."
},
"vlc_subtitles": {
"title": "VLC Subtitle Settings",
"hint": "Customize subtitle appearance for VLC player. Changes take effect on next playback.",
"text_color": "Text Color",
"background_color": "Background Color",
"background_opacity": "Background Opacity",
"outline_color": "Outline Color",
"outline_opacity": "Outline Opacity",
"outline_thickness": "Outline Thickness",
"bold": "Bold Text",
"margin": "Bottom Margin"
},
"video_player": {
"title": "Video Player",
"video_player": "Video Player",
"video_player_description": "Choose which video player to use on iOS.",
"ksplayer": "KSPlayer",
"vlc": "VLC"
"subtitle_font": "Subtitle Font"
},
"other": {
"other_title": "Other",
@@ -294,20 +272,15 @@
"UNKNOWN": "Unknown"
},
"safe_area_in_controls": "Safe Area in Controls",
"video_player": "Video Player",
"video_players": {
"VLC_3": "VLC 3",
"VLC_4": "VLC 4 (Experimental + PiP)"
},
"show_custom_menu_links": "Show Custom Menu Links",
"show_large_home_carousel": "Show Large Home Carousel (beta)",
"hide_libraries": "Hide Libraries",
"select_liraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.",
"select_libraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.",
"disable_haptic_feedback": "Disable Haptic Feedback",
"default_quality": "Default Quality",
"default_playback_speed": "Default Playback Speed",
"auto_play_next_episode": "Auto-play Next Episode",
"max_auto_play_episode_count": "Max Auto Play Episode Count",
"auto_play_next_episode": "Autoplay Next Episode",
"max_auto_play_episode_count": "Max Autoplay Episode Count",
"disabled": "Disabled"
},
"downloads": {
@@ -326,8 +299,8 @@
},
"plugins": {
"plugins_title": "Plugins",
"jellyseerr": {
"jellyseerr_warning": "This integration is in its early stages. Expect things to change.",
"seerr": {
"seerr_warning": "This integration is in early development. Features may change.",
"server_url": "Server URL",
"server_url_hint": "Example: http(s)://your-host.url\n(add port if required)",
"server_url_placeholder": "Seerr URL",
@@ -339,9 +312,9 @@
"movie_quota_days": "Movie Quota Days",
"tv_quota_limit": "TV Quota Limit",
"tv_quota_days": "TV Quota Days",
"reset_jellyseerr_config_button": "Reset Seerr Config",
"reset_seerr_config_button": "Reset Seerr Config",
"unlimited": "Unlimited",
"plus_n_more": "+{{n}} More",
"plus_n_more": "+{{n}} more",
"order_by": {
"DEFAULT": "Default",
"VOTE_COUNT_AND_AVERAGE": "Vote count and average",
@@ -353,7 +326,7 @@
"url": "URL",
"server_url_placeholder": "http(s)://domain.org:port",
"marlin_search_hint": "Enter the URL for the Marlin server. The URL should include http or https and optionally the port.",
"read_more_about_marlin": "Read More About Marlin.",
"read_more_about_marlin": "Read more about Marlin.",
"save_button": "Save",
"toasts": {
"saved": "Saved",
@@ -368,7 +341,7 @@
"url": "URL",
"server_url_placeholder": "http(s)://streamystats.example.com",
"streamystats_search_hint": "Enter the URL for your Streamystats server. The URL should include http or https and optionally the port.",
"read_more_about_streamystats": "Read More About Streamystats.",
"read_more_about_streamystats": "Read more about Streamystats.",
"save_button": "Save",
"save": "Save",
"features_title": "Features",
@@ -427,18 +400,18 @@
"system": "System"
},
"toasts": {
"error_deleting_files": "Error Deleting Files",
"error_deleting_files": "Error deleting files",
"background_downloads_enabled": "Background downloads enabled",
"background_downloads_disabled": "Background downloads disabled"
}
},
"sessions": {
"title": "Sessions",
"no_active_sessions": "No Active Sessions"
"no_active_sessions": "No active sessions"
},
"downloads": {
"downloads_title": "Downloads",
"tvseries": "TV-Series",
"tvseries": "TV Series",
"movies": "Movies",
"queue": "Queue",
"other_media": "Other media",
@@ -446,7 +419,7 @@
"no_items_in_queue": "No Items in Queue",
"no_downloaded_items": "No Downloaded Items",
"delete_all_movies_button": "Delete All Movies",
"delete_all_tvseries_button": "Delete All TV-Series",
"delete_all_tvseries_button": "Delete All TV Series",
"delete_all_button": "Delete All",
"delete_all_other_media_button": "Delete other media",
"active_download": "Active Download",
@@ -461,27 +434,27 @@
"eta": "ETA {{eta}}",
"toasts": {
"you_are_not_allowed_to_download_files": "You are not allowed to download files.",
"deleted_all_movies_successfully": "Deleted All Movies Successfully!",
"failed_to_delete_all_movies": "Failed to Delete All Movies",
"deleted_all_tvseries_successfully": "Deleted All TV-Series Successfully!",
"failed_to_delete_all_tvseries": "Failed to Delete All TV-Series",
"deleted_media_successfully": "Deleted other media Successfully!",
"failed_to_delete_media": "Failed to Delete other media",
"download_deleted": "Download Deleted",
"download_cancelled": "Download Cancelled",
"could_not_delete_download": "Could Not Delete Download",
"download_paused": "Download Paused",
"could_not_pause_download": "Could Not Pause Download",
"download_resumed": "Download Resumed",
"could_not_resume_download": "Could Not Resume Download",
"download_completed": "Download Completed",
"download_failed": "Download Failed",
"deleted_all_movies_successfully": "Deleted all movies successfully!",
"failed_to_delete_all_movies": "Failed to delete all movies",
"deleted_all_tvseries_successfully": "Deleted all TV series successfully!",
"failed_to_delete_all_tvseries": "Failed to delete all TV series",
"deleted_media_successfully": "Deleted other media successfully!",
"failed_to_delete_media": "Failed to delete other media",
"download_deleted": "Download deleted",
"download_cancelled": "Download cancelled",
"could_not_delete_download": "Could not delete download",
"download_paused": "Download paused",
"could_not_pause_download": "Could not pause download",
"download_resumed": "Download resumed",
"could_not_resume_download": "Could not resume download",
"download_completed": "Download completed",
"download_failed": "Download failed",
"download_failed_for_item": "Download failed for {{item}} - {{error}}",
"download_completed_for_item": "Download Completed for {{item}}",
"download_started_for_item": "Download Started for {{item}}",
"download_completed_for_item": "Download completed for {{item}}",
"download_started_for_item": "Download started for {{item}}",
"failed_to_start_download": "Failed to start download",
"item_already_downloading": "{{item}} is already downloading",
"all_files_deleted": "All Downloads Deleted Successfully",
"all_files_deleted": "All downloads deleted successfully",
"files_deleted_by_type": "{{count}} {{type}} deleted",
"all_files_folders_and_jobs_deleted_successfully": "All files, folders, and jobs deleted successfully",
"failed_to_clean_cache_directory": "Failed to clean cache directory",
@@ -515,7 +488,7 @@
"library": "Library",
"discover": "Discover",
"no_results": "No Results",
"no_results_found_for": "No Results Found For",
"no_results_found_for": "No results found for",
"movies": "Movies",
"series": "Series",
"episodes": "Episodes",
@@ -559,6 +532,7 @@
"items": "Items"
},
"options": {
"options_title": "Options",
"display": "Display",
"row": "Row",
"list": "List",
@@ -598,17 +572,26 @@
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
"message_from_server": "Message from Server: {{message}}",
"next_episode": "Next Episode",
"refresh_tracks": "Refresh Tracks",
"audio_tracks": "Audio Tracks:",
"playback_state": "Playback State:",
"index": "Index:",
"continue_watching": "Continue Watching",
"go_back": "Go Back",
"downloaded_file_title": "You have this file downloaded",
"downloaded_file_message": "Do you want to play the downloaded file?",
"downloaded_file_yes": "Yes",
"downloaded_file_no": "No",
"downloaded_file_cancel": "Cancel"
"downloaded_file_cancel": "Cancel",
"playback_options_title": "Playback Options",
"mpv_subtitle_settings_title": "MPV Subtitle Settings",
"mpv_subtitle_settings_description": "Advanced subtitle customization for MPV player",
"subtitle_scale": "Subtitle Scale",
"vertical_margin": "Vertical Margin",
"horizontal_alignment": "Horizontal Alignment",
"vertical_alignment": "Vertical Alignment",
"alignment_left": "Left",
"alignment_center": "Center",
"alignment_right": "Right",
"alignment_top": "Top",
"alignment_bottom": "Bottom",
"mpv_player_title": "MPV Player"
},
"item_card": {
"next_up": "Next Up",
@@ -627,11 +610,11 @@
"media_options": "Media Options",
"quality": "Quality",
"audio": "Audio",
"subtitles": "Subtitle",
"subtitles": "Subtitles",
"show_more": "Show More",
"show_less": "Show Less",
"appeared_in": "Appeared In",
"could_not_load_item": "Could Not Load Item",
"could_not_load_item": "Could not load item",
"none": "None",
"download": {
"download_season": "Download Season",
@@ -654,11 +637,11 @@
"for_kids": "For Kids",
"news": "News"
},
"jellyseerr": {
"seerr": {
"confirm": "Confirm",
"cancel": "Cancel",
"yes": "Yes",
"whats_wrong": "What's Wrong?",
"whats_wrong": "What's wrong?",
"issue_type": "Issue Type",
"select_an_issue": "Select an Issue",
"types": "Types",
@@ -667,7 +650,7 @@
"report_issue_button": "Report Issue",
"request_button": "Request",
"are_you_sure_you_want_to_request_all_seasons": "Are you sure you want to request all seasons?",
"failed_to_login": "Failed to Login",
"failed_to_login": "Failed to log in",
"cast": "Cast",
"details": "Details",
"status": "Status",
@@ -698,17 +681,17 @@
"requested_by": "Requested by {{user}}",
"unknown_user": "Unknown User",
"toasts": {
"jellyseer_does_not_meet_requirements": "Seerr server does not meet minimum version requirements! Please update to at least 2.0.0",
"jellyseerr_test_failed": "Seerr test failed. Please try again.",
"failed_to_test_jellyseerr_server_url": "Failed to test Seerr server url",
"seerr_does_not_meet_requirements": "Seerr server does not meet minimum version requirements! Please update to at least 2.0.0",
"seerr_test_failed": "Seerr test failed. Please try again.",
"failed_to_test_seerr_server_url": "Failed to test Seerr server URL",
"issue_submitted": "Issue Submitted!",
"requested_item": "Requested {{item}}!",
"you_dont_have_permission_to_request": "You don't have permission to request!",
"something_went_wrong_requesting_media": "Something went wrong requesting media!",
"request_approved": "Request Approved!",
"request_declined": "Request Declined!",
"failed_to_approve_request": "Failed to Approve Request",
"failed_to_decline_request": "Failed to Decline Request"
"failed_to_approve_request": "Failed to approve request",
"failed_to_decline_request": "Failed to decline request"
}
},
"tabs": {
@@ -725,7 +708,7 @@
"albums": "Albums",
"artists": "Artists",
"playlists": "Playlists",
"tracks": "tracks"
"tracks": "Tracks"
},
"filters": {
"all": "All"

View File

@@ -1,5 +1,5 @@
import { useMemo } from "react";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useSeerr } from "@/hooks/useSeerr";
import {
MediaRequestStatus,
MediaStatus,
@@ -18,13 +18,13 @@ import type MediaRequest from "../jellyseerr/server/entity/MediaRequest";
import type { MovieDetails } from "../jellyseerr/server/models/Movie";
import type { TvDetails } from "../jellyseerr/server/models/Tv";
export const useJellyseerrCanRequest = (
export const useSeerrCanRequest = (
item?: MovieResult | TvResult | MovieDetails | TvDetails | PersonCreditCast,
) => {
const { jellyseerrUser } = useJellyseerr();
const { seerrUser } = useSeerr();
const canRequest = useMemo(() => {
if (!jellyseerrUser || !item) return false;
if (!seerrUser || !item) return false;
const canNotRequest =
item?.mediaInfo?.requests?.some(
@@ -46,22 +46,22 @@ export const useJellyseerrCanRequest = (
? Permission.REQUEST_MOVIE
: Permission.REQUEST_TV,
],
jellyseerrUser.permissions,
seerrUser.permissions,
{ type: "or" },
);
return userHasPermission && !canNotRequest;
}, [item, jellyseerrUser]);
}, [item, seerrUser]);
const hasAdvancedRequestPermission = useMemo(() => {
if (!jellyseerrUser) return false;
if (!seerrUser) return false;
return hasPermission(
[Permission.REQUEST_ADVANCED, Permission.MANAGE_REQUESTS],
jellyseerrUser.permissions,
seerrUser.permissions,
{ type: "or" },
);
}, [jellyseerrUser]);
}, [seerrUser]);
return [canRequest, hasAdvancedRequestPermission];
};

View File

@@ -174,7 +174,7 @@ export type Settings = {
disableHapticFeedback: boolean;
subtitleSize: number;
safeAreaInControlsEnabled: boolean;
jellyseerrServerUrl?: string;
seerrServerUrl?: string;
useKefinTweaks: boolean;
hiddenLibraries?: string[];
enableH265ForChromecast: boolean;
@@ -259,7 +259,7 @@ export const defaultValues: Settings = {
disableHapticFeedback: false,
subtitleSize: 100, // Scale value * 100, so 100 = 1.0x
safeAreaInControlsEnabled: true,
jellyseerrServerUrl: undefined,
seerrServerUrl: undefined,
useKefinTweaks: false,
hiddenLibraries: [],
enableH265ForChromecast: false,

View File

@@ -1,18 +0,0 @@
/**
* Convert bits to megabits or gigabits
*
* Return nice looking string
* If under 1000Mb, return XXXMB, else return X.XGB
*/
export function convertBitsToMegabitsOrGigabits(bits?: number | null): string {
if (!bits) return "0MB";
const megabits = bits / 1000000;
if (megabits < 1000) {
return `${Math.round(megabits)}MB`;
}
const gigabits = megabits / 1000;
return `${gigabits.toFixed(1)}GB`;
}

View File

@@ -1,47 +0,0 @@
import {
BaseItemKind,
CollectionType,
} from "@jellyfin/sdk/lib/generated-client";
/**
* Converts a ColletionType to a BaseItemKind (also called ItemType)
*
* CollectionTypes
* readonly Unknown: "unknown";
readonly Movies: "movies";
readonly Tvshows: "tvshows";
readonly Trailers: "trailers";
readonly Homevideos: "homevideos";
readonly Boxsets: "boxsets";
readonly Books: "books";
readonly Photos: "photos";
readonly Livetv: "livetv";
readonly Playlists: "playlists";
readonly Folders: "folders";
*/
export const colletionTypeToItemType = (
collectionType?: CollectionType | null,
): BaseItemKind | undefined => {
if (!collectionType) return undefined;
switch (collectionType) {
case CollectionType.Movies:
return BaseItemKind.Movie;
case CollectionType.Tvshows:
return BaseItemKind.Series;
case CollectionType.Homevideos:
return BaseItemKind.Video;
case CollectionType.Books:
return BaseItemKind.Book;
case CollectionType.Playlists:
return BaseItemKind.Playlist;
case CollectionType.Folders:
return BaseItemKind.Folder;
case CollectionType.Photos:
return BaseItemKind.Photo;
case CollectionType.Trailers:
return BaseItemKind.Trailer;
}
return undefined;
};

View File

@@ -1,56 +0,0 @@
import axios from "axios";
export interface SubtitleTrack {
index: number;
name: string;
uri: string;
language: string;
default: boolean;
forced: boolean;
autoSelect: boolean;
}
export async function parseM3U8ForSubtitles(
url: string,
): Promise<SubtitleTrack[]> {
try {
const response = await axios.get(url, { responseType: "text" });
const lines = response.data.split(/\r?\n/);
const subtitleTracks: SubtitleTrack[] = [];
let index = 0;
lines.forEach((line: string) => {
if (line.startsWith("#EXT-X-MEDIA:TYPE=SUBTITLES")) {
const attributes = parseAttributes(line);
const track: SubtitleTrack = {
index: index++,
name: attributes.NAME || "",
uri: attributes.URI || "",
language: attributes.LANGUAGE || "",
default: attributes.DEFAULT === "YES",
forced: attributes.FORCED === "YES",
autoSelect: attributes.AUTOSELECT === "YES",
};
subtitleTracks.push(track);
}
});
return subtitleTracks;
} catch (error) {
console.error("Failed to fetch or parse the M3U8 file:", error);
throw error;
}
}
function parseAttributes(line: string): { [key: string]: string } {
const attributes: { [key: string]: string } = {};
const regex = /([A-Z-]+)=(?:"([^"]*)"|([^,]*))/g;
for (const match of line.matchAll(regex)) {
const key = match[1];
const value = match[2] ?? match[3]; // quoted or unquoted
attributes[key] = value;
}
return attributes;
}

View File

@@ -15,11 +15,6 @@ import type {
} from "@jellyfin/sdk/lib/generated-client";
import { BITRATES } from "@/components/BitrateSelector";
import { type Settings } from "../atoms/settings";
import {
AudioStreamRanker,
StreamRanker,
SubtitleStreamRanker,
} from "../streamRanker";
export interface PlaySettings {
item: BaseItemDto;
@@ -54,42 +49,27 @@ export function getDefaultPlaySettings(
}
const mediaSource = item.MediaSources?.[0];
const streams = mediaSource?.MediaStreams ?? [];
const _streams = mediaSource?.MediaStreams ?? [];
// Start with media source defaults
let audioIndex = mediaSource?.DefaultAudioStreamIndex;
let subtitleIndex = mediaSource?.DefaultSubtitleStreamIndex ?? -1;
// Try to match previous selections (sequential play)
if (previous?.indexes && previous?.source && settings) {
// Simplified: just use previous indexes if available
if (previous?.indexes && settings) {
if (
settings.rememberSubtitleSelections &&
previous.indexes.subtitleIndex !== undefined
) {
const ranker = new StreamRanker(new SubtitleStreamRanker());
const result = { DefaultSubtitleStreamIndex: subtitleIndex };
ranker.rankStream(
previous.indexes.subtitleIndex,
previous.source,
streams,
result,
);
subtitleIndex = result.DefaultSubtitleStreamIndex;
subtitleIndex = previous.indexes.subtitleIndex;
}
if (
settings.rememberAudioSelections &&
previous.indexes.audioIndex !== undefined
) {
const ranker = new StreamRanker(new AudioStreamRanker());
const result = { DefaultAudioStreamIndex: audioIndex };
ranker.rankStream(
previous.indexes.audioIndex,
previous.source,
streams,
result,
);
audioIndex = result.DefaultAudioStreamIndex;
audioIndex = previous.indexes.audioIndex;
}
}

View File

@@ -1,56 +0,0 @@
import type { Api } from "@jellyfin/sdk";
import type { AxiosResponse } from "axios";
import type { Settings } from "../../atoms/settings";
import { generateDeviceProfile } from "../../profiles/native";
import { getAuthHeaders } from "../jellyfin";
interface PostCapabilitiesParams {
api: Api | null | undefined;
itemId: string | null | undefined;
sessionId: string | null | undefined;
deviceProfile: Settings["deviceProfile"];
}
/**
* Marks a media item as not played for a specific user.
*
* @param params - The parameters for marking an item as not played
* @returns A promise that resolves to true if the operation was successful, false otherwise
*/
export const postCapabilities = async ({
api,
itemId,
sessionId,
}: PostCapabilitiesParams): Promise<AxiosResponse> => {
if (!api || !itemId || !sessionId) {
throw new Error("Missing parameters for marking item as not played");
}
try {
const d = api.axiosInstance.post(
`${api.basePath}/Sessions/Capabilities/Full`,
{
playableMediaTypes: ["Audio", "Video"],
supportedCommands: [
"PlayState",
"Play",
"ToggleFullscreen",
"DisplayMessage",
"Mute",
"Unmute",
"SetVolume",
"ToggleMute",
],
supportsMediaControl: true,
id: sessionId,
DeviceProfile: generateDeviceProfile(),
},
{
headers: getAuthHeaders(api),
},
);
return d;
} catch (_error) {
throw new Error("Failed to mark as not played");
}
};

View File

@@ -1,44 +0,0 @@
import type { Api } from "@jellyfin/sdk";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getAuthHeaders } from "../jellyfin";
interface NextUpParams {
itemId?: string | null;
userId?: string | null;
api?: Api | null;
}
/**
* Fetches the next up episodes for a series or all series for a user.
*
* @param params - The parameters for fetching next up episodes
* @returns A promise that resolves to an array of BaseItemDto representing the next up episodes
*/
export const nextUp = async ({
itemId,
userId,
api,
}: NextUpParams): Promise<BaseItemDto[]> => {
if (!userId || !api) {
console.error("Invalid parameters for nextUp: missing userId or api");
return [];
}
try {
const response = await api.axiosInstance.get<{ Items: BaseItemDto[] }>(
`${api.basePath}/Shows/NextUp`,
{
params: {
SeriesId: itemId || undefined,
UserId: userId,
Fields: "MediaSourceCount",
},
headers: getAuthHeaders(api),
},
);
return response.data.Items;
} catch (_error) {
return [];
}
};

View File

@@ -1,34 +0,0 @@
import type { Api } from "@jellyfin/sdk";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
/**
* Retrieves an item by its ID from the API.
*
* @param api - The Jellyfin API instance.
* @param itemId - The ID of the item to retrieve.
* @returns The item object or undefined if no item matches the ID.
*/
export const getItemById = async (
api?: Api | null | undefined,
itemId?: string | null | undefined,
): Promise<BaseItemDto | undefined> => {
if (!api || !itemId) {
return undefined;
}
try {
const itemData = await getUserLibraryApi(api).getItem({ itemId });
const item = itemData.data;
if (!item) {
console.error("No items found with the specified ID:", itemId);
return undefined;
}
return item;
} catch (error) {
console.error("Failed to retrieve the item:", error);
throw new Error(`Failed to retrieve the item due to an error: ${error}`);
}
};

View File

@@ -72,21 +72,6 @@ export const readFromLog = (): LogEntry[] => {
return logs ? JSON.parse(logs) : [];
};
export const clearLogs = () => {
storage.remove("logs");
};
export const dumpDownloadDiagnostics = (extra: any = {}) => {
const diagnostics = {
timestamp: new Date().toISOString(),
processes: extra?.processes || [],
nativeTasks: extra?.nativeTasks || [],
focusedProcess: extra?.focusedProcess || null,
};
writeDebugLog("Download diagnostics", diagnostics);
return diagnostics;
};
export function useLog() {
const context = useContext(LogContext);
if (context === null) {

View File

@@ -1,5 +0,0 @@
// seconds to ticks util
export function secondsToTicks(seconds: number): number {
return seconds * 10000000;
}

View File

@@ -76,9 +76,9 @@ interface LegacyServerCredential {
}
/**
* Encode server URL to valid secure store key (legacy, for migration).
* Generate a unique key for a server URL (for secure storage).
*/
export function serverUrlToKey(serverUrl: string): string {
function serverUrlToKey(serverUrl: string): string {
const encoded = btoa(serverUrl).replace(/[^a-zA-Z0-9]/g, "_");
return `${CREDENTIAL_KEY_PREFIX}${encoded}`;
}
@@ -86,7 +86,7 @@ export function serverUrlToKey(serverUrl: string): string {
/**
* Generate credential key for a specific account (serverUrl + userId).
*/
export function credentialKey(serverUrl: string, userId: string): string {
function credentialKey(serverUrl: string, userId: string): string {
const combined = `${serverUrl}:${userId}`;
const encoded = btoa(combined).replace(/[^a-zA-Z0-9]/g, "_");
return `${CREDENTIAL_KEY_PREFIX}${encoded}`;
@@ -200,10 +200,7 @@ export async function hasAccountCredential(
return stored !== null;
}
/**
* Delete all credentials for all accounts on all servers.
*/
export async function clearAllCredentials(): Promise<void> {
async function _clearAllCredentials(): Promise<void> {
const previousServers = getPreviousServers();
for (const server of previousServers) {

View File

@@ -1,159 +0,0 @@
import type {
MediaSourceInfo,
MediaStream,
} from "@jellyfin/sdk/lib/generated-client";
abstract class StreamRankerStrategy {
abstract streamType: string;
abstract rankStream(
prevIndex: number,
prevSource: MediaSourceInfo,
mediaStreams: MediaStream[],
trackOptions: any,
): void;
protected rank(
prevIndex: number,
prevSource: MediaSourceInfo,
mediaStreams: MediaStream[],
trackOptions: any,
): void {
if (prevIndex === -1) {
console.debug("AutoSet Subtitle - No Stream Set");
trackOptions[`Default${this.streamType}StreamIndex`] = -1;
return;
}
if (!prevSource.MediaStreams || !mediaStreams) {
console.debug(`AutoSet ${this.streamType} - No MediaStreams`);
return;
}
let bestStreamIndex = null;
let bestStreamScore = 0;
const prevStream = prevSource.MediaStreams[prevIndex];
if (!prevStream) {
console.debug(`AutoSet ${this.streamType} - No prevStream`);
return;
}
console.debug(
`AutoSet ${this.streamType} - Previous was ${prevStream.Index} - ${prevStream.DisplayTitle}`,
);
let prevRelIndex = 0;
for (const stream of prevSource.MediaStreams) {
if (stream.Type !== this.streamType) {
continue;
}
if (stream.Index === prevIndex) {
break;
}
prevRelIndex += 1;
}
let newRelIndex = 0;
for (const stream of mediaStreams) {
if (stream.Type !== this.streamType) {
continue;
}
let score = 0;
if (prevStream.Codec === stream.Codec) {
score += 1;
}
if (prevRelIndex === newRelIndex) {
score += 1;
}
if (
prevStream.DisplayTitle &&
prevStream.DisplayTitle === stream.DisplayTitle
) {
score += 2;
}
if (
prevStream.Language &&
prevStream.Language !== "und" &&
prevStream.Language === stream.Language
) {
score += 2;
}
console.debug(
`AutoSet ${this.streamType} - Score ${score} for ${stream.Index} - ${stream.DisplayTitle}`,
);
if (score > bestStreamScore && score >= 3) {
bestStreamScore = score;
bestStreamIndex = stream.Index;
}
newRelIndex += 1;
}
if (bestStreamIndex != null) {
console.debug(
`AutoSet ${this.streamType} - Using ${bestStreamIndex} score ${bestStreamScore}.`,
);
trackOptions[`Default${this.streamType}StreamIndex`] = bestStreamIndex;
} else {
console.debug(
`AutoSet ${this.streamType} - Threshold not met. Using default.`,
);
}
}
}
class SubtitleStreamRanker extends StreamRankerStrategy {
streamType = "Subtitle";
rankStream(
prevIndex: number,
prevSource: MediaSourceInfo,
mediaStreams: MediaStream[],
trackOptions: any,
): void {
super.rank(prevIndex, prevSource, mediaStreams, trackOptions);
}
}
class AudioStreamRanker extends StreamRankerStrategy {
streamType = "Audio";
rankStream(
prevIndex: number,
prevSource: MediaSourceInfo,
mediaStreams: MediaStream[],
trackOptions: any,
): void {
super.rank(prevIndex, prevSource, mediaStreams, trackOptions);
}
}
class StreamRanker {
private strategy: StreamRankerStrategy;
constructor(strategy: StreamRankerStrategy) {
this.strategy = strategy;
}
setStrategy(strategy: StreamRankerStrategy) {
this.strategy = strategy;
}
rankStream(
prevIndex: number,
prevSource: MediaSourceInfo,
mediaStreams: MediaStream[],
trackOptions: any,
) {
this.strategy.rankStream(prevIndex, prevSource, mediaStreams, trackOptions);
}
}
export { StreamRanker, SubtitleStreamRanker, AudioStreamRanker };