mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-16 08:08:18 +00:00
Compare commits
85 Commits
feat/new-b
...
refactor/n
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6dd433821 | ||
|
|
2be78a232c | ||
|
|
30dc3980e3 | ||
|
|
f7da29b9c9 | ||
|
|
7a5f0b52b6 | ||
|
|
62dfe7c9e1 | ||
|
|
50d559d528 | ||
|
|
38aba3d67a | ||
|
|
5765793d79 | ||
|
|
222ba13529 | ||
|
|
de6c2072c9 | ||
|
|
76ec8e0e46 | ||
|
|
389d9e2d31 | ||
|
|
485dc6eeac | ||
|
|
154788cf91 | ||
|
|
3e181eca72 | ||
|
|
f5b9e03dd9 | ||
|
|
196f91400b | ||
|
|
51a14c6058 | ||
|
|
5432476ca1 | ||
|
|
9d9ec974ff | ||
|
|
0dadfd3d90 | ||
|
|
96d6220f5e | ||
|
|
b847baa314 | ||
|
|
a4cce27737 | ||
|
|
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,5 +0,0 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
Don't run the development server or build anything. Assume the user has a separate terminal. Tell the user what to execute.
|
||||
@@ -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'
|
||||
|
||||
74
.gitignore
vendored
74
.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,22 +18,51 @@ 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
|
||||
|
||||
# Secrets and Credentials
|
||||
pc-api-7079014811501811218-719-3b9f15aeccf8.json
|
||||
credentials.json
|
||||
streamyfin-4fec1-firebase-adminsdk.json
|
||||
|
||||
# Version and Backup Files
|
||||
/version-backup-*
|
||||
bun.lockb
|
||||
modules/background-downloader/android/build/*
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -7,10 +7,15 @@ module.exports = ({ config }) => {
|
||||
{ useDefaultExpandedMediaControls: true },
|
||||
]);
|
||||
}
|
||||
|
||||
// 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,
|
||||
};
|
||||
};
|
||||
|
||||
16
app.json
16
app.json
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "Streamyfin",
|
||||
"slug": "streamyfin",
|
||||
"version": "0.39.0",
|
||||
"version": "0.47.1",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "streamyfin",
|
||||
@@ -38,7 +38,7 @@
|
||||
},
|
||||
"android": {
|
||||
"jsEngine": "hermes",
|
||||
"versionCode": 71,
|
||||
"versionCode": 84,
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/icon-android-plain.png",
|
||||
"monochromeImage": "./assets/images/icon-android-themed.png",
|
||||
@@ -117,10 +117,6 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
["./plugins/withChangeNativeAndroidTextToWhite.js"],
|
||||
["./plugins/withAndroidManifest.js"],
|
||||
["./plugins/withTrustLocalCerts.js"],
|
||||
["./plugins/withGradleProperties.js"],
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
@@ -136,8 +132,12 @@
|
||||
"color": "#9333EA"
|
||||
}
|
||||
],
|
||||
"./plugins/with-runtime-framework-headers.js",
|
||||
"react-native-bottom-tabs"
|
||||
"expo-web-browser",
|
||||
["./plugins/with-runtime-framework-headers.js"],
|
||||
["./plugins/withChangeNativeAndroidTextToWhite.js"],
|
||||
["./plugins/withAndroidManifest.js"],
|
||||
["./plugins/withTrustLocalCerts.js"],
|
||||
["./plugins/withGradleProperties.js"]
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { RefreshControl, ScrollView, View } from "react-native";
|
||||
import { Platform, RefreshControl, ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Favorites } from "@/components/home/Favorites";
|
||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||
@@ -28,7 +28,7 @@ export default function favorites() {
|
||||
paddingBottom: 16,
|
||||
}}
|
||||
>
|
||||
<View className='my-4'>
|
||||
<View style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}>
|
||||
<Favorites />
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
@@ -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,
|
||||
@@ -35,7 +30,6 @@ export default function IndexLayout() {
|
||||
{!Platform.isTV && (
|
||||
<>
|
||||
<Chromecast.Chromecast background='transparent' />
|
||||
|
||||
{user?.Policy?.IsAdministrator && <SessionsButton />}
|
||||
<SettingsButton />
|
||||
</>
|
||||
@@ -47,9 +41,16 @@ export default function IndexLayout() {
|
||||
<Stack.Screen
|
||||
name='downloads/index'
|
||||
options={{
|
||||
headerShown: true,
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
title: t("home.downloads.downloads_title"),
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
|
||||
<TouchableOpacity
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
@@ -58,9 +59,17 @@ export default function IndexLayout() {
|
||||
<Stack.Screen
|
||||
name='downloads/[seriesId]'
|
||||
options={{
|
||||
headerShown: true,
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
title: t("home.downloads.tvseries"),
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
|
||||
<TouchableOpacity
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
@@ -70,8 +79,16 @@ export default function IndexLayout() {
|
||||
name='sessions/index'
|
||||
options={{
|
||||
title: t("home.sessions.title"),
|
||||
headerShown: true,
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
|
||||
<TouchableOpacity
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
@@ -81,41 +98,159 @@ export default function IndexLayout() {
|
||||
name='settings'
|
||||
options={{
|
||||
title: t("home.settings.settings_title"),
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
|
||||
<TouchableOpacity
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='settings/marlin-search/page'
|
||||
name='settings/playback-controls/page'
|
||||
options={{
|
||||
title: "",
|
||||
title: t("home.settings.playback_controls.title"),
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
|
||||
<TouchableOpacity
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='settings/jellyseerr/page'
|
||||
name='settings/audio-subtitles/page'
|
||||
options={{
|
||||
title: "",
|
||||
title: t("home.settings.audio_subtitles.title"),
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
|
||||
<TouchableOpacity
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='settings/hide-libraries/page'
|
||||
name='settings/appearance/page'
|
||||
options={{
|
||||
title: "",
|
||||
title: t("home.settings.appearance.title"),
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
|
||||
<TouchableOpacity
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='settings/appearance/hide-libraries/page'
|
||||
options={{
|
||||
title: t("home.settings.other.hide_libraries"),
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='settings/plugins/page'
|
||||
options={{
|
||||
title: t("home.settings.plugins.plugins_title"),
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='settings/plugins/marlin-search/page'
|
||||
options={{
|
||||
title: "Marlin Search",
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='settings/plugins/jellyseerr/page'
|
||||
options={{
|
||||
title: "Jellyseerr",
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='settings/intro/page'
|
||||
options={{
|
||||
title: t("home.settings.intro.title"),
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
@@ -124,9 +259,16 @@ export default function IndexLayout() {
|
||||
<Stack.Screen
|
||||
name='settings/logs/page'
|
||||
options={{
|
||||
title: "",
|
||||
title: t("home.settings.logs.logs_title"),
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
|
||||
<TouchableOpacity
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import { Alert, Platform, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { EpisodeCard } from "@/components/downloads/EpisodeCard";
|
||||
import {
|
||||
@@ -24,6 +26,7 @@ export default function page() {
|
||||
{},
|
||||
);
|
||||
const { downloadedItems, deleteItems } = useDownload();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const series = useMemo(() => {
|
||||
try {
|
||||
@@ -31,7 +34,8 @@ export default function page() {
|
||||
downloadedItems
|
||||
?.filter((f) => f.item.SeriesId === seriesId)
|
||||
?.sort(
|
||||
(a, b) => a?.item.ParentIndexNumber! - b.item.ParentIndexNumber!,
|
||||
(a, b) =>
|
||||
(a.item.ParentIndexNumber ?? 0) - (b.item.ParentIndexNumber ?? 0),
|
||||
) || []
|
||||
);
|
||||
} catch {
|
||||
@@ -70,8 +74,9 @@ export default function page() {
|
||||
}, [seasonGroups]);
|
||||
|
||||
const seasonIndex =
|
||||
seasonIndexState[series?.[0]?.item?.ParentId ?? ""] ||
|
||||
episodeSeasonIndex ||
|
||||
seasonIndexState[series?.[0]?.item?.ParentId ?? ""] ??
|
||||
episodeSeasonIndex ??
|
||||
series?.[0]?.item?.ParentIndexNumber ??
|
||||
"";
|
||||
|
||||
const groupBySeason = useMemo<BaseItemDto[]>(() => {
|
||||
@@ -80,9 +85,9 @@ export default function page() {
|
||||
|
||||
const initialSeasonIndex = useMemo(
|
||||
() =>
|
||||
Object.values(groupBySeason)?.[0]?.ParentIndexNumber ??
|
||||
groupBySeason?.[0]?.ParentIndexNumber ??
|
||||
series?.[0]?.item?.ParentIndexNumber,
|
||||
[groupBySeason],
|
||||
[groupBySeason, series],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -110,46 +115,67 @@ export default function page() {
|
||||
onPress: () =>
|
||||
deleteItems(
|
||||
groupBySeason
|
||||
.map((episode) => episode.Id!)
|
||||
.map((item) => item.Id)
|
||||
.filter((id) => id !== undefined),
|
||||
),
|
||||
style: "destructive",
|
||||
},
|
||||
],
|
||||
);
|
||||
}, [groupBySeason]);
|
||||
}, [groupBySeason, deleteItems]);
|
||||
|
||||
const ListHeaderComponent = useCallback(() => {
|
||||
if (series.length === 0) return null;
|
||||
|
||||
return (
|
||||
<View className='flex flex-row items-center justify-start pb-2'>
|
||||
<SeasonDropdown
|
||||
item={series[0].item}
|
||||
seasons={uniqueSeasons}
|
||||
state={seasonIndexState}
|
||||
initialSeasonIndex={initialSeasonIndex!}
|
||||
onSelect={(season) => {
|
||||
setSeasonIndexState((prev) => ({
|
||||
...prev,
|
||||
[series[0].item.ParentId ?? ""]: season.ParentIndexNumber,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center ml-2'>
|
||||
<Text className='text-xs font-bold'>{groupBySeason.length}</Text>
|
||||
</View>
|
||||
<View className='bg-neutral-800/80 rounded-full h-9 w-9 flex items-center justify-center ml-auto'>
|
||||
<TouchableOpacity onPress={deleteSeries}>
|
||||
<Ionicons name='trash' size={20} color='white' />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}, [
|
||||
series,
|
||||
uniqueSeasons,
|
||||
seasonIndexState,
|
||||
initialSeasonIndex,
|
||||
groupBySeason,
|
||||
deleteSeries,
|
||||
]);
|
||||
|
||||
return (
|
||||
<View className='flex-1'>
|
||||
{series.length > 0 && (
|
||||
<View className='flex flex-row items-center justify-start my-2 px-4'>
|
||||
<SeasonDropdown
|
||||
item={series[0].item}
|
||||
seasons={uniqueSeasons}
|
||||
state={seasonIndexState}
|
||||
initialSeasonIndex={initialSeasonIndex!}
|
||||
onSelect={(season) => {
|
||||
setSeasonIndexState((prev) => ({
|
||||
...prev,
|
||||
[series[0].item.ParentId ?? ""]: season.ParentIndexNumber,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center ml-2'>
|
||||
<Text className='text-xs font-bold'>{groupBySeason.length}</Text>
|
||||
</View>
|
||||
<View className='bg-neutral-800/80 rounded-full h-9 w-9 flex items-center justify-center ml-auto'>
|
||||
<TouchableOpacity onPress={deleteSeries}>
|
||||
<Ionicons name='trash' size={20} color='white' />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
<ScrollView key={seasonIndex} style={{ paddingHorizontal: 16 }}>
|
||||
{groupBySeason.map((episode, index) => (
|
||||
<EpisodeCard key={index} item={episode} />
|
||||
))}
|
||||
</ScrollView>
|
||||
<FlashList
|
||||
key={seasonIndex}
|
||||
data={groupBySeason}
|
||||
renderItem={({ item }) => <EpisodeCard item={item} />}
|
||||
keyExtractor={(item, index) => item.Id ?? `episode-${index}`}
|
||||
ListHeaderComponent={ListHeaderComponent}
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 16,
|
||||
paddingLeft: insets.left + 16,
|
||||
paddingRight: insets.right + 16,
|
||||
paddingTop: Platform.OS === "android" ? 10 : 8,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
type BottomSheetBackdropProps,
|
||||
BottomSheetModal,
|
||||
BottomSheetView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { BottomSheetModal } from "@gorhom/bottom-sheet";
|
||||
import { useNavigation, useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import {
|
||||
Alert,
|
||||
Platform,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { toast } from "sonner-native";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import ActiveDownloads from "@/components/downloads/ActiveDownloads";
|
||||
@@ -26,14 +26,15 @@ import { writeToLog } from "@/utils/log";
|
||||
export default function page() {
|
||||
const navigation = useNavigation();
|
||||
const { t } = useTranslation();
|
||||
const [queue, setQueue] = useAtom(queueAtom);
|
||||
const { removeProcess, downloadedItems, deleteFileByType, deleteAllFiles } =
|
||||
useDownload();
|
||||
const [_queue, _setQueue] = useAtom(queueAtom);
|
||||
const { downloadedItems, deleteFileByType, deleteAllFiles } = useDownload();
|
||||
const router = useRouter();
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
|
||||
const [showMigration, setShowMigration] = useState(false);
|
||||
|
||||
const _insets = useSafeAreaInsets();
|
||||
|
||||
const migration_20241124 = () => {
|
||||
Alert.alert(
|
||||
t("home.downloads.new_app_version_requires_re_download"),
|
||||
@@ -58,7 +59,7 @@ export default function page() {
|
||||
);
|
||||
};
|
||||
|
||||
const downloadedFiles = downloadedItems;
|
||||
const downloadedFiles = useMemo(() => downloadedItems, [downloadedItems]);
|
||||
|
||||
const movies = useMemo(() => {
|
||||
try {
|
||||
@@ -118,7 +119,7 @@ export default function page() {
|
||||
}
|
||||
}, [showMigration]);
|
||||
|
||||
const deleteMovies = () =>
|
||||
const _deleteMovies = () =>
|
||||
deleteFileByType("Movie")
|
||||
.then(() =>
|
||||
toast.success(
|
||||
@@ -129,7 +130,7 @@ export default function page() {
|
||||
writeToLog("ERROR", reason);
|
||||
toast.error(t("home.downloads.toasts.failed_to_delete_all_movies"));
|
||||
});
|
||||
const deleteShows = () =>
|
||||
const _deleteShows = () =>
|
||||
deleteFileByType("Episode")
|
||||
.then(() =>
|
||||
toast.success(
|
||||
@@ -140,12 +141,12 @@ export default function page() {
|
||||
writeToLog("ERROR", reason);
|
||||
toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
|
||||
});
|
||||
const deleteOtherMedia = () =>
|
||||
const _deleteOtherMedia = () =>
|
||||
Promise.all(
|
||||
otherMedia.map(
|
||||
(item) =>
|
||||
item.item.Type &&
|
||||
deleteFileByType(item.item.Type)
|
||||
otherMedia
|
||||
.filter((item) => item.item.Type)
|
||||
.map((item) =>
|
||||
deleteFileByType(item.item.Type!)
|
||||
.then(() =>
|
||||
toast.success(
|
||||
t("home.downloads.toasts.deleted_media_successfully", {
|
||||
@@ -161,19 +162,18 @@ export default function page() {
|
||||
}),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const deleteAllMedia = async () =>
|
||||
await Promise.all([deleteMovies(), deleteShows(), deleteOtherMedia()]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<View style={{ flex: 1 }}>
|
||||
<ScrollView showsVerticalScrollIndicator={false} className='flex-1'>
|
||||
<View className='py-4'>
|
||||
<View className='mb-4 flex flex-col gap-y-4 px-4'>
|
||||
<View className='bg-neutral-900 p-4 rounded-2xl'>
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
>
|
||||
<View style={{ paddingTop: Platform.OS === "android" ? 17 : 0 }}>
|
||||
<View className='mb-4 flex flex-col gap-y-4 px-4'>
|
||||
{/* Queue card - hidden */}
|
||||
{/* <View className='bg-neutral-900 p-4 rounded-2xl'>
|
||||
<Text className='text-lg font-bold'>
|
||||
{t("home.downloads.queue")}
|
||||
</Text>
|
||||
@@ -215,139 +215,96 @@ export default function page() {
|
||||
{t("home.downloads.no_items_in_queue")}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View> */}
|
||||
|
||||
<ActiveDownloads />
|
||||
<ActiveDownloads />
|
||||
</View>
|
||||
|
||||
{movies.length > 0 && (
|
||||
<View className='mb-4'>
|
||||
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
||||
<Text className='text-lg font-bold'>
|
||||
{t("home.downloads.movies")}
|
||||
</Text>
|
||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
||||
<Text className='text-xs font-bold'>{movies?.length}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{movies.length > 0 && (
|
||||
<View className='mb-4'>
|
||||
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
||||
<Text className='text-lg font-bold'>
|
||||
{t("home.downloads.movies")}
|
||||
</Text>
|
||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
||||
<Text className='text-xs font-bold'>{movies?.length}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View className='px-4 flex flex-row'>
|
||||
{movies?.map((item) => (
|
||||
<TouchableItemRouter
|
||||
item={item.item}
|
||||
isOffline
|
||||
key={item.item.Id}
|
||||
>
|
||||
<MovieCard item={item.item} />
|
||||
</TouchableItemRouter>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View className='px-4 flex flex-row'>
|
||||
{movies?.map((item) => (
|
||||
<TouchableItemRouter
|
||||
item={item.item}
|
||||
isOffline
|
||||
key={item.item.Id}
|
||||
>
|
||||
<MovieCard item={item.item} />
|
||||
</TouchableItemRouter>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
{groupedBySeries.length > 0 && (
|
||||
<View className='mb-4'>
|
||||
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
||||
<Text className='text-lg font-bold'>
|
||||
{t("home.downloads.tvseries")}
|
||||
</Text>
|
||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
||||
<Text className='text-xs font-bold'>
|
||||
{groupedBySeries?.length}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View className='px-4 flex flex-row'>
|
||||
{groupedBySeries?.map((items) => (
|
||||
<View
|
||||
className='mb-2 last:mb-0'
|
||||
key={items[0].item.SeriesId}
|
||||
>
|
||||
<SeriesCard
|
||||
items={items.map((i) => i.item)}
|
||||
key={items[0].item.SeriesId}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{otherMedia.length > 0 && (
|
||||
<View className='mb-4'>
|
||||
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
||||
<Text className='text-lg font-bold'>
|
||||
{t("home.downloads.other_media")}
|
||||
</Text>
|
||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
||||
<Text className='text-xs font-bold'>
|
||||
{otherMedia?.length}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View className='px-4 flex flex-row'>
|
||||
{otherMedia?.map((item) => (
|
||||
<TouchableItemRouter
|
||||
item={item.item}
|
||||
isOffline
|
||||
key={item.item.Id}
|
||||
>
|
||||
<MovieCard item={item.item} />
|
||||
</TouchableItemRouter>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
{downloadedFiles?.length === 0 && (
|
||||
<View className='flex px-4'>
|
||||
<Text className='opacity-50'>
|
||||
{t("home.downloads.no_downloaded_items")}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
{groupedBySeries.length > 0 && (
|
||||
<View className='mb-4'>
|
||||
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
||||
<Text className='text-lg font-bold'>
|
||||
{t("home.downloads.tvseries")}
|
||||
</Text>
|
||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
||||
<Text className='text-xs font-bold'>
|
||||
{groupedBySeries?.length}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View className='px-4 flex flex-row'>
|
||||
{groupedBySeries?.map((items) => (
|
||||
<View className='mb-2 last:mb-0' key={items[0].item.SeriesId}>
|
||||
<SeriesCard
|
||||
items={items.map((i) => i.item)}
|
||||
key={items[0].item.SeriesId}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetModalRef}
|
||||
enableDynamicSizing
|
||||
handleIndicatorStyle={{
|
||||
backgroundColor: "white",
|
||||
}}
|
||||
backgroundStyle={{
|
||||
backgroundColor: "#171717",
|
||||
}}
|
||||
backdropComponent={(props: BottomSheetBackdropProps) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<BottomSheetView>
|
||||
<View className='p-4 space-y-4 mb-4'>
|
||||
<Button color='purple' onPress={deleteMovies}>
|
||||
{t("home.downloads.delete_all_movies_button")}
|
||||
</Button>
|
||||
<Button color='purple' onPress={deleteShows}>
|
||||
{t("home.downloads.delete_all_tvseries_button")}
|
||||
</Button>
|
||||
{otherMedia.length > 0 && (
|
||||
<Button color='purple' onPress={deleteOtherMedia}>
|
||||
{t("home.downloads.delete_all_other_media_button")}
|
||||
</Button>
|
||||
)}
|
||||
<Button color='red' onPress={deleteAllMedia}>
|
||||
{t("home.downloads.delete_all_button")}
|
||||
</Button>
|
||||
|
||||
{otherMedia.length > 0 && (
|
||||
<View className='mb-4'>
|
||||
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
||||
<Text className='text-lg font-bold'>
|
||||
{t("home.downloads.other_media")}
|
||||
</Text>
|
||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
||||
<Text className='text-xs font-bold'>{otherMedia?.length}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View className='px-4 flex flex-row'>
|
||||
{otherMedia?.map((item) => (
|
||||
<TouchableItemRouter
|
||||
item={item.item}
|
||||
isOffline
|
||||
key={item.item.Id}
|
||||
>
|
||||
<MovieCard item={item.item} />
|
||||
</TouchableItemRouter>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</BottomSheetView>
|
||||
</BottomSheetModal>
|
||||
</>
|
||||
)}
|
||||
{downloadedFiles?.length === 0 && (
|
||||
<View className='flex px-4'>
|
||||
<Text className='opacity-50'>
|
||||
{t("home.downloads.no_downloaded_items")}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import { HomeIndex } from "@/components/home/HomeIndex";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { Home } from "../../../../components/home/Home";
|
||||
import { HomeWithCarousel } from "../../../../components/home/HomeWithCarousel";
|
||||
|
||||
export default function page() {
|
||||
return <HomeIndex />;
|
||||
}
|
||||
const Index = () => {
|
||||
const { settings } = useSettings();
|
||||
const showLargeHomeCarousel = settings.showLargeHomeCarousel ?? false;
|
||||
|
||||
if (showLargeHomeCarousel) {
|
||||
return <HomeWithCarousel />;
|
||||
}
|
||||
|
||||
return <Home />;
|
||||
};
|
||||
|
||||
export default Index;
|
||||
|
||||
@@ -20,7 +20,7 @@ export default function page() {
|
||||
|
||||
return (
|
||||
<View
|
||||
className={`bg-neutral-900 h-full ${Platform.isTV ? "py-5 space-y-4" : "py-16 space-y-8"} px-4`}
|
||||
className={`bg-neutral-900 h-full ${Platform.isTV ? "py-5 gap-y-4" : "py-16 gap-y-8"} px-4`}
|
||||
>
|
||||
<View>
|
||||
<Text className='text-3xl font-bold text-center mb-2'>
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import {
|
||||
HardwareAccelerationType,
|
||||
type SessionInfoDto,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { HardwareAccelerationType } from "@jellyfin/sdk/lib/generated-client";
|
||||
import {
|
||||
GeneralCommandType,
|
||||
PlaystateCommand,
|
||||
SessionInfoDto,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
@@ -13,7 +11,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { Badge } from "@/components/Badge";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
@@ -49,14 +47,13 @@ export default function page() {
|
||||
<FlashList
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentContainerStyle={{
|
||||
paddingTop: 17,
|
||||
paddingTop: Platform.OS === "android" ? 17 : 0,
|
||||
paddingHorizontal: 17,
|
||||
paddingBottom: 150,
|
||||
}}
|
||||
data={sessions}
|
||||
renderItem={({ item }) => <SessionCard session={item} />}
|
||||
keyExtractor={(item) => item.Id || ""}
|
||||
estimatedItemSize={200}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,34 +8,16 @@ import { Text } from "@/components/common/Text";
|
||||
import { ListGroup } from "@/components/list/ListGroup";
|
||||
import { ListItem } from "@/components/list/ListItem";
|
||||
import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
|
||||
import { AudioToggles } from "@/components/settings/AudioToggles";
|
||||
import { ChromecastSettings } from "@/components/settings/ChromecastSettings";
|
||||
import DownloadSettings from "@/components/settings/DownloadSettings";
|
||||
import { GestureControls } from "@/components/settings/GestureControls";
|
||||
import { MediaProvider } from "@/components/settings/MediaContext";
|
||||
import { MediaToggles } from "@/components/settings/MediaToggles";
|
||||
import { OtherSettings } from "@/components/settings/OtherSettings";
|
||||
import { PluginSettings } from "@/components/settings/PluginSettings";
|
||||
import { QuickConnect } from "@/components/settings/QuickConnect";
|
||||
import { StorageSettings } from "@/components/settings/StorageSettings";
|
||||
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
|
||||
import { UserInfo } from "@/components/settings/UserInfo";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { clearLogs } from "@/utils/log";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
|
||||
export default function settings() {
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [_user] = useAtom(userAtom);
|
||||
const { logout } = useJellyfin();
|
||||
const successHapticFeedback = useHaptic("success");
|
||||
|
||||
const onClearLogsClicked = async () => {
|
||||
clearLogs();
|
||||
successHapticFeedback();
|
||||
};
|
||||
|
||||
const navigation = useNavigation();
|
||||
useEffect(() => {
|
||||
@@ -56,61 +38,58 @@ export default function settings() {
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<View className='p-4 flex flex-col gap-y-4'>
|
||||
<UserInfo />
|
||||
<View
|
||||
className='p-4 flex flex-col'
|
||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||
>
|
||||
<View className='mb-4'>
|
||||
<UserInfo />
|
||||
</View>
|
||||
|
||||
<QuickConnect className='mb-4' />
|
||||
|
||||
<MediaProvider>
|
||||
<MediaToggles className='mb-4' />
|
||||
<GestureControls className='mb-4' />
|
||||
<AudioToggles className='mb-4' />
|
||||
<SubtitleToggles className='mb-4' />
|
||||
</MediaProvider>
|
||||
|
||||
<OtherSettings />
|
||||
|
||||
{!Platform.isTV && <DownloadSettings />}
|
||||
|
||||
<PluginSettings />
|
||||
|
||||
<AppLanguageSelector />
|
||||
|
||||
{!Platform.isTV && <ChromecastSettings />}
|
||||
|
||||
<ListGroup title={"Intro"}>
|
||||
<ListItem
|
||||
onPress={() => {
|
||||
router.push("/intro/page");
|
||||
}}
|
||||
title={t("home.settings.intro.show_intro")}
|
||||
/>
|
||||
<ListItem
|
||||
textColor='red'
|
||||
onPress={() => {
|
||||
storage.set("hasShownIntro", false);
|
||||
}}
|
||||
title={t("home.settings.intro.reset_intro")}
|
||||
/>
|
||||
</ListGroup>
|
||||
<View className='mb-4'>
|
||||
<AppLanguageSelector />
|
||||
</View>
|
||||
|
||||
<View className='mb-4'>
|
||||
<ListGroup title={t("home.settings.logs.logs_title")}>
|
||||
<ListGroup title={t("home.settings.categories.title")}>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/playback-controls/page")}
|
||||
showArrow
|
||||
title={t("home.settings.playback_controls.title")}
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/audio-subtitles/page")}
|
||||
showArrow
|
||||
title={t("home.settings.audio_subtitles.title")}
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/appearance/page")}
|
||||
showArrow
|
||||
title={t("home.settings.appearance.title")}
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/plugins/page")}
|
||||
showArrow
|
||||
title={t("home.settings.plugins.plugins_title")}
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/intro/page")}
|
||||
showArrow
|
||||
title={t("home.settings.intro.title")}
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/logs/page")}
|
||||
showArrow
|
||||
title={t("home.settings.logs.logs_title")}
|
||||
/>
|
||||
<ListItem
|
||||
textColor='red'
|
||||
onPress={onClearLogsClicked}
|
||||
title={t("home.settings.logs.delete_all_logs")}
|
||||
/>
|
||||
</ListGroup>
|
||||
</View>
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ScrollView, Switch, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { ListGroup } from "@/components/list/ListGroup";
|
||||
import { ListItem } from "@/components/list/ListItem";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function page() {
|
||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||
const user = useAtomValue(userAtom);
|
||||
const api = useAtomValue(apiAtom);
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["user-views", user?.Id],
|
||||
queryFn: async () => {
|
||||
const response = await getUserViewsApi(api!).getUserViews({
|
||||
userId: user?.Id,
|
||||
});
|
||||
|
||||
return response.data.Items || null;
|
||||
},
|
||||
});
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<View className='mt-4'>
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<DisabledSetting
|
||||
disabled={pluginSettings?.hiddenLibraries?.locked === true}
|
||||
className='px-4'
|
||||
>
|
||||
<ListGroup title={t("home.settings.other.hide_libraries")}>
|
||||
{data?.map((view) => (
|
||||
<ListItem key={view.Id} title={view.Name} onPress={() => {}}>
|
||||
<Switch
|
||||
value={settings.hiddenLibraries?.includes(view.Id!) || false}
|
||||
onValueChange={(value) => {
|
||||
updateSettings({
|
||||
hiddenLibraries: value
|
||||
? [...(settings.hiddenLibraries || []), view.Id!]
|
||||
: settings.hiddenLibraries?.filter(
|
||||
(id) => id !== view.Id,
|
||||
),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</ListGroup>
|
||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||
{t("home.settings.other.select_liraries_you_want_to_hide")}
|
||||
</Text>
|
||||
</DisabledSetting>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
25
app/(auth)/(tabs)/(home)/settings/appearance/page.tsx
Normal file
25
app/(auth)/(tabs)/(home)/settings/appearance/page.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Platform, ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { AppearanceSettings } from "@/components/settings/AppearanceSettings";
|
||||
|
||||
export default function AppearancePage() {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
className='p-4 flex flex-col'
|
||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||
>
|
||||
<AppearanceSettings />
|
||||
<View className='h-24' />
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
29
app/(auth)/(tabs)/(home)/settings/audio-subtitles/page.tsx
Normal file
29
app/(auth)/(tabs)/(home)/settings/audio-subtitles/page.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Platform, ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { AudioToggles } from "@/components/settings/AudioToggles";
|
||||
import { MediaProvider } from "@/components/settings/MediaContext";
|
||||
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
|
||||
|
||||
export default function AudioSubtitlesPage() {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
className='p-4 flex flex-col'
|
||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||
>
|
||||
<MediaProvider>
|
||||
<AudioToggles className='mb-4' />
|
||||
<SubtitleToggles className='mb-4' />
|
||||
</MediaProvider>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
45
app/(auth)/(tabs)/(home)/settings/intro/page.tsx
Normal file
45
app/(auth)/(tabs)/(home)/settings/intro/page.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useRouter } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { ListGroup } from "@/components/list/ListGroup";
|
||||
import { ListItem } from "@/components/list/ListItem";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
|
||||
export default function IntroPage() {
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
className='p-4 flex flex-col'
|
||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||
>
|
||||
<ListGroup title={t("home.settings.intro.title")}>
|
||||
<ListItem
|
||||
onPress={() => {
|
||||
router.push("/intro/page");
|
||||
}}
|
||||
title={t("home.settings.intro.show_intro")}
|
||||
/>
|
||||
<ListItem
|
||||
textColor='red'
|
||||
onPress={() => {
|
||||
storage.set("hasShownIntro", false);
|
||||
}}
|
||||
title={t("home.settings.intro.reset_intro")}
|
||||
/>
|
||||
</ListGroup>
|
||||
<View className='h-24' />
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function page() {
|
||||
const { pluginSettings } = useSettings();
|
||||
|
||||
return (
|
||||
<DisabledSetting
|
||||
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
|
||||
className='p-4'
|
||||
>
|
||||
<JellyseerrSettings />
|
||||
</DisabledSetting>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import { File, Paths } from "expo-file-system";
|
||||
import { useNavigation } from "expo-router";
|
||||
import * as Sharing from "expo-sharing";
|
||||
import { useCallback, useEffect, useId, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import Collapsible from "react-native-collapsible";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { FilterButton } from "@/components/filters/FilterButton";
|
||||
import { Loader } from "@/components/Loader";
|
||||
@@ -32,6 +34,7 @@ export default function Page() {
|
||||
|
||||
const _orderId = useId();
|
||||
const _levelsId = useId();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const filteredLogs = useMemo(
|
||||
() =>
|
||||
@@ -46,18 +49,17 @@ export default function Page() {
|
||||
|
||||
// Sharing it as txt while its formatted allows us to share it with many more applications
|
||||
const share = useCallback(async () => {
|
||||
const uri = `${FileSystem.documentDirectory}logs.txt`;
|
||||
const logsFile = new File(Paths.document, "logs.txt");
|
||||
|
||||
setLoading(true);
|
||||
FileSystem.writeAsStringAsync(uri, JSON.stringify(filteredLogs))
|
||||
.then(() => {
|
||||
setLoading(false);
|
||||
Sharing.shareAsync(uri, { mimeType: "txt", UTI: "txt" });
|
||||
})
|
||||
.catch((e) =>
|
||||
writeErrorLog("Something went wrong attempting to export", e),
|
||||
)
|
||||
.finally(() => setLoading(false));
|
||||
try {
|
||||
logsFile.write(JSON.stringify(filteredLogs));
|
||||
await Sharing.shareAsync(logsFile.uri, { mimeType: "txt", UTI: "txt" });
|
||||
} catch (e: any) {
|
||||
writeErrorLog("Something went wrong attempting to export", e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filteredLogs]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -66,7 +68,7 @@ export default function Page() {
|
||||
loading ? (
|
||||
<Loader />
|
||||
) : (
|
||||
<TouchableOpacity onPress={share}>
|
||||
<TouchableOpacity onPress={share} className='px-2'>
|
||||
<Text>{t("home.settings.logs.export_logs")}</Text>
|
||||
</TouchableOpacity>
|
||||
),
|
||||
@@ -74,8 +76,13 @@ export default function Page() {
|
||||
}, [share, loading]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<View className='flex flex-row justify-end py-2 px-4 space-x-2'>
|
||||
<View
|
||||
className='flex-1'
|
||||
style={{
|
||||
paddingTop: insets.top + 48,
|
||||
}}
|
||||
>
|
||||
<View className='flex flex-row justify-end py-2 px-4 gap-x-2'>
|
||||
<FilterButton
|
||||
id={orderFilterId}
|
||||
queryKey='log'
|
||||
@@ -99,7 +106,7 @@ export default function Page() {
|
||||
/>
|
||||
</View>
|
||||
<ScrollView className='pb-4 px-4'>
|
||||
<View className='flex flex-col space-y-2'>
|
||||
<View className='flex flex-col gap-y-2'>
|
||||
{filteredLogs?.map((log, index) => (
|
||||
<View className='bg-neutral-900 rounded-xl p-3' key={index}>
|
||||
<TouchableOpacity
|
||||
@@ -139,7 +146,7 @@ export default function Page() {
|
||||
</Text>
|
||||
)}
|
||||
<Collapsible collapsed={!state[log.timestamp]}>
|
||||
<View className='mt-2 flex flex-col space-y-2'>
|
||||
<View className='mt-2 flex flex-col gap-y-2'>
|
||||
<ScrollView className='rounded-xl' style={codeBlockStyle}>
|
||||
<Text>{JSON.stringify(log.data, null, 2)}</Text>
|
||||
</ScrollView>
|
||||
@@ -156,6 +163,6 @@ export default function Page() {
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigation } from "expo-router";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Linking,
|
||||
Switch,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ListGroup } from "@/components/list/ListGroup";
|
||||
import { ListItem } from "@/components/list/ListItem";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function page() {
|
||||
const navigation = useNavigation();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
|
||||
|
||||
const onSave = (val: string) => {
|
||||
updateSettings({
|
||||
marlinServerUrl: !val.endsWith("/") ? val : val.slice(0, -1),
|
||||
});
|
||||
toast.success(t("home.settings.plugins.marlin_search.toasts.saved"));
|
||||
};
|
||||
|
||||
const handleOpenLink = () => {
|
||||
Linking.openURL("https://github.com/fredrikburmester/marlin-search");
|
||||
};
|
||||
|
||||
const disabled = useMemo(() => {
|
||||
return (
|
||||
pluginSettings?.searchEngine?.locked === true &&
|
||||
pluginSettings?.marlinServerUrl?.locked === true
|
||||
);
|
||||
}, [pluginSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pluginSettings?.marlinServerUrl?.locked) {
|
||||
navigation.setOptions({
|
||||
headerRight: () => (
|
||||
<TouchableOpacity onPress={() => onSave(value)}>
|
||||
<Text className='text-blue-500'>
|
||||
{t("home.settings.plugins.marlin_search.save_button")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
),
|
||||
});
|
||||
}
|
||||
}, [navigation, value]);
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
return (
|
||||
<DisabledSetting disabled={disabled} className='px-4'>
|
||||
<ListGroup>
|
||||
<DisabledSetting
|
||||
disabled={pluginSettings?.searchEngine?.locked === true}
|
||||
showText={!pluginSettings?.marlinServerUrl?.locked}
|
||||
>
|
||||
<ListItem
|
||||
title={t(
|
||||
"home.settings.plugins.marlin_search.enable_marlin_search",
|
||||
)}
|
||||
onPress={() => {
|
||||
updateSettings({ searchEngine: "Jellyfin" });
|
||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||
}}
|
||||
>
|
||||
<Switch
|
||||
value={settings.searchEngine === "Marlin"}
|
||||
onValueChange={(value) => {
|
||||
updateSettings({ searchEngine: value ? "Marlin" : "Jellyfin" });
|
||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
</DisabledSetting>
|
||||
</ListGroup>
|
||||
|
||||
<DisabledSetting
|
||||
disabled={pluginSettings?.marlinServerUrl?.locked === true}
|
||||
showText={!pluginSettings?.searchEngine?.locked}
|
||||
className='mt-2 flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4'
|
||||
>
|
||||
<View className={"flex flex-row items-center bg-neutral-900 h-11 pr-4"}>
|
||||
<Text className='mr-4'>
|
||||
{t("home.settings.plugins.marlin_search.url")}
|
||||
</Text>
|
||||
<TextInput
|
||||
editable={settings.searchEngine === "Marlin"}
|
||||
className='text-white'
|
||||
placeholder={t(
|
||||
"home.settings.plugins.marlin_search.server_url_placeholder",
|
||||
)}
|
||||
value={value}
|
||||
keyboardType='url'
|
||||
returnKeyType='done'
|
||||
autoCapitalize='none'
|
||||
textContentType='URL'
|
||||
onChangeText={(text) => setValue(text)}
|
||||
/>
|
||||
</View>
|
||||
</DisabledSetting>
|
||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
|
||||
<Text className='text-blue-500' onPress={handleOpenLink}>
|
||||
{t("home.settings.plugins.marlin_search.read_more_about_marlin")}
|
||||
</Text>
|
||||
</Text>
|
||||
</DisabledSetting>
|
||||
);
|
||||
}
|
||||
35
app/(auth)/(tabs)/(home)/settings/playback-controls/page.tsx
Normal file
35
app/(auth)/(tabs)/(home)/settings/playback-controls/page.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Platform, ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { GestureControls } from "@/components/settings/GestureControls";
|
||||
import { MediaProvider } from "@/components/settings/MediaContext";
|
||||
import { MediaToggles } from "@/components/settings/MediaToggles";
|
||||
import { PlaybackControlsSettings } from "@/components/settings/PlaybackControlsSettings";
|
||||
import { ChromecastSettings } from "../../../../../../components/settings/ChromecastSettings";
|
||||
|
||||
export default function PlaybackControlsPage() {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
className='p-4 flex flex-col'
|
||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||
>
|
||||
<View className='mb-4'>
|
||||
<MediaProvider>
|
||||
<MediaToggles className='mb-4' />
|
||||
<GestureControls className='mb-4' />
|
||||
<PlaybackControlsSettings />
|
||||
</MediaProvider>
|
||||
</View>
|
||||
{!Platform.isTV && <ChromecastSettings />}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { ScrollView } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function page() {
|
||||
const { pluginSettings } = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<DisabledSetting
|
||||
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
|
||||
className='px-4'
|
||||
>
|
||||
<JellyseerrSettings />
|
||||
</DisabledSetting>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
138
app/(auth)/(tabs)/(home)/settings/plugins/marlin-search/page.tsx
Normal file
138
app/(auth)/(tabs)/(home)/settings/plugins/marlin-search/page.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigation } from "expo-router";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Linking,
|
||||
ScrollView,
|
||||
Switch,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { toast } from "sonner-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ListGroup } from "@/components/list/ListGroup";
|
||||
import { ListItem } from "@/components/list/ListItem";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function page() {
|
||||
const navigation = useNavigation();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
|
||||
|
||||
const onSave = (val: string) => {
|
||||
updateSettings({
|
||||
marlinServerUrl: !val.endsWith("/") ? val : val.slice(0, -1),
|
||||
});
|
||||
toast.success(t("home.settings.plugins.marlin_search.toasts.saved"));
|
||||
};
|
||||
|
||||
const handleOpenLink = () => {
|
||||
Linking.openURL("https://github.com/fredrikburmester/marlin-search");
|
||||
};
|
||||
|
||||
const disabled = useMemo(() => {
|
||||
return (
|
||||
pluginSettings?.searchEngine?.locked === true &&
|
||||
pluginSettings?.marlinServerUrl?.locked === true
|
||||
);
|
||||
}, [pluginSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pluginSettings?.marlinServerUrl?.locked) {
|
||||
navigation.setOptions({
|
||||
headerRight: () => (
|
||||
<TouchableOpacity onPress={() => onSave(value)} className='px-2'>
|
||||
<Text className='text-blue-500'>
|
||||
{t("home.settings.plugins.marlin_search.save_button")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
),
|
||||
});
|
||||
}
|
||||
}, [navigation, value]);
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<DisabledSetting disabled={disabled} className='px-4'>
|
||||
<ListGroup>
|
||||
<DisabledSetting
|
||||
disabled={pluginSettings?.searchEngine?.locked === true}
|
||||
showText={!pluginSettings?.marlinServerUrl?.locked}
|
||||
>
|
||||
<ListItem
|
||||
title={t(
|
||||
"home.settings.plugins.marlin_search.enable_marlin_search",
|
||||
)}
|
||||
onPress={() => {
|
||||
updateSettings({ searchEngine: "Jellyfin" });
|
||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||
}}
|
||||
>
|
||||
<Switch
|
||||
value={settings.searchEngine === "Marlin"}
|
||||
onValueChange={(value) => {
|
||||
updateSettings({
|
||||
searchEngine: value ? "Marlin" : "Jellyfin",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
</DisabledSetting>
|
||||
</ListGroup>
|
||||
|
||||
<DisabledSetting
|
||||
disabled={pluginSettings?.marlinServerUrl?.locked === true}
|
||||
showText={!pluginSettings?.searchEngine?.locked}
|
||||
className='mt-2 flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4'
|
||||
>
|
||||
<View
|
||||
className={"flex flex-row items-center bg-neutral-900 h-11 pr-4"}
|
||||
>
|
||||
<Text className='mr-4'>
|
||||
{t("home.settings.plugins.marlin_search.url")}
|
||||
</Text>
|
||||
<TextInput
|
||||
editable={settings.searchEngine === "Marlin"}
|
||||
className='text-white'
|
||||
placeholder={t(
|
||||
"home.settings.plugins.marlin_search.server_url_placeholder",
|
||||
)}
|
||||
value={value}
|
||||
keyboardType='url'
|
||||
returnKeyType='done'
|
||||
autoCapitalize='none'
|
||||
textContentType='URL'
|
||||
onChangeText={(text) => setValue(text)}
|
||||
/>
|
||||
</View>
|
||||
</DisabledSetting>
|
||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
|
||||
<Text className='text-blue-500' onPress={handleOpenLink}>
|
||||
{t("home.settings.plugins.marlin_search.read_more_about_marlin")}
|
||||
</Text>
|
||||
</Text>
|
||||
</DisabledSetting>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
24
app/(auth)/(tabs)/(home)/settings/plugins/page.tsx
Normal file
24
app/(auth)/(tabs)/(home)/settings/plugins/page.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Platform, ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { PluginSettings } from "@/components/settings/PluginSettings";
|
||||
|
||||
export default function PluginsPage() {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
className='px-4 flex flex-col'
|
||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||
>
|
||||
<PluginSettings />
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import type React from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FlatList, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import { FilterButton } from "@/components/filters/FilterButton";
|
||||
@@ -204,154 +205,154 @@ const page: React.FC = () => {
|
||||
|
||||
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||
|
||||
const _insets = useSafeAreaInsets();
|
||||
|
||||
const ListHeaderComponent = useCallback(
|
||||
() => (
|
||||
<View className=''>
|
||||
<FlatList
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{
|
||||
display: "flex",
|
||||
paddingHorizontal: 15,
|
||||
paddingVertical: 16,
|
||||
flexDirection: "row",
|
||||
}}
|
||||
extraData={[
|
||||
selectedGenres,
|
||||
selectedYears,
|
||||
selectedTags,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
]}
|
||||
data={[
|
||||
{
|
||||
key: "reset",
|
||||
component: <ResetFiltersButton />,
|
||||
},
|
||||
{
|
||||
key: "genre",
|
||||
component: (
|
||||
<FilterButton
|
||||
className='mr-1'
|
||||
id={collectionId}
|
||||
queryKey='genreFilter'
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api,
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: collectionId,
|
||||
});
|
||||
return response.data.Genres || [];
|
||||
}}
|
||||
set={setSelectedGenres}
|
||||
values={selectedGenres}
|
||||
title={t("library.filters.genres")}
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "year",
|
||||
component: (
|
||||
<FilterButton
|
||||
className='mr-1'
|
||||
id={collectionId}
|
||||
queryKey='yearFilter'
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api,
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: collectionId,
|
||||
});
|
||||
return response.data.Years || [];
|
||||
}}
|
||||
set={setSelectedYears}
|
||||
values={selectedYears}
|
||||
title={t("library.filters.years")}
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) => item.includes(search)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "tags",
|
||||
component: (
|
||||
<FilterButton
|
||||
className='mr-1'
|
||||
id={collectionId}
|
||||
queryKey='tagsFilter'
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api,
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: collectionId,
|
||||
});
|
||||
return response.data.Tags || [];
|
||||
}}
|
||||
set={setSelectedTags}
|
||||
values={selectedTags}
|
||||
title={t("library.filters.tags")}
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "sortBy",
|
||||
component: (
|
||||
<FilterButton
|
||||
className='mr-1'
|
||||
id={collectionId}
|
||||
queryKey='sortBy'
|
||||
queryFn={async () => sortOptions.map((s) => s.key)}
|
||||
set={setSortBy}
|
||||
values={sortBy}
|
||||
title={t("library.filters.sort_by")}
|
||||
renderItemLabel={(item) =>
|
||||
sortOptions.find((i) => i.key === item)?.value || ""
|
||||
}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "sortOrder",
|
||||
component: (
|
||||
<FilterButton
|
||||
className='mr-1'
|
||||
id={collectionId}
|
||||
queryKey='sortOrder'
|
||||
queryFn={async () => sortOrderOptions.map((s) => s.key)}
|
||||
set={setSortOrder}
|
||||
values={sortOrder}
|
||||
title={t("library.filters.sort_order")}
|
||||
renderItemLabel={(item) =>
|
||||
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
||||
}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
renderItem={({ item }) => item.component}
|
||||
keyExtractor={(item) => item.key}
|
||||
/>
|
||||
</View>
|
||||
<FlatList
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{
|
||||
display: "flex",
|
||||
paddingHorizontal: 15,
|
||||
paddingVertical: 16,
|
||||
flexDirection: "row",
|
||||
}}
|
||||
extraData={[
|
||||
selectedGenres,
|
||||
selectedYears,
|
||||
selectedTags,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
]}
|
||||
data={[
|
||||
{
|
||||
key: "reset",
|
||||
component: <ResetFiltersButton />,
|
||||
},
|
||||
{
|
||||
key: "genre",
|
||||
component: (
|
||||
<FilterButton
|
||||
className='mr-1'
|
||||
id={collectionId}
|
||||
queryKey='genreFilter'
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api,
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: collectionId,
|
||||
});
|
||||
return response.data.Genres || [];
|
||||
}}
|
||||
set={setSelectedGenres}
|
||||
values={selectedGenres}
|
||||
title={t("library.filters.genres")}
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "year",
|
||||
component: (
|
||||
<FilterButton
|
||||
className='mr-1'
|
||||
id={collectionId}
|
||||
queryKey='yearFilter'
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api,
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: collectionId,
|
||||
});
|
||||
return response.data.Years || [];
|
||||
}}
|
||||
set={setSelectedYears}
|
||||
values={selectedYears}
|
||||
title={t("library.filters.years")}
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) => item.includes(search)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "tags",
|
||||
component: (
|
||||
<FilterButton
|
||||
className='mr-1'
|
||||
id={collectionId}
|
||||
queryKey='tagsFilter'
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api,
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: collectionId,
|
||||
});
|
||||
return response.data.Tags || [];
|
||||
}}
|
||||
set={setSelectedTags}
|
||||
values={selectedTags}
|
||||
title={t("library.filters.tags")}
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "sortBy",
|
||||
component: (
|
||||
<FilterButton
|
||||
className='mr-1'
|
||||
id={collectionId}
|
||||
queryKey='sortBy'
|
||||
queryFn={async () => sortOptions.map((s) => s.key)}
|
||||
set={setSortBy}
|
||||
values={sortBy}
|
||||
title={t("library.filters.sort_by")}
|
||||
renderItemLabel={(item) =>
|
||||
sortOptions.find((i) => i.key === item)?.value || ""
|
||||
}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "sortOrder",
|
||||
component: (
|
||||
<FilterButton
|
||||
className='mr-1'
|
||||
id={collectionId}
|
||||
queryKey='sortOrder'
|
||||
queryFn={async () => sortOrderOptions.map((s) => s.key)}
|
||||
set={setSortOrder}
|
||||
values={sortOrder}
|
||||
title={t("library.filters.sort_order")}
|
||||
renderItemLabel={(item) =>
|
||||
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
||||
}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
renderItem={({ item }) => item.component}
|
||||
keyExtractor={(item) => item.key}
|
||||
/>
|
||||
),
|
||||
[
|
||||
collectionId,
|
||||
|
||||
@@ -80,7 +80,7 @@ const Page: React.FC = () => {
|
||||
<View className='h-6 bg-neutral-900 rounded mb-4 w-14' />
|
||||
<View className='h-10 bg-neutral-900 rounded-lg mb-2 w-1/2' />
|
||||
<View className='h-3 bg-neutral-900 rounded mb-3 w-8' />
|
||||
<View className='flex flex-row space-x-1 mb-8'>
|
||||
<View className='flex flex-row gap-x-1 mb-8'>
|
||||
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
|
||||
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
|
||||
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
|
||||
|
||||
@@ -63,6 +63,7 @@ const Page: React.FC = () => {
|
||||
const [issueType, setIssueType] = useState<IssueType>();
|
||||
const [issueMessage, setIssueMessage] = useState<string>();
|
||||
const [requestBody, _setRequestBody] = useState<MediaRequestBody>();
|
||||
const [issueTypeDropdownOpen, setIssueTypeDropdownOpen] = useState(false);
|
||||
const advancedReqModalRef = useRef<BottomSheetModal>(null);
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
|
||||
@@ -113,6 +114,10 @@ const Page: React.FC = () => {
|
||||
}
|
||||
}, [jellyseerrApi, details, result, issueType, issueMessage]);
|
||||
|
||||
const handleIssueModalDismiss = useCallback(() => {
|
||||
setIssueTypeDropdownOpen(false);
|
||||
}, []);
|
||||
|
||||
const setRequestBody = useCallback(
|
||||
(body: MediaRequestBody) => {
|
||||
_setRequestBody(body);
|
||||
@@ -176,7 +181,9 @@ const Page: React.FC = () => {
|
||||
if (details) {
|
||||
navigation.setOptions({
|
||||
headerRight: () => (
|
||||
<TouchableOpacity className='rounded-full p-2 bg-neutral-800/80'>
|
||||
<TouchableOpacity
|
||||
className={`rounded-full pl-1.5 ${Platform.OS === "android" ? "" : "bg-neutral-800/80"}`}
|
||||
>
|
||||
<ItemActions item={details} />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
@@ -275,7 +282,7 @@ const Page: React.FC = () => {
|
||||
</Button>
|
||||
) : (
|
||||
details?.mediaInfo?.jellyfinMediaId && (
|
||||
<View className='flex flex-row space-x-2 mt-4'>
|
||||
<View className='flex flex-row gap-x-2 mt-4'>
|
||||
{!Platform.isTV && (
|
||||
<Button
|
||||
className='flex-1 bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100'
|
||||
@@ -371,15 +378,17 @@ const Page: React.FC = () => {
|
||||
backgroundColor: "#171717",
|
||||
}}
|
||||
backdropComponent={renderBackdrop}
|
||||
stackBehavior='push'
|
||||
onDismiss={handleIssueModalDismiss}
|
||||
>
|
||||
<BottomSheetView>
|
||||
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
||||
<View className='flex flex-col gap-y-4 px-4 pb-8 pt-2'>
|
||||
<View>
|
||||
<Text className='font-bold text-2xl text-neutral-100'>
|
||||
{t("jellyseerr.whats_wrong")}
|
||||
</Text>
|
||||
</View>
|
||||
<View className='flex flex-col space-y-2 items-start'>
|
||||
<View className='flex flex-col gap-y-2 items-start'>
|
||||
<View className='flex flex-col w-full'>
|
||||
<Text className='opacity-50 mb-1 text-xs'>
|
||||
{t("jellyseerr.issue_type")}
|
||||
@@ -396,6 +405,8 @@ const Page: React.FC = () => {
|
||||
</View>
|
||||
}
|
||||
title={t("jellyseerr.types")}
|
||||
open={issueTypeDropdownOpen}
|
||||
onOpenChange={setIssueTypeDropdownOpen}
|
||||
/>
|
||||
</View>
|
||||
|
||||
|
||||
@@ -87,14 +87,15 @@ export default function page() {
|
||||
<Text className='font-bold text-2xl mb-1'>{data?.details?.name}</Text>
|
||||
<Text className='opacity-50'>
|
||||
{t("jellyseerr.born")}{" "}
|
||||
{new Date(data?.details?.birthday!).toLocaleDateString(
|
||||
`${locale}-${region}`,
|
||||
{
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
},
|
||||
)}{" "}
|
||||
{data?.details?.birthday &&
|
||||
new Date(data.details.birthday).toLocaleDateString(
|
||||
`${locale}-${region}`,
|
||||
{
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
},
|
||||
)}{" "}
|
||||
| {data?.details?.placeOfBirth}
|
||||
</Text>
|
||||
</>
|
||||
|
||||
@@ -33,7 +33,6 @@ export default function page() {
|
||||
<View className='flex flex-1'>
|
||||
<FlashList
|
||||
data={channels?.Items}
|
||||
estimatedItemSize={76}
|
||||
renderItem={({ item }) => (
|
||||
<View className='flex flex-row items-center px-4 mb-2'>
|
||||
<View className='w-22 mr-4 rounded-lg overflow-hidden'>
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function page() {
|
||||
paddingTop: 8,
|
||||
}}
|
||||
>
|
||||
<View className='flex flex-col space-y-2'>
|
||||
<View className='flex flex-col gap-y-2'>
|
||||
<ScrollingCollectionList
|
||||
queryKey={["livetv", "recommended"]}
|
||||
title={t("live_tv.on_now")}
|
||||
|
||||
@@ -65,9 +65,11 @@ const page: React.FC = () => {
|
||||
const { data: allEpisodes, isLoading } = useQuery({
|
||||
queryKey: ["AllEpisodes", item?.Id],
|
||||
queryFn: async () => {
|
||||
const res = await getTvShowsApi(api!).getEpisodes({
|
||||
seriesId: item?.Id!,
|
||||
userId: user?.Id!,
|
||||
if (!api || !user?.Id || !item?.Id) return [];
|
||||
|
||||
const res = await getTvShowsApi(api).getEpisodes({
|
||||
seriesId: item.Id,
|
||||
userId: user.Id,
|
||||
enableUserData: true,
|
||||
// Note: Including trick play is necessary to enable trick play downloads
|
||||
fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
|
||||
@@ -92,7 +94,7 @@ const page: React.FC = () => {
|
||||
item &&
|
||||
allEpisodes &&
|
||||
allEpisodes.length > 0 && (
|
||||
<View className='flex flex-row items-center space-x-2'>
|
||||
<View className='flex flex-row items-center gap-x-2'>
|
||||
<AddToFavorites item={item} />
|
||||
{!Platform.isTV && (
|
||||
<DownloadItems
|
||||
|
||||
@@ -271,145 +271,143 @@ const Page = () => {
|
||||
|
||||
const ListHeaderComponent = useCallback(
|
||||
() => (
|
||||
<View className=''>
|
||||
<FlatList
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{
|
||||
display: "flex",
|
||||
paddingHorizontal: 15,
|
||||
paddingVertical: 16,
|
||||
flexDirection: "row",
|
||||
}}
|
||||
data={[
|
||||
{
|
||||
key: "reset",
|
||||
component: <ResetFiltersButton />,
|
||||
},
|
||||
{
|
||||
key: "genre",
|
||||
component: (
|
||||
<FilterButton
|
||||
className='mr-1'
|
||||
id={libraryId}
|
||||
queryKey='genreFilter'
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api,
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: libraryId,
|
||||
});
|
||||
return response.data.Genres || [];
|
||||
}}
|
||||
set={setSelectedGenres}
|
||||
values={selectedGenres}
|
||||
title={t("library.filters.genres")}
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "year",
|
||||
component: (
|
||||
<FilterButton
|
||||
className='mr-1'
|
||||
id={libraryId}
|
||||
queryKey='yearFilter'
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api,
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: libraryId,
|
||||
});
|
||||
return response.data.Years || [];
|
||||
}}
|
||||
set={setSelectedYears}
|
||||
values={selectedYears}
|
||||
title={t("library.filters.years")}
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) => item.includes(search)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "tags",
|
||||
component: (
|
||||
<FilterButton
|
||||
className='mr-1'
|
||||
id={libraryId}
|
||||
queryKey='tagsFilter'
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api,
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: libraryId,
|
||||
});
|
||||
return response.data.Tags || [];
|
||||
}}
|
||||
set={setSelectedTags}
|
||||
values={selectedTags}
|
||||
title={t("library.filters.tags")}
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "sortBy",
|
||||
component: (
|
||||
<FilterButton
|
||||
className='mr-1'
|
||||
id={libraryId}
|
||||
queryKey='sortBy'
|
||||
queryFn={async () => sortOptions.map((s) => s.key)}
|
||||
set={setSortBy}
|
||||
values={sortBy}
|
||||
title={t("library.filters.sort_by")}
|
||||
renderItemLabel={(item) =>
|
||||
sortOptions.find((i) => i.key === item)?.value || ""
|
||||
}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "sortOrder",
|
||||
component: (
|
||||
<FilterButton
|
||||
className='mr-1'
|
||||
id={libraryId}
|
||||
queryKey='sortOrder'
|
||||
queryFn={async () => sortOrderOptions.map((s) => s.key)}
|
||||
set={setSortOrder}
|
||||
values={sortOrder}
|
||||
title={t("library.filters.sort_order")}
|
||||
renderItemLabel={(item) =>
|
||||
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
||||
}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
renderItem={({ item }) => item.component}
|
||||
keyExtractor={(item) => item.key}
|
||||
/>
|
||||
</View>
|
||||
<FlatList
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{
|
||||
display: "flex",
|
||||
paddingHorizontal: 15,
|
||||
paddingVertical: 16,
|
||||
flexDirection: "row",
|
||||
}}
|
||||
data={[
|
||||
{
|
||||
key: "reset",
|
||||
component: <ResetFiltersButton />,
|
||||
},
|
||||
{
|
||||
key: "genre",
|
||||
component: (
|
||||
<FilterButton
|
||||
className='mr-1'
|
||||
id={libraryId}
|
||||
queryKey='genreFilter'
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api,
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: libraryId,
|
||||
});
|
||||
return response.data.Genres || [];
|
||||
}}
|
||||
set={setSelectedGenres}
|
||||
values={selectedGenres}
|
||||
title={t("library.filters.genres")}
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "year",
|
||||
component: (
|
||||
<FilterButton
|
||||
className='mr-1'
|
||||
id={libraryId}
|
||||
queryKey='yearFilter'
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api,
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: libraryId,
|
||||
});
|
||||
return response.data.Years || [];
|
||||
}}
|
||||
set={setSelectedYears}
|
||||
values={selectedYears}
|
||||
title={t("library.filters.years")}
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) => item.includes(search)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "tags",
|
||||
component: (
|
||||
<FilterButton
|
||||
className='mr-1'
|
||||
id={libraryId}
|
||||
queryKey='tagsFilter'
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api,
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: libraryId,
|
||||
});
|
||||
return response.data.Tags || [];
|
||||
}}
|
||||
set={setSelectedTags}
|
||||
values={selectedTags}
|
||||
title={t("library.filters.tags")}
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "sortBy",
|
||||
component: (
|
||||
<FilterButton
|
||||
className='mr-1'
|
||||
id={libraryId}
|
||||
queryKey='sortBy'
|
||||
queryFn={async () => sortOptions.map((s) => s.key)}
|
||||
set={setSortBy}
|
||||
values={sortBy}
|
||||
title={t("library.filters.sort_by")}
|
||||
renderItemLabel={(item) =>
|
||||
sortOptions.find((i) => i.key === item)?.value || ""
|
||||
}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "sortOrder",
|
||||
component: (
|
||||
<FilterButton
|
||||
className='mr-1'
|
||||
id={libraryId}
|
||||
queryKey='sortOrder'
|
||||
queryFn={async () => sortOrderOptions.map((s) => s.key)}
|
||||
set={setSortOrder}
|
||||
values={sortOrder}
|
||||
title={t("library.filters.sort_order")}
|
||||
renderItemLabel={(item) =>
|
||||
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
||||
}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
renderItem={({ item }) => item.component}
|
||||
keyExtractor={(item) => item.key}
|
||||
/>
|
||||
),
|
||||
[
|
||||
libraryId,
|
||||
@@ -453,7 +451,6 @@ const Page = () => {
|
||||
renderItem={renderItem}
|
||||
extraData={[orientation, nrOfCols]}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemSize={244}
|
||||
numColumns={nrOfCols}
|
||||
onEndReached={() => {
|
||||
if (hasNextPage) {
|
||||
|
||||
@@ -1,16 +1,152 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Stack } from "expo-router";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform } from "react-native";
|
||||
import { Platform, View } from "react-native";
|
||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function IndexLayout() {
|
||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Reset dropdown state when component unmounts or navigates away
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setDropdownOpen(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Memoize callbacks to prevent recreating on every render
|
||||
const handleDisplayRow = useCallback(() => {
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
display: "row",
|
||||
},
|
||||
});
|
||||
}, [settings.libraryOptions, updateSettings]);
|
||||
|
||||
const handleDisplayList = useCallback(() => {
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
display: "list",
|
||||
},
|
||||
});
|
||||
}, [settings.libraryOptions, updateSettings]);
|
||||
|
||||
const handleImageStylePoster = useCallback(() => {
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
imageStyle: "poster",
|
||||
},
|
||||
});
|
||||
}, [settings.libraryOptions, updateSettings]);
|
||||
|
||||
const handleImageStyleCover = useCallback(() => {
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
imageStyle: "cover",
|
||||
},
|
||||
});
|
||||
}, [settings.libraryOptions, updateSettings]);
|
||||
|
||||
const handleToggleTitles = useCallback(() => {
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
showTitles: !settings.libraryOptions.showTitles,
|
||||
},
|
||||
});
|
||||
}, [settings.libraryOptions, updateSettings]);
|
||||
|
||||
const handleToggleStats = useCallback(() => {
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
showStats: !settings.libraryOptions.showStats,
|
||||
},
|
||||
});
|
||||
}, [settings.libraryOptions, updateSettings]);
|
||||
|
||||
// Memoize groups to prevent recreating the array on every render
|
||||
const dropdownGroups = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: t("library.options.display"),
|
||||
options: [
|
||||
{
|
||||
type: "radio" as const,
|
||||
label: t("library.options.row"),
|
||||
value: "row",
|
||||
selected: settings.libraryOptions.display === "row",
|
||||
onPress: handleDisplayRow,
|
||||
},
|
||||
{
|
||||
type: "radio" as const,
|
||||
label: t("library.options.list"),
|
||||
value: "list",
|
||||
selected: settings.libraryOptions.display === "list",
|
||||
onPress: handleDisplayList,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t("library.options.image_style"),
|
||||
options: [
|
||||
{
|
||||
type: "radio" as const,
|
||||
label: t("library.options.poster"),
|
||||
value: "poster",
|
||||
selected: settings.libraryOptions.imageStyle === "poster",
|
||||
onPress: handleImageStylePoster,
|
||||
},
|
||||
{
|
||||
type: "radio" as const,
|
||||
label: t("library.options.cover"),
|
||||
value: "cover",
|
||||
selected: settings.libraryOptions.imageStyle === "cover",
|
||||
onPress: handleImageStyleCover,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Options",
|
||||
options: [
|
||||
{
|
||||
type: "toggle" as const,
|
||||
label: t("library.options.show_titles"),
|
||||
value: settings.libraryOptions.showTitles,
|
||||
onToggle: handleToggleTitles,
|
||||
disabled: settings.libraryOptions.imageStyle === "poster",
|
||||
},
|
||||
{
|
||||
type: "toggle" as const,
|
||||
label: t("library.options.show_stats"),
|
||||
value: settings.libraryOptions.showStats,
|
||||
onToggle: handleToggleStats,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
t,
|
||||
settings.libraryOptions,
|
||||
handleDisplayRow,
|
||||
handleDisplayList,
|
||||
handleImageStylePoster,
|
||||
handleImageStyleCover,
|
||||
handleToggleTitles,
|
||||
handleToggleStats,
|
||||
],
|
||||
);
|
||||
|
||||
if (!settings?.libraryOptions) return null;
|
||||
|
||||
return (
|
||||
@@ -27,111 +163,19 @@ export default function IndexLayout() {
|
||||
!pluginSettings?.libraryOptions?.locked &&
|
||||
!Platform.isTV && (
|
||||
<PlatformDropdown
|
||||
open={dropdownOpen}
|
||||
onOpenChange={setDropdownOpen}
|
||||
trigger={
|
||||
<Ionicons
|
||||
name='ellipsis-horizontal-outline'
|
||||
size={24}
|
||||
color='white'
|
||||
/>
|
||||
<View className='pl-1.5'>
|
||||
<Ionicons
|
||||
name='ellipsis-horizontal-outline'
|
||||
size={24}
|
||||
color='white'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
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,
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
groups={dropdownGroups}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, View } from "react-native";
|
||||
import { Platform, StyleSheet, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
@@ -84,7 +84,7 @@ export default function index() {
|
||||
extraData={settings}
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentContainerStyle={{
|
||||
paddingTop: 17,
|
||||
paddingTop: Platform.OS === "android" ? 17 : 0,
|
||||
paddingHorizontal: settings?.libraryOptions?.display === "row" ? 0 : 17,
|
||||
paddingBottom: 150,
|
||||
paddingLeft: insets.left + 17,
|
||||
|
||||
@@ -28,7 +28,7 @@ export default function SearchLayout() {
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: !Platform.isTV,
|
||||
headerBlurEffect: "prominent",
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
|
||||
@@ -282,9 +282,12 @@ export default function search() {
|
||||
maxLength={500}
|
||||
/>
|
||||
)}
|
||||
<View className='flex flex-col'>
|
||||
<View
|
||||
className='flex flex-col'
|
||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||
>
|
||||
{jellyseerrApi && (
|
||||
<View className='pl-4 pr-4 pt-2 flex flex-row'>
|
||||
<View className='pl-4 pr-4 flex flex-row'>
|
||||
<SearchTabButtons
|
||||
searchType={searchType}
|
||||
setSearchType={setSearchType}
|
||||
@@ -415,7 +418,7 @@ export default function search() {
|
||||
</Text>
|
||||
</View>
|
||||
) : debouncedSearch.length === 0 ? (
|
||||
<View className='mt-4 flex flex-col items-center space-y-2'>
|
||||
<View className='mt-4 flex flex-col items-center gap-y-2'>
|
||||
{exampleSearches.map((e) => (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
|
||||
@@ -55,6 +55,7 @@ export default function TabLayout() {
|
||||
backgroundColor: "#121212",
|
||||
}}
|
||||
tabBarActiveTintColor={Colors.primary}
|
||||
activeIndicatorColor={"#392c3b"}
|
||||
scrollEdgeAppearance='default'
|
||||
>
|
||||
<NativeTabs.Screen redirect name='index' />
|
||||
@@ -70,10 +71,7 @@ export default function TabLayout() {
|
||||
tabBarIcon:
|
||||
Platform.OS === "android"
|
||||
? (_e) => require("@/assets/icons/house.fill.png")
|
||||
: ({ focused }) =>
|
||||
focused
|
||||
? { sfSymbol: "house.fill" }
|
||||
: { sfSymbol: "house" },
|
||||
: (_e) => ({ sfSymbol: "house.fill" }),
|
||||
}}
|
||||
/>
|
||||
<NativeTabs.Screen
|
||||
@@ -84,14 +82,12 @@ export default function TabLayout() {
|
||||
})}
|
||||
name='(search)'
|
||||
options={{
|
||||
role: "search",
|
||||
title: t("tabs.search"),
|
||||
tabBarIcon:
|
||||
Platform.OS === "android"
|
||||
? (_e) => require("@/assets/icons/magnifyingglass.png")
|
||||
: ({ focused }) =>
|
||||
focused
|
||||
? { sfSymbol: "magnifyingglass" }
|
||||
: { sfSymbol: "magnifyingglass" },
|
||||
: (_e) => ({ sfSymbol: "magnifyingglass" }),
|
||||
}}
|
||||
/>
|
||||
<NativeTabs.Screen
|
||||
@@ -100,14 +96,8 @@ export default function TabLayout() {
|
||||
title: t("tabs.favorites"),
|
||||
tabBarIcon:
|
||||
Platform.OS === "android"
|
||||
? ({ focused }) =>
|
||||
focused
|
||||
? require("@/assets/icons/heart.fill.png")
|
||||
: require("@/assets/icons/heart.png")
|
||||
: ({ focused }) =>
|
||||
focused
|
||||
? { sfSymbol: "heart.fill" }
|
||||
: { sfSymbol: "heart" },
|
||||
? (_e) => require("@/assets/icons/heart.fill.png")
|
||||
: (_e) => ({ sfSymbol: "heart.fill" }),
|
||||
}}
|
||||
/>
|
||||
<NativeTabs.Screen
|
||||
@@ -117,10 +107,7 @@ export default function TabLayout() {
|
||||
tabBarIcon:
|
||||
Platform.OS === "android"
|
||||
? (_e) => require("@/assets/icons/server.rack.png")
|
||||
: ({ focused }) =>
|
||||
focused
|
||||
? { sfSymbol: "rectangle.stack.fill" }
|
||||
: { sfSymbol: "rectangle.stack" },
|
||||
: (_e) => ({ sfSymbol: "rectangle.stack.fill" }),
|
||||
}}
|
||||
/>
|
||||
<NativeTabs.Screen
|
||||
@@ -131,10 +118,7 @@ export default function TabLayout() {
|
||||
tabBarIcon:
|
||||
Platform.OS === "android"
|
||||
? (_e) => require("@/assets/icons/list.png")
|
||||
: ({ focused }) =>
|
||||
focused
|
||||
? { sfSymbol: "list.dash.fill" }
|
||||
: { sfSymbol: "list.dash" },
|
||||
: (_e) => ({ sfSymbol: "list.dash.fill" }),
|
||||
}}
|
||||
/>
|
||||
</NativeTabs>
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
VLCColor,
|
||||
} from "@/constants/SubtitleConstants";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||
@@ -56,6 +57,7 @@ export default function page() {
|
||||
|
||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
||||
const [showControls, _setShowControls] = useState(true);
|
||||
const [isPipMode, setIsPipMode] = useState(false);
|
||||
const [aspectRatio, setAspectRatio] = useState<
|
||||
"default" | "16:9" | "4:3" | "1:1" | "21:9"
|
||||
>("default");
|
||||
@@ -108,6 +110,7 @@ export default function page() {
|
||||
playbackPosition?: string;
|
||||
}>();
|
||||
const { settings } = useSettings();
|
||||
const { lockOrientation, unlockOrientation } = useOrientation();
|
||||
|
||||
const offline = offlineStr === "true";
|
||||
const playbackManager = usePlaybackManager();
|
||||
@@ -170,6 +173,16 @@ export default function page() {
|
||||
}
|
||||
}, [itemId, offline, api, user?.Id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (settings?.defaultVideoOrientation) {
|
||||
lockOrientation(settings.defaultVideoOrientation);
|
||||
}
|
||||
|
||||
return () => {
|
||||
unlockOrientation();
|
||||
};
|
||||
}, [settings?.defaultVideoOrientation]);
|
||||
|
||||
interface Stream {
|
||||
mediaSource: MediaSourceInfo;
|
||||
sessionId: string;
|
||||
@@ -285,12 +298,14 @@ export default function page() {
|
||||
};
|
||||
|
||||
const reportPlaybackStopped = useCallback(async () => {
|
||||
if (!item?.Id || !stream?.sessionId) return;
|
||||
|
||||
const currentTimeInTicks = msToTicks(progress.get());
|
||||
await getPlaystateApi(api!).onPlaybackStopped({
|
||||
itemId: item?.Id!,
|
||||
itemId: item.Id,
|
||||
mediaSourceId: mediaSourceId,
|
||||
positionTicks: currentTimeInTicks,
|
||||
playSessionId: stream?.sessionId!,
|
||||
playSessionId: stream.sessionId,
|
||||
});
|
||||
}, [
|
||||
api,
|
||||
@@ -321,9 +336,9 @@ export default function page() {
|
||||
}, [navigation, stop]);
|
||||
|
||||
const currentPlayStateInfo = useCallback(() => {
|
||||
if (!stream) return;
|
||||
if (!stream || !item?.Id) return;
|
||||
return {
|
||||
itemId: item?.Id!,
|
||||
itemId: item.Id,
|
||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||
mediaSourceId: mediaSourceId,
|
||||
@@ -730,9 +745,12 @@ export default function page() {
|
||||
);
|
||||
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
||||
}}
|
||||
onPipStarted={(e) => {
|
||||
setIsPipMode(e.nativeEvent.pipStarted);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
{isMounted === true && item && (
|
||||
{isMounted === true && item && !isPipMode && (
|
||||
<Controls
|
||||
mediaSource={stream?.mediaSource}
|
||||
item={item}
|
||||
@@ -764,6 +782,7 @@ export default function page() {
|
||||
setAspectRatio={setAspectRatio}
|
||||
setScaleFactor={setScaleFactor}
|
||||
isVlc
|
||||
api={api}
|
||||
downloadedFiles={downloadedFiles}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
import { Link, Stack } from "expo-router";
|
||||
import { StyleSheet } from "react-native";
|
||||
|
||||
import { ThemedText } from "@/components/ThemedText";
|
||||
import { ThemedView } from "@/components/ThemedView";
|
||||
import { StyleSheet, View } from "react-native";
|
||||
import { Text } from "../components/common/Text";
|
||||
|
||||
export default function NotFoundScreen() {
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: "Oops!" }} />
|
||||
<ThemedView style={styles.container}>
|
||||
<ThemedText type='title'>This screen doesn't exist.</ThemedText>
|
||||
<View style={styles.container}>
|
||||
<Text>This screen doesn't exist.</Text>
|
||||
<Link href={"/home"} style={styles.link}>
|
||||
<ThemedText type='link'>Go to home screen!</ThemedText>
|
||||
<Text>Go to home screen!</Text>
|
||||
</Link>
|
||||
</ThemedView>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
223
app/_layout.tsx
223
app/_layout.tsx
@@ -5,7 +5,6 @@ import { DarkTheme, ThemeProvider } from "@react-navigation/native";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import * as BackgroundTask from "expo-background-task";
|
||||
import * as Device from "expo-device";
|
||||
import { Paths } from "expo-file-system";
|
||||
import { Platform } from "react-native";
|
||||
import { GlobalModal } from "@/components/GlobalModal";
|
||||
import i18n from "@/i18n";
|
||||
@@ -14,12 +13,12 @@ import { GlobalModalProvider } from "@/providers/GlobalModalProvider";
|
||||
import {
|
||||
apiAtom,
|
||||
getOrSetDeviceId,
|
||||
getTokenFromStorage,
|
||||
JellyfinProvider,
|
||||
} from "@/providers/JellyfinProvider";
|
||||
import { NetworkStatusProvider } from "@/providers/NetworkStatusProvider";
|
||||
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
||||
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
||||
import { type Settings, useSettings } from "@/utils/atoms/settings";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import {
|
||||
BACKGROUND_FETCH_TASK,
|
||||
BACKGROUND_FETCH_TASK_SESSIONS,
|
||||
@@ -35,31 +34,28 @@ import { storage } from "@/utils/mmkv";
|
||||
|
||||
const Notifications = !Platform.isTV ? require("expo-notifications") : null;
|
||||
|
||||
import { getLocales } from "expo-localization";
|
||||
import { router, Stack, useSegments } from "expo-router";
|
||||
import * as SplashScreen from "expo-splash-screen";
|
||||
|
||||
import * as TaskManager from "expo-task-manager";
|
||||
import { Provider as JotaiProvider } from "jotai";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import { Appearance } from "react-native";
|
||||
import { SystemBars } from "react-native-edge-to-edge";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import "react-native-reanimated";
|
||||
import "@/global.css";
|
||||
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
||||
import { getLocales } from "expo-localization";
|
||||
import type { EventSubscription } from "expo-modules-core";
|
||||
import type {
|
||||
Notification,
|
||||
NotificationResponse,
|
||||
} from "expo-notifications/build/Notifications.types";
|
||||
import type { ExpoPushToken } from "expo-notifications/build/Tokens.types";
|
||||
import { useAtom } from "jotai";
|
||||
import { Toaster } from "sonner-native";
|
||||
import { router, Stack, useSegments } from "expo-router";
|
||||
import * as SplashScreen from "expo-splash-screen";
|
||||
import * as TaskManager from "expo-task-manager";
|
||||
import { Provider as JotaiProvider, useAtom } from "jotai";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import { Appearance } from "react-native";
|
||||
import { SystemBars } from "react-native-edge-to-edge";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { userAtom } from "@/providers/JellyfinProvider";
|
||||
import { store } from "@/utils/store";
|
||||
import "../global.css";
|
||||
import "react-native-reanimated";
|
||||
import { Toaster } from "sonner-native";
|
||||
|
||||
if (!Platform.isTV) {
|
||||
Notifications.setNotificationHandler({
|
||||
@@ -127,24 +123,7 @@ if (!Platform.isTV) {
|
||||
|
||||
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
|
||||
console.log("TaskManager ~ trigger");
|
||||
|
||||
const settingsData = storage.getString("settings");
|
||||
|
||||
if (!settingsData) return BackgroundTask.BackgroundTaskResult.Failed;
|
||||
|
||||
const settings: Partial<Settings> = JSON.parse(settingsData);
|
||||
|
||||
if (!settings?.autoDownload)
|
||||
return BackgroundTask.BackgroundTaskResult.Failed;
|
||||
|
||||
const token = getTokenFromStorage();
|
||||
const deviceId = getOrSetDeviceId();
|
||||
const baseDirectory = Paths.document.uri;
|
||||
|
||||
if (!token || !deviceId || !baseDirectory)
|
||||
return BackgroundTask.BackgroundTaskResult.Failed;
|
||||
|
||||
// Be sure to return the successful result type!
|
||||
// Background fetch task placeholder - currently unused
|
||||
return BackgroundTask.BackgroundTaskResult.Success;
|
||||
});
|
||||
}
|
||||
@@ -209,11 +188,7 @@ export default function RootLayout() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 0,
|
||||
refetchOnMount: true,
|
||||
refetchOnReconnect: true,
|
||||
refetchOnWindowFocus: true,
|
||||
retryOnMount: true,
|
||||
staleTime: 30000,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -222,7 +197,7 @@ function Layout() {
|
||||
const { settings } = useSettings();
|
||||
const [user] = useAtom(userAtom);
|
||||
const [api] = useAtom(apiAtom);
|
||||
const segments = useSegments();
|
||||
const _segments = useSegments();
|
||||
|
||||
useEffect(() => {
|
||||
i18n.changeLanguage(
|
||||
@@ -251,7 +226,7 @@ function Layout() {
|
||||
} else console.log("No token available");
|
||||
}, [api, expoPushToken, user]);
|
||||
|
||||
async function registerNotifications() {
|
||||
const registerNotifications = useCallback(async () => {
|
||||
if (Platform.OS === "android") {
|
||||
console.log("Setting android notification channel 'default'");
|
||||
await Notifications?.setNotificationChannelAsync("default", {
|
||||
@@ -282,11 +257,21 @@ function Layout() {
|
||||
|
||||
// only create push token for real devices (pointless for emulators)
|
||||
if (Device.isDevice) {
|
||||
Notifications?.getExpoPushTokenAsync()
|
||||
.then((token: ExpoPushToken) => token && setExpoPushToken(token))
|
||||
.catch((reason: any) => console.log("Failed to get token", reason));
|
||||
Notifications?.getExpoPushTokenAsync({
|
||||
projectId: "e79219d1-797f-4fbe-9fa1-cfd360690a68",
|
||||
})
|
||||
.then((token: ExpoPushToken) => {
|
||||
if (token) {
|
||||
console.log("Expo push token obtained:", token.data);
|
||||
setExpoPushToken(token);
|
||||
}
|
||||
})
|
||||
.catch((reason: any) => {
|
||||
console.error("Failed to get push token:", reason);
|
||||
writeErrorLog("Failed to get Expo push token", reason);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!Platform.isTV) {
|
||||
@@ -350,96 +335,70 @@ function Layout() {
|
||||
responseListener.current?.remove();
|
||||
};
|
||||
}
|
||||
}, [user, api]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.isTV) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (segments.includes("direct-player" as never)) {
|
||||
if (
|
||||
!settings.followDeviceOrientation &&
|
||||
settings.defaultVideoOrientation
|
||||
) {
|
||||
ScreenOrientation.lockAsync(settings.defaultVideoOrientation);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (settings.followDeviceOrientation === true) {
|
||||
ScreenOrientation.unlockAsync();
|
||||
} else {
|
||||
ScreenOrientation.lockAsync(
|
||||
ScreenOrientation.OrientationLock.PORTRAIT_UP,
|
||||
);
|
||||
}
|
||||
}, [
|
||||
settings.followDeviceOrientation,
|
||||
settings.defaultVideoOrientation,
|
||||
segments,
|
||||
]);
|
||||
}, [user]);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<JellyfinProvider>
|
||||
<PlaySettingsProvider>
|
||||
<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,
|
||||
<NetworkStatusProvider>
|
||||
<PlaySettingsProvider>
|
||||
<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: Platform.OS === "ios",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name='+not-found' />
|
||||
</Stack>
|
||||
<Toaster
|
||||
duration={4000}
|
||||
toastOptions={{
|
||||
style: {
|
||||
backgroundColor: "#262626",
|
||||
borderColor: "#363639",
|
||||
borderWidth: 1,
|
||||
},
|
||||
titleStyle: {
|
||||
color: "white",
|
||||
},
|
||||
}}
|
||||
closeButton
|
||||
/>
|
||||
<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
|
||||
/>
|
||||
<GlobalModal />
|
||||
</ThemeProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</GlobalModalProvider>
|
||||
</DownloadProvider>
|
||||
</WebSocketProvider>
|
||||
</LogProvider>
|
||||
</PlaySettingsProvider>
|
||||
<GlobalModal />
|
||||
</ThemeProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</GlobalModalProvider>
|
||||
</DownloadProvider>
|
||||
</WebSocketProvider>
|
||||
</LogProvider>
|
||||
</PlaySettingsProvider>
|
||||
</NetworkStatusProvider>
|
||||
</JellyfinProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
@@ -10,10 +10,10 @@ import {
|
||||
Keyboard,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
SafeAreaView,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Input } from "@/components/common/Input";
|
||||
@@ -62,12 +62,13 @@ const Login: React.FC = () => {
|
||||
address: _apiUrl,
|
||||
});
|
||||
|
||||
// Wait for server setup and state updates to complete
|
||||
setTimeout(() => {
|
||||
if (_username && _password) {
|
||||
setCredentials({ username: _username, password: _password });
|
||||
login(_username, _password);
|
||||
}
|
||||
}, 300);
|
||||
}, 0);
|
||||
}
|
||||
})();
|
||||
}, [_apiUrl, _username, _password]);
|
||||
@@ -370,10 +371,11 @@ const Login: React.FC = () => {
|
||||
// Mobile layout
|
||||
<SafeAreaView style={{ flex: 1, paddingBottom: 16 }}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
{api?.basePath ? (
|
||||
<View className='flex flex-col h-full relative items-center justify-center'>
|
||||
<View className='flex flex-col flex-1 items-center justify-center'>
|
||||
<View className='px-4 -mt-20 w-full'>
|
||||
<View className='flex flex-col gap-y-2'>
|
||||
<Text className='text-2xl font-bold -mb-2'>
|
||||
@@ -442,7 +444,7 @@ const Login: React.FC = () => {
|
||||
<View className='absolute bottom-0 left-0 w-full px-4 mb-2' />
|
||||
</View>
|
||||
) : (
|
||||
<View className='flex flex-col h-full items-center justify-center w-full'>
|
||||
<View className='flex flex-col flex-1 items-center justify-center w-full'>
|
||||
<View className='flex flex-col gap-y-2 px-4 w-full -mt-36'>
|
||||
<Image
|
||||
style={{
|
||||
|
||||
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,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.2.4/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.3.5/schema.json",
|
||||
"files": {
|
||||
"includes": [
|
||||
"**/*",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import type { FC } from "react";
|
||||
import { Platform, View, type ViewProps } from "react-native";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { RoundButton } from "@/components/RoundButton";
|
||||
import { useFavorite } from "@/hooks/useFavorite";
|
||||
|
||||
@@ -11,24 +11,11 @@ interface Props extends ViewProps {
|
||||
export const AddToFavorites: FC<Props> = ({ item, ...props }) => {
|
||||
const { isFavorite, toggleFavorite } = useFavorite(item);
|
||||
|
||||
if (Platform.OS === "ios") {
|
||||
return (
|
||||
<View {...props}>
|
||||
<RoundButton
|
||||
size='large'
|
||||
icon={isFavorite ? "heart" : "heart-outline"}
|
||||
onPress={toggleFavorite}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<RoundButton
|
||||
size='large'
|
||||
icon={isFavorite ? "heart" : "heart-outline"}
|
||||
fillColor={isFavorite ? "primary" : undefined}
|
||||
onPress={toggleFavorite}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -69,28 +69,27 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
||||
return (
|
||||
<View
|
||||
className={`
|
||||
relative w-44 aspect-video rounded-xl overflow-hidden border border-neutral-800
|
||||
relative w-44 aspect-video rounded-lg overflow-hidden border border-neutral-800
|
||||
${size === "small" ? "w-32" : "w-44"}
|
||||
`}
|
||||
>
|
||||
<Image
|
||||
key={item.Id}
|
||||
id={item.Id}
|
||||
source={{
|
||||
uri: url,
|
||||
}}
|
||||
cachePolicy={"memory-disk"}
|
||||
contentFit='cover'
|
||||
style={{
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
}}
|
||||
/>
|
||||
{showPlayButton && (
|
||||
<View className='absolute inset-0 flex items-center justify-center'>
|
||||
<Ionicons name='play-circle' size={40} color='white' />
|
||||
</View>
|
||||
)}
|
||||
<View className='w-full h-full flex items-center justify-center'>
|
||||
<Image
|
||||
key={item.Id}
|
||||
id={item.Id}
|
||||
source={{
|
||||
uri: url,
|
||||
}}
|
||||
cachePolicy={"memory-disk"}
|
||||
contentFit='cover'
|
||||
className='w-full h-full'
|
||||
/>
|
||||
{showPlayButton && (
|
||||
<View className='absolute inset-0 flex items-center justify-center'>
|
||||
<Ionicons name='play-circle' size={40} color='white' />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{!item.UserData?.Played && <WatchedIndicator item={item} />}
|
||||
<ProgressBar item={item} />
|
||||
</View>
|
||||
|
||||
@@ -89,11 +89,8 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
bottomSheetModalRef.current?.present();
|
||||
}, []);
|
||||
|
||||
const handleSheetChanges = useCallback((index: number) => {
|
||||
// Ensure modal is fully dismissed when index is -1
|
||||
if (index === -1) {
|
||||
// Modal is fully closed
|
||||
}
|
||||
const handleSheetChanges = useCallback((_index: number) => {
|
||||
// Modal state tracking handled by BottomSheetModal
|
||||
}, []);
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
@@ -156,6 +153,13 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
itemsNotDownloaded.every((p) => queue.some((q) => p.Id === q.item.Id))
|
||||
);
|
||||
}, [queue, itemsNotDownloaded]);
|
||||
|
||||
const itemsInProgressOrQueued = useMemo(() => {
|
||||
const inProgress = itemsProcesses.length;
|
||||
const inQueue = queue.filter((q) => itemIds.includes(q.item.Id)).length;
|
||||
return inProgress + inQueue;
|
||||
}, [itemsProcesses, queue, itemIds]);
|
||||
|
||||
const navigateToDownloads = () => router.push("/downloads");
|
||||
|
||||
const onDownloadedPress = () => {
|
||||
@@ -255,13 +259,12 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
throw new Error("No item id");
|
||||
}
|
||||
|
||||
// Ensure modal is dismissed before starting download
|
||||
await closeModal();
|
||||
closeModal();
|
||||
|
||||
// Small delay to ensure modal is fully dismissed
|
||||
setTimeout(() => {
|
||||
// Wait for modal dismiss animation to complete
|
||||
requestAnimationFrame(() => {
|
||||
initiateDownload(...itemsToDownload);
|
||||
}, 100);
|
||||
});
|
||||
} else {
|
||||
toast.error(
|
||||
t("home.downloads.toasts.you_are_not_allowed_to_download_files"),
|
||||
@@ -281,7 +284,14 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
);
|
||||
|
||||
const renderButtonContent = () => {
|
||||
if (processes.length > 0 && itemsProcesses.length > 0) {
|
||||
// For single item downloads, show progress if item is being processed
|
||||
// For multi-item downloads (season/series), show progress only if 2+ items are in progress or queued
|
||||
const shouldShowProgress =
|
||||
itemIds.length === 1
|
||||
? itemsProcesses.length > 0
|
||||
: itemsInProgressOrQueued > 1;
|
||||
|
||||
if (processes.length > 0 && shouldShowProgress) {
|
||||
return progress === 0 ? (
|
||||
<Loader />
|
||||
) : (
|
||||
@@ -335,9 +345,6 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
backgroundColor: "#171717",
|
||||
}}
|
||||
onChange={handleSheetChanges}
|
||||
onDismiss={() => {
|
||||
// Ensure any pending state is cleared when modal is dismissed
|
||||
}}
|
||||
backdropComponent={renderBackdrop}
|
||||
enablePanDownToClose
|
||||
enableDismissOnClose
|
||||
@@ -346,7 +353,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
keyboardBlurBehavior='restore'
|
||||
>
|
||||
<BottomSheetView>
|
||||
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
||||
<View className='flex flex-col gap-y-4 px-4 pb-8 pt-2'>
|
||||
<View>
|
||||
<Text className='font-bold text-2xl text-neutral-100'>
|
||||
{title}
|
||||
@@ -358,7 +365,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
})}
|
||||
</Text>
|
||||
</View>
|
||||
<View className='flex flex-col space-y-2 w-full'>
|
||||
<View className='flex flex-col gap-y-2 w-full'>
|
||||
<View className='items-start'>
|
||||
<BitrateSelector
|
||||
inverted
|
||||
@@ -397,7 +404,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
/>
|
||||
</View>
|
||||
{selectedOptions?.mediaSource && (
|
||||
<View className='flex flex-col space-y-2 items-start'>
|
||||
<View className='flex flex-col gap-y-2 items-start'>
|
||||
<AudioTrackSelector
|
||||
source={selectedOptions.mediaSource}
|
||||
onChange={(val) => {
|
||||
|
||||
@@ -22,7 +22,9 @@ export const Tag: React.FC<
|
||||
> = ({ text, textClass, textStyle, ...props }) => {
|
||||
return (
|
||||
<View className='bg-neutral-800 rounded-full px-2 py-1' {...props}>
|
||||
<Text className='text-white'>{text}</Text>
|
||||
<Text className={textClass} style={textStyle}>
|
||||
{text}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -64,6 +64,8 @@ export const GlobalModal = () => {
|
||||
backgroundStyle={modalOptions.backgroundStyle}
|
||||
enablePanDownToClose={modalOptions.enablePanDownToClose}
|
||||
enableDismissOnClose
|
||||
stackBehavior='push'
|
||||
style={{ zIndex: 1000 }}
|
||||
>
|
||||
{modalState.content}
|
||||
</BottomSheetModal>
|
||||
|
||||
@@ -124,10 +124,10 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
)}
|
||||
</View>
|
||||
) : (
|
||||
<View className='flex flex-row items-center space-x-2'>
|
||||
<View className='flex flex-row items-center gap-x-2'>
|
||||
<Chromecast.Chromecast width={22} height={22} />
|
||||
{item.Type !== "Program" && (
|
||||
<View className='flex flex-row items-center space-x-2'>
|
||||
<View className='flex flex-row items-center gap-x-2'>
|
||||
{!Platform.isTV && (
|
||||
<DownloadSingleItem item={item} size='large' />
|
||||
)}
|
||||
@@ -201,7 +201,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
}
|
||||
>
|
||||
<View className='flex flex-col bg-transparent shrink'>
|
||||
<View className='flex flex-col px-4 w-full pt-2 mb-2 shrink'>
|
||||
<View className='flex flex-col px-4 w-full gap-y-2 pt-2 mb-2 shrink'>
|
||||
<ItemHeader item={item} className='mb-2' />
|
||||
{item.Type !== "Program" && !Platform.isTV && !isOffline && (
|
||||
<View className='flex flex-row items-center justify-start w-full h-16 mb-2'>
|
||||
@@ -287,21 +287,21 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
{item.Type !== "Program" && (
|
||||
<>
|
||||
{item.Type === "Episode" && !isOffline && (
|
||||
<CurrentSeries item={item} className='' />
|
||||
<CurrentSeries item={item} className='mb-4' />
|
||||
)}
|
||||
|
||||
{!isOffline && (
|
||||
<CastAndCrew item={item} className='' loading={loading} />
|
||||
<CastAndCrew item={item} className='mb-4' loading={loading} />
|
||||
)}
|
||||
|
||||
{item.People && item.People.length > 0 && !isOffline && (
|
||||
<View className=''>
|
||||
<View className='mb-4'>
|
||||
{item.People.slice(0, 3).map((person, idx) => (
|
||||
<MoreMoviesWithActor
|
||||
currentItem={item}
|
||||
key={idx}
|
||||
actorId={person.Id!}
|
||||
className=''
|
||||
className='mb-4'
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
|
||||
@@ -15,7 +15,7 @@ export const ItemHeader: React.FC<Props> = ({ item, ...props }) => {
|
||||
if (!item)
|
||||
return (
|
||||
<View
|
||||
className='flex flex-col space-y-1.5 w-full items-start h-32'
|
||||
className='flex flex-col gap-y-1.5 w-full items-start h-32'
|
||||
{...props}
|
||||
>
|
||||
<View className='w-1/3 h-6 bg-neutral-900 rounded' />
|
||||
|
||||
@@ -29,7 +29,7 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source }) => {
|
||||
<View className='px-4 mt-2 mb-4'>
|
||||
<Text className='text-lg font-bold mb-4'>{t("item_card.video")}</Text>
|
||||
<TouchableOpacity onPress={() => bottomSheetModalRef.current?.present()}>
|
||||
<View className='flex flex-row mb-2'>
|
||||
<View className='flex flex-row gap-x-2'>
|
||||
<VideoStreamInfo source={source} />
|
||||
</View>
|
||||
<Text className='text-purple-600'>{t("item_card.more_details")}</Text>
|
||||
@@ -52,12 +52,12 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source }) => {
|
||||
)}
|
||||
>
|
||||
<BottomSheetScrollView>
|
||||
<View className='flex flex-col space-y-2 p-4 mb-4'>
|
||||
<View className='flex flex-col gap-y-2 p-4 mb-4'>
|
||||
<View>
|
||||
<Text className='text-lg font-bold mb-4'>
|
||||
{t("item_card.video")}
|
||||
</Text>
|
||||
<View className='flex flex-row space-x-2'>
|
||||
<View className='flex flex-row gap-x-2'>
|
||||
<VideoStreamInfo source={source} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -82,7 +82,7 @@ export const MoreMoviesWithActor: React.FC<Props> = ({
|
||||
<HorizontalScroll
|
||||
data={items}
|
||||
loading={isLoading}
|
||||
height={218}
|
||||
height={247}
|
||||
renderItem={(item: BaseItemDto, idx: number) => (
|
||||
<TouchableItemRouter
|
||||
key={idx}
|
||||
|
||||
@@ -99,7 +99,7 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
|
||||
style={{
|
||||
top: -50,
|
||||
}}
|
||||
className='relative flex-1 bg-transparent pb-24'
|
||||
className='relative flex-1 bg-transparent pb-4'
|
||||
>
|
||||
<LinearGradient
|
||||
// Background Linear Gradient
|
||||
|
||||
@@ -127,7 +127,8 @@ const BottomSheetContent: React.FC<{
|
||||
title?: string;
|
||||
groups: OptionGroup[];
|
||||
onOptionSelect?: (value?: any) => void;
|
||||
}> = ({ title, groups, onOptionSelect }) => {
|
||||
onClose?: () => void;
|
||||
}> = ({ title, groups, onOptionSelect, onClose }) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
// Wrap the groups to call onOptionSelect when an option is pressed
|
||||
@@ -140,6 +141,7 @@ const BottomSheetContent: React.FC<{
|
||||
onPress: () => {
|
||||
option.onPress();
|
||||
onOptionSelect?.(option.value);
|
||||
onClose?.();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -176,22 +178,26 @@ const PlatformDropdownComponent = ({
|
||||
trigger,
|
||||
title,
|
||||
groups,
|
||||
open,
|
||||
onOpenChange,
|
||||
open: controlledOpen,
|
||||
onOpenChange: controlledOnOpenChange,
|
||||
onOptionSelect,
|
||||
expoUIConfig,
|
||||
bottomSheetConfig,
|
||||
}: PlatformDropdownProps) => {
|
||||
const { showModal, hideModal } = useGlobalModal();
|
||||
|
||||
const handlePress = () => {
|
||||
if (Platform.OS === "android") {
|
||||
onOpenChange?.(true);
|
||||
// Handle controlled open state for Android
|
||||
useEffect(() => {
|
||||
if (Platform.OS === "android" && controlledOpen === true) {
|
||||
showModal(
|
||||
<BottomSheetContent
|
||||
title={title}
|
||||
groups={groups}
|
||||
onOptionSelect={onOptionSelect}
|
||||
onClose={() => {
|
||||
hideModal();
|
||||
controlledOnOpenChange?.(false);
|
||||
}}
|
||||
/>,
|
||||
{
|
||||
snapPoints: ["90%"],
|
||||
@@ -199,14 +205,7 @@ const PlatformDropdownComponent = ({
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Close modal when open prop changes to false
|
||||
useEffect(() => {
|
||||
if (Platform.OS === "android" && open === false) {
|
||||
hideModal();
|
||||
}
|
||||
}, [open, hideModal]);
|
||||
}, [controlledOpen]);
|
||||
|
||||
if (Platform.OS === "ios") {
|
||||
return (
|
||||
@@ -300,9 +299,24 @@ const PlatformDropdownComponent = ({
|
||||
);
|
||||
}
|
||||
|
||||
// Android: Trigger button for bottom modal
|
||||
// Android: Direct modal trigger
|
||||
const handlePress = () => {
|
||||
showModal(
|
||||
<BottomSheetContent
|
||||
title={title}
|
||||
groups={groups}
|
||||
onOptionSelect={onOptionSelect}
|
||||
onClose={hideModal}
|
||||
/>,
|
||||
{
|
||||
snapPoints: ["90%"],
|
||||
enablePanDownToClose: bottomSheetConfig?.enablePanDownToClose ?? true,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity onPress={handlePress}>
|
||||
<TouchableOpacity onPress={handlePress} activeOpacity={0.7}>
|
||||
{trigger || <Text className='text-white'>Open Menu</Text>}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
@@ -51,7 +50,6 @@ export const PlayButton: React.FC<Props> = ({
|
||||
selectedOptions,
|
||||
isOffline,
|
||||
colors,
|
||||
...props
|
||||
}: Props) => {
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
const client = useRemoteMediaClient();
|
||||
@@ -165,7 +163,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
api,
|
||||
item,
|
||||
deviceProfile: enableH265 ? chromecasth265 : chromecast,
|
||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks ?? 0,
|
||||
userId: user.Id,
|
||||
audioStreamIndex: selectedOptions.audioIndex,
|
||||
maxStreamingBitrate: selectedOptions.bitrate?.value,
|
||||
@@ -364,45 +362,51 @@ export const PlayButton: React.FC<Props> = ({
|
||||
* *********************
|
||||
*/
|
||||
|
||||
if (Platform.OS === "ios")
|
||||
return (
|
||||
<Host
|
||||
style={{
|
||||
height: 50,
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant='glassProminent'
|
||||
onPress={onPress}
|
||||
color={effectiveColors.primary}
|
||||
>
|
||||
<View className='flex flex-row items-center gap-x-2 h-full w-full justify-center -mb-3.5 '>
|
||||
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
|
||||
{runtimeTicksToMinutes(item?.RunTimeTicks)}
|
||||
</Animated.Text>
|
||||
<Animated.Text style={animatedTextStyle}>
|
||||
<Ionicons name='play-circle' size={24} />
|
||||
</Animated.Text>
|
||||
{client && (
|
||||
<Animated.Text style={animatedTextStyle}>
|
||||
<Feather name='cast' size={22} />
|
||||
<CastButton tintColor='transparent' />
|
||||
</Animated.Text>
|
||||
)}
|
||||
{!client && settings?.openInVLC && (
|
||||
<Animated.Text style={animatedTextStyle}>
|
||||
<MaterialCommunityIcons
|
||||
name='vlc'
|
||||
size={18}
|
||||
color={animatedTextStyle.color}
|
||||
/>
|
||||
</Animated.Text>
|
||||
)}
|
||||
</View>
|
||||
</Button>
|
||||
</Host>
|
||||
);
|
||||
// if (Platform.OS === "ios")
|
||||
// return (
|
||||
// <Host
|
||||
// style={{
|
||||
// height: 50,
|
||||
// flex: 1,
|
||||
// flexShrink: 0,
|
||||
// }}
|
||||
// >
|
||||
// <Button
|
||||
// variant='glassProminent'
|
||||
// onPress={onPress}
|
||||
// color={effectiveColors.primary}
|
||||
// modifiers={[fixedSize()]}
|
||||
// >
|
||||
// <View className='flex flex-row items-center gap-x-2 h-full w-full justify-center -mb-3.5 '>
|
||||
// <Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
|
||||
// {runtimeTicksToMinutes(
|
||||
// (item?.RunTimeTicks || 0) -
|
||||
// (item?.UserData?.PlaybackPositionTicks || 0),
|
||||
// )}
|
||||
// {(item?.UserData?.PlaybackPositionTicks || 0) > 0 && " left"}
|
||||
// </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
|
||||
@@ -411,7 +415,6 @@ export const PlayButton: React.FC<Props> = ({
|
||||
accessibilityHint='Tap to play the media'
|
||||
onPress={onPress}
|
||||
className={"relative"}
|
||||
{...props}
|
||||
>
|
||||
<View className='absolute w-full h-full top-0 left-0 rounded-full z-10 overflow-hidden'>
|
||||
<Animated.View
|
||||
@@ -437,9 +440,13 @@ export const PlayButton: React.FC<Props> = ({
|
||||
}}
|
||||
className='flex flex-row items-center justify-center bg-transparent rounded-full z-20 h-12 w-full '
|
||||
>
|
||||
<View className='flex flex-row items-center space-x-2'>
|
||||
<View className='flex flex-row items-center gap-x-2'>
|
||||
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
|
||||
{runtimeTicksToMinutes(item?.RunTimeTicks)}
|
||||
{runtimeTicksToMinutes(
|
||||
(item?.RunTimeTicks || 0) -
|
||||
(item?.UserData?.PlaybackPositionTicks || 0),
|
||||
)}
|
||||
{(item?.UserData?.PlaybackPositionTicks || 0) > 0 && " left"}
|
||||
</Animated.Text>
|
||||
<Animated.Text style={animatedTextStyle}>
|
||||
<Ionicons name='play-circle' size={24} />
|
||||
|
||||
@@ -200,7 +200,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
}}
|
||||
className='flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full '
|
||||
>
|
||||
<View className='flex flex-row items-center space-x-2'>
|
||||
<View className='flex flex-row items-center gap-x-2'>
|
||||
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
|
||||
{runtimeTicksToMinutes(item?.RunTimeTicks)}
|
||||
</Animated.Text>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import type React from "react";
|
||||
import { Platform, View, type ViewProps } from "react-native";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||
import { RoundButton } from "./RoundButton";
|
||||
|
||||
@@ -14,25 +14,10 @@ export const PlayedStatus: React.FC<Props> = ({ items, ...props }) => {
|
||||
const allPlayed = items.every((item) => item.UserData?.Played);
|
||||
const toggle = useMarkAsPlayed(items);
|
||||
|
||||
if (Platform.OS === "ios") {
|
||||
return (
|
||||
<View {...props}>
|
||||
<RoundButton
|
||||
color={allPlayed ? "purple" : "white"}
|
||||
icon={allPlayed ? "checkmark" : "checkmark"}
|
||||
onPress={async () => {
|
||||
await toggle(!allPlayed);
|
||||
}}
|
||||
size={props.size}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<RoundButton
|
||||
fillColor={allPlayed ? "primary" : undefined}
|
||||
color={allPlayed ? "purple" : "white"}
|
||||
icon={allPlayed ? "checkmark" : "checkmark"}
|
||||
onPress={async () => {
|
||||
await toggle(!allPlayed);
|
||||
|
||||
@@ -21,7 +21,7 @@ interface Props extends ViewProps {
|
||||
export const Ratings: React.FC<Props> = ({ item, ...props }) => {
|
||||
if (!item) return null;
|
||||
return (
|
||||
<View className='flex flex-row items-center mt-2 space-x-2' {...props}>
|
||||
<View className='flex flex-row items-center mt-2 gap-x-2' {...props}>
|
||||
{item.OfficialRating && (
|
||||
<Badge text={item.OfficialRating} variant='gray' />
|
||||
)}
|
||||
@@ -79,7 +79,7 @@ export const JellyserrRatings: React.FC<{
|
||||
!!result.voteCount ||
|
||||
(data?.criticsRating && !!data?.criticsScore) ||
|
||||
(data?.audienceRating && !!data?.audienceScore)) && (
|
||||
<View className='flex flex-row flex-wrap space-x-1'>
|
||||
<View className='flex flex-row flex-wrap gap-x-1'>
|
||||
{data?.criticsRating && !!data?.criticsScore && (
|
||||
<Badge
|
||||
text={`${data.criticsScore}%`}
|
||||
|
||||
@@ -96,7 +96,7 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
className={`rounded-full ${buttonSize} flex items-center justify-center ${
|
||||
fillColor ? fillColorClass : "bg-neutral-800/80"
|
||||
fillColor ? fillColorClass : "bg-transparent"
|
||||
}`}
|
||||
{...(viewProps as any)}
|
||||
>
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { StyleSheet, Text, type TextProps } from "react-native";
|
||||
|
||||
export type ThemedTextProps = TextProps & {
|
||||
lightColor?: string;
|
||||
darkColor?: string;
|
||||
type?: "default" | "title" | "defaultSemiBold" | "subtitle" | "link";
|
||||
};
|
||||
|
||||
export function ThemedText({
|
||||
style,
|
||||
type = "default",
|
||||
...rest
|
||||
}: ThemedTextProps) {
|
||||
return (
|
||||
<Text
|
||||
style={[
|
||||
{ color: "white" },
|
||||
type === "default" ? styles.default : undefined,
|
||||
type === "title" ? styles.title : undefined,
|
||||
type === "defaultSemiBold" ? styles.defaultSemiBold : undefined,
|
||||
type === "subtitle" ? styles.subtitle : undefined,
|
||||
type === "link" ? styles.link : undefined,
|
||||
style,
|
||||
]}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
default: {
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
},
|
||||
defaultSemiBold: {
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
fontWeight: "600",
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontWeight: "bold",
|
||||
lineHeight: 32,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: "bold",
|
||||
},
|
||||
link: {
|
||||
lineHeight: 30,
|
||||
fontSize: 16,
|
||||
color: "#0a7ea4",
|
||||
},
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
import { View, type ViewProps } from "react-native";
|
||||
|
||||
export type ThemedViewProps = ViewProps & {
|
||||
lightColor?: string;
|
||||
darkColor?: string;
|
||||
};
|
||||
|
||||
export function ThemedView({
|
||||
style,
|
||||
lightColor,
|
||||
darkColor,
|
||||
...otherProps
|
||||
}: ThemedViewProps) {
|
||||
return <View style={[{ backgroundColor: "black" }, style]} {...otherProps} />;
|
||||
}
|
||||
@@ -33,12 +33,27 @@ export const TrackSheet: React.FC<Props> = ({
|
||||
() => streams?.find((x) => x.Index === selected),
|
||||
[streams, selected],
|
||||
);
|
||||
|
||||
const noneOption = useMemo(
|
||||
() => ({ Index: -1, DisplayTitle: t("common.none") }),
|
||||
[t],
|
||||
);
|
||||
|
||||
// Creates a modified data array that includes a "None" option for subtitles
|
||||
// We might want to possibly do this for other places, like audio?
|
||||
const addNoneToSubtitles = useMemo(() => {
|
||||
if (streamType === "Subtitle") {
|
||||
const result = streams ? [noneOption, ...streams] : [noneOption];
|
||||
return result;
|
||||
}
|
||||
return streams;
|
||||
}, [streams, streamType, noneOption]);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (isTv || (streams && streams.length === 0)) return null;
|
||||
|
||||
return (
|
||||
<View className='flex shrink' style={{ minWidth: 25 }} {...props}>
|
||||
<View className='flex shrink' style={{ minWidth: 60 }} {...props}>
|
||||
<View className='flex flex-col'>
|
||||
<Text className='opacity-50 mb-1 text-xs'>{title}</Text>
|
||||
<TouchableOpacity
|
||||
@@ -46,7 +61,9 @@ export const TrackSheet: React.FC<Props> = ({
|
||||
onPress={() => setOpen(true)}
|
||||
>
|
||||
<Text numberOfLines={1}>
|
||||
{selectedSteam?.DisplayTitle || t("common.select", "Select")}
|
||||
{selected === -1 && streamType === "Subtitle"
|
||||
? t("common.none")
|
||||
: selectedSteam?.DisplayTitle || t("common.select", "Select")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
@@ -54,8 +71,14 @@ export const TrackSheet: React.FC<Props> = ({
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
title={title}
|
||||
data={streams || []}
|
||||
values={selectedSteam ? [selectedSteam] : []}
|
||||
data={addNoneToSubtitles || []}
|
||||
values={
|
||||
selected === -1 && streamType === "Subtitle"
|
||||
? [{ Index: -1, DisplayTitle: t("common.none") }]
|
||||
: selectedSteam
|
||||
? [selectedSteam]
|
||||
: []
|
||||
}
|
||||
multiple={false}
|
||||
searchFilter={(item, query) => {
|
||||
const label = (item as any).DisplayTitle || "";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -10,11 +10,18 @@ import { LinearGradient } from "expo-linear-gradient";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Dimensions, Pressable, TouchableOpacity, View } from "react-native";
|
||||
import {
|
||||
Pressable,
|
||||
TouchableOpacity,
|
||||
useWindowDimensions,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import Animated, {
|
||||
Easing,
|
||||
interpolate,
|
||||
runOnJS,
|
||||
type SharedValue,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
@@ -35,12 +42,10 @@ import { MarkAsPlayedLargeButton } from "./MarkAsPlayedLargeButton";
|
||||
interface AppleTVCarouselProps {
|
||||
initialIndex?: number;
|
||||
onItemChange?: (index: number) => void;
|
||||
scrollOffset?: SharedValue<number>;
|
||||
}
|
||||
|
||||
const { width: screenWidth, height: screenHeight } = Dimensions.get("window");
|
||||
|
||||
// Layout Constants
|
||||
const CAROUSEL_HEIGHT = screenHeight / 1.45;
|
||||
const GRADIENT_HEIGHT_TOP = 150;
|
||||
const GRADIENT_HEIGHT_BOTTOM = 150;
|
||||
const LOGO_HEIGHT = 80;
|
||||
@@ -61,7 +66,7 @@ const PLAYED_STATUS_SKELETON_SIZE = 40;
|
||||
const TEXT_SKELETON_HEIGHT = 20;
|
||||
const TEXT_SKELETON_WIDTH = 250;
|
||||
const OVERVIEW_SKELETON_HEIGHT = 16;
|
||||
const OVERVIEW_SKELETON_WIDTH = 300;
|
||||
const OVERVIEW_SKELETON_WIDTH = 400;
|
||||
const _EMPTY_STATE_ICON_SIZE = 64;
|
||||
|
||||
// Spacing Constants
|
||||
@@ -154,14 +159,21 @@ const DotIndicator = ({
|
||||
export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
||||
initialIndex = 0,
|
||||
onItemChange,
|
||||
scrollOffset,
|
||||
}) => {
|
||||
const { settings } = useSettings();
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const { isConnected, serverConnected } = useNetworkStatus();
|
||||
const router = useRouter();
|
||||
const { width: screenWidth, height: screenHeight } = useWindowDimensions();
|
||||
const isLandscape = screenWidth >= screenHeight;
|
||||
const carouselHeight = useMemo(
|
||||
() => (isLandscape ? screenHeight * 0.9 : screenHeight / 1.45),
|
||||
[isLandscape, screenHeight],
|
||||
);
|
||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||
const translateX = useSharedValue(-currentIndex * screenWidth);
|
||||
const translateX = useSharedValue(-initialIndex * screenWidth);
|
||||
|
||||
const isQueryEnabled =
|
||||
!!api && !!user?.Id && isConnected && serverConnected === true;
|
||||
@@ -225,11 +237,21 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
||||
const nextItems = nextUpData ?? [];
|
||||
const recentItems = recentlyAddedData ?? [];
|
||||
|
||||
return [
|
||||
const allItems = [
|
||||
...continueItems.slice(0, 2),
|
||||
...nextItems.slice(0, 2),
|
||||
...recentItems.slice(0, 2),
|
||||
];
|
||||
|
||||
// Deduplicate by item ID to prevent duplicate keys
|
||||
const seen = new Set<string>();
|
||||
return allItems.filter((item) => {
|
||||
if (item.Id && !seen.has(item.Id)) {
|
||||
seen.add(item.Id);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}, [continueWatchingData, nextUpData, recentlyAddedData]);
|
||||
|
||||
const isLoading =
|
||||
@@ -288,7 +310,11 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
||||
translateX.value = -newIndex * screenWidth;
|
||||
return newIndex;
|
||||
});
|
||||
}, [hasItems, items, initialIndex, translateX]);
|
||||
}, [hasItems, items, initialIndex, screenWidth, translateX]);
|
||||
|
||||
useEffect(() => {
|
||||
translateX.value = -currentIndex * screenWidth;
|
||||
}, [currentIndex, screenWidth, translateX]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasItems) {
|
||||
@@ -308,7 +334,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
||||
setCurrentIndex(index);
|
||||
onItemChange?.(index);
|
||||
},
|
||||
[hasItems, items, onItemChange, translateX],
|
||||
[hasItems, items, onItemChange, screenWidth, translateX],
|
||||
);
|
||||
|
||||
const navigateToItem = useCallback(
|
||||
@@ -357,6 +383,28 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
||||
|
||||
const togglePlayedStatus = useMarkAsPlayed(items);
|
||||
|
||||
const headerAnimatedStyle = useAnimatedStyle(() => {
|
||||
if (!scrollOffset) return {};
|
||||
return {
|
||||
transform: [
|
||||
{
|
||||
translateY: interpolate(
|
||||
scrollOffset.value,
|
||||
[-carouselHeight, 0, carouselHeight],
|
||||
[-carouselHeight / 2, 0, carouselHeight * 0.75],
|
||||
),
|
||||
},
|
||||
{
|
||||
scale: interpolate(
|
||||
scrollOffset.value,
|
||||
[-carouselHeight, 0, carouselHeight],
|
||||
[2, 1, 1],
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
const renderDots = () => {
|
||||
if (!hasItems || items.length <= 1) return null;
|
||||
|
||||
@@ -390,7 +438,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
||||
<View
|
||||
style={{
|
||||
width: screenWidth,
|
||||
height: CAROUSEL_HEIGHT,
|
||||
height: carouselHeight,
|
||||
backgroundColor: "#000",
|
||||
}}
|
||||
>
|
||||
@@ -588,20 +636,30 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
||||
key={item.Id}
|
||||
style={{
|
||||
width: screenWidth,
|
||||
height: CAROUSEL_HEIGHT,
|
||||
height: carouselHeight,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{/* Background Backdrop */}
|
||||
<ItemImage
|
||||
item={item}
|
||||
variant='Backdrop'
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
position: "absolute",
|
||||
}}
|
||||
/>
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
position: "absolute",
|
||||
},
|
||||
headerAnimatedStyle,
|
||||
]}
|
||||
>
|
||||
<ItemImage
|
||||
item={item}
|
||||
variant='Backdrop'
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
</Animated.View>
|
||||
|
||||
{/* Dark Overlay */}
|
||||
<View
|
||||
@@ -806,7 +864,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
height: CAROUSEL_HEIGHT,
|
||||
height: carouselHeight,
|
||||
backgroundColor: "#000",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
@@ -824,7 +882,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
height: CAROUSEL_HEIGHT, // Fixed height instead of flex: 1
|
||||
height: carouselHeight, // Fixed height instead of flex: 1
|
||||
backgroundColor: "#000",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
@@ -833,7 +891,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
height: CAROUSEL_HEIGHT, // Fixed height instead of flex: 1
|
||||
height: carouselHeight, // Fixed height instead of flex: 1
|
||||
flexDirection: "row",
|
||||
width: screenWidth * items.length,
|
||||
},
|
||||
|
||||
@@ -26,7 +26,7 @@ export const HeaderBackButton: React.FC<Props> = ({
|
||||
className='flex items-center justify-center w-9 h-9'
|
||||
{...touchableOpacityProps}
|
||||
>
|
||||
<Ionicons name='chevron-back' size={24} color='white' />
|
||||
<Ionicons name='arrow-back' size={24} color='white' />
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
@@ -55,7 +55,7 @@ export const HeaderBackButton: React.FC<Props> = ({
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => router.back()}
|
||||
className=' bg-neutral-800/80 rounded-full p-2'
|
||||
className=' rounded-full p-2'
|
||||
{...touchableOpacityProps}
|
||||
>
|
||||
<Ionicons
|
||||
|
||||
@@ -8,7 +8,7 @@ export interface HorizontalScrollRef {
|
||||
}
|
||||
|
||||
interface HorizontalScrollProps<T>
|
||||
extends Omit<FlashListProps<T>, "renderItem" | "data"> {
|
||||
extends Omit<FlashListProps<T>, "renderItem" | "estimatedItemSize" | "data"> {
|
||||
data?: T[] | null;
|
||||
renderItem: (item: T, index: number) => React.ReactNode;
|
||||
keyExtractor?: (item: T, index: number) => string;
|
||||
|
||||
@@ -59,6 +59,7 @@ export function InfiniteHorizontalScroll({
|
||||
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||
queryKey,
|
||||
queryFn,
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
getNextPageParam: (lastPage, pages) => {
|
||||
if (
|
||||
!lastPage?.Items ||
|
||||
@@ -119,7 +120,6 @@ export function InfiniteHorizontalScroll({
|
||||
renderItem={({ item, index }) => (
|
||||
<View className='mr-2'>{renderItem(item, index)}</View>
|
||||
)}
|
||||
estimatedItemSize={height}
|
||||
horizontal
|
||||
onEndReached={() => {
|
||||
if (hasNextPage) {
|
||||
|
||||
@@ -1,29 +1,16 @@
|
||||
import { Platform, Text as RNText, type TextProps } from "react-native";
|
||||
|
||||
interface CustomTextProps extends TextProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Text({ className, ...props }: CustomTextProps) {
|
||||
export function Text({ className, ...props }: TextProps) {
|
||||
if (Platform.isTV)
|
||||
return (
|
||||
<RNText allowFontScaling={false} className={clsx(className)} {...props} />
|
||||
<RNText allowFontScaling={false} style={{ color: "white" }} {...props} />
|
||||
);
|
||||
|
||||
return (
|
||||
<RNText allowFontScaling={false} className={clsx(className)} {...props} />
|
||||
<RNText
|
||||
allowFontScaling={false}
|
||||
className={`text-white ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const clsx = (className?: string) => {
|
||||
const colorClassRegex = /\btext-[a-z]+-\d+\b/;
|
||||
const hasColorClass = className ? colorClassRegex.test(className) : false;
|
||||
const defaultClassName = "text-white";
|
||||
const classes = [
|
||||
...(hasColorClass ? [] : [defaultClassName]),
|
||||
...(className ? [className] : []),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
return classes;
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Text } from "@/components/common/Text";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { calculateSmoothedETA } from "@/providers/Downloads/hooks/useDownloadSpeedCalculator";
|
||||
import { JobStatus } from "@/providers/Downloads/types";
|
||||
import { estimateDownloadSize } from "@/utils/download";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { formatTimeString } from "@/utils/time";
|
||||
|
||||
@@ -22,19 +23,26 @@ const bytesToMB = (bytes: number) => {
|
||||
return bytes / 1024 / 1024;
|
||||
};
|
||||
|
||||
const formatBytes = (bytes: number): string => {
|
||||
if (bytes >= 1024 * 1024 * 1024) {
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
return `${(bytes / (1024 * 1024)).toFixed(0)} MB`;
|
||||
};
|
||||
|
||||
interface DownloadCardProps extends TouchableOpacityProps {
|
||||
process: JobStatus;
|
||||
}
|
||||
|
||||
export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||
const { removeProcess } = useDownload();
|
||||
const { cancelDownload } = useDownload();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await removeProcess(id);
|
||||
toast.success(t("home.downloads.toasts.download_deleted"));
|
||||
await cancelDownload(id);
|
||||
// cancelDownload already shows a toast, so don't show another one
|
||||
queryClient.invalidateQueries({ queryKey: ["downloads"] });
|
||||
} catch (error) {
|
||||
console.error("Error deleting download:", error);
|
||||
@@ -60,6 +68,31 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||
return formatTimeString(secondsRemaining, "s");
|
||||
}, [process.id, process.bytesDownloaded, process.estimatedTotalSizeBytes]);
|
||||
|
||||
const estimatedSize = useMemo(() => {
|
||||
if (process.estimatedTotalSizeBytes) return process.estimatedTotalSizeBytes;
|
||||
|
||||
// Calculate from bitrate + duration (only if bitrate value is defined)
|
||||
if (process.maxBitrate.value) {
|
||||
return estimateDownloadSize(
|
||||
process.maxBitrate.value,
|
||||
process.item.RunTimeTicks,
|
||||
);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [
|
||||
process.maxBitrate.value,
|
||||
process.item.RunTimeTicks,
|
||||
process.estimatedTotalSizeBytes,
|
||||
]);
|
||||
|
||||
const isTranscoding = process.isTranscoding || false;
|
||||
|
||||
const downloadedAmount = useMemo(() => {
|
||||
if (!process.bytesDownloaded) return null;
|
||||
return formatBytes(process.bytesDownloaded);
|
||||
}, [process.bytesDownloaded]);
|
||||
|
||||
const base64Image = useMemo(() => {
|
||||
return storage.getString(process.item.Id!);
|
||||
}, []);
|
||||
@@ -83,9 +116,7 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||
>
|
||||
{process.status === "downloading" && (
|
||||
<View
|
||||
className={`
|
||||
bg-purple-600 h-1 absolute bottom-0 left-0
|
||||
`}
|
||||
className={`bg-purple-600 h-1 absolute bottom-0 left-0 ${isTranscoding ? "animate-pulse" : ""}`}
|
||||
style={{
|
||||
width:
|
||||
sanitizedProgress > 0
|
||||
@@ -121,32 +152,50 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<View className='shrink mb-1 flex-1'>
|
||||
<View className='shrink mb-1 flex-1 pr-12'>
|
||||
<Text className='text-xs opacity-50'>{process.item.Type}</Text>
|
||||
<Text className='font-semibold shrink'>{process.item.Name}</Text>
|
||||
<Text className='text-xs opacity-50'>
|
||||
{process.item.ProductionYear}
|
||||
</Text>
|
||||
<View className='flex flex-row items-center gap-x-2 mt-1 text-purple-600'>
|
||||
|
||||
{isTranscoding && (
|
||||
<View className='bg-purple-600/20 px-2 py-0.5 rounded-md mt-1 self-start'>
|
||||
<Text className='text-xs text-purple-400'>Transcoding</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Row 1: Progress + Downloaded/Total */}
|
||||
<View className='flex flex-row items-center gap-x-2 mt-1.5'>
|
||||
{sanitizedProgress === 0 ? (
|
||||
<ActivityIndicator size={"small"} color={"white"} />
|
||||
) : (
|
||||
<Text className='text-xs'>{sanitizedProgress.toFixed(0)}%</Text>
|
||||
)}
|
||||
{process.speed && process.speed > 0 && (
|
||||
<Text className='text-xs'>
|
||||
{bytesToMB(process.speed).toFixed(2)} MB/s
|
||||
<Text className='text-xs font-semibold'>
|
||||
{sanitizedProgress.toFixed(0)}%
|
||||
</Text>
|
||||
)}
|
||||
{eta && (
|
||||
<Text className='text-xs'>
|
||||
{t("home.downloads.eta", { eta: eta })}
|
||||
{downloadedAmount && (
|
||||
<Text className='text-xs opacity-75'>
|
||||
{downloadedAmount}
|
||||
{estimatedSize
|
||||
? ` / ${isTranscoding ? "~" : ""}${formatBytes(estimatedSize)}`
|
||||
: ""}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className='flex flex-row items-center gap-x-2 mt-1 text-purple-600'>
|
||||
<Text className='text-xs capitalize'>{process.status}</Text>
|
||||
{/* Row 2: Speed + ETA */}
|
||||
<View className='flex flex-row items-center gap-x-2 mt-0.5'>
|
||||
{process.speed && process.speed > 0 && (
|
||||
<Text className='text-xs text-purple-400'>
|
||||
{bytesToMB(process.speed).toFixed(2)} MB/s
|
||||
</Text>
|
||||
)}
|
||||
{eta && (
|
||||
<Text className='text-xs text-green-400'>
|
||||
{t("home.downloads.eta", { eta: eta })}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -29,15 +29,15 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
|
||||
const base64Image = useMemo(() => {
|
||||
return storage.getString(item?.Id!);
|
||||
}, []);
|
||||
return item?.Id ? storage.getString(item.Id) : undefined;
|
||||
}, [item?.Id]);
|
||||
|
||||
/**
|
||||
* Handles deleting the file with haptic feedback.
|
||||
*/
|
||||
const handleDeleteFile = useCallback(() => {
|
||||
if (item.Id) {
|
||||
deleteFile(item.Id, item.Type);
|
||||
deleteFile(item.Id);
|
||||
}
|
||||
}, [deleteFile, item.Id]);
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ export const FilterButton = <T,>({
|
||||
>
|
||||
<View
|
||||
className={`
|
||||
px-3 py-1.5 rounded-full flex flex-row items-center space-x-1
|
||||
px-3 py-1.5 rounded-full flex flex-row items-center gap-x-1
|
||||
${
|
||||
values.length > 0
|
||||
? "bg-purple-600 border border-purple-700"
|
||||
|
||||
@@ -109,11 +109,22 @@ export const FilterSheet = <T,>({
|
||||
// to implement efficient "load more" functionality
|
||||
useEffect(() => {
|
||||
if (!_data || _data.length === 0) return;
|
||||
const tmp = new Set(data);
|
||||
|
||||
const newData = [...data];
|
||||
|
||||
for (let i = offset; i < Math.min(_data.length, offset + LIMIT); i++) {
|
||||
tmp.add(_data[i]);
|
||||
const item = _data[i];
|
||||
// Check if this item already exists in our data array
|
||||
// some dups happened with re-renders during testing
|
||||
const exists = newData.some((existingItem) =>
|
||||
isEqual(existingItem, item),
|
||||
);
|
||||
if (!exists) {
|
||||
newData.push(item);
|
||||
}
|
||||
}
|
||||
setData(Array.from(tmp));
|
||||
|
||||
setData(newData);
|
||||
}, [offset, _data]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -233,7 +244,7 @@ export const FilterSheet = <T,>({
|
||||
{data.length < (_data?.length || 0) && (
|
||||
<Button
|
||||
onPress={() => {
|
||||
setOffset(offset + 100);
|
||||
setOffset(offset + LIMIT);
|
||||
}}
|
||||
>
|
||||
Load more
|
||||
|
||||
506
components/home/Home.tsx
Normal file
506
components/home/Home.tsx
Normal file
@@ -0,0 +1,506 @@
|
||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||
import type {
|
||||
BaseItemDto,
|
||||
BaseItemDtoQueryResult,
|
||||
BaseItemKind,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import {
|
||||
getItemsApi,
|
||||
getSuggestionsApi,
|
||||
getTvShowsApi,
|
||||
getUserLibraryApi,
|
||||
getUserViewsApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
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 { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Platform,
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { eventBus } from "@/utils/eventBus";
|
||||
|
||||
type InfiniteScrollingCollectionListSection = {
|
||||
type: "InfiniteScrollingCollectionList";
|
||||
title?: string;
|
||||
queryKey: (string | undefined | null)[];
|
||||
queryFn: QueryFunction<BaseItemDto[], any, number>;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
pageSize?: number;
|
||||
};
|
||||
|
||||
type MediaListSectionType = {
|
||||
type: "MediaListSection";
|
||||
queryKey: (string | undefined)[];
|
||||
queryFn: QueryFunction<BaseItemDto>;
|
||||
};
|
||||
|
||||
type Section = InfiniteScrollingCollectionListSection | MediaListSectionType;
|
||||
|
||||
export const Home = () => {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const insets = useSafeAreaInsets();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { settings, refreshStreamyfinPluginSettings } = useSettings();
|
||||
const navigation = useNavigation();
|
||||
const scrollRef = useRef<ScrollView>(null);
|
||||
const { downloadedItems, cleanCacheDirectory } = useDownload();
|
||||
const prevIsConnected = useRef<boolean | null>(false);
|
||||
const {
|
||||
isConnected,
|
||||
serverConnected,
|
||||
loading: retryLoading,
|
||||
retryCheck,
|
||||
} = useNetworkStatus();
|
||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||
const [scrollY, setScrollY] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (isConnected && !prevIsConnected.current) {
|
||||
invalidateCache();
|
||||
}
|
||||
prevIsConnected.current = isConnected;
|
||||
}, [isConnected, invalidateCache]);
|
||||
|
||||
const hasDownloads = useMemo(() => {
|
||||
if (Platform.isTV) return false;
|
||||
return downloadedItems.length > 0;
|
||||
}, [downloadedItems]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.isTV) {
|
||||
navigation.setOptions({
|
||||
headerLeft: () => null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
navigation.setOptions({
|
||||
headerLeft: () => (
|
||||
<View className='flex flex-row items-center ml-1.5'>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push("/(auth)/downloads");
|
||||
}}
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather
|
||||
name='download'
|
||||
color={hasDownloads ? Colors.primary : "white"}
|
||||
size={24}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
),
|
||||
});
|
||||
}, [navigation, router, hasDownloads]);
|
||||
|
||||
useEffect(() => {
|
||||
cleanCacheDirectory().catch((_e) =>
|
||||
console.error("Something went wrong cleaning cache directory"),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const segments = useSegments();
|
||||
useEffect(() => {
|
||||
const unsubscribe = eventBus.on("scrollToTop", () => {
|
||||
if ((segments as string[])[2] === "(home)")
|
||||
scrollRef.current?.scrollTo({
|
||||
y: Platform.isTV ? -152 : -100,
|
||||
animated: true,
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [segments]);
|
||||
|
||||
const {
|
||||
data,
|
||||
isError: e1,
|
||||
isLoading: l1,
|
||||
} = useQuery({
|
||||
queryKey: ["home", "userViews", user?.Id],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await getUserViewsApi(api).getUserViews({
|
||||
userId: user.Id,
|
||||
});
|
||||
|
||||
return response.data.Items || null;
|
||||
},
|
||||
enabled: !!api && !!user?.Id,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
const userViews = useMemo(
|
||||
() => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
|
||||
[data, settings?.hiddenLibraries],
|
||||
);
|
||||
|
||||
const collections = useMemo(() => {
|
||||
const allow = ["movies", "tvshows"];
|
||||
return (
|
||||
userViews?.filter(
|
||||
(c) => c.CollectionType && allow.includes(c.CollectionType),
|
||||
) || []
|
||||
);
|
||||
}, [userViews]);
|
||||
|
||||
const refetch = async () => {
|
||||
setLoading(true);
|
||||
await refreshStreamyfinPluginSettings();
|
||||
await invalidateCache();
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const createCollectionConfig = useCallback(
|
||||
(
|
||||
title: string,
|
||||
queryKey: string[],
|
||||
includeItemTypes: BaseItemKind[],
|
||||
parentId: string | undefined,
|
||||
pageSize: number = 10,
|
||||
): InfiniteScrollingCollectionListSection => ({
|
||||
title,
|
||||
queryKey,
|
||||
queryFn: async ({ pageParam = 0 }) => {
|
||||
if (!api) return [];
|
||||
// getLatestMedia doesn't support startIndex, so we fetch all and slice client-side
|
||||
const allData =
|
||||
(
|
||||
await getUserLibraryApi(api).getLatestMedia({
|
||||
userId: user?.Id,
|
||||
limit: 100, // Fetch a larger set for pagination
|
||||
fields: ["PrimaryImageAspectRatio", "Path", "Genres"],
|
||||
imageTypeLimit: 1,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||
includeItemTypes,
|
||||
parentId,
|
||||
})
|
||||
).data || [];
|
||||
|
||||
// Simulate pagination by slicing
|
||||
return allData.slice(pageParam, pageParam + pageSize);
|
||||
},
|
||||
type: "InfiniteScrollingCollectionList",
|
||||
pageSize,
|
||||
}),
|
||||
[api, user?.Id],
|
||||
);
|
||||
|
||||
const defaultSections = useMemo(() => {
|
||||
if (!api || !user?.Id) return [];
|
||||
|
||||
const latestMediaViews = collections.map((c) => {
|
||||
const includeItemTypes: BaseItemKind[] =
|
||||
c.CollectionType === "tvshows" || c.CollectionType === "movies"
|
||||
? []
|
||||
: ["Movie"];
|
||||
const title = t("home.recently_added_in", { libraryName: c.Name });
|
||||
const queryKey: string[] = [
|
||||
"home",
|
||||
`recentlyAddedIn${c.CollectionType}`,
|
||||
user.Id!,
|
||||
c.Id!,
|
||||
];
|
||||
return createCollectionConfig(
|
||||
title || "",
|
||||
queryKey,
|
||||
includeItemTypes,
|
||||
c.Id,
|
||||
10,
|
||||
);
|
||||
});
|
||||
|
||||
const ss: Section[] = [
|
||||
{
|
||||
title: t("home.continue_watching"),
|
||||
queryKey: ["home", "resumeItems"],
|
||||
queryFn: async ({ pageParam = 0 }) =>
|
||||
(
|
||||
await getItemsApi(api).getResumeItems({
|
||||
userId: user.Id,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||
fields: ["Genres"],
|
||||
startIndex: pageParam,
|
||||
limit: 10,
|
||||
})
|
||||
).data.Items || [],
|
||||
type: "InfiniteScrollingCollectionList",
|
||||
orientation: "horizontal",
|
||||
pageSize: 10,
|
||||
},
|
||||
{
|
||||
title: t("home.next_up"),
|
||||
queryKey: ["home", "nextUp-all"],
|
||||
queryFn: async ({ pageParam = 0 }) =>
|
||||
(
|
||||
await getTvShowsApi(api).getNextUp({
|
||||
userId: user?.Id,
|
||||
fields: ["MediaSourceCount", "Genres"],
|
||||
startIndex: pageParam,
|
||||
limit: 10,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||
enableResumable: false,
|
||||
})
|
||||
).data.Items || [],
|
||||
type: "InfiniteScrollingCollectionList",
|
||||
orientation: "horizontal",
|
||||
pageSize: 10,
|
||||
},
|
||||
...latestMediaViews,
|
||||
{
|
||||
title: t("home.suggested_movies"),
|
||||
queryKey: ["home", "suggestedMovies", user?.Id],
|
||||
queryFn: async ({ pageParam = 0 }) =>
|
||||
(
|
||||
await getSuggestionsApi(api).getSuggestions({
|
||||
userId: user?.Id,
|
||||
startIndex: pageParam,
|
||||
limit: 10,
|
||||
mediaType: ["Video"],
|
||||
type: ["Movie"],
|
||||
})
|
||||
).data.Items || [],
|
||||
type: "InfiniteScrollingCollectionList",
|
||||
orientation: "vertical",
|
||||
pageSize: 10,
|
||||
},
|
||||
];
|
||||
return ss;
|
||||
}, [api, user?.Id, collections, t, createCollectionConfig]);
|
||||
|
||||
const customSections = useMemo(() => {
|
||||
if (!api || !user?.Id || !settings?.home?.sections) return [];
|
||||
const ss: Section[] = [];
|
||||
settings.home.sections.forEach((section, index) => {
|
||||
const id = section.title || `section-${index}`;
|
||||
const pageSize = 10;
|
||||
ss.push({
|
||||
title: t(`${id}`),
|
||||
queryKey: ["home", "custom", String(index), section.title ?? null],
|
||||
queryFn: async ({ pageParam = 0 }) => {
|
||||
if (section.items) {
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user?.Id,
|
||||
startIndex: pageParam,
|
||||
limit: section.items?.limit || pageSize,
|
||||
recursive: true,
|
||||
includeItemTypes: section.items?.includeItemTypes,
|
||||
sortBy: section.items?.sortBy,
|
||||
sortOrder: section.items?.sortOrder,
|
||||
filters: section.items?.filters,
|
||||
parentId: section.items?.parentId,
|
||||
});
|
||||
return response.data.Items || [];
|
||||
}
|
||||
if (section.nextUp) {
|
||||
const response = await getTvShowsApi(api).getNextUp({
|
||||
userId: user?.Id,
|
||||
fields: ["MediaSourceCount", "Genres"],
|
||||
startIndex: pageParam,
|
||||
limit: section.nextUp?.limit || pageSize,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||
enableResumable: section.nextUp?.enableResumable,
|
||||
enableRewatching: section.nextUp?.enableRewatching,
|
||||
});
|
||||
return response.data.Items || [];
|
||||
}
|
||||
if (section.latest) {
|
||||
// getLatestMedia doesn't support startIndex, so we fetch all and slice client-side
|
||||
const allData =
|
||||
(
|
||||
await getUserLibraryApi(api).getLatestMedia({
|
||||
userId: user?.Id,
|
||||
includeItemTypes: section.latest?.includeItemTypes,
|
||||
limit: section.latest?.limit || 100, // Fetch larger set
|
||||
isPlayed: section.latest?.isPlayed,
|
||||
groupItems: section.latest?.groupItems,
|
||||
})
|
||||
).data || [];
|
||||
|
||||
// Simulate pagination by slicing
|
||||
return allData.slice(pageParam, pageParam + pageSize);
|
||||
}
|
||||
if (section.custom) {
|
||||
const response = await api.get<BaseItemDtoQueryResult>(
|
||||
section.custom.endpoint,
|
||||
{
|
||||
params: {
|
||||
...(section.custom.query || {}),
|
||||
userId: user?.Id,
|
||||
startIndex: pageParam,
|
||||
limit: pageSize,
|
||||
},
|
||||
headers: section.custom.headers || {},
|
||||
},
|
||||
);
|
||||
return response.data.Items || [];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
type: "InfiniteScrollingCollectionList",
|
||||
orientation: section?.orientation || "vertical",
|
||||
pageSize,
|
||||
});
|
||||
});
|
||||
return ss;
|
||||
}, [api, user?.Id, settings?.home?.sections, t]);
|
||||
|
||||
const sections = settings?.home?.sections ? customSections : defaultSections;
|
||||
|
||||
if (!isConnected || serverConnected !== true) {
|
||||
let title = "";
|
||||
let subtitle = "";
|
||||
|
||||
if (!isConnected) {
|
||||
title = t("home.no_internet");
|
||||
subtitle = t("home.no_internet_message");
|
||||
} else if (serverConnected === null) {
|
||||
title = t("home.checking_server_connection");
|
||||
subtitle = t("home.checking_server_connection_message");
|
||||
} else if (!serverConnected) {
|
||||
title = t("home.server_unreachable");
|
||||
subtitle = t("home.server_unreachable_message");
|
||||
}
|
||||
return (
|
||||
<View className='flex flex-col items-center justify-center h-full -mt-6 px-8'>
|
||||
<Text className='text-3xl font-bold mb-2'>{title}</Text>
|
||||
<Text className='text-center opacity-70'>{subtitle}</Text>
|
||||
|
||||
<View className='mt-4'>
|
||||
{!Platform.isTV && (
|
||||
<Button
|
||||
color='purple'
|
||||
onPress={() => router.push("/(auth)/downloads")}
|
||||
justify='center'
|
||||
iconRight={
|
||||
<Ionicons name='arrow-forward' size={20} color='white' />
|
||||
}
|
||||
>
|
||||
{t("home.go_to_downloads")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
color='black'
|
||||
onPress={retryCheck}
|
||||
justify='center'
|
||||
className='mt-2'
|
||||
iconRight={
|
||||
retryLoading ? null : (
|
||||
<Ionicons name='refresh' size={20} color='white' />
|
||||
)
|
||||
}
|
||||
>
|
||||
{retryLoading ? (
|
||||
<ActivityIndicator size='small' color='white' />
|
||||
) : (
|
||||
t("home.retry")
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (e1)
|
||||
return (
|
||||
<View className='flex flex-col items-center justify-center h-full -mt-6'>
|
||||
<Text className='text-3xl font-bold mb-2'>{t("home.oops")}</Text>
|
||||
<Text className='text-center opacity-70'>
|
||||
{t("home.error_message")}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
if (l1)
|
||||
return (
|
||||
<View className='justify-center items-center h-full'>
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
ref={scrollRef}
|
||||
nestedScrollEnabled
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
onScroll={(event) => {
|
||||
setScrollY(event.nativeEvent.contentOffset.y - 500);
|
||||
}}
|
||||
scrollEventThrottle={16}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={loading}
|
||||
onRefresh={refetch}
|
||||
tintColor='white'
|
||||
colors={["white"]}
|
||||
/>
|
||||
}
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: 16,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
className={`flex flex-col gap-y-4 ${Platform.OS === "android" ? "pt-4" : ""}`}
|
||||
>
|
||||
{sections.map((section, index) => {
|
||||
if (section.type === "InfiniteScrollingCollectionList") {
|
||||
return (
|
||||
<InfiniteScrollingCollectionList
|
||||
key={index}
|
||||
title={section.title}
|
||||
queryKey={section.queryKey}
|
||||
queryFn={section.queryFn}
|
||||
orientation={section.orientation}
|
||||
hideIfEmpty
|
||||
pageSize={section.pageSize}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (section.type === "MediaListSection") {
|
||||
return (
|
||||
<MediaListSection
|
||||
key={index}
|
||||
queryKey={section.queryKey}
|
||||
queryFn={section.queryFn}
|
||||
scrollY={scrollY}
|
||||
enableLazyLoading={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,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 {
|
||||
@@ -19,15 +19,17 @@ import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Platform,
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import Animated, {
|
||||
useAnimatedRef,
|
||||
useScrollViewOffset,
|
||||
} from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
||||
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
@@ -39,12 +41,13 @@ import { useSettings } from "@/utils/atoms/settings";
|
||||
import { eventBus } from "@/utils/eventBus";
|
||||
import { AppleTVCarousel } from "../apple-tv-carousel/AppleTVCarousel";
|
||||
|
||||
type ScrollingCollectionListSection = {
|
||||
type: "ScrollingCollectionList";
|
||||
type InfiniteScrollingCollectionListSection = {
|
||||
type: "InfiniteScrollingCollectionList";
|
||||
title?: string;
|
||||
queryKey: (string | undefined | null)[];
|
||||
queryFn: QueryFunction<BaseItemDto[]>;
|
||||
queryFn: QueryFunction<BaseItemDto[], any, number>;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
pageSize?: number;
|
||||
};
|
||||
|
||||
type MediaListSectionType = {
|
||||
@@ -53,25 +56,20 @@ type MediaListSectionType = {
|
||||
queryFn: QueryFunction<BaseItemDto>;
|
||||
};
|
||||
|
||||
type Section = ScrollingCollectionListSection | MediaListSectionType;
|
||||
type Section = InfiniteScrollingCollectionListSection | MediaListSectionType;
|
||||
|
||||
export const HomeIndex = () => {
|
||||
export const HomeWithCarousel = () => {
|
||||
const router = useRouter();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { settings, refreshStreamyfinPluginSettings } = useSettings();
|
||||
|
||||
const navigation = useNavigation();
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const scrollViewRef = useRef<ScrollView>(null);
|
||||
|
||||
const [_loading, setLoading] = useState(false);
|
||||
const { settings, refreshStreamyfinPluginSettings } = useSettings();
|
||||
const headerOverlayOffset = Platform.isTV ? 0 : 60;
|
||||
const navigation = useNavigation();
|
||||
const animatedScrollRef = useAnimatedRef<Animated.ScrollView>();
|
||||
const scrollOffset = useScrollViewOffset(animatedScrollRef);
|
||||
const { downloadedItems, cleanCacheDirectory } = useDownload();
|
||||
const prevIsConnected = useRef<boolean | null>(false);
|
||||
const {
|
||||
@@ -81,12 +79,12 @@ export const HomeIndex = () => {
|
||||
retryCheck,
|
||||
} = useNetworkStatus();
|
||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||
const [scrollY, setScrollY] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
// Only invalidate cache when transitioning from offline to online
|
||||
if (isConnected && !prevIsConnected.current) {
|
||||
invalidateCache();
|
||||
}
|
||||
// Update the ref to the current state for the next render
|
||||
prevIsConnected.current = isConnected;
|
||||
}, [isConnected, invalidateCache]);
|
||||
|
||||
@@ -108,12 +106,13 @@ export const HomeIndex = () => {
|
||||
onPress={() => {
|
||||
router.push("/(auth)/downloads");
|
||||
}}
|
||||
className='p-2'
|
||||
className='ml-1.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather
|
||||
name='download'
|
||||
color={hasDownloads ? Colors.primary : "white"}
|
||||
size={22}
|
||||
size={24}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
),
|
||||
@@ -130,7 +129,7 @@ export const HomeIndex = () => {
|
||||
useEffect(() => {
|
||||
const unsubscribe = eventBus.on("scrollToTop", () => {
|
||||
if ((segments as string[])[2] === "(home)")
|
||||
scrollViewRef.current?.scrollTo({
|
||||
animatedScrollRef.current?.scrollTo({
|
||||
y: Platform.isTV ? -152 : -100,
|
||||
animated: true,
|
||||
});
|
||||
@@ -176,7 +175,7 @@ export const HomeIndex = () => {
|
||||
);
|
||||
}, [userViews]);
|
||||
|
||||
const refetch = async () => {
|
||||
const _refetch = async () => {
|
||||
setLoading(true);
|
||||
await refreshStreamyfinPluginSettings();
|
||||
await invalidateCache();
|
||||
@@ -189,42 +188,48 @@ export const HomeIndex = () => {
|
||||
queryKey: string[],
|
||||
includeItemTypes: BaseItemKind[],
|
||||
parentId: string | undefined,
|
||||
): ScrollingCollectionListSection => ({
|
||||
pageSize: number = 10,
|
||||
): InfiniteScrollingCollectionListSection => ({
|
||||
title,
|
||||
queryKey,
|
||||
queryFn: async () => {
|
||||
queryFn: async ({ pageParam = 0 }) => {
|
||||
if (!api) return [];
|
||||
return (
|
||||
// getLatestMedia doesn't support startIndex, so we fetch all and slice client-side
|
||||
const allData =
|
||||
(
|
||||
await getUserLibraryApi(api).getLatestMedia({
|
||||
userId: user?.Id,
|
||||
limit: 20,
|
||||
limit: 100, // Fetch a larger set for pagination
|
||||
fields: ["PrimaryImageAspectRatio", "Path", "Genres"],
|
||||
imageTypeLimit: 1,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||
includeItemTypes,
|
||||
parentId,
|
||||
})
|
||||
).data || []
|
||||
);
|
||||
).data || [];
|
||||
|
||||
// Simulate pagination by slicing
|
||||
return allData.slice(pageParam, pageParam + pageSize);
|
||||
},
|
||||
type: "ScrollingCollectionList",
|
||||
type: "InfiniteScrollingCollectionList",
|
||||
pageSize,
|
||||
}),
|
||||
[api, user?.Id],
|
||||
);
|
||||
|
||||
// Always call useMemo() at the top-level, using computed dependencies for both "default"/custom sections
|
||||
const defaultSections = useMemo(() => {
|
||||
if (!api || !user?.Id) return [];
|
||||
|
||||
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 = [
|
||||
const queryKey: string[] = [
|
||||
"home",
|
||||
`recentlyAddedIn${c.CollectionType}`,
|
||||
user?.Id!,
|
||||
user.Id!,
|
||||
c.Id!,
|
||||
];
|
||||
return createCollectionConfig(
|
||||
@@ -232,6 +237,7 @@ export const HomeIndex = () => {
|
||||
queryKey,
|
||||
includeItemTypes,
|
||||
c.Id,
|
||||
10,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -239,79 +245,56 @@ export const HomeIndex = () => {
|
||||
{
|
||||
title: t("home.continue_watching"),
|
||||
queryKey: ["home", "resumeItems"],
|
||||
queryFn: async () =>
|
||||
queryFn: async ({ pageParam = 0 }) =>
|
||||
(
|
||||
await getItemsApi(api).getResumeItems({
|
||||
userId: user.Id,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||
fields: ["Genres"],
|
||||
startIndex: pageParam,
|
||||
limit: 10,
|
||||
})
|
||||
).data.Items || [],
|
||||
type: "ScrollingCollectionList",
|
||||
type: "InfiniteScrollingCollectionList",
|
||||
orientation: "horizontal",
|
||||
pageSize: 10,
|
||||
},
|
||||
{
|
||||
title: t("home.next_up"),
|
||||
queryKey: ["home", "nextUp-all"],
|
||||
queryFn: async () =>
|
||||
queryFn: async ({ pageParam = 0 }) =>
|
||||
(
|
||||
await getTvShowsApi(api).getNextUp({
|
||||
userId: user?.Id,
|
||||
fields: ["MediaSourceCount", "Genres"],
|
||||
limit: 20,
|
||||
startIndex: pageParam,
|
||||
limit: 10,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||
enableResumable: false,
|
||||
})
|
||||
).data.Items || [],
|
||||
type: "ScrollingCollectionList",
|
||||
type: "InfiniteScrollingCollectionList",
|
||||
orientation: "horizontal",
|
||||
pageSize: 10,
|
||||
},
|
||||
...latestMediaViews,
|
||||
// ...(mediaListCollections?.map(
|
||||
// (ml) =>
|
||||
// ({
|
||||
// title: ml.Name,
|
||||
// queryKey: ["home", "mediaList", ml.Id!],
|
||||
// queryFn: async () => ml,
|
||||
// type: "MediaListSection",
|
||||
// orientation: "vertical",
|
||||
// } as Section)
|
||||
// ) || []),
|
||||
{
|
||||
title: t("home.suggested_movies"),
|
||||
queryKey: ["home", "suggestedMovies", user?.Id],
|
||||
queryFn: async () =>
|
||||
queryFn: async ({ pageParam = 0 }) =>
|
||||
(
|
||||
await getSuggestionsApi(api).getSuggestions({
|
||||
userId: user?.Id,
|
||||
startIndex: pageParam,
|
||||
limit: 10,
|
||||
mediaType: ["Video"],
|
||||
type: ["Movie"],
|
||||
})
|
||||
).data.Items || [],
|
||||
type: "ScrollingCollectionList",
|
||||
type: "InfiniteScrollingCollectionList",
|
||||
orientation: "vertical",
|
||||
},
|
||||
{
|
||||
title: t("home.suggested_episodes"),
|
||||
queryKey: ["home", "suggestedEpisodes", user?.Id],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const suggestions = await getSuggestions(api, user.Id);
|
||||
const nextUpPromises = suggestions.map((series) =>
|
||||
getNextUp(api, user.Id, series.Id),
|
||||
);
|
||||
const nextUpResults = await Promise.all(nextUpPromises);
|
||||
|
||||
return nextUpResults.filter((item) => item !== null) || [];
|
||||
} catch (error) {
|
||||
console.error("Error fetching data:", error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
type: "ScrollingCollectionList",
|
||||
orientation: "horizontal",
|
||||
pageSize: 10,
|
||||
},
|
||||
];
|
||||
return ss;
|
||||
@@ -320,16 +303,18 @@ export const HomeIndex = () => {
|
||||
const customSections = useMemo(() => {
|
||||
if (!api || !user?.Id || !settings?.home?.sections) return [];
|
||||
const ss: Section[] = [];
|
||||
for (const [index, section] of settings.home.sections.entries()) {
|
||||
settings.home.sections.forEach((section, index) => {
|
||||
const id = section.title || `section-${index}`;
|
||||
const pageSize = 10;
|
||||
ss.push({
|
||||
title: t(`${id}`),
|
||||
queryKey: ["home", "custom", String(index), section.title ?? null],
|
||||
queryFn: async () => {
|
||||
queryFn: async ({ pageParam = 0 }) => {
|
||||
if (section.items) {
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user?.Id,
|
||||
limit: section.items?.limit || 25,
|
||||
startIndex: pageParam,
|
||||
limit: section.items?.limit || pageSize,
|
||||
recursive: true,
|
||||
includeItemTypes: section.items?.includeItemTypes,
|
||||
sortBy: section.items?.sortBy,
|
||||
@@ -343,7 +328,8 @@ export const HomeIndex = () => {
|
||||
const response = await getTvShowsApi(api).getNextUp({
|
||||
userId: user?.Id,
|
||||
fields: ["MediaSourceCount", "Genres"],
|
||||
limit: section.nextUp?.limit || 25,
|
||||
startIndex: pageParam,
|
||||
limit: section.nextUp?.limit || pageSize,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||
enableResumable: section.nextUp?.enableResumable,
|
||||
enableRewatching: section.nextUp?.enableRewatching,
|
||||
@@ -351,23 +337,45 @@ export const HomeIndex = () => {
|
||||
return response.data.Items || [];
|
||||
}
|
||||
if (section.latest) {
|
||||
const response = await getUserLibraryApi(api).getLatestMedia({
|
||||
userId: user?.Id,
|
||||
includeItemTypes: section.latest?.includeItemTypes,
|
||||
limit: section.latest?.limit || 25,
|
||||
isPlayed: section.latest?.isPlayed,
|
||||
groupItems: section.latest?.groupItems,
|
||||
});
|
||||
return response.data || [];
|
||||
// getLatestMedia doesn't support startIndex, so we fetch all and slice client-side
|
||||
const allData =
|
||||
(
|
||||
await getUserLibraryApi(api).getLatestMedia({
|
||||
userId: user?.Id,
|
||||
includeItemTypes: section.latest?.includeItemTypes,
|
||||
limit: section.latest?.limit || 100, // Fetch larger set
|
||||
isPlayed: section.latest?.isPlayed,
|
||||
groupItems: section.latest?.groupItems,
|
||||
})
|
||||
).data || [];
|
||||
|
||||
// Simulate pagination by slicing
|
||||
return allData.slice(pageParam, pageParam + pageSize);
|
||||
}
|
||||
if (section.custom) {
|
||||
const response = await api.get<BaseItemDtoQueryResult>(
|
||||
section.custom.endpoint,
|
||||
{
|
||||
params: {
|
||||
...(section.custom.query || {}),
|
||||
userId: user?.Id,
|
||||
startIndex: pageParam,
|
||||
limit: pageSize,
|
||||
},
|
||||
headers: section.custom.headers || {},
|
||||
},
|
||||
);
|
||||
return response.data.Items || [];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
type: "ScrollingCollectionList",
|
||||
type: "InfiniteScrollingCollectionList",
|
||||
orientation: section?.orientation || "vertical",
|
||||
pageSize,
|
||||
});
|
||||
}
|
||||
});
|
||||
return ss;
|
||||
}, [api, user?.Id, settings?.home?.sections]);
|
||||
}, [api, user?.Id, settings?.home?.sections, t]);
|
||||
|
||||
const sections = settings?.home?.sections ? customSections : defaultSections;
|
||||
|
||||
@@ -376,15 +384,12 @@ export const HomeIndex = () => {
|
||||
let subtitle = "";
|
||||
|
||||
if (!isConnected) {
|
||||
// No network connection
|
||||
title = t("home.no_internet");
|
||||
subtitle = t("home.no_internet_message");
|
||||
} else if (serverConnected === null) {
|
||||
// Network is up, but server is being checked
|
||||
title = t("home.checking_server_connection");
|
||||
subtitle = t("home.checking_server_connection_message");
|
||||
} else if (!serverConnected) {
|
||||
// Network is up, but server is unreachable
|
||||
title = t("home.server_unreachable");
|
||||
subtitle = t("home.server_unreachable_message");
|
||||
}
|
||||
@@ -447,42 +452,41 @@ export const HomeIndex = () => {
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
<Animated.ScrollView
|
||||
scrollToOverflowEnabled={true}
|
||||
ref={scrollViewRef}
|
||||
ref={animatedScrollRef}
|
||||
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
|
||||
/>
|
||||
}
|
||||
style={{ marginTop: Platform.isTV ? 0 : -100 }}
|
||||
contentContainerStyle={{ paddingTop: Platform.isTV ? 0 : 100 }}
|
||||
scrollEventThrottle={16}
|
||||
bounces={false}
|
||||
overScrollMode='never'
|
||||
style={{ marginTop: -headerOverlayOffset }}
|
||||
contentContainerStyle={{ paddingTop: headerOverlayOffset }}
|
||||
onScroll={(event) => {
|
||||
setScrollY(event.nativeEvent.contentOffset.y);
|
||||
}}
|
||||
>
|
||||
<AppleTVCarousel initialIndex={0} />
|
||||
<AppleTVCarousel initialIndex={0} scrollOffset={scrollOffset} />
|
||||
<View
|
||||
style={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: 16,
|
||||
paddingTop: 0,
|
||||
}}
|
||||
>
|
||||
<View className='flex flex-col gap-4'>
|
||||
<View className='flex flex-col gap-y-4'>
|
||||
{sections.map((section, index) => {
|
||||
if (section.type === "ScrollingCollectionList") {
|
||||
if (section.type === "InfiniteScrollingCollectionList") {
|
||||
return (
|
||||
<ScrollingCollectionList
|
||||
<InfiniteScrollingCollectionList
|
||||
key={index}
|
||||
title={section.title}
|
||||
queryKey={section.queryKey}
|
||||
queryFn={section.queryFn}
|
||||
orientation={section.orientation}
|
||||
hideIfEmpty
|
||||
pageSize={section.pageSize}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -492,6 +496,8 @@ export const HomeIndex = () => {
|
||||
key={index}
|
||||
queryKey={section.queryKey}
|
||||
queryFn={section.queryFn}
|
||||
scrollY={scrollY}
|
||||
enableLazyLoading={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -500,33 +506,6 @@ export const HomeIndex = () => {
|
||||
</View>
|
||||
</View>
|
||||
<View className='h-24' />
|
||||
</ScrollView>
|
||||
</Animated.ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
// Function to get suggestions
|
||||
async function getSuggestions(api: Api, userId: string | undefined) {
|
||||
if (!userId) return [];
|
||||
const response = await getSuggestionsApi(api).getSuggestions({
|
||||
userId,
|
||||
limit: 10,
|
||||
mediaType: ["Unknown"],
|
||||
type: ["Series"],
|
||||
});
|
||||
return response.data.Items ?? [];
|
||||
}
|
||||
|
||||
// Function to get the next up TV show for a series
|
||||
async function getNextUp(
|
||||
api: Api,
|
||||
userId: string | undefined,
|
||||
seriesId: string | undefined,
|
||||
) {
|
||||
if (!userId || !seriesId) return null;
|
||||
const response = await getTvShowsApi(api).getNextUp({
|
||||
userId,
|
||||
seriesId,
|
||||
limit: 1,
|
||||
});
|
||||
return response.data.Items?.[0] ?? null;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import { Colors } from "../../constants/Colors";
|
||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||
import { ItemCardText } from "../ItemCardText";
|
||||
@@ -35,7 +36,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
queryFn,
|
||||
queryKey,
|
||||
hideIfEmpty = false,
|
||||
pageSize = 20,
|
||||
pageSize = 10,
|
||||
...props
|
||||
}) => {
|
||||
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
|
||||
@@ -52,9 +53,9 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
return allPages.length * pageSize;
|
||||
},
|
||||
initialPageParam: 0,
|
||||
staleTime: 0,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: true,
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: true,
|
||||
});
|
||||
|
||||
@@ -83,9 +84,9 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<Text className='px-4 text-lg font-bold mb-2 text-neutral-100'>
|
||||
{title}
|
||||
</Text>
|
||||
<View className='px-4 mb-2'>
|
||||
<Text className='text-lg font-bold text-neutral-100'>{title}</Text>
|
||||
</View>
|
||||
{isLoading === false && allItems.length === 0 && (
|
||||
<View className='px-4'>
|
||||
<Text className='text-neutral-500'>{t("home.no_items")}</Text>
|
||||
@@ -179,8 +180,13 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
))}
|
||||
{/* Loading indicator for next page */}
|
||||
{isFetchingNextPage && (
|
||||
<View className='justify-center items-center w-16'>
|
||||
<ActivityIndicator size='small' color='#6366f1' />
|
||||
<View
|
||||
style={{
|
||||
marginLeft: 8,
|
||||
marginTop: orientation === "horizontal" ? 37 : 70,
|
||||
}}
|
||||
>
|
||||
<ActivityIndicator size='small' color={Colors.primary} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { ScrollView, View, type ViewProps } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import { useInView } from "@/hooks/useInView";
|
||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||
import { ItemCardText } from "../ItemCardText";
|
||||
@@ -21,6 +22,8 @@ interface Props extends ViewProps {
|
||||
queryFn: QueryFunction<BaseItemDto[]>;
|
||||
hideIfEmpty?: boolean;
|
||||
isOffline?: boolean;
|
||||
scrollY?: number; // For lazy loading
|
||||
enableLazyLoading?: boolean; // Enable/disable lazy loading
|
||||
}
|
||||
|
||||
export const ScrollingCollectionList: React.FC<Props> = ({
|
||||
@@ -31,33 +34,44 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
||||
queryKey,
|
||||
hideIfEmpty = false,
|
||||
isOffline = false,
|
||||
scrollY = 0,
|
||||
enableLazyLoading = false,
|
||||
...props
|
||||
}) => {
|
||||
const { ref, isInView, onLayout } = useInView(scrollY, {
|
||||
enabled: enableLazyLoading,
|
||||
});
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: queryKey,
|
||||
queryFn,
|
||||
staleTime: 0,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: true,
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: true,
|
||||
enabled: enableLazyLoading ? isInView : true,
|
||||
});
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (hideIfEmpty === true && data?.length === 0) return null;
|
||||
// Show skeleton if loading OR if lazy loading is enabled and not in view yet
|
||||
const shouldShowSkeleton = isLoading || (enableLazyLoading && !isInView);
|
||||
|
||||
if (hideIfEmpty === true && data?.length === 0 && !shouldShowSkeleton)
|
||||
return null;
|
||||
if (disabled || !title) return null;
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<View ref={ref} onLayout={onLayout} {...props}>
|
||||
<Text className='px-4 text-lg font-bold mb-2 text-neutral-100'>
|
||||
{title}
|
||||
</Text>
|
||||
{isLoading === false && data?.length === 0 && (
|
||||
{!shouldShowSkeleton && data?.length === 0 && (
|
||||
<View className='px-4'>
|
||||
<Text className='text-neutral-500'>{t("home.no_items")}</Text>
|
||||
</View>
|
||||
)}
|
||||
{isLoading ? (
|
||||
{shouldShowSkeleton ? (
|
||||
<View
|
||||
className={`
|
||||
flex flex-row gap-2 px-4
|
||||
|
||||
@@ -23,7 +23,6 @@ const CastSlide: React.FC<
|
||||
showsHorizontalScrollIndicator={false}
|
||||
data={details?.credits.cast}
|
||||
ItemSeparatorComponent={() => <View className='w-2' />}
|
||||
estimatedItemSize={15}
|
||||
keyExtractor={(item) => item?.id?.toString()}
|
||||
contentContainerStyle={{ paddingHorizontal: 16 }}
|
||||
renderItem={({ item }) => (
|
||||
|
||||
@@ -154,7 +154,7 @@ const DetailFacts: React.FC<
|
||||
<Facts
|
||||
title={t("jellyseerr.release_dates")}
|
||||
facts={filteredReleases?.map?.((r: Release, idx) => (
|
||||
<View key={idx} className='flex flex-row space-x-2 items-center'>
|
||||
<View key={idx} className='flex flex-row gap-x-2 items-center'>
|
||||
{r.type === 3 ? (
|
||||
// Theatrical
|
||||
<Ionicons name='ticket' size={16} color='white' />
|
||||
@@ -189,7 +189,7 @@ const DetailFacts: React.FC<
|
||||
<Facts
|
||||
title={t("jellyseerr.production_country")}
|
||||
facts={details?.productionCountries?.map((n, idx) => (
|
||||
<View key={idx} className='flex flex-row items-center space-x-2'>
|
||||
<View key={idx} className='flex flex-row items-center gap-x-2'>
|
||||
<CountryFlag isoCode={n.iso_3166_1} size={10} />
|
||||
<Text>{n.name}</Text>
|
||||
</View>
|
||||
|
||||
@@ -118,7 +118,7 @@ const ParallaxSlideShow = <T,>({
|
||||
}
|
||||
logo={logo}
|
||||
>
|
||||
<View className='flex flex-col space-y-4 px-4'>
|
||||
<View className='flex flex-col gap-y-4 px-4'>
|
||||
<View className='flex flex-row justify-between w-full'>
|
||||
<View className='flex flex-col w-full'>{HeaderContent?.()}</View>
|
||||
</View>
|
||||
@@ -143,7 +143,6 @@ const ParallaxSlideShow = <T,>({
|
||||
renderItem={({ item, index }) => renderItem(item, index)}
|
||||
keyExtractor={keyExtractor}
|
||||
numColumns={3}
|
||||
estimatedItemSize={214}
|
||||
ItemSeparatorComponent={() => <View className='h-2 w-2' />}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -8,7 +8,7 @@ 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 { Text } from "@/components/common/Text";
|
||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||
@@ -48,8 +48,22 @@ const RequestModal = forwardRef<
|
||||
userId: jellyseerrUser?.id,
|
||||
});
|
||||
|
||||
const [qualityProfileOpen, setQualityProfileOpen] = useState(false);
|
||||
const [rootFolderOpen, setRootFolderOpen] = useState(false);
|
||||
const [tagsOpen, setTagsOpen] = useState(false);
|
||||
const [usersOpen, setUsersOpen] = useState(false);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Reset all dropdown states when modal closes
|
||||
const handleDismiss = useCallback(() => {
|
||||
setQualityProfileOpen(false);
|
||||
setRootFolderOpen(false);
|
||||
setTagsOpen(false);
|
||||
setUsersOpen(false);
|
||||
onDismiss?.();
|
||||
}, [onDismiss]);
|
||||
|
||||
const { data: serviceSettings } = useQuery({
|
||||
queryKey: ["jellyseerr", "request", type, "service"],
|
||||
queryFn: async () =>
|
||||
@@ -144,7 +158,6 @@ const RequestModal = forwardRef<
|
||||
const qualityProfileOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: t("jellyseerr.quality_profile"),
|
||||
options:
|
||||
defaultServiceDetails?.profiles.map((profile) => ({
|
||||
type: "radio" as const,
|
||||
@@ -165,14 +178,12 @@ const RequestModal = forwardRef<
|
||||
defaultServiceDetails?.profiles,
|
||||
defaultProfile,
|
||||
requestOverrides.profileId,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
const rootFolderOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: t("jellyseerr.root_folder"),
|
||||
options:
|
||||
defaultServiceDetails?.rootFolders.map((folder) => ({
|
||||
type: "radio" as const,
|
||||
@@ -193,14 +204,12 @@ const RequestModal = forwardRef<
|
||||
defaultServiceDetails?.rootFolders,
|
||||
defaultFolder,
|
||||
requestOverrides.rootFolder,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
const tagsOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: t("jellyseerr.tags"),
|
||||
options:
|
||||
defaultServiceDetails?.tags.map((tag) => ({
|
||||
type: "toggle" as const,
|
||||
@@ -222,13 +231,12 @@ const RequestModal = forwardRef<
|
||||
})) || [],
|
||||
},
|
||||
],
|
||||
[defaultServiceDetails?.tags, defaultTags, requestOverrides.tags, t],
|
||||
[defaultServiceDetails?.tags, defaultTags, requestOverrides.tags],
|
||||
);
|
||||
|
||||
const usersOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: t("jellyseerr.request_as"),
|
||||
options:
|
||||
users?.map((user) => ({
|
||||
type: "radio" as const,
|
||||
@@ -244,7 +252,7 @@ const RequestModal = forwardRef<
|
||||
})) || [],
|
||||
},
|
||||
],
|
||||
[users, jellyseerrUser, requestOverrides.userId, t],
|
||||
[users, jellyseerrUser, requestOverrides.userId],
|
||||
);
|
||||
|
||||
const request = useCallback(() => {
|
||||
@@ -277,7 +285,7 @@ const RequestModal = forwardRef<
|
||||
ref={ref}
|
||||
enableDynamicSizing
|
||||
enableDismissOnClose
|
||||
onDismiss={onDismiss}
|
||||
onDismiss={handleDismiss}
|
||||
handleIndicatorStyle={{
|
||||
backgroundColor: "white",
|
||||
}}
|
||||
@@ -291,9 +299,10 @@ const RequestModal = forwardRef<
|
||||
appearsOnIndex={0}
|
||||
/>
|
||||
)}
|
||||
stackBehavior='push'
|
||||
>
|
||||
<BottomSheetView>
|
||||
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
||||
<View className='flex flex-col gap-y-4 px-4 pb-8 pt-2'>
|
||||
<View>
|
||||
<Text className='font-bold text-2xl text-neutral-100'>
|
||||
{t("jellyseerr.advanced")}
|
||||
@@ -302,7 +311,7 @@ const RequestModal = forwardRef<
|
||||
<Text className='text-neutral-300'>{seasonTitle}</Text>
|
||||
)}
|
||||
</View>
|
||||
<View className='flex flex-col space-y-2'>
|
||||
<View className='flex flex-col gap-y-2'>
|
||||
{defaultService && defaultServiceDetails && users && (
|
||||
<>
|
||||
<View className='flex flex-col'>
|
||||
@@ -312,7 +321,7 @@ const RequestModal = forwardRef<
|
||||
<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'>
|
||||
<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}>
|
||||
{defaultServiceDetails.profiles.find(
|
||||
(p) =>
|
||||
@@ -321,9 +330,11 @@ const RequestModal = forwardRef<
|
||||
defaultProfile?.id),
|
||||
)?.name || defaultProfile?.name}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
}
|
||||
title={t("jellyseerr.quality_profile")}
|
||||
open={qualityProfileOpen}
|
||||
onOpenChange={setQualityProfileOpen}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -334,7 +345,7 @@ const RequestModal = forwardRef<
|
||||
<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'>
|
||||
<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}>
|
||||
{defaultServiceDetails.rootFolders.find(
|
||||
(f) =>
|
||||
@@ -352,9 +363,11 @@ const RequestModal = forwardRef<
|
||||
)
|
||||
: pathTitleExtractor(defaultFolder!)}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
}
|
||||
title={t("jellyseerr.root_folder")}
|
||||
open={rootFolderOpen}
|
||||
onOpenChange={setRootFolderOpen}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -365,7 +378,7 @@ const RequestModal = forwardRef<
|
||||
<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'>
|
||||
<View className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||
<Text numberOfLines={1}>
|
||||
{requestOverrides.tags
|
||||
? defaultServiceDetails.tags
|
||||
@@ -377,9 +390,11 @@ const RequestModal = forwardRef<
|
||||
defaultTags.map((t) => t.label).join(", ")
|
||||
: defaultTags.map((t) => t.label).join(", ")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
}
|
||||
title={t("jellyseerr.tags")}
|
||||
open={tagsOpen}
|
||||
onOpenChange={setTagsOpen}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -390,7 +405,7 @@ const RequestModal = forwardRef<
|
||||
<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'>
|
||||
<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}>
|
||||
{users.find(
|
||||
(u) =>
|
||||
@@ -398,9 +413,11 @@ const RequestModal = forwardRef<
|
||||
(requestOverrides.userId || jellyseerrUser?.id),
|
||||
)?.displayName || jellyseerrUser!.displayName}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
}
|
||||
title={t("jellyseerr.request_as")}
|
||||
open={usersOpen}
|
||||
onOpenChange={setUsersOpen}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import type { ContentStyle } from "@shopify/flash-list/src/FlashListProps";
|
||||
import { t } from "i18next";
|
||||
import type React from "react";
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { View, type ViewProps, type ViewStyle } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||
import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
|
||||
|
||||
export interface SlideProps {
|
||||
slide: DiscoverSlider;
|
||||
contentContainerStyle?: ContentStyle;
|
||||
contentContainerStyle?: ViewStyle;
|
||||
}
|
||||
|
||||
interface Props<T> extends SlideProps {
|
||||
@@ -45,7 +44,6 @@ const Slide = <T,>({
|
||||
}}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemSize={250}
|
||||
data={data}
|
||||
onEndReachedThreshold={1}
|
||||
onEndReached={onEndReached}
|
||||
|
||||
@@ -27,23 +27,18 @@ export const ListGroup: React.FC<PropsWithChildren<Props>> = ({
|
||||
{title}
|
||||
</Text>
|
||||
<View
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
}}
|
||||
style={[]}
|
||||
className='flex flex-col rounded-xl overflow-hidden pl-0 bg-neutral-900'
|
||||
>
|
||||
{Children.map(childrenArray, (child, index) => {
|
||||
if (isValidElement<{ style?: ViewStyle }>(child)) {
|
||||
const isLastItem = index === childrenArray.length - 1;
|
||||
return cloneElement(child as any, {
|
||||
...(isLastItem
|
||||
? {}
|
||||
: {
|
||||
style: StyleSheet.compose(
|
||||
child.props.style,
|
||||
styles.borderBottom,
|
||||
),
|
||||
}),
|
||||
style: StyleSheet.compose(
|
||||
child.props.style,
|
||||
index < childrenArray.length - 1
|
||||
? styles.borderBottom
|
||||
: undefined,
|
||||
),
|
||||
});
|
||||
}
|
||||
return child;
|
||||
|
||||
@@ -107,7 +107,7 @@ const ListItemContent = ({
|
||||
</Text>
|
||||
{subtitle && (
|
||||
<Text
|
||||
className='text-neutral-500 text-[11px] mt-0.5'
|
||||
className='text-[#9899A1] text-[12px] mt-0.5'
|
||||
numberOfLines={2}
|
||||
>
|
||||
{subtitle}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user