Compare commits

..

59 Commits

Author SHA1 Message Date
Fredrik Burmester
744c35d71c fix: design 2025-10-06 10:16:56 +02:00
Fredrik Burmester
f28d6ca56d fix: patch for image 2025-10-06 10:14:13 +02:00
Fredrik Burmester
23406b957d fix: design 2025-10-06 10:13:59 +02:00
Fredrik Burmester
ec7954036e fix: delete entire season wrong params 2025-10-06 10:13:49 +02:00
Fredrik Burmester
87716aff92 fix: deisng 2025-10-04 09:20:01 +02:00
Fredrik Burmester
380f5cbf70 fix: design 2025-10-04 09:13:49 +02:00
Fredrik Burmester
23c1c817a0 feat: upgrade to native wind v5 2025-10-03 19:34:58 +02:00
Fredrik Burmester
3a8fb0a5e5 fix: remove unused code 2025-10-03 14:47:02 +02:00
Fredrik Burmester
06e19bd7e6 feat: download finish never registered 2025-10-03 14:03:49 +02:00
Fredrik Burmester
ac0f088ee3 Remove build artifacts from git tracking 2025-10-03 13:29:22 +02:00
Fredrik Burmester
930c98caec feat: enhance background downloader with internal storage support and logging 2025-10-03 13:29:14 +02:00
Fredrik Burmester
5894272149 fix: don't commit some files 2025-10-03 13:22:45 +02:00
Fredrik Burmester
0b39ab0212 fix: remove all references to old background downloader 2025-10-03 13:03:42 +02:00
Fredrik Burmester
e905737d5b wip: android support 2025-10-03 11:15:33 +02:00
Fredrik Burmester
4517fe354b fix: download status 2025-10-03 11:02:58 +02:00
Fredrik Burmester
d764e5f9d2 fix: speed calculation 2025-10-03 08:52:39 +02:00
Fredrik Burmester
7fef2ed5e2 fix: auto update on download/file actions 2025-10-03 07:57:45 +02:00
Fredrik Burmester
c36cd66e36 fix: delete items 2025-10-03 07:54:39 +02:00
Fredrik Burmester
1363c3137e fix: download speed 2025-10-03 07:45:18 +02:00
Fredrik Burmester
e55f2462e5 fix: download metadata 2025-10-03 07:24:59 +02:00
Fredrik Burmester
c88de0250f fix: working downloads 2025-10-03 07:07:28 +02:00
Fredrik Burmester
8d59065c49 fix: building 2025-10-02 20:54:25 +02:00
Fredrik Burmester
ec622aba55 wip: bg downloader module 2025-10-02 20:12:02 +02:00
Fredrik Burmester
a39461e09a fix: handle optional chaining for 'other' media items in DownloadProvider 2025-10-02 19:49:20 +02:00
Fredrik Burmester
a0725d89a0 Merge branch 'develop' into feature/newarch 2025-10-02 19:46:34 +02:00
Fredrik Burmester
7e2cfb9790 chore: back to bun 2025-10-02 19:10:00 +02:00
Fredrik Burmester
38d1b513d4 fix: building at least 2025-10-02 19:09:37 +02:00
Fredrik Burmester
cc54a3a71b fix: update deps 2025-10-02 18:18:22 +02:00
Fredrik Burmester
6842ae03f9 chore: update deps 2025-10-02 10:26:17 +02:00
Fredrik Burmester
a5ffbd6a4c fix: deps linking 2025-10-01 20:59:17 +02:00
Fredrik Burmester
02fa738cfd wip: downloads "complete" is broken? 2025-10-01 16:57:02 +02:00
Fredrik Burmester
32c01c6f89 fix: rn downloads 2025-10-01 15:25:16 +02:00
Fredrik Burmester
6fc4c33759 fix: update package 2025-10-01 12:18:52 +02:00
Fredrik Burmester
49ea64b0fd wip: rnbd fix? 2025-10-01 12:15:58 +02:00
Fredrik Burmester
c866b105e7 Revert "chore(tmp): workaround download provider"
This reverts commit e9effd5436.
2025-10-01 11:36:21 +02:00
Fredrik Burmester
1b42e61310 Revert "fix(tmp): remove plugin temporarely"
This reverts commit 47c52e0739.
2025-10-01 11:36:16 +02:00
Fredrik Burmester
fb032fa973 feat: glass filter ios for search 2025-09-30 18:35:28 +02:00
Fredrik Burmester
a0a90e48d8 feat: home page design 2025-09-30 16:45:18 +02:00
Fredrik Burmester
ab472bab6e fix: modal for android + dropdown for ios 2025-09-30 15:23:15 +02:00
Fredrik Burmester
8407124464 fix: memoize dropdown rerender controls issue 2025-09-30 13:28:05 +02:00
Fredrik Burmester
afe57d4c76 wip 2025-09-30 13:07:15 +02:00
Fredrik Burmester
7a11f4a54b wip 2025-09-30 11:58:59 +02:00
Fredrik Burmester
47c52e0739 fix(tmp): remove plugin temporarely 2025-09-30 11:00:15 +02:00
Fredrik Burmester
e9effd5436 chore(tmp): workaround download provider 2025-09-30 10:20:28 +02:00
Fredrik Burmester
6ae655abf2 chore 2025-09-30 10:20:11 +02:00
Fredrik Burmester
c74a394a6a wip: global modal provider 2025-09-30 10:20:05 +02:00
Fredrik Burmester
5e6cd6bed6 wip: remove zeego + expo ui 2025-09-30 08:26:45 +02:00
Fredrik Burmester
dfb6bd03a9 fix: non supported prop 2025-09-29 15:21:04 +02:00
Fredrik Burmester
eaf0a9fae4 fix: update tvos dep 2025-09-29 15:11:51 +02:00
Fredrik Burmester
f2bd10b1a6 fix: deps 2025-09-29 15:07:27 +02:00
Fredrik Burmester
dd03c2038d Merge branch 'develop' into feature/newarch 2025-09-29 15:07:23 +02:00
Gauvain
6af9d88a72 Merge branch 'develop' into feature/newarch 2025-09-26 20:52:32 +02:00
Lance Chant
dfa3c06857 Merge branch 'feature/newarch' of https://github.com/streamyfin/streamyfin into feature/newarch 2025-09-08 13:41:36 +02:00
Lance Chant
b0bb9d10e5 chore: adding package updates + MMKV changes
Updated package versions
Updated mmkv.ts to work with the new layout/design

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2025-09-08 13:40:21 +02:00
Gauvain
5d080664a0 Merge branch 'develop' into feature/newarch 2025-09-03 17:09:32 +02:00
Lance Chant
cde205e762 fix: android building
It builds, but don't ask it to run, it'll give exception hell

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2025-09-02 13:01:19 +02:00
Lance Chant
c335a3269e chore: package updates to try get android to build
Updated packages to latest expo beta
Updated some MMKV usages

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2025-09-02 08:15:04 +02:00
sarendsen
ccf27284f6 wip 2025-09-01 12:57:04 +02:00
sarendsen
a11b9f5875 chore: upgrade to newarch 2025-09-01 12:52:43 +02:00
142 changed files with 7309 additions and 4835 deletions

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,11 @@
# Contributing to Streamyfin # Contributing to Streamyfin
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. 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.
--- ---
## Table of Contents ## Table of Contents
- [AI Assistance Disclosure](#ai-assistance-disclosure)
- [Reporting Issues](#reporting-issues) - [Reporting Issues](#reporting-issues)
- [Reporting Security Vulnerabilities](#reporting-security-vulnerabilities) - [Reporting Security Vulnerabilities](#reporting-security-vulnerabilities)
- [Requesting Features & Enhancements](#requesting-features--enhancements) - [Requesting Features & Enhancements](#requesting-features--enhancements)
@@ -21,25 +19,6 @@ Thank you for your interest in contributing to the Streamyfin project. This docu
--- ---
## 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 ## Reporting Issues
Streamyfin uses GitHub issues to track bugs and improvements. Before opening a new issue: Streamyfin uses GitHub issues to track bugs and improvements. Before opening a new issue:
@@ -67,11 +46,11 @@ When creating a new feature request:
- Check if the idea or similar request exists. - Check if the idea or similar request exists.
- Use reactions like 👍 to support existing requests. - Use reactions like 👍 to support existing requests.
- Clearly describe the use case and potential benefits. - Provide a clear explanation of the use case and benefits.
- Include screenshots when relevant.
--- ---
## Developing Streamyfin ## Developing the Mobile App
### Codebase Overview ### Codebase Overview
@@ -157,8 +136,6 @@ When opening a PR:
- Provide a detailed description in the PR body, explaining what, why, and any impacts. - Provide a detailed description in the PR body, explaining what, why, and any impacts.
- Include screenshots or recordings if UI changes are involved. - Include screenshots or recordings if UI changes are involved.
- Ensure CI checks are green (lint, type-check, build). - 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. - Do not include secrets, tokens, or production credentials. Redact sensitive data in logs and screenshots.
- Keep PRs focused; avoid bundling unrelated changes together. - Keep PRs focused; avoid bundling unrelated changes together.
@@ -182,4 +159,4 @@ PRs require review and approval by maintainers before merging.---
--- ---
Thank you for contributing to make Streamyfin better for everyone! Thank you for helping make Streamyfin a better app for everyone!

View File

@@ -3,94 +3,58 @@
## Project Overview ## Project Overview
Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native). Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native).
It supports mobile (iOS/Android) and TV platforms, integrates with Jellyfin and Jellyseerr APIs, It supports mobile (iOS/Android) and TV platforms, and integrates with Jellyfin and Jellyseerr APIs.
and provides seamless media streaming with offline capabilities and Chromecast support.
## Main Technologies ## Main Technologies
- **Runtime**: Bun (JavaScript/TypeScript execution) - React Native (Expo)
- **Framework**: React Native (Expo) - TypeScript
- **Language**: TypeScript (strict mode) - React Query
- **State Management**: Jotai (global state) + React Query (server state) - Jotai (state management)
- **API SDK**: Jellyfin SDK (TypeScript) - Jellyfin SDK (TypeScript)
- **Navigation**: Expo Router (file-based routing) - BiomeJS (code formatting/linting)
- **Code Quality**: BiomeJS (formatting/linting) - EAS (Expo Application Services)
- **Build Platform**: EAS (Expo Application Services) - Shell scripting (for automation)
- **CI/CD**: GitHub Actions with Bun - GitHub Actions (CI/CD)
## 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 ## Code Structure
- `app/` Main application code (screens, navigation, etc.) - `app/` Main application code (screens, navigation, etc.)
- `components/` Reusable UI components - `components/` Reusable UI components
- `providers/` Context and API providers (e.g., JellyfinProvider.tsx) - `providers/` Context and API providers (e.g., JellyfinProvider.tsx)
- `utils/` Utility functions and Jotai atoms - `utils/` Utility functions and atoms
- `assets/` Images and static assets - `assets/` Images and static assets
- `scripts/` Automation scripts (Node.js, Bash) - `scripts/` Automation scripts (Node.js, Bash)
- `plugins/` Expo/Metro plugins - `plugins/` Expo/Metro plugins
- `README.md` Project documentation
## Coding Standards ## Coding Conventions
- Use TypeScript for ALL files (no .js files) - Use TypeScript for all new code.
- Use descriptive English names for variables, functions, and components - Prefer functional React components.
- Prefer functional React components with hooks - Use hooks for state and side effects.
- Use Jotai atoms for global state management - Use Jotai for global state.
- Use React Query for server state and caching - Use React Query for data fetching/caching.
- Follow BiomeJS formatting and linting rules - Use BiomeJS for formatting and linting.
- Use `const` over `let`, avoid `var` entirely - Follow the established folder structure for screens/components.
- Implement proper error boundaries
- Use React.memo() for performance optimization
- Handle both mobile and TV navigation patterns
## API Integration ## API Usage
- Use Jellyfin SDK for all server interactions - Use the Jellyfin SDK for all server interactions.
- Access authenticated APIs via `apiAtom` and `userAtom` from JellyfinProvider - Use the `apiAtom` and `userAtom` from `JellyfinProvider` for authenticated API calls.
- Implement proper loading states and error handling - For navigation, use `expo-router`.
- 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 ## Commit Messages
Use Conventional Commits (https://www.conventionalcommits.org/): - Use [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) (e.g., `feat:`, `fix:`, `chore:`).
Exemples: - Example: `feat(player): add Chromecast support`
- `feat(player): add Chromecast support`
- `fix(auth): handle expired JWT tokens`
- `chore(deps): update Jellyfin SDK`
## Special Instructions ## Special Instructions
- Prioritize cross-platform compatibility (mobile + TV) - When suggesting code, prefer using existing atoms, hooks, and utility functions.
- Ensure accessibility for TV remote navigation - When adding new features, ensure they are accessible via both mobile and TV navigation if relevant.
- Use existing atoms, hooks, and utilities before creating new ones - When updating dependencies or scripts, check for compatibility with Expo and EAS.
- 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.** **Copilot: Please use these instructions to provide context-aware suggestions and code completions for this repository.**

12
.github/crowdin.yml vendored
View File

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

View File

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

View File

@@ -76,7 +76,7 @@ jobs:
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
- name: 📤 Upload APK artifact - name: 📤 Upload APK artifact
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with: with:
name: streamyfin-android-phone-apk-${{ env.DATE_TAG }} name: streamyfin-android-phone-apk-${{ env.DATE_TAG }}
path: | path: |
@@ -147,7 +147,7 @@ jobs:
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
- name: 📤 Upload APK artifact - name: 📤 Upload APK artifact
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with: with:
name: streamyfin-android-tv-apk-${{ env.DATE_TAG }} name: streamyfin-android-tv-apk-${{ env.DATE_TAG }}
path: | path: |
@@ -210,7 +210,7 @@ jobs:
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
- name: 📤 Upload IPA artifact - name: 📤 Upload IPA artifact
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with: with:
name: streamyfin-ios-phone-ipa-${{ env.DATE_TAG }} name: streamyfin-ios-phone-ipa-${{ env.DATE_TAG }}
path: build-*.ipa path: build-*.ipa

View File

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

View File

@@ -1,50 +1,34 @@
name: 🌐 Translation Sync name: Crowdin Action
on: on:
push: push:
branches: [develop] branches: [ main ]
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: jobs:
sync-translations: synchronize-with-crowdin:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: 📥 Checkout Repository - name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@v4
with:
fetch-depth: 0
- name: 🌐 Sync Translations with Crowdin - name: crowdin action
uses: crowdin/github-action@08713f00a50548bfe39b37e8f44afb53e7a802d4 # v2.12.0 uses: crowdin/github-action@v2
with: with:
upload_sources: true upload_sources: true
upload_translations: true upload_translations: true
download_translations: true download_translations: true
localization_branch_name: I10n_crowdin_translations localization_branch_name: l10n_crowdin_translations
create_pull_request: true create_pull_request: true
pull_request_title: "feat: New Crowdin Translations" 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_body: 'New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)'
pull_request_base_branch_name: "develop" 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: 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 }} 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 }} CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
# Visit https://crowdin.com/settings#api-key to create this token
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}

View File

@@ -57,7 +57,7 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: Dependency Review - name: Dependency Review
uses: actions/dependency-review-action@40c09b7dc99638e5ddb0bfd91c1673effc064d8a # v4.8.1 uses: actions/dependency-review-action@56339e523c0409420f6c2c9a2f4292bbb3c07dd3 # v4.8.0
with: with:
fail-on-severity: high fail-on-severity: high
base-ref: ${{ github.event.pull_request.base.sha || 'develop' }} base-ref: ${{ github.event.pull_request.base.sha || 'develop' }}
@@ -107,7 +107,7 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: "🟢 Setup Node.js" - name: "🟢 Setup Node.js"
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with: with:
node-version: '24.x' node-version: '24.x'

View File

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

View File

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

View File

@@ -21,7 +21,7 @@ jobs:
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: "🟢 Setup Node.js" - name: "🟢 Setup Node.js"
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with: with:
node-version: '24.x' node-version: '24.x'
cache: 'npm' cache: 'npm'

73
.gitignore vendored
View File

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

View File

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

176
.vscode/settings.json vendored
View File

@@ -1,178 +1,24 @@
{ {
// ==========================================
// FORMATTING & LINTING
// ==========================================
// Biome as default formatter
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true,
"editor.formatOnPaste": true,
"editor.formatOnType": false,
// Language-specific formatters
"[javascript]": { "[javascript]": {
"editor.defaultFormatter": "biomejs.biome", "editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true "editor.formatOnSave": true
}, },
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true
},
"[typescriptreact]": { "[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome", "editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true "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]": { "[javascriptreact]": {
"editor.defaultFormatter": "biomejs.biome", "editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true "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"
} }

232
GLOBAL_MODAL_GUIDE.md Normal file
View File

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

161
README.md
View File

@@ -5,135 +5,146 @@
<img src="https://raw.githubusercontent.com/streamyfin/.github/refs/heads/main/streamyfin-github-banner.png" alt="Streamyfin" width="100%"> <img src="https://raw.githubusercontent.com/streamyfin/.github/refs/heads/main/streamyfin-github-banner.png" alt="Streamyfin" width="100%">
</p> </p>
**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.** **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.**
--- ---
<p align="center"> <p align="center">
<img src="./assets/images/screenshots/screenshot1.png" width="20%"> <img src="./assets/images/screenshots/screenshot1.png" width="22%">
&nbsp; &nbsp;
<img src="./assets/images/screenshots/screenshot3.png" width="20%"> <img src="./assets/images/screenshots/screenshot3.png" width="22%">
&nbsp; &nbsp;
<img src="./assets/images/screenshots/screenshot2.png" width="20%"> <img src="./assets/images/screenshots/screenshot2.png" width="22%">
&nbsp; &nbsp;
<img src="./assets/images/jellyseerr.PNG" width="21%"> <img src="./assets/images/jellyseerr.PNG" width="23%">
</p> </p>
## 🌟 Features ## 🌟 Features
- 🚀 **Skip Intro / Credits Support**: Lets you quickly skip intros and credits during playback - 🚀 **Skip Intro / Credits Support**
- 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking - 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking.
- 📥 **Download media**: Save your media locally and watch it offline - 📥 **Download media** (Experimental): Save your media locally and watch it offline.
- ⚙️ **Settings management**: Manage app configurations for all users through our plugin - 📡 **Chromecast** (Experimental): Cast your media to any Chromecast-enabled device.
- 🤖 **Seerr (formerly Jellyseerr) integration**: Request media directly in the app - 📡 **Settings management** (Experimental): Manage app settings for all your users with a JF plugin.
- 👁️ **Sessions view:** View all active sessions currently streaming on your server - 🤖 **Jellyseerr integration**: Request media directly in the app.
- 📡 **Chromecast**: Cast your media to any Chromecast-enabled device - 👁️ **Sessions View:** View all active sessions currently streaming on your server.
## 🧪 Experimental Features ## 🧪 Experimental Features
Streamyfin offers exciting experimental features such as media downloading and Chromecast support. These features are under active development, and your feedback and patience help us make them even better. Streamyfin includes some exciting experimental features like media downloading and Chromecast support. These features are still in development, and your patience and feedback are much appreciated as we work to improve them.
### 📥 Downloading ### 📥 Downloading
Downloading works by using FFmpeg to convert an HLS stream into a video file on your device. This lets you download and watch any content that you can stream. The conversion is handled in real time by Jellyfin on the server during the download. While this may take a bit longer, it ensures compatibility with any file your server can transcode. Downloading works by using ffmpeg to convert an HLS stream into a video file on the device. This means that you can download and view any file you can stream! The file is converted by Jellyfin on the server in real time as it is downloaded. This means a **bit longer download times** but supports any file that your server can transcode.
### 🎥 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.
### 🧩 Streamyfin Plugin ### 🧩 Streamyfin Plugin
The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that holds all settings for the client Streamyfin. This allows you to synchronize settings across all your users, like for example: The Jellyfin Plugin for Streamyfin 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:
- Automatic Seerr login with no user input required - Auto log in to Jellyseerr without the user having to do anything
- Set your preferred default languages - Choose the default languages
- Configure download method and search provider - Set download method and search provider
- Personalize your home screen - Customize home screen
- And much more - And much more...
[Streamyfin Plugin](https://github.com/streamyfin/jellyfin-plugin-streamyfin) [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
[Jellysearch](https://gitlab.com/DomiStyle/jellysearch) works with Streamyfin [Jellysearch](https://gitlab.com/DomiStyle/jellysearch) now works with Streamyfin!
> A fast full-text search proxy for Jellyfin. Integrates seamlessly with most Jellyfin clients. > A fast full-text search proxy for Jellyfin. Integrates seamlessly with most Jellyfin clients.
## 🛣️ Roadmap ## 🛣️ Roadmap for V1
Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) To see what we're working on next, we are always open to feedback and suggestions. Please let us know if you have any ideas or feature requests. Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) To see what we're working on next, we are always open to feedback and suggestions. Please let us know if you have any ideas or feature requests.
## 📥 Download Streamyfin ## 📥 Get it now
<div style="display: flex; gap: 5px;"> <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://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 Streamyfin on Google Play Store" 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 the beta on Google Play" 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> </div>
### 🧪 Beta Testing Or download the APKs [here on GitHub](https://github.com/streamyfin/streamyfin/releases) for Android.
To access the Streamyfin beta, you need to subscribe to the Member tier (or higher) on [Patreon](https://www.patreon.com/streamyfin). This grants you immediate access to the ⁠🧪-beta-releases channel on Discord and lets me know youve subscribed. This is where I share APKs and IPAs. It does not provide automatic TestFlight access, so please send me a DM (Cagemaster) with the email you use for Apple so we can add you manually. ### 🧪 Beta testing
**Note**: Anyone actively contributing to Streamyfins source code will receive automatic access to beta releases. To access the Streamyfin beta, you need to subscribe to the Member tier (or higher) on [Patreon](https://www.patreon.com/streamyfin). This will give you immediate access to the ⁠🧪-public-beta channel on Discord and I'll know that you have subscribed. This is where I post APKs and IPAs. This won't give automatic access to the TestFlight, however, so you need to send me a DM with the email you use for Apple so that I can manually add you.
**Note**: Everyone who is actively contributing to the source code of Streamyfin will have automatic access to the betas.
## 🚀 Getting Started ## 🚀 Getting Started
### ⚙️ Prerequisites ### Prerequisites
- Your device is on the same network as the Jellyfin server (for local connections) - Ensure you have an active Jellyfin server.
- Your Jellyfin server is up and running with remote access enabled if you plan to connect from outside your local network - Make sure your device is connected to the same network as your Jellyfin server.
- 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 ## 🙌 Contributing
We welcome contributions that improve Streamyfin. Start by forking the repository and submitting a pull request. For major changes or new features, please open an issue first to discuss your ideas and ensure alignment with the project. We welcome any help to make Streamyfin better. If you'd like to contribute, please fork the repository and submit a pull request. For major changes, it's best to open an issue first to discuss your ideas.
## 🌍 Translations ### 👨‍💻 Development info
[![Crowdin Translation Status](https://badges.crowdin.net/streamyfin/localized.svg)](https://crowdin.com/project/streamyfin)
Streamyfin is available in multiple languages, and were 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` 1. Use node `>20`
2. Install dependencies `bun i && bun run submodule-reload` 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/) 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. Install BiomeJS extension in VSCode/Your IDE (https://biomejs.dev/)
4. run `npm run prebuild` 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`. For the TV version suffix the npm commands with `:tv`.
`npm run prebuild:tv` `npm run prebuild:tv`
`npm run ios:tv or npm run android:tv` `npm run ios:tv or npm run android:tv`
## 👋 Get in Touch with Us ## 📄 License
Need assistance or have any questions? 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:
- **Discord:** [Join our server](https://discord.gg/BuGG9ZNhaE) - You can use the software for any purpose
- **GitHub Issues:** [Report bugs or request features](https://github.com/streamyfin/streamyfin/issues) - You can modify the software and distribute modified versions
- **Email:** [developer@streamyfin.app](mailto:developer@streamyfin.app) - 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
## 🌐 Connect with Us
Join our Discord: [![](https://dcbadge.limes.pink/api/server/https://discord.gg/BuGG9ZNhaE)](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 ## ❓ FAQ
1. Q: Why can't I see my libraries in Streamyfin? 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 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? 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 A: We don't currently support music and are unlikely to support music in the near future.
## 📝 Credits ## 📝 Credits
Streamyfin is developed by [Fredrik Burmester](https://github.com/fredrikburmester) and is not affiliated with Jellyfin. The app is built using Expo, React Native, and other open-source libraries. 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.
## 🎖️ Core Developers ## ✨ 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
Thanks to the following contributors for their significant contributions: Thanks to the following contributors for their significant contributions:
@@ -218,41 +229,21 @@ Thanks to the following contributors for their significant contributions:
</table> </table>
</div> </div>
## ✨ Acknowledgements And all other developers who have contributed to Streamyfin, thank you for your contributions.
We would like to thank the Jellyfin team for their excellent software and support on Discord. I'd also like to thank the following people and projects for their contributions to Streamyfin:
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 ## ⭐ Star History
[![Star History Chart](https://api.star-history.com/svg?repos=streamyfin/streamyfin&type=Date)](https://star-history.com/#streamyfin/streamyfin&Date) [![Star History Chart](https://api.star-history.com/svg?repos=streamyfin/streamyfin&type=Date)](https://star-history.com/#streamyfin/streamyfin&Date)
## 📄 License
Streamyfin is licensed under the Mozilla Public License 2.0 (MPL-2.0).
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 ## ⚠️ Disclaimer
Streamyfin does not promote, support, or condone piracy in any form. The app is intended solely for streaming media that you personally own and control. It does not provide or include any media content. Any discussions, support requests, or references to piracy, as well as any tools, software, or websites related to piracy, are strictly prohibited across all our channels. 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.
## 🤝 Sponsorship ## 🤝 Sponsorship
VPS hosting generously provided by [Hexabyte](https://hexabyte.se/en/vps/?currency=eur) and [SweHosting](https://swehosting.se/en/#tj%C3%A4nster) VPS hosting generously provided by [Hexabyte](https://hexabyte.se/en/vps/?currency=eur) and [SweHosting](https://swehosting.se/en/#tj%C3%A4nster)

View File

@@ -6,9 +6,6 @@ module.exports = ({ config }) => {
"react-native-google-cast", "react-native-google-cast",
{ useDefaultExpandedMediaControls: true }, { useDefaultExpandedMediaControls: true },
]); ]);
// Add the background downloader plugin only for non-TV builds
config.plugins.push("./plugins/withRNBackgroundDownloader.js");
} }
return { return {
android: { android: {

View File

@@ -8,6 +8,7 @@
"scheme": "streamyfin", "scheme": "streamyfin",
"userInterfaceStyle": "dark", "userInterfaceStyle": "dark",
"jsEngine": "hermes", "jsEngine": "hermes",
"newArchEnabled": true,
"assetBundlePatterns": ["**/*"], "assetBundlePatterns": ["**/*"],
"ios": { "ios": {
"requireFullScreen": true, "requireFullScreen": true,
@@ -77,6 +78,7 @@
"useFrameworks": "static" "useFrameworks": "static"
}, },
"android": { "android": {
"buildArchs": ["arm64-v8a", "x86_64"],
"compileSdkVersion": 35, "compileSdkVersion": 35,
"targetSdkVersion": 35, "targetSdkVersion": 35,
"buildToolsVersion": "35.0.0", "buildToolsVersion": "35.0.0",
@@ -154,7 +156,6 @@
}, },
"updates": { "updates": {
"url": "https://u.expo.dev/e79219d1-797f-4fbe-9fa1-cfd360690a68" "url": "https://u.expo.dev/e79219d1-797f-4fbe-9fa1-cfd360690a68"
}, }
"newArchEnabled": false
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

View File

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

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

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

View File

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

View File

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

1282
bun.lock

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

12
components/_template.tsx Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

5
global.css Normal file
View File

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

View File

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

View File

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

View File

@@ -96,9 +96,7 @@ export const useWebSocket = ({
| Record<string, string> | Record<string, string>
| undefined; // Arguments are Dictionary<string, string> | undefined; // Arguments are Dictionary<string, string>
// Sanitize output to avoid log injection console.log("[WS] ~ ", lastMessage);
const msgStr = JSON.stringify(lastMessage).replaceAll(/[\n\r]/g, " ");
console.log("[WS] ~ %s", msgStr);
if (command === "PlayPause") { if (command === "PlayPause") {
console.log("Command ~ PlayPause"); console.log("Command ~ PlayPause");

View File

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

6
login.yaml Normal file
View File

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

View File

@@ -1,8 +1,9 @@
// Learn more https://docs.expo.io/guides/customizing-metro // Learn more https://docs.expo.io/guides/customizing-metro
const { getDefaultConfig } = require("expo/metro-config"); const { getDefaultConfig } = require("expo/metro-config");
const { withNativewind } = require("nativewind/metro");
/** @type {import('expo/metro-config').MetroConfig} */ /** @type {import('expo/metro-config').MetroConfig} */
const config = getDefaultConfig(__dirname); // eslint-disable-line no-undef const config = getDefaultConfig(__dirname);
// Add Hermes parser // Add Hermes parser
config.transformer.hermesParser = true; config.transformer.hermesParser = true;
@@ -25,4 +26,4 @@ if (process.env?.EXPO_TV === "1") {
// config.resolver.unstable_enablePackageExports = false; // config.resolver.unstable_enablePackageExports = false;
module.exports = config; module.exports = withNativewind(config, { input: "./global.css" });

View File

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

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