mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 23:59:08 +00:00
Compare commits
61 Commits
feature/ne
...
feat-tv-ca
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ece40534ac | ||
|
|
ce37351099 | ||
|
|
e4e1e556bf | ||
|
|
1ff723b29f | ||
|
|
11d35c846d | ||
|
|
ce82f3044b | ||
|
|
df638dae28 | ||
|
|
9a5e49ae16 | ||
|
|
4f2120f85d | ||
|
|
8eeea35441 | ||
|
|
781464b768 | ||
|
|
61d60c2e74 | ||
|
|
6c722e1fd9 | ||
|
|
e117e98c8e | ||
|
|
f5c791cce4 | ||
|
|
eaebb79f4c | ||
|
|
7e82d9c7b6 | ||
|
|
c62c8ca077 | ||
|
|
25cfcf0735 | ||
|
|
f19b33eff6 | ||
|
|
1c4f54584f | ||
|
|
87d02078c8 | ||
|
|
1e9fba6f85 | ||
|
|
dbcf5759da | ||
|
|
37364b235f | ||
|
|
fcb8e83845 | ||
|
|
16046a4a15 | ||
|
|
13b29bdee0 | ||
|
|
bf4a1f9462 | ||
|
|
264439858d | ||
|
|
6576a58884 | ||
|
|
3eae2f3fde | ||
|
|
deae6ab21a | ||
|
|
5478179367 | ||
|
|
3e20050b64 | ||
|
|
59e9913c78 | ||
|
|
2b2797005a | ||
|
|
c53acb16fc | ||
|
|
53570a5ee5 | ||
|
|
e3b7dd8241 | ||
|
|
786d082706 | ||
|
|
164de0af0d | ||
|
|
820b30b7e2 | ||
|
|
5bc4c4a856 | ||
|
|
f7e0667416 | ||
|
|
8c68283c56 | ||
|
|
bb0149406c | ||
|
|
b1d5630025 | ||
|
|
a5f5531bb9 | ||
|
|
c2a3817fa8 | ||
|
|
700bb2dc79 | ||
|
|
d741ca3ecc | ||
|
|
ae9f6b1ce4 | ||
|
|
cd3f1a8cee | ||
|
|
be745dc136 | ||
|
|
7b6fe0a6c0 | ||
|
|
fc44283f09 | ||
|
|
b2f6edc54e | ||
|
|
b42d033b87 | ||
|
|
1fb166bcd1 | ||
|
|
de6133581b |
@@ -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": []
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
33
.github/CONTRIBUTING.md
vendored
33
.github/CONTRIBUTING.md
vendored
@@ -1,11 +1,13 @@
|
||||
# Contributing to Streamyfin
|
||||
|
||||
Thank you for your interest in contributing to the Streamyfin mobile app project! This document provides guidelines to smoothly collaborate on the Streamyfin codebase and help improve the app for all users.
|
||||
Thank you for your interest in contributing to the Streamyfin project. This document outlines the guidelines for effective collaboration across the Streamyfin codebase and aims to ensure a smooth, productive experience for all contributors.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
|
||||
- [AI Assistance Disclosure](#ai-assistance-disclosure)
|
||||
- [Reporting Issues](#reporting-issues)
|
||||
- [Reporting Security Vulnerabilities](#reporting-security-vulnerabilities)
|
||||
- [Requesting Features & Enhancements](#requesting-features--enhancements)
|
||||
@@ -19,6 +21,25 @@ Thank you for your interest in contributing to the Streamyfin mobile app project
|
||||
|
||||
---
|
||||
|
||||
## AI Assistance Disclosure
|
||||
|
||||
> [!IMPORTANT]
|
||||
> If any AI tool was used while contributing to Streamyfin, it must be disclosed in the pull request.
|
||||
|
||||
State in your PR whether AI assistance was used and to what extent (for example, *docs only* or *code generation*).
|
||||
If AI-generated text was used in PR discussions or responses, disclose that as well.
|
||||
Minor autocomplete or keyword suggestions do not require disclosure.
|
||||
|
||||
### Examples
|
||||
|
||||
> This PR was written primarily by Claude Code.
|
||||
> I used Cursor to explore parts of the codebase, but the implementation is fully manual.
|
||||
|
||||
Failing to disclose AI usage wastes maintainers’ time and complicates review efforts.
|
||||
AI-assisted contributions are welcome, but contributors remain fully responsible for the code they submit.
|
||||
|
||||
Always disclose AI involvement to maintain transparency and respect for maintainers’ time.
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
Streamyfin uses GitHub issues to track bugs and improvements. Before opening a new issue:
|
||||
@@ -46,11 +67,11 @@ When creating a new feature request:
|
||||
|
||||
- Check if the idea or similar request exists.
|
||||
- Use reactions like 👍 to support existing requests.
|
||||
- Provide a clear explanation of the use case and benefits.
|
||||
|
||||
- Clearly describe the use case and potential benefits.
|
||||
- Include screenshots when relevant.
|
||||
---
|
||||
|
||||
## Developing the Mobile App
|
||||
## Developing Streamyfin
|
||||
|
||||
### Codebase Overview
|
||||
|
||||
@@ -136,6 +157,8 @@ When opening a PR:
|
||||
- Provide a detailed description in the PR body, explaining what, why, and any impacts.
|
||||
- Include screenshots or recordings if UI changes are involved.
|
||||
- Ensure CI checks are green (lint, type-check, build).
|
||||
- Confirm that the branch is **up to date with `main`** before submission.
|
||||
- Mention if AI-generated code or content was used (see [AI Assistance Disclosure](#ai-assistance-disclosure)).
|
||||
- Do not include secrets, tokens, or production credentials. Redact sensitive data in logs and screenshots.
|
||||
- Keep PRs focused; avoid bundling unrelated changes together.
|
||||
|
||||
@@ -159,4 +182,4 @@ PRs require review and approval by maintainers before merging.---
|
||||
|
||||
---
|
||||
|
||||
Thank you for helping make Streamyfin a better app for everyone!
|
||||
Thank you for contributing to make Streamyfin better for everyone!
|
||||
|
||||
98
.github/copilot-instructions.md
vendored
98
.github/copilot-instructions.md
vendored
@@ -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
12
.github/crowdin.yml
vendored
Normal 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"
|
||||
}
|
||||
]
|
||||
2
.github/workflows/artifact-comment.yml
vendored
2
.github/workflows/artifact-comment.yml
vendored
@@ -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)
|
||||
|
||||
6
.github/workflows/build-apps.yml
vendored
6
.github/workflows/build-apps.yml
vendored
@@ -76,7 +76,7 @@ jobs:
|
||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||
|
||||
- name: 📤 Upload APK artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: streamyfin-android-phone-apk-${{ env.DATE_TAG }}
|
||||
path: |
|
||||
@@ -147,7 +147,7 @@ jobs:
|
||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||
|
||||
- name: 📤 Upload APK artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: streamyfin-android-tv-apk-${{ env.DATE_TAG }}
|
||||
path: |
|
||||
@@ -210,7 +210,7 @@ jobs:
|
||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||
|
||||
- name: 📤 Upload IPA artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: streamyfin-ios-phone-ipa-${{ env.DATE_TAG }}
|
||||
path: build-*.ipa
|
||||
|
||||
6
.github/workflows/ci-codeql.yml
vendored
6
.github/workflows/ci-codeql.yml
vendored
@@ -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@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
|
||||
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@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
|
||||
|
||||
- name: 🧪 Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5
|
||||
uses: github/codeql-action/analyze@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
|
||||
|
||||
50
.github/workflows/crowdin.yml
vendored
50
.github/workflows/crowdin.yml
vendored
@@ -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@08713f00a50548bfe39b37e8f44afb53e7a802d4 # v2.12.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 }}
|
||||
|
||||
4
.github/workflows/linting.yml
vendored
4
.github/workflows/linting.yml
vendored
@@ -57,7 +57,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Dependency Review
|
||||
uses: actions/dependency-review-action@56339e523c0409420f6c2c9a2f4292bbb3c07dd3 # v4.8.0
|
||||
uses: actions/dependency-review-action@40c09b7dc99638e5ddb0bfd91c1673effc064d8a # v4.8.1
|
||||
with:
|
||||
fail-on-severity: high
|
||||
base-ref: ${{ github.event.pull_request.base.sha || 'develop' }}
|
||||
@@ -107,7 +107,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: "🟢 Setup Node.js"
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version: '24.x'
|
||||
|
||||
|
||||
25
.github/workflows/notification.yml
vendored
25
.github/workflows/notification.yml
vendored
@@ -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 }}
|
||||
|
||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@@ -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 }}
|
||||
|
||||
2
.github/workflows/update-issue-form.yml
vendored
2
.github/workflows/update-issue-form.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: "🟢 Setup Node.js"
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version: '24.x'
|
||||
cache: 'npm'
|
||||
|
||||
76
.gitignore
vendored
76
.gitignore
vendored
@@ -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,21 +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
|
||||
|
||||
# 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
24
.vscode/extensions.json
vendored
Normal 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
176
.vscode/settings.json
vendored
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
161
README.md
161
README.md
@@ -5,146 +5,135 @@
|
||||
<img src="https://raw.githubusercontent.com/streamyfin/.github/refs/heads/main/streamyfin-github-banner.png" alt="Streamyfin" width="100%">
|
||||
</p>
|
||||
|
||||
**Streamyfin is a simple, user-friendly Jellyfin video streaming client built with Expo. Designed as an alternative to other Jellyfin clients, it aims to offer a smooth and reliable streaming experience. We hope you'll find it a valuable addition to your media streaming toolbox.**
|
||||
**Streamyfin is a user-friendly Jellyfin video streaming client built with Expo. Designed as an alternative to other Jellyfin clients, it aims to offer a smooth and reliable streaming experience. We hope you'll find it a valuable addition to your media streaming toolbox.**
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<img src="./assets/images/screenshots/screenshot1.png" width="22%">
|
||||
<img src="./assets/images/screenshots/screenshot1.png" width="20%">
|
||||
|
||||
<img src="./assets/images/screenshots/screenshot3.png" width="22%">
|
||||
<img src="./assets/images/screenshots/screenshot3.png" width="20%">
|
||||
|
||||
<img src="./assets/images/screenshots/screenshot2.png" width="22%">
|
||||
<img src="./assets/images/screenshots/screenshot2.png" width="20%">
|
||||
|
||||
<img src="./assets/images/jellyseerr.PNG" width="23%">
|
||||
<img src="./assets/images/jellyseerr.PNG" width="21%">
|
||||
</p>
|
||||
|
||||
|
||||
## 🌟 Features
|
||||
|
||||
- 🚀 **Skip Intro / Credits Support**
|
||||
- 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking.
|
||||
- 📥 **Download media** (Experimental): Save your media locally and watch it offline.
|
||||
- 📡 **Chromecast** (Experimental): Cast your media to any Chromecast-enabled device.
|
||||
- 📡 **Settings management** (Experimental): Manage app settings for all your users with a JF plugin.
|
||||
- 🤖 **Jellyseerr integration**: Request media directly in the app.
|
||||
- 👁️ **Sessions View:** View all active sessions currently streaming on your server.
|
||||
- 🚀 **Skip Intro / Credits Support**: Lets you quickly skip intros and credits during playback
|
||||
- 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking
|
||||
- 📥 **Download media**: Save your media locally and watch it offline
|
||||
- ⚙️ **Settings management**: Manage app configurations for all users through our plugin
|
||||
- 🤖 **Seerr (formerly Jellyseerr) integration**: Request media directly in the app
|
||||
- 👁️ **Sessions view:** View all active sessions currently streaming on your server
|
||||
- 📡 **Chromecast**: Cast your media to any Chromecast-enabled device
|
||||
|
||||
## 🧪 Experimental Features
|
||||
|
||||
Streamyfin includes some exciting experimental features like media downloading and Chromecast support. These features are still in development, and your patience and feedback are much appreciated as we work to improve them.
|
||||
Streamyfin offers exciting experimental features such as media downloading and Chromecast support. These features are under active development, and your feedback and patience help us make them even better.
|
||||
|
||||
### 📥 Downloading
|
||||
|
||||
Downloading works by using ffmpeg to convert an HLS stream into a video file on the device. This means that you can download and view any file you can stream! The file is converted by Jellyfin on the server in real time as it is downloaded. This means a **bit longer download times** but supports any file that your server can transcode.
|
||||
|
||||
### 🎥 Chromecast
|
||||
|
||||
Chromecast support is still in development, and we're working on improving it. Currently, it supports casting videos, but we're working on adding support for subtitles and other features.
|
||||
Downloading works by using FFmpeg to convert an HLS stream into a video file on your device. This lets you download and watch any content that you can stream. The conversion is handled in real time by Jellyfin on the server during the download. While this may take a bit longer, it ensures compatibility with any file your server can transcode.
|
||||
|
||||
### 🧩 Streamyfin Plugin
|
||||
|
||||
The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that holds all settings for the client Streamyfin. This allows you to synchronize settings across all your users, like for example:
|
||||
|
||||
- Auto log in to Jellyseerr without the user having to do anything
|
||||
- Choose the default languages
|
||||
- Set download method and search provider
|
||||
- Customize home screen
|
||||
- And much more...
|
||||
- Automatic Seerr login with no user input required
|
||||
- Set your preferred default languages
|
||||
- Configure download method and search provider
|
||||
- Personalize your home screen
|
||||
- And much more
|
||||
|
||||
[Streamyfin Plugin](https://github.com/streamyfin/jellyfin-plugin-streamyfin)
|
||||
|
||||
### 📡 Chromecast
|
||||
|
||||
Chromecast support is currently under development. Video casting is already available, and we're actively working on adding subtitle support and additional features.
|
||||
|
||||
### 🔍 Jellysearch
|
||||
|
||||
[Jellysearch](https://gitlab.com/DomiStyle/jellysearch) now works with Streamyfin!
|
||||
[Jellysearch](https://gitlab.com/DomiStyle/jellysearch) works with Streamyfin
|
||||
|
||||
> A fast full-text search proxy for Jellyfin. Integrates seamlessly with most Jellyfin clients.
|
||||
|
||||
## 🛣️ Roadmap for V1
|
||||
## 🛣️ Roadmap
|
||||
|
||||
Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) To see what we're working on next, we are always open to feedback and suggestions. Please let us know if you have any ideas or feature requests.
|
||||
|
||||
## 📥 Get it now
|
||||
## 📥 Download Streamyfin
|
||||
|
||||
<div style="display: flex; gap: 5px;">
|
||||
<a href="https://apps.apple.com/app/streamyfin/id6593660679?l=en-GB"><img height=50 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/></a>
|
||||
<a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin"><img height=50 alt="Get the beta on Google Play" src="./assets/Google_Play_Store_badge_EN.svg"/></a>
|
||||
<a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin"><img height=50 alt="Get Streamyfin on Google Play Store" src="./assets/Google_Play_Store_badge_EN.svg"/></a>
|
||||
<a href="https://github.com/streamyfin/streamyfin/releases/latest"><img height=50 alt="Get Streamyfin on Github" src="./assets/Download_on_Github_.png"/></a>
|
||||
</div>
|
||||
|
||||
Or download the APKs [here on GitHub](https://github.com/streamyfin/streamyfin/releases) for Android.
|
||||
### 🧪 Beta Testing
|
||||
|
||||
### 🧪 Beta testing
|
||||
To access the Streamyfin beta, you need to subscribe to the Member tier (or higher) on [Patreon](https://www.patreon.com/streamyfin). This grants you immediate access to the 🧪-beta-releases channel on Discord and lets me know you’ve subscribed. This is where I share APKs and IPAs. It does not provide automatic TestFlight access, so please send me a DM (Cagemaster) with the email you use for Apple so we can add you manually.
|
||||
|
||||
To access the Streamyfin beta, you need to subscribe to the Member tier (or higher) on [Patreon](https://www.patreon.com/streamyfin). This will give you immediate access to the 🧪-public-beta channel on Discord and I'll know that you have subscribed. This is where I post APKs and IPAs. This won't give automatic access to the TestFlight, however, so you need to send me a DM with the email you use for Apple so that I can manually add you.
|
||||
|
||||
**Note**: Everyone who is actively contributing to the source code of Streamyfin will have automatic access to the betas.
|
||||
**Note**: Anyone actively contributing to Streamyfin’s source code will receive automatic access to beta releases.
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
### Prerequisites
|
||||
### ⚙️ Prerequisites
|
||||
|
||||
- Ensure you have an active Jellyfin server.
|
||||
- Make sure your device is connected to the same network as your Jellyfin server.
|
||||
- Your device is on the same network as the Jellyfin server (for local connections)
|
||||
- Your Jellyfin server is up and running with remote access enabled if you plan to connect from outside your local network
|
||||
- Your server version is up to date (older versions may cause compatibility issues)
|
||||
- You have a valid Jellyfin user account with access to the media libraries you want to view
|
||||
- If using features such as **downloads** or **Seerr integration**, confirm the required plugins are installed and configured on your Jellyfin server
|
||||
|
||||
## 🙌 Contributing
|
||||
|
||||
We welcome any help to make Streamyfin better. If you'd like to contribute, please fork the repository and submit a pull request. For major changes, it's best to open an issue first to discuss your ideas.
|
||||
We welcome contributions that improve Streamyfin. Start by forking the repository and submitting a pull request. For major changes or new features, please open an issue first to discuss your ideas and ensure alignment with the project.
|
||||
|
||||
### 👨💻 Development info
|
||||
## 🌍 Translations
|
||||
|
||||
[](https://crowdin.com/project/streamyfin)
|
||||
|
||||
Streamyfin is available in multiple languages, and we’re always looking for contributors to help make the app accessible worldwide.
|
||||
You can contribute translations directly on our [Crowdin project page](https://crowdin.com/project/streamyfin).
|
||||
|
||||
### 👨💻 Development Info
|
||||
|
||||
1. Use node `>20`
|
||||
2. Install dependencies `bun i && bun run submodule-reload`
|
||||
3. Make sure you have xcode and/or android studio installed. (follow the guides for expo: https://docs.expo.dev/workflow/android-studio-emulator/)
|
||||
4. Install BiomeJS extension in VSCode/Your IDE (https://biomejs.dev/)
|
||||
4. run `npm run prebuild`
|
||||
5. Create an expo dev build by running `npm run ios` or `npm run android`. This will open a simulator on your computer and run the app.
|
||||
5. Create an expo dev build by running `npm run ios` or `npm run android`. This will open a simulator on your computer and run the app
|
||||
|
||||
For the TV version suffix the npm commands with `:tv`.
|
||||
|
||||
`npm run prebuild:tv`
|
||||
`npm run ios:tv or npm run android:tv`
|
||||
|
||||
## 📄 License
|
||||
## 👋 Get in Touch with Us
|
||||
|
||||
Streamyfin is licensed under the Mozilla Public License 2.0 (MPL-2.0).
|
||||
This means you are free to use, modify, and distribute this software. The MPL-2.0 is a copyleft license that allows for more flexibility in combining the software with proprietary code.
|
||||
Key points of the MPL-2.0:
|
||||
Need assistance or have any questions?
|
||||
|
||||
- You can use the software for any purpose
|
||||
- You can modify the software and distribute modified versions
|
||||
- You must include the original copyright and license notices
|
||||
- You must disclose your source code for any modifications to the covered files
|
||||
- Larger works may combine MPL code with code under other licenses
|
||||
- MPL-licensed components must remain under the MPL, but the larger work can be under a different license
|
||||
- For the full text of the license, please see the LICENSE file in this repository
|
||||
- **Discord:** [Join our server](https://discord.gg/BuGG9ZNhaE)
|
||||
- **GitHub Issues:** [Report bugs or request features](https://github.com/streamyfin/streamyfin/issues)
|
||||
- **Email:** [developer@streamyfin.app](mailto:developer@streamyfin.app)
|
||||
|
||||
## 🌐 Connect with Us
|
||||
|
||||
Join our Discord: [](https://discord.gg/BuGG9ZNhaE)
|
||||
|
||||
Need support or have questions:
|
||||
|
||||
- GitHub Issues: Report bugs or request features here.
|
||||
- Email: [developer@streamyfin.app](mailto:developer@streamyfin.app)
|
||||
|
||||
## ❓ FAQ
|
||||
|
||||
1. Q: Why can't I see my libraries in Streamyfin?
|
||||
A: Make sure your server is running one of the latest versions and that you have at least one library that isn't audio only.
|
||||
2. Q: Why can't I see my music library?
|
||||
A: We don't currently support music and are unlikely to support music in the near future.
|
||||
1. Q: Why can't I see my libraries in Streamyfin?
|
||||
A: Make sure your server is running one of the latest versions and that you have at least one library that isn't audio only
|
||||
2. Q: Why can't I see my music library?
|
||||
A: We don't currently support music and are unlikely to support music in the near future
|
||||
|
||||
## 📝 Credits
|
||||
|
||||
Streamyfin is developed by [Fredrik Burmester](https://github.com/fredrikburmester) and is not affiliated with Jellyfin. The app is built with Expo, React Native, and other open-source libraries.
|
||||
Streamyfin is developed by [Fredrik Burmester](https://github.com/fredrikburmester) and is not affiliated with Jellyfin. The app is built using Expo, React Native, and other open-source libraries.
|
||||
|
||||
## ✨ Acknowledgements
|
||||
|
||||
We would like to thank the Jellyfin team for their great software and awesome support on discord.
|
||||
|
||||
Special shoutout to the JF official clients for being an inspiration to ours.
|
||||
|
||||
### 🏆 Core Developers
|
||||
## 🎖️ Core Developers
|
||||
|
||||
Thanks to the following contributors for their significant contributions:
|
||||
|
||||
@@ -229,21 +218,41 @@ Thanks to the following contributors for their significant contributions:
|
||||
</table>
|
||||
</div>
|
||||
|
||||
And all other developers who have contributed to Streamyfin, thank you for your contributions.
|
||||
## ✨ Acknowledgements
|
||||
|
||||
I'd also like to thank the following people and projects for their contributions to Streamyfin:
|
||||
We would like to thank the Jellyfin team for their excellent software and support on Discord.
|
||||
|
||||
Special thanks to the official Jellyfin clients, which have served as an inspiration for Streamyfin.
|
||||
|
||||
We also thank all other developers who have contributed to Streamyfin, your efforts are greatly appreciated.
|
||||
|
||||
A special mention to the following people and projects for their contributions:
|
||||
|
||||
- [Reiverr](https://github.com/aleksilassila/reiverr) for invaluable help with understanding the Jellyfin API
|
||||
- [Jellyfin TS SDK](https://github.com/jellyfin/jellyfin-sdk-typescript) for providing the TypeScript SDK
|
||||
- [Seerr](https://github.com/seerr-team/seerr) for enabling API integration with their project
|
||||
|
||||
- [Reiverr](https://github.com/aleksilassila/reiverr) for great help with understanding the Jellyfin API.
|
||||
- [Jellyfin TS SDK](https://github.com/jellyfin/jellyfin-sdk-typescript) for the TypeScript SDK.
|
||||
- [Jellyseerr](https://github.com/Fallenbagel/jellyseerr) for enabling API integration with their project.
|
||||
- The Jellyfin devs for always being helpful in the Discord.
|
||||
|
||||
## ⭐ Star History
|
||||
|
||||
[](https://star-history.com/#streamyfin/streamyfin&Date)
|
||||
|
||||
## 📄 License
|
||||
|
||||
Streamyfin is licensed under the Mozilla Public License 2.0 (MPL-2.0).
|
||||
This means you are free to use, modify, and distribute this software. The MPL-2.0 is a copyleft license that allows for more flexibility in combining the software with proprietary code.
|
||||
Key points of the MPL-2.0:
|
||||
|
||||
- You can use the software for any purpose
|
||||
- You can modify the software and distribute modified versions
|
||||
- You must include the original copyright and license notices
|
||||
- You must disclose your source code for any modifications to the covered files
|
||||
- Larger works may combine MPL code with code under other licenses
|
||||
- MPL-licensed components must remain under the MPL, but the larger work can be under a different license
|
||||
- For the full text of the license, please see the LICENSE file in this repository
|
||||
|
||||
## ⚠️ Disclaimer
|
||||
Streamyfin does not promote, support, or condone piracy in any form. The app is intended solely for streaming media that you personally own and control. It does not provide or include any media content. Any discussions or support requests related to piracy are strictly prohibited across all our channels.
|
||||
Streamyfin does not promote, support, or condone piracy in any form. The app is intended solely for streaming media that you personally own and control. It does not provide or include any media content. Any discussions, support requests, or references to piracy, as well as any tools, software, or websites related to piracy, are strictly prohibited across all our channels.
|
||||
|
||||
## 🤝 Sponsorship
|
||||
VPS hosting generously provided by [Hexabyte](https://hexabyte.se/en/vps/?currency=eur) and [SweHosting](https://swehosting.se/en/#tj%C3%A4nster)
|
||||
|
||||
@@ -10,10 +10,15 @@ module.exports = ({ config }) => {
|
||||
// Add the background downloader plugin only for non-TV builds
|
||||
config.plugins.push("./plugins/withRNBackgroundDownloader.js");
|
||||
}
|
||||
|
||||
// Only override googleServicesFile if env var is set
|
||||
const androidConfig = {};
|
||||
if (process.env.GOOGLE_SERVICES_JSON) {
|
||||
androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON;
|
||||
}
|
||||
|
||||
return {
|
||||
android: {
|
||||
googleServicesFile: process.env.GOOGLE_SERVICES_JSON,
|
||||
},
|
||||
...(Object.keys(androidConfig).length > 0 && { android: androidConfig }),
|
||||
...config,
|
||||
};
|
||||
};
|
||||
|
||||
9
app.json
9
app.json
@@ -2,13 +2,12 @@
|
||||
"expo": {
|
||||
"name": "Streamyfin",
|
||||
"slug": "streamyfin",
|
||||
"version": "0.39.0",
|
||||
"version": "0.40.1",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "streamyfin",
|
||||
"userInterfaceStyle": "dark",
|
||||
"jsEngine": "hermes",
|
||||
"newArchEnabled": true,
|
||||
"assetBundlePatterns": ["**/*"],
|
||||
"ios": {
|
||||
"requireFullScreen": true,
|
||||
@@ -38,7 +37,7 @@
|
||||
},
|
||||
"android": {
|
||||
"jsEngine": "hermes",
|
||||
"versionCode": 71,
|
||||
"versionCode": 73,
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/icon-android-plain.png",
|
||||
"monochromeImage": "./assets/images/icon-android-themed.png",
|
||||
@@ -78,7 +77,6 @@
|
||||
"useFrameworks": "static"
|
||||
},
|
||||
"android": {
|
||||
"buildArchs": ["arm64-v8a", "x86_64"],
|
||||
"compileSdkVersion": 35,
|
||||
"targetSdkVersion": 35,
|
||||
"buildToolsVersion": "35.0.0",
|
||||
@@ -156,6 +154,7 @@
|
||||
},
|
||||
"updates": {
|
||||
"url": "https://u.expo.dev/e79219d1-797f-4fbe-9fa1-cfd360690a68"
|
||||
}
|
||||
},
|
||||
"newArchEnabled": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -91,7 +91,7 @@ export default function page() {
|
||||
title: series[0].item.SeriesName,
|
||||
});
|
||||
} else {
|
||||
storage.remove(seriesId);
|
||||
storage.delete(seriesId);
|
||||
router.back();
|
||||
}
|
||||
}, [series]);
|
||||
|
||||
@@ -62,10 +62,7 @@ export default function page() {
|
||||
);
|
||||
};
|
||||
|
||||
const downloadedFiles = useMemo(
|
||||
() => getDownloadedItems(),
|
||||
[getDownloadedItems],
|
||||
);
|
||||
const downloadedFiles = getDownloadedItems();
|
||||
|
||||
const movies = useMemo(() => {
|
||||
try {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { HomeIndex } from "@/components/home/HomeIndex";
|
||||
import { HomeIndex } from "@/components/settings/HomeIndex";
|
||||
|
||||
export default function page() {
|
||||
return <HomeIndex />;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -393,6 +393,7 @@ const page: React.FC = () => {
|
||||
data={flatData}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemSize={255}
|
||||
numColumns={
|
||||
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
@@ -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'>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
109
app/_layout.tsx
109
app/_layout.tsx
@@ -2,10 +2,8 @@ import "@/augmentations";
|
||||
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||
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,
|
||||
@@ -38,7 +36,7 @@ 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 * as FileSystem from "expo-file-system";
|
||||
|
||||
const Notifications = !Platform.isTV ? require("expo-notifications") : null;
|
||||
|
||||
@@ -145,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;
|
||||
@@ -388,7 +386,7 @@ function Layout() {
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.isTV || !BackGroundDownloader) {
|
||||
if (Platform.isTV) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -397,7 +395,7 @@ function Layout() {
|
||||
appState.current.match(/inactive|background/) &&
|
||||
nextAppState === "active"
|
||||
) {
|
||||
BackGroundDownloader?.checkForExistingDownloads().catch(
|
||||
BackGroundDownloader.checkForExistingDownloads().catch(
|
||||
(error: unknown) => {
|
||||
writeErrorLog("Failed to resume background downloads", error);
|
||||
},
|
||||
@@ -405,11 +403,9 @@ function Layout() {
|
||||
}
|
||||
});
|
||||
|
||||
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();
|
||||
};
|
||||
@@ -422,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
BIN
assets/Download_on_Github_.png
Normal file
BIN
assets/Download_on_Github_.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.5 KiB |
@@ -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 |
@@ -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));
|
||||
}
|
||||
|
||||
@@ -2,6 +2,6 @@ module.exports = (api) => {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ["babel-preset-expo"],
|
||||
plugins: ["nativewind/babel", "react-native-worklets/plugin"],
|
||||
plugins: ["nativewind/babel", "react-native-reanimated/plugin"],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.2.4/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.2.7/schema.json",
|
||||
"files": {
|
||||
"includes": [
|
||||
"**/*",
|
||||
|
||||
@@ -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;
|
||||
@@ -40,17 +39,16 @@ interface AppleTVCarouselProps {
|
||||
const { width: screenWidth, height: screenHeight } = Dimensions.get("window");
|
||||
|
||||
// Layout Constants
|
||||
const CAROUSEL_HEIGHT = screenHeight / 1.45;
|
||||
export const APPLE_TV_CAROUSEL_HEIGHT = screenHeight / 1.45;
|
||||
const GRADIENT_HEIGHT_TOP = 150;
|
||||
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 = 400;
|
||||
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;
|
||||
|
||||
@@ -390,7 +381,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
||||
<View
|
||||
style={{
|
||||
width: screenWidth,
|
||||
height: CAROUSEL_HEIGHT,
|
||||
height: APPLE_TV_CAROUSEL_HEIGHT,
|
||||
backgroundColor: "#000",
|
||||
}}
|
||||
>
|
||||
@@ -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={{
|
||||
@@ -588,7 +549,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
||||
key={item.Id}
|
||||
style={{
|
||||
width: screenWidth,
|
||||
height: CAROUSEL_HEIGHT,
|
||||
height: APPLE_TV_CAROUSEL_HEIGHT,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
@@ -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>
|
||||
@@ -806,7 +731,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
height: CAROUSEL_HEIGHT,
|
||||
height: APPLE_TV_CAROUSEL_HEIGHT,
|
||||
backgroundColor: "#000",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
@@ -824,7 +749,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
height: CAROUSEL_HEIGHT, // Fixed height instead of flex: 1
|
||||
height: APPLE_TV_CAROUSEL_HEIGHT, // Fixed height instead of flex: 1
|
||||
backgroundColor: "#000",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
@@ -833,7 +758,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
height: CAROUSEL_HEIGHT, // Fixed height instead of flex: 1
|
||||
height: APPLE_TV_CAROUSEL_HEIGHT, // Fixed height instead of flex: 1
|
||||
flexDirection: "row",
|
||||
width: screenWidth * items.length,
|
||||
},
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -66,10 +66,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
|
||||
const { processes, startBackgroundDownload, getDownloadedItems } =
|
||||
useDownload();
|
||||
const downloadedFiles = useMemo(
|
||||
() => getDownloadedItems(),
|
||||
[getDownloadedItems],
|
||||
);
|
||||
const downloadedFiles = getDownloadedItems();
|
||||
|
||||
const [selectedOptions, setSelectedOptions] = useState<
|
||||
SelectedOptions | undefined
|
||||
@@ -362,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>
|
||||
@@ -385,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) => {
|
||||
@@ -434,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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import type { PropsWithChildren, ReactElement } from "react";
|
||||
import { type NativeScrollEvent, View, type ViewProps } from "react-native";
|
||||
import type {
|
||||
MutableRefObject,
|
||||
PropsWithChildren,
|
||||
ReactElement,
|
||||
Ref,
|
||||
} from "react";
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
type NativeScrollEvent,
|
||||
type ScrollViewProps,
|
||||
type StyleProp,
|
||||
View,
|
||||
type ViewProps,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import Animated, {
|
||||
interpolate,
|
||||
useAnimatedRef,
|
||||
@@ -14,6 +27,9 @@ interface Props extends ViewProps {
|
||||
episodePoster?: ReactElement;
|
||||
headerHeight?: number;
|
||||
onEndReached?: (() => void) | null | undefined;
|
||||
scrollViewProps?: Animated.AnimatedProps<ScrollViewProps>;
|
||||
contentContainerStyle?: StyleProp<ViewStyle>;
|
||||
scrollViewRef?: Ref<Animated.ScrollView>;
|
||||
}
|
||||
|
||||
export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
|
||||
@@ -23,10 +39,33 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
|
||||
headerHeight = 400,
|
||||
logo,
|
||||
onEndReached,
|
||||
contentContainerStyle,
|
||||
scrollViewProps,
|
||||
scrollViewRef,
|
||||
...props
|
||||
}: Props) => {
|
||||
const scrollRef = useAnimatedRef<Animated.ScrollView>();
|
||||
const scrollOffset = useScrollViewOffset(scrollRef);
|
||||
const animatedScrollRef = useAnimatedRef<Animated.ScrollView>();
|
||||
const scrollOffset = useScrollViewOffset(animatedScrollRef);
|
||||
|
||||
const {
|
||||
onScroll: externalOnScroll,
|
||||
style: scrollStyle,
|
||||
scrollEventThrottle: externalScrollEventThrottle,
|
||||
...restScrollViewProps
|
||||
} = scrollViewProps ?? {};
|
||||
|
||||
useEffect(() => {
|
||||
if (!scrollViewRef) return;
|
||||
const node = animatedScrollRef.current;
|
||||
|
||||
if (typeof scrollViewRef === "function") {
|
||||
scrollViewRef(node);
|
||||
return () => scrollViewRef(null);
|
||||
}
|
||||
|
||||
(scrollViewRef as MutableRefObject<Animated.ScrollView | null>).current =
|
||||
node;
|
||||
}, [animatedScrollRef, scrollViewRef]);
|
||||
|
||||
const headerAnimatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
@@ -62,12 +101,17 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
|
||||
return (
|
||||
<View className='flex-1' {...props}>
|
||||
<Animated.ScrollView
|
||||
style={{
|
||||
position: "relative",
|
||||
}}
|
||||
ref={scrollRef}
|
||||
scrollEventThrottle={16}
|
||||
{...restScrollViewProps}
|
||||
style={[
|
||||
{
|
||||
position: "relative",
|
||||
},
|
||||
scrollStyle,
|
||||
]}
|
||||
ref={animatedScrollRef}
|
||||
scrollEventThrottle={externalScrollEventThrottle ?? 16}
|
||||
onScroll={(e) => {
|
||||
externalOnScroll?.(e);
|
||||
if (isCloseToBottom(e.nativeEvent)) onEndReached?.();
|
||||
}}
|
||||
>
|
||||
@@ -96,9 +140,12 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
|
||||
</Animated.View>
|
||||
|
||||
<View
|
||||
style={{
|
||||
top: -50,
|
||||
}}
|
||||
style={[
|
||||
{
|
||||
top: -50,
|
||||
},
|
||||
contentContainerStyle,
|
||||
]}
|
||||
className='relative flex-1 bg-transparent pb-24'
|
||||
>
|
||||
<LinearGradient
|
||||
|
||||
@@ -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
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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 space-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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
125
components/common/Dropdown.tsx
Normal file
125
components/common/Dropdown.tsx
Normal 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;
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -14,10 +14,7 @@ export const DownloadSize: React.FC<DownloadSizeProps> = ({
|
||||
...props
|
||||
}) => {
|
||||
const { getDownloadedItemSize, getDownloadedItems } = useDownload();
|
||||
const downloadedFiles = useMemo(
|
||||
() => getDownloadedItems(),
|
||||
[getDownloadedItems],
|
||||
);
|
||||
const downloadedFiles = getDownloadedItems();
|
||||
const [size, setSize] = useState<string | undefined>();
|
||||
|
||||
const itemIds = useMemo(() => items.map((i) => i.Id), [items]);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -29,10 +29,7 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const { getDownloadedItems } = useDownload();
|
||||
const downloadedFiles = useMemo(
|
||||
() => getDownloadedItems(),
|
||||
[getDownloadedItems],
|
||||
);
|
||||
const downloadedFiles = getDownloadedItems();
|
||||
|
||||
const scrollRef = useRef<HorizontalScrollRef>(null);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
@@ -15,14 +16,15 @@ import { type QueryFunction, useQuery } from "@tanstack/react-query";
|
||||
import { useNavigation, useRouter, useSegments } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import Animated from "react-native-reanimated";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Platform,
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
ScrollView,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Button } from "@/components/Button";
|
||||
@@ -37,7 +39,8 @@ 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 { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
import { AppleTVCarousel, APPLE_TV_CAROUSEL_HEIGHT } from "../AppleTVCarousel";
|
||||
|
||||
type ScrollingCollectionListSection = {
|
||||
type: "ScrollingCollectionList";
|
||||
@@ -65,12 +68,13 @@ export const HomeIndex = () => {
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { settings, refreshStreamyfinPluginSettings } = useSettings();
|
||||
const showCarousel = settings?.showHomeCarousel ?? true;
|
||||
|
||||
const navigation = useNavigation();
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const scrollViewRef = useRef<ScrollView>(null);
|
||||
const scrollViewRef = useRef<Animated.ScrollView | null>(null);
|
||||
|
||||
const { getDownloadedItems, cleanCacheDirectory } = useDownload();
|
||||
const prevIsConnected = useRef<boolean | null>(false);
|
||||
@@ -90,11 +94,6 @@ export const HomeIndex = () => {
|
||||
prevIsConnected.current = isConnected;
|
||||
}, [isConnected, invalidateCache]);
|
||||
|
||||
const hasDownloads = useMemo(() => {
|
||||
if (Platform.isTV) return false;
|
||||
return getDownloadedItems().length > 0;
|
||||
}, [getDownloadedItems]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.isTV) {
|
||||
navigation.setOptions({
|
||||
@@ -102,6 +101,7 @@ export const HomeIndex = () => {
|
||||
});
|
||||
return;
|
||||
}
|
||||
const hasDownloads = getDownloadedItems().length > 0;
|
||||
navigation.setOptions({
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity
|
||||
@@ -118,7 +118,7 @@ export const HomeIndex = () => {
|
||||
</TouchableOpacity>
|
||||
),
|
||||
});
|
||||
}, [navigation, router, hasDownloads]);
|
||||
}, [navigation, router]);
|
||||
|
||||
useEffect(() => {
|
||||
cleanCacheDirectory().catch((_e) =>
|
||||
@@ -131,7 +131,7 @@ export const HomeIndex = () => {
|
||||
const unsubscribe = eventBus.on("scrollToTop", () => {
|
||||
if ((segments as string[])[2] === "(home)")
|
||||
scrollViewRef.current?.scrollTo({
|
||||
y: Platform.isTV ? -152 : -100,
|
||||
y: 0,
|
||||
animated: true,
|
||||
});
|
||||
});
|
||||
@@ -219,7 +219,9 @@ export const HomeIndex = () => {
|
||||
|
||||
const latestMediaViews = collections.map((c) => {
|
||||
const includeItemTypes: BaseItemKind[] =
|
||||
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"];
|
||||
c.CollectionType === "tvshows" || c.CollectionType === "movies"
|
||||
? []
|
||||
: ["Movie"];
|
||||
const title = t("home.recently_added_in", { libraryName: c.Name });
|
||||
const queryKey = [
|
||||
"home",
|
||||
@@ -360,6 +362,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",
|
||||
@@ -446,25 +458,36 @@ export const HomeIndex = () => {
|
||||
</View>
|
||||
);
|
||||
|
||||
const headerHeight = showCarousel ? APPLE_TV_CAROUSEL_HEIGHT : 120;
|
||||
const refreshProgressOffset = showCarousel ? 200 : 80;
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
scrollToOverflowEnabled={true}
|
||||
ref={scrollViewRef}
|
||||
nestedScrollEnabled
|
||||
contentInsetAdjustmentBehavior='never'
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={loading}
|
||||
onRefresh={refetch}
|
||||
tintColor='white' // For iOS
|
||||
colors={["white"]} // For Android
|
||||
progressViewOffset={200} // This offsets the refresh indicator to appear over the carousel
|
||||
/>
|
||||
<ParallaxScrollView
|
||||
scrollViewRef={scrollViewRef}
|
||||
headerHeight={headerHeight}
|
||||
headerImage={
|
||||
showCarousel ? (
|
||||
<AppleTVCarousel initialIndex={0} />
|
||||
) : (
|
||||
<View className='flex-1 bg-black' />
|
||||
)
|
||||
}
|
||||
style={{ marginTop: Platform.isTV ? 0 : -100 }}
|
||||
contentContainerStyle={{ paddingTop: Platform.isTV ? 0 : 100 }}
|
||||
contentContainerStyle={showCarousel ? undefined : { top: 0 }}
|
||||
scrollViewProps={{
|
||||
refreshControl: (
|
||||
<RefreshControl
|
||||
refreshing={loading}
|
||||
onRefresh={refetch}
|
||||
tintColor='white'
|
||||
colors={["white"]}
|
||||
progressViewOffset={refreshProgressOffset}
|
||||
/>
|
||||
),
|
||||
contentInsetAdjustmentBehavior: "never",
|
||||
nestedScrollEnabled: true,
|
||||
scrollToOverflowEnabled: true,
|
||||
}}
|
||||
>
|
||||
<AppleTVCarousel initialIndex={0} />
|
||||
<View
|
||||
style={{
|
||||
paddingLeft: insets.left,
|
||||
@@ -500,7 +523,7 @@ export const HomeIndex = () => {
|
||||
</View>
|
||||
</View>
|
||||
<View className='h-24' />
|
||||
</ScrollView>
|
||||
</ParallaxScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
@@ -62,6 +62,7 @@ export const OtherSettings: React.FC = () => {
|
||||
pluginSettings?.followDeviceOrientation?.locked === true &&
|
||||
pluginSettings?.defaultVideoOrientation?.locked === true &&
|
||||
pluginSettings?.safeAreaInControlsEnabled?.locked === true &&
|
||||
pluginSettings?.showHomeCarousel?.locked === true &&
|
||||
pluginSettings?.showCustomMenuLinks?.locked === true &&
|
||||
pluginSettings?.hiddenLibraries?.locked === true &&
|
||||
pluginSettings?.disableHapticFeedback?.locked === true,
|
||||
@@ -89,52 +90,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 +115,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 +137,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>
|
||||
|
||||
@@ -195,6 +159,19 @@ export const OtherSettings: React.FC = () => {
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={t("home.settings.other.show_home_carousel")}
|
||||
disabled={pluginSettings?.showHomeCarousel?.locked}
|
||||
>
|
||||
<Switch
|
||||
value={settings.showHomeCarousel}
|
||||
disabled={pluginSettings?.showHomeCarousel?.locked}
|
||||
onValueChange={(value) =>
|
||||
updateSettings({ showHomeCarousel: value })
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
{/* {(Platform.OS === "ios" || Platform.isTVOS)&& (
|
||||
<ListItem
|
||||
title={t("home.settings.other.video_player")}
|
||||
@@ -251,10 +228,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 +243,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 +262,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 +276,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />,
|
||||
};
|
||||
|
||||
|
||||
@@ -56,10 +56,7 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
}, []);
|
||||
|
||||
const { getDownloadedItems } = useDownload();
|
||||
const downloadedFiles = useMemo(
|
||||
() => getDownloadedItems(),
|
||||
[getDownloadedItems],
|
||||
);
|
||||
const downloadedFiles = getDownloadedItems();
|
||||
|
||||
const seasonIndex = seasonIndexState[item.ParentId ?? ""];
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ export const useControlsTimeout = ({
|
||||
isSliding,
|
||||
episodeView,
|
||||
onHideControls,
|
||||
timeout = 10000,
|
||||
timeout = 4000,
|
||||
}: UseControlsTimeoutProps) => {
|
||||
const controlsTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
|
||||
6
eas.json
6
eas.json
@@ -45,14 +45,14 @@
|
||||
},
|
||||
"production": {
|
||||
"environment": "production",
|
||||
"channel": "0.39.0",
|
||||
"channel": "0.40.1",
|
||||
"android": {
|
||||
"image": "latest"
|
||||
}
|
||||
},
|
||||
"production-apk": {
|
||||
"environment": "production",
|
||||
"channel": "0.39.0",
|
||||
"channel": "0.40.1",
|
||||
"android": {
|
||||
"buildType": "apk",
|
||||
"image": "latest"
|
||||
@@ -60,7 +60,7 @@
|
||||
},
|
||||
"production-apk-tv": {
|
||||
"environment": "production",
|
||||
"channel": "0.39.0",
|
||||
"channel": "0.40.1",
|
||||
"android": {
|
||||
"buildType": "apk",
|
||||
"image": "latest"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
6
i18n.ts
6
i18n.ts
@@ -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 },
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
# login.yaml
|
||||
|
||||
appId: your.app.id
|
||||
---
|
||||
- launchApp
|
||||
- tapOn: "Text on the screen"
|
||||
@@ -2,7 +2,7 @@
|
||||
const { getDefaultConfig } = require("expo/metro-config");
|
||||
|
||||
/** @type {import('expo/metro-config').MetroConfig} */
|
||||
const config = getDefaultConfig(__dirname);
|
||||
const config = getDefaultConfig(__dirname); // eslint-disable-line no-undef
|
||||
|
||||
// Add Hermes parser
|
||||
config.transformer.hermesParser = true;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {
|
||||
import type {
|
||||
ChapterInfo,
|
||||
PlaybackStatePayload,
|
||||
ProgressUpdatePayload,
|
||||
@@ -12,16 +12,20 @@ import {
|
||||
} from "./VlcPlayer.types";
|
||||
import VlcPlayerView from "./VlcPlayerView";
|
||||
|
||||
export {
|
||||
VlcPlayerView,
|
||||
VlcPlayerViewProps,
|
||||
VlcPlayerViewRef,
|
||||
// Component
|
||||
export { VlcPlayerView };
|
||||
|
||||
// Component Types
|
||||
export type { VlcPlayerViewProps, VlcPlayerViewRef };
|
||||
|
||||
// Media Types
|
||||
export type { ChapterInfo, TrackInfo, VlcPlayerSource };
|
||||
|
||||
// Playback Events (alphabetically sorted)
|
||||
export type {
|
||||
PlaybackStatePayload,
|
||||
ProgressUpdatePayload,
|
||||
VideoLoadStartPayload,
|
||||
VideoStateChangePayload,
|
||||
VideoProgressPayload,
|
||||
VlcPlayerSource,
|
||||
TrackInfo,
|
||||
ChapterInfo,
|
||||
VideoStateChangePayload,
|
||||
};
|
||||
|
||||
133
package.json
133
package.json
@@ -22,108 +22,107 @@
|
||||
"test": "bun run typecheck && bun run lint && bun run format && bun run doctor"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bottom-tabs/react-navigation": "^0.12.2",
|
||||
"@expo/metro-runtime": "~6.1.1",
|
||||
"@bottom-tabs/react-navigation": "^0.11.2",
|
||||
"@expo/metro-runtime": "~5.0.5",
|
||||
"@expo/react-native-action-sheet": "^4.1.1",
|
||||
"@expo/ui": "^0.2.0-beta.4",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@expo/vector-icons": "^14.1.0",
|
||||
"@gorhom/bottom-sheet": "^5.1.0",
|
||||
"@jellyfin/sdk": "^0.11.0",
|
||||
"@kesha-antonov/react-native-background-downloader": "github:fredrikburmester/react-native-background-downloader#d78699b60866062f6d95887412cee3649a548bf2",
|
||||
"@kesha-antonov/react-native-background-downloader": "^3.2.6",
|
||||
"@react-native-community/netinfo": "^11.4.1",
|
||||
"@react-native-menu/menu": "1.2.3",
|
||||
"@react-navigation/material-top-tabs": "^7.2.14",
|
||||
"@react-navigation/native": "^7.0.14",
|
||||
"@shopify/flash-list": "2.0.2",
|
||||
"@shopify/flash-list": "^1.8.3",
|
||||
"@tanstack/react-query": "^5.66.0",
|
||||
"axios": "^1.7.9",
|
||||
"expo": "^54.0.10",
|
||||
"expo-application": "~7.0.5",
|
||||
"expo-asset": "~12.0.6",
|
||||
"expo-background-task": "~1.0.5",
|
||||
"expo-blur": "~15.0.5",
|
||||
"expo-brightness": "~14.0.5",
|
||||
"expo-build-properties": "~1.0.6",
|
||||
"expo-constants": "~18.0.6",
|
||||
"expo-dev-client": "~6.0.7",
|
||||
"expo-device": "~8.0.5",
|
||||
"expo-font": "~14.0.6",
|
||||
"expo-haptics": "~15.0.5",
|
||||
"expo-image": "~3.0.5",
|
||||
"expo-linear-gradient": "~15.0.5",
|
||||
"expo-linking": "~8.0.6",
|
||||
"expo-localization": "~17.0.5",
|
||||
"expo-notifications": "~0.32.7",
|
||||
"expo-router": "~6.0.0-preview.12",
|
||||
"expo-screen-orientation": "~9.0.5",
|
||||
"expo-sensors": "~15.0.5",
|
||||
"expo-sharing": "~14.0.5",
|
||||
"expo-splash-screen": "~31.0.7",
|
||||
"expo-status-bar": "~3.0.6",
|
||||
"expo-system-ui": "~6.0.5",
|
||||
"expo-task-manager": "~14.0.5",
|
||||
"expo-web-browser": "~15.0.5",
|
||||
"expo": "^53.0.23",
|
||||
"expo-application": "~6.1.4",
|
||||
"expo-asset": "~11.1.7",
|
||||
"expo-background-task": "~0.2.8",
|
||||
"expo-blur": "~14.1.4",
|
||||
"expo-brightness": "~13.1.4",
|
||||
"expo-build-properties": "~0.14.6",
|
||||
"expo-constants": "~17.1.5",
|
||||
"expo-device": "~7.1.4",
|
||||
"expo-font": "~13.3.1",
|
||||
"expo-haptics": "~14.1.4",
|
||||
"expo-image": "~2.4.0",
|
||||
"expo-linear-gradient": "~14.1.4",
|
||||
"expo-linking": "~7.1.4",
|
||||
"expo-localization": "~16.1.5",
|
||||
"expo-notifications": "~0.31.2",
|
||||
"expo-router": "~5.1.7",
|
||||
"expo-screen-orientation": "~8.1.6",
|
||||
"expo-sensors": "~14.1.4",
|
||||
"expo-sharing": "~13.1.5",
|
||||
"expo-splash-screen": "~0.30.8",
|
||||
"expo-status-bar": "~2.2.3",
|
||||
"expo-system-ui": "~5.0.11",
|
||||
"expo-task-manager": "~13.1.6",
|
||||
"expo-web-browser": "~14.2.0",
|
||||
"i18next": "^25.0.0",
|
||||
"jotai": "^2.12.5",
|
||||
"lodash": "^4.17.21",
|
||||
"nativewind": "^2.0.11",
|
||||
"patch-package": "^8.0.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"react-i18next": "^15.4.0",
|
||||
"react-native": "npm:react-native-tvos@0.81.4-0",
|
||||
"react-native": "npm:react-native-tvos@0.79.5-0",
|
||||
"react-native-awesome-slider": "^2.9.0",
|
||||
"react-native-bottom-tabs": "^0.12.2",
|
||||
"react-native-bottom-tabs": "^0.11.2",
|
||||
"react-native-circular-progress": "^1.4.1",
|
||||
"react-native-collapsible": "^1.6.2",
|
||||
"react-native-country-flag": "^2.0.2",
|
||||
"react-native-device-info": "^14.0.4",
|
||||
"react-native-edge-to-edge": "^1.7.0",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-gesture-handler": "~2.24.0",
|
||||
"react-native-google-cast": "^4.9.0",
|
||||
"react-native-image-colors": "^2.4.0",
|
||||
"react-native-ios-context-menu": "^3.2.1",
|
||||
"react-native-ios-utilities": "5.2.0",
|
||||
"react-native-mmkv": "4.0.0-beta.12",
|
||||
"react-native-nitro-modules": "^0.29.1",
|
||||
"react-native-ios-context-menu": "^3.1.0",
|
||||
"react-native-ios-utilities": "5.1.8",
|
||||
"react-native-mmkv": "2.12.2",
|
||||
"react-native-pager-view": "^6.9.1",
|
||||
"react-native-reanimated": "~4.1.0",
|
||||
"react-native-reanimated": "~3.19.1",
|
||||
"react-native-reanimated-carousel": "4.0.2",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-svg": "15.12.1",
|
||||
"react-native-safe-area-context": "5.4.0",
|
||||
"react-native-screens": "~4.11.1",
|
||||
"react-native-svg": "15.11.2",
|
||||
"react-native-udp": "^4.1.7",
|
||||
"react-native-url-polyfill": "^2.0.0",
|
||||
"react-native-uuid": "^2.0.3",
|
||||
"react-native-video": "6.16.1",
|
||||
"react-native-video": "6.14.1",
|
||||
"react-native-volume-manager": "^2.0.8",
|
||||
"react-native-web": "^0.21.0",
|
||||
"react-native-worklets": "0.5.1",
|
||||
"react-native-web": "^0.20.0",
|
||||
"sonner-native": "^0.21.0",
|
||||
"tailwindcss": "3.3.2",
|
||||
"use-debounce": "^10.0.4",
|
||||
"zeego": "^3.0.6",
|
||||
"zod": "^4.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.20.0",
|
||||
"@biomejs/biome": "^2.2.4",
|
||||
"@react-native-community/cli": "^20.0.0",
|
||||
"@react-native-tvos/config-tv": "^0.1.1",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/lodash": "^4.17.15",
|
||||
"@types/react": "~19.1.10",
|
||||
"@types/react-test-renderer": "^19.0.0",
|
||||
"cross-env": "^10.0.0",
|
||||
"expo-doctor": "^1.17.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.1.5",
|
||||
"postinstall-postinstall": "^2.1.0",
|
||||
"@babel/core": "7.28.5",
|
||||
"@biomejs/biome": "2.2.7",
|
||||
"@react-native-community/cli": "20.0.2",
|
||||
"@react-native-tvos/config-tv": "0.1.4",
|
||||
"@types/jest": "30.0.0",
|
||||
"@types/lodash": "4.17.20",
|
||||
"@types/react": "~19.0.10",
|
||||
"@types/react-test-renderer": "19.1.0",
|
||||
"cross-env": "10.1.0",
|
||||
"expo-dev-client": "5.2.4",
|
||||
"expo-doctor": "1.17.11",
|
||||
"husky": "9.1.7",
|
||||
"lint-staged": "16.2.6",
|
||||
"react-test-renderer": "19.1.1",
|
||||
"typescript": "~5.9.2"
|
||||
"typescript": "5.8.3"
|
||||
},
|
||||
"expo": {
|
||||
"install": {
|
||||
"exclude": [
|
||||
"react-native"
|
||||
"react-native",
|
||||
"@shopify/flash-list",
|
||||
"react-native-reanimated",
|
||||
"react-native-pager-view"
|
||||
]
|
||||
},
|
||||
"doctor": {
|
||||
@@ -140,9 +139,6 @@
|
||||
}
|
||||
},
|
||||
"private": true,
|
||||
"disabledDependencies": {
|
||||
"@kesha-antonov/react-native-background-downloader": "^3.2.6"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx}": [
|
||||
"biome check --write --unsafe --no-errors-on-unmatched"
|
||||
@@ -152,7 +148,6 @@
|
||||
]
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"postinstall-postinstall",
|
||||
"unrs-resolver"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
diff --git a/node_modules/@react-native-menu/menu/android/.DS_Store b/node_modules/@react-native-menu/menu/android/.DS_Store
|
||||
new file mode 100644
|
||||
index 0000000..5008ddf
|
||||
Binary files /dev/null and b/node_modules/@react-native-menu/menu/android/.DS_Store differ
|
||||
diff --git a/node_modules/@react-native-menu/menu/android/src/main/java/com/reactnativemenu/MenuView.kt b/node_modules/@react-native-menu/menu/android/src/main/java/com/reactnativemenu/MenuView.kt
|
||||
index 17ed7c6..c45f5cc 100644
|
||||
--- a/node_modules/@react-native-menu/menu/android/src/main/java/com/reactnativemenu/MenuView.kt
|
||||
+++ b/node_modules/@react-native-menu/menu/android/src/main/java/com/reactnativemenu/MenuView.kt
|
||||
@@ -24,6 +24,11 @@ class MenuView(private val mContext: ReactContext) : ReactViewGroup(mContext) {
|
||||
private var mIsOnLongPress = false
|
||||
private var mGestureDetector: GestureDetector
|
||||
private var mHitSlopRect: Rect? = null
|
||||
+ set(value) {
|
||||
+ super.hitSlopRect = value
|
||||
+ mHitSlopRect = value
|
||||
+ updateTouchDelegate()
|
||||
+ }
|
||||
|
||||
init {
|
||||
mGestureDetector = GestureDetector(mContext, object : GestureDetector.SimpleOnGestureListener() {
|
||||
@@ -47,12 +52,6 @@ class MenuView(private val mContext: ReactContext) : ReactViewGroup(mContext) {
|
||||
prepareMenu()
|
||||
}
|
||||
|
||||
- override fun setHitSlopRect(rect: Rect?) {
|
||||
- super.setHitSlopRect(rect)
|
||||
- mHitSlopRect = rect
|
||||
- updateTouchDelegate()
|
||||
- }
|
||||
-
|
||||
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
|
||||
return true
|
||||
}
|
||||
diff --git a/node_modules/@react-native-menu/menu/android/src/main/java/com/reactnativemenu/MenuViewManagerBase.kt b/node_modules/@react-native-menu/menu/android/src/main/java/com/reactnativemenu/MenuViewManagerBase.kt
|
||||
index 4731e1a..e4d2743 100644
|
||||
--- a/node_modules/@react-native-menu/menu/android/src/main/java/com/reactnativemenu/MenuViewManagerBase.kt
|
||||
+++ b/node_modules/@react-native-menu/menu/android/src/main/java/com/reactnativemenu/MenuViewManagerBase.kt
|
||||
@@ -123,9 +123,9 @@ abstract class MenuViewManagerBase : ReactClippingViewManager<MenuView>() {
|
||||
fun setHitSlop(view: ReactViewGroup, @Nullable hitSlop: ReadableMap?) {
|
||||
if (hitSlop == null) {
|
||||
// We should keep using setters as `Val cannot be reassigned`
|
||||
- view.setHitSlopRect(null)
|
||||
+ view.hitSlopRect = null
|
||||
} else {
|
||||
- view.setHitSlopRect(
|
||||
+ view.hitSlopRect = (
|
||||
Rect(
|
||||
if (hitSlop.hasKey("left"))
|
||||
PixelUtil.toPixelFromDIP(hitSlop.getDouble("left")).toInt()
|
||||
@@ -206,7 +206,7 @@ abstract class MenuViewManagerBase : ReactClippingViewManager<MenuView>() {
|
||||
|
||||
@ReactProp(name = ViewProps.OVERFLOW)
|
||||
fun setOverflow(view: ReactViewGroup, overflow: String?) {
|
||||
- view.setOverflow(overflow)
|
||||
+ view.overflow = overflow
|
||||
}
|
||||
|
||||
@ReactProp(name = "backfaceVisibility")
|
||||
@@ -3,11 +3,12 @@ import type {
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import * as Application from "expo-application";
|
||||
import { Directory, File, Paths } from "expo-file-system";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import * as Notifications from "expo-notifications";
|
||||
import { router } from "expo-router";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import {
|
||||
import { throttle } from "lodash";
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
@@ -15,7 +16,7 @@ import {
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DeviceEventEmitter, Platform } from "react-native";
|
||||
import { Platform } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import useImageStorage from "@/hooks/useImageStorage";
|
||||
@@ -113,20 +114,6 @@ function useDownloadProvider() {
|
||||
const { settings } = useSettings();
|
||||
const successHapticFeedback = useHaptic("success");
|
||||
|
||||
// Set up global download complete listener for debugging
|
||||
useEffect(() => {
|
||||
const listener = DeviceEventEmitter.addListener(
|
||||
"downloadComplete",
|
||||
(data) => {
|
||||
console.log("🔥 GLOBAL TEST LISTENER received downloadComplete:", data);
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
listener.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Generate notification content based on item type
|
||||
const getNotificationContent = useCallback(
|
||||
(item: BaseItemDto, isSuccess: boolean) => {
|
||||
@@ -193,12 +180,9 @@ function useDownloadProvider() {
|
||||
/// Cant use the background downloader callback. As its not triggered if size is unknown.
|
||||
const updateProgress = async () => {
|
||||
const tasks = await BackGroundDownloader.checkForExistingDownloads();
|
||||
if (!tasks || tasks.length === 0) {
|
||||
if (!tasks) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[UPDATE_PROGRESS] Checking ${tasks.length} active tasks`);
|
||||
|
||||
// check if processes are missing
|
||||
setProcesses((processes) => {
|
||||
const missingProcesses = tasks
|
||||
@@ -217,41 +201,10 @@ function useDownloadProvider() {
|
||||
|
||||
// Find task for this process
|
||||
const task = tasks.find((s: any) => s.id === p.id);
|
||||
|
||||
if (!task) {
|
||||
// ORPHANED DOWNLOAD CHECK: Task disappeared, but was it because it completed?
|
||||
// This handles the race condition where download finishes between polling intervals
|
||||
if (p.progress >= 90) {
|
||||
// Lower threshold to catch more cases
|
||||
console.log(
|
||||
`[UPDATE_PROGRESS] Orphaned download detected for ${p.item.Name} at ${p.progress.toFixed(1)}%, checking file...`,
|
||||
);
|
||||
const filename = generateFilename(p.item);
|
||||
const videoFile = new File(Paths.document, `${filename}.mp4`);
|
||||
|
||||
if (videoFile.exists && videoFile.size > 0) {
|
||||
console.log(
|
||||
`[UPDATE_PROGRESS] Orphaned download complete! File size: ${videoFile.size}, marking as complete`,
|
||||
);
|
||||
return {
|
||||
...p,
|
||||
progress: 100,
|
||||
speed: 0,
|
||||
bytesDownloaded: videoFile.size,
|
||||
lastProgressUpdateTime: new Date(),
|
||||
estimatedTotalSizeBytes: videoFile.size,
|
||||
lastSessionBytes: videoFile.size,
|
||||
lastSessionUpdateTime: new Date(),
|
||||
status: "completed" as const,
|
||||
};
|
||||
} else {
|
||||
console.warn(
|
||||
`[UPDATE_PROGRESS] Orphaned download at ${p.progress.toFixed(1)}% but file not found. Keeping current state.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return p; // No task found, keep current state
|
||||
}
|
||||
|
||||
/*
|
||||
// TODO: Uncomment this block to re-enable iOS zombie task detection
|
||||
// iOS: Extra validation to prevent zombie task interference
|
||||
@@ -319,52 +272,6 @@ function useDownloadProvider() {
|
||||
progress = MAX_PROGRESS_BEFORE_COMPLETION;
|
||||
}
|
||||
const speed = calculateSpeed(p, task.bytesDownloaded);
|
||||
|
||||
console.log(
|
||||
`[UPDATE_PROGRESS] Task ${p.item.Name}: ${progress.toFixed(1)}% (${task.bytesDownloaded}/${estimatedSize} bytes), state: ${task.state}`,
|
||||
);
|
||||
|
||||
// WORKAROUND: Check if download is actually complete by checking file existence
|
||||
// This handles cases where the .done() callback doesn't fire (unknown content length, simulator issues, etc.)
|
||||
if (progress >= 90 && task.state === "DONE") {
|
||||
console.log(
|
||||
`[UPDATE_PROGRESS] Task appears complete (state=DONE), checking file...`,
|
||||
);
|
||||
const filename = generateFilename(p.item);
|
||||
const videoFile = new File(Paths.document, `${filename}.mp4`);
|
||||
|
||||
console.log(
|
||||
`[UPDATE_PROGRESS] Looking for file at: ${videoFile.uri}`,
|
||||
);
|
||||
console.log(
|
||||
`[UPDATE_PROGRESS] Paths.document.uri: ${Paths.document.uri}`,
|
||||
);
|
||||
console.log(`[UPDATE_PROGRESS] File exists: ${videoFile.exists}`);
|
||||
console.log(`[UPDATE_PROGRESS] File size: ${videoFile.size}`);
|
||||
|
||||
if (videoFile.exists && videoFile.size > 0) {
|
||||
console.log(
|
||||
`[UPDATE_PROGRESS] File exists with size ${videoFile.size}, marking as complete!`,
|
||||
);
|
||||
// Mark as complete by setting status - this will trigger removal from processes
|
||||
return {
|
||||
...p,
|
||||
progress: 100,
|
||||
speed: 0,
|
||||
bytesDownloaded: videoFile.size,
|
||||
lastProgressUpdateTime: new Date(),
|
||||
estimatedTotalSizeBytes: videoFile.size,
|
||||
lastSessionBytes: videoFile.size,
|
||||
lastSessionUpdateTime: new Date(),
|
||||
status: "completed" as const,
|
||||
};
|
||||
} else {
|
||||
console.warn(
|
||||
`[UPDATE_PROGRESS] File not found or empty! Task state=${task.state}, progress=${progress}%`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...p,
|
||||
progress,
|
||||
@@ -384,14 +291,13 @@ function useDownloadProvider() {
|
||||
});
|
||||
};
|
||||
|
||||
useInterval(updateProgress, 1000);
|
||||
useInterval(updateProgress, 2000);
|
||||
|
||||
const getDownloadedItemById = (id: string): DownloadedItem | undefined => {
|
||||
const db = getDownloadsDatabase();
|
||||
|
||||
// Check movies first
|
||||
if (db.movies[id]) {
|
||||
console.log(`[DB] Found movie with ID: ${id}`);
|
||||
return db.movies[id];
|
||||
}
|
||||
|
||||
@@ -400,16 +306,14 @@ function useDownloadProvider() {
|
||||
for (const season of Object.values(series.seasons)) {
|
||||
for (const episode of Object.values(season.episodes)) {
|
||||
if (episode.item.Id === id) {
|
||||
console.log(`[DB] Found episode with ID: ${id}`);
|
||||
return episode;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[DB] No item found with ID: ${id}`);
|
||||
// Check other media types
|
||||
if (db.other?.[id]) {
|
||||
if (db.other[id]) {
|
||||
return db.other[id];
|
||||
}
|
||||
|
||||
@@ -442,41 +346,34 @@ function useDownloadProvider() {
|
||||
return api?.accessToken;
|
||||
}, [api]);
|
||||
|
||||
const APP_CACHE_DOWNLOAD_DIRECTORY = new Directory(
|
||||
Paths.cache,
|
||||
`${Application.applicationId}/Downloads/`,
|
||||
);
|
||||
const APP_CACHE_DOWNLOAD_DIRECTORY = `${FileSystem.cacheDirectory}${Application.applicationId}/Downloads/`;
|
||||
|
||||
const getDownloadsDatabase = (): DownloadsDatabase => {
|
||||
const file = storage.getString(DOWNLOADS_DATABASE_KEY);
|
||||
if (file) {
|
||||
const db = JSON.parse(file) as DownloadsDatabase;
|
||||
return db;
|
||||
return JSON.parse(file) as DownloadsDatabase;
|
||||
}
|
||||
return { movies: {}, series: {}, other: {} }; // Initialize other media types storage
|
||||
};
|
||||
|
||||
const getDownloadedItems = useCallback(() => {
|
||||
const getDownloadedItems = () => {
|
||||
const db = getDownloadsDatabase();
|
||||
const movies = Object.values(db.movies);
|
||||
const episodes = Object.values(db.series).flatMap((series) =>
|
||||
Object.values(series.seasons).flatMap((season) =>
|
||||
Object.values(season.episodes),
|
||||
const allItems = [
|
||||
...Object.values(db.movies),
|
||||
...Object.values(db.series).flatMap((series) =>
|
||||
Object.values(series.seasons).flatMap((season) =>
|
||||
Object.values(season.episodes),
|
||||
),
|
||||
),
|
||||
);
|
||||
const otherItems = Object.values(db.other || {});
|
||||
const allItems = [...movies, ...episodes, ...otherItems];
|
||||
...Object.values(db.other), // Include other media types in results
|
||||
];
|
||||
return allItems;
|
||||
}, []);
|
||||
};
|
||||
|
||||
const downloadedItems = getDownloadedItems();
|
||||
|
||||
const saveDownloadsDatabase = (db: DownloadsDatabase) => {
|
||||
const movieCount = Object.keys(db.movies).length;
|
||||
const seriesCount = Object.keys(db.series).length;
|
||||
console.log(
|
||||
`[DB] Saving database: ${movieCount} movies, ${seriesCount} series`,
|
||||
);
|
||||
storage.set(DOWNLOADS_DATABASE_KEY, JSON.stringify(db));
|
||||
console.log(`[DB] Database saved successfully to MMKV`);
|
||||
};
|
||||
|
||||
/** Generates a filename for a given item */
|
||||
@@ -515,17 +412,20 @@ function useDownloadProvider() {
|
||||
}
|
||||
|
||||
const filename = generateFilename(item);
|
||||
const trickplayDir = new Directory(Paths.document, `${filename}_trickplay`);
|
||||
trickplayDir.create({ intermediates: true });
|
||||
const trickplayDir = `${FileSystem.documentDirectory}${filename}_trickplay/`;
|
||||
await FileSystem.makeDirectoryAsync(trickplayDir, { intermediates: true });
|
||||
let totalSize = 0;
|
||||
|
||||
for (let index = 0; index < trickplayInfo.totalImageSheets; index++) {
|
||||
const url = generateTrickplayUrl(item, index);
|
||||
if (!url) continue;
|
||||
const destination = new File(trickplayDir, `${index}.jpg`);
|
||||
const destination = `${trickplayDir}${index}.jpg`;
|
||||
try {
|
||||
await File.downloadFileAsync(url, destination);
|
||||
totalSize += destination.size;
|
||||
await FileSystem.downloadAsync(url, destination);
|
||||
const fileInfo = await FileSystem.getInfoAsync(destination);
|
||||
if (fileInfo.exists) {
|
||||
totalSize += fileInfo.size;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Failed to download trickplay image ${index} for item ${item.Id}`,
|
||||
@@ -534,7 +434,7 @@ function useDownloadProvider() {
|
||||
}
|
||||
}
|
||||
|
||||
return { path: trickplayDir.uri, size: totalSize };
|
||||
return { path: trickplayDir, size: totalSize };
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -554,12 +454,9 @@ function useDownloadProvider() {
|
||||
externalSubtitles.map(async (subtitle) => {
|
||||
const url = api.basePath + subtitle.DeliveryUrl;
|
||||
const filename = generateFilename(item);
|
||||
const destination = new File(
|
||||
Paths.document,
|
||||
`${filename}_subtitle_${subtitle.Index}`,
|
||||
);
|
||||
await File.downloadFileAsync(url, destination);
|
||||
subtitle.DeliveryUrl = destination.uri;
|
||||
const destination = `${FileSystem.documentDirectory}${filename}_subtitle_${subtitle.Index}`;
|
||||
await FileSystem.downloadAsync(url, destination);
|
||||
subtitle.DeliveryUrl = destination;
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -645,86 +542,86 @@ function useDownloadProvider() {
|
||||
progress: process.progress || 0, // Preserve existing progress for resume
|
||||
});
|
||||
|
||||
if (!BackGroundDownloader) {
|
||||
throw new Error("Background downloader not available");
|
||||
}
|
||||
|
||||
BackGroundDownloader.setConfig({
|
||||
isLogsEnabled: true, // Enable logs to debug
|
||||
BackGroundDownloader?.setConfig({
|
||||
isLogsEnabled: false,
|
||||
progressInterval: 500,
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
},
|
||||
});
|
||||
const filename = generateFilename(process.item);
|
||||
const videoFile = new File(Paths.document, `${filename}.mp4`);
|
||||
const videoFilePath = videoFile.uri;
|
||||
console.log(`[DOWNLOAD] Starting download for ${filename}`);
|
||||
console.log(`[DOWNLOAD] Destination path: ${videoFilePath}`);
|
||||
|
||||
BackGroundDownloader.download({
|
||||
const videoFilePath = `${FileSystem.documentDirectory}${filename}.mp4`;
|
||||
BackGroundDownloader?.download({
|
||||
id: process.id,
|
||||
url: process.inputUrl,
|
||||
destination: videoFilePath,
|
||||
metadata: process,
|
||||
});
|
||||
},
|
||||
[authHeader, sendDownloadNotification, getNotificationContent],
|
||||
);
|
||||
})
|
||||
.begin(() => {
|
||||
updateProcess(process.id, {
|
||||
status: "downloading",
|
||||
progress: process.progress || 0,
|
||||
bytesDownloaded: process.bytesDownloaded || 0,
|
||||
lastProgressUpdateTime: new Date(),
|
||||
lastSessionBytes: process.lastSessionBytes || 0,
|
||||
lastSessionUpdateTime: new Date(),
|
||||
});
|
||||
})
|
||||
.progress(
|
||||
throttle((data) => {
|
||||
updateProcess(process.id, (currentProcess) => {
|
||||
// If this is a resumed download, add the paused bytes to current session bytes
|
||||
const resumedBytes = currentProcess.pausedBytes || 0;
|
||||
const totalBytes = data.bytesDownloaded + resumedBytes;
|
||||
|
||||
const manageDownloadQueue = useCallback(() => {
|
||||
// Handle completed downloads (workaround for when .done() callback doesn't fire)
|
||||
const completedDownloads = processes.filter(
|
||||
(p) => p.status === "completed",
|
||||
);
|
||||
for (const completedProcess of completedDownloads) {
|
||||
console.log(
|
||||
`[QUEUE] Processing completed download: ${completedProcess.item.Name}`,
|
||||
);
|
||||
// Calculate progress based on total bytes if we have resumed bytes
|
||||
let percent: number;
|
||||
if (resumedBytes > 0 && data.bytesTotal > 0) {
|
||||
// For resumed downloads, calculate based on estimated total size
|
||||
const estimatedTotal =
|
||||
currentProcess.estimatedTotalSizeBytes ||
|
||||
data.bytesTotal + resumedBytes;
|
||||
percent = (totalBytes / estimatedTotal) * 100;
|
||||
} else {
|
||||
// For fresh downloads, use normal calculation
|
||||
percent = (data.bytesDownloaded / data.bytesTotal) * 100;
|
||||
}
|
||||
|
||||
// Save to database
|
||||
(async () => {
|
||||
try {
|
||||
const filename = generateFilename(completedProcess.item);
|
||||
const videoFile = new File(Paths.document, `${filename}.mp4`);
|
||||
const videoFilePath = videoFile.uri;
|
||||
const videoFileSize = videoFile.size;
|
||||
|
||||
console.log(`[QUEUE] Saving completed download to database`);
|
||||
console.log(`[QUEUE] Video file path: ${videoFilePath}`);
|
||||
console.log(`[QUEUE] Video file size: ${videoFileSize}`);
|
||||
console.log(`[QUEUE] Video file exists: ${videoFile.exists}`);
|
||||
|
||||
if (!videoFile.exists) {
|
||||
console.error(
|
||||
`[QUEUE] Cannot save - video file does not exist at ${videoFilePath}`,
|
||||
);
|
||||
removeProcess(completedProcess.id);
|
||||
return;
|
||||
return {
|
||||
speed: calculateSpeed(currentProcess, totalBytes),
|
||||
status: "downloading",
|
||||
progress: Math.min(percent, MAX_PROGRESS_BEFORE_COMPLETION),
|
||||
bytesDownloaded: totalBytes,
|
||||
lastProgressUpdateTime: new Date(),
|
||||
// update session-only counters - use current session bytes only for speed calc
|
||||
lastSessionBytes: data.bytesDownloaded,
|
||||
lastSessionUpdateTime: new Date(),
|
||||
};
|
||||
});
|
||||
}, 500),
|
||||
)
|
||||
.done(async () => {
|
||||
const trickPlayData = await downloadTrickplayImages(process.item);
|
||||
const videoFileInfo = await FileSystem.getInfoAsync(videoFilePath);
|
||||
if (!videoFileInfo.exists) {
|
||||
throw new Error("Downloaded file does not exist");
|
||||
}
|
||||
|
||||
const trickPlayData = await downloadTrickplayImages(
|
||||
completedProcess.item,
|
||||
);
|
||||
const videoFileSize = videoFileInfo.size;
|
||||
const db = getDownloadsDatabase();
|
||||
const { item, mediaSource } = completedProcess;
|
||||
|
||||
const { item, mediaSource } = process;
|
||||
// Only download external subtitles for non-transcoded streams.
|
||||
if (!mediaSource.TranscodingUrl) {
|
||||
await downloadAndLinkSubtitles(mediaSource, item);
|
||||
}
|
||||
|
||||
const { introSegments, creditSegments } = await fetchAndParseSegments(
|
||||
item.Id!,
|
||||
api!,
|
||||
);
|
||||
|
||||
const downloadedItem: DownloadedItem = {
|
||||
item,
|
||||
mediaSource,
|
||||
videoFilePath,
|
||||
videoFileSize,
|
||||
videoFileName: `${filename}.mp4`,
|
||||
trickPlayData,
|
||||
userData: {
|
||||
audioStreamIndex: 0,
|
||||
@@ -769,29 +666,63 @@ function useDownloadProvider() {
|
||||
] = downloadedItem;
|
||||
} else if (item.Id) {
|
||||
// Handle other media types
|
||||
if (!db.other) db.other = {};
|
||||
db.other[item.Id] = downloadedItem;
|
||||
}
|
||||
|
||||
await saveDownloadsDatabase(db);
|
||||
|
||||
// Send native notification for successful download
|
||||
const successNotification = getNotificationContent(
|
||||
process.item,
|
||||
true,
|
||||
);
|
||||
await sendDownloadNotification(
|
||||
successNotification.title,
|
||||
successNotification.body,
|
||||
{
|
||||
itemId: process.item.Id,
|
||||
itemName: process.item.Name,
|
||||
type: "download_completed",
|
||||
},
|
||||
);
|
||||
|
||||
toast.success(
|
||||
t("home.downloads.toasts.download_completed_for_item", {
|
||||
item: item.Name,
|
||||
item: process.item.Name,
|
||||
}),
|
||||
);
|
||||
removeProcess(process.id);
|
||||
})
|
||||
.error(async (error: any) => {
|
||||
console.error("Download error:", error);
|
||||
|
||||
console.log(
|
||||
`[QUEUE] Removing completed process: ${completedProcess.id}`,
|
||||
// Send native notification for failed download
|
||||
const failureNotification = getNotificationContent(
|
||||
process.item,
|
||||
false,
|
||||
);
|
||||
await sendDownloadNotification(
|
||||
failureNotification.title,
|
||||
failureNotification.body,
|
||||
{
|
||||
itemId: process.item.Id,
|
||||
itemName: process.item.Name,
|
||||
type: "download_failed",
|
||||
error: error?.message || "Unknown error",
|
||||
},
|
||||
);
|
||||
removeProcess(completedProcess.id);
|
||||
} catch (error) {
|
||||
console.error(`[QUEUE] Error processing completed download:`, error);
|
||||
removeProcess(completedProcess.id);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
toast.error(
|
||||
t("home.downloads.toasts.download_failed_for_item", {
|
||||
item: process.item.Name,
|
||||
}),
|
||||
);
|
||||
removeProcess(process.id);
|
||||
});
|
||||
},
|
||||
[authHeader, sendDownloadNotification, getNotificationContent],
|
||||
);
|
||||
|
||||
const manageDownloadQueue = useCallback(() => {
|
||||
const activeDownloads = processes.filter(
|
||||
(p) => p.status === "downloading",
|
||||
).length;
|
||||
@@ -812,7 +743,7 @@ function useDownloadProvider() {
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [processes, settings?.remuxConcurrentLimit, startDownload, api, t]);
|
||||
}, [processes, settings?.remuxConcurrentLimit, startDownload]);
|
||||
|
||||
const removeProcess = useCallback(
|
||||
async (id: string) => {
|
||||
@@ -865,13 +796,12 @@ function useDownloadProvider() {
|
||||
*/
|
||||
const cleanCacheDirectory = async (): Promise<void> => {
|
||||
try {
|
||||
if (APP_CACHE_DOWNLOAD_DIRECTORY.exists) {
|
||||
APP_CACHE_DOWNLOAD_DIRECTORY.delete();
|
||||
}
|
||||
APP_CACHE_DOWNLOAD_DIRECTORY.create({
|
||||
intermediates: true,
|
||||
await FileSystem.deleteAsync(APP_CACHE_DOWNLOAD_DIRECTORY, {
|
||||
idempotent: true,
|
||||
});
|
||||
await FileSystem.makeDirectoryAsync(APP_CACHE_DOWNLOAD_DIRECTORY, {
|
||||
intermediates: true,
|
||||
});
|
||||
} catch (_error) {
|
||||
toast.error(t("home.downloads.toasts.failed_to_clean_cache_directory"));
|
||||
}
|
||||
@@ -884,14 +814,8 @@ function useDownloadProvider() {
|
||||
mediaSource: MediaSourceInfo,
|
||||
maxBitrate: Bitrate,
|
||||
) => {
|
||||
if (!api || !item.Id || !authHeader) {
|
||||
console.warn("startBackgroundDownload ~ Missing required params", {
|
||||
api,
|
||||
item,
|
||||
authHeader,
|
||||
});
|
||||
if (!api || !item.Id || !authHeader)
|
||||
throw new Error("startBackgroundDownload ~ Missing required params");
|
||||
}
|
||||
try {
|
||||
const deviceId = getOrSetDeviceId();
|
||||
await saveSeriesPrimaryImage(item);
|
||||
@@ -982,109 +906,35 @@ function useDownloadProvider() {
|
||||
}
|
||||
} else {
|
||||
// Handle other media types
|
||||
if (db.other) {
|
||||
downloadedItem = db.other[id];
|
||||
if (downloadedItem) {
|
||||
delete db.other[id];
|
||||
}
|
||||
downloadedItem = db.other[id];
|
||||
if (downloadedItem) {
|
||||
delete db.other[id];
|
||||
}
|
||||
}
|
||||
|
||||
if (downloadedItem?.videoFilePath) {
|
||||
try {
|
||||
console.log(
|
||||
`[DELETE] Attempting to delete video file: ${downloadedItem.videoFilePath}`,
|
||||
);
|
||||
|
||||
// Properly reconstruct File object using Paths.document and filename
|
||||
let videoFile: File;
|
||||
if (downloadedItem.videoFileName) {
|
||||
// New approach: use stored filename with Paths.document
|
||||
videoFile = new File(Paths.document, downloadedItem.videoFileName);
|
||||
console.log(
|
||||
`[DELETE] Reconstructed file from stored filename: ${downloadedItem.videoFileName}`,
|
||||
);
|
||||
} else {
|
||||
// Fallback for old downloads: extract filename from URI
|
||||
const filename = downloadedItem.videoFilePath.split("/").pop();
|
||||
if (!filename) {
|
||||
throw new Error("Could not extract filename from path");
|
||||
}
|
||||
videoFile = new File(Paths.document, filename);
|
||||
console.log(
|
||||
`[DELETE] Reconstructed file from URI (legacy): ${filename}`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`[DELETE] File URI: ${videoFile.uri}`);
|
||||
console.log(
|
||||
`[DELETE] File exists before deletion: ${videoFile.exists}`,
|
||||
);
|
||||
|
||||
if (videoFile.exists) {
|
||||
videoFile.delete();
|
||||
console.log(`[DELETE] Video file deleted successfully`);
|
||||
} else {
|
||||
console.warn(`[DELETE] File does not exist, skipping deletion`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[DELETE] Failed to delete video file:`, err);
|
||||
// File might not exist, continue anyway
|
||||
}
|
||||
await FileSystem.deleteAsync(downloadedItem.videoFilePath, {
|
||||
idempotent: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (downloadedItem?.mediaSource?.MediaStreams) {
|
||||
for (const stream of downloadedItem.mediaSource.MediaStreams) {
|
||||
if (
|
||||
stream.Type === "Subtitle" &&
|
||||
stream.DeliveryMethod === "External" &&
|
||||
stream.DeliveryUrl
|
||||
stream.DeliveryMethod === "External"
|
||||
) {
|
||||
try {
|
||||
console.log(
|
||||
`[DELETE] Deleting subtitle file: ${stream.DeliveryUrl}`,
|
||||
);
|
||||
// Extract filename from the subtitle URI
|
||||
const subtitleFilename = stream.DeliveryUrl.split("/").pop();
|
||||
if (subtitleFilename) {
|
||||
const subtitleFile = new File(Paths.document, subtitleFilename);
|
||||
if (subtitleFile.exists) {
|
||||
subtitleFile.delete();
|
||||
console.log(
|
||||
`[DELETE] Subtitle file deleted: ${subtitleFilename}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[DELETE] Failed to delete subtitle:`, err);
|
||||
// File might not exist, ignore
|
||||
}
|
||||
await FileSystem.deleteAsync(stream.DeliveryUrl!, {
|
||||
idempotent: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (downloadedItem?.trickPlayData?.path) {
|
||||
try {
|
||||
console.log(
|
||||
`[DELETE] Deleting trickplay directory: ${downloadedItem.trickPlayData.path}`,
|
||||
);
|
||||
// Extract directory name from URI
|
||||
const trickplayDirName = downloadedItem.trickPlayData.path
|
||||
.split("/")
|
||||
.pop();
|
||||
if (trickplayDirName) {
|
||||
const trickplayDir = new Directory(Paths.document, trickplayDirName);
|
||||
if (trickplayDir.exists) {
|
||||
trickplayDir.delete();
|
||||
console.log(
|
||||
`[DELETE] Trickplay directory deleted: ${trickplayDirName}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[DELETE] Failed to delete trickplay directory:`, err);
|
||||
// Directory might not exist, ignore
|
||||
}
|
||||
await FileSystem.deleteAsync(downloadedItem.trickPlayData.path, {
|
||||
idempotent: true,
|
||||
});
|
||||
}
|
||||
|
||||
await saveDownloadsDatabase(db);
|
||||
@@ -1112,7 +962,6 @@ function useDownloadProvider() {
|
||||
|
||||
/** Deletes all files of a given type. */
|
||||
const deleteFileByType = async (type: BaseItemDto["Type"]) => {
|
||||
const downloadedItems = getDownloadedItems();
|
||||
const itemsToDelete = downloadedItems?.filter(
|
||||
(file) => file.item.Type === type,
|
||||
);
|
||||
@@ -1136,7 +985,7 @@ function useDownloadProvider() {
|
||||
const db = getDownloadsDatabase();
|
||||
if (db.movies[itemId]) {
|
||||
db.movies[itemId] = updatedItem;
|
||||
} else if (db.other?.[itemId]) {
|
||||
} else if (db.other[itemId]) {
|
||||
db.other[itemId] = updatedItem;
|
||||
} else {
|
||||
for (const series of Object.values(db.series)) {
|
||||
@@ -1157,41 +1006,22 @@ function useDownloadProvider() {
|
||||
* @returns The size of the app and the remaining space on the device.
|
||||
*/
|
||||
const appSizeUsage = async () => {
|
||||
const total = Paths.totalDiskSpace;
|
||||
const remaining = Paths.availableDiskSpace;
|
||||
const [total, remaining] = await Promise.all([
|
||||
FileSystem.getTotalDiskCapacityAsync(),
|
||||
FileSystem.getFreeDiskStorageAsync(),
|
||||
]);
|
||||
|
||||
let appSize = 0;
|
||||
try {
|
||||
// Paths.document is a Directory object in the new API
|
||||
const documentDir = Paths.document;
|
||||
console.log(`[STORAGE] Listing contents of: ${documentDir.uri}`);
|
||||
console.log(`[STORAGE] Document dir exists: ${documentDir.exists}`);
|
||||
|
||||
if (!documentDir.exists) {
|
||||
console.warn(`[STORAGE] Document directory does not exist`);
|
||||
return { total, remaining, appSize: 0 };
|
||||
}
|
||||
|
||||
const contents = documentDir.list();
|
||||
console.log(
|
||||
`[STORAGE] Found ${contents.length} items in document directory`,
|
||||
const downloadedFiles = await FileSystem.readDirectoryAsync(
|
||||
`${FileSystem.documentDirectory!}`,
|
||||
);
|
||||
for (const file of downloadedFiles) {
|
||||
const fileInfo = await FileSystem.getInfoAsync(
|
||||
`${FileSystem.documentDirectory!}${file}`,
|
||||
);
|
||||
|
||||
for (const item of contents) {
|
||||
if (item instanceof File) {
|
||||
console.log(`[STORAGE] File: ${item.name}, size: ${item.size} bytes`);
|
||||
appSize += item.size;
|
||||
} else if (item instanceof Directory) {
|
||||
const dirSize = item.size || 0;
|
||||
console.log(
|
||||
`[STORAGE] Directory: ${item.name}, size: ${dirSize} bytes`,
|
||||
);
|
||||
appSize += dirSize;
|
||||
}
|
||||
if (fileInfo.exists) {
|
||||
appSize += fileInfo.size;
|
||||
}
|
||||
console.log(`[STORAGE] Total app size: ${appSize} bytes`);
|
||||
} catch (error) {
|
||||
console.error(`[STORAGE] Error calculating app size:`, error);
|
||||
}
|
||||
return { total, remaining, appSize: appSize };
|
||||
};
|
||||
@@ -1395,7 +1225,7 @@ function useDownloadProvider() {
|
||||
deleteFileByType,
|
||||
getDownloadedItemSize,
|
||||
getDownloadedItemById,
|
||||
APP_CACHE_DOWNLOAD_DIRECTORY: APP_CACHE_DOWNLOAD_DIRECTORY.uri,
|
||||
APP_CACHE_DOWNLOAD_DIRECTORY,
|
||||
cleanCacheDirectory,
|
||||
updateDownloadedItem,
|
||||
appSizeUsage,
|
||||
|
||||
@@ -46,8 +46,6 @@ export interface DownloadedItem {
|
||||
videoFilePath: string;
|
||||
/** The size of the video file in bytes. */
|
||||
videoFileSize: number;
|
||||
/** The video filename (for easy File object reconstruction). Optional for backwards compatibility. */
|
||||
videoFileName?: string;
|
||||
/** The local file path of the downloaded trickplay images. */
|
||||
trickPlayData?: TrickPlayData;
|
||||
/** The intro segments for the item. */
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import type { BottomSheetModal } from "@gorhom/bottom-sheet";
|
||||
import type React from "react";
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
interface ModalOptions {
|
||||
enableDynamicSizing?: boolean;
|
||||
snapPoints?: (string | number)[];
|
||||
enablePanDownToClose?: boolean;
|
||||
backgroundStyle?: object;
|
||||
handleIndicatorStyle?: object;
|
||||
}
|
||||
|
||||
interface GlobalModalState {
|
||||
content: ReactNode | null;
|
||||
options?: ModalOptions;
|
||||
}
|
||||
|
||||
interface GlobalModalContextType {
|
||||
showModal: (content: ReactNode, options?: ModalOptions) => void;
|
||||
hideModal: () => void;
|
||||
isVisible: boolean;
|
||||
modalState: GlobalModalState;
|
||||
modalRef: React.RefObject<BottomSheetModal>;
|
||||
}
|
||||
|
||||
const GlobalModalContext = createContext<GlobalModalContextType | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
export const useGlobalModal = () => {
|
||||
const context = useContext(GlobalModalContext);
|
||||
if (!context) {
|
||||
throw new Error("useGlobalModal must be used within GlobalModalProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
interface GlobalModalProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const GlobalModalProvider: React.FC<GlobalModalProviderProps> = ({
|
||||
children,
|
||||
}) => {
|
||||
const [modalState, setModalState] = useState<GlobalModalState>({
|
||||
content: null,
|
||||
options: undefined,
|
||||
});
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const modalRef = useRef<BottomSheetModal>(null);
|
||||
|
||||
const showModal = useCallback(
|
||||
(content: ReactNode, options?: ModalOptions) => {
|
||||
setModalState({ content, options });
|
||||
setIsVisible(true);
|
||||
// Small delay to ensure state is updated before presenting
|
||||
setTimeout(() => {
|
||||
modalRef.current?.present();
|
||||
}, 100);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const hideModal = useCallback(() => {
|
||||
modalRef.current?.dismiss();
|
||||
setIsVisible(false);
|
||||
// Clear content after animation completes
|
||||
setTimeout(() => {
|
||||
setModalState({ content: null, options: undefined });
|
||||
}, 300);
|
||||
}, []);
|
||||
|
||||
const value = {
|
||||
showModal,
|
||||
hideModal,
|
||||
isVisible,
|
||||
modalState,
|
||||
modalRef,
|
||||
};
|
||||
|
||||
return (
|
||||
<GlobalModalContext.Provider value={value}>
|
||||
{children}
|
||||
</GlobalModalContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type { GlobalModalContextType, ModalOptions };
|
||||
@@ -64,7 +64,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
setJellyfin(
|
||||
() =>
|
||||
new Jellyfin({
|
||||
clientInfo: { name: "Streamyfin", version: "0.39.0" },
|
||||
clientInfo: { name: "Streamyfin", version: "0.40.1" },
|
||||
deviceInfo: {
|
||||
name: deviceName,
|
||||
id,
|
||||
@@ -87,7 +87,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
return {
|
||||
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
||||
Platform.OS === "android" ? "Android" : "iOS"
|
||||
}, DeviceId="${deviceId}", Version="0.39.0"`,
|
||||
}, DeviceId="${deviceId}", Version="0.40.1"`,
|
||||
};
|
||||
}, [deviceId]);
|
||||
|
||||
@@ -203,7 +203,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
|
||||
const removeServerMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
storage.remove("serverUrl");
|
||||
storage.delete("serverUrl");
|
||||
setApi(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -286,7 +286,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
writeErrorLog("Failed to delete expo push token for device"),
|
||||
);
|
||||
|
||||
storage.remove("token");
|
||||
storage.delete("token");
|
||||
setUser(null);
|
||||
setApi(null);
|
||||
setPluginSettings(undefined);
|
||||
|
||||
500
translations/ar.json
Normal file
500
translations/ar.json
Normal file
@@ -0,0 +1,500 @@
|
||||
{
|
||||
"login": {
|
||||
"username_required": "اسم المستخدم مطلوب",
|
||||
"error_title": "خطأ",
|
||||
"login_title": "تسجيل الدخول",
|
||||
"login_to_title": "تسجيل الدخول إلى",
|
||||
"username_placeholder": "اسم المستخدم",
|
||||
"password_placeholder": "كلمة المرور",
|
||||
"login_button": "تسجيل الدخول",
|
||||
"quick_connect": "اتصال سريع",
|
||||
"enter_code_to_login": "أدخل الرمز {{code}} لتسجيل الدخول",
|
||||
"failed_to_initiate_quick_connect": "فشل في بدء الاتصال السريع",
|
||||
"got_it": "حسنًا",
|
||||
"connection_failed": "فشل الاتصال",
|
||||
"could_not_connect_to_server": "تعذر الاتصال بالخادم. يرجى التحقق من الرابط واتصال الشبكة.",
|
||||
"an_unexpected_error_occured": "حدث خطأ غير متوقع",
|
||||
"change_server": "تغيير الخادم",
|
||||
"invalid_username_or_password": "اسم المستخدم أو كلمة المرور غير صالحة",
|
||||
"user_does_not_have_permission_to_log_in": "ليس لدى المستخدم صلاحية تسجيل الدخول",
|
||||
"server_is_taking_too_long_to_respond_try_again_later": "يستغرق الخادم وقتًا طويلاً للرد، يرجى المحاولة مرة أخرى لاحقًا",
|
||||
"server_received_too_many_requests_try_again_later": "تلقى الخادم عددًا كبيرًا جدًا من الطلبات، يرجى المحاولة مرة أخرى لاحقًا.",
|
||||
"there_is_a_server_error": "هناك خطأ في الخادم",
|
||||
"an_unexpected_error_occured_did_you_enter_the_correct_url": "حدث خطأ غير متوقع. هل أدخلت رابط الخادم بشكل صحيح؟",
|
||||
"too_old_server_text": "تم اكتشاف خادم jellyfin غير مدعوم",
|
||||
"too_old_server_description": "يرجى تحديث jellyfin إلى أحدث إصدار"
|
||||
},
|
||||
"server": {
|
||||
"enter_url_to_jellyfin_server": "أدخل رابط خادم Jellyfin الخاص بك",
|
||||
"server_url_placeholder": "http(s)://your-server.com",
|
||||
"connect_button": "اتصال",
|
||||
"previous_servers": "الخوادم السابقة",
|
||||
"clear_button": "مسح",
|
||||
"search_for_local_servers": "البحث عن الخوادم المحلية",
|
||||
"searching": "يبحث...",
|
||||
"servers": "الخوادم"
|
||||
},
|
||||
"home": {
|
||||
"no_internet": "لا يوجد اتصال بالإنترنت",
|
||||
"no_items": "لا توجد عناصر",
|
||||
"no_internet_message": "لا تقلق، لا يزال بإمكانك مشاهدة المحتوى الذي تم تنزيله.",
|
||||
"go_to_downloads": "الذهاب إلى التنزيلات",
|
||||
"oops": "عفوًا!",
|
||||
"error_message": "حدث خطأ ما.\nيرجى تسجيل الخروج ثم الدخول مرة أخرى.",
|
||||
"continue_watching": "متابعة المشاهدة",
|
||||
"next_up": "التالي",
|
||||
"recently_added_in": "أضيف مؤخراً في {{libraryName}}",
|
||||
"suggested_movies": "أفلام مقترحة",
|
||||
"suggested_episodes": "حلقات مقترحة",
|
||||
"intro": {
|
||||
"welcome_to_streamyfin": "مرحبًا بك في Streamyfin",
|
||||
"a_free_and_open_source_client_for_jellyfin": "عميل مجاني ومفتوح المصدر لـ Jellyfin.",
|
||||
"features_title": "الميزات",
|
||||
"features_description": "يحتوي Streamyfin على مجموعة من الميزات ويتكامل مع مجموعة واسعة من البرامج التي يمكنك العثور عليها في قائمة الإعدادات، وتشمل:",
|
||||
"jellyseerr_feature_description": "اتصل بمثيل Jellyseerr الخاص بك واطلب الأفلام مباشرة في التطبيق.",
|
||||
"downloads_feature_title": "التنزيلات",
|
||||
"downloads_feature_description": "قم بتنزيل الأفلام والمسلسلات التلفزيونية لمشاهدتها في وضع عدم الاتصال. استخدم إما الطريقة الافتراضية أو قم بتثبيت الخادم المحسن لتنزيل الملفات في الخلفية.",
|
||||
"chromecast_feature_description": "قم ببث الأفلام والبرامج التلفزيونية على أجهزة Chromecast الخاصة بك.",
|
||||
"centralised_settings_plugin_title": "إضافة الإعدادات المركزية",
|
||||
"centralised_settings_plugin_description": "قم بتكوين الإعدادات من موقع مركزي على خادم Jellyfin الخاص بك. ستتم مزامنة جميع إعدادات العميل لجميع المستخدمين تلقائيًا.",
|
||||
"done_button": "تم",
|
||||
"go_to_settings_button": "الذهاب إلى الإعدادات",
|
||||
"read_more": "اقرأ المزيد"
|
||||
},
|
||||
"settings": {
|
||||
"settings_title": "الإعدادات",
|
||||
"log_out_button": "تسجيل الخروج",
|
||||
"user_info": {
|
||||
"user_info_title": "معلومات المستخدم",
|
||||
"user": "المستخدم",
|
||||
"server": "الخادم",
|
||||
"token": "الرمز",
|
||||
"app_version": "إصدار التطبيق"
|
||||
},
|
||||
"quick_connect": {
|
||||
"quick_connect_title": "اتصال سريع",
|
||||
"authorize_button": "تفويض الاتصال السريع",
|
||||
"enter_the_quick_connect_code": "أدخل رمز الاتصال السريع...",
|
||||
"success": "نجاح",
|
||||
"quick_connect_autorized": "تم تفويض الاتصال السريع",
|
||||
"error": "خطأ",
|
||||
"invalid_code": "رمز غير صالح",
|
||||
"authorize": "تفويض"
|
||||
},
|
||||
"media_controls": {
|
||||
"media_controls_title": "عناصر التحكم بالوسائط",
|
||||
"forward_skip_length": "مدة التقديم السريع",
|
||||
"rewind_length": "مدة الترجيع",
|
||||
"seconds_unit": "ث"
|
||||
},
|
||||
"gesture_controls": {
|
||||
"gesture_controls_title": "التحكم بالإيماءات",
|
||||
"horizontal_swipe_skip": "السحب الأفقي للتخطي",
|
||||
"horizontal_swipe_skip_description": "اسحب لليسار/لليمين عندما تكون عناصر التحكم مخفية للتخطي",
|
||||
"left_side_brightness": "التحكم في السطوع من الجانب الأيسر",
|
||||
"left_side_brightness_description": "اسحب لأعلى/لأسفل على الجانب الأيسر لضبط السطوع",
|
||||
"right_side_volume": "التحكم في مستوى الصوت من الجانب الأيمن",
|
||||
"right_side_volume_description": "اسحب لأعلى/لأسفل على الجانب الأيمن لضبط مستوى الصوت"
|
||||
},
|
||||
"audio": {
|
||||
"audio_title": "الصوت",
|
||||
"set_audio_track": "تعيين مسار الصوت من العنصر السابق",
|
||||
"audio_language": "لغة الصوت",
|
||||
"audio_hint": "اختر لغة صوت افتراضية.",
|
||||
"none": "لا شيء",
|
||||
"language": "اللغة"
|
||||
},
|
||||
"subtitles": {
|
||||
"subtitle_title": "الترجمة",
|
||||
"subtitle_language": "لغة الترجمة",
|
||||
"subtitle_mode": "وضع الترجمة",
|
||||
"set_subtitle_track": "تعيين مسار الترجمة من العنصر السابق",
|
||||
"subtitle_size": "حجم الترجمة",
|
||||
"subtitle_hint": "تكوين تفضيلات الترجمة.",
|
||||
"none": "لا شيء",
|
||||
"language": "اللغة",
|
||||
"loading": "جار التحميل",
|
||||
"modes": {
|
||||
"Default": "افتراضي",
|
||||
"Smart": "ذكي",
|
||||
"Always": "دائماً",
|
||||
"None": "لا شيء",
|
||||
"OnlyForced": "فقط الإجبارية"
|
||||
}
|
||||
},
|
||||
"other": {
|
||||
"other_title": "أخرى",
|
||||
"follow_device_orientation": "تدوير تلقائي",
|
||||
"video_orientation": "اتجاه الفيديو",
|
||||
"orientation": "الاتجاه",
|
||||
"orientations": {
|
||||
"DEFAULT": "افتراضي",
|
||||
"ALL": "الكل",
|
||||
"PORTRAIT": "عمودي",
|
||||
"PORTRAIT_UP": "عمودي لأعلى",
|
||||
"PORTRAIT_DOWN": "عمودي لأسفل",
|
||||
"LANDSCAPE": "أفقي",
|
||||
"LANDSCAPE_LEFT": "أفقي لليسار",
|
||||
"LANDSCAPE_RIGHT": "أفقي لليمين",
|
||||
"OTHER": "أخرى",
|
||||
"UNKNOWN": "غير معروف"
|
||||
},
|
||||
"safe_area_in_controls": "مساحة آمنة في عناصر التحكم",
|
||||
"video_player": "مشغل الفيديو",
|
||||
"video_players": {
|
||||
"VLC_3": "VLC 3",
|
||||
"VLC_4": "VLC 4 (تجريبي + صورة داخل صورة)"
|
||||
},
|
||||
"show_custom_menu_links": "إظهار روابط القائمة المخصصة",
|
||||
"hide_libraries": "إخفاء المكتبات",
|
||||
"select_liraries_you_want_to_hide": "حدد المكتبات التي تريد إخفاءها من علامة تبويب المكتبة وأقسام الصفحة الرئيسية.",
|
||||
"disable_haptic_feedback": "تعطيل ردود الفعل اللمسية",
|
||||
"default_quality": "الجودة الافتراضية",
|
||||
"max_auto_play_episode_count": "الحد الأقصى لعدد الحلقات التي يتم تشغيلها تلقائيًا",
|
||||
"disabled": "معطل"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "التنزيلات",
|
||||
"download_method": "طريقة التنزيل",
|
||||
"remux_max_download": "الحد الأقصى لتنزيل الريمكس",
|
||||
"auto_download": "تنزيل تلقائي",
|
||||
"optimized_versions_server": "خادم الإصدارات المحسّنة",
|
||||
"save_button": "حفظ",
|
||||
"optimized_server": "الخادم المحسن",
|
||||
"optimized": "محسن",
|
||||
"default": "افتراضي",
|
||||
"optimized_version_hint": "أدخل رابط الخادم المحسن. يجب أن يتضمن الرابط http أو https ويمكن أن يتضمن المنفذ اختياريًا.",
|
||||
"read_more_about_optimized_server": "اقرأ المزيد عن الخادم المحسن.",
|
||||
"url": "الرابط",
|
||||
"server_url_placeholder": "http(s)://domain.org:port"
|
||||
},
|
||||
"plugins": {
|
||||
"plugins_title": "الإضافات",
|
||||
"jellyseerr": {
|
||||
"jellyseerr_warning": "هذا التكامل في مراحله الأولى. توقع أن تتغير الأمور.",
|
||||
"server_url": "رابط الخادم",
|
||||
"server_url_hint": "مثال: http(s)://your-host.url\n(أضف المنفذ إذا لزم الأمر)",
|
||||
"server_url_placeholder": "رابط Jellyseerr...",
|
||||
"password": "كلمة المرور",
|
||||
"password_placeholder": "أدخل كلمة المرور لمستخدم Jellyfin {{username}}",
|
||||
"save_button": "حفظ",
|
||||
"clear_button": "مسح",
|
||||
"login_button": "تسجيل الدخول",
|
||||
"total_media_requests": "إجمالي طلبات الوسائط",
|
||||
"movie_quota_limit": "حد حصة الأفلام",
|
||||
"movie_quota_days": "أيام حصة الأفلام",
|
||||
"tv_quota_limit": "حد حصة المسلسلات",
|
||||
"tv_quota_days": "أيام حصة المسلسلات",
|
||||
"reset_jellyseerr_config_button": "إعادة تعيين تكوين Jellyseerr",
|
||||
"unlimited": "غير محدود",
|
||||
"plus_n_more": "+{{n}} المزيد",
|
||||
"order_by": {
|
||||
"DEFAULT": "افتراضي",
|
||||
"VOTE_COUNT_AND_AVERAGE": "عدد الأصوات والمعدل",
|
||||
"POPULARITY": "الشعبية"
|
||||
}
|
||||
},
|
||||
"marlin_search": {
|
||||
"enable_marlin_search": "تمكين بحث مارلن",
|
||||
"url": "الرابط",
|
||||
"server_url_placeholder": "http(s)://domain.org:port",
|
||||
"marlin_search_hint": "أدخل رابط خادم مارلن. يجب أن يتضمن الرابط http أو https ويمكن أن يتضمن المنفذ اختياريًا.",
|
||||
"read_more_about_marlin": "اقرأ المزيد عن مارلن.",
|
||||
"save_button": "حفظ",
|
||||
"toasts": {
|
||||
"saved": "تم الحفظ"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
"storage_title": "التخزين",
|
||||
"app_usage": "التطبيق {{usedSpace}}%",
|
||||
"device_usage": "الجهاز {{availableSpace}}%",
|
||||
"size_used": "تم استخدام {{used}} من {{total}}",
|
||||
"delete_all_downloaded_files": "حذف جميع الملفات التي تم تنزيلها"
|
||||
},
|
||||
"intro": {
|
||||
"show_intro": "إظهار المقدمة",
|
||||
"reset_intro": "إعادة تعيين المقدمة"
|
||||
},
|
||||
"logs": {
|
||||
"logs_title": "السجلات",
|
||||
"export_logs": "تصدير السجلات",
|
||||
"click_for_more_info": "انقر لمزيد من المعلومات",
|
||||
"level": "المستوى",
|
||||
"no_logs_available": "لا توجد سجلات متاحة",
|
||||
"delete_all_logs": "حذف جميع السجلات"
|
||||
},
|
||||
"languages": {
|
||||
"title": "اللغات",
|
||||
"app_language": "لغة التطبيق",
|
||||
"app_language_description": "حدد لغة التطبيق.",
|
||||
"system": "النظام"
|
||||
},
|
||||
"toasts": {
|
||||
"error_deleting_files": "خطأ في حذف الملفات",
|
||||
"background_downloads_enabled": "تمكين التنزيلات في الخلفية",
|
||||
"background_downloads_disabled": "تعطيل التنزيلات في الخلفية",
|
||||
"connected": "متصل",
|
||||
"could_not_connect": "تعذر الاتصال",
|
||||
"invalid_url": "رابط غير صالح"
|
||||
}
|
||||
},
|
||||
"sessions": {
|
||||
"title": "الجلسات",
|
||||
"no_active_sessions": "لا توجد جلسات نشطة"
|
||||
},
|
||||
"downloads": {
|
||||
"downloads_title": "التنزيلات",
|
||||
"tvseries": "مسلسلات",
|
||||
"movies": "أفلام",
|
||||
"queue": "قائمة الانتظار",
|
||||
"queue_hint": "ستفقد قائمة الانتظار والتنزيلات عند إعادة تشغيل التطبيق",
|
||||
"no_items_in_queue": "لا توجد عناصر في قائمة الانتظار",
|
||||
"no_downloaded_items": "لا توجد عناصر تم تنزيلها",
|
||||
"delete_all_movies_button": "حذف جميع الأفلام",
|
||||
"delete_all_tvseries_button": "حذف جميع المسلسلات",
|
||||
"delete_all_button": "حذف الكل",
|
||||
"active_download": "تنزيل نشط",
|
||||
"no_active_downloads": "لا توجد تنزيلات نشطة",
|
||||
"active_downloads": "تنزيلات نشطة",
|
||||
"new_app_version_requires_re_download": "يتطلب إصدار التطبيق الجديد إعادة التنزيل",
|
||||
"new_app_version_requires_re_download_description": "يتطلب التحديث الجديد تنزيل المحتوى مرة أخرى. يرجى إزالة كل المحتوى الذي تم تنزيله والمحاولة مرة أخرى.",
|
||||
"back": "رجوع",
|
||||
"delete": "حذف",
|
||||
"something_went_wrong": "حدث خطأ ما",
|
||||
"could_not_get_stream_url_from_jellyfin": "تعذر الحصول على رابط البث من Jellyfin",
|
||||
"eta": "الوقت المتبقي {{eta}}",
|
||||
"methods": "الطرق",
|
||||
"toasts": {
|
||||
"you_are_not_allowed_to_download_files": "غير مسموح لك بتنزيل الملفات.",
|
||||
"deleted_all_movies_successfully": "تم حذف جميع الأفلام بنجاح!",
|
||||
"failed_to_delete_all_movies": "فشل حذف جميع الأفلام",
|
||||
"deleted_all_tvseries_successfully": "تم حذف جميع المسلسلات بنجاح!",
|
||||
"failed_to_delete_all_tvseries": "فشل حذف جميع المسلسلات",
|
||||
"download_deleted": "تم حذف التنزيل",
|
||||
"could_not_delete_download": "تعذر حذف التنزيل",
|
||||
"download_paused": "تم إيقاف التنزيل مؤقتًا",
|
||||
"could_not_pause_download": "تعذر إيقاف التنزيل مؤقتًا",
|
||||
"download_resumed": "تم استئناف التنزيل",
|
||||
"could_not_resume_download": "تعذر استئناف التنزيل",
|
||||
"download_completed": "اكتمل التنزيل",
|
||||
"download_started_for": "بدأ تنزيل {{item}}",
|
||||
"item_is_ready_to_be_downloaded": "{{item}} جاهز للتنزيل",
|
||||
"download_stated_for_item": "بدأ تنزيل {{item}}",
|
||||
"download_failed_for_item": "فشل تنزيل {{item}} - {{error}}",
|
||||
"download_completed_for_item": "اكتمل تنزيل {{item}}",
|
||||
"queued_item_for_optimization": "تمت إضافة {{item}} إلى قائمة الانتظار للتحسين",
|
||||
"failed_to_start_download_for_item": "فشل بدء تنزيل {{item}}: {{message}}",
|
||||
"server_responded_with_status_code": "استجاب الخادم بالحالة {{statusCode}}",
|
||||
"no_response_received_from_server": "لم يتم تلقي أي رد من الخادم",
|
||||
"error_setting_up_the_request": "خطأ في إعداد الطلب",
|
||||
"failed_to_start_download_for_item_unexpected_error": "فشل بدء تنزيل {{item}}: خطأ غير متوقع",
|
||||
"all_files_folders_and_jobs_deleted_successfully": "تم حذف جميع الملفات والمجلدات والمهام بنجاح",
|
||||
"an_error_occured_while_deleting_files_and_jobs": "حدث خطأ أثناء حذف الملفات والمهام",
|
||||
"go_to_downloads": "الذهاب إلى التنزيلات"
|
||||
}
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
"search_here": "ابحث هنا...",
|
||||
"search": "بحث...",
|
||||
"x_items": "{{count}} عناصر",
|
||||
"library": "المكتبة",
|
||||
"discover": "اكتشف",
|
||||
"no_results": "لا توجد نتائج",
|
||||
"no_results_found_for": "لم يتم العثور على نتائج لـ",
|
||||
"movies": "أفلام",
|
||||
"series": "مسلسلات",
|
||||
"episodes": "حلقات",
|
||||
"collections": "مجموعات",
|
||||
"actors": "ممثلون",
|
||||
"request_movies": "طلب أفلام",
|
||||
"request_series": "طلب مسلسلات",
|
||||
"recently_added": "أضيف مؤخراً",
|
||||
"recent_requests": "الطلبات الأخيرة",
|
||||
"plex_watchlist": "قائمة مشاهدة Plex",
|
||||
"trending": "شائع",
|
||||
"popular_movies": "أفلام شائعة",
|
||||
"movie_genres": "أنواع الأفلام",
|
||||
"upcoming_movies": "أفلام قادمة",
|
||||
"studios": "استوديوهات",
|
||||
"popular_tv": "مسلسلات شائعة",
|
||||
"tv_genres": "أنواع المسلسلات",
|
||||
"upcoming_tv": "مسلسلات قادمة",
|
||||
"networks": "شبكات",
|
||||
"tmdb_movie_keyword": "كلمة مفتاحية لفيلم TMDB",
|
||||
"tmdb_movie_genre": "نوع فيلم TMDB",
|
||||
"tmdb_tv_keyword": "كلمة مفتاحية لمسلسل TMDB",
|
||||
"tmdb_tv_genre": "نوع مسلسل TMDB",
|
||||
"tmdb_search": "بحث TMDB",
|
||||
"tmdb_studio": "استوديو TMDB",
|
||||
"tmdb_network": "شبكة TMDB",
|
||||
"tmdb_movie_streaming_services": "خدمات بث الأفلام TMDB",
|
||||
"tmdb_tv_streaming_services": "خدمات بث المسلسلات TMDB"
|
||||
},
|
||||
"library": {
|
||||
"no_items_found": "لم يتم العثور على عناصر",
|
||||
"no_results": "لا توجد نتائج",
|
||||
"no_libraries_found": "لم يتم العثور على مكتبات",
|
||||
"item_types": {
|
||||
"movies": "أفلام",
|
||||
"series": "مسلسلات",
|
||||
"boxsets": "مجموعات",
|
||||
"items": "عناصر"
|
||||
},
|
||||
"options": {
|
||||
"display": "عرض",
|
||||
"row": "صف",
|
||||
"list": "قائمة",
|
||||
"image_style": "نمط الصورة",
|
||||
"poster": "ملصق",
|
||||
"cover": "غلاف",
|
||||
"show_titles": "إظهار العناوين",
|
||||
"show_stats": "إظهار الإحصائيات"
|
||||
},
|
||||
"filters": {
|
||||
"genres": "الأنواع",
|
||||
"years": "السنوات",
|
||||
"sort_by": "ترتيب حسب",
|
||||
"sort_order": "ترتيب",
|
||||
"asc": "تصاعدي",
|
||||
"desc": "تنازلي",
|
||||
"tags": "الوسوم"
|
||||
}
|
||||
},
|
||||
"favorites": {
|
||||
"series": "مسلسلات",
|
||||
"movies": "أفلام",
|
||||
"episodes": "حلقات",
|
||||
"videos": "فيديوهات",
|
||||
"boxsets": "مجموعات",
|
||||
"playlists": "قوائم التشغيل",
|
||||
"noDataTitle": "لا توجد مفضلات بعد",
|
||||
"noData": "ضع علامة على العناصر كمفضلة لتظهر هنا للوصول السريع."
|
||||
},
|
||||
"custom_links": {
|
||||
"no_links": "لا توجد روابط"
|
||||
},
|
||||
"player": {
|
||||
"error": "خطأ",
|
||||
"failed_to_get_stream_url": "فشل في الحصول على رابط البث",
|
||||
"an_error_occured_while_playing_the_video": "حدث خطأ أثناء تشغيل الفيديو. تحقق من السجلات في الإعدادات.",
|
||||
"client_error": "خطأ في العميل",
|
||||
"could_not_create_stream_for_chromecast": "تعذر إنشاء بث لـ Chromecast",
|
||||
"message_from_server": "رسالة من الخادم: {{message}}",
|
||||
"video_has_finished_playing": "انتهى تشغيل الفيديو!",
|
||||
"no_video_source": "لا يوجد مصدر فيديو...",
|
||||
"next_episode": "الحلقة التالية",
|
||||
"refresh_tracks": "تحديث المسارات",
|
||||
"subtitle_tracks": "مسارات الترجمة:",
|
||||
"audio_tracks": "مسارات الصوت:",
|
||||
"playback_state": "حالة التشغيل:",
|
||||
"no_data_available": "لا توجد بيانات متاحة",
|
||||
"index": "الفهرس:",
|
||||
"continue_watching": "متابعة المشاهدة",
|
||||
"go_back": "رجوع"
|
||||
},
|
||||
"item_card": {
|
||||
"next_up": "التالي",
|
||||
"no_items_to_display": "لا توجد عناصر لعرضها",
|
||||
"cast_and_crew": "طاقم العمل",
|
||||
"series": "مسلسلات",
|
||||
"seasons": "مواسم",
|
||||
"season": "موسم",
|
||||
"no_episodes_for_this_season": "لا توجد حلقات لهذا الموسم",
|
||||
"overview": "نظرة عامة",
|
||||
"more_with": "المزيد مع {{name}}",
|
||||
"similar_items": "عناصر مشابهة",
|
||||
"no_similar_items_found": "لم يتم العثور على عناصر مشابهة",
|
||||
"video": "فيديو",
|
||||
"more_details": "المزيد من التفاصيل",
|
||||
"quality": "الجودة",
|
||||
"audio": "الصوت",
|
||||
"subtitles": "الترجمة",
|
||||
"show_more": "عرض المزيد",
|
||||
"show_less": "عرض أقل",
|
||||
"appeared_in": "ظهر في",
|
||||
"could_not_load_item": "تعذر تحميل العنصر",
|
||||
"none": "لا شيء",
|
||||
"download": {
|
||||
"download_season": "تنزيل الموسم",
|
||||
"download_series": "تنزيل المسلسل",
|
||||
"download_episode": "تنزيل الحلقة",
|
||||
"download_movie": "تنزيل الفيلم",
|
||||
"download_x_item": "تنزيل {{item_count}} عناصر",
|
||||
"download_unwatched_only": "فقط غير المشاهدة",
|
||||
"download_button": "تنزيل",
|
||||
"using_optimized_server": "استخدام الخادم المحسن",
|
||||
"using_default_method": "استخدام الطريقة الافتراضية"
|
||||
}
|
||||
},
|
||||
"live_tv": {
|
||||
"next": "التالي",
|
||||
"previous": "السابق",
|
||||
"live_tv": "بث مباشر",
|
||||
"coming_soon": "قريباً",
|
||||
"on_now": "يعرض الآن",
|
||||
"shows": "برامج",
|
||||
"movies": "أفلام",
|
||||
"sports": "رياضة",
|
||||
"for_kids": "للأطفال",
|
||||
"news": "أخبار"
|
||||
},
|
||||
"jellyseerr": {
|
||||
"confirm": "تأكيد",
|
||||
"cancel": "إلغاء",
|
||||
"yes": "نعم",
|
||||
"whats_wrong": "ما المشكلة؟",
|
||||
"issue_type": "نوع المشكلة",
|
||||
"select_an_issue": "حدد مشكلة",
|
||||
"types": "الأنواع",
|
||||
"describe_the_issue": "(اختياري) صف المشكلة...",
|
||||
"submit_button": "إرسال",
|
||||
"report_issue_button": "الإبلاغ عن مشكلة",
|
||||
"request_button": "طلب",
|
||||
"are_you_sure_you_want_to_request_all_seasons": "هل أنت متأكد أنك تريد طلب جميع المواسم؟",
|
||||
"failed_to_login": "فشل تسجيل الدخول",
|
||||
"cast": "طاقم العمل",
|
||||
"details": "التفاصيل",
|
||||
"status": "الحالة",
|
||||
"original_title": "العنوان الأصلي",
|
||||
"series_type": "نوع المسلسل",
|
||||
"release_dates": "تواريخ الإصدار",
|
||||
"first_air_date": "تاريخ أول عرض",
|
||||
"next_air_date": "تاريخ العرض التالي",
|
||||
"revenue": "الإيرادات",
|
||||
"budget": "الميزانية",
|
||||
"original_language": "اللغة الأصلية",
|
||||
"production_country": "بلد الإنتاج",
|
||||
"studios": "استوديوهات",
|
||||
"network": "شبكة",
|
||||
"currently_streaming_on": "يتم بثه حاليًا على",
|
||||
"advanced": "متقدم",
|
||||
"request_as": "طلب باسم",
|
||||
"tags": "الوسوم",
|
||||
"quality_profile": "ملف تعريف الجودة",
|
||||
"root_folder": "المجلد الجذر",
|
||||
"season_all": "الموسم (الكل)",
|
||||
"season_number": "الموسم {{season_number}}",
|
||||
"number_episodes": "{{episode_number}} حلقات",
|
||||
"born": "مواليد",
|
||||
"appearances": "المشاركات",
|
||||
"toasts": {
|
||||
"jellyseer_does_not_meet_requirements": "خادم Jellyseerr لا يفي بالحد الأدنى من متطلبات الإصدار! يرجى التحديث إلى 2.0.0 على الأقل",
|
||||
"jellyseerr_test_failed": "فشل اختبار Jellyseerr. يرجى المحاولة مرة أخرى.",
|
||||
"failed_to_test_jellyseerr_server_url": "فشل اختبار رابط خادم jellyseerr",
|
||||
"issue_submitted": "تم إرسال المشكلة!",
|
||||
"requested_item": "تم طلب {{item}}!",
|
||||
"you_dont_have_permission_to_request": "ليس لديك إذن للطلب!",
|
||||
"something_went_wrong_requesting_media": "حدث خطأ ما أثناء طلب الوسائط!"
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
"home": "الرئيسية",
|
||||
"search": "بحث",
|
||||
"library": "المكتبة",
|
||||
"custom_links": "روابط مخصصة",
|
||||
"favorites": "المفضلة"
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,7 @@
|
||||
"a_free_and_open_source_client_for_jellyfin": "A Free and Open-Source Client for Jellyfin.",
|
||||
"features_title": "Features",
|
||||
"features_description": "Streamyfin has a bunch of features and integrates with a wide array of software which you can find in the settings menu, these include:",
|
||||
"jellyseerr_feature_description": "Connect to your Jellyseerr instance and request movies directly in the app.",
|
||||
"jellyseerr_feature_description": "Connect to your Seerr instance and request movies directly in the app.",
|
||||
"downloads_feature_title": "Downloads",
|
||||
"downloads_feature_description": "Download movies and tv-shows to view offline. Use either the default method or install the optimize server to download files in the background.",
|
||||
"chromecast_feature_description": "Cast movies and tv-shows to your Chromecast devices.",
|
||||
@@ -176,6 +176,7 @@
|
||||
"UNKNOWN": "Unknown"
|
||||
},
|
||||
"safe_area_in_controls": "Safe Area in Controls",
|
||||
"show_home_carousel": "Show Home Carousel",
|
||||
"video_player": "Video Player",
|
||||
"video_players": {
|
||||
"VLC_3": "VLC 3",
|
||||
@@ -199,7 +200,7 @@
|
||||
"jellyseerr_warning": "This integration is in its early stages. Expect things to change.",
|
||||
"server_url": "Server URL",
|
||||
"server_url_hint": "Example: http(s)://your-host.url\n(add port if required)",
|
||||
"server_url_placeholder": "Jellyseerr URL...",
|
||||
"server_url_placeholder": "Seerr URL",
|
||||
"password": "Password",
|
||||
"password_placeholder": "Enter password for Jellyfin user {{username}}",
|
||||
"login_button": "Login",
|
||||
@@ -208,7 +209,7 @@
|
||||
"movie_quota_days": "Movie Quota Days",
|
||||
"tv_quota_limit": "TV Quota Limit",
|
||||
"tv_quota_days": "TV Quota Days",
|
||||
"reset_jellyseerr_config_button": "Reset Jellyseerr Config",
|
||||
"reset_jellyseerr_config_button": "Reset Seerr Config",
|
||||
"unlimited": "Unlimited",
|
||||
"plus_n_more": "+{{n}} More",
|
||||
"order_by": {
|
||||
@@ -218,7 +219,7 @@
|
||||
}
|
||||
},
|
||||
"marlin_search": {
|
||||
"enable_marlin_search": "Enable Marlin Search ",
|
||||
"enable_marlin_search": "Enable Marlin Search",
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://domain.org:port",
|
||||
"marlin_search_hint": "Enter the URL for the Marlin server. The URL should include http or https and optionally the port.",
|
||||
@@ -388,7 +389,7 @@
|
||||
"movies": "Movies",
|
||||
"episodes": "Episodes",
|
||||
"videos": "Videos",
|
||||
"boxsets": "Boxsets",
|
||||
"boxsets": "Box Sets",
|
||||
"playlists": "Playlists",
|
||||
"noDataTitle": "No Favorites Yet",
|
||||
"noData": "Mark items as favorites to see them appear here for quick access."
|
||||
@@ -494,9 +495,9 @@
|
||||
"born": "Born",
|
||||
"appearances": "Appearances",
|
||||
"toasts": {
|
||||
"jellyseer_does_not_meet_requirements": "Jellyseerr server does not meet minimum version requirements! Please update to at least 2.0.0",
|
||||
"jellyseerr_test_failed": "Jellyseerr test failed. Please try again.",
|
||||
"failed_to_test_jellyseerr_server_url": "Failed to test jellyseerr server url",
|
||||
"jellyseer_does_not_meet_requirements": "Seerr server does not meet minimum version requirements! Please update to at least 2.0.0",
|
||||
"jellyseerr_test_failed": "Seerr test failed. Please try again.",
|
||||
"failed_to_test_jellyseerr_server_url": "Failed to test Seerr server url",
|
||||
"issue_submitted": "Issue Submitted!",
|
||||
"requested_item": "Requested {{item}}!",
|
||||
"you_dont_have_permission_to_request": "You don't have permission to request!",
|
||||
|
||||
@@ -6,7 +6,17 @@
|
||||
"jsxImportSource": "react",
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"incremental": true,
|
||||
"tsBuildInfoFile": ".tsbuildinfo",
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": [
|
||||
"app/**/*",
|
||||
|
||||
@@ -91,7 +91,7 @@ export const sortByPreferenceAtom = atomWithStorage<SortPreference>(
|
||||
storage.set(key, JSON.stringify(value));
|
||||
},
|
||||
removeItem: (key) => {
|
||||
storage.remove(key);
|
||||
storage.delete(key);
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -108,7 +108,7 @@ export const sortOrderPreferenceAtom = atomWithStorage<SortOrderPreference>(
|
||||
storage.set(key, JSON.stringify(value));
|
||||
},
|
||||
removeItem: (key) => {
|
||||
storage.remove(key);
|
||||
storage.delete(key);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -93,6 +93,7 @@ export type HomeSection = {
|
||||
items?: HomeSectionItemResolver;
|
||||
nextUp?: HomeSectionNextUpResolver;
|
||||
latest?: HomeSectionLatestResolver;
|
||||
custom?: HomeSectionCustomEndpointResolver;
|
||||
};
|
||||
|
||||
export type HomeSectionItemResolver = {
|
||||
@@ -106,6 +107,13 @@ export type HomeSectionItemResolver = {
|
||||
filters?: Array<ItemFilter>;
|
||||
};
|
||||
|
||||
export type HomeSectionCustomEndpointResolver = {
|
||||
title?: string;
|
||||
endpoint: string;
|
||||
headers?: any;
|
||||
query?: any;
|
||||
};
|
||||
|
||||
export type HomeSectionNextUpResolver = {
|
||||
parentId?: string;
|
||||
limit?: number;
|
||||
@@ -152,6 +160,7 @@ export type Settings = {
|
||||
subtitleMode: SubtitlePlaybackMode;
|
||||
rememberSubtitleSelections: boolean;
|
||||
showHomeTitles: boolean;
|
||||
showHomeCarousel: boolean;
|
||||
defaultVideoOrientation: ScreenOrientation.OrientationLock;
|
||||
forwardSkipTime: number;
|
||||
rewindSkipTime: number;
|
||||
@@ -220,6 +229,7 @@ export const defaultValues: Settings = {
|
||||
subtitleMode: SubtitlePlaybackMode.Default,
|
||||
rememberSubtitleSelections: true,
|
||||
showHomeTitles: true,
|
||||
showHomeCarousel: true,
|
||||
defaultVideoOrientation: ScreenOrientation.OrientationLock.DEFAULT,
|
||||
forwardSkipTime: 30,
|
||||
rewindSkipTime: 10,
|
||||
|
||||
@@ -16,7 +16,7 @@ interface LogEntry {
|
||||
const mmkvStorage = createJSONStorage(() => ({
|
||||
getItem: (key: string) => storage.getString(key) || null,
|
||||
setItem: (key: string, value: string) => storage.set(key, value),
|
||||
removeItem: (key: string) => storage.remove(key),
|
||||
removeItem: (key: string) => storage.delete(key),
|
||||
}));
|
||||
const logsAtom = atomWithStorage("logs", [], mmkvStorage);
|
||||
|
||||
@@ -74,7 +74,7 @@ export const readFromLog = (): LogEntry[] => {
|
||||
};
|
||||
|
||||
export const clearLogs = () => {
|
||||
storage.remove("logs");
|
||||
storage.delete("logs");
|
||||
};
|
||||
|
||||
export const dumpDownloadDiagnostics = (extra: any = {}) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createMMKV } from "react-native-mmkv";
|
||||
import { MMKV } from "react-native-mmkv";
|
||||
|
||||
// Create a single MMKV instance following the official documentation
|
||||
// https://github.com/mrousavy/react-native-mmkv
|
||||
export const storage = createMMKV();
|
||||
export const storage = new MMKV();
|
||||
|
||||
Reference in New Issue
Block a user