Compare commits

..

48 Commits

Author SHA1 Message Date
Gauvain
d02007c213 Merge branch 'develop' into build-performance 2025-10-09 16:32:11 +02:00
Uruk
3e20050b64 chore: ignore AI assistant configuration directories
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Moves AI assistant configuration folders (.cursor/ and .claude/) to .gitignore instead of tracking them in the repository.

Removes IDE-specific tooling configurations that are personal to individual developers and should not be version controlled.
2025-10-09 16:28:25 +02:00
Gauvain
a5552db377 Merge branch 'develop' into build-performance 2025-10-09 16:19:42 +02:00
Uruk
59e9913c78 refactor: separate type and value exports
Improves TypeScript export organization by explicitly distinguishing between value exports and type exports.

Separates the module export into two distinct export statements to follow TypeScript best practices, making it clearer which exports are runtime values versus compile-time types.
2025-10-09 16:16:22 +02:00
Gauvain
cf203a7c28 Merge branch 'develop' into build-performance 2025-10-09 16:10:23 +02:00
Uruk
2b2797005a chore: enhance TypeScript compiler configuration
Improves build performance and developer experience by enabling incremental compilation and adding essential compiler options.

Enables incremental builds with build info caching to speed up subsequent compilations.

Adds modern module resolution and interoperability options for better compatibility with bundlers and JavaScript modules.

Enforces stricter type checking with isolated modules and consistent file naming conventions.
2025-10-09 16:09:40 +02:00
Uruk
c53acb16fc chore: organize .gitignore and add VS Code workspace config
Restructures .gitignore with logical sections and comments to improve maintainability and clarity. Groups related patterns under headers like Dependencies, Build Artifacts, Certificates, and Secrets.

Adds VS Code workspace configuration to standardize development environment across the team. Includes recommended extensions for React Native/Expo development (Biome, Expo tools, React Native debugger, Tailwind IntelliSense) and comprehensive editor settings for formatting, TypeScript performance, and file navigation.

Configures Biome as the default formatter with format-on-save enabled for JavaScript/TypeScript files. Optimizes TypeScript settings for better auto-imports and IntelliSense. Enables file nesting in explorer and excludes build directories from file watcher to improve editor performance.
2025-10-09 16:08:59 +02:00
Gauvain
d7958296a5 Merge branch 'develop' into build-performance 2025-10-09 15:57:22 +02:00
Uruk
53570a5ee5 Merge branch 'develop' of https://github.com/streamyfin/streamyfin into develop 2025-10-09 13:57:27 +02:00
Uruk
e3b7dd8241 docs(copilot): enhance instructions with Bun requirements and development standards
Expands Copilot instructions to enforce critical Bun-first package management workflow and prevent usage of npm/yarn/npx commands.

Adds comprehensive sections covering:
- Runtime and tooling stack details with Bun as primary runtime
- Explicit package management commands to prevent npm/yarn usage
- Performance optimization guidelines leveraging Bun's capabilities
- Testing approach using Bun's built-in test runner
- Enhanced coding standards and API integration patterns
- Cross-platform development considerations for mobile and TV

Improves developer onboarding by providing clearer context about project architecture, offline capabilities, and Chromecast support in the overview.
2025-10-09 13:57:07 +02:00
renovate[bot]
786d082706 chore(deps): Update actions/stale action to v10.1.0 (#1120)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-09 13:56:16 +02:00
Uruk
164de0af0d feat: add workflow failure notifications to Discord
Extends the Discord notification system to monitor and report workflow failures in addition to pull requests.

Adds a new workflow_run trigger that listens for completed workflows on the develop branch and sends Discord notifications when any workflow fails.

Separates notification logic into two jobs: one for pull request events and another for workflow failures, each with appropriate conditional guards and using different webhook URLs for distinct notification channels.

Renames the workflow from "Discord Pull Request Notification" to "Discord Notification" to reflect the expanded scope.
2025-10-09 13:41:19 +02:00
Simon Eklundh
820b30b7e2 feat: adds the hungarian option to i18n.ts (#1112)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
Co-authored-by: lostb1t <coding-mosses0z@icloud.com>
2025-10-09 08:27:24 +02:00
Copilot
5bc4c4a856 fix: resolve TypeScript type errors in SubtitleToggles.tsx by using settings instead of pluginSettings for values (#1119)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: lostb1t <168401+lostb1t@users.noreply.github.com>
2025-10-09 06:25:51 +02:00
Zach Ross-Clyne
f7e0667416 feat: Adding custom endpoint option for sections (#1118)
Co-authored-by: lostb1t <coding-mosses0z@icloud.com>
2025-10-09 01:41:59 +02:00
Chris
8c68283c56 Revert jellyseerr-logo.svg to previous version
Seerr branding is still pending finalization. The logo is awaiting approval before proceeding with updates. Reverting to the previous one for now
2025-10-09 00:48:16 +02:00
renovate[bot]
bb0149406c chore(deps): Update github/codeql-action action to v4 (#1116)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-08 13:49:57 +02:00
Chris
b1d5630025 Update jellyseerr-logo.svg
Replaced the old Jellyseerr logo with the new Seerr branding to align with the project's updated name and visual identity
2025-10-07 23:16:07 +02:00
Chris
a5f5531bb9 Update English translations for Seerr rebranding
Replaced user-facing instances of "Jellyseerr" with "Seerr" due to the product rebranding. Ensures all app strings reflect the new name consistently
2025-10-07 22:53:58 +02:00
Chris
c2a3817fa8 Standardize to "Box Sets" in English localization strings
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
Standardize "Boxset"/"Boxsets" → "Box Sets" for consistency
2025-10-07 01:35:42 +02:00
renovate[bot]
700bb2dc79 chore(deps): Pin dependency expo-dev-client to 5.2.4 (#1110)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-03 03:05:10 +02:00
renovate[bot]
d741ca3ecc chore(deps): Update github/codeql-action action to v3.30.6 (#1111)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-03 03:05:00 +02:00
renovate[bot]
ae9f6b1ce4 chore(deps): Update dependency @types/jest to v30 (#906)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-03 02:50:44 +02:00
Gauvain
3f3f95571c Merge branch 'develop' into build-performance 2025-10-03 01:05:22 +02:00
Uruk
cd3f1a8cee refactor: move expo-dev-client to devDependencies
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
Moves expo-dev-client from runtime dependencies to devDependencies since it's only needed during development and testing phases, not in production builds.

Reduces bundle size and clarifies the dependency's intended usage scope.
2025-10-02 22:48:21 +02:00
Uruk
be745dc136 chore: cleanup unused files and reorganize dependencies
Updates Biome schema to latest version and removes template files that are no longer needed.

Moves expo-dev-client to devDependencies where it belongs for development-only usage.
2025-10-02 22:33:03 +02:00
renovate[bot]
7b6fe0a6c0 chore(deps): Pin dependencies (#1014)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-02 22:14:10 +02:00
Uruk
fc44283f09 feat: add Crowdin configuration for translation management
Enables automated translation workflow by configuring Crowdin integration.

Sets up source translation file mapping from English to multiple language codes with update approval workflow.
2025-10-02 22:10:22 +02:00
Uruk
b2f6edc54e ci: allow untranslated files in Crowdin workflow
Removes the skip_untranslated_files configuration to include files that may contain some untranslated strings in the export process.

This enables more comprehensive translation updates while still maintaining quality control through the skip_untranslated_strings option.
2025-10-02 22:00:46 +02:00
Simon Eklundh
b42d033b87 feat(ci): enhance Crowdin workflow (#1104)
Co-authored-by: Uruk <contact@uruk.dev>
Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com>
2025-10-02 21:54:11 +02:00
VXsz
1fb166bcd1 Add Arabic Translation (#1058)
Co-authored-by: lostb1t <coding-mosses0z@icloud.com>
2025-10-02 20:17:28 +02:00
Uruk
de6133581b remove: postinstall-postinstall dependency
Removes unused postinstall-postinstall package from development dependencies and trusted dependencies list.

Cleans up package configuration by eliminating unnecessary dependency that was no longer serving a purpose in the project.
2025-10-02 19:26:31 +02:00
Gauvain
9e26196fb3 Merge branch 'develop' into build-performance 2025-09-30 12:44:26 +02:00
Gauvain
e6f69e0c7b Merge branch 'develop' into build-performance 2025-09-30 12:40:40 +02:00
Gauvain
e8bf2b721e Merge branch 'develop' into build-performance 2025-09-26 19:45:31 +02:00
Uruk
84d7ad72a6 fix: conditionally load TV plugin based on build target
Moves TV-specific plugin configuration from static app.json to dynamic loading in app.config.js based on EXPO_TV environment variable.

Ensures TV plugin only loads for TV builds while phone-specific plugins load for non-TV builds, preventing conflicts between different build targets.
2025-09-26 19:41:51 +02:00
Uruk
edc9c8640d perf: optimize EAS build caching and Metro bundling
Improves build performance by adding platform-specific cache paths including Gradle and iOS Pods directories. Updates cache keys to use app.config.js instead of package.json for more accurate invalidation.

Enhances Metro minification with Hermes-optimized settings, adds ASCII-only output, and enables advanced compression optimizations like dead code elimination and variable reduction.

Configures production environment variables for bundle size optimization across preview and production builds.
2025-09-26 16:31:01 +02:00
Gauvain
49ece8d34e Merge branch 'develop' into build-performance 2025-09-25 22:48:39 +02:00
Gauvain
98d571187e Merge branch 'develop' into build-performance 2025-09-23 02:37:25 +02:00
Uruk
d1e55ca506 docs: correct spelling of 'examples' in commit message section 2025-09-22 20:45:58 +02:00
Uruk
adec78832a chore: update expo-doctor and remove postinstall-postinstall
Updates expo-doctor from version 1.17.0 to 1.17.8 to get latest bug fixes and improvements.

Removes postinstall-postinstall dependency as it's no longer needed, simplifying the dependency tree and reducing package bloat.
2025-09-22 20:45:03 +02:00
Uruk
19f604e986 refactor: remove debug console log from storage calculation
Cleans up debugging output that was left in the storage percentage calculation function to improve code quality and reduce console noise in production.
2025-09-22 20:35:48 +02:00
Uruk
4398810b6c config: update development tooling configurations
Removes bunx from the list of prohibited package managers in Copilot instructions, allowing its use alongside bun.

Updates VS Code terminal configuration to use the modern profiles format instead of deprecated shell settings for better Windows compatibility.

Fixes EAS build cache key syntax by removing incorrect dash separator in checksum function calls across all build profiles.
2025-09-22 12:19:04 +02:00
Uruk
0a8068e1b3 fix: update Android build type to app-bundle format
Changes the Android build configuration from legacy "aab" to the
standardized "app-bundle" format for better compatibility with
modern build tools and deployment pipelines.
2025-09-22 03:30:02 +02:00
Uruk
4b7986a125 fix: apply review suggestions 2025-09-22 03:22:22 +02:00
Gauvain
3eaeaa3b4a Update .env.development
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-09-22 03:20:36 +02:00
Uruk
9cd9861253 feat: add expo-atlas bundle analyzer and optimize metro config
Adds expo-atlas dependency for bundle size monitoring and analysis across development and production environments.

Enhances metro configuration with comprehensive performance optimizations including:
- Hermes parser with inline requires for 15-30% startup improvement
- Advanced minification settings optimized for streaming applications
- Enhanced resolver with package exports and extended asset type support
- TV platform-specific optimizations with dedicated file extensions
- Production serializer optimizations with module ID hashing
- Development-focused error reporting and caching improvements

Configures environment-specific settings for development debugging and production performance, with specialized support for media file formats and TV platform deployment.
2025-09-22 03:16:45 +02:00
Uruk
5e9755ea3c chore: standardize development environment and cleanup config files
Removes IDE-specific configuration files and establishes Bun as the primary package manager.

Updates project documentation to emphasize Bun usage throughout the development workflow and enhances VS Code settings for better TypeScript performance.

Optimizes EAS build configuration with caching strategies and resource allocation improvements.

Cleans up unused imports and improves TypeScript configuration for better development experience.
2025-09-22 02:45:10 +02:00
137 changed files with 5012 additions and 7220 deletions

View File

@@ -1,14 +0,0 @@
{
"permissions": {
"allow": [
"Bash(find:*)",
"Bash(bun install:*)",
"Bash(bunx expo prebuild:*)",
"Bash(bunx expo run:*)",
"Bash(npx expo prebuild:*)",
"Bash(npx expo run:*)",
"Bash(xcodebuild:*)"
],
"deny": []
}
}

View File

@@ -1,5 +0,0 @@
---
alwaysApply: true
---
Don't run the development server or build anything. Assume the user has a separate terminal. Tell the user what to execute.

View File

@@ -1,7 +0,0 @@
---
description: Don't write code directly in the ios folder.
globs:
alwaysApply: true
---
We never write code directly in the ios folder. This code is generated by expo plugins.

View File

@@ -1 +1,15 @@
EXPO_PUBLIC_WRITE_DEBUG=1
# Streamyfin-specific debug flag
EXPO_PUBLIC_WRITE_DEBUG=1
# Performance optimization (official Metro flag)
EXPO_USE_METRO_REQUIRE=1
# TV development support (used in metro.config.js)
# EXPO_TV=1
# Uncomment the above line ONLY when working on TV features. Leave commented for mobile-only development to avoid issues.
# Fast resolver optimization (2025 feature)
EXPO_USE_FAST_RESOLVER=1
# Bundle analysis for monitoring
EXPO_ATLAS=1

View File

@@ -1 +1,26 @@
EXPO_PUBLIC_WRITE_DEBUG=0
# Streamyfin Production Configuration
EXPO_PUBLIC_WRITE_DEBUG=0
# Production Performance Optimizations
NODE_ENV=production
EXPO_USE_METRO_REQUIRE=1
EXPO_USE_FAST_RESOLVER=1
# Production Build Optimizations
EXPO_OPTIMIZE_BUNDLE_SIZE=1
EXPO_NO_CLIENT_ENV_VARS=1
EXPO_LEGACY_BUNDLER=0
# Bundle Analysis (for monitoring)
EXPO_ATLAS=0
# Production Cache Optimizations
METRO_CACHE=1
# Security & Performance
EXPO_NO_DOTENV=1
FAST_REFRESH=0
# Production Bundle Features
EXPO_USE_HERMES=1
EXPO_MINIFY=1

View File

@@ -3,58 +3,94 @@
## Project Overview
Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native).
It supports mobile (iOS/Android) and TV platforms, and integrates with Jellyfin and Jellyseerr APIs.
It supports mobile (iOS/Android) and TV platforms, integrates with Jellyfin and Jellyseerr APIs,
and provides seamless media streaming with offline capabilities and Chromecast support.
## Main Technologies
- React Native (Expo)
- TypeScript
- React Query
- Jotai (state management)
- Jellyfin SDK (TypeScript)
- BiomeJS (code formatting/linting)
- EAS (Expo Application Services)
- Shell scripting (for automation)
- GitHub Actions (CI/CD)
- **Runtime**: Bun (JavaScript/TypeScript execution)
- **Framework**: React Native (Expo)
- **Language**: TypeScript (strict mode)
- **State Management**: Jotai (global state) + React Query (server state)
- **API SDK**: Jellyfin SDK (TypeScript)
- **Navigation**: Expo Router (file-based routing)
- **Code Quality**: BiomeJS (formatting/linting)
- **Build Platform**: EAS (Expo Application Services)
- **CI/CD**: GitHub Actions with Bun
## Package Management
**CRITICAL: ALWAYS use `bun` for all package management operations**
- **NEVER use `npm`, `yarn` or `npx` commands**
- Use `bun install` instead of `npm install` or `yarn install`
- Use `bun add <package>` instead of `npm install <package>`
- Use `bun remove <package>` instead of `npm uninstall <package>`
- Use `bun run <script>` instead of `npm run <script>`
- Use `bunx <command>` instead of `npx <command>`
- For Expo: use `bunx create-expo-app` or `bunx @expo/cli`
## Code Structure
- `app/` Main application code (screens, navigation, etc.)
- `components/` Reusable UI components
- `providers/` Context and API providers (e.g., JellyfinProvider.tsx)
- `utils/` Utility functions and atoms
- `utils/` Utility functions and Jotai atoms
- `assets/` Images and static assets
- `scripts/` Automation scripts (Node.js, Bash)
- `plugins/` Expo/Metro plugins
- `README.md` Project documentation
## Coding Conventions
## Coding Standards
- Use TypeScript for all new code.
- Prefer functional React components.
- Use hooks for state and side effects.
- Use Jotai for global state.
- Use React Query for data fetching/caching.
- Use BiomeJS for formatting and linting.
- Follow the established folder structure for screens/components.
- Use TypeScript for ALL files (no .js files)
- Use descriptive English names for variables, functions, and components
- Prefer functional React components with hooks
- Use Jotai atoms for global state management
- Use React Query for server state and caching
- Follow BiomeJS formatting and linting rules
- Use `const` over `let`, avoid `var` entirely
- Implement proper error boundaries
- Use React.memo() for performance optimization
- Handle both mobile and TV navigation patterns
## API Usage
## API Integration
- Use the Jellyfin SDK for all server interactions.
- Use the `apiAtom` and `userAtom` from `JellyfinProvider` for authenticated API calls.
- For navigation, use `expo-router`.
- Use Jellyfin SDK for all server interactions
- Access authenticated APIs via `apiAtom` and `userAtom` from JellyfinProvider
- Implement proper loading states and error handling
- Use React Query for caching and background updates
- Handle offline scenarios gracefully
## Performance Optimization
- Leverage Bun's superior runtime performance
- Optimize FlatList components with proper props
- Use lazy loading for non-critical components
- Implement proper image caching strategies
- Monitor bundle size and use tree-shaking effectively
## Testing
- Use Bun's built-in test runner when possible
- Test files: `*.test.ts` or `*.test.tsx`
- Run tests with: `bun test`
- Mock external APIs in tests
- Focus on testing business logic and custom hooks
## Commit Messages
- Use [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) (e.g., `feat:`, `fix:`, `chore:`).
- Example: `feat(player): add Chromecast support`
Use [Conventional Commits](https://www.conventionalcommits.org/):
Exemples:
- `feat(player): add Chromecast support`
- `fix(auth): handle expired JWT tokens`
- `chore(deps): update Jellyfin SDK`
## Special Instructions
- When suggesting code, prefer using existing atoms, hooks, and utility functions.
- When adding new features, ensure they are accessible via both mobile and TV navigation if relevant.
- When updating dependencies or scripts, check for compatibility with Expo and EAS.
---
- Prioritize cross-platform compatibility (mobile + TV)
- Ensure accessibility for TV remote navigation
- Use existing atoms, hooks, and utilities before creating new ones
- Maintain compatibility with Expo and EAS workflows
- Always verify Bun compatibility when suggesting new dependencies
**Copilot: Please use these instructions to provide context-aware suggestions and code completions for this repository.**

12
.github/crowdin.yml vendored Normal file
View File

@@ -0,0 +1,12 @@
"project_id_env": "CROWDIN_PROJECT_ID"
"api_token_env": "CROWDIN_PERSONAL_TOKEN"
"base_path": "."
"preserve_hierarchy": true
"files": [
{
"source": "translations/en.json",
"translation": "translations/%two_letters_code%.json"
}
]

View File

@@ -26,7 +26,7 @@ jobs:
steps:
- name: 🔍 Get PR and Artifacts
uses: actions/github-script@v8
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: |
// Check if we're running from a fork (more precise detection)

View File

@@ -31,13 +31,13 @@ jobs:
fetch-depth: 0
- name: 🏁 Initialize CodeQL
uses: github/codeql-action/init@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5
uses: github/codeql-action/init@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7
with:
languages: ${{ matrix.language }}
queries: +security-extended,security-and-quality
- name: 🛠️ Autobuild
uses: github/codeql-action/autobuild@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5
uses: github/codeql-action/autobuild@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7
- name: 🧪 Perform CodeQL Analysis
uses: github/codeql-action/analyze@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5
uses: github/codeql-action/analyze@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7

View File

@@ -1,34 +1,50 @@
name: Crowdin Action
name: 🌐 Translation Sync
on:
push:
branches: [ main ]
branches: [develop]
paths:
- "translations/**"
- "crowdin.yml"
- "i18n.ts"
- ".github/workflows/crowdin.yml"
# Run weekly to pull new translations
schedule:
- cron: "0 2 * * 1" # Every Monday at 2 AM UTC
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
synchronize-with-crowdin:
sync-translations:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: 📥 Checkout Repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
fetch-depth: 0
- name: crowdin action
uses: crowdin/github-action@v2
- name: 🌐 Sync Translations with Crowdin
uses: crowdin/github-action@0749939f635900a2521aa6aac7a3766642b2dc71 # v2.11.0
with:
upload_sources: true
upload_translations: true
download_translations: true
localization_branch_name: l10n_crowdin_translations
localization_branch_name: I10n_crowdin_translations
create_pull_request: true
pull_request_title: 'feat: New Crowdin Translations'
pull_request_body: 'New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)'
pull_request_base_branch_name: 'develop'
pull_request_title: "feat: New Crowdin Translations"
pull_request_body: "New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)"
pull_request_base_branch_name: "develop"
pull_request_labels: "🌐 translation"
# Quality control options
skip_untranslated_strings: true
export_only_approved: false
# Commit customization
commit_message: "feat(i18n): update translations from Crowdin"
env:
# A classic GitHub Personal Access Token with the 'repo' scope selected (the user should have write access to the repository).
GITHUB_TOKEN: ${{ secrets.CROWDIN_GITHUB_TOKEN }}
# A numeric ID, found at https://crowdin.com/project/<projectName>/tools/api
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
# Visit https://crowdin.com/settings#api-key to create this token
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}

View File

@@ -1,13 +1,18 @@
name: 🛎️ Discord Pull Request Notification
name: 🛎️ Discord Notification
on:
pull_request:
types: [opened, reopened]
branches: [develop]
workflow_run:
workflows: ["*"]
types: [completed]
branches: [develop]
jobs:
notify:
runs-on: ubuntu-24.04
if: github.event_name == 'pull_request'
steps:
- name: 🛎️ Notify Discord
uses: Ilshidur/action-discord@d2594079a10f1d6739ee50a2471f0ca57418b554 # 0.4.0
@@ -21,3 +26,21 @@ jobs:
**By:** ${{ github.event.pull_request.user.login }}
**Branch:** ${{ github.event.pull_request.head.ref }}
🔗 ${{ github.event.pull_request.html_url }}
notify-on-failure:
runs-on: ubuntu-24.04
if: github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'failure'
steps:
- name: 🚨 Notify Discord on Failure
uses: Ilshidur/action-discord@d2594079a10f1d6739ee50a2471f0ca57418b554 # 0.4.0
env:
DISCORD_WEBHOOK: ${{ secrets.WEBHOOK_FAILED_JOB_URL }}
DISCORD_AVATAR: https://avatars.githubusercontent.com/u/193271640
with:
args: |
🚨 **Workflow Failed** in **${{ github.repository }}**
**Workflow:** ${{ github.event.workflow_run.name }}
**Branch:** ${{ github.event.workflow_run.head_branch }}
**Triggered by:** ${{ github.event.workflow_run.triggering_actor.login }}
**Commit:** ${{ github.event.workflow_run.head_commit.message }}
🔗 ${{ github.event.workflow_run.html_url }}

View File

@@ -15,7 +15,7 @@ jobs:
steps:
- name: 🔄 Mark/Close Stale Issues
uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0
uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
with:
# Global settings
repo-token: ${{ secrets.GITHUB_TOKEN }}

77
.gitignore vendored
View File

@@ -1,27 +1,16 @@
# Dependencies and Package Managers
node_modules/
.expo/
dist/
npm-debug.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
*.orig.*
web-build/
modules/vlc-player/android/build
# macOS
.DS_Store
expo-env.d.ts
Streamyfin.app
*.mp4
Streamyfin.app
bun.lock
bun.lockb
package-lock.json
# Expo and React Native Build Artifacts
.expo/
dist/
web-build/
.tsbuildinfo
# Platform-specific Build Directories
/ios
/android
/iostv
@@ -29,22 +18,50 @@ package-lock.json
/androidmobile
/androidtv
# Module-specific Builds
modules/vlc-player/android/build
modules/player/android
modules/hls-downloader/android/build
pc-api-7079014811501811218-719-3b9f15aeccf8.json
credentials.json
# Generated Applications
Streamyfin.app
*.apk
*.ipa
.continuerc.json
*.aab
# Certificates and Keys
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Debug and Temporary Files
npm-debug.*
*.orig.*
*.mp4
# OS-specific Files
# macOS
.DS_Store
# IDE and Editor Files
.vscode/
.idea/
.ruby-lsp
modules/hls-downloader/android/build
streamyfin-4fec1-firebase-adminsdk.json
.cursor/
.claude/
# Environment and Configuration
expo-env.d.ts
.continuerc.json
.env
.env.local
*.aab
/version-backup-*
bun.lockb
modules/background-downloader/android/build/*
# Secrets and Credentials
pc-api-7079014811501811218-719-3b9f15aeccf8.json
credentials.json
streamyfin-4fec1-firebase-adminsdk.json
# Version and Backup Files
/version-backup-*

24
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,24 @@
{
// ==========================================
// Streamyfin - Recommended VS Code Extensions
// ==========================================
// Essential extensions for working with Streamyfin
// See .github/copilot-instructions.md for coding standards
"recommendations": [
// Code Quality & Formatting
"biomejs.biome", // Fast formatter and linter for JavaScript/TypeScript - replaces ESLint + Prettier
// React Native & Expo
"expo.vscode-expo-tools", // Official Expo extension - provides commands, debugging, and config IntelliSense
"msjsdiag.vscode-react-native", // React Native debugging and IntelliSense - essential for RN development
// Developer Experience
"bradlc.vscode-tailwindcss", // Tailwind CSS IntelliSense - autocomplete for NativeWind classes
"yoavbls.pretty-ts-errors", // Makes TypeScript error messages human-readable with formatting and highlights
"usernamehw.errorlens", // Shows errors and warnings inline in the editor - faster debugging
// Bun Support
"oven.bun-vscode" // Official Bun extension - provides debugging and language support for Bun runtime
]
}

176
.vscode/settings.json vendored
View File

@@ -1,24 +1,178 @@
{
// ==========================================
// FORMATTING & LINTING
// ==========================================
// Biome as default formatter
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true,
"editor.formatOnPaste": true,
"editor.formatOnType": false,
// Language-specific formatters
"[javascript]": {
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true
},
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true
},
"prettier.printWidth": 120,
"[swift]": {
"editor.defaultFormatter": "sswg.swift-lang"
},
"editor.formatOnSave": true,
"editor.defaultFormatter": "biomejs.biome",
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true
},
"[javascriptreact]": {
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true
}
},
"[json]": {
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true
},
"[jsonc]": {
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true
},
"[swift]": {
"editor.insertSpaces": true,
"editor.tabSize": 2
},
// ==========================================
// TYPESCRIPT & JAVASCRIPT
// ==========================================
// TypeScript performance optimizations
"typescript.preferences.includePackageJsonAutoImports": "auto",
"typescript.suggest.autoImports": true,
"typescript.updateImportsOnFileMove.enabled": "always",
"typescript.preferences.preferTypeOnlyAutoImports": true,
"typescript.preferences.importModuleSpecifier": "relative",
"typescript.preferences.includeCompletionsForImportStatements": true,
"typescript.preferences.includeCompletionsWithSnippetText": true,
// JavaScript settings
"javascript.preferences.importModuleSpecifier": "relative",
"javascript.suggest.autoImports": true,
"javascript.updateImportsOnFileMove.enabled": "always",
// ==========================================
// REACT NATIVE & EXPO
// ==========================================
// File associations for React Native
"files.associations": {
"*.expo.ts": "typescript",
"*.expo.tsx": "typescriptreact",
"*.expo.js": "javascript",
"*.expo.jsx": "javascriptreact",
"metro.config.js": "javascript",
"babel.config.js": "javascript",
"app.config.js": "javascript",
"eas.json": "jsonc"
},
// React Native specific settings
"emmet.includeLanguages": {
"typescriptreact": "html",
"javascriptreact": "html"
},
"emmet.triggerExpansionOnTab": true,
// Exclude build directories from search
"search.exclude": {
"**/node_modules": true
},
// ==========================================
// EDITOR PERFORMANCE & UX
// ==========================================
// Performance optimizations
"editor.largeFileOptimizations": true,
"files.watcherExclude": {
"**/.git/objects/**": true,
"**/.git/subtree-cache/**": true,
"**/node_modules/**": true,
"**/.expo/**": true,
"**/ios/**": true,
"**/android/**": true,
"**/build/**": true,
"**/dist/**": true
},
// Better editor behavior
"editor.suggestSelection": "first",
"editor.quickSuggestions": {
"strings": true,
"comments": true,
"other": true
},
"editor.snippetSuggestions": "top",
"editor.tabCompletion": "on",
"editor.wordBasedSuggestions": "off",
// ==========================================
// TERMINAL & DEVELOPMENT
// ==========================================
// Terminal settings for Bun (Windows-specific)
"terminal.integrated.profiles.windows": {
"Command Prompt": {
"path": "C:\\Windows\\System32\\cmd.exe",
"env": {
"PATH": "${env:PATH};./node_modules/.bin"
}
}
},
// ==========================================
// WORKSPACE & NAVIGATION
// ==========================================
// Better workspace navigation
"explorer.fileNesting.enabled": true,
"explorer.fileNesting.expand": false,
"explorer.fileNesting.patterns": {
"*.ts": "${capture}.js",
"*.tsx": "${capture}.js",
"*.js": "${capture}.js,${capture}.js.map,${capture}.min.js,${capture}.d.ts",
"*.jsx": "${capture}.js",
"package.json": "package-lock.json,yarn.lock,bun.lock,bun.lockb,.yarnrc,.yarnrc.yml",
"tsconfig.json": "tsconfig.*.json",
".env": ".env.*",
"app.json": "app.config.js,eas.json,expo-env.d.ts",
"README.md": "LICENSE.txt,SECURITY.md,CODE_OF_CONDUCT.md,CONTRIBUTING.md"
},
// Better breadcrumbs and navigation
"breadcrumbs.enabled": true,
"outline.showVariables": true,
"outline.showConstants": true,
// ==========================================
// GIT & VERSION CONTROL
// ==========================================
// Git integration
"git.autofetch": true,
"git.enableSmartCommit": true,
"git.confirmSync": false,
"git.ignoreLimitWarning": true,
// ==========================================
// CODE QUALITY & ERRORS
// ==========================================
// Better error detection
"typescript.validate.enable": true,
"javascript.validate.enable": true,
"editor.codeActionsOnSave": {
"source.fixAll": "explicit",
"source.organizeImports": "explicit"
},
// Problem matcher for better error display
"typescript.tsc.autoDetect": "on"
}

View File

@@ -1,232 +0,0 @@
# Global Modal System with Gorhom Bottom Sheet
This guide explains how to use the global modal system implemented in this project.
## Overview
The global modal system allows you to trigger a bottom sheet modal from anywhere in your app programmatically, and render any component inside it.
## Architecture
The system consists of three main parts:
1. **GlobalModalProvider** (`providers/GlobalModalProvider.tsx`) - Context provider that manages modal state
2. **GlobalModal** (`components/GlobalModal.tsx`) - The actual modal component rendered at root level
3. **useGlobalModal** hook - Hook to interact with the modal from anywhere
## Setup (Already Configured)
The system is already integrated into your app:
```tsx
// In app/_layout.tsx
<BottomSheetModalProvider>
<GlobalModalProvider>
{/* Your app content */}
<GlobalModal />
</GlobalModalProvider>
</BottomSheetModalProvider>
```
## Usage
### Basic Usage
```tsx
import { useGlobalModal } from "@/providers/GlobalModalProvider";
import { View, Text } from "react-native";
function MyComponent() {
const { showModal, hideModal } = useGlobalModal();
const handleOpenModal = () => {
showModal(
<View className='p-6'>
<Text className='text-white text-2xl'>Hello from Modal!</Text>
</View>
);
};
return (
<Button onPress={handleOpenModal} title="Open Modal" />
);
}
```
### With Custom Options
```tsx
const handleOpenModal = () => {
showModal(
<YourCustomComponent />,
{
snapPoints: ["25%", "50%", "90%"], // Custom snap points
enablePanDownToClose: true, // Allow swipe to close
backgroundStyle: { // Custom background
backgroundColor: "#000000",
},
}
);
};
```
### Programmatic Control
```tsx
// Open modal
showModal(<Content />);
// Close modal from within the modal content
function ModalContent() {
const { hideModal } = useGlobalModal();
return (
<View>
<Button onPress={hideModal} title="Close" />
</View>
);
}
// Close modal from outside
hideModal();
```
### In Event Handlers or Functions
```tsx
function useApiCall() {
const { showModal } = useGlobalModal();
const fetchData = async () => {
try {
const result = await api.fetch();
// Show success modal
showModal(
<SuccessMessage data={result} />
);
} catch (error) {
// Show error modal
showModal(
<ErrorMessage error={error} />
);
}
};
return fetchData;
}
```
## API Reference
### `useGlobalModal()`
Returns an object with the following properties:
- **`showModal(content, options?)`** - Show the modal with given content
- `content: ReactNode` - Any React component or element to render
- `options?: ModalOptions` - Optional configuration object
- **`hideModal()`** - Programmatically hide the modal
- **`isVisible: boolean`** - Current visibility state of the modal
### `ModalOptions`
```typescript
interface ModalOptions {
enableDynamicSizing?: boolean; // Auto-size based on content (default: true)
snapPoints?: (string | number)[]; // Fixed snap points (e.g., ["50%", "90%"])
enablePanDownToClose?: boolean; // Allow swipe down to close (default: true)
backgroundStyle?: object; // Custom background styles
handleIndicatorStyle?: object; // Custom handle indicator styles
}
```
## Examples
See `components/ExampleGlobalModalUsage.tsx` for comprehensive examples including:
- Simple content modal
- Modal with custom snap points
- Complex component in modal
- Success/error modals triggered from functions
## Default Styling
The modal uses these default styles (can be overridden via options):
```typescript
{
enableDynamicSizing: true,
enablePanDownToClose: true,
backgroundStyle: {
backgroundColor: "#171717", // Dark background
},
handleIndicatorStyle: {
backgroundColor: "white",
},
}
```
## Best Practices
1. **Keep content in separate components** - Don't inline large JSX in `showModal()` calls
2. **Use the hook in custom hooks** - Create specialized hooks like `useShowSuccessModal()` for reusable modal patterns
3. **Handle cleanup** - The modal automatically clears content when closed
4. **Avoid nesting** - Don't show modals from within modals
5. **Consider UX** - Only use for important, contextual information that requires user attention
## Using with PlatformDropdown
When using `PlatformDropdown` with option groups, avoid setting a `title` on the `OptionGroup` if you're already passing a `title` prop to `PlatformDropdown`. This prevents nested menu behavior on iOS where users have to click through an extra layer.
```tsx
// Good - No title in option group (title is on PlatformDropdown)
const optionGroups: OptionGroup[] = [
{
options: items.map((item) => ({
type: "radio",
label: item.name,
value: item,
selected: item.id === selected?.id,
onPress: () => onChange(item),
})),
},
];
<PlatformDropdown
groups={optionGroups}
title="Select Item" // Title here
// ...
/>
// Bad - Causes nested menu on iOS
const optionGroups: OptionGroup[] = [
{
title: "Items", // This creates a nested Picker on iOS
options: items.map((item) => ({
type: "radio",
label: item.name,
value: item,
selected: item.id === selected?.id,
onPress: () => onChange(item),
})),
},
];
```
## Troubleshooting
### Modal doesn't appear
- Ensure `GlobalModalProvider` is above the component calling `useGlobalModal()`
- Check that `BottomSheetModalProvider` is present in the tree
- Verify `GlobalModal` component is rendered
### Content is cut off
- Use `enableDynamicSizing: true` for auto-sizing
- Or specify appropriate `snapPoints`
### Modal won't close
- Ensure `enablePanDownToClose` is `true`
- Check that backdrop is clickable
- Use `hideModal()` for programmatic closing

View File

@@ -1,11 +1,18 @@
module.exports = ({ config }) => {
if (process.env.EXPO_TV !== "1") {
if (process.env.EXPO_TV === "1") {
// Add TV-specific plugin for TV builds
config.plugins.push("@react-native-tvos/config-tv");
} else {
// Add non-TV specific plugins for phone builds
config.plugins.push("expo-background-task");
config.plugins.push([
"react-native-google-cast",
{ useDefaultExpandedMediaControls: true },
]);
// Add the background downloader plugin only for non-TV builds
config.plugins.push("./plugins/withRNBackgroundDownloader.js");
}
return {
android: {

View File

@@ -8,7 +8,6 @@
"scheme": "streamyfin",
"userInterfaceStyle": "dark",
"jsEngine": "hermes",
"newArchEnabled": true,
"assetBundlePatterns": ["**/*"],
"ios": {
"requireFullScreen": true,
@@ -54,7 +53,6 @@
"googleServicesFile": "./google-services.json"
},
"plugins": [
"@react-native-tvos/config-tv",
"expo-router",
"expo-font",
[
@@ -78,7 +76,6 @@
"useFrameworks": "static"
},
"android": {
"buildArchs": ["arm64-v8a", "x86_64"],
"compileSdkVersion": 35,
"targetSdkVersion": 35,
"buildToolsVersion": "35.0.0",
@@ -156,6 +153,7 @@
},
"updates": {
"url": "https://u.expo.dev/e79219d1-797f-4fbe-9fa1-cfd360690a68"
}
},
"newArchEnabled": false
}
}

View File

@@ -9,7 +9,7 @@ export default function CustomMenuLayout() {
<Stack.Screen
name='index'
options={{
headerShown: Platform.OS !== "ios",
headerShown: true,
headerLargeTitle: true,
headerTitle: t("tabs.custom_links"),
headerBlurEffect: "none",

View File

@@ -22,11 +22,6 @@ export default function IndexLayout() {
options={{
headerShown: !Platform.isTV,
headerTitle: t("tabs.home"),
headerLeft: () => (
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
@@ -48,88 +43,48 @@ export default function IndexLayout() {
name='downloads/index'
options={{
title: t("home.downloads.downloads_title"),
headerLeft: () => (
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
}}
/>
<Stack.Screen
name='downloads/[seriesId]'
options={{
title: t("home.downloads.tvseries"),
headerLeft: () => (
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
}}
/>
<Stack.Screen
name='sessions/index'
options={{
title: t("home.sessions.title"),
headerLeft: () => (
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
}}
/>
<Stack.Screen
name='settings'
options={{
title: t("home.settings.settings_title"),
headerLeft: () => (
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
}}
/>
<Stack.Screen
name='settings/marlin-search/page'
options={{
title: "",
headerLeft: () => (
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
}}
/>
<Stack.Screen
name='settings/jellyseerr/page'
options={{
title: "",
headerLeft: () => (
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
}}
/>
<Stack.Screen
name='settings/hide-libraries/page'
options={{
title: "",
headerLeft: () => (
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
}}
/>
<Stack.Screen
name='settings/logs/page'
options={{
title: "",
headerLeft: () => (
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
}}
/>
<Stack.Screen
@@ -137,11 +92,6 @@ export default function IndexLayout() {
options={{
headerShown: false,
title: "",
headerLeft: () => (
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
presentation: "modal",
}}
/>
@@ -152,11 +102,6 @@ export default function IndexLayout() {
name='collections/[collectionId]'
options={{
title: "",
headerLeft: () => (
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
headerShown: true,
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios",

View File

@@ -23,12 +23,12 @@ export default function page() {
const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>(
{},
);
const { downloadedItems, deleteItems } = useDownload();
const { getDownloadedItems, deleteItems } = useDownload();
const series = useMemo(() => {
try {
return (
downloadedItems
getDownloadedItems()
?.filter((f) => f.item.SeriesId === seriesId)
?.sort(
(a, b) => a?.item.ParentIndexNumber! - b.item.ParentIndexNumber!,
@@ -37,7 +37,7 @@ export default function page() {
} catch {
return [];
}
}, [downloadedItems, seriesId]);
}, [getDownloadedItems]);
// Group episodes by season in a single pass
const seasonGroups = useMemo(() => {
@@ -91,7 +91,7 @@ export default function page() {
title: series[0].item.SeriesName,
});
} else {
storage.remove(seriesId);
storage.delete(seriesId);
router.back();
}
}, [series]);
@@ -107,12 +107,7 @@ export default function page() {
},
{
text: "Delete",
onPress: () =>
deleteItems(
groupBySeason
.map((episode) => episode.Id!)
.filter((id) => id !== undefined),
),
onPress: () => deleteItems(groupBySeason),
style: "destructive",
},
],
@@ -145,7 +140,7 @@ export default function page() {
</View>
</View>
)}
<ScrollView key={seasonIndex} style={{ paddingHorizontal: 16 }}>
<ScrollView key={seasonIndex} className='px-4'>
{groupBySeason.map((episode, index) => (
<EpisodeCard key={index} item={episode} />
))}

View File

@@ -27,8 +27,12 @@ export default function page() {
const navigation = useNavigation();
const { t } = useTranslation();
const [queue, setQueue] = useAtom(queueAtom);
const { removeProcess, downloadedItems, deleteFileByType, deleteAllFiles } =
useDownload();
const {
removeProcess,
getDownloadedItems,
deleteFileByType,
deleteAllFiles,
} = useDownload();
const router = useRouter();
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
@@ -58,7 +62,7 @@ export default function page() {
);
};
const downloadedFiles = downloadedItems;
const downloadedFiles = getDownloadedItems();
const movies = useMemo(() => {
try {
@@ -102,10 +106,7 @@ export default function page() {
useEffect(() => {
navigation.setOptions({
headerRight: () => (
<TouchableOpacity
onPress={bottomSheetModalRef.current?.present}
className='px-2'
>
<TouchableOpacity onPress={bottomSheetModalRef.current?.present}>
<DownloadSize items={downloadedFiles?.map((f) => f.item) || []} />
</TouchableOpacity>
),
@@ -142,25 +143,23 @@ export default function page() {
});
const deleteOtherMedia = () =>
Promise.all(
otherMedia.map(
(item) =>
item.item.Type &&
deleteFileByType(item.item.Type)
.then(() =>
toast.success(
t("home.downloads.toasts.deleted_media_successfully", {
type: item.item.Type,
}),
),
)
.catch((reason) => {
writeToLog("ERROR", reason);
toast.error(
t("home.downloads.toasts.failed_to_delete_media", {
type: item.item.Type,
}),
);
}),
otherMedia.map((item) =>
deleteFileByType(item.item.Type)
.then(() =>
toast.success(
t("home.downloads.toasts.deleted_media_successfully", {
type: item.item.Type,
}),
),
)
.catch((reason) => {
writeToLog("ERROR", reason);
toast.error(
t("home.downloads.toasts.failed_to_delete_media", {
type: item.item.Type,
}),
);
}),
),
);
@@ -172,7 +171,7 @@ export default function page() {
<View style={{ flex: 1 }}>
<ScrollView showsVerticalScrollIndicator={false} className='flex-1'>
<View className='py-4'>
<View className='mb-4 flex flex-col gap-y-4 px-4'>
<View className='mb-4 flex flex-col space-y-4 px-4'>
<View className='bg-neutral-900 p-4 rounded-2xl'>
<Text className='text-lg font-bold'>
{t("home.downloads.queue")}
@@ -180,7 +179,7 @@ export default function page() {
<Text className='text-xs opacity-70 text-red-600'>
{t("home.downloads.queue_hint")}
</Text>
<View className='flex flex-col gap-y-2 mt-2'>
<View className='flex flex-col space-y-2 mt-2'>
{queue.map((q, index) => (
<TouchableOpacity
onPress={() =>

View File

@@ -1,4 +1,4 @@
import { HomeIndex } from "@/components/home/HomeIndex";
import { HomeIndex } from "@/components/settings/HomeIndex";
export default function page() {
return <HomeIndex />;

View File

@@ -258,7 +258,7 @@ const SessionCard = ({ session }: SessionCardProps) => {
</View>
{/* Session controls */}
<View className='flex flex-row mt-2 gap-x-4 justify-center'>
<View className='flex flex-row mt-2 space-x-4 justify-center'>
<TouchableOpacity
onPress={handlePrevious}
disabled={isControlLoading[PlaystateCommand.PreviousTrack]}

View File

@@ -46,7 +46,7 @@ export default function settings() {
logout();
}}
>
<Text className='text-red-600 px-2'>
<Text className='text-red-600'>
{t("home.settings.log_out_button")}
</Text>
</TouchableOpacity>

View File

@@ -1,3 +1,4 @@
import * as FileSystem from "expo-file-system";
import { useNavigation } from "expo-router";
import * as Sharing from "expo-sharing";
import { useCallback, useEffect, useId, useMemo, useState } from "react";

View File

@@ -393,6 +393,7 @@ const page: React.FC = () => {
data={flatData}
renderItem={renderItem}
keyExtractor={keyExtractor}
estimatedItemSize={255}
numColumns={
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5
}

View File

@@ -19,29 +19,31 @@ 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 { ItemActions } from "@/components/series/SeriesActions";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
import {
type IssueType,
IssueTypeName,
} from "@/utils/jellyseerr/server/constants/issue";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
import type {
MovieResult,
TvResult,
} from "@/utils/jellyseerr/server/models/Search";
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import RequestModal from "@/components/jellyseerr/RequestModal";
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
const Page: React.FC = () => {
const insets = useSafeAreaInsets();
const params = useLocalSearchParams();
@@ -154,24 +156,6 @@ const Page: React.FC = () => {
[details],
);
const issueTypeOptionGroups = useMemo(
() => [
{
title: t("jellyseerr.types"),
options: Object.entries(IssueTypeName)
.reverse()
.map(([key, value]) => ({
type: "radio" as const,
label: value,
value: key,
selected: key === String(issueType),
onPress: () => setIssueType(key as unknown as IssueType),
})),
},
],
[issueType, t],
);
useEffect(() => {
if (details) {
navigation.setOptions({
@@ -232,7 +216,7 @@ const Page: React.FC = () => {
}
>
<View className='flex flex-col'>
<View className='gap-y-4'>
<View className='space-y-4'>
<View className='px-4'>
<View className='flex flex-row justify-between w-full'>
<View className='flex flex-col w-56'>
@@ -380,23 +364,50 @@ const Page: React.FC = () => {
</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")}
</Text>
<PlatformDropdown
groups={issueTypeOptionGroups}
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}>
{issueType
? IssueTypeName[issueType]
: t("jellyseerr.select_an_issue")}
<View className='flex flex-col'>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className='flex flex-col'>
<Text className='opacity-50 mb-1 text-xs'>
{t("jellyseerr.issue_type")}
</Text>
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
<Text style={{}} className='' numberOfLines={1}>
{issueType
? IssueTypeName[issueType]
: t("jellyseerr.select_an_issue")}
</Text>
</TouchableOpacity>
</View>
}
title={t("jellyseerr.types")}
/>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={false}
side='bottom'
align='center'
alignOffset={0}
avoidCollisions={true}
collisionPadding={0}
sideOffset={0}
>
<DropdownMenu.Label>
{t("jellyseerr.types")}
</DropdownMenu.Label>
{Object.entries(IssueTypeName)
.reverse()
.map(([key, value], _idx) => (
<DropdownMenu.Item
key={value}
onSelect={() =>
setIssueType(key as unknown as IssueType)
}
>
<DropdownMenu.ItemTitle>
{value}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full'>

View File

@@ -105,7 +105,7 @@ const page: React.FC = () => {
/>
}
>
<View className='flex flex-col gap-y-4 my-4'>
<View className='flex flex-col space-y-4 my-4'>
<View className='px-4 mb-4'>
<MoviesTitleHeader item={item} className='mb-4' />
<OverviewText text={item.Overview} />

View File

@@ -1,164 +1,85 @@
import { Ionicons } from "@expo/vector-icons";
import { Stack } from "expo-router";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform } from "react-native";
import { PlatformDropdown } from "@/components/PlatformDropdown";
import { Platform, TouchableOpacity } from "react-native";
import { LibraryOptionsSheet } from "@/components/settings/LibraryOptionsSheet";
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import { useSettings } from "@/utils/atoms/settings";
export default function IndexLayout() {
const { settings, updateSettings, pluginSettings } = useSettings();
const [optionsSheetOpen, setOptionsSheetOpen] = useState(false);
const { t } = useTranslation();
if (!settings?.libraryOptions) return null;
return (
<Stack>
<Stack.Screen
name='index'
options={{
headerShown: !Platform.isTV,
headerTitle: t("tabs.library"),
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerRight: () =>
!pluginSettings?.libraryOptions?.locked &&
!Platform.isTV && (
<PlatformDropdown
trigger={
<>
<Stack>
<Stack.Screen
name='index'
options={{
headerShown: !Platform.isTV,
headerTitle: t("tabs.library"),
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerRight: () =>
!pluginSettings?.libraryOptions?.locked &&
!Platform.isTV && (
<TouchableOpacity
onPress={() => setOptionsSheetOpen(true)}
className='flex flex-row items-center justify-center w-9 h-9'
>
<Ionicons
name='ellipsis-horizontal-outline'
size={24}
color='white'
/>
}
title={t("library.options.display")}
groups={[
{
title: t("library.options.display"),
options: [
{
type: "radio",
label: t("library.options.row"),
value: "row",
selected: settings.libraryOptions.display === "row",
onPress: () =>
updateSettings({
libraryOptions: {
...settings.libraryOptions,
display: "row",
},
}),
},
{
type: "radio",
label: t("library.options.list"),
value: "list",
selected: settings.libraryOptions.display === "list",
onPress: () =>
updateSettings({
libraryOptions: {
...settings.libraryOptions,
display: "list",
},
}),
},
],
},
{
title: t("library.options.image_style"),
options: [
{
type: "radio",
label: t("library.options.poster"),
value: "poster",
selected:
settings.libraryOptions.imageStyle === "poster",
onPress: () =>
updateSettings({
libraryOptions: {
...settings.libraryOptions,
imageStyle: "poster",
},
}),
},
{
type: "radio",
label: t("library.options.cover"),
value: "cover",
selected:
settings.libraryOptions.imageStyle === "cover",
onPress: () =>
updateSettings({
libraryOptions: {
...settings.libraryOptions,
imageStyle: "cover",
},
}),
},
],
},
{
title: "Options",
options: [
{
type: "toggle",
label: t("library.options.show_titles"),
value: settings.libraryOptions.showTitles,
onToggle: () =>
updateSettings({
libraryOptions: {
...settings.libraryOptions,
showTitles: !settings.libraryOptions.showTitles,
},
}),
disabled:
settings.libraryOptions.imageStyle === "poster",
},
{
type: "toggle",
label: t("library.options.show_stats"),
value: settings.libraryOptions.showStats,
onToggle: () =>
updateSettings({
libraryOptions: {
...settings.libraryOptions,
showStats: !settings.libraryOptions.showStats,
},
}),
},
],
},
]}
/>
),
}}
</TouchableOpacity>
),
}}
/>
<Stack.Screen
name='[libraryId]'
options={{
title: "",
headerShown: !Platform.isTV,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
}}
/>
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
<Stack.Screen key={name} name={name} options={options} />
))}
<Stack.Screen
name='collections/[collectionId]'
options={{
title: "",
headerShown: !Platform.isTV,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
}}
/>
</Stack>
<LibraryOptionsSheet
open={optionsSheetOpen}
setOpen={setOptionsSheetOpen}
settings={settings.libraryOptions}
updateSettings={(options) =>
updateSettings({
libraryOptions: {
...settings.libraryOptions,
...options,
},
})
}
disabled={pluginSettings?.libraryOptions?.locked}
/>
<Stack.Screen
name='[libraryId]'
options={{
title: "",
headerShown: !Platform.isTV,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
}}
/>
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
<Stack.Screen key={name} name={name} options={options} />
))}
<Stack.Screen
name='collections/[collectionId]'
options={{
title: "",
headerShown: !Platform.isTV,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
}}
/>
</Stack>
</>
);
}

View File

@@ -87,8 +87,8 @@ export default function index() {
paddingTop: 17,
paddingHorizontal: settings?.libraryOptions?.display === "row" ? 0 : 17,
paddingBottom: 150,
paddingLeft: insets.left + 17,
paddingRight: insets.right + 17,
paddingLeft: insets.left,
paddingRight: insets.right,
}}
data={libraries}
renderItem={({ item }) => <LibraryItemCard library={item} />}
@@ -105,6 +105,7 @@ export default function index() {
<View className='h-4' />
)
}
estimatedItemSize={200}
/>
);
}

View File

@@ -24,6 +24,8 @@ import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { FilterButton } from "@/components/filters/FilterButton";
import { Tag } from "@/components/GenreTags";
import { ItemCardText } from "@/components/ItemCardText";
import {
JellyseerrSearchSort,
@@ -31,10 +33,8 @@ import {
} 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 { useJellyseerr } from "@/hooks/useJellyseerr";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
@@ -282,29 +282,69 @@ export default function search() {
maxLength={500}
/>
)}
<View className='flex flex-col'>
<View
className='flex flex-col'
style={{
marginTop: Platform.OS === "android" ? 16 : 0,
}}
>
{jellyseerrApi && (
<View className='pl-4 pr-4 pt-2 flex flex-row'>
<SearchTabButtons
searchType={searchType}
setSearchType={setSearchType}
t={t}
/>
<ScrollView
horizontal
className='flex flex-row flex-wrap space-x-2 px-4 mb-2'
>
<TouchableOpacity onPress={() => setSearchType("Library")}>
<Tag
text={t("search.library")}
textClass='p-1'
className={
searchType === "Library" ? "bg-purple-600" : undefined
}
/>
</TouchableOpacity>
<TouchableOpacity onPress={() => setSearchType("Discover")}>
<Tag
text={t("search.discover")}
textClass='p-1'
className={
searchType === "Discover" ? "bg-purple-600" : undefined
}
/>
</TouchableOpacity>
{searchType === "Discover" &&
!loading &&
noResults &&
debouncedSearch.length > 0 && (
<DiscoverFilters
searchFilterId={searchFilterId}
orderFilterId={orderFilterId}
jellyseerrOrderBy={jellyseerrOrderBy}
setJellyseerrOrderBy={setJellyseerrOrderBy}
jellyseerrSortOrder={jellyseerrSortOrder}
setJellyseerrSortOrder={setJellyseerrSortOrder}
t={t}
/>
<View className='flex flex-row justify-end items-center space-x-1'>
<FilterButton
id={searchFilterId}
queryKey='jellyseerr_search'
queryFn={async () =>
Object.keys(JellyseerrSearchSort).filter((v) =>
Number.isNaN(Number(v)),
)
}
set={(value) => setJellyseerrOrderBy(value[0])}
values={[jellyseerrOrderBy]}
title={t("library.filters.sort_by")}
renderItemLabel={(item) =>
t(`home.settings.plugins.jellyseerr.order_by.${item}`)
}
disableSearch={true}
/>
<FilterButton
id={orderFilterId}
queryKey='jellysearr_search'
queryFn={async () => ["asc", "desc"]}
set={(value) => setJellyseerrSortOrder(value[0])}
values={[jellyseerrSortOrder]}
title={t("library.filters.sort_order")}
renderItemLabel={(item) => t(`library.filters.${item}`)}
disableSearch={true}
/>
</View>
)}
</View>
</ScrollView>
)}
<View className='mt-2'>

View File

@@ -75,10 +75,7 @@ export default function page() {
: require("react-native-volume-manager");
const downloadUtils = useDownload();
const downloadedFiles = useMemo(
() => downloadUtils.getDownloadedItems(),
[downloadUtils.getDownloadedItems],
);
const downloadedFiles = downloadUtils.getDownloadedItems();
const revalidateProgressCache = useInvalidatePlaybackProgressCache();

View File

@@ -1,16 +1,9 @@
import "@/augmentations";
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import * as BackgroundTask from "expo-background-task";
import * as Device from "expo-device";
import { Paths } from "expo-file-system";
import { Platform } from "react-native";
import { GlobalModal } from "@/components/GlobalModal";
import i18n from "@/i18n";
import { DownloadProvider } from "@/providers/DownloadProvider";
import { GlobalModalProvider } from "@/providers/GlobalModalProvider";
import {
apiAtom,
getOrSetDeviceId,
@@ -33,6 +26,18 @@ import {
} from "@/utils/log";
import { storage } from "@/utils/mmkv";
const BackGroundDownloader = !Platform.isTV
? require("@kesha-antonov/react-native-background-downloader")
: null;
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import * as BackgroundTask from "expo-background-task";
import * as Device from "expo-device";
import * as FileSystem from "expo-file-system";
const Notifications = !Platform.isTV ? require("expo-notifications") : null;
import { getLocales } from "expo-localization";
@@ -43,7 +48,7 @@ import * as TaskManager from "expo-task-manager";
import { Provider as JotaiProvider } from "jotai";
import { useEffect, useRef, useState } from "react";
import { I18nextProvider } from "react-i18next";
import { Appearance } from "react-native";
import { Appearance, AppState } from "react-native";
import { SystemBars } from "react-native-edge-to-edge";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
@@ -59,7 +64,6 @@ import { useAtom } from "jotai";
import { Toaster } from "sonner-native";
import { userAtom } from "@/providers/JellyfinProvider";
import { store } from "@/utils/store";
import "../global.css";
if (!Platform.isTV) {
Notifications.setNotificationHandler({
@@ -139,7 +143,7 @@ if (!Platform.isTV) {
const token = getTokenFromStorage();
const deviceId = getOrSetDeviceId();
const baseDirectory = Paths.document.uri;
const baseDirectory = FileSystem.documentDirectory;
if (!token || !deviceId || !baseDirectory)
return BackgroundTask.BackgroundTaskResult.Failed;
@@ -222,6 +226,7 @@ function Layout() {
const { settings } = useSettings();
const [user] = useAtom(userAtom);
const [api] = useAtom(apiAtom);
const appState = useRef(AppState.currentState);
const segments = useSegments();
useEffect(() => {
@@ -380,6 +385,32 @@ function Layout() {
segments,
]);
useEffect(() => {
if (Platform.isTV) {
return;
}
const subscription = AppState.addEventListener("change", (nextAppState) => {
if (
appState.current.match(/inactive|background/) &&
nextAppState === "active"
) {
BackGroundDownloader.checkForExistingDownloads().catch(
(error: unknown) => {
writeErrorLog("Failed to resume background downloads", error);
},
);
}
});
BackGroundDownloader.checkForExistingDownloads().catch((error: unknown) => {
writeErrorLog("Failed to resume background downloads", error);
});
return () => {
subscription.remove();
};
}, []);
return (
<QueryClientProvider client={queryClient}>
<JellyfinProvider>
@@ -387,55 +418,52 @@ function Layout() {
<LogProvider>
<WebSocketProvider>
<DownloadProvider>
<GlobalModalProvider>
<BottomSheetModalProvider>
<ThemeProvider value={DarkTheme}>
<SystemBars style='light' hidden={false} />
<Stack initialRouteName='(auth)/(tabs)'>
<Stack.Screen
name='(auth)/(tabs)'
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name='(auth)/player'
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name='login'
options={{
headerShown: true,
title: "",
headerTransparent: true,
}}
/>
<Stack.Screen name='+not-found' />
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
<BottomSheetModalProvider>
<SystemBars style='light' hidden={false} />
<ThemeProvider value={DarkTheme}>
<Stack initialRouteName='(auth)/(tabs)'>
<Stack.Screen
name='(auth)/(tabs)'
options={{
headerShown: false,
title: "",
header: () => null,
}}
closeButton
/>
<GlobalModal />
</ThemeProvider>
</BottomSheetModalProvider>
</GlobalModalProvider>
<Stack.Screen
name='(auth)/player'
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name='login'
options={{
headerShown: true,
title: "",
headerTransparent: true,
}}
/>
<Stack.Screen name='+not-found' />
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}}
closeButton
/>
</ThemeProvider>
</BottomSheetModalProvider>
</DownloadProvider>
</WebSocketProvider>
</LogProvider>

View File

@@ -4,6 +4,7 @@ import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { t } from "i18next";
import { useAtomValue } from "jotai";
import type React from "react";
import { useCallback, useEffect, useState } from "react";
import {
Alert,
@@ -81,10 +82,10 @@ const Login: React.FC = () => {
onPress={() => {
removeServer();
}}
className='flex flex-row items-center pr-2 pl-1'
className='flex flex-row items-center'
>
<Ionicons name='chevron-back' size={18} color={Colors.primary} />
<Text className=' ml-1 text-purple-600'>
<Text className='ml-2 text-purple-600'>
{t("login.change_server")}
</Text>
</TouchableOpacity>
@@ -375,7 +376,7 @@ const Login: React.FC = () => {
{api?.basePath ? (
<View className='flex flex-col h-full relative items-center justify-center'>
<View className='px-4 -mt-20 w-full'>
<View className='flex flex-col gap-y-2'>
<View className='flex flex-col space-y-2'>
<Text className='text-2xl font-bold -mb-2'>
{serverName ? (
<>

View File

@@ -115,4 +115,4 @@
<path id="path259-2-6-4-6-7-0-1-0-5-9-4-7-1-5-7-6-2" class="cls-11" d="M46.97,39.46c5.94,0,10.75,4.81,10.75,10.75s-4.81,10.75-10.75,10.75-10.75-4.81-10.75-10.75c0-1.1.16-2.16.47-3.17.84,1.87,2.72,3.17,4.9,3.17,2.97,0,5.37-2.41,5.37-5.37,0-2.18-1.3-4.06-3.17-4.9,1-.31,2.06-.47,3.17-.47h.01Z"/>
</g>
</g>
</svg>
</svg>

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -1,4 +1,4 @@
import { storage } from "@/utils/mmkv";
import { MMKV } from "react-native-mmkv";
declare module "react-native-mmkv" {
interface MMKV {
@@ -9,7 +9,7 @@ declare module "react-native-mmkv" {
// Add the augmentation methods directly to the MMKV prototype
// This follows the recommended pattern while adding the helper methods your app uses
(storage as any).get = function <T>(key: string): T | undefined {
MMKV.prototype.get = function <T>(key: string): T | undefined {
try {
const serializedItem = this.getString(key);
if (!serializedItem) return undefined;
@@ -20,10 +20,10 @@ declare module "react-native-mmkv" {
}
};
(storage as any).setAny = function (key: string, value: any | undefined): void {
MMKV.prototype.setAny = function (key: string, value: any | undefined): void {
try {
if (value === undefined) {
this.remove(key);
this.delete(key);
} else {
this.set(key, JSON.stringify(value));
}

View File

@@ -2,6 +2,6 @@ module.exports = (api) => {
api.cache(true);
return {
presets: ["babel-preset-expo"],
plugins: ["react-native-worklets/plugin"],
plugins: ["nativewind/babel", "react-native-reanimated/plugin"],
};
};

View File

@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.2.4/schema.json",
"$schema": "https://biomejs.dev/schemas/2.2.5/schema.json",
"files": {
"includes": [
"**/*",

1223
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -21,16 +21,15 @@ import Animated, {
} from "react-native-reanimated";
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { ItemImage } from "../common/ItemImage";
import { getItemNavigation } from "../common/TouchableItemRouter";
import type { SelectedOptions } from "../ItemContent";
import { PlayButton } from "../PlayButton";
import { MarkAsPlayedLargeButton } from "./MarkAsPlayedLargeButton";
import { ItemImage } from "./common/ItemImage";
import { getItemNavigation } from "./common/TouchableItemRouter";
import type { SelectedOptions } from "./ItemContent";
import { PlayButton } from "./PlayButton";
import { PlayedStatus } from "./PlayedStatus";
interface AppleTVCarouselProps {
initialIndex?: number;
@@ -46,11 +45,10 @@ const GRADIENT_HEIGHT_BOTTOM = 150;
const LOGO_HEIGHT = 80;
// Position Constants
const LOGO_BOTTOM_POSITION = 260;
const GENRES_BOTTOM_POSITION = 220;
const OVERVIEW_BOTTOM_POSITION = 165;
const CONTROLS_BOTTOM_POSITION = 80;
const DOTS_BOTTOM_POSITION = 40;
const LOGO_BOTTOM_POSITION = 210;
const GENRES_BOTTOM_POSITION = 170;
const CONTROLS_BOTTOM_POSITION = 100;
const DOTS_BOTTOM_POSITION = 60;
// Size Constants
const DOT_HEIGHT = 6;
@@ -60,15 +58,13 @@ const PLAY_BUTTON_SKELETON_HEIGHT = 50;
const PLAYED_STATUS_SKELETON_SIZE = 40;
const TEXT_SKELETON_HEIGHT = 20;
const TEXT_SKELETON_WIDTH = 250;
const OVERVIEW_SKELETON_HEIGHT = 16;
const OVERVIEW_SKELETON_WIDTH = 300;
const _EMPTY_STATE_ICON_SIZE = 64;
// Spacing Constants
const HORIZONTAL_PADDING = 40;
const DOT_PADDING = 2;
const DOT_GAP = 4;
const CONTROLS_GAP = 10;
const CONTROLS_GAP = 20;
const _TEXT_MARGIN_TOP = 16;
// Border Radius Constants
@@ -87,16 +83,13 @@ const VELOCITY_THRESHOLD = 400;
// Text Constants
const GENRES_FONT_SIZE = 16;
const OVERVIEW_FONT_SIZE = 14;
const _EMPTY_STATE_FONT_SIZE = 18;
const TEXT_SHADOW_RADIUS = 2;
const MAX_GENRES_COUNT = 2;
const MAX_BUTTON_WIDTH = 300;
const OVERVIEW_MAX_LINES = 2;
const OVERVIEW_MAX_WIDTH = "80%";
// Opacity Constants
const OVERLAY_OPACITY = 0.3;
const OVERLAY_OPACITY = 0.4;
const DOT_INACTIVE_OPACITY = 0.6;
const TEXT_OPACITY = 0.9;
@@ -175,7 +168,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
userId: user.Id,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
includeItemTypes: ["Movie", "Series", "Episode"],
fields: ["Genres", "Overview"],
fields: ["Genres"],
limit: 2,
});
return response.data.Items || [];
@@ -190,7 +183,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
if (!api || !user?.Id) return [];
const response = await getTvShowsApi(api).getNextUp({
userId: user.Id,
fields: ["MediaSourceCount", "Genres", "Overview"],
fields: ["MediaSourceCount", "Genres"],
limit: 2,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
enableResumable: false,
@@ -209,7 +202,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
const response = await getUserLibraryApi(api).getLatestMedia({
userId: user.Id,
limit: 2,
fields: ["PrimaryImageAspectRatio", "Path", "Genres", "Overview"],
fields: ["PrimaryImageAspectRatio", "Path", "Genres"],
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
});
@@ -355,8 +348,6 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
};
});
const togglePlayedStatus = useMarkAsPlayed(items);
const renderDots = () => {
if (!hasItems || items.length <= 1) return null;
@@ -482,36 +473,6 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
/>
</View>
{/* Overview Skeleton */}
<View
style={{
position: "absolute",
bottom: OVERVIEW_BOTTOM_POSITION,
left: 0,
right: 0,
paddingHorizontal: HORIZONTAL_PADDING,
alignItems: "center",
gap: 6,
}}
>
<View
style={{
height: OVERVIEW_SKELETON_HEIGHT,
width: OVERVIEW_SKELETON_WIDTH,
backgroundColor: SKELETON_ELEMENT_COLOR,
borderRadius: TEXT_SKELETON_BORDER_RADIUS,
}}
/>
<View
style={{
height: OVERVIEW_SKELETON_HEIGHT,
width: OVERVIEW_SKELETON_WIDTH * 0.7,
backgroundColor: SKELETON_ELEMENT_COLOR,
borderRadius: TEXT_SKELETON_BORDER_RADIUS,
}}
/>
</View>
{/* Controls Skeleton */}
<View
style={{
@@ -728,39 +689,6 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
</TouchableOpacity>
</View>
{/* Overview Section - for Episodes and Movies */}
{(item.Type === "Episode" || item.Type === "Movie") &&
item.Overview && (
<View
style={{
position: "absolute",
bottom: OVERVIEW_BOTTOM_POSITION,
left: 0,
right: 0,
paddingHorizontal: HORIZONTAL_PADDING,
alignItems: "center",
}}
>
<TouchableOpacity onPress={() => navigateToItem(item)}>
<Animated.Text
numberOfLines={OVERVIEW_MAX_LINES}
style={{
color: `rgba(255, 255, 255, ${TEXT_OPACITY * 0.85})`,
fontSize: OVERVIEW_FONT_SIZE,
fontWeight: "400",
textAlign: "center",
maxWidth: OVERVIEW_MAX_WIDTH,
textShadowColor: TEXT_SHADOW_COLOR,
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: TEXT_SHADOW_RADIUS,
}}
>
{item.Overview}
</Animated.Text>
</TouchableOpacity>
</View>
)}
{/* Controls Section */}
<View
style={{
@@ -791,10 +719,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
</View>
{/* Mark as Played */}
<MarkAsPlayedLargeButton
isPlayed={item.UserData?.Played ?? false}
onToggle={togglePlayedStatus}
/>
<PlayedStatus items={[item]} size='large' />
</View>
</View>
</View>

View File

@@ -1,9 +1,11 @@
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMemo } from "react";
import { Platform, TouchableOpacity, View } from "react-native";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { useTranslation } from "react-i18next";
import { Text } from "./common/Text";
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
interface Props extends React.ComponentProps<typeof View> {
source?: MediaSourceInfo;
@@ -18,8 +20,6 @@ export const AudioTrackSelector: React.FC<Props> = ({
...props
}) => {
const isTv = Platform.isTV;
const [open, setOpen] = useState(false);
const { t } = useTranslation();
const audioStreams = useMemo(
() => source?.MediaStreams?.filter((x) => x.Type === "Audio"),
@@ -31,58 +31,55 @@ export const AudioTrackSelector: React.FC<Props> = ({
[audioStreams, selected],
);
const optionGroups: OptionGroup[] = useMemo(
() => [
{
options:
audioStreams?.map((audio, idx) => ({
type: "radio" as const,
label: audio.DisplayTitle || `Audio Stream ${idx + 1}`,
value: audio.Index ?? idx,
selected: audio.Index === selected,
onPress: () => {
if (audio.Index !== null && audio.Index !== undefined) {
onChange(audio.Index);
}
},
})) || [],
},
],
[audioStreams, selected, onChange],
);
const handleOptionSelect = () => {
setOpen(false);
};
const trigger = (
<View className='flex flex-col' {...props}>
<Text className='opacity-50 mb-1 text-xs'>{t("item_card.audio")}</Text>
<TouchableOpacity
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'
onPress={() => setOpen(true)}
>
<Text numberOfLines={1}>{selectedAudioSteam?.DisplayTitle}</Text>
</TouchableOpacity>
</View>
);
const { t } = useTranslation();
if (isTv) return null;
return (
<PlatformDropdown
groups={optionGroups}
trigger={trigger}
title={t("item_card.audio")}
open={open}
onOpenChange={setOpen}
onOptionSelect={handleOptionSelect}
expoUIConfig={{
hostStyle: { flex: 1 },
<View
className='flex shrink'
style={{
minWidth: 50,
}}
bottomSheetConfig={{
enablePanDownToClose: true,
}}
/>
>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className='flex flex-col' {...props}>
<Text className='opacity-50 mb-1 text-xs'>
{t("item_card.audio")}
</Text>
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
<Text className='' numberOfLines={1}>
{selectedAudioSteam?.DisplayTitle}
</Text>
</TouchableOpacity>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side='bottom'
align='start'
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Audio streams</DropdownMenu.Label>
{audioStreams?.map((audio, idx: number) => (
<DropdownMenu.Item
key={idx.toString()}
onSelect={() => {
if (audio.Index !== null && audio.Index !== undefined)
onChange(audio.Index);
}}
>
<DropdownMenu.ItemTitle>
{audio.DisplayTitle}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
);
};

View File

@@ -1,8 +1,10 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Text } from "./common/Text";
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
export type Bitrate = {
key: string;
@@ -59,8 +61,6 @@ export const BitrateSelector: React.FC<Props> = ({
...props
}) => {
const isTv = Platform.isTV;
const [open, setOpen] = useState(false);
const { t } = useTranslation();
const sorted = useMemo(() => {
if (inverted)
@@ -76,59 +76,53 @@ export const BitrateSelector: React.FC<Props> = ({
);
}, [inverted]);
const optionGroups: OptionGroup[] = useMemo(
() => [
{
options: sorted.map((bitrate) => ({
type: "radio" as const,
label: bitrate.key,
value: bitrate,
selected: bitrate.value === selected?.value,
onPress: () => onChange(bitrate),
})),
},
],
[sorted, selected, onChange],
);
const handleOptionSelect = (optionId: string) => {
const selectedBitrate = sorted.find((b) => b.key === optionId);
if (selectedBitrate) {
onChange(selectedBitrate);
}
setOpen(false);
};
const trigger = (
<View className='flex flex-col' {...props}>
<Text className='opacity-50 mb-1 text-xs'>{t("item_card.quality")}</Text>
<TouchableOpacity
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'
onPress={() => setOpen(true)}
>
<Text numberOfLines={1}>
{BITRATES.find((b) => b.value === selected?.value)?.key}
</Text>
</TouchableOpacity>
</View>
);
const { t } = useTranslation();
if (isTv) return null;
return (
<PlatformDropdown
groups={optionGroups}
trigger={trigger}
title={t("item_card.quality")}
open={open}
onOpenChange={setOpen}
onOptionSelect={handleOptionSelect}
expoUIConfig={{
hostStyle: { flex: 1 },
<View
className='flex shrink'
style={{
minWidth: 60,
maxWidth: 200,
}}
bottomSheetConfig={{
enablePanDownToClose: true,
}}
/>
>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className='flex flex-col' {...props}>
<Text className='opacity-50 mb-1 text-xs'>
{t("item_card.quality")}
</Text>
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
<Text style={{}} className='' numberOfLines={1}>
{BITRATES.find((b) => b.value === selected?.value)?.key}
</Text>
</TouchableOpacity>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={false}
side='bottom'
align='center'
alignOffset={0}
avoidCollisions={true}
collisionPadding={0}
sideOffset={0}
>
<DropdownMenu.Label>Bitrates</DropdownMenu.Label>
{sorted.map((b) => (
<DropdownMenu.Item
key={b.key}
onSelect={() => {
onChange(b);
}}
>
<DropdownMenu.ItemTitle>{b.key}</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
);
};

View File

@@ -69,28 +69,27 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
return (
<View
className={`
relative w-44 aspect-video rounded-xl overflow-hidden border border-neutral-800
relative w-44 aspect-video rounded-lg overflow-hidden border border-neutral-800
${size === "small" ? "w-32" : "w-44"}
`}
>
<Image
key={item.Id}
id={item.Id}
source={{
uri: url,
}}
cachePolicy={"memory-disk"}
contentFit='cover'
style={{
height: "100%",
width: "100%",
}}
/>
{showPlayButton && (
<View className='absolute inset-0 flex items-center justify-center'>
<Ionicons name='play-circle' size={40} color='white' />
</View>
)}
<View className='w-full h-full flex items-center justify-center'>
<Image
key={item.Id}
id={item.Id}
source={{
uri: url,
}}
cachePolicy={"memory-disk"}
contentFit='cover'
className='w-full h-full'
/>
{showPlayButton && (
<View className='absolute inset-0 flex items-center justify-center'>
<Ionicons name='play-circle' size={40} color='white' />
</View>
)}
</View>
{!item.UserData?.Played && <WatchedIndicator item={item} />}
<ProgressBar item={item} />
</View>

View File

@@ -64,8 +64,9 @@ export const DownloadItems: React.FC<DownloadProps> = ({
const { settings } = useSettings();
const [downloadUnwatchedOnly, setDownloadUnwatchedOnly] = useState(false);
const { processes, startBackgroundDownload, downloadedItems } = useDownload();
const downloadedFiles = downloadedItems;
const { processes, startBackgroundDownload, getDownloadedItems } =
useDownload();
const downloadedFiles = getDownloadedItems();
const [selectedOptions, setSelectedOptions] = useState<
SelectedOptions | undefined
@@ -358,18 +359,16 @@ export const DownloadItems: React.FC<DownloadProps> = ({
})}
</Text>
</View>
<View className='flex flex-col space-y-2 w-full'>
<View className='items-start'>
<BitrateSelector
inverted
onChange={(val) =>
setSelectedOptions(
(prev) => prev && { ...prev, bitrate: val },
)
}
selected={selectedOptions?.bitrate}
/>
</View>
<View className='flex flex-col space-y-2 w-full items-start'>
<BitrateSelector
inverted
onChange={(val) =>
setSelectedOptions(
(prev) => prev && { ...prev, bitrate: val },
)
}
selected={selectedOptions?.bitrate}
/>
{itemsNotDownloaded.length > 1 && (
<View className='flex flex-row items-center justify-between w-full py-2'>
<Text>{t("item_card.download.download_unwatched_only")}</Text>
@@ -381,23 +380,21 @@ export const DownloadItems: React.FC<DownloadProps> = ({
)}
{itemsNotDownloaded.length === 1 && (
<View>
<View className='items-start'>
<MediaSourceSelector
item={items[0]}
onChange={(val) =>
setSelectedOptions(
(prev) =>
prev && {
...prev,
mediaSource: val,
},
)
}
selected={selectedOptions?.mediaSource}
/>
</View>
<MediaSourceSelector
item={items[0]}
onChange={(val) =>
setSelectedOptions(
(prev) =>
prev && {
...prev,
mediaSource: val,
},
)
}
selected={selectedOptions?.mediaSource}
/>
{selectedOptions?.mediaSource && (
<View className='flex flex-col space-y-2 items-start'>
<View className='flex flex-col space-y-2'>
<AudioTrackSelector
source={selectedOptions.mediaSource}
onChange={(val) => {
@@ -430,7 +427,11 @@ export const DownloadItems: React.FC<DownloadProps> = ({
)}
</View>
<Button onPress={acceptDownloadOptions} color='purple'>
<Button
className='mt-auto'
onPress={acceptDownloadOptions}
color='purple'
>
{t("item_card.download.download_button")}
</Button>
</View>

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

@@ -22,7 +22,9 @@ export const Tag: React.FC<
> = ({ text, textClass, textStyle, ...props }) => {
return (
<View className='bg-neutral-800 rounded-full px-2 py-1' {...props}>
<Text className='text-white'>{text}</Text>
<Text className={textClass} style={textStyle}>
{text}
</Text>
</View>
);
};

View File

@@ -1,71 +0,0 @@
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
} from "@gorhom/bottom-sheet";
import { useCallback } from "react";
import { useGlobalModal } from "@/providers/GlobalModalProvider";
/**
* GlobalModal Component
*
* This component renders a global bottom sheet modal that can be controlled
* from anywhere in the app using the useGlobalModal hook.
*
* Place this component at the root level of your app (in _layout.tsx)
* after BottomSheetModalProvider.
*/
export const GlobalModal = () => {
const { hideModal, modalState, modalRef } = useGlobalModal();
const handleSheetChanges = useCallback(
(index: number) => {
if (index === -1) {
hideModal();
}
},
[hideModal],
);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const defaultOptions = {
enableDynamicSizing: true,
enablePanDownToClose: true,
backgroundStyle: {
backgroundColor: "#171717",
},
handleIndicatorStyle: {
backgroundColor: "white",
},
};
// Merge default options with provided options
const modalOptions = { ...defaultOptions, ...modalState.options };
return (
<BottomSheetModal
ref={modalRef}
{...(modalOptions.snapPoints
? { snapPoints: modalOptions.snapPoints }
: { enableDynamicSizing: modalOptions.enableDynamicSizing })}
onChange={handleSheetChanges}
backdropComponent={renderBackdrop}
handleIndicatorStyle={modalOptions.handleIndicatorStyle}
backgroundStyle={modalOptions.backgroundStyle}
enablePanDownToClose={modalOptions.enablePanDownToClose}
enableDismissOnClose
>
{modalState.content}
</BottomSheetModal>
);
};

View File

@@ -201,10 +201,10 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
}
>
<View className='flex flex-col bg-transparent shrink'>
<View className='flex flex-col px-4 w-full pt-2 mb-2 shrink'>
<View className='flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink'>
<ItemHeader item={item} className='mb-2' />
{item.Type !== "Program" && !Platform.isTV && !isOffline && (
<View className='flex flex-row items-center justify-start w-full h-16 mb-2'>
<View className='flex flex-row items-center justify-start w-full h-16'>
<BitrateSheet
className='mr-1'
onChange={(val) =>
@@ -287,21 +287,21 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
{item.Type !== "Program" && (
<>
{item.Type === "Episode" && !isOffline && (
<CurrentSeries item={item} className='' />
<CurrentSeries item={item} className='mb-4' />
)}
{!isOffline && (
<CastAndCrew item={item} className='' loading={loading} />
<CastAndCrew item={item} className='mb-4' loading={loading} />
)}
{item.People && item.People.length > 0 && !isOffline && (
<View className=''>
<View className='mb-4'>
{item.People.slice(0, 3).map((person, idx) => (
<MoreMoviesWithActor
currentItem={item}
key={idx}
actorId={person.Id!}
className=''
className='mb-4'
/>
))}
</View>

View File

@@ -29,7 +29,7 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source }) => {
<View className='px-4 mt-2 mb-4'>
<Text className='text-lg font-bold mb-4'>{t("item_card.video")}</Text>
<TouchableOpacity onPress={() => bottomSheetModalRef.current?.present()}>
<View className='flex flex-row mb-2'>
<View className='flex flex-row space-x-2'>
<VideoStreamInfo source={source} />
</View>
<Text className='text-purple-600'>{t("item_card.more_details")}</Text>

View File

@@ -2,11 +2,13 @@ import type {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useCallback, useMemo } from "react";
import { Platform, TouchableOpacity, View } from "react-native";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { useTranslation } from "react-i18next";
import { Text } from "./common/Text";
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
interface Props extends React.ComponentProps<typeof View> {
item: BaseItemDto;
@@ -21,7 +23,7 @@ export const MediaSourceSelector: React.FC<Props> = ({
...props
}) => {
const isTv = Platform.isTV;
const [open, setOpen] = useState(false);
const { t } = useTranslation();
const getDisplayName = useCallback((source: MediaSourceInfo) => {
@@ -44,60 +46,50 @@ export const MediaSourceSelector: React.FC<Props> = ({
return getDisplayName(selected);
}, [selected, getDisplayName]);
const optionGroups: OptionGroup[] = useMemo(
() => [
{
options:
item.MediaSources?.map((source) => ({
type: "radio" as const,
label: getDisplayName(source),
value: source,
selected: source.Id === selected?.Id,
onPress: () => onChange(source),
})) || [],
},
],
[item.MediaSources, selected, getDisplayName, onChange],
);
const handleOptionSelect = (optionId: string) => {
const selectedSource = item.MediaSources?.find(
(source, idx) => `${source.Id || idx}` === optionId,
);
if (selectedSource) {
onChange(selectedSource);
}
setOpen(false);
};
const trigger = (
<View className='flex flex-col' {...props}>
<Text className='opacity-50 mb-1 text-xs'>{t("item_card.video")}</Text>
<TouchableOpacity
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center'
onPress={() => setOpen(true)}
>
<Text numberOfLines={1}>{selectedName}</Text>
</TouchableOpacity>
</View>
);
if (isTv) return null;
return (
<PlatformDropdown
groups={optionGroups}
trigger={trigger}
title={t("item_card.video")}
open={open}
onOpenChange={setOpen}
onOptionSelect={handleOptionSelect}
expoUIConfig={{
hostStyle: { flex: 1 },
<View
className='flex shrink'
style={{
minWidth: 50,
}}
bottomSheetConfig={{
enablePanDownToClose: true,
}}
/>
>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className='flex flex-col' {...props}>
<Text className='opacity-50 mb-1 text-xs'>
{t("item_card.video")}
</Text>
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center'>
<Text numberOfLines={1}>{selectedName}</Text>
</TouchableOpacity>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side='bottom'
align='start'
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Media sources</DropdownMenu.Label>
{item.MediaSources?.map((source, idx: number) => (
<DropdownMenu.Item
key={idx.toString()}
onSelect={() => {
onChange(source);
}}
>
<DropdownMenu.ItemTitle>
{getDisplayName(source)}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
);
};

View File

@@ -82,7 +82,7 @@ export const MoreMoviesWithActor: React.FC<Props> = ({
<HorizontalScroll
data={items}
loading={isLoading}
height={218}
height={247}
renderItem={(item: BaseItemDto, idx: number) => (
<TouchableItemRouter
key={idx}

View File

@@ -1,323 +0,0 @@
import { Button, ContextMenu, Host, Picker } from "@expo/ui/swift-ui";
import { Ionicons } from "@expo/vector-icons";
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
import React, { useEffect } from "react";
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { useGlobalModal } from "@/providers/GlobalModalProvider";
// Option types
export type RadioOption<T = any> = {
type: "radio";
label: string;
value: T;
selected: boolean;
onPress: () => void;
disabled?: boolean;
};
export type ToggleOption = {
type: "toggle";
label: string;
value: boolean;
onToggle: () => void;
disabled?: boolean;
};
export type Option = RadioOption | ToggleOption;
// Option group structure
export type OptionGroup = {
title?: string;
options: Option[];
};
interface PlatformDropdownProps {
trigger?: React.ReactNode;
title?: string;
groups: OptionGroup[];
open?: boolean;
onOpenChange?: (open: boolean) => void;
onOptionSelect?: (value?: any) => void;
expoUIConfig?: {
hostStyle?: any;
};
bottomSheetConfig?: {
enableDynamicSizing?: boolean;
enablePanDownToClose?: boolean;
};
}
const ToggleSwitch: React.FC<{ value: boolean }> = ({ value }) => (
<View
className={`w-12 h-7 rounded-full ${value ? "bg-purple-600" : "bg-neutral-600"} flex-row items-center`}
>
<View
className={`w-5 h-5 rounded-full bg-white shadow-md transform transition-transform ${
value ? "translate-x-6" : "translate-x-1"
}`}
/>
</View>
);
const OptionItem: React.FC<{ option: Option; isLast?: boolean }> = ({
option,
isLast,
}) => {
const isToggle = option.type === "toggle";
const handlePress = isToggle ? option.onToggle : option.onPress;
return (
<>
<TouchableOpacity
onPress={handlePress}
disabled={option.disabled}
className={`px-4 py-3 flex flex-row items-center justify-between ${
option.disabled ? "opacity-50" : ""
}`}
>
<Text className='flex-1 text-white'>{option.label}</Text>
{isToggle ? (
<ToggleSwitch value={option.value} />
) : option.selected ? (
<Ionicons name='checkmark-circle' size={24} color='#9333ea' />
) : (
<Ionicons name='ellipse-outline' size={24} color='#6b7280' />
)}
</TouchableOpacity>
{!isLast && (
<View
style={{
height: StyleSheet.hairlineWidth,
}}
className='bg-neutral-700 mx-4'
/>
)}
</>
);
};
const OptionGroupComponent: React.FC<{ group: OptionGroup }> = ({ group }) => (
<View className='mb-6'>
{group.title && (
<Text className='text-lg font-semibold mb-3 text-neutral-300'>
{group.title}
</Text>
)}
<View
style={{
borderRadius: 12,
overflow: "hidden",
}}
className='bg-neutral-800 rounded-xl overflow-hidden'
>
{group.options.map((option, index) => (
<OptionItem
key={index}
option={option}
isLast={index === group.options.length - 1}
/>
))}
</View>
</View>
);
const BottomSheetContent: React.FC<{
title?: string;
groups: OptionGroup[];
onOptionSelect?: (value?: any) => void;
}> = ({ title, groups, onOptionSelect }) => {
const insets = useSafeAreaInsets();
// Wrap the groups to call onOptionSelect when an option is pressed
const wrappedGroups = groups.map((group) => ({
...group,
options: group.options.map((option) => {
if (option.type === "radio") {
return {
...option,
onPress: () => {
option.onPress();
onOptionSelect?.(option.value);
},
};
}
if (option.type === "toggle") {
return {
...option,
onToggle: () => {
option.onToggle();
onOptionSelect?.(option.value);
},
};
}
return option;
}),
}));
return (
<BottomSheetScrollView
className='px-4 pb-8 pt-2'
style={{
paddingLeft: Math.max(16, insets.left),
paddingRight: Math.max(16, insets.right),
}}
>
{title && <Text className='font-bold text-2xl mb-6'>{title}</Text>}
{wrappedGroups.map((group, index) => (
<OptionGroupComponent key={index} group={group} />
))}
</BottomSheetScrollView>
);
};
const PlatformDropdownComponent = ({
trigger,
title,
groups,
open,
onOpenChange,
onOptionSelect,
expoUIConfig,
bottomSheetConfig,
}: PlatformDropdownProps) => {
const { showModal, hideModal } = useGlobalModal();
const handlePress = () => {
if (Platform.OS === "android") {
onOpenChange?.(true);
showModal(
<BottomSheetContent
title={title}
groups={groups}
onOptionSelect={onOptionSelect}
/>,
{
snapPoints: ["90%"],
enablePanDownToClose: bottomSheetConfig?.enablePanDownToClose ?? true,
},
);
}
};
// Close modal when open prop changes to false
useEffect(() => {
if (Platform.OS === "android" && open === false) {
hideModal();
}
}, [open, hideModal]);
if (Platform.OS === "ios") {
return (
<Host style={expoUIConfig?.hostStyle}>
<ContextMenu>
<ContextMenu.Trigger>
<View className=''>
{trigger || <Button variant='bordered'>Show Menu</Button>}
</View>
</ContextMenu.Trigger>
<ContextMenu.Items>
{groups.flatMap((group, groupIndex) => {
// Check if this group has radio options
const radioOptions = group.options.filter(
(opt) => opt.type === "radio",
) as RadioOption[];
const toggleOptions = group.options.filter(
(opt) => opt.type === "toggle",
) as ToggleOption[];
const items = [];
// Add Picker for radio options ONLY if there's a group title
// Otherwise render as individual buttons
if (radioOptions.length > 0) {
if (group.title) {
// Use Picker for grouped options
items.push(
<Picker
key={`picker-${groupIndex}`}
label={group.title}
options={radioOptions.map((opt) => opt.label)}
variant='menu'
selectedIndex={radioOptions.findIndex(
(opt) => opt.selected,
)}
onOptionSelected={(event: any) => {
const index = event.nativeEvent.index;
const selectedOption = radioOptions[index];
selectedOption?.onPress();
onOptionSelect?.(selectedOption?.value);
}}
/>,
);
} else {
// Render radio options as direct buttons
radioOptions.forEach((option, optionIndex) => {
items.push(
<Button
key={`radio-${groupIndex}-${optionIndex}`}
systemImage={
option.selected ? "checkmark.circle.fill" : "circle"
}
onPress={() => {
option.onPress();
onOptionSelect?.(option.value);
}}
disabled={option.disabled}
>
{option.label}
</Button>,
);
});
}
}
// Add Buttons for toggle options
toggleOptions.forEach((option, optionIndex) => {
items.push(
<Button
key={`toggle-${groupIndex}-${optionIndex}`}
systemImage={
option.value ? "checkmark.circle.fill" : "circle"
}
onPress={() => {
option.onToggle();
onOptionSelect?.(option.value);
}}
disabled={option.disabled}
>
{option.label}
</Button>,
);
});
return items;
})}
</ContextMenu.Items>
</ContextMenu>
</Host>
);
}
// Android: Trigger button for bottom modal
return (
<TouchableOpacity onPress={handlePress}>
{trigger || <Text className='text-white'>Open Menu</Text>}
</TouchableOpacity>
);
};
// Memoize to prevent unnecessary re-renders when parent re-renders
export const PlatformDropdown = React.memo(
PlatformDropdownComponent,
(prevProps, nextProps) => {
// Custom comparison - only re-render if these props actually change
return (
prevProps.title === nextProps.title &&
prevProps.open === nextProps.open &&
prevProps.groups === nextProps.groups && // Reference equality (works because we memoize groups in caller)
prevProps.trigger === nextProps.trigger // Reference equality
);
},
);

View File

@@ -1,12 +1,11 @@
import { useActionSheet } from "@expo/react-native-action-sheet";
import { Button, Host } from "@expo/ui/swift-ui";
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useRouter } from "expo-router";
import { useAtom, useAtomValue } from "jotai";
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Alert, Platform, TouchableOpacity, View } from "react-native";
import { Alert, TouchableOpacity, View } from "react-native";
import CastContext, {
CastButton,
PlayServicesState,
@@ -34,9 +33,10 @@ import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { chromecast } from "@/utils/profiles/chromecast";
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
import { runtimeTicksToMinutes } from "@/utils/time";
import type { Button } from "./Button";
import type { SelectedOptions } from "./ItemContent";
interface Props extends React.ComponentProps<typeof TouchableOpacity> {
interface Props extends React.ComponentProps<typeof Button> {
item: BaseItemDto;
selectedOptions: SelectedOptions;
isOffline?: boolean;
@@ -364,46 +364,6 @@ export const PlayButton: React.FC<Props> = ({
* *********************
*/
if (Platform.OS === "ios")
return (
<Host
style={{
height: 50,
flex: 1,
}}
>
<Button
variant='glassProminent'
onPress={onPress}
color={effectiveColors.primary}
>
<View className='flex flex-row items-center gap-x-2 h-full w-full justify-center -mb-3.5 '>
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
{runtimeTicksToMinutes(item?.RunTimeTicks)}
</Animated.Text>
<Animated.Text style={animatedTextStyle}>
<Ionicons name='play-circle' size={24} />
</Animated.Text>
{client && (
<Animated.Text style={animatedTextStyle}>
<Feather name='cast' size={22} />
<CastButton tintColor='transparent' />
</Animated.Text>
)}
{!client && settings?.openInVLC && (
<Animated.Text style={animatedTextStyle}>
<MaterialCommunityIcons
name='vlc'
size={18}
color={animatedTextStyle.color}
/>
</Animated.Text>
)}
</View>
</Button>
</Host>
);
return (
<TouchableOpacity
disabled={!item}

View File

@@ -1,10 +1,12 @@
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMemo } from "react";
import { Platform, TouchableOpacity, View } from "react-native";
import { tc } from "@/utils/textTools";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { useTranslation } from "react-i18next";
import { Text } from "./common/Text";
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
interface Props extends React.ComponentProps<typeof View> {
source?: MediaSourceInfo;
@@ -19,8 +21,6 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
...props
}) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const subtitleStreams = useMemo(() => {
return source?.MediaStreams?.filter((x) => x.Type === "Subtitle");
}, [source]);
@@ -30,83 +30,64 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
[subtitleStreams, selected],
);
const optionGroups: OptionGroup[] = useMemo(() => {
const options = [
{
type: "radio" as const,
label: t("item_card.none"),
value: -1,
selected: selected === -1,
onPress: () => onChange(-1),
},
...(subtitleStreams?.map((subtitle, idx) => ({
type: "radio" as const,
label: subtitle.DisplayTitle || `Subtitle Stream ${idx + 1}`,
value: subtitle.Index,
selected: subtitle.Index === selected,
onPress: () => onChange(subtitle.Index ?? -1),
})) || []),
];
return [
{
options,
},
];
}, [subtitleStreams, selected, t, onChange]);
const handleOptionSelect = (optionId: string) => {
if (optionId === "none") {
onChange(-1);
} else {
const selectedStream = subtitleStreams?.find(
(subtitle, idx) => `${subtitle.Index || idx}` === optionId,
);
if (
selectedStream &&
selectedStream.Index !== undefined &&
selectedStream.Index !== null
) {
onChange(selectedStream.Index);
}
}
setOpen(false);
};
const trigger = (
<View className='flex flex-col' {...props}>
<Text numberOfLines={1} className='opacity-50 mb-1 text-xs'>
{t("item_card.subtitles")}
</Text>
<TouchableOpacity
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'
onPress={() => setOpen(true)}
>
<Text>
{selectedSubtitleSteam
? tc(selectedSubtitleSteam?.DisplayTitle, 7)
: t("item_card.none")}
</Text>
</TouchableOpacity>
</View>
);
if (Platform.isTV || subtitleStreams?.length === 0) return null;
return (
<PlatformDropdown
groups={optionGroups}
trigger={trigger}
title={t("item_card.subtitles")}
open={open}
onOpenChange={setOpen}
onOptionSelect={handleOptionSelect}
expoUIConfig={{
hostStyle: { flex: 1 },
<View
className='flex col shrink justify-start place-self-start items-start'
style={{
minWidth: 60,
maxWidth: 200,
}}
bottomSheetConfig={{
enablePanDownToClose: true,
}}
/>
>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className='flex flex-col ' {...props}>
<Text numberOfLines={1} className='opacity-50 mb-1 text-xs'>
{t("item_card.subtitles")}
</Text>
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
<Text className=' '>
{selectedSubtitleSteam
? tc(selectedSubtitleSteam?.DisplayTitle, 7)
: t("item_card.none")}
</Text>
</TouchableOpacity>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side='bottom'
align='start'
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Subtitle tracks</DropdownMenu.Label>
<DropdownMenu.Item
key={"-1"}
onSelect={() => {
onChange(-1);
}}
>
<DropdownMenu.ItemTitle>None</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
{subtitleStreams?.map((subtitle, idx: number) => (
<DropdownMenu.Item
key={idx.toString()}
onSelect={() => {
if (subtitle.Index !== undefined && subtitle.Index !== null)
onChange(subtitle.Index);
}}
>
<DropdownMenu.ItemTitle>
{subtitle.DisplayTitle}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
);
};

View File

@@ -1,12 +0,0 @@
import { View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
interface Props extends ViewProps {}
export const TitleHeader: React.FC<Props> = ({ ...props }) => {
return (
<View {...props}>
<Text />
</View>
);
};

View File

@@ -1,51 +0,0 @@
import { Button, Host } from "@expo/ui/swift-ui";
import { Ionicons } from "@expo/vector-icons";
import { Platform, View } from "react-native";
import { RoundButton } from "../RoundButton";
interface MarkAsPlayedLargeButtonProps {
isPlayed: boolean;
onToggle: (isPlayed: boolean) => void;
}
export const MarkAsPlayedLargeButton: React.FC<
MarkAsPlayedLargeButtonProps
> = ({ isPlayed, onToggle }) => {
if (Platform.OS === "ios")
return (
<Host
style={{
flex: 0,
width: 50,
height: 50,
justifyContent: "center",
alignItems: "center",
flexDirection: "row",
}}
>
<Button onPress={() => onToggle(isPlayed)} variant='glass'>
<View>
<Ionicons
name='checkmark'
size={24}
color='white'
style={{
marginTop: 6,
marginLeft: 1,
}}
/>
</View>
</Button>
</Host>
);
return (
<View>
<RoundButton
size='large'
icon={isPlayed ? "checkmark" : "checkmark"}
onPress={() => onToggle(isPlayed)}
/>
</View>
);
};

View File

@@ -0,0 +1,125 @@
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import {
type PropsWithChildren,
type ReactNode,
useEffect,
useState,
} from "react";
import { Platform, TouchableOpacity, View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import DisabledSetting from "@/components/settings/DisabledSetting";
interface Props<T> {
data: T[];
disabled?: boolean;
placeholderText?: string;
keyExtractor: (item: T) => string;
titleExtractor: (item: T) => string | undefined;
title: string | ReactNode;
label: string;
onSelected: (...item: T[]) => void;
multiple?: boolean;
}
const Dropdown = <T,>({
data,
disabled,
placeholderText,
keyExtractor,
titleExtractor,
title,
label,
onSelected,
multiple = false,
...props
}: PropsWithChildren<Props<T> & ViewProps>) => {
const isTv = Platform.isTV;
const [selected, setSelected] = useState<T[]>();
useEffect(() => {
if (selected !== undefined) {
onSelected(...selected);
}
}, [selected, onSelected]);
if (isTv) return null;
return (
<DisabledSetting disabled={disabled === true} showText={false} {...props}>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{typeof title === "string" ? (
<View className='flex flex-col'>
<Text className='opacity-50 mb-1 text-xs'>{title}</Text>
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
<Text style={{}} className='' numberOfLines={1}>
{selected?.length !== undefined
? selected.map(titleExtractor).join(",")
: placeholderText}
</Text>
</TouchableOpacity>
</View>
) : (
title
)}
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={false}
side='bottom'
align='center'
alignOffset={0}
avoidCollisions={true}
collisionPadding={0}
sideOffset={0}
>
<DropdownMenu.Label>{label}</DropdownMenu.Label>
{data.map((item, _idx) =>
multiple ? (
<DropdownMenu.CheckboxItem
value={
selected?.some((s) => keyExtractor(s) === keyExtractor(item))
? "on"
: "off"
}
key={keyExtractor(item)}
onValueChange={(
next: "on" | "off",
_previous: "on" | "off",
) => {
setSelected((p) => {
const prev = p || [];
if (next === "on") {
return [...prev, item];
}
return [
...prev.filter(
(p) => keyExtractor(p) !== keyExtractor(item),
),
];
});
}}
>
<DropdownMenu.ItemTitle>
{titleExtractor(item)}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
) : (
<DropdownMenu.Item
key={keyExtractor(item)}
onSelect={() => setSelected([item])}
>
<DropdownMenu.ItemTitle>
{titleExtractor(item)}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
),
)}
</DropdownMenu.Content>
</DropdownMenu.Root>
</DisabledSetting>
);
};
export default Dropdown;

View File

@@ -26,7 +26,7 @@ export const HeaderBackButton: React.FC<Props> = ({
className='flex items-center justify-center w-9 h-9'
{...touchableOpacityProps}
>
<Ionicons name='chevron-back' size={24} color='white' />
<Ionicons name='arrow-back' size={24} color='white' />
</TouchableOpacity>
);
}

View File

@@ -3,12 +3,17 @@ import React, { useImperativeHandle, useRef } from "react";
import { View, type ViewStyle } from "react-native";
import { Text } from "./Text";
type PartialExcept<T, K extends keyof T> = Partial<T> & Pick<T, K>;
export interface HorizontalScrollRef {
scrollToIndex: (index: number, viewOffset: number) => void;
}
interface HorizontalScrollProps<T>
extends Omit<FlashListProps<T>, "renderItem" | "data"> {
extends PartialExcept<
Omit<FlashListProps<T>, "renderItem">,
"estimatedItemSize"
> {
data?: T[] | null;
renderItem: (item: T, index: number) => React.ReactNode;
keyExtractor?: (item: T, index: number) => string;
@@ -39,7 +44,7 @@ export const HorizontalScroll = <T,>(
...restProps
} = props;
const flashListRef = useRef<React.ComponentRef<typeof FlashList<T>>>(null);
const flashListRef = useRef<FlashList<T>>(null);
useImperativeHandle(ref!, () => ({
scrollToIndex: (index: number, viewOffset: number) => {
@@ -73,6 +78,7 @@ export const HorizontalScroll = <T,>(
extraData={extraData}
renderItem={renderFlashListItem}
horizontal
estimatedItemSize={200}
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
paddingHorizontal: 16,

View File

@@ -1,8 +1,14 @@
import { useRouter, useSegments } from "expo-router";
import type React from "react";
import { type PropsWithChildren } from "react";
import { type PropsWithChildren, useCallback, useMemo } from "react";
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
import * as ContextMenu from "zeego/context-menu";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import {
hasPermission,
Permission,
} from "@/utils/jellyseerr/server/lib/permissions";
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
import type {
@@ -32,33 +38,90 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
}) => {
const router = useRouter();
const segments = useSegments();
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
const from = (segments as string[])[2] || "(home)";
const autoApprove = useMemo(() => {
return (
jellyseerrUser &&
hasPermission(Permission.AUTO_APPROVE, jellyseerrUser.permissions, {
type: "or",
})
);
}, [jellyseerrApi, jellyseerrUser]);
const request = useCallback(() => {
if (!result) return;
requestMedia(mediaTitle, {
mediaId: result.id,
mediaType,
});
}, [jellyseerrApi, result]);
if (from === "(home)" || from === "(search)" || from === "(libraries)")
return (
<TouchableOpacity
onPress={() => {
if (!result) return;
<ContextMenu.Root>
<ContextMenu.Trigger>
<TouchableOpacity
onPress={() => {
if (!result) return;
router.push({
pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`,
// @ts-expect-error
params: {
...result,
mediaTitle,
releaseYear,
canRequest: canRequest.toString(),
posterSrc,
mediaType,
},
});
}}
{...props}
>
{children}
</TouchableOpacity>
router.push({
pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`,
// @ts-expect-error
params: {
...result,
mediaTitle,
releaseYear,
canRequest: canRequest.toString(),
posterSrc,
mediaType,
},
});
}}
{...props}
>
{children}
</TouchableOpacity>
</ContextMenu.Trigger>
<ContextMenu.Content
avoidCollisions
alignOffset={0}
collisionPadding={0}
loop={false}
key={"content"}
>
<ContextMenu.Label key='label-1'>Actions</ContextMenu.Label>
{canRequest && mediaType === MediaType.MOVIE && (
<ContextMenu.Item
key='item-1'
onSelect={() => {
if (autoApprove) {
request();
}
}}
shouldDismissMenuOnSelect
>
<ContextMenu.ItemTitle key='item-1-title'>
Request
</ContextMenu.ItemTitle>
<ContextMenu.ItemIcon
ios={{
name: "arrow.down.to.line",
pointSize: 18,
weight: "semibold",
scale: "medium",
hierarchicalColor: {
dark: "purple",
light: "purple",
},
}}
androidIconName='download'
/>
</ContextMenu.Item>
)}
</ContextMenu.Content>
</ContextMenu.Root>
);
return null;
};

View File

@@ -1,29 +1,20 @@
import { Platform, Text as RNText, type TextProps } from "react-native";
interface CustomTextProps extends TextProps {
className?: string;
}
export function Text({ className, ...props }: CustomTextProps) {
export function Text(props: TextProps) {
const { style, ...otherProps } = props;
if (Platform.isTV)
return (
<RNText allowFontScaling={false} className={clsx(className)} {...props} />
<RNText
allowFontScaling={false}
style={[{ color: "white" }, style]}
{...otherProps}
/>
);
return (
<RNText allowFontScaling={false} className={clsx(className)} {...props} />
<RNText
allowFontScaling={false}
style={[{ color: "white" }, style]}
{...otherProps}
/>
);
}
const clsx = (className?: string) => {
const colorClassRegex = /\btext-[a-z]+-\d+\b/;
const hasColorClass = className ? colorClassRegex.test(className) : false;
const defaultClassName = "text-white";
const classes = [
...(hasColorClass ? [] : [defaultClassName]),
...(className ? [className] : []),
]
.filter(Boolean)
.join(" ");
return classes;
};

View File

@@ -26,7 +26,7 @@ export default function ActiveDownloads({ ...props }: ActiveDownloadsProps) {
<Text className='text-lg font-bold mb-2'>
{t("home.downloads.active_downloads")}
</Text>
<View className='gap-y-2'>
<View className='space-y-2'>
{processes?.map((p: JobStatus) => (
<DownloadCard key={p.item.Id} process={p} />
))}

View File

@@ -6,6 +6,7 @@ import { t } from "i18next";
import { useMemo } from "react";
import {
ActivityIndicator,
Platform,
TouchableOpacity,
type TouchableOpacityProps,
View,
@@ -13,10 +14,10 @@ import {
import { toast } from "sonner-native";
import { Text } from "@/components/common/Text";
import { useDownload } from "@/providers/DownloadProvider";
import { calculateSmoothedETA } from "@/providers/Downloads/hooks/useDownloadSpeedCalculator";
import { JobStatus } from "@/providers/Downloads/types";
import { storage } from "@/utils/mmkv";
import { formatTimeString } from "@/utils/time";
import { Button } from "../Button";
const bytesToMB = (bytes: number) => {
return bytes / 1024 / 1024;
@@ -27,10 +28,31 @@ interface DownloadCardProps extends TouchableOpacityProps {
}
export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
const { removeProcess } = useDownload();
const { startDownload, pauseDownload, resumeDownload, removeProcess } =
useDownload();
const router = useRouter();
const queryClient = useQueryClient();
const handlePause = async (id: string) => {
try {
await pauseDownload(id);
toast.success(t("home.downloads.toasts.download_paused"));
} catch (error) {
console.error("Error pausing download:", error);
toast.error(t("home.downloads.toasts.could_not_pause_download"));
}
};
const handleResume = async (id: string) => {
try {
await resumeDownload(id);
toast.success(t("home.downloads.toasts.download_resumed"));
} catch (error) {
console.error("Error resuming download:", error);
toast.error(t("home.downloads.toasts.could_not_resume_download"));
}
};
const handleDelete = async (id: string) => {
try {
await removeProcess(id);
@@ -42,23 +64,16 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
}
};
const eta = useMemo(() => {
if (!process.estimatedTotalSizeBytes || !process.bytesDownloaded) {
return null;
}
const eta = (p: JobStatus) => {
if (!p.speed || p.speed <= 0 || !p.estimatedTotalSizeBytes) return null;
const secondsRemaining = calculateSmoothedETA(
process.id,
process.bytesDownloaded,
process.estimatedTotalSizeBytes,
);
const bytesRemaining = p.estimatedTotalSizeBytes - (p.bytesDownloaded || 0);
if (bytesRemaining <= 0) return null;
if (!secondsRemaining || secondsRemaining <= 0) {
return null;
}
const secondsRemaining = bytesRemaining / p.speed;
return formatTimeString(secondsRemaining, "s");
}, [process.id, process.bytesDownloaded, process.estimatedTotalSizeBytes]);
};
const base64Image = useMemo(() => {
return storage.getString(process.item.Id!);
@@ -96,10 +111,26 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
)}
{/* Action buttons in bottom right corner */}
<View className='absolute bottom-2 right-2 flex flex-row items-center z-10'>
<View className='absolute bottom-2 right-2 flex flex-row items-center space-x-2 z-10'>
{process.status === "downloading" && Platform.OS !== "ios" && (
<TouchableOpacity
onPress={() => handlePause(process.id)}
className='p-1'
>
<Ionicons name='pause' size={20} color='white' />
</TouchableOpacity>
)}
{process.status === "paused" && Platform.OS !== "ios" && (
<TouchableOpacity
onPress={() => handleResume(process.id)}
className='p-1'
>
<Ionicons name='play' size={20} color='white' />
</TouchableOpacity>
)}
<TouchableOpacity
onPress={() => handleDelete(process.id)}
className='p-2 bg-neutral-800 rounded-full'
className='p-1'
>
<Ionicons name='close' size={20} color='red' />
</TouchableOpacity>
@@ -127,7 +158,7 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
<Text className='text-xs opacity-50'>
{process.item.ProductionYear}
</Text>
<View className='flex flex-row items-center gap-x-2 mt-1 text-purple-600'>
<View className='flex flex-row items-center space-x-2 mt-1 text-purple-600'>
{sanitizedProgress === 0 ? (
<ActivityIndicator size={"small"} color={"white"} />
) : (
@@ -138,18 +169,30 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
{bytesToMB(process.speed).toFixed(2)} MB/s
</Text>
)}
{eta && (
{eta(process) && (
<Text className='text-xs'>
{t("home.downloads.eta", { eta: eta })}
{t("home.downloads.eta", { eta: eta(process) })}
</Text>
)}
</View>
<View className='flex flex-row items-center gap-x-2 mt-1 text-purple-600'>
<View className='flex flex-row items-center space-x-2 mt-1 text-purple-600'>
<Text className='text-xs capitalize'>{process.status}</Text>
</View>
</View>
</View>
{process.status === "completed" && (
<View className='flex flex-row mt-4 space-x-4'>
<Button
onPress={() => {
startDownload(process);
}}
className='w-full'
>
Download now
</Button>
</View>
)}
</View>
</TouchableOpacity>
);

View File

@@ -13,13 +13,14 @@ export const DownloadSize: React.FC<DownloadSizeProps> = ({
items,
...props
}) => {
const { getDownloadedItemSize, downloadedItems } = useDownload();
const { getDownloadedItemSize, getDownloadedItems } = useDownload();
const downloadedFiles = getDownloadedItems();
const [size, setSize] = useState<string | undefined>();
const itemIds = useMemo(() => items.map((i) => i.Id), [items]);
useEffect(() => {
if (!downloadedItems) return;
if (!downloadedFiles) return;
let s = 0;
@@ -31,7 +32,7 @@ export const DownloadSize: React.FC<DownloadSizeProps> = ({
}
}
setSize(s.bytesToReadable());
}, [itemIds, downloadedItems, getDownloadedItemSize]);
}, [itemIds]);
const sizeText = useMemo(() => {
if (!size) return "...";

View File

@@ -28,7 +28,7 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
*/
const handleDeleteFile = useCallback(() => {
if (item.Id) {
deleteFile(item.Id);
deleteFile(item.Id, "Episode");
successHapticFeedback();
}
}, [deleteFile, item.Id]);

View File

@@ -19,13 +19,7 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
return storage.getString(items[0].SeriesId!);
}, []);
const deleteSeries = useCallback(
async () =>
deleteItems(
items.map((item) => item.Id).filter((id) => id !== undefined),
),
[items],
);
const deleteSeries = useCallback(async () => deleteItems(items), [items]);
const showActionSheet = useCallback(() => {
const options = ["Delete", "Cancel"];

View File

@@ -8,10 +8,10 @@ import type { BottomSheetModalMethods } from "@gorhom/bottom-sheet/lib/typescrip
import { useQuery } from "@tanstack/react-query";
import { forwardRef, useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { TouchableOpacity, View, type ViewProps } from "react-native";
import { View, type ViewProps } from "react-native";
import { Button } from "@/components/Button";
import Dropdown from "@/components/common/Dropdown";
import { Text } from "@/components/common/Text";
import { PlatformDropdown } from "@/components/PlatformDropdown";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import type {
QualityProfile,
@@ -138,115 +138,6 @@ const RequestModal = forwardRef<
});
}, [requestBody?.seasons]);
const pathTitleExtractor = (item: RootFolder) =>
`${item.path} (${item.freeSpace.bytesToReadable()})`;
const qualityProfileOptions = useMemo(
() => [
{
title: t("jellyseerr.quality_profile"),
options:
defaultServiceDetails?.profiles.map((profile) => ({
type: "radio" as const,
label: profile.name,
value: profile.id.toString(),
selected:
(requestOverrides.profileId || defaultProfile?.id) ===
profile.id,
onPress: () =>
setRequestOverrides((prev) => ({
...prev,
profileId: profile.id,
})),
})) || [],
},
],
[
defaultServiceDetails?.profiles,
defaultProfile,
requestOverrides.profileId,
t,
],
);
const rootFolderOptions = useMemo(
() => [
{
title: t("jellyseerr.root_folder"),
options:
defaultServiceDetails?.rootFolders.map((folder) => ({
type: "radio" as const,
label: pathTitleExtractor(folder),
value: folder.id.toString(),
selected:
(requestOverrides.rootFolder || defaultFolder?.path) ===
folder.path,
onPress: () =>
setRequestOverrides((prev) => ({
...prev,
rootFolder: folder.path,
})),
})) || [],
},
],
[
defaultServiceDetails?.rootFolders,
defaultFolder,
requestOverrides.rootFolder,
t,
],
);
const tagsOptions = useMemo(
() => [
{
title: t("jellyseerr.tags"),
options:
defaultServiceDetails?.tags.map((tag) => ({
type: "toggle" as const,
label: tag.label,
value:
requestOverrides.tags?.includes(tag.id) ||
defaultTags.some((dt) => dt.id === tag.id),
onToggle: () =>
setRequestOverrides((prev) => {
const currentTags = prev.tags || defaultTags.map((t) => t.id);
const hasTag = currentTags.includes(tag.id);
return {
...prev,
tags: hasTag
? currentTags.filter((id) => id !== tag.id)
: [...currentTags, tag.id],
};
}),
})) || [],
},
],
[defaultServiceDetails?.tags, defaultTags, requestOverrides.tags, t],
);
const usersOptions = useMemo(
() => [
{
title: t("jellyseerr.request_as"),
options:
users?.map((user) => ({
type: "radio" as const,
label: user.displayName,
value: user.id.toString(),
selected:
(requestOverrides.userId || jellyseerrUser?.id) === user.id,
onPress: () =>
setRequestOverrides((prev) => ({
...prev,
userId: user.id,
})),
})) || [],
},
],
[users, jellyseerrUser, requestOverrides.userId, t],
);
const request = useCallback(() => {
const body = {
is4k: defaultService?.is4k || defaultServiceDetails?.server.is4k,
@@ -272,6 +163,9 @@ const RequestModal = forwardRef<
defaultTags,
]);
const pathTitleExtractor = (item: RootFolder) =>
`${item.path} (${item.freeSpace.bytesToReadable()})`;
return (
<BottomSheetModal
ref={ref}
@@ -305,104 +199,70 @@ const RequestModal = forwardRef<
<View className='flex flex-col space-y-2'>
{defaultService && defaultServiceDetails && users && (
<>
<View className='flex flex-col'>
<Text className='opacity-50 mb-1 text-xs'>
{t("jellyseerr.quality_profile")}
</Text>
<PlatformDropdown
groups={qualityProfileOptions}
trigger={
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
<Text numberOfLines={1}>
{defaultServiceDetails.profiles.find(
(p) =>
p.id ===
(requestOverrides.profileId ||
defaultProfile?.id),
)?.name || defaultProfile?.name}
</Text>
</TouchableOpacity>
}
title={t("jellyseerr.quality_profile")}
/>
</View>
<View className='flex flex-col'>
<Text className='opacity-50 mb-1 text-xs'>
{t("jellyseerr.root_folder")}
</Text>
<PlatformDropdown
groups={rootFolderOptions}
trigger={
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
<Text numberOfLines={1}>
{defaultServiceDetails.rootFolders.find(
(f) =>
f.path ===
(requestOverrides.rootFolder ||
defaultFolder?.path),
)
? pathTitleExtractor(
defaultServiceDetails.rootFolders.find(
(f) =>
f.path ===
(requestOverrides.rootFolder ||
defaultFolder?.path),
)!,
)
: pathTitleExtractor(defaultFolder!)}
</Text>
</TouchableOpacity>
}
title={t("jellyseerr.root_folder")}
/>
</View>
<View className='flex flex-col'>
<Text className='opacity-50 mb-1 text-xs'>
{t("jellyseerr.tags")}
</Text>
<PlatformDropdown
groups={tagsOptions}
trigger={
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
<Text 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>
</TouchableOpacity>
}
title={t("jellyseerr.tags")}
/>
</View>
<View className='flex flex-col'>
<Text className='opacity-50 mb-1 text-xs'>
{t("jellyseerr.request_as")}
</Text>
<PlatformDropdown
groups={usersOptions}
trigger={
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
<Text numberOfLines={1}>
{users.find(
(u) =>
u.id ===
(requestOverrides.userId || jellyseerrUser?.id),
)?.displayName || jellyseerrUser!.displayName}
</Text>
</TouchableOpacity>
}
title={t("jellyseerr.request_as")}
/>
</View>
<Dropdown
data={defaultServiceDetails.profiles}
titleExtractor={(item) => item.name}
placeholderText={
requestOverrides.profileName || defaultProfile.name
}
keyExtractor={(item) => item.id.toString()}
label={t("jellyseerr.quality_profile")}
onSelected={(item) =>
item &&
setRequestOverrides((prev) => ({
...prev,
profileId: item?.id,
}))
}
title={t("jellyseerr.quality_profile")}
/>
<Dropdown
data={defaultServiceDetails.rootFolders}
titleExtractor={pathTitleExtractor}
placeholderText={
defaultFolder ? pathTitleExtractor(defaultFolder) : ""
}
keyExtractor={(item) => item.id.toString()}
label={t("jellyseerr.root_folder")}
onSelected={(item) =>
item &&
setRequestOverrides((prev) => ({
...prev,
rootFolder: item.path,
}))
}
title={t("jellyseerr.root_folder")}
/>
<Dropdown
multiple
data={defaultServiceDetails.tags}
titleExtractor={(item) => item.label}
placeholderText={defaultTags.map((t) => t.label).join(",")}
keyExtractor={(item) => item.id.toString()}
label={t("jellyseerr.tags")}
onSelected={(...selected) =>
setRequestOverrides((prev) => ({
...prev,
tags: selected.map((i) => i.id),
}))
}
title={t("jellyseerr.tags")}
/>
<Dropdown
data={users}
titleExtractor={(item) => item.displayName}
placeholderText={jellyseerrUser!.displayName}
keyExtractor={(item) => item.id.toString() || ""}
label={t("jellyseerr.request_as")}
onSelected={(item) =>
item &&
setRequestOverrides((prev) => ({
...prev,
userId: item?.id,
}))
}
title={t("jellyseerr.request_as")}
/>
</>
)}
</View>

View File

@@ -31,7 +31,7 @@ const Discover: React.FC<Props> = ({ sliders }) => {
if (!hasSliders) return null;
return (
<View className='flex flex-col gap-y-4 mb-8'>
<View className='flex flex-col space-y-4 mb-8'>
{sortedSliders.map((slide) => {
switch (slide.type) {
case DiscoverSliderType.RECENT_REQUESTS:

View File

@@ -27,23 +27,18 @@ export const ListGroup: React.FC<PropsWithChildren<Props>> = ({
{title}
</Text>
<View
style={{
borderRadius: 12,
}}
style={[]}
className='flex flex-col rounded-xl overflow-hidden pl-0 bg-neutral-900'
>
{Children.map(childrenArray, (child, index) => {
if (isValidElement<{ style?: ViewStyle }>(child)) {
const isLastItem = index === childrenArray.length - 1;
return cloneElement(child as any, {
...(isLastItem
? {}
: {
style: StyleSheet.compose(
child.props.style,
styles.borderBottom,
),
}),
style: StyleSheet.compose(
child.props.style,
index < childrenArray.length - 1
? styles.borderBottom
: undefined,
),
});
}
return child;

View File

@@ -107,7 +107,7 @@ const ListItemContent = ({
</Text>
{subtitle && (
<Text
className='text-neutral-500 text-[11px] mt-0.5'
className='text-[#9899A1] text-[12px] mt-0.5'
numberOfLines={2}
>
{subtitle}

View File

@@ -1,115 +0,0 @@
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";
interface DiscoverFiltersProps {
searchFilterId: string;
orderFilterId: string;
jellyseerrOrderBy: JellyseerrSearchSort;
setJellyseerrOrderBy: (value: JellyseerrSearchSort) => void;
jellyseerrSortOrder: "asc" | "desc";
setJellyseerrSortOrder: (value: "asc" | "desc") => void;
t: (key: string) => string;
}
const sortOptions = Object.keys(JellyseerrSearchSort).filter((v) =>
Number.isNaN(Number(v)),
);
const orderOptions = ["asc", "desc"] as const;
export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
searchFilterId,
orderFilterId,
jellyseerrOrderBy,
setJellyseerrOrderBy,
jellyseerrSortOrder,
setJellyseerrSortOrder,
t,
}) => {
if (Platform.OS === "ios") {
return (
<Host
style={{
justifyContent: "center",
alignItems: "center",
overflow: "visible",
height: 40,
width: 50,
marginLeft: "auto",
}}
>
<ContextMenu>
<ContextMenu.Trigger>
<Button
variant='glass'
modifiers={[]}
systemImage='line.3.horizontal.decrease.circle'
></Button>
</ContextMenu.Trigger>
<ContextMenu.Items>
<Picker
label={t("library.filters.sort_by")}
options={sortOptions.map((item) =>
t(`home.settings.plugins.jellyseerr.order_by.${item}`),
)}
variant='menu'
selectedIndex={sortOptions.indexOf(
jellyseerrOrderBy as unknown as string,
)}
onOptionSelected={(event: any) => {
const index = event.nativeEvent.index;
setJellyseerrOrderBy(
sortOptions[index] as unknown as JellyseerrSearchSort,
);
}}
/>
<Picker
label={t("library.filters.sort_order")}
options={orderOptions.map((item) => t(`library.filters.${item}`))}
variant='menu'
selectedIndex={orderOptions.indexOf(jellyseerrSortOrder)}
onOptionSelected={(event: any) => {
const index = event.nativeEvent.index;
setJellyseerrSortOrder(orderOptions[index]);
}}
/>
</ContextMenu.Items>
</ContextMenu>
</Host>
);
}
// Android UI
return (
<View className='flex flex-row justify-end items-center space-x-1'>
<FilterButton
id={searchFilterId}
queryKey='jellyseerr_search'
queryFn={async () =>
Object.keys(JellyseerrSearchSort).filter((v) =>
Number.isNaN(Number(v)),
)
}
set={(value) => setJellyseerrOrderBy(value[0])}
values={[jellyseerrOrderBy]}
title={t("library.filters.sort_by")}
renderItemLabel={(item) =>
t(`home.settings.plugins.jellyseerr.order_by.${item}`)
}
disableSearch={true}
/>
<FilterButton
id={orderFilterId}
queryKey='jellysearr_search'
queryFn={async () => ["asc", "desc"]}
set={(value) => setJellyseerrSortOrder(value[0])}
values={[jellyseerrSortOrder]}
title={t("library.filters.sort_order")}
renderItemLabel={(item) => t(`library.filters.${item}`)}
disableSearch={true}
/>
</View>
);
};

View File

@@ -1,76 +0,0 @@
import { Button, Host } from "@expo/ui/swift-ui";
import { Platform, TouchableOpacity, View } from "react-native";
import { Tag } from "@/components/GenreTags";
type SearchType = "Library" | "Discover";
interface SearchTabButtonsProps {
searchType: SearchType;
setSearchType: (type: SearchType) => void;
t: (key: string) => string;
}
export const SearchTabButtons: React.FC<SearchTabButtonsProps> = ({
searchType,
setSearchType,
t,
}) => {
if (Platform.OS === "ios") {
return (
<>
<Host
style={{
height: 40,
width: 80,
flexDirection: "row",
gap: 10,
justifyContent: "space-between",
}}
>
<Button
variant={searchType === "Library" ? "glassProminent" : "glass"}
onPress={() => setSearchType("Library")}
>
{t("search.library")}
</Button>
</Host>
<Host
style={{
height: 40,
width: 100,
flexDirection: "row",
gap: 10,
justifyContent: "space-between",
}}
>
<Button
variant={searchType === "Discover" ? "glassProminent" : "glass"}
onPress={() => setSearchType("Discover")}
>
{t("search.discover")}
</Button>
</Host>
</>
);
}
// Android UI
return (
<View className='flex flex-row gap-1 mr-1'>
<TouchableOpacity onPress={() => setSearchType("Library")}>
<Tag
text={t("search.library")}
textClass='p-1'
className={searchType === "Library" ? "bg-purple-600" : undefined}
/>
</TouchableOpacity>
<TouchableOpacity onPress={() => setSearchType("Discover")}>
<Tag
text={t("search.discover")}
textClass='p-1'
className={searchType === "Discover" ? "bg-purple-600" : undefined}
/>
</TouchableOpacity>
</View>
);
};

View File

@@ -25,7 +25,7 @@ export const CurrentSeries: React.FC<Props> = ({ item, ...props }) => {
</Text>
<HorizontalScroll
data={[item]}
height={218}
height={247}
renderItem={(item, _index) => (
<TouchableOpacity
key={item?.Id}
@@ -38,7 +38,7 @@ export const CurrentSeries: React.FC<Props> = ({ item, ...props }) => {
id={item?.Id}
url={getPrimaryImageUrlById({ api, id: item?.ParentId })}
/>
<Text className='mt-2'>{item?.SeriesName}</Text>
<Text>{item?.SeriesName}</Text>
</TouchableOpacity>
)}
/>

View File

@@ -1,9 +1,11 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { t } from "i18next";
import { useEffect, useMemo } from "react";
import { Platform, View } from "react-native";
import { Platform, TouchableOpacity, View } from "react-native";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { t } from "i18next";
import { Text } from "../common/Text";
import { PlatformDropdown } from "../PlatformDropdown";
type Props = {
item: BaseItemDto;
@@ -53,32 +55,6 @@ export const SeasonDropdown: React.FC<Props> = ({
[state, item, keys],
);
const sortByIndex = (a: BaseItemDto, b: BaseItemDto) =>
Number(a[keys.index]) - Number(b[keys.index]);
const optionGroups = useMemo(
() => [
{
title: t("item_card.seasons"),
options:
seasons?.sort(sortByIndex).map((season: any) => {
const title =
season[keys.title] ||
season.Name ||
`Season ${season.IndexNumber}`;
return {
type: "radio" as const,
label: title,
value: season.Id || season.IndexNumber,
selected: Number(season[keys.index]) === Number(seasonIndex),
onPress: () => onSelect(season),
};
}) || [],
},
],
[seasons, keys, seasonIndex, onSelect],
);
useEffect(() => {
if (isTv) return;
if (seasons && seasons.length > 0 && seasonIndex === undefined) {
@@ -120,19 +96,45 @@ export const SeasonDropdown: React.FC<Props> = ({
keys,
]);
const sortByIndex = (a: BaseItemDto, b: BaseItemDto) =>
Number(a[keys.index]) - Number(b[keys.index]);
if (isTv) return null;
return (
<PlatformDropdown
groups={optionGroups}
trigger={
<View className='bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between'>
<Text>
{t("item_card.season")} {seasonIndex}
</Text>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className='flex flex-row'>
<TouchableOpacity className='bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between'>
<Text>
{t("item_card.season")} {seasonIndex}
</Text>
</TouchableOpacity>
</View>
}
title={t("item_card.seasons")}
/>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side='bottom'
align='start'
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>{t("item_card.seasons")}</DropdownMenu.Label>
{seasons?.sort(sortByIndex).map((season: any) => {
const title =
season[keys.title] || season.Name || `Season ${season.IndexNumber}`;
return (
<DropdownMenu.Item
key={season.Id || season.IndexNumber}
onSelect={() => onSelect(season)}
>
<DropdownMenu.ItemTitle>{title}</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
);
})}
</DropdownMenu.Content>
</DropdownMenu.Root>
);
};

View File

@@ -28,7 +28,8 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
}) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { downloadedItems } = useDownload();
const { getDownloadedItems } = useDownload();
const downloadedFiles = getDownloadedItems();
const scrollRef = useRef<HorizontalScrollRef>(null);
@@ -41,10 +42,10 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
}, [item]);
const { data: episodes, isPending } = useQuery({
queryKey: ["episodes", seasonId, isOffline, downloadedItems],
queryKey: ["episodes", seasonId, isOffline],
queryFn: async () => {
if (isOffline) {
return downloadedItems
return downloadedFiles
?.filter(
(f) => f.item.Type === "Episode" && f.item.SeasonId === seasonId,
)

View File

@@ -1,12 +1,12 @@
import { useMemo } from "react";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { useTranslation } from "react-i18next";
import { Platform, View, type ViewProps } from "react-native";
import { Platform, TouchableOpacity, View, type ViewProps } from "react-native";
import { APP_LANGUAGES } from "@/i18n";
import { useSettings } from "@/utils/atoms/settings";
import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
import { PlatformDropdown } from "../PlatformDropdown";
interface Props extends ViewProps {}
@@ -15,31 +15,6 @@ export const AppLanguageSelector: React.FC<Props> = () => {
const { settings, updateSettings } = useSettings();
const { t } = useTranslation();
const optionGroups = useMemo(() => {
const options = [
{
type: "radio" as const,
label: t("home.settings.languages.system"),
value: "system",
selected: !settings?.preferedLanguage,
onPress: () => updateSettings({ preferedLanguage: undefined }),
},
...APP_LANGUAGES.map((lang) => ({
type: "radio" as const,
label: lang.label,
value: lang.value,
selected: lang.value === settings?.preferedLanguage,
onPress: () => updateSettings({ preferedLanguage: lang.value }),
})),
];
return [
{
options,
},
];
}, [settings?.preferedLanguage, t, updateSettings]);
if (isTv) return null;
if (!settings) return null;
@@ -47,19 +22,54 @@ export const AppLanguageSelector: React.FC<Props> = () => {
<View>
<ListGroup title={t("home.settings.languages.title")}>
<ListItem title={t("home.settings.languages.app_language")}>
<PlatformDropdown
groups={optionGroups}
trigger={
<View className='bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between'>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className='bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between'>
<Text>
{APP_LANGUAGES.find(
(l) => l.value === settings?.preferedLanguage,
)?.label || t("home.settings.languages.system")}
</Text>
</View>
}
title={t("home.settings.languages.title")}
/>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side='bottom'
align='start'
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>
{t("home.settings.languages.title")}
</DropdownMenu.Label>
<DropdownMenu.Item
key={"unknown"}
onSelect={() => {
updateSettings({
preferedLanguage: undefined,
});
}}
>
<DropdownMenu.ItemTitle>
{t("home.settings.languages.system")}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
{APP_LANGUAGES?.map((l) => (
<DropdownMenu.Item
key={l?.value ?? "unknown"}
onSelect={() => {
updateSettings({
preferedLanguage: l.value,
});
}}
>
<DropdownMenu.ItemTitle>{l.label}</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</ListItem>
</ListGroup>
</View>

View File

@@ -1,13 +1,14 @@
import { Platform, TouchableOpacity, View, type ViewProps } from "react-native";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { Ionicons } from "@expo/vector-icons";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Platform, View, type ViewProps } from "react-native";
import { Switch } from "react-native-gesture-handler";
import { useSettings } from "@/utils/atoms/settings";
import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
import { PlatformDropdown } from "../PlatformDropdown";
import { useMedia } from "./MediaContext";
interface Props extends ViewProps {}
@@ -21,39 +22,6 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
const cultures = media.cultures;
const { t } = useTranslation();
const optionGroups = useMemo(() => {
const options = [
{
type: "radio" as const,
label: t("home.settings.audio.none"),
value: "none",
selected: !settings?.defaultAudioLanguage,
onPress: () => updateSettings({ defaultAudioLanguage: null }),
},
...(cultures?.map((culture) => ({
type: "radio" as const,
label:
culture.DisplayName ||
culture.ThreeLetterISOLanguageName ||
"Unknown",
value:
culture.ThreeLetterISOLanguageName ||
culture.DisplayName ||
"unknown",
selected:
culture.ThreeLetterISOLanguageName ===
settings?.defaultAudioLanguage?.ThreeLetterISOLanguageName,
onPress: () => updateSettings({ defaultAudioLanguage: culture }),
})) || []),
];
return [
{
options,
},
];
}, [cultures, settings?.defaultAudioLanguage, t, updateSettings]);
if (isTv) return null;
if (!settings) return null;
@@ -80,10 +48,9 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
/>
</ListItem>
<ListItem title={t("home.settings.audio.audio_language")}>
<PlatformDropdown
groups={optionGroups}
trigger={
<View className='flex flex-row items-center justify-between py-3 pl-3'>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3 '>
<Text className='mr-1 text-[#8E8D91]'>
{settings?.defaultAudioLanguage?.DisplayName ||
t("home.settings.audio.none")}
@@ -93,10 +60,48 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
size={18}
color='#5A5960'
/>
</View>
}
title={t("home.settings.audio.language")}
/>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side='bottom'
align='start'
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>
{t("home.settings.audio.language")}
</DropdownMenu.Label>
<DropdownMenu.Item
key={"none-audio"}
onSelect={() => {
updateSettings({
defaultAudioLanguage: null,
});
}}
>
<DropdownMenu.ItemTitle>
{t("home.settings.audio.none")}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
{cultures?.map((l) => (
<DropdownMenu.Item
key={l?.ThreeLetterISOLanguageName ?? "unknown"}
onSelect={() => {
updateSettings({
defaultAudioLanguage: l,
});
}}
>
<DropdownMenu.ItemTitle>
{l.DisplayName}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</ListItem>
</ListGroup>
</View>

View File

@@ -2,6 +2,7 @@ import { Feather, Ionicons } from "@expo/vector-icons";
import type { Api } from "@jellyfin/sdk";
import type {
BaseItemDto,
BaseItemDtoQueryResult,
BaseItemKind,
} from "@jellyfin/sdk/lib/generated-client/models";
import {
@@ -37,7 +38,7 @@ import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus";
import { AppleTVCarousel } from "../apple-tv-carousel/AppleTVCarousel";
import { AppleTVCarousel } from "../AppleTVCarousel";
type ScrollingCollectionListSection = {
type: "ScrollingCollectionList";
@@ -72,7 +73,7 @@ export const HomeIndex = () => {
const scrollViewRef = useRef<ScrollView>(null);
const { downloadedItems, cleanCacheDirectory } = useDownload();
const { getDownloadedItems, cleanCacheDirectory } = useDownload();
const prevIsConnected = useRef<boolean | null>(false);
const {
isConnected,
@@ -90,11 +91,6 @@ export const HomeIndex = () => {
prevIsConnected.current = isConnected;
}, [isConnected, invalidateCache]);
const hasDownloads = useMemo(() => {
if (Platform.isTV) return false;
return downloadedItems.length > 0;
}, [downloadedItems]);
useEffect(() => {
if (Platform.isTV) {
navigation.setOptions({
@@ -102,6 +98,7 @@ export const HomeIndex = () => {
});
return;
}
const hasDownloads = getDownloadedItems().length > 0;
navigation.setOptions({
headerLeft: () => (
<TouchableOpacity
@@ -118,7 +115,7 @@ export const HomeIndex = () => {
</TouchableOpacity>
),
});
}, [navigation, router, hasDownloads]);
}, [navigation, router]);
useEffect(() => {
cleanCacheDirectory().catch((_e) =>
@@ -360,6 +357,16 @@ export const HomeIndex = () => {
});
return response.data || [];
}
if (section.custom) {
const response = await api.get<BaseItemDtoQueryResult>(
section.custom.endpoint,
{
params: { ...(section.custom.query || {}), userId: user?.Id },
headers: section.custom.headers || {},
},
);
return response.data.Items || [];
}
return [];
},
type: "ScrollingCollectionList",
@@ -472,7 +479,7 @@ export const HomeIndex = () => {
paddingBottom: 16,
}}
>
<View className='flex flex-col gap-4'>
<View className='flex flex-col space-y-4'>
{sections.map((section, index) => {
if (section.type === "ScrollingCollectionList") {
return (

View File

@@ -5,10 +5,10 @@ import { TFunction } from "i18next";
import type React from "react";
import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Linking, Platform, Switch, View } from "react-native";
import { Linking, Platform, Switch, TouchableOpacity } from "react-native";
import { toast } from "sonner-native";
import { BITRATES } from "@/components/BitrateSelector";
import { PlatformDropdown } from "@/components/PlatformDropdown";
import Dropdown from "@/components/common/Dropdown";
import DisabledSetting from "@/components/settings/DisabledSetting";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
@@ -89,52 +89,6 @@ export const OtherSettings: React.FC = () => {
[],
);
const orientationOptions = useMemo(
() => [
{
options: orientations.map((orientation) => ({
type: "radio" as const,
label: t(ScreenOrientationEnum[orientation]),
value: String(orientation),
selected: orientation === settings?.defaultVideoOrientation,
onPress: () =>
updateSettings({ defaultVideoOrientation: orientation }),
})),
},
],
[orientations, settings?.defaultVideoOrientation, t, updateSettings],
);
const bitrateOptions = useMemo(
() => [
{
options: BITRATES.map((bitrate) => ({
type: "radio" as const,
label: bitrate.key,
value: bitrate.key,
selected: bitrate.key === settings?.defaultBitrate?.key,
onPress: () => updateSettings({ defaultBitrate: bitrate }),
})),
},
],
[settings?.defaultBitrate?.key, t, updateSettings],
);
const autoPlayEpisodeOptions = useMemo(
() => [
{
options: AUTOPLAY_EPISODES_COUNT(t).map((item) => ({
type: "radio" as const,
label: item.key,
value: item.key,
selected: item.key === settings?.maxAutoPlayEpisodeCount?.key,
onPress: () => updateSettings({ maxAutoPlayEpisodeCount: item }),
})),
},
],
[settings?.maxAutoPlayEpisodeCount?.key, t, updateSettings],
);
if (!settings) return null;
return (
@@ -160,10 +114,16 @@ export const OtherSettings: React.FC = () => {
settings.followDeviceOrientation
}
>
<PlatformDropdown
groups={orientationOptions}
trigger={
<View className='flex flex-row items-center justify-between py-3 pl-3'>
<Dropdown
data={orientations}
disabled={
pluginSettings?.defaultVideoOrientation?.locked ||
settings.followDeviceOrientation
}
keyExtractor={String}
titleExtractor={(item) => t(ScreenOrientationEnum[item])}
title={
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{t(
orientationTranslations[
@@ -176,9 +136,12 @@ export const OtherSettings: React.FC = () => {
size={18}
color='#5A5960'
/>
</View>
</TouchableOpacity>
}
label={t("home.settings.other.orientation")}
onSelected={(defaultVideoOrientation) =>
updateSettings({ defaultVideoOrientation })
}
title={t("home.settings.other.orientation")}
/>
</ListItem>
@@ -251,10 +214,13 @@ export const OtherSettings: React.FC = () => {
title={t("home.settings.other.default_quality")}
disabled={pluginSettings?.defaultBitrate?.locked}
>
<PlatformDropdown
groups={bitrateOptions}
trigger={
<View className='flex flex-row items-center justify-between py-3 pl-3'>
<Dropdown
data={BITRATES}
disabled={pluginSettings?.defaultBitrate?.locked}
keyExtractor={(item) => item.key}
titleExtractor={(item) => item.key}
title={
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{settings.defaultBitrate?.key}
</Text>
@@ -263,9 +229,10 @@ export const OtherSettings: React.FC = () => {
size={18}
color='#5A5960'
/>
</View>
</TouchableOpacity>
}
title={t("home.settings.other.default_quality")}
label={t("home.settings.other.default_quality")}
onSelected={(defaultBitrate) => updateSettings({ defaultBitrate })}
/>
</ListItem>
<ListItem
@@ -281,10 +248,12 @@ export const OtherSettings: React.FC = () => {
/>
</ListItem>
<ListItem title={t("home.settings.other.max_auto_play_episode_count")}>
<PlatformDropdown
groups={autoPlayEpisodeOptions}
trigger={
<View className='flex flex-row items-center justify-between py-3 pl-3'>
<Dropdown
data={AUTOPLAY_EPISODES_COUNT(t)}
keyExtractor={(item) => item.key}
titleExtractor={(item) => item.key}
title={
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{t(settings?.maxAutoPlayEpisodeCount.key)}
</Text>
@@ -293,9 +262,12 @@ export const OtherSettings: React.FC = () => {
size={18}
color='#5A5960'
/>
</View>
</TouchableOpacity>
}
label={t("home.settings.other.max_auto_play_episode_count")}
onSelected={(maxAutoPlayEpisodeCount) =>
updateSettings({ maxAutoPlayEpisodeCount })
}
title={t("home.settings.other.max_auto_play_episode_count")}
/>
</ListItem>
</ListGroup>

View File

@@ -1,25 +1,23 @@
import { Platform, TouchableOpacity, View, type ViewProps } from "react-native";
const _DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { Ionicons } from "@expo/vector-icons";
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Platform, View, type ViewProps } from "react-native";
import { Switch } from "react-native-gesture-handler";
import Dropdown from "@/components/common/Dropdown";
import { Stepper } from "@/components/inputs/Stepper";
import {
OUTLINE_THICKNESS,
type OutlineThickness,
VLC_COLORS,
type VLCColor,
} from "@/constants/SubtitleConstants";
import { useSettings } from "@/utils/atoms/settings";
import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
import { PlatformDropdown } from "../PlatformDropdown";
import { useMedia } from "./MediaContext";
interface Props extends ViewProps {}
import { OUTLINE_THICKNESS, VLC_COLORS } from "@/constants/SubtitleConstants";
export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
const isTv = Platform.isTV;
@@ -29,6 +27,18 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
const cultures = media.cultures;
const { t } = useTranslation();
// Get VLC subtitle settings from the settings system
const textColor = settings?.vlcTextColor ?? "White";
const backgroundColor = settings?.vlcBackgroundColor ?? "Black";
const outlineColor = settings?.vlcOutlineColor ?? "Black";
const outlineThickness = settings?.vlcOutlineThickness ?? "Normal";
const backgroundOpacity = settings?.vlcBackgroundOpacity ?? 128;
const outlineOpacity = settings?.vlcOutlineOpacity ?? 255;
const isBold = settings?.vlcIsBold ?? false;
if (isTv) return null;
if (!settings) return null;
const subtitleModes = [
SubtitlePlaybackMode.Default,
SubtitlePlaybackMode.Smart,
@@ -46,133 +56,6 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
[SubtitlePlaybackMode.None]: "home.settings.subtitles.modes.None",
};
const subtitleLanguageOptionGroups = useMemo(() => {
const options = [
{
type: "radio" as const,
label: t("home.settings.subtitles.none"),
value: "none",
selected: !settings?.defaultSubtitleLanguage,
onPress: () => updateSettings({ defaultSubtitleLanguage: null }),
},
...(cultures?.map((culture) => ({
type: "radio" as const,
label: culture.DisplayName || "Unknown",
value:
culture.ThreeLetterISOLanguageName ||
culture.DisplayName ||
"unknown",
selected:
culture.ThreeLetterISOLanguageName ===
settings?.defaultSubtitleLanguage?.ThreeLetterISOLanguageName,
onPress: () => updateSettings({ defaultSubtitleLanguage: culture }),
})) || []),
];
return [
{
options,
},
];
}, [cultures, settings?.defaultSubtitleLanguage, t, updateSettings]);
const subtitleModeOptionGroups = useMemo(() => {
const options = subtitleModes.map((mode) => ({
type: "radio" as const,
label: t(subtitleModeKeys[mode]) || String(mode),
value: String(mode),
selected: mode === settings?.subtitleMode,
onPress: () => updateSettings({ subtitleMode: mode }),
}));
return [
{
options,
},
];
}, [settings?.subtitleMode, t, updateSettings]);
const textColorOptionGroups = useMemo(() => {
const colors = Object.keys(VLC_COLORS) as VLCColor[];
const options = colors.map((color) => ({
type: "radio" as const,
label: t(`home.settings.subtitles.colors.${color}`),
value: color,
selected: (settings?.vlcTextColor || "White") === color,
onPress: () => updateSettings({ vlcTextColor: color }),
}));
return [{ options }];
}, [settings?.vlcTextColor, t, updateSettings]);
const backgroundColorOptionGroups = useMemo(() => {
const colors = Object.keys(VLC_COLORS) as VLCColor[];
const options = colors.map((color) => ({
type: "radio" as const,
label: t(`home.settings.subtitles.colors.${color}`),
value: color,
selected: (settings?.vlcBackgroundColor || "Black") === color,
onPress: () => updateSettings({ vlcBackgroundColor: color }),
}));
return [{ options }];
}, [settings?.vlcBackgroundColor, t, updateSettings]);
const outlineColorOptionGroups = useMemo(() => {
const colors = Object.keys(VLC_COLORS) as VLCColor[];
const options = colors.map((color) => ({
type: "radio" as const,
label: t(`home.settings.subtitles.colors.${color}`),
value: color,
selected: (settings?.vlcOutlineColor || "Black") === color,
onPress: () => updateSettings({ vlcOutlineColor: color }),
}));
return [{ options }];
}, [settings?.vlcOutlineColor, t, updateSettings]);
const outlineThicknessOptionGroups = useMemo(() => {
const thicknesses = Object.keys(OUTLINE_THICKNESS) as OutlineThickness[];
const options = thicknesses.map((thickness) => ({
type: "radio" as const,
label: t(`home.settings.subtitles.thickness.${thickness}`),
value: thickness,
selected: (settings?.vlcOutlineThickness || "Normal") === thickness,
onPress: () => updateSettings({ vlcOutlineThickness: thickness }),
}));
return [{ options }];
}, [settings?.vlcOutlineThickness, t, updateSettings]);
const backgroundOpacityOptionGroups = useMemo(() => {
const opacities = [0, 32, 64, 96, 128, 160, 192, 224, 255];
const options = opacities.map((opacity) => ({
type: "radio" as const,
label: `${Math.round((opacity / 255) * 100)}%`,
value: opacity,
selected: (settings?.vlcBackgroundOpacity ?? 128) === opacity,
onPress: () => updateSettings({ vlcBackgroundOpacity: opacity }),
}));
return [{ options }];
}, [settings?.vlcBackgroundOpacity, updateSettings]);
const outlineOpacityOptionGroups = useMemo(() => {
const opacities = [0, 32, 64, 96, 128, 160, 192, 224, 255];
const options = opacities.map((opacity) => ({
type: "radio" as const,
label: `${Math.round((opacity / 255) * 100)}%`,
value: opacity,
selected: (settings?.vlcOutlineOpacity ?? 255) === opacity,
onPress: () => updateSettings({ vlcOutlineOpacity: opacity }),
}));
return [{ options }];
}, [settings?.vlcOutlineOpacity, updateSettings]);
if (isTv) return null;
if (!settings) return null;
return (
<View {...props}>
<ListGroup
@@ -184,10 +67,20 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
}
>
<ListItem title={t("home.settings.subtitles.subtitle_language")}>
<PlatformDropdown
groups={subtitleLanguageOptionGroups}
trigger={
<View className='flex flex-row items-center justify-between py-3 pl-3'>
<Dropdown
data={[
{
DisplayName: t("home.settings.subtitles.none"),
ThreeLetterISOLanguageName: "none-subs",
},
...(cultures ?? []),
]}
keyExtractor={(item) =>
item?.ThreeLetterISOLanguageName ?? "unknown"
}
titleExtractor={(item) => item?.DisplayName}
title={
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{settings?.defaultSubtitleLanguage?.DisplayName ||
t("home.settings.subtitles.none")}
@@ -197,9 +90,18 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
size={18}
color='#5A5960'
/>
</View>
</TouchableOpacity>
}
label={t("home.settings.subtitles.language")}
onSelected={(defaultSubtitleLanguage) =>
updateSettings({
defaultSubtitleLanguage:
defaultSubtitleLanguage.DisplayName ===
t("home.settings.subtitles.none")
? null
: defaultSubtitleLanguage,
})
}
title={t("home.settings.subtitles.language")}
/>
</ListItem>
@@ -207,10 +109,13 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
title={t("home.settings.subtitles.subtitle_mode")}
disabled={pluginSettings?.subtitleMode?.locked}
>
<PlatformDropdown
groups={subtitleModeOptionGroups}
trigger={
<View className='flex flex-row items-center justify-between py-3 pl-3'>
<Dropdown
data={subtitleModes}
disabled={pluginSettings?.subtitleMode?.locked}
keyExtractor={String}
titleExtractor={(item) => t(subtitleModeKeys[item]) || String(item)}
title={
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{t(subtitleModeKeys[settings?.subtitleMode]) ||
t("home.settings.subtitles.loading")}
@@ -220,9 +125,10 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
size={18}
color='#5A5960'
/>
</View>
</TouchableOpacity>
}
title={t("home.settings.subtitles.subtitle_mode")}
label={t("home.settings.subtitles.subtitle_mode")}
onSelected={(subtitleMode) => updateSettings({ subtitleMode })}
/>
</ListItem>
@@ -253,120 +159,144 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
/>
</ListItem>
<ListItem title={t("home.settings.subtitles.text_color")}>
<PlatformDropdown
groups={textColorOptionGroups}
trigger={
<View className='flex flex-row items-center justify-between py-3 pl-3'>
<Dropdown
data={Object.keys(VLC_COLORS)}
keyExtractor={(item) => item}
titleExtractor={(item) =>
t(`home.settings.subtitles.colors.${item}`)
}
title={
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{t(
`home.settings.subtitles.colors.${settings?.vlcTextColor || "White"}`,
)}
{t(`home.settings.subtitles.colors.${textColor}`)}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
</TouchableOpacity>
}
title={t("home.settings.subtitles.text_color")}
label={t("home.settings.subtitles.text_color")}
onSelected={(value) => updateSettings({ vlcTextColor: value })}
/>
</ListItem>
<ListItem title={t("home.settings.subtitles.background_color")}>
<PlatformDropdown
groups={backgroundColorOptionGroups}
trigger={
<View className='flex flex-row items-center justify-between py-3 pl-3'>
<Dropdown
data={Object.keys(VLC_COLORS)}
keyExtractor={(item) => item}
titleExtractor={(item) =>
t(`home.settings.subtitles.colors.${item}`)
}
title={
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{t(
`home.settings.subtitles.colors.${settings?.vlcBackgroundColor || "Black"}`,
)}
{t(`home.settings.subtitles.colors.${backgroundColor}`)}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
</TouchableOpacity>
}
label={t("home.settings.subtitles.background_color")}
onSelected={(value) =>
updateSettings({ vlcBackgroundColor: value })
}
title={t("home.settings.subtitles.background_color")}
/>
</ListItem>
<ListItem title={t("home.settings.subtitles.outline_color")}>
<PlatformDropdown
groups={outlineColorOptionGroups}
trigger={
<View className='flex flex-row items-center justify-between py-3 pl-3'>
<Dropdown
data={Object.keys(VLC_COLORS)}
keyExtractor={(item) => item}
titleExtractor={(item) =>
t(`home.settings.subtitles.colors.${item}`)
}
title={
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{t(
`home.settings.subtitles.colors.${settings?.vlcOutlineColor || "Black"}`,
)}
{t(`home.settings.subtitles.colors.${outlineColor}`)}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
</TouchableOpacity>
}
title={t("home.settings.subtitles.outline_color")}
label={t("home.settings.subtitles.outline_color")}
onSelected={(value) => updateSettings({ vlcOutlineColor: value })}
/>
</ListItem>
<ListItem title={t("home.settings.subtitles.outline_thickness")}>
<PlatformDropdown
groups={outlineThicknessOptionGroups}
trigger={
<View className='flex flex-row items-center justify-between py-3 pl-3'>
<Dropdown
data={Object.keys(OUTLINE_THICKNESS)}
keyExtractor={(item) => item}
titleExtractor={(item) =>
t(`home.settings.subtitles.thickness.${item}`)
}
title={
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{t(
`home.settings.subtitles.thickness.${settings?.vlcOutlineThickness || "Normal"}`,
)}
{t(`home.settings.subtitles.thickness.${outlineThickness}`)}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
</TouchableOpacity>
}
label={t("home.settings.subtitles.outline_thickness")}
onSelected={(value) =>
updateSettings({ vlcOutlineThickness: value })
}
title={t("home.settings.subtitles.outline_thickness")}
/>
</ListItem>
<ListItem title={t("home.settings.subtitles.background_opacity")}>
<PlatformDropdown
groups={backgroundOpacityOptionGroups}
trigger={
<View className='flex flex-row items-center justify-between py-3 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>{`${Math.round(((settings?.vlcBackgroundOpacity ?? 128) / 255) * 100)}%`}</Text>
<Dropdown
data={[0, 32, 64, 96, 128, 160, 192, 224, 255]}
keyExtractor={String}
titleExtractor={(item) => `${Math.round((item / 255) * 100)}%`}
title={
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>{`${Math.round((backgroundOpacity / 255) * 100)}%`}</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
</TouchableOpacity>
}
label={t("home.settings.subtitles.background_opacity")}
onSelected={(value) =>
updateSettings({ vlcBackgroundOpacity: value })
}
title={t("home.settings.subtitles.background_opacity")}
/>
</ListItem>
<ListItem title={t("home.settings.subtitles.outline_opacity")}>
<PlatformDropdown
groups={outlineOpacityOptionGroups}
trigger={
<View className='flex flex-row items-center justify-between py-3 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>{`${Math.round(((settings?.vlcOutlineOpacity ?? 255) / 255) * 100)}%`}</Text>
<Dropdown
data={[0, 32, 64, 96, 128, 160, 192, 224, 255]}
keyExtractor={String}
titleExtractor={(item) => `${Math.round((item / 255) * 100)}%`}
title={
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>{`${Math.round((outlineOpacity / 255) * 100)}%`}</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
</TouchableOpacity>
}
title={t("home.settings.subtitles.outline_opacity")}
label={t("home.settings.subtitles.outline_opacity")}
onSelected={(value) => updateSettings({ vlcOutlineOpacity: value })}
/>
</ListItem>
<ListItem title={t("home.settings.subtitles.bold_text")}>
<Switch
value={settings?.vlcIsBold ?? false}
value={isBold}
onValueChange={(value) => updateSettings({ vlcIsBold: value })}
/>
</ListItem>

View File

@@ -15,7 +15,7 @@ export const commonScreenOptions: ICommonScreenOptions = {
headerShown: true,
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerBlurEffect: Platform.OS === "ios" ? "none" : undefined,
headerBlurEffect: "none",
headerLeft: () => <HeaderBackButton />,
};

View File

@@ -55,8 +55,8 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
}
}, []);
const { downloadedItems } = useDownload();
const downloadedFiles = downloadedItems;
const { getDownloadedItems } = useDownload();
const downloadedFiles = getDownloadedItems();
const seasonIndex = seasonIndexState[item.ParentId ?? ""];

View File

@@ -111,7 +111,7 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
pointerEvents={showControls ? "auto" : "none"}
className={"flex flex-row w-full pt-2"}
>
<View className='mr-auto' pointerEvents='box-none'>
<View className='mr-auto'>
{!Platform.isTV && (!offline || !mediaSource?.TranscodingUrl) && (
<VideoProvider
getAudioTracks={getAudioTracks}
@@ -120,9 +120,7 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
setSubtitleTrack={setSubtitleTrack}
setSubtitleURL={setSubtitleURL}
>
<View pointerEvents='auto'>
<DropdownView />
</View>
<DropdownView />
</VideoProvider>
)}
</View>

View File

@@ -1,10 +1,8 @@
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 React, { useState } from "react";
import { Platform, TouchableOpacity } from "react-native";
import { Text } from "@/components/common/Text";
import { FilterSheet } from "@/components/filters/FilterSheet";
import { useHaptic } from "@/hooks/useHaptic";
export type ScaleFactor =
@@ -96,51 +94,56 @@ export const ScaleFactorSelector: React.FC<ScaleFactorSelectorProps> = ({
disabled = false,
}) => {
const lightHapticFeedback = useHaptic("light");
const [open, setOpen] = useState(false);
// Hide on TV platforms
if (Platform.isTV) return null;
const handleScaleSelect = (scale: ScaleFactor) => {
onScaleChange(scale);
lightHapticFeedback();
};
const optionGroups = useMemo<OptionGroup[]>(() => {
return [
{
options: SCALE_FACTOR_OPTIONS.map((option) => ({
type: "radio" as const,
label: option.label,
value: option.id,
selected: option.id === currentScale,
onPress: () => handleScaleSelect(option.id),
disabled,
})),
},
];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentScale, 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='search-outline' size={24} color='white' />
</View>
),
[disabled],
const currentOption = SCALE_FACTOR_OPTIONS.find(
(option) => option.id === currentScale,
);
// Hide on TV platforms
if (Platform.isTV) return null;
return (
<PlatformDropdown
title='Scale Factor'
groups={optionGroups}
trigger={trigger}
bottomSheetConfig={{
enablePanDownToClose: true,
}}
/>
<>
<TouchableOpacity
disabled={disabled}
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
style={{ opacity: disabled ? 0.5 : 1 }}
onPress={() => setOpen(true)}
>
<Ionicons name='search-outline' size={24} color='white' />
</TouchableOpacity>
<FilterSheet
open={open}
setOpen={setOpen}
title='Scale Factor'
data={SCALE_FACTOR_OPTIONS}
values={currentOption ? [currentOption] : []}
multiple={false}
searchFilter={(item, query) => {
const option = item as ScaleFactorOption;
return (
option.label.toLowerCase().includes(query.toLowerCase()) ||
option.description.toLowerCase().includes(query.toLowerCase())
);
}}
renderItemLabel={(item) => {
const option = item as ScaleFactorOption;
return <Text>{option.label}</Text>;
}}
set={(vals) => {
const chosen = vals[0] as ScaleFactorOption | undefined;
if (chosen) {
handleScaleSelect(chosen.id);
}
}}
/>
</>
);
};

View File

@@ -1,10 +1,8 @@
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 React, { useState } from "react";
import { Platform, TouchableOpacity } from "react-native";
import { Text } from "@/components/common/Text";
import { FilterSheet } from "@/components/filters/FilterSheet";
import { useHaptic } from "@/hooks/useHaptic";
export type AspectRatio = "default" | "16:9" | "4:3" | "1:1" | "21:9";
@@ -55,51 +53,56 @@ export const AspectRatioSelector: React.FC<AspectRatioSelectorProps> = ({
disabled = false,
}) => {
const lightHapticFeedback = useHaptic("light");
const [open, setOpen] = useState(false);
// Hide on TV platforms
if (Platform.isTV) return null;
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],
const currentOption = ASPECT_RATIO_OPTIONS.find(
(option) => option.id === currentRatio,
);
// Hide on TV platforms
if (Platform.isTV) return null;
return (
<PlatformDropdown
title='Aspect Ratio'
groups={optionGroups}
trigger={trigger}
bottomSheetConfig={{
enablePanDownToClose: true,
}}
/>
<>
<TouchableOpacity
disabled={disabled}
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
style={{ opacity: disabled ? 0.5 : 1 }}
onPress={() => setOpen(true)}
>
<Ionicons name='crop-outline' size={24} color='white' />
</TouchableOpacity>
<FilterSheet
open={open}
setOpen={setOpen}
title='Aspect Ratio'
data={ASPECT_RATIO_OPTIONS}
values={currentOption ? [currentOption] : []}
multiple={false}
searchFilter={(item, query) => {
const option = item as AspectRatioOption;
return (
option.label.toLowerCase().includes(query.toLowerCase()) ||
option.description.toLowerCase().includes(query.toLowerCase())
);
}}
renderItemLabel={(item) => {
const option = item as AspectRatioOption;
return <Text>{option.label}</Text>;
}}
set={(vals) => {
const chosen = vals[0] as AspectRatioOption | undefined;
if (chosen) {
handleRatioSelect(chosen.id);
}
}}
/>
</>
);
};

View File

@@ -1,12 +1,16 @@
import { Ionicons } from "@expo/vector-icons";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useCallback, useMemo, useRef } from "react";
import { Platform, View } from "react-native";
import { BITRATES } from "@/components/BitrateSelector";
import {
type OptionGroup,
PlatformDropdown,
} from "@/components/PlatformDropdown";
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetScrollView,
} from "@gorhom/bottom-sheet";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { BITRATES } from "@/components/BitrateSelector";
import { Text } from "@/components/common/Text";
import { useControlContext } from "../contexts/ControlContext";
import { useVideoContext } from "../contexts/VideoContext";
@@ -19,6 +23,10 @@ const DropdownView = () => {
ControlContext?.mediaSource,
];
const router = useRouter();
const insets = useSafeAreaInsets();
const [open, setOpen] = useState(false);
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const snapPoints = useMemo(() => ["75%"], []);
const { subtitleIndex, audioIndex, bitrateValue, playbackPosition, offline } =
useLocalSearchParams<{
@@ -31,127 +39,248 @@ const DropdownView = () => {
offline: string;
}>();
// Use ref to track playbackPosition without causing re-renders
const playbackPositionRef = useRef(playbackPosition);
playbackPositionRef.current = playbackPosition;
const isOffline = offline === "true";
// Stabilize IDs to prevent unnecessary recalculations
const itemIdRef = useRef(item.Id);
const mediaSourceIdRef = useRef(mediaSource?.Id);
itemIdRef.current = item.Id;
mediaSourceIdRef.current = mediaSource?.Id;
const changeBitrate = useCallback(
(bitrate: string) => {
const queryParams = new URLSearchParams({
itemId: itemIdRef.current ?? "",
itemId: item.Id ?? "",
audioIndex: audioIndex?.toString() ?? "",
subtitleIndex: subtitleIndex?.toString() ?? "",
mediaSourceId: mediaSourceIdRef.current ?? "",
subtitleIndex: subtitleIndex.toString() ?? "",
mediaSourceId: mediaSource?.Id ?? "",
bitrateValue: bitrate.toString(),
playbackPosition: playbackPositionRef.current,
playbackPosition: playbackPosition,
}).toString();
router.replace(`player/direct-player?${queryParams}` as any);
},
[audioIndex, subtitleIndex, router],
[item, mediaSource, subtitleIndex, audioIndex, playbackPosition],
);
// Create stable identifiers for tracks
const subtitleTracksKey = useMemo(
() => subtitleTracks?.map((t) => `${t.index}-${t.name}`).join(",") ?? "",
[subtitleTracks],
);
const audioTracksKey = useMemo(
() => audioTracks?.map((t) => `${t.index}-${t.name}`).join(",") ?? "",
[audioTracks],
);
// Transform sections into OptionGroup format
const optionGroups = useMemo<OptionGroup[]>(() => {
const groups: OptionGroup[] = [];
// Quality Section
if (!isOffline) {
groups.push({
title: "Quality",
options:
BITRATES?.map((bitrate) => ({
type: "radio" as const,
label: bitrate.key,
value: bitrate.value?.toString() ?? "",
selected: bitrateValue === (bitrate.value?.toString() ?? ""),
onPress: () => changeBitrate(bitrate.value?.toString() ?? ""),
})) || [],
});
const handleSheetChanges = useCallback((index: number) => {
if (index === -1) {
setOpen(false);
}
}, []);
// Subtitle Section
if (subtitleTracks && subtitleTracks.length > 0) {
groups.push({
title: "Subtitles",
options: subtitleTracks.map((sub) => ({
type: "radio" as const,
label: sub.name,
value: sub.index.toString(),
selected: subtitleIndex === sub.index.toString(),
onPress: () => sub.setTrack(),
})),
});
}
// Audio Section
if (audioTracks && audioTracks.length > 0) {
groups.push({
title: "Audio",
options: audioTracks.map((track) => ({
type: "radio" as const,
label: track.name,
value: track.index.toString(),
selected: audioIndex === track.index.toString(),
onPress: () => track.setTrack(),
})),
});
}
return groups;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
isOffline,
bitrateValue,
changeBitrate,
subtitleTracksKey,
audioTracksKey,
subtitleIndex,
audioIndex,
// Note: subtitleTracks and audioTracks are intentionally excluded
// because we use subtitleTracksKey and audioTracksKey for stability
]);
// Memoize the trigger to prevent re-renders
const trigger = useMemo(
() => (
<View className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'>
<Ionicons name='ellipsis-horizontal' size={24} color={"white"} />
</View>
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const handleOpen = () => {
setOpen(true);
bottomSheetModalRef.current?.present();
};
const handleClose = () => {
setOpen(false);
bottomSheetModalRef.current?.dismiss();
};
useEffect(() => {
if (open) bottomSheetModalRef.current?.present();
else bottomSheetModalRef.current?.dismiss();
}, [open]);
// Hide on TV platforms
if (Platform.isTV) return null;
return (
<PlatformDropdown
title='Playback Options'
groups={optionGroups}
trigger={trigger}
bottomSheetConfig={{
enablePanDownToClose: true,
}}
/>
<>
<TouchableOpacity
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
onPress={handleOpen}
>
<Ionicons name='ellipsis-horizontal' size={24} color={"white"} />
</TouchableOpacity>
<BottomSheetModal
ref={bottomSheetModalRef}
index={0}
snapPoints={snapPoints}
onChange={handleSheetChanges}
backdropComponent={renderBackdrop}
handleIndicatorStyle={{
backgroundColor: "white",
}}
backgroundStyle={{
backgroundColor: "#171717",
}}
>
<BottomSheetScrollView
style={{
flex: 1,
}}
>
<View
className='mt-2 mb-8'
style={{
paddingLeft: Math.max(16, insets.left),
paddingRight: Math.max(16, insets.right),
}}
>
<Text className='font-bold text-2xl mb-6'>Playback Options</Text>
{/* Quality Section */}
{!isOffline && (
<View className='mb-6'>
<Text className='font-semibold text-lg mb-3 text-neutral-300'>
Quality
</Text>
<View
style={{
borderRadius: 20,
overflow: "hidden",
}}
className='flex flex-col rounded-xl overflow-hidden'
>
{BITRATES?.map((bitrate, idx: number) => (
<View key={`quality-item-${idx}`}>
<TouchableOpacity
onPress={() => {
changeBitrate(bitrate.value?.toString() ?? "");
setTimeout(() => handleClose(), 250);
}}
className='bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between'
>
<Text className='flex shrink'>{bitrate.key}</Text>
{bitrateValue === (bitrate.value?.toString() ?? "") ? (
<Ionicons
name='radio-button-on'
size={24}
color='white'
/>
) : (
<Ionicons
name='radio-button-off'
size={24}
color='white'
/>
)}
</TouchableOpacity>
{idx < BITRATES.length - 1 && (
<View
style={{
height: StyleSheet.hairlineWidth,
}}
className='bg-neutral-700'
/>
)}
</View>
))}
</View>
</View>
)}
{/* Subtitle Section */}
<View className='mb-6'>
<Text className='font-semibold text-lg mb-3 text-neutral-300'>
Subtitles
</Text>
<View
style={{
borderRadius: 20,
overflow: "hidden",
}}
className='flex flex-col rounded-xl overflow-hidden'
>
{subtitleTracks?.map((sub, idx: number) => (
<View key={`subtitle-item-${idx}`}>
<TouchableOpacity
onPress={() => {
sub.setTrack();
setTimeout(() => handleClose(), 250);
}}
className='bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between'
>
<Text className='flex shrink'>{sub.name}</Text>
{subtitleIndex === sub.index.toString() ? (
<Ionicons
name='radio-button-on'
size={24}
color='white'
/>
) : (
<Ionicons
name='radio-button-off'
size={24}
color='white'
/>
)}
</TouchableOpacity>
{idx < (subtitleTracks?.length ?? 0) - 1 && (
<View
style={{
height: StyleSheet.hairlineWidth,
}}
className='bg-neutral-700'
/>
)}
</View>
))}
</View>
</View>
{/* Audio Section */}
{(audioTracks?.length ?? 0) > 0 && (
<View className='mb-6'>
<Text className='font-semibold text-lg mb-3 text-neutral-300'>
Audio
</Text>
<View
style={{
borderRadius: 20,
overflow: "hidden",
}}
className='flex flex-col rounded-xl overflow-hidden'
>
{audioTracks?.map((track, idx: number) => (
<View key={`audio-item-${idx}`}>
<TouchableOpacity
onPress={() => {
track.setTrack();
setTimeout(() => handleClose(), 250);
}}
className='bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between'
>
<Text className='flex shrink'>{track.name}</Text>
{audioIndex === track.index.toString() ? (
<Ionicons
name='radio-button-on'
size={24}
color='white'
/>
) : (
<Ionicons
name='radio-button-off'
size={24}
color='white'
/>
)}
</TouchableOpacity>
{idx < (audioTracks?.length ?? 0) - 1 && (
<View
style={{
height: StyleSheet.hairlineWidth,
}}
className='bg-neutral-700'
/>
)}
</View>
))}
</View>
</View>
)}
</View>
</BottomSheetScrollView>
</BottomSheetModal>
</>
);
};

View File

@@ -13,7 +13,7 @@ export const useControlsTimeout = ({
isSliding,
episodeView,
onHideControls,
timeout = 10000,
timeout = 4000,
}: UseControlsTimeoutProps) => {
const controlsTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

View File

@@ -4,6 +4,17 @@
},
"build": {
"development": {
"resourceClass": "medium",
"cache": {
"key": "dev-{{ checksum \"bun.lock\" \"app.config.js\" }}",
"paths": [
"~/.bun/install/cache",
"node_modules",
".expo",
"android/.gradle",
"ios/Pods"
]
},
"environment": "development",
"developmentClient": true,
"distribution": "internal",
@@ -15,6 +26,11 @@
}
},
"development_tv": {
"resourceClass": "medium",
"cache": {
"key": "development-tv-{{ checksum \"bun.lock\" \"package.json\" }}",
"paths": ["~/.bun/install/cache", "node_modules", ".expo"]
},
"environment": "development",
"developmentClient": true,
"distribution": "internal",
@@ -27,6 +43,11 @@
}
},
"development-simulator": {
"resourceClass": "medium",
"cache": {
"key": "development-simulator-{{ checksum \"bun.lock\" \"package.json\" }}",
"paths": ["~/.bun/install/cache", "node_modules", ".expo"]
},
"environment": "development",
"developmentClient": true,
"distribution": "internal",
@@ -38,19 +59,57 @@
}
},
"preview": {
"resourceClass": "large",
"cache": {
"key": "preview-{{ checksum \"bun.lock\" \"app.config.js\" }}",
"paths": [
"~/.bun/install/cache",
"node_modules",
".expo",
"android/.gradle",
"ios/Pods",
".next"
]
},
"distribution": "internal",
"env": {
"EXPO_OPTIMIZE_BUNDLE_SIZE": "1",
"NODE_ENV": "production",
"EXPO_PUBLIC_WRITE_DEBUG": "1"
}
},
"production": {
"resourceClass": "large",
"cache": {
"key": "production-{{ checksum \"bun.lock\" \"app.config.js\" }}",
"paths": [
"~/.bun/install/cache",
"node_modules",
".expo",
"android/.gradle",
"ios/Pods"
]
},
"environment": "production",
"channel": "0.39.0",
"android": {
"buildType": "app-bundle",
"image": "latest"
},
"ios": {
"image": "latest"
},
"env": {
"EXPO_OPTIMIZE_BUNDLE_SIZE": "1",
"NODE_ENV": "production"
}
},
"production-apk": {
"resourceClass": "large",
"cache": {
"key": "production-apk-{{ checksum \"bun.lock\" \"package.json\" }}",
"paths": ["~/.bun/install/cache", "node_modules", ".expo"]
},
"environment": "production",
"channel": "0.39.0",
"android": {
@@ -59,6 +118,11 @@
}
},
"production-apk-tv": {
"resourceClass": "large",
"cache": {
"key": "production-apk-tv-{{ checksum \"bun.lock\" \"package.json\" }}",
"paths": ["~/.bun/install/cache", "node_modules", ".expo"]
},
"environment": "production",
"channel": "0.39.0",
"android": {

View File

@@ -1,5 +0,0 @@
@import "tailwindcss/theme.css" layer(theme);
@import "tailwindcss/preflight.css" layer(base);
@import "tailwindcss/utilities.css";
@import "nativewind/theme";

View File

@@ -66,8 +66,8 @@ const JELLYSEERR_USER = "JELLYSEERR_USER";
const JELLYSEERR_COOKIES = "JELLYSEERR_COOKIES";
export const clearJellyseerrStorageData = () => {
storage.remove(JELLYSEERR_USER);
storage.remove(JELLYSEERR_COOKIES);
storage.delete(JELLYSEERR_USER);
storage.delete(JELLYSEERR_COOKIES);
};
export enum Endpoints {

View File

@@ -69,7 +69,7 @@ export const usePlaybackManager = ({
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const { isConnected } = useNetworkStatus();
const { getDownloadedItemById, updateDownloadedItem, downloadedItems } =
const { getDownloadedItemById, updateDownloadedItem, getDownloadedItems } =
useDownload();
/** Whether the device is online. actually it's connected to the internet. */
@@ -77,20 +77,14 @@ export const usePlaybackManager = ({
// Adjacent episodes logic
const { data: adjacentItems } = useQuery({
queryKey: [
"adjacentItems",
item?.Id,
item?.SeriesId,
isOffline,
downloadedItems,
],
queryKey: ["adjacentItems", item?.Id, item?.SeriesId, isOffline],
queryFn: async (): Promise<BaseItemDto[] | null> => {
if (!item || !item.SeriesId) {
return null;
}
if (isOffline) {
return getOfflineAdjacentItems(item, downloadedItems || []);
return getOfflineAdjacentItems(item, getDownloadedItems() || []);
}
if (!api) {

View File

@@ -1,6 +1,7 @@
import { getLocales } from "expo-localization";
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import ar from "./translations/ar.json";
import ca from "./translations/ca.json";
import da from "./translations/da.json";
import de from "./translations/de.json";
@@ -9,6 +10,7 @@ import eo from "./translations/eo.json";
import es from "./translations/es.json";
import fi from "./translations/fi.json";
import fr from "./translations/fr.json";
import hu from "./translations/hu.json";
import it from "./translations/it.json";
import ja from "./translations/ja.json";
import nb from "./translations/nb.json";
@@ -29,6 +31,7 @@ import zhTW from "./translations/zh-TW.json";
export const APP_LANGUAGES = [
{ label: "Catalan", value: "ca" },
{ label: "العربية", value: "ar" },
{ label: "Dansk", value: "da" },
{ label: "Deutsch", value: "de" },
{ label: "English", value: "en" },
@@ -39,6 +42,7 @@ export const APP_LANGUAGES = [
{ label: "日本語", value: "ja" },
{ label: "Klingon", value: "tlh" },
{ label: "Türkçe", value: "tr" },
{ label: "Magyar", value: "hu" },
{ label: "Nederlands", value: "nl" },
{ label: "Polski", value: "pl" },
{ label: "Português (Brasil)", value: "pt-BR" },
@@ -59,12 +63,14 @@ i18n.use(initReactI18next).init({
compatibilityJSON: "v4",
resources: {
ca: { translation: ca },
ar: { translation: ar },
da: { translation: da },
de: { translation: de },
en: { translation: en },
es: { translation: es },
eo: { translation: eo },
fr: { translation: fr },
hu: { translation: hu },
it: { translation: it },
ja: { translation: ja },
nl: { translation: nl },

View File

@@ -1,6 +0,0 @@
# login.yaml
appId: your.app.id
---
- launchApp
- tapOn: "Text on the screen"

View File

@@ -1,29 +1,243 @@
// Learn more https://docs.expo.io/guides/customizing-metro
const { getDefaultConfig } = require("expo/metro-config");
const { withNativewind } = require("nativewind/metro");
const path = require("node:path");
/** @type {import('expo/metro-config').MetroConfig} */
const config = getDefaultConfig(__dirname);
// Add Hermes parser
// =======================================================
// STREAMYFIN METRO CONFIG - PERFORMANCE OPTIMIZED 🚀
// =======================================================
// Advanced configuration for multi-platform Jellyfin client
// Optimized for media streaming, TV support, and Bun
// =======================================================
// HERMES + ADVANCED PERFORMANCE
// ==============================
config.transformer.hermesParser = true;
// When enabled, the optional code below will allow Metro to resolve
// and bundle source files with TV-specific extensions
// (e.g., *.ios.tv.tsx, *.android.tv.tsx, *.tv.tsx)
//
// Metro will still resolve source files with standard extensions
// as usual if TV-specific files are not found for a module.
//
// CPU optimization (your existing setting)
const os = require("node:os");
config.maxWorkers = Math.max(1, os.cpus().length - 1);
// JAVASCRIPT OPTIMIZATION (Safe & Stable)
// ========================================
config.transformer = {
...config.transformer,
hermesParser: true,
// NEW: Inline requires for 15-30% startup improvement
inlineRequires: true,
// ADVANCED: Hermes-optimized minification for streaming apps
minifierConfig: {
mangle: {
keep_fnames: process.env.NODE_ENV === "development",
},
output: {
ascii_only: true,
beautify: false,
semicolons: false,
},
compress: {
// Production-only optimizations
drop_console: process.env.NODE_ENV === "production",
dead_code: true,
drop_debugger: true,
conditionals: true,
evaluate: true,
unused: true,
reduce_vars: true,
// Keep class names for error reporting
keep_classnames: true,
// Preserve function names for performance profiling in dev
keep_fnames: process.env.NODE_ENV === "development",
},
},
};
// RESOLVER OPTIMIZATIONS ENHANCED
// ===============================
config.resolver = {
...config.resolver,
// NEW: Package exports (stable in recent Metro)
unstable_enablePackageExports: true,
// ENHANCED: Extensions optimized for Streamyfin
assetExts: [
...config.resolver.assetExts,
// Video formats (enhanced for Jellyfin)
"mkv",
"mp4",
"avi",
"mov",
"wmv",
"flv",
"webm",
"m4v",
"mpg",
"mpeg",
// Audio formats
"mp3",
"wav",
"flac",
"aac",
"m4a",
"ogg",
"wma",
"opus",
// Subtitle files (complete for media)
"srt",
"vtt",
"ass",
"ssa",
"sub",
"idx",
"sbv",
"ttml",
// Database/Cache
"db",
"sqlite",
"realm",
"json5",
// Fonts (TV optimized)
"woff2",
"woff",
"eot",
"otf",
// Images (enhanced for thumbnails)
"avif",
"heic",
"heif", // Modern formats
],
sourceExts: [
...config.resolver.sourceExts,
"mjs",
"cjs", // Modern JS support
],
// NEW: Platform prioritization for performance
platforms: ["ios", "android", "native", "web", "tv"],
};
// SERIALIZER OPTIMIZATIONS (Production)
// ====================================
if (process.env.NODE_ENV === "production") {
config.serializer = {
...config.serializer,
// NEW: Module IDs optimized for caching
createModuleIdFactory: () => {
return (path) => {
// Shorter module IDs for smaller bundles
return require("node:crypto")
.createHash("sha1")
.update(path)
.digest("hex")
.substring(0, 8);
};
},
// NEW: Bundle pre-loading for streaming apps
getModulesRunBeforeMainModule: (_entryFilePath) => [
// Pre-load critical modules for faster TTI
require.resolve("react-native/Libraries/Core/InitializeCore"),
],
// Web bundle splitting (if applicable)
...(process.env.EXPO_PLATFORM === "web" && {
// Experimental code splitting for web
experimentalSerializerHook: () => {},
}),
};
}
// TV PLATFORM ENHANCEMENTS
// ========================
if (process.env?.EXPO_TV === "1") {
const originalSourceExts = config.resolver.sourceExts;
const tvSourceExts = [
...originalSourceExts.map((e) => `tv.${e}`),
...originalSourceExts.map((ext) => `tv.${ext}`),
...originalSourceExts,
];
config.resolver.sourceExts = tvSourceExts;
// NEW: TV-specific optimizations
config.transformer = {
...config.transformer,
// Optimize transforms for TV hardware
experimentalImportSupport: false, // Reduce complexity on TV
// Use legacy transformer for better TV compatibility
allowOptionalDependencies: true,
};
console.log("📺 TV platform optimized for Streamyfin");
console.log(`📁 TV extensions: ${tvSourceExts.slice(0, 6).join(", ")}...`);
}
// config.resolver.unstable_enablePackageExports = false;
// DEVELOPMENT ENHANCEMENTS
// ========================
if (process.env.NODE_ENV === "development") {
// NEW: Enhanced error reporting
config.reporter = {
update: (event) => {
if (event.type === "bundle_build_failed") {
console.error(
"🔴 Streamyfin Bundle Build Failed:",
event.error.message,
);
} else if (event.type === "bundle_build_done") {
console.log(
"✅ Streamyfin Bundle Ready:",
event.bundleDetails?.bundleSize || "Unknown size",
);
}
},
};
module.exports = withNativewind(config, { input: "./global.css" });
console.log("🚀 Streamyfin Metro Config - OPTIMIZED VERSION");
console.log(`📦 Workers: ${config.maxWorkers}`);
console.log(`🎯 Hermes: ${config.transformer.hermesParser}`);
console.log(`⚡ Inline requires: ${config.transformer.inlineRequires}`);
console.log(
`📺 TV support: ${process.env.EXPO_TV === "1" ? "ENABLED" : "DISABLED"}`,
);
}
// STREAMING APP SPECIFIC OPTIMIZATIONS
// ===================================
// NEW: Cache hints for better performance
if (typeof config.cacheStores === "undefined") {
// Only add if not causing issues
try {
const MetroCache = require("metro-cache");
config.cacheStores = [
new MetroCache.FileStore({
root: path.join(os.tmpdir(), "streamyfin-metro-cache"),
}),
];
} catch (_e) {
// Fallback: use default cache
console.log(" Using default Metro cache (custom cache not available)");
}
}
// NEW: Network request optimizations for streaming
config.server = {
...config.server,
// Enhanced request handling for media assets
enhanceMiddleware: (middleware, _server) => {
// Add caching headers for static assets
return (req, res, next) => {
if (req.url?.match(/\.(mp4|mkv|jpg|jpeg|png|webp)$/)) {
res.setHeader("Cache-Control", "public, max-age=31536000");
}
return middleware(req, res, next);
};
},
};
module.exports = config;

View File

@@ -1,258 +0,0 @@
# Background Downloader Module
A native iOS and Android module for downloading large files in the background using `NSURLSession` (iOS) and `DownloadManager` (Android).
## Features
- **Background Downloads**: Downloads continue even when the app is backgrounded or suspended
- **Progress Tracking**: Real-time progress updates via events
- **Multiple Downloads**: Support for concurrent downloads
- **Cancellation**: Cancel individual or all downloads
- **Custom Destination**: Optionally specify custom file paths
- **Error Handling**: Comprehensive error reporting
- **Cross-Platform**: Works on both iOS and Android
## Usage
### Basic Example
```typescript
import { BackgroundDownloader } from '@/modules';
// Start a download
const taskId = await BackgroundDownloader.startDownload(
'https://example.com/largefile.mp4'
);
// Listen for progress updates
const progressSub = BackgroundDownloader.addProgressListener((event) => {
console.log(`Progress: ${Math.floor(event.progress * 100)}%`);
console.log(`Downloaded: ${event.bytesWritten} / ${event.totalBytes}`);
});
// Listen for completion
const completeSub = BackgroundDownloader.addCompleteListener((event) => {
console.log('Download complete!');
console.log('File saved to:', event.filePath);
console.log('Task ID:', event.taskId);
});
// Listen for errors
const errorSub = BackgroundDownloader.addErrorListener((event) => {
console.error('Download failed:', event.error);
});
// Cancel a download
BackgroundDownloader.cancelDownload(taskId);
// Get all active downloads
const activeDownloads = await BackgroundDownloader.getActiveDownloads();
// Cleanup listeners when done
progressSub.remove();
completeSub.remove();
errorSub.remove();
```
### Custom Destination Path
```typescript
import { BackgroundDownloader } from '@/modules';
import * as FileSystem from 'expo-file-system';
const destinationPath = `${FileSystem.documentDirectory}myfile.mp4`;
const taskId = await BackgroundDownloader.startDownload(
'https://example.com/video.mp4',
destinationPath
);
```
### Managing Multiple Downloads
```typescript
import { BackgroundDownloader } from '@/modules';
const downloads = new Map();
async function startMultipleDownloads(urls: string[]) {
for (const url of urls) {
const taskId = await BackgroundDownloader.startDownload(url);
downloads.set(taskId, { url, progress: 0 });
}
}
// Track progress for each download
const progressSub = BackgroundDownloader.addProgressListener((event) => {
const download = downloads.get(event.taskId);
if (download) {
download.progress = event.progress;
}
});
// Cancel all downloads
BackgroundDownloader.cancelAllDownloads();
```
## API Reference
### Methods
#### `startDownload(url: string, destinationPath?: string): Promise<number>`
Starts a new background download.
- **Parameters:**
- `url`: The URL of the file to download
- `destinationPath`: (Optional) Custom file path for the downloaded file
- **Returns:** Promise that resolves to the task ID (number)
#### `cancelDownload(taskId: number): void`
Cancels a specific download by task ID.
- **Parameters:**
- `taskId`: The task ID returned by `startDownload`
#### `cancelAllDownloads(): void`
Cancels all active downloads.
#### `getActiveDownloads(): Promise<ActiveDownload[]>`
Gets information about all active downloads.
- **Returns:** Promise that resolves to an array of active downloads
### Event Listeners
#### `addProgressListener(listener: (event: DownloadProgressEvent) => void): Subscription`
Listens for download progress updates.
- **Event payload:**
- `taskId`: number
- `bytesWritten`: number
- `totalBytes`: number
- `progress`: number (0.0 to 1.0)
#### `addCompleteListener(listener: (event: DownloadCompleteEvent) => void): Subscription`
Listens for download completion.
- **Event payload:**
- `taskId`: number
- `filePath`: string
- `url`: string
#### `addErrorListener(listener: (event: DownloadErrorEvent) => void): Subscription`
Listens for download errors.
- **Event payload:**
- `taskId`: number
- `error`: string
#### `addStartedListener(listener: (event: DownloadStartedEvent) => void): Subscription`
Listens for download start confirmation.
- **Event payload:**
- `taskId`: number
- `url`: string
## Types
```typescript
interface DownloadProgressEvent {
taskId: number;
bytesWritten: number;
totalBytes: number;
progress: number;
}
interface DownloadCompleteEvent {
taskId: number;
filePath: string;
url: string;
}
interface DownloadErrorEvent {
taskId: number;
error: string;
}
interface DownloadStartedEvent {
taskId: number;
url: string;
}
interface ActiveDownload {
taskId: number;
url: string;
state: 'running' | 'suspended' | 'canceling' | 'completed' | 'unknown';
}
```
## Implementation Details
### iOS Background Downloads
- Uses `NSURLSession` with background configuration
- Session identifier: `com.fredrikburmester.streamyfin.backgrounddownloader`
- Downloads continue when app is backgrounded or suspended
- System may terminate downloads if app is force-quit
### Android Background Downloads
- Uses Android's `DownloadManager` API
- Downloads are managed by the system and continue in the background
- Shows download notification in the notification tray
- Downloads continue even if the app is closed
- Requires `INTERNET` permission (automatically added by Expo)
### Background Modes
The app's `Info.plist` already includes the required background mode for iOS:
- `UIBackgroundModes`: `["audio", "fetch"]`
### File Storage
**iOS:** By default, downloaded files are saved to the app's Documents directory.
**Android:** By default, files are saved to the app's external files directory (accessible via `FileSystem.documentDirectory` in Expo).
You can specify a custom path using the `destinationPath` parameter on both platforms.
## Building
After adding this module, rebuild the app:
```bash
# iOS
npx expo prebuild -p ios
npx expo run:ios
# Android
npx expo prebuild -p android
npx expo run:android
```
Or install manually:
```bash
# iOS
cd ios
pod install
cd ..
# Android - prebuild handles everything
npx expo prebuild -p android
```
## Notes
- Background downloads may be cancelled if the user force-quits the app (iOS)
- The OS manages download priority and may pause downloads to save battery
- Android shows a system notification for ongoing downloads
- Downloads over cellular are allowed by default on both platforms

View File

@@ -1,46 +0,0 @@
plugins {
id 'com.android.library'
id 'kotlin-android'
}
group = 'expo.modules.backgrounddownloader'
version = '1.0.0'
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
def kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.25'
apply from: expoModulesCorePlugin
applyKotlinExpoModulesCorePlugin()
useDefaultAndroidSdkVersions()
useCoreDependencies()
useExpoPublishing()
android {
namespace "expo.modules.backgrounddownloader"
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
lintOptions {
abortOnError false
}
}
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
}
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
kotlinOptions {
freeCompilerArgs += ["-Xshow-kotlin-compiler-errors"]
jvmTarget = "17"
}
}

View File

@@ -1,3 +0,0 @@
<manifest>
</manifest>

View File

@@ -1,459 +0,0 @@
package expo.modules.backgrounddownloader
import android.app.DownloadManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.database.Cursor
import android.net.Uri
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.core.content.ContextCompat
import expo.modules.kotlin.Promise
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import java.io.File
class BackgroundDownloaderModule : Module() {
companion object {
private const val TAG = "BackgroundDownloader"
}
private val context
get() = requireNotNull(appContext.reactContext)
private val downloadManager: DownloadManager by lazy {
context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
}
private val downloadTasks = mutableMapOf<Long, DownloadTaskInfo>()
private val progressHandler = Handler(Looper.getMainLooper())
private val progressRunnables = mutableMapOf<Long, Runnable>()
private val downloadCompleteReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val downloadId = intent?.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1) ?: -1
Log.d(TAG, "Broadcast received for downloadId: $downloadId, action: ${intent?.action}")
if (downloadId != -1L && downloadTasks.containsKey(downloadId)) {
Log.d(TAG, "Calling handleDownloadComplete for task: $downloadId")
handleDownloadComplete(downloadId)
} else if (downloadId != -1L) {
Log.w(TAG, "Received broadcast for unknown downloadId: $downloadId (not in our task map)")
} else {
Log.w(TAG, "Received broadcast with invalid downloadId: $downloadId")
}
}
}
override fun definition() = ModuleDefinition {
Name("BackgroundDownloader")
Events(
"onDownloadProgress",
"onDownloadComplete",
"onDownloadError",
"onDownloadStarted"
)
OnCreate {
registerDownloadReceiver()
}
OnDestroy {
unregisterDownloadReceiver()
progressRunnables.values.forEach { progressHandler.removeCallbacks(it) }
progressRunnables.clear()
}
AsyncFunction("startDownload") { urlString: String, destinationPath: String?, promise: Promise ->
try {
val uri = Uri.parse(urlString)
val request = DownloadManager.Request(uri).apply {
setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE)
setAllowedNetworkTypes(
DownloadManager.Request.NETWORK_WIFI or DownloadManager.Request.NETWORK_MOBILE
)
setAllowedOverMetered(true)
setAllowedOverRoaming(true)
if (destinationPath != null) {
val file = File(destinationPath)
val fileName = file.name
// Check if destination is in internal storage (starts with /data/data/ or /data/user/)
// DownloadManager can't write to internal storage directly
val isInternalPath = destinationPath.startsWith("/data/data/") ||
destinationPath.startsWith("/data/user/")
if (isInternalPath) {
// Download to external files dir, we'll move it later
setDestinationInExternalFilesDir(
context,
null,
fileName
)
} else {
// External path - create directory and set destination
val directory = file.parentFile
if (directory != null && !directory.exists()) {
directory.mkdirs()
}
setDestinationUri(Uri.fromFile(file))
}
} else {
val fileName = uri.lastPathSegment ?: "download_${System.currentTimeMillis()}"
setDestinationInExternalFilesDir(
context,
null,
fileName
)
}
}
val downloadId = downloadManager.enqueue(request)
downloadTasks[downloadId] = DownloadTaskInfo(
url = urlString,
destinationPath = destinationPath
)
startProgressTracking(downloadId)
sendEvent("onDownloadStarted", mapOf(
"taskId" to downloadId.toInt(),
"url" to urlString
))
promise.resolve(downloadId.toInt())
} catch (e: Exception) {
promise.reject("DOWNLOAD_ERROR", "Failed to start download: ${e.message}", e)
}
}
Function("cancelDownload") { taskId: Int ->
val downloadId = taskId.toLong()
if (downloadTasks.containsKey(downloadId)) {
downloadManager.remove(downloadId)
stopProgressTracking(downloadId)
downloadTasks.remove(downloadId)
}
}
Function("cancelAllDownloads") {
val downloadIds = downloadTasks.keys.toList()
downloadIds.forEach { downloadId ->
downloadManager.remove(downloadId)
stopProgressTracking(downloadId)
}
downloadTasks.clear()
}
AsyncFunction("getActiveDownloads") { promise: Promise ->
try {
val activeDownloads = mutableListOf<Map<String, Any>>()
downloadTasks.forEach { (downloadId, taskInfo) ->
val query = DownloadManager.Query().setFilterById(downloadId)
val cursor = downloadManager.query(query)
if (cursor.moveToFirst()) {
val statusIndex = cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)
val status = if (statusIndex >= 0) cursor.getInt(statusIndex) else -1
activeDownloads.add(mapOf(
"taskId" to downloadId.toInt(),
"url" to taskInfo.url,
"state" to getStateString(status)
))
}
cursor.close()
}
promise.resolve(activeDownloads)
} catch (e: Exception) {
promise.reject("GET_DOWNLOADS_ERROR", "Failed to get active downloads: ${e.message}", e)
}
}
}
private fun registerDownloadReceiver() {
val filter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
ContextCompat.registerReceiver(
context,
downloadCompleteReceiver,
filter,
ContextCompat.RECEIVER_NOT_EXPORTED
)
} else {
context.registerReceiver(downloadCompleteReceiver, filter)
}
}
private fun unregisterDownloadReceiver() {
try {
context.unregisterReceiver(downloadCompleteReceiver)
} catch (e: IllegalArgumentException) {
// Receiver not registered, ignore
}
}
private fun startProgressTracking(downloadId: Long) {
val runnable = object : Runnable {
override fun run() {
if (!downloadTasks.containsKey(downloadId)) {
Log.d(TAG, "Task $downloadId no longer in map, stopping progress tracking")
return
}
val query = DownloadManager.Query().setFilterById(downloadId)
val cursor = downloadManager.query(query)
if (cursor.moveToFirst()) {
val statusIndex = cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)
val status = if (statusIndex >= 0) cursor.getInt(statusIndex) else -1
val bytesDownloadedIndex = cursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)
val bytesDownloaded = if (bytesDownloadedIndex >= 0) cursor.getLong(bytesDownloadedIndex) else 0L
val totalBytesIndex = cursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
val totalBytes = if (totalBytesIndex >= 0) cursor.getLong(totalBytesIndex) else 0L
val statusString = when (status) {
DownloadManager.STATUS_RUNNING -> "RUNNING"
DownloadManager.STATUS_PAUSED -> "PAUSED"
DownloadManager.STATUS_PENDING -> "PENDING"
DownloadManager.STATUS_SUCCESSFUL -> "SUCCESSFUL"
DownloadManager.STATUS_FAILED -> "FAILED"
else -> "UNKNOWN($status)"
}
// Log status periodically for debugging
val progress = if (totalBytes > 0) (bytesDownloaded.toDouble() / totalBytes.toDouble() * 100).toInt() else 0
if (progress % 10 == 0 || status != DownloadManager.STATUS_RUNNING) {
Log.d(TAG, "Task $downloadId: status=$statusString, progress=$progress%, bytes=$bytesDownloaded/$totalBytes")
}
if (status == DownloadManager.STATUS_RUNNING && totalBytes > 0) {
val progressRatio = bytesDownloaded.toDouble() / totalBytes.toDouble()
sendEvent("onDownloadProgress", mapOf(
"taskId" to downloadId.toInt(),
"bytesWritten" to bytesDownloaded,
"totalBytes" to totalBytes,
"progress" to progressRatio
))
}
// Check if download completed but broadcast was missed
if (status == DownloadManager.STATUS_SUCCESSFUL) {
Log.w(TAG, "Task $downloadId: Download is SUCCESSFUL but completion handler wasn't called! Calling manually.")
cursor.close()
stopProgressTracking(downloadId)
handleDownloadComplete(downloadId)
return
}
// Check for errors
if (status == DownloadManager.STATUS_FAILED) {
val reasonIndex = cursor.getColumnIndex(DownloadManager.COLUMN_REASON)
val reason = if (reasonIndex >= 0) cursor.getInt(reasonIndex) else -1
Log.e(TAG, "Task $downloadId: Download FAILED with reason code: $reason")
cursor.close()
stopProgressTracking(downloadId)
sendEvent("onDownloadError", mapOf(
"taskId" to downloadId.toInt(),
"error" to getErrorString(reason)
))
downloadTasks.remove(downloadId)
return
}
// Check if download is paused or pending for too long
if (status == DownloadManager.STATUS_PAUSED || status == DownloadManager.STATUS_PENDING) {
Log.w(TAG, "Task $downloadId: Download is $statusString")
}
} else {
Log.e(TAG, "Task $downloadId: No cursor data found in DownloadManager")
}
cursor.close()
// Continue tracking if still in progress
if (downloadTasks.containsKey(downloadId)) {
progressHandler.postDelayed(this, 500)
}
}
}
progressRunnables[downloadId] = runnable
progressHandler.post(runnable)
}
private fun stopProgressTracking(downloadId: Long) {
progressRunnables[downloadId]?.let { runnable ->
progressHandler.removeCallbacks(runnable)
progressRunnables.remove(downloadId)
}
}
private fun handleDownloadComplete(downloadId: Long) {
stopProgressTracking(downloadId)
val taskInfo = downloadTasks[downloadId]
if (taskInfo == null) {
return
}
val query = DownloadManager.Query().setFilterById(downloadId)
val cursor = downloadManager.query(query)
if (cursor.moveToFirst()) {
val statusIndex = cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)
val status = if (statusIndex >= 0) cursor.getInt(statusIndex) else -1
if (status == DownloadManager.STATUS_SUCCESSFUL) {
val uriIndex = cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI)
val localUri = if (uriIndex >= 0) cursor.getString(uriIndex) else null
if (localUri != null) {
val downloadedFilePath = Uri.parse(localUri).path ?: localUri
// If we have a custom destination path for internal storage, move the file in background
if (taskInfo.destinationPath != null) {
val isInternalPath = taskInfo.destinationPath.startsWith("/data/data/") ||
taskInfo.destinationPath.startsWith("/data/user/")
if (isInternalPath) {
Log.d(TAG, "Starting file move in background thread for taskId: ${downloadId.toInt()}")
// Move file in background thread to avoid blocking
Thread {
try {
val sourceFile = File(downloadedFilePath)
val destFile = File(taskInfo.destinationPath)
Log.d(TAG, "Moving file from $downloadedFilePath to ${taskInfo.destinationPath}")
// Create destination directory if needed
val destDir = destFile.parentFile
if (destDir != null && !destDir.exists()) {
destDir.mkdirs()
}
// Try to move file (fast if on same filesystem)
val moveSuccessful = sourceFile.renameTo(destFile)
if (moveSuccessful) {
Log.d(TAG, "File moved successfully via rename")
sendEvent("onDownloadComplete", mapOf(
"taskId" to downloadId.toInt(),
"filePath" to taskInfo.destinationPath,
"url" to taskInfo.url
))
} else {
// Rename failed (likely different filesystems), need to copy
Log.d(TAG, "Rename failed, copying file (this may take a while for large files)")
sourceFile.inputStream().use { input ->
destFile.outputStream().use { output ->
input.copyTo(output)
}
}
// Delete source file after successful copy
if (sourceFile.delete()) {
Log.d(TAG, "File copied and source deleted successfully")
} else {
Log.w(TAG, "File copied but failed to delete source file")
}
sendEvent("onDownloadComplete", mapOf(
"taskId" to downloadId.toInt(),
"filePath" to taskInfo.destinationPath,
"url" to taskInfo.url
))
}
} catch (e: Exception) {
Log.e(TAG, "Failed to move file to internal storage: ${e.message}", e)
sendEvent("onDownloadError", mapOf(
"taskId" to downloadId.toInt(),
"error" to "Failed to move file to destination: ${e.message}"
))
}
}.start()
cursor.close()
downloadTasks.remove(downloadId)
return
}
}
// No internal path or external path - send completion immediately
sendEvent("onDownloadComplete", mapOf(
"taskId" to downloadId.toInt(),
"filePath" to downloadedFilePath,
"url" to taskInfo.url
))
} else {
sendEvent("onDownloadError", mapOf(
"taskId" to downloadId.toInt(),
"error" to "Could not retrieve downloaded file path"
))
}
} else if (status == DownloadManager.STATUS_FAILED) {
val reasonIndex = cursor.getColumnIndex(DownloadManager.COLUMN_REASON)
val reason = if (reasonIndex >= 0) cursor.getInt(reasonIndex) else -1
sendEvent("onDownloadError", mapOf(
"taskId" to downloadId.toInt(),
"error" to getErrorString(reason)
))
}
}
cursor.close()
downloadTasks.remove(downloadId)
}
private fun getStateString(status: Int): String {
return when (status) {
DownloadManager.STATUS_RUNNING -> "running"
DownloadManager.STATUS_PAUSED -> "suspended"
DownloadManager.STATUS_PENDING -> "suspended"
DownloadManager.STATUS_SUCCESSFUL -> "completed"
DownloadManager.STATUS_FAILED -> "completed"
else -> "unknown"
}
}
private fun getErrorString(reason: Int): String {
return when (reason) {
DownloadManager.ERROR_CANNOT_RESUME -> "Cannot resume download"
DownloadManager.ERROR_DEVICE_NOT_FOUND -> "No external storage device found"
DownloadManager.ERROR_FILE_ALREADY_EXISTS -> "File already exists"
DownloadManager.ERROR_FILE_ERROR -> "Storage error"
DownloadManager.ERROR_HTTP_DATA_ERROR -> "HTTP data error"
DownloadManager.ERROR_INSUFFICIENT_SPACE -> "Insufficient storage space"
DownloadManager.ERROR_TOO_MANY_REDIRECTS -> "Too many redirects"
DownloadManager.ERROR_UNHANDLED_HTTP_CODE -> "Unhandled HTTP response code"
DownloadManager.ERROR_UNKNOWN -> "Unknown error"
else -> "Download failed (code: $reason)"
}
}
}
data class DownloadTaskInfo(
val url: String,
val destinationPath: String?
)

View File

@@ -1,98 +0,0 @@
import type {
DownloadCompleteEvent,
DownloadErrorEvent,
DownloadProgressEvent,
} from "@/modules";
import { BackgroundDownloader } from "@/modules";
export class DownloadManager {
private progressSubscription: any;
private completeSubscription: any;
private errorSubscription: any;
private activeDownloads = new Map<
number,
{ url: string; progress: number }
>();
constructor() {
this.setupListeners();
}
private setupListeners() {
this.progressSubscription = BackgroundDownloader.addProgressListener(
(event: DownloadProgressEvent) => {
const download = this.activeDownloads.get(event.taskId);
if (download) {
download.progress = event.progress;
console.log(
`Download ${event.taskId}: ${Math.floor(event.progress * 100)}%`,
);
}
},
);
this.completeSubscription = BackgroundDownloader.addCompleteListener(
(event: DownloadCompleteEvent) => {
console.log("Download complete:", event.filePath);
this.activeDownloads.delete(event.taskId);
},
);
this.errorSubscription = BackgroundDownloader.addErrorListener(
(event: DownloadErrorEvent) => {
console.error("Download error:", event.error);
this.activeDownloads.delete(event.taskId);
},
);
}
async startDownload(url: string, destinationPath?: string): Promise<number> {
const taskId = await BackgroundDownloader.startDownload(
url,
destinationPath,
);
this.activeDownloads.set(taskId, { url, progress: 0 });
return taskId;
}
cancelDownload(taskId: number): void {
BackgroundDownloader.cancelDownload(taskId);
this.activeDownloads.delete(taskId);
}
cancelAllDownloads(): void {
BackgroundDownloader.cancelAllDownloads();
this.activeDownloads.clear();
}
async getActiveDownloads() {
return await BackgroundDownloader.getActiveDownloads();
}
cleanup(): void {
this.progressSubscription?.remove();
this.completeSubscription?.remove();
this.errorSubscription?.remove();
}
}
const downloadManager = new DownloadManager();
export async function downloadFile(
url: string,
destinationPath?: string,
): Promise<number> {
return await downloadManager.startDownload(url, destinationPath);
}
export function cancelDownload(taskId: number): void {
downloadManager.cancelDownload(taskId);
}
export function cancelAllDownloads(): void {
downloadManager.cancelAllDownloads();
}
export async function getActiveDownloads() {
return await downloadManager.getActiveDownloads();
}

Some files were not shown because too many files have changed in this diff Show More