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
290 changed files with 6778 additions and 10128 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
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
- [AI Assistance Disclosure](#ai-assistance-disclosure)
- [Reporting Issues](#reporting-issues)
- [Reporting Security Vulnerabilities](#reporting-security-vulnerabilities)
- [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
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.
- Use reactions like 👍 to support existing requests.
- Clearly describe the use case and potential benefits.
- Include screenshots when relevant.
- Provide a clear explanation of the use case and benefits.
---
## Developing Streamyfin
## Developing the Mobile App
### Codebase Overview
@@ -157,8 +136,6 @@ When opening a PR:
- Provide a detailed description in the PR body, explaining what, why, and any impacts.
- Include screenshots or recordings if UI changes are involved.
- Ensure CI checks are green (lint, type-check, build).
- Confirm that the branch is **up to date with `main`** before submission.
- Mention if AI-generated code or content was used (see [AI Assistance Disclosure](#ai-assistance-disclosure)).
- Do not include secrets, tokens, or production credentials. Redact sensitive data in logs and screenshots.
- Keep PRs focused; avoid bundling unrelated changes together.
@@ -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

@@ -77,8 +77,13 @@ body:
label: Streamyfin Version
description: What version of Streamyfin are you running?
options:
- 0.47.1
- 0.30.2
- 0.29.0
- 0.28.0
- 0.27.0
- 0.26.1
- 0.26.0
- 0.25.0
- older
- TestFlight/Development build
validations:
@@ -111,4 +116,4 @@ body:
id: additional-info
attributes:
label: Additional information
description: Any additional context that might help us understand and reproduce the issue.
description: Any additional context that might help us understand and reproduce the issue.

View File

@@ -3,94 +3,58 @@
## Project Overview
Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native).
It supports mobile (iOS/Android) and TV platforms, integrates with Jellyfin and Jellyseerr APIs,
and provides seamless media streaming with offline capabilities and Chromecast support.
It supports mobile (iOS/Android) and TV platforms, and integrates with Jellyfin and Jellyseerr APIs.
## Main Technologies
- **Runtime**: Bun (JavaScript/TypeScript execution)
- **Framework**: React Native (Expo)
- **Language**: TypeScript (strict mode)
- **State Management**: Jotai (global state) + React Query (server state)
- **API SDK**: Jellyfin SDK (TypeScript)
- **Navigation**: Expo Router (file-based routing)
- **Code Quality**: BiomeJS (formatting/linting)
- **Build Platform**: EAS (Expo Application Services)
- **CI/CD**: GitHub Actions with Bun
## Package Management
**CRITICAL: ALWAYS use `bun` for all package management operations**
- **NEVER use `npm`, `yarn` or `npx` commands**
- Use `bun install` instead of `npm install` or `yarn install`
- Use `bun add <package>` instead of `npm install <package>`
- Use `bun remove <package>` instead of `npm uninstall <package>`
- Use `bun run <script>` instead of `npm run <script>`
- Use `bunx <command>` instead of `npx <command>`
- For Expo: use `bunx create-expo-app` or `bunx @expo/cli`
- React Native (Expo)
- TypeScript
- React Query
- Jotai (state management)
- Jellyfin SDK (TypeScript)
- BiomeJS (code formatting/linting)
- EAS (Expo Application Services)
- Shell scripting (for automation)
- GitHub Actions (CI/CD)
## Code Structure
- `app/` Main application code (screens, navigation, etc.)
- `components/` Reusable UI components
- `providers/` Context and API providers (e.g., JellyfinProvider.tsx)
- `utils/` Utility functions and Jotai atoms
- `utils/` Utility functions and atoms
- `assets/` Images and static assets
- `scripts/` Automation scripts (Node.js, Bash)
- `plugins/` Expo/Metro plugins
- `README.md` Project documentation
## Coding Standards
## Coding Conventions
- Use TypeScript for ALL files (no .js files)
- Use descriptive English names for variables, functions, and components
- Prefer functional React components with hooks
- Use Jotai atoms for global state management
- Use React Query for server state and caching
- Follow BiomeJS formatting and linting rules
- Use `const` over `let`, avoid `var` entirely
- Implement proper error boundaries
- Use React.memo() for performance optimization
- Handle both mobile and TV navigation patterns
- Use TypeScript for all new code.
- Prefer functional React components.
- Use hooks for state and side effects.
- Use Jotai for global state.
- Use React Query for data fetching/caching.
- Use BiomeJS for formatting and linting.
- Follow the established folder structure for screens/components.
## API Integration
## API Usage
- Use Jellyfin SDK for all server interactions
- Access authenticated APIs via `apiAtom` and `userAtom` from JellyfinProvider
- Implement proper loading states and error handling
- Use React Query for caching and background updates
- Handle offline scenarios gracefully
## Performance Optimization
- Leverage Bun's superior runtime performance
- Optimize FlatList components with proper props
- Use lazy loading for non-critical components
- Implement proper image caching strategies
- Monitor bundle size and use tree-shaking effectively
## Testing
- Use Bun's built-in test runner when possible
- Test files: `*.test.ts` or `*.test.tsx`
- Run tests with: `bun test`
- Mock external APIs in tests
- Focus on testing business logic and custom hooks
- Use the Jellyfin SDK for all server interactions.
- Use the `apiAtom` and `userAtom` from `JellyfinProvider` for authenticated API calls.
- For navigation, use `expo-router`.
## Commit Messages
Use Conventional Commits (https://www.conventionalcommits.org/):
Exemples:
- `feat(player): add Chromecast support`
- `fix(auth): handle expired JWT tokens`
- `chore(deps): update Jellyfin SDK`
- Use [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) (e.g., `feat:`, `fix:`, `chore:`).
- Example: `feat(player): add Chromecast support`
## Special Instructions
- Prioritize cross-platform compatibility (mobile + TV)
- Ensure accessibility for TV remote navigation
- Use existing atoms, hooks, and utilities before creating new ones
- Maintain compatibility with Expo and EAS workflows
- Always verify Bun compatibility when suggesting new dependencies
- When suggesting code, prefer using existing atoms, hooks, and utility functions.
- When adding new features, ensure they are accessible via both mobile and TV navigation if relevant.
- When updating dependencies or scripts, check for compatibility with Expo and EAS.
---
**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:
- name: 🔍 Get PR and Artifacts
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
uses: actions/github-script@v8
with:
script: |
// 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
- name: 📤 Upload APK artifact
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: streamyfin-android-phone-apk-${{ env.DATE_TAG }}
path: |
@@ -147,7 +147,7 @@ jobs:
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
- name: 📤 Upload APK artifact
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: streamyfin-android-tv-apk-${{ env.DATE_TAG }}
path: |
@@ -156,7 +156,7 @@ jobs:
build-ios-phone:
if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
runs-on: macos-26
runs-on: macos-15
name: 🍎 Build iOS IPA (Phone)
permissions:
contents: read
@@ -191,11 +191,6 @@ jobs:
- name: 🛠️ Generate project files
run: bun run prebuild
- name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1
with:
xcode-version: "26.0.1"
- name: 🏗️ Setup EAS
uses: expo/expo-github-action@main
with:
@@ -215,7 +210,7 @@ jobs:
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
- name: 📤 Upload IPA artifact
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: streamyfin-ios-phone-ipa-${{ env.DATE_TAG }}
path: build-*.ipa
@@ -224,7 +219,7 @@ jobs:
# Disabled for now - uncomment when ready to build iOS TV
# build-ios-tv:
# if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
# runs-on: macos-26
# runs-on: macos-15
# name: 🍎 Build iOS IPA (TV)
# permissions:
# contents: read
@@ -259,11 +254,6 @@ jobs:
# - name: 🛠️ Generate project files
# run: bun run prebuild:tv
#
# - name: 🔧 Setup Xcode
# uses: maxim-lobanov/setup-xcode@v1
# with:
# xcode-version: '26.0.1'
#
# - name: 🏗️ Setup EAS
# uses: expo/expo-github-action@main
# with:

View File

@@ -25,15 +25,19 @@ jobs:
steps:
- name: 📥 Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
show-progress: false
fetch-depth: 0
- name: 🏁 Initialize CodeQL
uses: github/codeql-action/init@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
uses: github/codeql-action/init@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5
with:
languages: ${{ matrix.language }}
queries: +security-extended,security-and-quality
- name: 🛠️ Autobuild
uses: github/codeql-action/autobuild@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
uses: github/codeql-action/autobuild@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5
- name: 🧪 Perform CodeQL Analysis
uses: github/codeql-action/analyze@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
uses: github/codeql-action/analyze@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5

View File

@@ -1,50 +1,34 @@
name: 🌐 Translation Sync
name: Crowdin Action
on:
push:
branches: [develop]
paths:
- "translations/**"
- "crowdin.yml"
- "i18n.ts"
- ".github/workflows/crowdin.yml"
# Run weekly to pull new translations
schedule:
- cron: "0 2 * * 1" # Every Monday at 2 AM UTC
workflow_dispatch:
permissions:
contents: write
pull-requests: write
branches: [ main ]
jobs:
sync-translations:
synchronize-with-crowdin:
runs-on: ubuntu-latest
steps:
- name: 📥 Checkout Repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
fetch-depth: 0
- name: Checkout
uses: actions/checkout@v4
- name: 🌐 Sync Translations with Crowdin
uses: crowdin/github-action@08713f00a50548bfe39b37e8f44afb53e7a802d4 # v2.12.0
- name: crowdin action
uses: crowdin/github-action@v2
with:
upload_sources: true
upload_translations: true
download_translations: true
localization_branch_name: I10n_crowdin_translations
localization_branch_name: l10n_crowdin_translations
create_pull_request: true
pull_request_title: "feat: New Crowdin Translations"
pull_request_body: "New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)"
pull_request_base_branch_name: "develop"
pull_request_labels: "🌐 translation"
# Quality control options
skip_untranslated_strings: true
export_only_approved: false
# Commit customization
commit_message: "feat(i18n): update translations from Crowdin"
pull_request_title: 'feat: New Crowdin Translations'
pull_request_body: 'New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)'
pull_request_base_branch_name: 'develop'
env:
# A classic GitHub Personal Access Token with the 'repo' scope selected (the user should have write access to the repository).
GITHUB_TOKEN: ${{ secrets.CROWDIN_GITHUB_TOKEN }}
# A numeric ID, found at https://crowdin.com/project/<projectName>/tools/api
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
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
- name: Dependency Review
uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2
uses: actions/dependency-review-action@56339e523c0409420f6c2c9a2f4292bbb3c07dd3 # v4.8.0
with:
fail-on-severity: high
base-ref: ${{ github.event.pull_request.base.sha || 'develop' }}
@@ -107,7 +107,7 @@ jobs:
fetch-depth: 0
- name: "🟢 Setup Node.js"
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: '24.x'

View File

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

View File

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

View File

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

70
.gitignore vendored
View File

@@ -1,16 +1,27 @@
# Dependencies and Package Managers
node_modules/
bun.lock
bun.lockb
package-lock.json
# Expo and React Native Build Artifacts
.expo/
dist/
npm-debug.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
*.orig.*
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
/android
/iostv
@@ -18,51 +29,22 @@ web-build/
/androidmobile
/androidtv
# Module-specific Builds
modules/vlc-player/android/build
modules/player/android
modules/hls-downloader/android/build
# Generated Applications
Streamyfin.app
pc-api-7079014811501811218-719-3b9f15aeccf8.json
credentials.json
*.apk
*.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/
.idea/
.ruby-lsp
.cursor/
.claude/
# Environment and Configuration
expo-env.d.ts
.continuerc.json
modules/hls-downloader/android/build
streamyfin-4fec1-firebase-adminsdk.json
.env
.env.local
# Secrets and Credentials
pc-api-7079014811501811218-719-3b9f15aeccf8.json
credentials.json
streamyfin-4fec1-firebase-adminsdk.json
# Version and Backup Files
*.aab
/version-backup-*
bun.lockb
modules/background-downloader/android/build/*

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
]
}

35
.vscode/settings.json vendored
View File

@@ -1,25 +1,24 @@
{
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
"[javascript]": {
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true
},
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true
},
"[javascript]": {
"editor.defaultFormatter": "biomejs.biome"
"prettier.printWidth": 120,
"[swift]": {
"editor.defaultFormatter": "sswg.swift-lang"
},
"editor.formatOnSave": true,
"editor.defaultFormatter": "biomejs.biome",
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true
},
"[javascriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[json]": {
"editor.defaultFormatter": "biomejs.biome"
},
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true,
"editor.formatOnSaveMode": "file"
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true
}
}

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%">
</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">
<img src="./assets/images/screenshots/screenshot1.png" width="20%">
<img src="./assets/images/screenshots/screenshot1.png" width="22%">
&nbsp;
<img src="./assets/images/screenshots/screenshot3.png" width="20%">
<img src="./assets/images/screenshots/screenshot3.png" width="22%">
&nbsp;
<img src="./assets/images/screenshots/screenshot2.png" width="20%">
<img src="./assets/images/screenshots/screenshot2.png" width="22%">
&nbsp;
<img src="./assets/images/jellyseerr.PNG" width="21%">
<img src="./assets/images/jellyseerr.PNG" width="23%">
</p>
## 🌟 Features
- 🚀 **Skip Intro / Credits Support**: Lets you quickly skip intros and credits during playback
- 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking
- 📥 **Download media**: Save your media locally and watch it offline
- ⚙️ **Settings management**: Manage app configurations for all users through our plugin
- 🤖 **Seerr (formerly Jellyseerr) integration**: Request media directly in the app
- 👁️ **Sessions view:** View all active sessions currently streaming on your server
- 📡 **Chromecast**: Cast your media to any Chromecast-enabled device
- 🚀 **Skip Intro / Credits Support**
- 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking.
- 📥 **Download media** (Experimental): Save your media locally and watch it offline.
- 📡 **Chromecast** (Experimental): Cast your media to any Chromecast-enabled device.
- 📡 **Settings management** (Experimental): Manage app settings for all your users with a JF plugin.
- 🤖 **Jellyseerr integration**: Request media directly in the app.
- 👁️ **Sessions View:** View all active sessions currently streaming on your server.
## 🧪 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 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
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
- Set your preferred default languages
- Configure download method and search provider
- Personalize your home screen
- And much more
- Auto log in to Jellyseerr without the user having to do anything
- Choose the default languages
- Set download method and search provider
- Customize home screen
- And much more...
[Streamyfin Plugin](https://github.com/streamyfin/jellyfin-plugin-streamyfin)
### 📡 Chromecast
Chromecast support is currently under development. Video casting is already available, and we're actively working on adding subtitle support and additional features.
### 🔍 Jellysearch
[Jellysearch](https://gitlab.com/DomiStyle/jellysearch) 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.
## 🛣️ 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.
## 📥 Download Streamyfin
## 📥 Get it now
<div style="display: flex; gap: 5px;">
<a href="https://apps.apple.com/app/streamyfin/id6593660679?l=en-GB"><img height=50 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/></a>
<a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin"><img height=50 alt="Get Streamyfin on Google Play Store" src="./assets/Google_Play_Store_badge_EN.svg"/></a>
<a href="https://github.com/streamyfin/streamyfin/releases/latest"><img height=50 alt="Get Streamyfin on Github" src="./assets/Download_on_Github_.png"/></a>
<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>
</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
### ⚙️ Prerequisites
### Prerequisites
- Your device is on the same network as the Jellyfin server (for local connections)
- Your Jellyfin server is up and running with remote access enabled if you plan to connect from outside your local network
- Your server version is up to date (older versions may cause compatibility issues)
- You have a valid Jellyfin user account with access to the media libraries you want to view
- If using features such as **downloads** or **Seerr integration**, confirm the required plugins are installed and configured on your Jellyfin server
- Ensure you have an active Jellyfin server.
- Make sure your device is connected to the same network as your Jellyfin server.
## 🙌 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
[![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
### 👨‍💻 Development info
1. Use node `>20`
2. Install dependencies `bun i && bun run submodule-reload`
3. Make sure you have xcode and/or android studio installed. (follow the guides for expo: https://docs.expo.dev/workflow/android-studio-emulator/)
4. Install BiomeJS extension in VSCode/Your IDE (https://biomejs.dev/)
4. run `npm run prebuild`
5. Create an expo dev build by running `npm run ios` or `npm run android`. This will open a simulator on your computer and run the app
5. Create an expo dev build by running `npm run ios` or `npm run android`. This will open a simulator on your computer and run the app.
For the TV version suffix the npm commands with `:tv`.
`npm run prebuild:tv`
`npm run ios:tv or npm run android:tv`
## 👋 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)
- **GitHub Issues:** [Report bugs or request features](https://github.com/streamyfin/streamyfin/issues)
- **Email:** [developer@streamyfin.app](mailto:developer@streamyfin.app)
- 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
## 🌐 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
1. Q: Why can't I see my libraries in Streamyfin?
A: Make sure your server is running one of the latest versions and that you have at least one library that isn't audio only
2. Q: Why can't I see my music library?
A: We don't currently support music and are unlikely to support music in the near future
1. Q: Why can't I see my libraries in Streamyfin?
A: Make sure your server is running one of the latest versions and that you have at least one library that isn't audio only.
2. Q: Why can't I see my music library?
A: We don't currently support music and are unlikely to support music in the near future.
## 📝 Credits
Streamyfin is developed by [Fredrik Burmester](https://github.com/fredrikburmester) and is not affiliated with Jellyfin. The app is built 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:
@@ -218,41 +229,21 @@ Thanks to the following contributors for their significant contributions:
</table>
</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.
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
I'd also like to thank the following people and projects for their contributions to Streamyfin:
- [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 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
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
VPS hosting generously provided by [Hexabyte](https://hexabyte.se/en/vps/?currency=eur) and [SweHosting](https://swehosting.se/en/#tj%C3%A4nster)

View File

@@ -7,15 +7,10 @@ module.exports = ({ config }) => {
{ useDefaultExpandedMediaControls: true },
]);
}
// Only override googleServicesFile if env var is set
const androidConfig = {};
if (process.env.GOOGLE_SERVICES_JSON) {
androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON;
}
return {
...(Object.keys(androidConfig).length > 0 && { android: androidConfig }),
android: {
googleServicesFile: process.env.GOOGLE_SERVICES_JSON,
},
...config,
};
};

View File

@@ -2,7 +2,7 @@
"expo": {
"name": "Streamyfin",
"slug": "streamyfin",
"version": "0.48.0",
"version": "0.39.0",
"orientation": "default",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",
@@ -29,12 +29,16 @@
},
"supportsTablet": true,
"bundleIdentifier": "com.fredrikburmester.streamyfin",
"icon": "./assets/images/icon-ios-liquid-glass.icon",
"icon": {
"dark": "./assets/images/icon-ios-plain.png",
"light": "./assets/images/icon-ios-light.png",
"tinted": "./assets/images/icon-ios-tinted.png"
},
"appleTeamId": "MWD5K362T8"
},
"android": {
"jsEngine": "hermes",
"versionCode": 85,
"versionCode": 71,
"adaptiveIcon": {
"foregroundImage": "./assets/images/icon-android-plain.png",
"monochromeImage": "./assets/images/icon-android-themed.png",
@@ -113,6 +117,10 @@
}
}
],
["./plugins/withChangeNativeAndroidTextToWhite.js"],
["./plugins/withAndroidManifest.js"],
["./plugins/withTrustLocalCerts.js"],
["./plugins/withGradleProperties.js"],
[
"expo-splash-screen",
{
@@ -128,12 +136,8 @@
"color": "#9333EA"
}
],
"expo-web-browser",
["./plugins/with-runtime-framework-headers.js"],
["./plugins/withChangeNativeAndroidTextToWhite.js"],
["./plugins/withAndroidManifest.js"],
["./plugins/withTrustLocalCerts.js"],
["./plugins/withGradleProperties.js"]
"./plugins/with-runtime-framework-headers.js",
"react-native-bottom-tabs"
],
"experiments": {
"typedRoutes": true

View File

@@ -1,5 +1,5 @@
import { useCallback, useState } from "react";
import { Platform, RefreshControl, ScrollView, View } from "react-native";
import { RefreshControl, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Favorites } from "@/components/home/Favorites";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
@@ -28,7 +28,7 @@ export default function favorites() {
paddingBottom: 16,
}}
>
<View style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}>
<View className='my-4'>
<Favorites />
</View>
</ScrollView>

View File

@@ -22,6 +22,11 @@ export default function IndexLayout() {
options={{
headerShown: !Platform.isTV,
headerTitle: t("tabs.home"),
headerLeft: () => (
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
@@ -30,6 +35,7 @@ export default function IndexLayout() {
{!Platform.isTV && (
<>
<Chromecast.Chromecast background='transparent' />
{user?.Policy?.IsAdministrator && <SessionsButton />}
<SettingsButton />
</>
@@ -41,16 +47,9 @@ export default function IndexLayout() {
<Stack.Screen
name='downloads/index'
options={{
headerShown: true,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
title: t("home.downloads.downloads_title"),
headerLeft: () => (
<TouchableOpacity
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
@@ -59,17 +58,9 @@ export default function IndexLayout() {
<Stack.Screen
name='downloads/[seriesId]'
options={{
headerShown: true,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
title: t("home.downloads.tvseries"),
headerLeft: () => (
<TouchableOpacity
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
@@ -79,16 +70,8 @@ export default function IndexLayout() {
name='sessions/index'
options={{
title: t("home.sessions.title"),
headerShown: true,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<TouchableOpacity
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
@@ -98,159 +81,41 @@ export default function IndexLayout() {
name='settings'
options={{
title: t("home.settings.settings_title"),
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<TouchableOpacity
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
}}
/>
<Stack.Screen
name='settings/playback-controls/page'
name='settings/marlin-search/page'
options={{
title: t("home.settings.playback_controls.title"),
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
title: "",
headerLeft: () => (
<TouchableOpacity
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
}}
/>
<Stack.Screen
name='settings/audio-subtitles/page'
name='settings/jellyseerr/page'
options={{
title: t("home.settings.audio_subtitles.title"),
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
title: "",
headerLeft: () => (
<TouchableOpacity
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
}}
/>
<Stack.Screen
name='settings/appearance/page'
name='settings/hide-libraries/page'
options={{
title: t("home.settings.appearance.title"),
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
title: "",
headerLeft: () => (
<TouchableOpacity
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
}}
/>
<Stack.Screen
name='settings/appearance/hide-libraries/page'
options={{
title: t("home.settings.other.hide_libraries"),
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<TouchableOpacity
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
}}
/>
<Stack.Screen
name='settings/plugins/page'
options={{
title: t("home.settings.plugins.plugins_title"),
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<TouchableOpacity
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
}}
/>
<Stack.Screen
name='settings/plugins/marlin-search/page'
options={{
title: "Marlin Search",
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<TouchableOpacity
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
}}
/>
<Stack.Screen
name='settings/plugins/jellyseerr/page'
options={{
title: "Jellyseerr",
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<TouchableOpacity
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
}}
/>
<Stack.Screen
name='settings/intro/page'
options={{
title: t("home.settings.intro.title"),
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<TouchableOpacity
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
@@ -259,16 +124,9 @@ export default function IndexLayout() {
<Stack.Screen
name='settings/logs/page'
options={{
title: t("home.settings.logs.logs_title"),
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
title: "",
headerLeft: () => (
<TouchableOpacity
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),

View File

@@ -1,10 +1,8 @@
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { FlashList } from "@shopify/flash-list";
import { router, useLocalSearchParams, useNavigation } from "expo-router";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Alert, Platform, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
import { Text } from "@/components/common/Text";
import { EpisodeCard } from "@/components/downloads/EpisodeCard";
import {
@@ -26,7 +24,6 @@ export default function page() {
{},
);
const { downloadedItems, deleteItems } = useDownload();
const insets = useSafeAreaInsets();
const series = useMemo(() => {
try {
@@ -34,8 +31,7 @@ export default function page() {
downloadedItems
?.filter((f) => f.item.SeriesId === seriesId)
?.sort(
(a, b) =>
(a.item.ParentIndexNumber ?? 0) - (b.item.ParentIndexNumber ?? 0),
(a, b) => a?.item.ParentIndexNumber! - b.item.ParentIndexNumber!,
) || []
);
} catch {
@@ -74,9 +70,8 @@ export default function page() {
}, [seasonGroups]);
const seasonIndex =
seasonIndexState[series?.[0]?.item?.ParentId ?? ""] ??
episodeSeasonIndex ??
series?.[0]?.item?.ParentIndexNumber ??
seasonIndexState[series?.[0]?.item?.ParentId ?? ""] ||
episodeSeasonIndex ||
"";
const groupBySeason = useMemo<BaseItemDto[]>(() => {
@@ -85,9 +80,9 @@ export default function page() {
const initialSeasonIndex = useMemo(
() =>
groupBySeason?.[0]?.ParentIndexNumber ??
Object.values(groupBySeason)?.[0]?.ParentIndexNumber ??
series?.[0]?.item?.ParentIndexNumber,
[groupBySeason, series],
[groupBySeason],
);
useEffect(() => {
@@ -115,67 +110,46 @@ export default function page() {
onPress: () =>
deleteItems(
groupBySeason
.map((item) => item.Id)
.map((episode) => episode.Id!)
.filter((id) => id !== undefined),
),
style: "destructive",
},
],
);
}, [groupBySeason, deleteItems]);
const ListHeaderComponent = useCallback(() => {
if (series.length === 0) return null;
return (
<View className='flex flex-row items-center justify-start pb-2'>
<SeasonDropdown
item={series[0].item}
seasons={uniqueSeasons}
state={seasonIndexState}
initialSeasonIndex={initialSeasonIndex!}
onSelect={(season) => {
setSeasonIndexState((prev) => ({
...prev,
[series[0].item.ParentId ?? ""]: season.ParentIndexNumber,
}));
}}
/>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center ml-2'>
<Text className='text-xs font-bold'>{groupBySeason.length}</Text>
</View>
<View className='bg-neutral-800/80 rounded-full h-9 w-9 flex items-center justify-center ml-auto'>
<TouchableOpacity onPress={deleteSeries}>
<Ionicons name='trash' size={20} color='white' />
</TouchableOpacity>
</View>
</View>
);
}, [
series,
uniqueSeasons,
seasonIndexState,
initialSeasonIndex,
groupBySeason,
deleteSeries,
]);
}, [groupBySeason]);
return (
<View className='flex-1'>
<FlashList
key={seasonIndex}
data={groupBySeason}
renderItem={({ item }) => <EpisodeCard item={item} />}
keyExtractor={(item, index) => item.Id ?? `episode-${index}`}
ListHeaderComponent={ListHeaderComponent}
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingHorizontal: 16,
paddingLeft: insets.left + 16,
paddingRight: insets.right + 16,
paddingTop: Platform.OS === "android" ? 10 : 8,
}}
/>
{series.length > 0 && (
<View className='flex flex-row items-center justify-start my-2 px-4'>
<SeasonDropdown
item={series[0].item}
seasons={uniqueSeasons}
state={seasonIndexState}
initialSeasonIndex={initialSeasonIndex!}
onSelect={(season) => {
setSeasonIndexState((prev) => ({
...prev,
[series[0].item.ParentId ?? ""]: season.ParentIndexNumber,
}));
}}
/>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center ml-2'>
<Text className='text-xs font-bold'>{groupBySeason.length}</Text>
</View>
<View className='bg-neutral-800/80 rounded-full h-9 w-9 flex items-center justify-center ml-auto'>
<TouchableOpacity onPress={deleteSeries}>
<Ionicons name='trash' size={20} color='white' />
</TouchableOpacity>
</View>
</View>
)}
<ScrollView key={seasonIndex} style={{ paddingHorizontal: 16 }}>
{groupBySeason.map((episode, index) => (
<EpisodeCard key={index} item={episode} />
))}
</ScrollView>
</View>
);
}

View File

@@ -1,17 +1,17 @@
import { BottomSheetModal } from "@gorhom/bottom-sheet";
import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import { useNavigation, useRouter } from "expo-router";
import { useAtom } from "jotai";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Alert,
Platform,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
import { toast } from "sonner-native";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import ActiveDownloads from "@/components/downloads/ActiveDownloads";
@@ -26,15 +26,14 @@ import { writeToLog } from "@/utils/log";
export default function page() {
const navigation = useNavigation();
const { t } = useTranslation();
const [_queue, _setQueue] = useAtom(queueAtom);
const { downloadedItems, deleteFileByType, deleteAllFiles } = useDownload();
const [queue, setQueue] = useAtom(queueAtom);
const { removeProcess, downloadedItems, deleteFileByType, deleteAllFiles } =
useDownload();
const router = useRouter();
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const [showMigration, setShowMigration] = useState(false);
const _insets = useSafeAreaInsets();
const migration_20241124 = () => {
Alert.alert(
t("home.downloads.new_app_version_requires_re_download"),
@@ -59,7 +58,7 @@ export default function page() {
);
};
const downloadedFiles = useMemo(() => downloadedItems, [downloadedItems]);
const downloadedFiles = downloadedItems;
const movies = useMemo(() => {
try {
@@ -119,7 +118,7 @@ export default function page() {
}
}, [showMigration]);
const _deleteMovies = () =>
const deleteMovies = () =>
deleteFileByType("Movie")
.then(() =>
toast.success(
@@ -130,7 +129,7 @@ export default function page() {
writeToLog("ERROR", reason);
toast.error(t("home.downloads.toasts.failed_to_delete_all_movies"));
});
const _deleteShows = () =>
const deleteShows = () =>
deleteFileByType("Episode")
.then(() =>
toast.success(
@@ -141,12 +140,12 @@ export default function page() {
writeToLog("ERROR", reason);
toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
});
const _deleteOtherMedia = () =>
const deleteOtherMedia = () =>
Promise.all(
otherMedia
.filter((item) => item.item.Type)
.map((item) =>
deleteFileByType(item.item.Type!)
otherMedia.map(
(item) =>
item.item.Type &&
deleteFileByType(item.item.Type)
.then(() =>
toast.success(
t("home.downloads.toasts.deleted_media_successfully", {
@@ -162,25 +161,26 @@ export default function page() {
}),
);
}),
),
),
);
const deleteAllMedia = async () =>
await Promise.all([deleteMovies(), deleteShows(), deleteOtherMedia()]);
return (
<ScrollView
showsVerticalScrollIndicator={false}
contentInsetAdjustmentBehavior='automatic'
>
<View style={{ paddingTop: Platform.OS === "android" ? 17 : 0 }}>
<View className='mb-4 flex flex-col space-y-4 px-4'>
{/* Queue card - hidden */}
{/* <View className='bg-neutral-900 p-4 rounded-2xl'>
<>
<View style={{ flex: 1 }}>
<ScrollView showsVerticalScrollIndicator={false} className='flex-1'>
<View className='py-4'>
<View className='mb-4 flex flex-col gap-y-4 px-4'>
<View className='bg-neutral-900 p-4 rounded-2xl'>
<Text className='text-lg font-bold'>
{t("home.downloads.queue")}
</Text>
<Text className='text-xs opacity-70 text-red-600'>
{t("home.downloads.queue_hint")}
</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) => (
<TouchableOpacity
onPress={() =>
@@ -215,96 +215,139 @@ export default function page() {
{t("home.downloads.no_items_in_queue")}
</Text>
)}
</View> */}
<ActiveDownloads />
</View>
{movies.length > 0 && (
<View className='mb-4'>
<View className='flex flex-row items-center justify-between mb-2 px-4'>
<Text className='text-lg font-bold'>
{t("home.downloads.movies")}
</Text>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
<Text className='text-xs font-bold'>{movies?.length}</Text>
</View>
<ActiveDownloads />
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className='px-4 flex flex-row'>
{movies?.map((item) => (
<TouchableItemRouter
item={item.item}
isOffline
key={item.item.Id}
>
<MovieCard item={item.item} />
</TouchableItemRouter>
))}
{movies.length > 0 && (
<View className='mb-4'>
<View className='flex flex-row items-center justify-between mb-2 px-4'>
<Text className='text-lg font-bold'>
{t("home.downloads.movies")}
</Text>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
<Text className='text-xs font-bold'>{movies?.length}</Text>
</View>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className='px-4 flex flex-row'>
{movies?.map((item) => (
<TouchableItemRouter
item={item.item}
isOffline
key={item.item.Id}
>
<MovieCard item={item.item} />
</TouchableItemRouter>
))}
</View>
</ScrollView>
</View>
</ScrollView>
</View>
)}
{groupedBySeries.length > 0 && (
<View className='mb-4'>
<View className='flex flex-row items-center justify-between mb-2 px-4'>
<Text className='text-lg font-bold'>
{t("home.downloads.tvseries")}
</Text>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
<Text className='text-xs font-bold'>
{groupedBySeries?.length}
)}
{groupedBySeries.length > 0 && (
<View className='mb-4'>
<View className='flex flex-row items-center justify-between mb-2 px-4'>
<Text className='text-lg font-bold'>
{t("home.downloads.tvseries")}
</Text>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
<Text className='text-xs font-bold'>
{groupedBySeries?.length}
</Text>
</View>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className='px-4 flex flex-row'>
{groupedBySeries?.map((items) => (
<View
className='mb-2 last:mb-0'
key={items[0].item.SeriesId}
>
<SeriesCard
items={items.map((i) => i.item)}
key={items[0].item.SeriesId}
/>
</View>
))}
</View>
</ScrollView>
</View>
)}
{otherMedia.length > 0 && (
<View className='mb-4'>
<View className='flex flex-row items-center justify-between mb-2 px-4'>
<Text className='text-lg font-bold'>
{t("home.downloads.other_media")}
</Text>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
<Text className='text-xs font-bold'>
{otherMedia?.length}
</Text>
</View>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className='px-4 flex flex-row'>
{otherMedia?.map((item) => (
<TouchableItemRouter
item={item.item}
isOffline
key={item.item.Id}
>
<MovieCard item={item.item} />
</TouchableItemRouter>
))}
</View>
</ScrollView>
</View>
)}
{downloadedFiles?.length === 0 && (
<View className='flex px-4'>
<Text className='opacity-50'>
{t("home.downloads.no_downloaded_items")}
</Text>
</View>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className='px-4 flex flex-row'>
{groupedBySeries?.map((items) => (
<View className='mb-2 last:mb-0' key={items[0].item.SeriesId}>
<SeriesCard
items={items.map((i) => i.item)}
key={items[0].item.SeriesId}
/>
</View>
))}
</View>
</ScrollView>
)}
</View>
)}
{otherMedia.length > 0 && (
<View className='mb-4'>
<View className='flex flex-row items-center justify-between mb-2 px-4'>
<Text className='text-lg font-bold'>
{t("home.downloads.other_media")}
</Text>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
<Text className='text-xs font-bold'>{otherMedia?.length}</Text>
</View>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className='px-4 flex flex-row'>
{otherMedia?.map((item) => (
<TouchableItemRouter
item={item.item}
isOffline
key={item.item.Id}
>
<MovieCard item={item.item} />
</TouchableItemRouter>
))}
</View>
</ScrollView>
</View>
)}
{downloadedFiles?.length === 0 && (
<View className='flex px-4'>
<Text className='opacity-50'>
{t("home.downloads.no_downloaded_items")}
</Text>
</View>
)}
</ScrollView>
</View>
</ScrollView>
<BottomSheetModal
ref={bottomSheetModalRef}
enableDynamicSizing
handleIndicatorStyle={{
backgroundColor: "white",
}}
backgroundStyle={{
backgroundColor: "#171717",
}}
backdropComponent={(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
)}
>
<BottomSheetView>
<View className='p-4 space-y-4 mb-4'>
<Button color='purple' onPress={deleteMovies}>
{t("home.downloads.delete_all_movies_button")}
</Button>
<Button color='purple' onPress={deleteShows}>
{t("home.downloads.delete_all_tvseries_button")}
</Button>
{otherMedia.length > 0 && (
<Button color='purple' onPress={deleteOtherMedia}>
{t("home.downloads.delete_all_other_media_button")}
</Button>
)}
<Button color='red' onPress={deleteAllMedia}>
{t("home.downloads.delete_all_button")}
</Button>
</View>
</BottomSheetView>
</BottomSheetModal>
</>
);
}

View File

@@ -1,16 +1,5 @@
import { useSettings } from "@/utils/atoms/settings";
import { Home } from "../../../../components/home/Home";
import { HomeWithCarousel } from "../../../../components/home/HomeWithCarousel";
import { HomeIndex } from "@/components/home/HomeIndex";
const Index = () => {
const { settings } = useSettings();
const showLargeHomeCarousel = settings.showLargeHomeCarousel ?? false;
if (showLargeHomeCarousel) {
return <HomeWithCarousel />;
}
return <Home />;
};
export default Index;
export default function page() {
return <HomeIndex />;
}

View File

@@ -1,9 +1,11 @@
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { HardwareAccelerationType } from "@jellyfin/sdk/lib/generated-client";
import {
HardwareAccelerationType,
type SessionInfoDto,
} from "@jellyfin/sdk/lib/generated-client";
import {
GeneralCommandType,
PlaystateCommand,
SessionInfoDto,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
import { FlashList } from "@shopify/flash-list";
@@ -11,7 +13,7 @@ import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native";
import { TouchableOpacity, View } from "react-native";
import { Badge } from "@/components/Badge";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
@@ -47,13 +49,14 @@ export default function page() {
<FlashList
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingTop: Platform.OS === "android" ? 17 : 0,
paddingTop: 17,
paddingHorizontal: 17,
paddingBottom: 150,
}}
data={sessions}
renderItem={({ item }) => <SessionCard session={item} />}
keyExtractor={(item) => item.Id || ""}
estimatedItemSize={200}
/>
);
}
@@ -255,7 +258,7 @@ const SessionCard = ({ session }: SessionCardProps) => {
</View>
{/* 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
onPress={handlePrevious}
disabled={isControlLoading[PlaystateCommand.PreviousTrack]}

View File

@@ -8,16 +8,34 @@ import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
import { AudioToggles } from "@/components/settings/AudioToggles";
import { ChromecastSettings } from "@/components/settings/ChromecastSettings";
import DownloadSettings from "@/components/settings/DownloadSettings";
import { GestureControls } from "@/components/settings/GestureControls";
import { MediaProvider } from "@/components/settings/MediaContext";
import { MediaToggles } from "@/components/settings/MediaToggles";
import { OtherSettings } from "@/components/settings/OtherSettings";
import { PluginSettings } from "@/components/settings/PluginSettings";
import { QuickConnect } from "@/components/settings/QuickConnect";
import { StorageSettings } from "@/components/settings/StorageSettings";
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
import { UserInfo } from "@/components/settings/UserInfo";
import { useHaptic } from "@/hooks/useHaptic";
import { useJellyfin, userAtom } from "@/providers/JellyfinProvider";
import { clearLogs } from "@/utils/log";
import { storage } from "@/utils/mmkv";
export default function settings() {
const router = useRouter();
const insets = useSafeAreaInsets();
const [_user] = useAtom(userAtom);
const { logout } = useJellyfin();
const successHapticFeedback = useHaptic("success");
const onClearLogsClicked = async () => {
clearLogs();
successHapticFeedback();
};
const navigation = useNavigation();
useEffect(() => {
@@ -38,58 +56,61 @@ export default function settings() {
return (
<ScrollView
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<View
className='p-4 flex flex-col'
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
>
<View className='mb-4'>
<UserInfo />
</View>
<View className='p-4 flex flex-col gap-y-4'>
<UserInfo />
<QuickConnect className='mb-4' />
<View className='mb-4'>
<AppLanguageSelector />
</View>
<MediaProvider>
<MediaToggles className='mb-4' />
<GestureControls className='mb-4' />
<AudioToggles className='mb-4' />
<SubtitleToggles className='mb-4' />
</MediaProvider>
<OtherSettings />
{!Platform.isTV && <DownloadSettings />}
<PluginSettings />
<AppLanguageSelector />
{!Platform.isTV && <ChromecastSettings />}
<ListGroup title={"Intro"}>
<ListItem
onPress={() => {
router.push("/intro/page");
}}
title={t("home.settings.intro.show_intro")}
/>
<ListItem
textColor='red'
onPress={() => {
storage.set("hasShownIntro", false);
}}
title={t("home.settings.intro.reset_intro")}
/>
</ListGroup>
<View className='mb-4'>
<ListGroup title={t("home.settings.categories.title")}>
<ListItem
onPress={() => router.push("/settings/playback-controls/page")}
showArrow
title={t("home.settings.playback_controls.title")}
/>
<ListItem
onPress={() => router.push("/settings/audio-subtitles/page")}
showArrow
title={t("home.settings.audio_subtitles.title")}
/>
<ListItem
onPress={() => router.push("/settings/appearance/page")}
showArrow
title={t("home.settings.appearance.title")}
/>
<ListItem
onPress={() => router.push("/settings/plugins/page")}
showArrow
title={t("home.settings.plugins.plugins_title")}
/>
<ListItem
onPress={() => router.push("/settings/intro/page")}
showArrow
title={t("home.settings.intro.title")}
/>
<ListGroup title={t("home.settings.logs.logs_title")}>
<ListItem
onPress={() => router.push("/settings/logs/page")}
showArrow
title={t("home.settings.logs.logs_title")}
/>
<ListItem
textColor='red'
onPress={onClearLogsClicked}
title={t("home.settings.logs.delete_all_logs")}
/>
</ListGroup>
</View>

View File

@@ -1,79 +0,0 @@
import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { useTranslation } from "react-i18next";
import { ScrollView, Switch, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import DisabledSetting from "@/components/settings/DisabledSetting";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
export default function page() {
const { settings, updateSettings, pluginSettings } = useSettings();
const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom);
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const { data, isLoading } = useQuery({
queryKey: ["user-views", user?.Id],
queryFn: async () => {
const response = await getUserViewsApi(api!).getUserViews({
userId: user?.Id,
});
return response.data.Items || null;
},
});
if (!settings) return null;
if (isLoading)
return (
<View className='mt-4'>
<Loader />
</View>
);
return (
<ScrollView
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<DisabledSetting
disabled={pluginSettings?.hiddenLibraries?.locked === true}
className='px-4'
>
<ListGroup title={t("home.settings.other.hide_libraries")}>
{data?.map((view) => (
<ListItem key={view.Id} title={view.Name} onPress={() => {}}>
<Switch
value={settings.hiddenLibraries?.includes(view.Id!) || false}
onValueChange={(value) => {
updateSettings({
hiddenLibraries: value
? [...(settings.hiddenLibraries || []), view.Id!]
: settings.hiddenLibraries?.filter(
(id) => id !== view.Id,
),
});
}}
/>
</ListItem>
))}
</ListGroup>
<Text className='px-4 text-xs text-neutral-500 mt-1'>
{t("home.settings.other.select_liraries_you_want_to_hide")}
</Text>
</DisabledSetting>
</ScrollView>
);
}

View File

@@ -1,25 +0,0 @@
import { Platform, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { AppearanceSettings } from "@/components/settings/AppearanceSettings";
export default function AppearancePage() {
const insets = useSafeAreaInsets();
return (
<ScrollView
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<View
className='p-4 flex flex-col'
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
>
<AppearanceSettings />
<View className='h-24' />
</View>
</ScrollView>
);
}

View File

@@ -1,29 +0,0 @@
import { Platform, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { AudioToggles } from "@/components/settings/AudioToggles";
import { MediaProvider } from "@/components/settings/MediaContext";
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
export default function AudioSubtitlesPage() {
const insets = useSafeAreaInsets();
return (
<ScrollView
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<View
className='p-4 flex flex-col'
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
>
<MediaProvider>
<AudioToggles className='mb-4' />
<SubtitleToggles className='mb-4' />
</MediaProvider>
</View>
</ScrollView>
);
}

View File

@@ -1,45 +0,0 @@
import { useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { Platform, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import { storage } from "@/utils/mmkv";
export default function IntroPage() {
const router = useRouter();
const insets = useSafeAreaInsets();
const { t } = useTranslation();
return (
<ScrollView
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<View
className='p-4 flex flex-col'
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
>
<ListGroup title={t("home.settings.intro.title")}>
<ListItem
onPress={() => {
router.push("/intro/page");
}}
title={t("home.settings.intro.show_intro")}
/>
<ListItem
textColor='red'
onPress={() => {
storage.set("hasShownIntro", false);
}}
title={t("home.settings.intro.reset_intro")}
/>
</ListGroup>
<View className='h-24' />
</View>
</ScrollView>
);
}

View File

@@ -0,0 +1,16 @@
import DisabledSetting from "@/components/settings/DisabledSetting";
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
import { useSettings } from "@/utils/atoms/settings";
export default function page() {
const { pluginSettings } = useSettings();
return (
<DisabledSetting
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
className='p-4'
>
<JellyseerrSettings />
</DisabledSetting>
);
}

View File

@@ -1,21 +1,14 @@
import { File, Paths } from "expo-file-system";
import { useNavigation } from "expo-router";
import type * as SharingType from "expo-sharing";
import * as Sharing from "expo-sharing";
import { useCallback, useEffect, useId, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
import { ScrollView, TouchableOpacity, View } from "react-native";
import Collapsible from "react-native-collapsible";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { FilterButton } from "@/components/filters/FilterButton";
import { Loader } from "@/components/Loader";
import { LogLevel, useLog, writeErrorLog } from "@/utils/log";
// Conditionally import expo-sharing only on non-TV platforms
const Sharing = Platform.isTV
? null
: (require("expo-sharing") as typeof SharingType);
export default function Page() {
const navigation = useNavigation();
const { logs } = useLog();
@@ -39,7 +32,6 @@ export default function Page() {
const _orderId = useId();
const _levelsId = useId();
const insets = useSafeAreaInsets();
const filteredLogs = useMemo(
() =>
@@ -54,30 +46,27 @@ export default function Page() {
// Sharing it as txt while its formatted allows us to share it with many more applications
const share = useCallback(async () => {
if (!Sharing) return;
const logsFile = new File(Paths.document, "logs.txt");
const uri = `${FileSystem.documentDirectory}logs.txt`;
setLoading(true);
try {
logsFile.write(JSON.stringify(filteredLogs));
await Sharing.shareAsync(logsFile.uri, { mimeType: "txt", UTI: "txt" });
} catch (e: any) {
writeErrorLog("Something went wrong attempting to export", e);
} finally {
setLoading(false);
}
}, [filteredLogs, Sharing]);
FileSystem.writeAsStringAsync(uri, JSON.stringify(filteredLogs))
.then(() => {
setLoading(false);
Sharing.shareAsync(uri, { mimeType: "txt", UTI: "txt" });
})
.catch((e) =>
writeErrorLog("Something went wrong attempting to export", e),
)
.finally(() => setLoading(false));
}, [filteredLogs]);
useEffect(() => {
if (Platform.isTV) return;
navigation.setOptions({
headerRight: () =>
loading ? (
<Loader />
) : (
<TouchableOpacity onPress={share} className='px-2'>
<TouchableOpacity onPress={share}>
<Text>{t("home.settings.logs.export_logs")}</Text>
</TouchableOpacity>
),
@@ -85,12 +74,7 @@ export default function Page() {
}, [share, loading]);
return (
<View
className='flex-1'
style={{
paddingTop: insets.top + 48,
}}
>
<>
<View className='flex flex-row justify-end py-2 px-4 space-x-2'>
<FilterButton
id={orderFilterId}
@@ -172,6 +156,6 @@ export default function Page() {
)}
</View>
</ScrollView>
</View>
</>
);
}

View File

@@ -0,0 +1,122 @@
import { useQueryClient } from "@tanstack/react-query";
import { useNavigation } from "expo-router";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Linking,
Switch,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import { toast } from "sonner-native";
import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import DisabledSetting from "@/components/settings/DisabledSetting";
import { useSettings } from "@/utils/atoms/settings";
export default function page() {
const navigation = useNavigation();
const { t } = useTranslation();
const { settings, updateSettings, pluginSettings } = useSettings();
const queryClient = useQueryClient();
const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
const onSave = (val: string) => {
updateSettings({
marlinServerUrl: !val.endsWith("/") ? val : val.slice(0, -1),
});
toast.success(t("home.settings.plugins.marlin_search.toasts.saved"));
};
const handleOpenLink = () => {
Linking.openURL("https://github.com/fredrikburmester/marlin-search");
};
const disabled = useMemo(() => {
return (
pluginSettings?.searchEngine?.locked === true &&
pluginSettings?.marlinServerUrl?.locked === true
);
}, [pluginSettings]);
useEffect(() => {
if (!pluginSettings?.marlinServerUrl?.locked) {
navigation.setOptions({
headerRight: () => (
<TouchableOpacity onPress={() => onSave(value)}>
<Text className='text-blue-500'>
{t("home.settings.plugins.marlin_search.save_button")}
</Text>
</TouchableOpacity>
),
});
}
}, [navigation, value]);
if (!settings) return null;
return (
<DisabledSetting disabled={disabled} className='px-4'>
<ListGroup>
<DisabledSetting
disabled={pluginSettings?.searchEngine?.locked === true}
showText={!pluginSettings?.marlinServerUrl?.locked}
>
<ListItem
title={t(
"home.settings.plugins.marlin_search.enable_marlin_search",
)}
onPress={() => {
updateSettings({ searchEngine: "Jellyfin" });
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
>
<Switch
value={settings.searchEngine === "Marlin"}
onValueChange={(value) => {
updateSettings({ searchEngine: value ? "Marlin" : "Jellyfin" });
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
/>
</ListItem>
</DisabledSetting>
</ListGroup>
<DisabledSetting
disabled={pluginSettings?.marlinServerUrl?.locked === true}
showText={!pluginSettings?.searchEngine?.locked}
className='mt-2 flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4'
>
<View className={"flex flex-row items-center bg-neutral-900 h-11 pr-4"}>
<Text className='mr-4'>
{t("home.settings.plugins.marlin_search.url")}
</Text>
<TextInput
editable={settings.searchEngine === "Marlin"}
className='text-white'
placeholder={t(
"home.settings.plugins.marlin_search.server_url_placeholder",
)}
value={value}
keyboardType='url'
returnKeyType='done'
autoCapitalize='none'
textContentType='URL'
onChangeText={(text) => setValue(text)}
/>
</View>
</DisabledSetting>
<Text className='px-4 text-xs text-neutral-500 mt-1'>
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
<Text className='text-blue-500' onPress={handleOpenLink}>
{t("home.settings.plugins.marlin_search.read_more_about_marlin")}
</Text>
</Text>
</DisabledSetting>
);
}

View File

@@ -1,35 +0,0 @@
import { Platform, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { GestureControls } from "@/components/settings/GestureControls";
import { MediaProvider } from "@/components/settings/MediaContext";
import { MediaToggles } from "@/components/settings/MediaToggles";
import { PlaybackControlsSettings } from "@/components/settings/PlaybackControlsSettings";
import { ChromecastSettings } from "../../../../../../components/settings/ChromecastSettings";
export default function PlaybackControlsPage() {
const insets = useSafeAreaInsets();
return (
<ScrollView
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<View
className='p-4 flex flex-col'
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
>
<View className='mb-4'>
<MediaProvider>
<MediaToggles className='mb-4' />
<GestureControls className='mb-4' />
<PlaybackControlsSettings />
</MediaProvider>
</View>
{!Platform.isTV && <ChromecastSettings />}
</View>
</ScrollView>
);
}

View File

@@ -1,27 +0,0 @@
import { ScrollView } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import DisabledSetting from "@/components/settings/DisabledSetting";
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
import { useSettings } from "@/utils/atoms/settings";
export default function page() {
const { pluginSettings } = useSettings();
const insets = useSafeAreaInsets();
return (
<ScrollView
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<DisabledSetting
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
className='px-4'
>
<JellyseerrSettings />
</DisabledSetting>
</ScrollView>
);
}

View File

@@ -1,138 +0,0 @@
import { useQueryClient } from "@tanstack/react-query";
import { useNavigation } from "expo-router";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Linking,
ScrollView,
Switch,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native";
import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import DisabledSetting from "@/components/settings/DisabledSetting";
import { useSettings } from "@/utils/atoms/settings";
export default function page() {
const navigation = useNavigation();
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const { settings, updateSettings, pluginSettings } = useSettings();
const queryClient = useQueryClient();
const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
const onSave = (val: string) => {
updateSettings({
marlinServerUrl: !val.endsWith("/") ? val : val.slice(0, -1),
});
toast.success(t("home.settings.plugins.marlin_search.toasts.saved"));
};
const handleOpenLink = () => {
Linking.openURL("https://github.com/fredrikburmester/marlin-search");
};
const disabled = useMemo(() => {
return (
pluginSettings?.searchEngine?.locked === true &&
pluginSettings?.marlinServerUrl?.locked === true
);
}, [pluginSettings]);
useEffect(() => {
if (!pluginSettings?.marlinServerUrl?.locked) {
navigation.setOptions({
headerRight: () => (
<TouchableOpacity onPress={() => onSave(value)} className='px-2'>
<Text className='text-blue-500'>
{t("home.settings.plugins.marlin_search.save_button")}
</Text>
</TouchableOpacity>
),
});
}
}, [navigation, value]);
if (!settings) return null;
return (
<ScrollView
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<DisabledSetting disabled={disabled} className='px-4'>
<ListGroup>
<DisabledSetting
disabled={pluginSettings?.searchEngine?.locked === true}
showText={!pluginSettings?.marlinServerUrl?.locked}
>
<ListItem
title={t(
"home.settings.plugins.marlin_search.enable_marlin_search",
)}
onPress={() => {
updateSettings({ searchEngine: "Jellyfin" });
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
>
<Switch
value={settings.searchEngine === "Marlin"}
onValueChange={(value) => {
updateSettings({
searchEngine: value ? "Marlin" : "Jellyfin",
});
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
/>
</ListItem>
</DisabledSetting>
</ListGroup>
<DisabledSetting
disabled={pluginSettings?.marlinServerUrl?.locked === true}
showText={!pluginSettings?.searchEngine?.locked}
className='mt-2 flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4'
>
<View
className={"flex flex-row items-center bg-neutral-900 h-11 pr-4"}
>
<Text className='mr-4'>
{t("home.settings.plugins.marlin_search.url")}
</Text>
<TextInput
editable={settings.searchEngine === "Marlin"}
className='text-white'
placeholder={t(
"home.settings.plugins.marlin_search.server_url_placeholder",
)}
value={value}
keyboardType='url'
returnKeyType='done'
autoCapitalize='none'
textContentType='URL'
onChangeText={(text) => setValue(text)}
/>
</View>
</DisabledSetting>
<Text className='px-4 text-xs text-neutral-500 mt-1'>
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
<Text className='text-blue-500' onPress={handleOpenLink}>
{t("home.settings.plugins.marlin_search.read_more_about_marlin")}
</Text>
</Text>
</DisabledSetting>
</ScrollView>
);
}

View File

@@ -1,24 +0,0 @@
import { Platform, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { PluginSettings } from "@/components/settings/PluginSettings";
export default function PluginsPage() {
const insets = useSafeAreaInsets();
return (
<ScrollView
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<View
className='px-4 flex flex-col'
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
>
<PluginSettings />
</View>
</ScrollView>
);
}

View File

@@ -16,7 +16,6 @@ import type React from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { FlatList, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { FilterButton } from "@/components/filters/FilterButton";
@@ -205,154 +204,154 @@ const page: React.FC = () => {
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
const _insets = useSafeAreaInsets();
const ListHeaderComponent = useCallback(
() => (
<FlatList
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
display: "flex",
paddingHorizontal: 15,
paddingVertical: 16,
flexDirection: "row",
}}
extraData={[
selectedGenres,
selectedYears,
selectedTags,
sortBy,
sortOrder,
]}
data={[
{
key: "reset",
component: <ResetFiltersButton />,
},
{
key: "genre",
component: (
<FilterButton
className='mr-1'
id={collectionId}
queryKey='genreFilter'
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api,
).getQueryFiltersLegacy({
userId: user?.Id,
parentId: collectionId,
});
return response.data.Genres || [];
}}
set={setSelectedGenres}
values={selectedGenres}
title={t("library.filters.genres")}
renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
),
},
{
key: "year",
component: (
<FilterButton
className='mr-1'
id={collectionId}
queryKey='yearFilter'
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api,
).getQueryFiltersLegacy({
userId: user?.Id,
parentId: collectionId,
});
return response.data.Years || [];
}}
set={setSelectedYears}
values={selectedYears}
title={t("library.filters.years")}
renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) => item.includes(search)}
/>
),
},
{
key: "tags",
component: (
<FilterButton
className='mr-1'
id={collectionId}
queryKey='tagsFilter'
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api,
).getQueryFiltersLegacy({
userId: user?.Id,
parentId: collectionId,
});
return response.data.Tags || [];
}}
set={setSelectedTags}
values={selectedTags}
title={t("library.filters.tags")}
renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
),
},
{
key: "sortBy",
component: (
<FilterButton
className='mr-1'
id={collectionId}
queryKey='sortBy'
queryFn={async () => sortOptions.map((s) => s.key)}
set={setSortBy}
values={sortBy}
title={t("library.filters.sort_by")}
renderItemLabel={(item) =>
sortOptions.find((i) => i.key === item)?.value || ""
}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
),
},
{
key: "sortOrder",
component: (
<FilterButton
className='mr-1'
id={collectionId}
queryKey='sortOrder'
queryFn={async () => sortOrderOptions.map((s) => s.key)}
set={setSortOrder}
values={sortOrder}
title={t("library.filters.sort_order")}
renderItemLabel={(item) =>
sortOrderOptions.find((i) => i.key === item)?.value || ""
}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
),
},
]}
renderItem={({ item }) => item.component}
keyExtractor={(item) => item.key}
/>
<View className=''>
<FlatList
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
display: "flex",
paddingHorizontal: 15,
paddingVertical: 16,
flexDirection: "row",
}}
extraData={[
selectedGenres,
selectedYears,
selectedTags,
sortBy,
sortOrder,
]}
data={[
{
key: "reset",
component: <ResetFiltersButton />,
},
{
key: "genre",
component: (
<FilterButton
className='mr-1'
id={collectionId}
queryKey='genreFilter'
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api,
).getQueryFiltersLegacy({
userId: user?.Id,
parentId: collectionId,
});
return response.data.Genres || [];
}}
set={setSelectedGenres}
values={selectedGenres}
title={t("library.filters.genres")}
renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
),
},
{
key: "year",
component: (
<FilterButton
className='mr-1'
id={collectionId}
queryKey='yearFilter'
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api,
).getQueryFiltersLegacy({
userId: user?.Id,
parentId: collectionId,
});
return response.data.Years || [];
}}
set={setSelectedYears}
values={selectedYears}
title={t("library.filters.years")}
renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) => item.includes(search)}
/>
),
},
{
key: "tags",
component: (
<FilterButton
className='mr-1'
id={collectionId}
queryKey='tagsFilter'
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api,
).getQueryFiltersLegacy({
userId: user?.Id,
parentId: collectionId,
});
return response.data.Tags || [];
}}
set={setSelectedTags}
values={selectedTags}
title={t("library.filters.tags")}
renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
),
},
{
key: "sortBy",
component: (
<FilterButton
className='mr-1'
id={collectionId}
queryKey='sortBy'
queryFn={async () => sortOptions.map((s) => s.key)}
set={setSortBy}
values={sortBy}
title={t("library.filters.sort_by")}
renderItemLabel={(item) =>
sortOptions.find((i) => i.key === item)?.value || ""
}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
),
},
{
key: "sortOrder",
component: (
<FilterButton
className='mr-1'
id={collectionId}
queryKey='sortOrder'
queryFn={async () => sortOrderOptions.map((s) => s.key)}
set={setSortOrder}
values={sortOrder}
title={t("library.filters.sort_order")}
renderItemLabel={(item) =>
sortOrderOptions.find((i) => i.key === item)?.value || ""
}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
),
},
]}
renderItem={({ item }) => item.component}
keyExtractor={(item) => item.key}
/>
</View>
),
[
collectionId,

View File

@@ -20,8 +20,7 @@ const Page: React.FC = () => {
const { offline } = useLocalSearchParams() as { offline?: string };
const isOffline = offline === "true";
// Fetch item with all fields including MediaSources
const { data: item, isError } = useItemQuery(id, isOffline, undefined, []);
const { data: item, isError } = useItemQuery(id, isOffline);
const opacity = useSharedValue(1);
const animatedStyle = useAnimatedStyle(() => {

View File

@@ -14,7 +14,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { GenreTags } from "@/components/GenreTags";
@@ -34,16 +33,8 @@ import {
type IssueType,
IssueTypeName,
} from "@/utils/jellyseerr/server/constants/issue";
import {
MediaRequestStatus,
MediaType,
} from "@/utils/jellyseerr/server/constants/media";
import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
import {
hasPermission,
Permission,
} from "@/utils/jellyseerr/server/lib/permissions";
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
import type {
MovieResult,
@@ -67,12 +58,11 @@ const Page: React.FC = () => {
} & Partial<MovieResult | TvResult | MovieDetails | TvDetails>;
const navigation = useNavigation();
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
const { jellyseerrApi, requestMedia } = useJellyseerr();
const [issueType, setIssueType] = useState<IssueType>();
const [issueMessage, setIssueMessage] = useState<string>();
const [requestBody, _setRequestBody] = useState<MediaRequestBody>();
const [issueTypeDropdownOpen, setIssueTypeDropdownOpen] = useState(false);
const advancedReqModalRef = useRef<BottomSheetModal>(null);
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
@@ -100,46 +90,6 @@ const Page: React.FC = () => {
const [canRequest, hasAdvancedRequestPermission] =
useJellyseerrCanRequest(details);
const canManageRequests = useMemo(() => {
if (!jellyseerrUser) return false;
return hasPermission(
Permission.MANAGE_REQUESTS,
jellyseerrUser.permissions,
);
}, [jellyseerrUser]);
const pendingRequest = useMemo(() => {
return details?.mediaInfo?.requests?.find(
(r: MediaRequest) => r.status === MediaRequestStatus.PENDING,
);
}, [details]);
const handleApproveRequest = useCallback(async () => {
if (!pendingRequest?.id) return;
try {
await jellyseerrApi?.approveRequest(pendingRequest.id);
toast.success(t("jellyseerr.toasts.request_approved"));
refetch();
} catch (error) {
toast.error(t("jellyseerr.toasts.failed_to_approve_request"));
console.error("Failed to approve request:", error);
}
}, [jellyseerrApi, pendingRequest, refetch, t]);
const handleDeclineRequest = useCallback(async () => {
if (!pendingRequest?.id) return;
try {
await jellyseerrApi?.declineRequest(pendingRequest.id);
toast.success(t("jellyseerr.toasts.request_declined"));
refetch();
} catch (error) {
toast.error(t("jellyseerr.toasts.failed_to_decline_request"));
console.error("Failed to decline request:", error);
}
}, [jellyseerrApi, pendingRequest, refetch, t]);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
@@ -163,10 +113,6 @@ const Page: React.FC = () => {
}
}, [jellyseerrApi, details, result, issueType, issueMessage]);
const handleIssueModalDismiss = useCallback(() => {
setIssueTypeDropdownOpen(false);
}, []);
const setRequestBody = useCallback(
(body: MediaRequestBody) => {
_setRequestBody(body);
@@ -180,11 +126,9 @@ const Page: React.FC = () => {
mediaId: Number(result.id!),
mediaType: mediaType!,
tvdbId: details?.externalIds?.tvdbId,
...(mediaType === MediaType.TV && {
seasons: (details as TvDetails)?.seasons
?.filter?.((s) => s.seasonNumber !== 0)
?.map?.((s) => s.seasonNumber),
}),
seasons: (details as TvDetails)?.seasons
?.filter?.((s) => s.seasonNumber !== 0)
?.map?.((s) => s.seasonNumber),
};
if (hasAdvancedRequestPermission) {
@@ -232,9 +176,7 @@ const Page: React.FC = () => {
if (details) {
navigation.setOptions({
headerRight: () => (
<TouchableOpacity
className={`rounded-full pl-1.5 ${Platform.OS === "android" ? "" : "bg-neutral-800/80"}`}
>
<TouchableOpacity className='rounded-full p-2 bg-neutral-800/80'>
<ItemActions item={details} />
</TouchableOpacity>
),
@@ -290,7 +232,7 @@ const Page: React.FC = () => {
}
>
<View className='flex flex-col'>
<View className='space-y-4'>
<View className='gap-y-4'>
<View className='px-4'>
<View className='flex flex-row justify-between w-full'>
<View className='flex flex-col w-56'>
@@ -383,60 +325,6 @@ const Page: React.FC = () => {
</View>
)
)}
{canManageRequests && pendingRequest && (
<View className='flex flex-col space-y-2 mt-4'>
<View className='flex flex-row items-center space-x-2'>
<Ionicons name='person-outline' size={16} color='#9CA3AF' />
<Text className='text-sm text-neutral-400'>
{t("jellyseerr.requested_by", {
user:
pendingRequest.requestedBy?.displayName ||
pendingRequest.requestedBy?.username ||
pendingRequest.requestedBy?.jellyfinUsername ||
t("jellyseerr.unknown_user"),
})}
</Text>
</View>
<View className='flex flex-row space-x-2'>
<Button
className='flex-1 bg-green-600/50 border-green-400 ring-green-400 text-green-100'
color='transparent'
onPress={handleApproveRequest}
iconLeft={
<Ionicons
name='checkmark-outline'
size={20}
color='white'
/>
}
style={{
borderWidth: 1,
borderStyle: "solid",
}}
>
<Text className='text-sm'>{t("jellyseerr.approve")}</Text>
</Button>
<Button
className='flex-1 bg-red-600/50 border-red-400 ring-red-400 text-red-100'
color='transparent'
onPress={handleDeclineRequest}
iconLeft={
<Ionicons
name='close-outline'
size={20}
color='white'
/>
}
style={{
borderWidth: 1,
borderStyle: "solid",
}}
>
<Text className='text-sm'>{t("jellyseerr.decline")}</Text>
</Button>
</View>
</View>
)}
<OverviewText text={result.overview} className='mt-4' />
</View>
@@ -483,8 +371,6 @@ const Page: React.FC = () => {
backgroundColor: "#171717",
}}
backdropComponent={renderBackdrop}
stackBehavior='push'
onDismiss={handleIssueModalDismiss}
>
<BottomSheetView>
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
@@ -510,8 +396,6 @@ const Page: React.FC = () => {
</View>
}
title={t("jellyseerr.types")}
open={issueTypeDropdownOpen}
onOpenChange={setIssueTypeDropdownOpen}
/>
</View>

View File

@@ -87,15 +87,14 @@ export default function page() {
<Text className='font-bold text-2xl mb-1'>{data?.details?.name}</Text>
<Text className='opacity-50'>
{t("jellyseerr.born")}{" "}
{data?.details?.birthday &&
new Date(data.details.birthday).toLocaleDateString(
`${locale}-${region}`,
{
year: "numeric",
month: "long",
day: "numeric",
},
)}{" "}
{new Date(data?.details?.birthday!).toLocaleDateString(
`${locale}-${region}`,
{
year: "numeric",
month: "long",
day: "numeric",
},
)}{" "}
| {data?.details?.placeOfBirth}
</Text>
</>

View File

@@ -33,6 +33,7 @@ export default function page() {
<View className='flex flex-1'>
<FlashList
data={channels?.Items}
estimatedItemSize={76}
renderItem={({ item }) => (
<View className='flex flex-row items-center px-4 mb-2'>
<View className='w-22 mr-4 rounded-lg overflow-hidden'>

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'>
<MoviesTitleHeader item={item} className='mb-4' />
<OverviewText text={item.Overview} />

View File

@@ -65,11 +65,9 @@ const page: React.FC = () => {
const { data: allEpisodes, isLoading } = useQuery({
queryKey: ["AllEpisodes", item?.Id],
queryFn: async () => {
if (!api || !user?.Id || !item?.Id) return [];
const res = await getTvShowsApi(api).getEpisodes({
seriesId: item.Id,
userId: user.Id,
const res = await getTvShowsApi(api!).getEpisodes({
seriesId: item?.Id!,
userId: user?.Id!,
enableUserData: true,
// Note: Including trick play is necessary to enable trick play downloads
fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],

View File

@@ -271,143 +271,145 @@ const Page = () => {
const ListHeaderComponent = useCallback(
() => (
<FlatList
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
display: "flex",
paddingHorizontal: 15,
paddingVertical: 16,
flexDirection: "row",
}}
data={[
{
key: "reset",
component: <ResetFiltersButton />,
},
{
key: "genre",
component: (
<FilterButton
className='mr-1'
id={libraryId}
queryKey='genreFilter'
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api,
).getQueryFiltersLegacy({
userId: user?.Id,
parentId: libraryId,
});
return response.data.Genres || [];
}}
set={setSelectedGenres}
values={selectedGenres}
title={t("library.filters.genres")}
renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
),
},
{
key: "year",
component: (
<FilterButton
className='mr-1'
id={libraryId}
queryKey='yearFilter'
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api,
).getQueryFiltersLegacy({
userId: user?.Id,
parentId: libraryId,
});
return response.data.Years || [];
}}
set={setSelectedYears}
values={selectedYears}
title={t("library.filters.years")}
renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) => item.includes(search)}
/>
),
},
{
key: "tags",
component: (
<FilterButton
className='mr-1'
id={libraryId}
queryKey='tagsFilter'
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api,
).getQueryFiltersLegacy({
userId: user?.Id,
parentId: libraryId,
});
return response.data.Tags || [];
}}
set={setSelectedTags}
values={selectedTags}
title={t("library.filters.tags")}
renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
),
},
{
key: "sortBy",
component: (
<FilterButton
className='mr-1'
id={libraryId}
queryKey='sortBy'
queryFn={async () => sortOptions.map((s) => s.key)}
set={setSortBy}
values={sortBy}
title={t("library.filters.sort_by")}
renderItemLabel={(item) =>
sortOptions.find((i) => i.key === item)?.value || ""
}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
),
},
{
key: "sortOrder",
component: (
<FilterButton
className='mr-1'
id={libraryId}
queryKey='sortOrder'
queryFn={async () => sortOrderOptions.map((s) => s.key)}
set={setSortOrder}
values={sortOrder}
title={t("library.filters.sort_order")}
renderItemLabel={(item) =>
sortOrderOptions.find((i) => i.key === item)?.value || ""
}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
),
},
]}
renderItem={({ item }) => item.component}
keyExtractor={(item) => item.key}
/>
<View className=''>
<FlatList
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
display: "flex",
paddingHorizontal: 15,
paddingVertical: 16,
flexDirection: "row",
}}
data={[
{
key: "reset",
component: <ResetFiltersButton />,
},
{
key: "genre",
component: (
<FilterButton
className='mr-1'
id={libraryId}
queryKey='genreFilter'
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api,
).getQueryFiltersLegacy({
userId: user?.Id,
parentId: libraryId,
});
return response.data.Genres || [];
}}
set={setSelectedGenres}
values={selectedGenres}
title={t("library.filters.genres")}
renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
),
},
{
key: "year",
component: (
<FilterButton
className='mr-1'
id={libraryId}
queryKey='yearFilter'
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api,
).getQueryFiltersLegacy({
userId: user?.Id,
parentId: libraryId,
});
return response.data.Years || [];
}}
set={setSelectedYears}
values={selectedYears}
title={t("library.filters.years")}
renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) => item.includes(search)}
/>
),
},
{
key: "tags",
component: (
<FilterButton
className='mr-1'
id={libraryId}
queryKey='tagsFilter'
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api,
).getQueryFiltersLegacy({
userId: user?.Id,
parentId: libraryId,
});
return response.data.Tags || [];
}}
set={setSelectedTags}
values={selectedTags}
title={t("library.filters.tags")}
renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
),
},
{
key: "sortBy",
component: (
<FilterButton
className='mr-1'
id={libraryId}
queryKey='sortBy'
queryFn={async () => sortOptions.map((s) => s.key)}
set={setSortBy}
values={sortBy}
title={t("library.filters.sort_by")}
renderItemLabel={(item) =>
sortOptions.find((i) => i.key === item)?.value || ""
}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
),
},
{
key: "sortOrder",
component: (
<FilterButton
className='mr-1'
id={libraryId}
queryKey='sortOrder'
queryFn={async () => sortOrderOptions.map((s) => s.key)}
set={setSortOrder}
values={sortOrder}
title={t("library.filters.sort_order")}
renderItemLabel={(item) =>
sortOrderOptions.find((i) => i.key === item)?.value || ""
}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
),
},
]}
renderItem={({ item }) => item.component}
keyExtractor={(item) => item.key}
/>
</View>
),
[
libraryId,
@@ -451,6 +453,7 @@ const Page = () => {
renderItem={renderItem}
extraData={[orientation, nrOfCols]}
keyExtractor={keyExtractor}
estimatedItemSize={244}
numColumns={nrOfCols}
onEndReached={() => {
if (hasNextPage) {

View File

@@ -1,152 +1,16 @@
import { Ionicons } from "@expo/vector-icons";
import { Stack } from "expo-router";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native";
import { Platform } from "react-native";
import { PlatformDropdown } from "@/components/PlatformDropdown";
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import { useSettings } from "@/utils/atoms/settings";
export default function IndexLayout() {
const { settings, updateSettings, pluginSettings } = useSettings();
const [dropdownOpen, setDropdownOpen] = useState(false);
const { t } = useTranslation();
// Reset dropdown state when component unmounts or navigates away
useEffect(() => {
return () => {
setDropdownOpen(false);
};
}, []);
// Memoize callbacks to prevent recreating on every render
const handleDisplayRow = useCallback(() => {
updateSettings({
libraryOptions: {
...settings.libraryOptions,
display: "row",
},
});
}, [settings.libraryOptions, updateSettings]);
const handleDisplayList = useCallback(() => {
updateSettings({
libraryOptions: {
...settings.libraryOptions,
display: "list",
},
});
}, [settings.libraryOptions, updateSettings]);
const handleImageStylePoster = useCallback(() => {
updateSettings({
libraryOptions: {
...settings.libraryOptions,
imageStyle: "poster",
},
});
}, [settings.libraryOptions, updateSettings]);
const handleImageStyleCover = useCallback(() => {
updateSettings({
libraryOptions: {
...settings.libraryOptions,
imageStyle: "cover",
},
});
}, [settings.libraryOptions, updateSettings]);
const handleToggleTitles = useCallback(() => {
updateSettings({
libraryOptions: {
...settings.libraryOptions,
showTitles: !settings.libraryOptions.showTitles,
},
});
}, [settings.libraryOptions, updateSettings]);
const handleToggleStats = useCallback(() => {
updateSettings({
libraryOptions: {
...settings.libraryOptions,
showStats: !settings.libraryOptions.showStats,
},
});
}, [settings.libraryOptions, updateSettings]);
// Memoize groups to prevent recreating the array on every render
const dropdownGroups = useMemo(
() => [
{
title: t("library.options.display"),
options: [
{
type: "radio" as const,
label: t("library.options.row"),
value: "row",
selected: settings.libraryOptions.display === "row",
onPress: handleDisplayRow,
},
{
type: "radio" as const,
label: t("library.options.list"),
value: "list",
selected: settings.libraryOptions.display === "list",
onPress: handleDisplayList,
},
],
},
{
title: t("library.options.image_style"),
options: [
{
type: "radio" as const,
label: t("library.options.poster"),
value: "poster",
selected: settings.libraryOptions.imageStyle === "poster",
onPress: handleImageStylePoster,
},
{
type: "radio" as const,
label: t("library.options.cover"),
value: "cover",
selected: settings.libraryOptions.imageStyle === "cover",
onPress: handleImageStyleCover,
},
],
},
{
title: "Options",
options: [
{
type: "toggle" as const,
label: t("library.options.show_titles"),
value: settings.libraryOptions.showTitles,
onToggle: handleToggleTitles,
disabled: settings.libraryOptions.imageStyle === "poster",
},
{
type: "toggle" as const,
label: t("library.options.show_stats"),
value: settings.libraryOptions.showStats,
onToggle: handleToggleStats,
},
],
},
],
[
t,
settings.libraryOptions,
handleDisplayRow,
handleDisplayList,
handleImageStylePoster,
handleImageStyleCover,
handleToggleTitles,
handleToggleStats,
],
);
if (!settings?.libraryOptions) return null;
return (
@@ -163,19 +27,111 @@ export default function IndexLayout() {
!pluginSettings?.libraryOptions?.locked &&
!Platform.isTV && (
<PlatformDropdown
open={dropdownOpen}
onOpenChange={setDropdownOpen}
trigger={
<View className='pl-1.5'>
<Ionicons
name='ellipsis-horizontal-outline'
size={24}
color='white'
/>
</View>
<Ionicons
name='ellipsis-horizontal-outline'
size={24}
color='white'
/>
}
title={t("library.options.display")}
groups={dropdownGroups}
groups={[
{
title: t("library.options.display"),
options: [
{
type: "radio",
label: t("library.options.row"),
value: "row",
selected: settings.libraryOptions.display === "row",
onPress: () =>
updateSettings({
libraryOptions: {
...settings.libraryOptions,
display: "row",
},
}),
},
{
type: "radio",
label: t("library.options.list"),
value: "list",
selected: settings.libraryOptions.display === "list",
onPress: () =>
updateSettings({
libraryOptions: {
...settings.libraryOptions,
display: "list",
},
}),
},
],
},
{
title: t("library.options.image_style"),
options: [
{
type: "radio",
label: t("library.options.poster"),
value: "poster",
selected:
settings.libraryOptions.imageStyle === "poster",
onPress: () =>
updateSettings({
libraryOptions: {
...settings.libraryOptions,
imageStyle: "poster",
},
}),
},
{
type: "radio",
label: t("library.options.cover"),
value: "cover",
selected:
settings.libraryOptions.imageStyle === "cover",
onPress: () =>
updateSettings({
libraryOptions: {
...settings.libraryOptions,
imageStyle: "cover",
},
}),
},
],
},
{
title: "Options",
options: [
{
type: "toggle",
label: t("library.options.show_titles"),
value: settings.libraryOptions.showTitles,
onToggle: () =>
updateSettings({
libraryOptions: {
...settings.libraryOptions,
showTitles: !settings.libraryOptions.showTitles,
},
}),
disabled:
settings.libraryOptions.imageStyle === "poster",
},
{
type: "toggle",
label: t("library.options.show_stats"),
value: settings.libraryOptions.showStats,
onToggle: () =>
updateSettings({
libraryOptions: {
...settings.libraryOptions,
showStats: !settings.libraryOptions.showStats,
},
}),
},
],
},
]}
/>
),
}}

View File

@@ -7,7 +7,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Platform, StyleSheet, View } from "react-native";
import { StyleSheet, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
@@ -84,7 +84,7 @@ export default function index() {
extraData={settings}
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingTop: Platform.OS === "android" ? 17 : 0,
paddingTop: 17,
paddingHorizontal: settings?.libraryOptions?.display === "row" ? 0 : 17,
paddingBottom: 150,
paddingLeft: insets.left + 17,

View File

@@ -28,7 +28,7 @@ export default function SearchLayout() {
options={{
title: "",
headerShown: !Platform.isTV,
headerBlurEffect: "none",
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
}}

View File

@@ -282,12 +282,9 @@ export default function search() {
maxLength={500}
/>
)}
<View
className='flex flex-col'
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
>
<View className='flex flex-col'>
{jellyseerrApi && (
<View className='pl-4 pr-4 flex flex-row'>
<View className='pl-4 pr-4 pt-2 flex flex-row'>
<SearchTabButtons
searchType={searchType}
setSearchType={setSearchType}

View File

@@ -55,7 +55,6 @@ export default function TabLayout() {
backgroundColor: "#121212",
}}
tabBarActiveTintColor={Colors.primary}
activeIndicatorColor={"#392c3b"}
scrollEdgeAppearance='default'
>
<NativeTabs.Screen redirect name='index' />
@@ -71,7 +70,10 @@ export default function TabLayout() {
tabBarIcon:
Platform.OS === "android"
? (_e) => require("@/assets/icons/house.fill.png")
: (_e) => ({ sfSymbol: "house.fill" }),
: ({ focused }) =>
focused
? { sfSymbol: "house.fill" }
: { sfSymbol: "house" },
}}
/>
<NativeTabs.Screen
@@ -82,12 +84,14 @@ export default function TabLayout() {
})}
name='(search)'
options={{
role: "search",
title: t("tabs.search"),
tabBarIcon:
Platform.OS === "android"
? (_e) => require("@/assets/icons/magnifyingglass.png")
: (_e) => ({ sfSymbol: "magnifyingglass" }),
: ({ focused }) =>
focused
? { sfSymbol: "magnifyingglass" }
: { sfSymbol: "magnifyingglass" },
}}
/>
<NativeTabs.Screen
@@ -96,8 +100,14 @@ export default function TabLayout() {
title: t("tabs.favorites"),
tabBarIcon:
Platform.OS === "android"
? (_e) => require("@/assets/icons/heart.fill.png")
: (_e) => ({ sfSymbol: "heart.fill" }),
? ({ focused }) =>
focused
? require("@/assets/icons/heart.fill.png")
: require("@/assets/icons/heart.png")
: ({ focused }) =>
focused
? { sfSymbol: "heart.fill" }
: { sfSymbol: "heart" },
}}
/>
<NativeTabs.Screen
@@ -107,7 +117,10 @@ export default function TabLayout() {
tabBarIcon:
Platform.OS === "android"
? (_e) => require("@/assets/icons/server.rack.png")
: (_e) => ({ sfSymbol: "rectangle.stack.fill" }),
: ({ focused }) =>
focused
? { sfSymbol: "rectangle.stack.fill" }
: { sfSymbol: "rectangle.stack" },
}}
/>
<NativeTabs.Screen
@@ -118,7 +131,10 @@ export default function TabLayout() {
tabBarIcon:
Platform.OS === "android"
? (_e) => require("@/assets/icons/list.png")
: (_e) => ({ sfSymbol: "list.dash.fill" }),
: ({ focused }) =>
focused
? { sfSymbol: "list.dash.fill" }
: { sfSymbol: "list.dash" },
}}
/>
</NativeTabs>

View File

@@ -1,33 +1,7 @@
import { Stack } from "expo-router";
import { useEffect } from "react";
import { AppState } from "react-native";
import { SystemBars } from "react-native-edge-to-edge";
import { useOrientation } from "@/hooks/useOrientation";
import { useSettings } from "@/utils/atoms/settings";
export default function Layout() {
const { settings } = useSettings();
const { lockOrientation, unlockOrientation } = useOrientation();
useEffect(() => {
if (settings?.defaultVideoOrientation) {
lockOrientation(settings.defaultVideoOrientation);
}
// Re-apply orientation lock when app returns to foreground (iOS resets it)
const subscription = AppState.addEventListener("change", (nextAppState) => {
if (nextAppState === "active" && settings?.defaultVideoOrientation) {
lockOrientation(settings.defaultVideoOrientation);
}
});
return () => {
subscription.remove();
unlockOrientation();
};
}, [settings?.defaultVideoOrientation, lockOrientation, unlockOrientation]);
return (
<>
<SystemBars hidden />

View File

@@ -22,34 +22,33 @@ import { BITRATES } from "@/components/BitrateSelector";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { Controls } from "@/components/video-player/controls/Controls";
import { PlayerProvider } from "@/components/video-player/controls/contexts/PlayerContext";
import { VideoProvider } from "@/components/video-player/controls/contexts/VideoContext";
import {
OUTLINE_THICKNESS,
OutlineThickness,
VLC_COLORS,
VLCColor,
} from "@/constants/SubtitleConstants";
import { useHaptic } from "@/hooks/useHaptic";
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useWebSocket } from "@/hooks/useWebsockets";
import {
MpvPlayerView,
type MpvPlayerViewRef,
type OnPlaybackStateChangePayload,
type OnProgressEventPayload,
type VideoSource,
} from "@/modules";
import { VlcPlayerView } from "@/modules";
import type {
PlaybackStatePayload,
ProgressUpdatePayload,
VlcPlayerViewRef,
} from "@/modules/VlcPlayer.types";
import { useDownload } from "@/providers/DownloadProvider";
import { DownloadedItem } from "@/providers/Downloads/types";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import {
getMpvAudioId,
getMpvSubtitleId,
} from "@/utils/jellyfin/subtitleUtils";
import { writeToLog } from "@/utils/log";
import { generateDeviceProfile } from "@/utils/profiles/native";
import { msToTicks, ticksToSeconds } from "@/utils/time";
export default function page() {
const videoRef = useRef<MpvPlayerViewRef>(null);
const videoRef = useRef<VlcPlayerViewRef>(null);
const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom);
const { t } = useTranslation();
@@ -57,7 +56,6 @@ export default function page() {
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
const [showControls, _setShowControls] = useState(true);
const [isPipMode, _setIsPipMode] = useState(false);
const [aspectRatio, setAspectRatio] = useState<
"default" | "16:9" | "4:3" | "1:1" | "21:9"
>("default");
@@ -68,7 +66,6 @@ export default function page() {
const [isMuted, setIsMuted] = useState(false);
const [isBuffering, setIsBuffering] = useState(true);
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
const [tracksReady, setTracksReady] = useState(false);
const progress = useSharedValue(0);
const isSeeking = useSharedValue(false);
@@ -219,6 +216,8 @@ export default function page() {
return;
}
const native = generateDeviceProfile();
const transcoding = generateDeviceProfile({ transcode: true });
const res = await getStreamUrl({
api,
item,
@@ -228,7 +227,7 @@ export default function page() {
maxStreamingBitrate: bitrateValue,
mediaSourceId: mediaSourceId,
subtitleStreamIndex: subtitleIndex,
deviceProfile: generateDeviceProfile(),
deviceProfile: bitrateValue ? transcoding : native,
});
if (!res) return;
const { mediaSource, sessionId, url } = res;
@@ -286,14 +285,12 @@ export default function page() {
};
const reportPlaybackStopped = useCallback(async () => {
if (!item?.Id || !stream?.sessionId) return;
const currentTimeInTicks = msToTicks(progress.get());
await getPlaystateApi(api!).onPlaybackStopped({
itemId: item.Id,
itemId: item?.Id!,
mediaSourceId: mediaSourceId,
positionTicks: currentTimeInTicks,
playSessionId: stream.sessionId,
playSessionId: stream?.sessionId!,
});
}, [
api,
@@ -312,8 +309,7 @@ export default function page() {
});
reportPlaybackStopped();
setIsPlaybackStopped(true);
// MPV doesn't have a stop method, use pause instead
videoRef.current?.pause();
videoRef.current?.stop();
revalidateProgressCache();
}, [videoRef, reportPlaybackStopped, progress]);
@@ -325,10 +321,9 @@ export default function page() {
}, [navigation, stop]);
const currentPlayStateInfo = useCallback(() => {
if (!stream || !item?.Id) return;
if (!stream) return;
return {
itemId: item.Id,
itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
@@ -369,13 +364,10 @@ export default function page() {
);
const onProgress = useCallback(
async (data: { nativeEvent: OnProgressEventPayload }) => {
async (data: ProgressUpdatePayload) => {
if (isSeeking.get() || isPlaybackStopped) return;
const { position } = data.nativeEvent;
// MPV reports position in seconds, convert to ms
const currentTime = position * 1000;
const { currentTime } = data.nativeEvent;
if (isBuffering) {
setIsBuffering(false);
}
@@ -421,46 +413,6 @@ export default function page() {
return ticksToSeconds(getInitialPlaybackTicks());
}, [getInitialPlaybackTicks]);
/** Build video source config for the native player */
const videoSource = useMemo<VideoSource | undefined>(() => {
if (!stream?.url) return undefined;
const mediaSource = stream.mediaSource;
const isTranscoding = Boolean(mediaSource?.TranscodingUrl);
// Get external subtitle URLs
const externalSubs = mediaSource?.MediaStreams?.filter(
(s) =>
s.Type === "Subtitle" &&
s.DeliveryMethod === "External" &&
s.DeliveryUrl,
).map((s) => `${api?.basePath}${s.DeliveryUrl}`);
// Calculate MPV track IDs for initial selection
const initialSubtitleId = getMpvSubtitleId(
mediaSource,
subtitleIndex,
isTranscoding,
);
const initialAudioId = getMpvAudioId(mediaSource, audioIndex);
return {
url: stream.url,
startPosition,
autoplay: true,
externalSubtitles: externalSubs,
initialSubtitleId,
initialAudioId,
};
}, [
stream?.url,
stream?.mediaSource,
startPosition,
api?.basePath,
subtitleIndex,
audioIndex,
]);
const volumeUpCb = useCallback(async () => {
if (Platform.isTV) return;
@@ -541,12 +493,10 @@ export default function page() {
});
const onPlaybackStateChanged = useCallback(
async (e: { nativeEvent: OnPlaybackStateChangePayload }) => {
const { isPaused, isPlaying: playing, isLoading } = e.nativeEvent;
if (playing) {
async (e: PlaybackStatePayload) => {
const { state, isBuffering, isPlaying } = e.nativeEvent;
if (state === "Playing") {
setIsPlaying(true);
setIsBuffering(false);
if (item?.Id) {
playbackManager.reportPlaybackProgress(
currentPlayStateInfo() as PlaybackProgressInfo,
@@ -556,7 +506,7 @@ export default function page() {
return;
}
if (isPaused) {
if (state === "Paused") {
setIsPlaying(false);
if (item?.Id) {
playbackManager.reportPlaybackProgress(
@@ -567,13 +517,87 @@ export default function page() {
return;
}
if (isLoading) {
if (isPlaying) {
setIsPlaying(true);
setIsBuffering(false);
} else if (isBuffering) {
setIsBuffering(true);
}
},
[playbackManager, item?.Id, progress],
);
const allAudio =
stream?.mediaSource.MediaStreams?.filter(
(audio) => audio.Type === "Audio",
) || [];
// Move all the external subtitles last, because vlc places them last.
const allSubs =
stream?.mediaSource.MediaStreams?.filter(
(sub) => sub.Type === "Subtitle",
).sort((a, b) => Number(a.IsExternal) - Number(b.IsExternal)) || [];
const externalSubtitles = allSubs
.filter((sub: any) => sub.DeliveryMethod === "External")
.map((sub: any) => ({
name: sub.DisplayTitle,
DeliveryUrl: offline ? sub.DeliveryUrl : api?.basePath + sub.DeliveryUrl,
}));
/** The text based subtitle tracks */
const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream);
/** The user chosen subtitle track from the server */
const chosenSubtitleTrack = allSubs.find(
(sub) => sub.Index === subtitleIndex,
);
/** The user chosen audio track from the server */
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
/** Whether the stream we're playing is not transcoding*/
const notTranscoding = !stream?.mediaSource.TranscodingUrl;
/** The initial options to pass to the VLC Player */
const initOptions = [``];
if (
chosenSubtitleTrack &&
(notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
) {
// If not transcoding, we can the index as normal.
// If transcoding, we need to reverse the text based subtitles, because VLC reverses the HLS subtitles.
const finalIndex = notTranscoding
? allSubs.indexOf(chosenSubtitleTrack)
: [...textSubs].reverse().indexOf(chosenSubtitleTrack);
initOptions.push(`--sub-track=${finalIndex}`);
// Add VLC subtitle styling options from settings
const textColor = (settings.vlcTextColor ?? "White") as VLCColor;
const backgroundColor = (settings.vlcBackgroundColor ??
"Black") as VLCColor;
const outlineColor = (settings.vlcOutlineColor ?? "Black") as VLCColor;
const outlineThickness = (settings.vlcOutlineThickness ??
"Normal") as OutlineThickness;
const backgroundOpacity = settings.vlcBackgroundOpacity ?? 128;
const outlineOpacity = settings.vlcOutlineOpacity ?? 255;
const isBold = settings.vlcIsBold ?? false;
// Add subtitle styling options
initOptions.push(`--freetype-color=${VLC_COLORS[textColor]}`);
initOptions.push(`--freetype-background-opacity=${backgroundOpacity}`);
initOptions.push(
`--freetype-background-color=${VLC_COLORS[backgroundColor]}`,
);
initOptions.push(`--freetype-outline-opacity=${outlineOpacity}`);
initOptions.push(`--freetype-outline-color=${VLC_COLORS[outlineColor]}`);
initOptions.push(
`--freetype-outline-thickness=${OUTLINE_THICKNESS[outlineThickness]}`,
);
initOptions.push(`--sub-text-scale=${settings.subtitleSize}`);
initOptions.push("--sub-margin=40");
if (isBold) {
initOptions.push("--freetype-bold");
}
}
if (notTranscoding && chosenAudioTrack) {
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
}
const [isMounted, setIsMounted] = useState(false);
// Add useEffect to handle mounting
@@ -586,7 +610,6 @@ export default function page() {
const startPictureInPicture = useCallback(async () => {
return videoRef.current?.startPictureInPicture?.();
}, []);
const play = useCallback(() => {
videoRef.current?.play?.();
}, []);
@@ -596,40 +619,46 @@ export default function page() {
}, []);
const seek = useCallback((position: number) => {
// MPV expects seconds, convert from ms
videoRef.current?.seekTo?.(position / 1000);
videoRef.current?.seekTo?.(position);
}, []);
const getAudioTracks = useCallback(async () => {
return videoRef.current?.getAudioTracks?.() || null;
}, []);
// Apply MPV subtitle settings when video loads
useEffect(() => {
if (!isVideoLoaded || !videoRef.current) return;
const getSubtitleTracks = useCallback(async () => {
return videoRef.current?.getSubtitleTracks?.() || null;
}, []);
const applySubtitleSettings = async () => {
if (settings.mpvSubtitleScale !== undefined) {
await videoRef.current?.setSubtitleScale(settings.mpvSubtitleScale);
}
if (settings.mpvSubtitleMarginY !== undefined) {
await videoRef.current?.setSubtitleMarginY(settings.mpvSubtitleMarginY);
}
if (settings.mpvSubtitleAlignX !== undefined) {
await videoRef.current?.setSubtitleAlignX(settings.mpvSubtitleAlignX);
}
if (settings.mpvSubtitleAlignY !== undefined) {
await videoRef.current?.setSubtitleAlignY(settings.mpvSubtitleAlignY);
}
if (settings.mpvSubtitleFontSize !== undefined) {
await videoRef.current?.setSubtitleFontSize(
settings.mpvSubtitleFontSize,
);
}
// Apply subtitle size from general settings
if (settings.subtitleSize) {
await videoRef.current?.setSubtitleFontSize(settings.subtitleSize);
}
};
const setSubtitleTrack = useCallback((index: number) => {
videoRef.current?.setSubtitleTrack?.(index);
}, []);
applySubtitleSettings();
}, [isVideoLoaded, settings]);
const setSubtitleURL = useCallback((url: string, _customName?: string) => {
// Note: VlcPlayer type only expects url parameter
videoRef.current?.setSubtitleURL?.(url);
}, []);
const setAudioTrack = useCallback((index: number) => {
videoRef.current?.setAudioTrack?.(index);
}, []);
const setVideoAspectRatio = useCallback(
async (aspectRatio: string | null) => {
return (
videoRef.current?.setVideoAspectRatio?.(aspectRatio) ||
Promise.resolve()
);
},
[],
);
const setVideoScaleFactor = useCallback(async (scaleFactor: number) => {
return (
videoRef.current?.setVideoScaleFactor?.(scaleFactor) || Promise.resolve()
);
}, []);
console.log("Debug: component render"); // Uncomment to debug re-renders
// Show error UI first, before checking loading/missingdata
if (itemStatus.isError || streamStatus.isError) {
@@ -640,7 +669,7 @@ export default function page() {
);
}
// Then show loader while either side is still fetching or data isn't present
// Then show loader while either side is still fetching or data isnt present
if (itemStatus.isLoading || streamStatus.isLoading || !item || !stream) {
// …loader UI…
return (
@@ -658,80 +687,86 @@ export default function page() {
);
return (
<PlayerProvider
playerRef={videoRef}
item={item}
mediaSource={stream?.mediaSource}
isVideoLoaded={isVideoLoaded}
tracksReady={tracksReady}
<View
style={{
flex: 1,
backgroundColor: "black",
height: "100%",
width: "100%",
}}
>
<VideoProvider>
<View
style={{
flex: 1,
backgroundColor: "black",
height: "100%",
width: "100%",
<View
style={{
display: "flex",
width: "100%",
height: "100%",
position: "relative",
flexDirection: "column",
justifyContent: "center",
}}
>
<VlcPlayerView
ref={videoRef}
source={{
uri: stream?.url || "",
autoplay: true,
isNetwork: !offline,
startPosition,
externalSubtitles,
initOptions,
}}
>
<View
style={{
display: "flex",
width: "100%",
height: "100%",
position: "relative",
flexDirection: "column",
justifyContent: "center",
}}
>
<MpvPlayerView
ref={videoRef}
source={videoSource}
style={{ width: "100%", height: "100%" }}
onProgress={onProgress}
onPlaybackStateChange={onPlaybackStateChanged}
onLoad={() => setIsVideoLoaded(true)}
onError={(e) => {
console.error("Video Error:", e.nativeEvent);
Alert.alert(
t("player.error"),
t("player.an_error_occured_while_playing_the_video"),
);
writeToLog("ERROR", "Video Error", e.nativeEvent);
}}
onTracksReady={() => {
setTracksReady(true);
}}
/>
</View>
{isMounted === true && item && !isPipMode && (
<Controls
mediaSource={stream?.mediaSource}
item={item}
togglePlay={togglePlay}
isPlaying={isPlaying}
isSeeking={isSeeking}
progress={progress}
cacheProgress={cacheProgress}
isBuffering={isBuffering}
showControls={showControls}
setShowControls={setShowControls}
startPictureInPicture={startPictureInPicture}
play={play}
pause={pause}
seek={seek}
enableTrickplay={true}
offline={offline}
aspectRatio={aspectRatio}
scaleFactor={scaleFactor}
setAspectRatio={setAspectRatio}
setScaleFactor={setScaleFactor}
api={api}
downloadedFiles={downloadedFiles}
/>
)}
</View>
</VideoProvider>
</PlayerProvider>
style={{ width: "100%", height: "100%" }}
onVideoProgress={onProgress}
progressUpdateInterval={1000}
onVideoStateChange={onPlaybackStateChanged}
onVideoLoadEnd={() => {
setIsVideoLoaded(true);
}}
onVideoError={(e) => {
console.error("Video Error:", e.nativeEvent);
Alert.alert(
t("player.error"),
t("player.an_error_occured_while_playing_the_video"),
);
writeToLog("ERROR", "Video Error", e.nativeEvent);
}}
/>
</View>
{isMounted === true && item && (
<Controls
mediaSource={stream?.mediaSource}
item={item}
videoRef={videoRef}
togglePlay={togglePlay}
isPlaying={isPlaying}
isSeeking={isSeeking}
progress={progress}
cacheProgress={cacheProgress}
isBuffering={isBuffering}
showControls={showControls}
setShowControls={setShowControls}
isVideoLoaded={isVideoLoaded}
startPictureInPicture={startPictureInPicture}
play={play}
pause={pause}
seek={seek}
enableTrickplay={true}
getAudioTracks={getAudioTracks}
getSubtitleTracks={getSubtitleTracks}
offline={offline}
setSubtitleTrack={setSubtitleTrack}
setSubtitleURL={setSubtitleURL}
setAudioTrack={setAudioTrack}
setVideoAspectRatio={setVideoAspectRatio}
setVideoScaleFactor={setVideoScaleFactor}
aspectRatio={aspectRatio}
scaleFactor={scaleFactor}
setAspectRatio={setAspectRatio}
setScaleFactor={setScaleFactor}
isVlc
downloadedFiles={downloadedFiles}
/>
)}
</View>
);
}

View File

@@ -5,6 +5,7 @@ import { DarkTheme, ThemeProvider } from "@react-navigation/native";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import * as BackgroundTask from "expo-background-task";
import * as Device from "expo-device";
import { Paths } from "expo-file-system";
import { Platform } from "react-native";
import { GlobalModal } from "@/components/GlobalModal";
import i18n from "@/i18n";
@@ -13,12 +14,12 @@ import { GlobalModalProvider } from "@/providers/GlobalModalProvider";
import {
apiAtom,
getOrSetDeviceId,
getTokenFromStorage,
JellyfinProvider,
} from "@/providers/JellyfinProvider";
import { NetworkStatusProvider } from "@/providers/NetworkStatusProvider";
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
import { WebSocketProvider } from "@/providers/WebSocketProvider";
import { useSettings } from "@/utils/atoms/settings";
import { type Settings, useSettings } from "@/utils/atoms/settings";
import {
BACKGROUND_FETCH_TASK,
BACKGROUND_FETCH_TASK_SESSIONS,
@@ -34,27 +35,31 @@ import { storage } from "@/utils/mmkv";
const Notifications = !Platform.isTV ? require("expo-notifications") : null;
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
import { getLocales } from "expo-localization";
import { router, Stack, useSegments } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import * as TaskManager from "expo-task-manager";
import { Provider as JotaiProvider } from "jotai";
import { useEffect, useRef, useState } from "react";
import { I18nextProvider } from "react-i18next";
import { Appearance } from "react-native";
import { SystemBars } from "react-native-edge-to-edge";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import "react-native-reanimated";
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
import type { EventSubscription } from "expo-modules-core";
import type {
Notification,
NotificationResponse,
} from "expo-notifications/build/Notifications.types";
import type { ExpoPushToken } from "expo-notifications/build/Tokens.types";
import { router, Stack, useSegments } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import * as TaskManager from "expo-task-manager";
import { Provider as JotaiProvider, useAtom } from "jotai";
import { useCallback, useEffect, useRef, useState } from "react";
import { I18nextProvider } from "react-i18next";
import { Appearance } from "react-native";
import { SystemBars } from "react-native-edge-to-edge";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { useAtom } from "jotai";
import { Toaster } from "sonner-native";
import { userAtom } from "@/providers/JellyfinProvider";
import { store } from "@/utils/store";
import "react-native-reanimated";
import { Toaster } from "sonner-native";
import "../global.css";
if (!Platform.isTV) {
Notifications.setNotificationHandler({
@@ -122,7 +127,24 @@ if (!Platform.isTV) {
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
console.log("TaskManager ~ trigger");
// Background fetch task placeholder - currently unused
const settingsData = storage.getString("settings");
if (!settingsData) return BackgroundTask.BackgroundTaskResult.Failed;
const settings: Partial<Settings> = JSON.parse(settingsData);
if (!settings?.autoDownload)
return BackgroundTask.BackgroundTaskResult.Failed;
const token = getTokenFromStorage();
const deviceId = getOrSetDeviceId();
const baseDirectory = Paths.document.uri;
if (!token || !deviceId || !baseDirectory)
return BackgroundTask.BackgroundTaskResult.Failed;
// Be sure to return the successful result type!
return BackgroundTask.BackgroundTaskResult.Success;
});
}
@@ -187,7 +209,11 @@ export default function RootLayout() {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30000,
staleTime: 0,
refetchOnMount: true,
refetchOnReconnect: true,
refetchOnWindowFocus: true,
retryOnMount: true,
},
},
});
@@ -196,7 +222,7 @@ function Layout() {
const { settings } = useSettings();
const [user] = useAtom(userAtom);
const [api] = useAtom(apiAtom);
const _segments = useSegments();
const segments = useSegments();
useEffect(() => {
i18n.changeLanguage(
@@ -225,7 +251,7 @@ function Layout() {
} else console.log("No token available");
}, [api, expoPushToken, user]);
const registerNotifications = useCallback(async () => {
async function registerNotifications() {
if (Platform.OS === "android") {
console.log("Setting android notification channel 'default'");
await Notifications?.setNotificationChannelAsync("default", {
@@ -256,21 +282,11 @@ function Layout() {
// only create push token for real devices (pointless for emulators)
if (Device.isDevice) {
Notifications?.getExpoPushTokenAsync({
projectId: "e79219d1-797f-4fbe-9fa1-cfd360690a68",
})
.then((token: ExpoPushToken) => {
if (token) {
console.log("Expo push token obtained:", token.data);
setExpoPushToken(token);
}
})
.catch((reason: any) => {
console.error("Failed to get push token:", reason);
writeErrorLog("Failed to get Expo push token", reason);
});
Notifications?.getExpoPushTokenAsync()
.then((token: ExpoPushToken) => token && setExpoPushToken(token))
.catch((reason: any) => console.log("Failed to get token", reason));
}
}, [user]);
}
useEffect(() => {
if (!Platform.isTV) {
@@ -334,70 +350,96 @@ function Layout() {
responseListener.current?.remove();
};
}
}, [user]);
}, [user, api]);
useEffect(() => {
if (Platform.isTV) {
return;
}
if (segments.includes("direct-player" as never)) {
if (
!settings.followDeviceOrientation &&
settings.defaultVideoOrientation
) {
ScreenOrientation.lockAsync(settings.defaultVideoOrientation);
}
return;
}
if (settings.followDeviceOrientation === true) {
ScreenOrientation.unlockAsync();
} else {
ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP,
);
}
}, [
settings.followDeviceOrientation,
settings.defaultVideoOrientation,
segments,
]);
return (
<QueryClientProvider client={queryClient}>
<JellyfinProvider>
<NetworkStatusProvider>
<PlaySettingsProvider>
<LogProvider>
<WebSocketProvider>
<DownloadProvider>
<GlobalModalProvider>
<BottomSheetModalProvider>
<ThemeProvider value={DarkTheme}>
<SystemBars style='light' hidden={false} />
<Stack initialRouteName='(auth)/(tabs)'>
<Stack.Screen
name='(auth)/(tabs)'
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name='(auth)/player'
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name='login'
options={{
headerShown: true,
title: "",
headerTransparent: Platform.OS === "ios",
}}
/>
<Stack.Screen name='+not-found' />
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
<PlaySettingsProvider>
<LogProvider>
<WebSocketProvider>
<DownloadProvider>
<GlobalModalProvider>
<BottomSheetModalProvider>
<ThemeProvider value={DarkTheme}>
<SystemBars style='light' hidden={false} />
<Stack initialRouteName='(auth)/(tabs)'>
<Stack.Screen
name='(auth)/(tabs)'
options={{
headerShown: false,
title: "",
header: () => null,
}}
closeButton
/>
<GlobalModal />
</ThemeProvider>
</BottomSheetModalProvider>
</GlobalModalProvider>
</DownloadProvider>
</WebSocketProvider>
</LogProvider>
</PlaySettingsProvider>
</NetworkStatusProvider>
<Stack.Screen
name='(auth)/player'
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name='login'
options={{
headerShown: true,
title: "",
headerTransparent: true,
}}
/>
<Stack.Screen name='+not-found' />
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}}
closeButton
/>
<GlobalModal />
</ThemeProvider>
</BottomSheetModalProvider>
</GlobalModalProvider>
</DownloadProvider>
</WebSocketProvider>
</LogProvider>
</PlaySettingsProvider>
</JellyfinProvider>
</QueryClientProvider>
);

View File

@@ -10,10 +10,10 @@ import {
Keyboard,
KeyboardAvoidingView,
Platform,
SafeAreaView,
TouchableOpacity,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { z } from "zod";
import { Button } from "@/components/Button";
import { Input } from "@/components/common/Input";
@@ -42,14 +42,14 @@ const Login: React.FC = () => {
const [loadingServerCheck, setLoadingServerCheck] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(false);
const [serverURL, setServerURL] = useState<string>(_apiUrl || "");
const [serverURL, setServerURL] = useState<string>(_apiUrl);
const [serverName, setServerName] = useState<string>("");
const [credentials, setCredentials] = useState<{
username: string;
password: string;
}>({
username: _username || "",
password: _password || "",
username: _username,
password: _password,
});
/**
@@ -62,13 +62,12 @@ const Login: React.FC = () => {
address: _apiUrl,
});
// Wait for server setup and state updates to complete
setTimeout(() => {
if (_username && _password) {
setCredentials({ username: _username, password: _password });
login(_username, _password);
}
}, 0);
}, 300);
}
})();
}, [_apiUrl, _username, _password]);
@@ -264,12 +263,6 @@ const Login: React.FC = () => {
onChangeText={(text: string) =>
setCredentials({ ...credentials, username: text })
}
onEndEditing={(e) => {
const newValue = e.nativeEvent.text;
if (newValue && newValue !== credentials.username) {
setCredentials({ ...credentials, username: newValue });
}
}}
value={credentials.username}
keyboardType='default'
returnKeyType='done'
@@ -278,8 +271,6 @@ const Login: React.FC = () => {
clearButtonMode='while-editing'
maxLength={500}
extraClassName='mb-4'
autoFocus={false}
blurOnSubmit={true}
/>
{/* Password */}
@@ -288,12 +279,6 @@ const Login: React.FC = () => {
onChangeText={(text: string) =>
setCredentials({ ...credentials, password: text })
}
onEndEditing={(e) => {
const newValue = e.nativeEvent.text;
if (newValue && newValue !== credentials.password) {
setCredentials({ ...credentials, password: newValue });
}
}}
value={credentials.password}
secureTextEntry
keyboardType='default'
@@ -303,17 +288,10 @@ const Login: React.FC = () => {
clearButtonMode='while-editing'
maxLength={500}
extraClassName='mb-4'
autoFocus={false}
blurOnSubmit={true}
/>
<View className='mt-4'>
<Button
onPress={handleLogin}
disabled={!credentials.username.trim()}
>
{t("login.login_button")}
</Button>
<Button onPress={handleLogin}>{t("login.login_button")}</Button>
</View>
<View className='mt-3'>
<Button
@@ -355,8 +333,6 @@ const Login: React.FC = () => {
autoCapitalize='none'
textContentType='URL'
maxLength={500}
autoFocus={false}
blurOnSubmit={true}
/>
{/* Full-width primary button */}
@@ -394,13 +370,12 @@ const Login: React.FC = () => {
// Mobile layout
<SafeAreaView style={{ flex: 1, paddingBottom: 16 }}>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
style={{ flex: 1 }}
behavior={Platform.OS === "ios" ? "padding" : "height"}
>
{api?.basePath ? (
<View className='flex flex-col flex-1 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='flex flex-col space-y-2'>
<View className='flex flex-col gap-y-2'>
<Text className='text-2xl font-bold -mb-2'>
{serverName ? (
<>
@@ -417,12 +392,6 @@ const Login: React.FC = () => {
onChangeText={(text) =>
setCredentials({ ...credentials, username: text })
}
onEndEditing={(e) => {
const newValue = e.nativeEvent.text;
if (newValue && newValue !== credentials.username) {
setCredentials({ ...credentials, username: newValue });
}
}}
value={credentials.username}
keyboardType='default'
returnKeyType='done'
@@ -439,12 +408,6 @@ const Login: React.FC = () => {
onChangeText={(text) =>
setCredentials({ ...credentials, password: text })
}
onEndEditing={(e) => {
const newValue = e.nativeEvent.text;
if (newValue && newValue !== credentials.password) {
setCredentials({ ...credentials, password: newValue });
}
}}
value={credentials.password}
secureTextEntry
keyboardType='default'
@@ -458,7 +421,6 @@ const Login: React.FC = () => {
<Button
onPress={handleLogin}
loading={loading}
disabled={!credentials.username.trim()}
className='flex-1 mr-2'
>
{t("login.login_button")}
@@ -480,7 +442,7 @@ const Login: React.FC = () => {
<View className='absolute bottom-0 left-0 w-full px-4 mb-2' />
</View>
) : (
<View className='flex flex-col flex-1 items-center justify-center w-full'>
<View className='flex flex-col h-full items-center justify-center w-full'>
<View className='flex flex-col gap-y-2 px-4 w-full -mt-36'>
<Image
style={{

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"/>
</g>
</g>
</svg>
</svg>

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 384 415" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.133333,0,0,-0.133333,-110.933,512.698)">
<g id="g10">
<path id="path88" d="M3547.01,1831.49C3493.38,1822.66 3262.53,1779.28 2992.01,1820.24C2424.16,1906.21 2154.85,2275.8 1882,2420.24C1473.31,2636.6 1060.97,2644.95 832,2592.03L832,1445.92C832,1321.76 863.078,1198.06 925.307,1090.27C1057.09,862.011 1323.38,718.405 1586.6,736.145C1695.48,743.482 1801.3,777.735 1895.64,832.199L3357.51,1676.21C3424.47,1714.87 3482.92,1761.76 3532.01,1815.41L3547.01,1831.49Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
</g>
</g>
<defs>
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(2879.19,0,0,2879.19,832.651,2289.93)"><stop offset="0" style="stop-color:rgb(149,41,235);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(98,22,247);stop-opacity:1"/></linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 384 415" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.133333,0,0,-0.133333,-110.933,512.698)">
<g id="g10">
<path id="path66" d="M3357.51,2903.64L1895.64,3747.65C1670.29,3877.76 1412.33,3877.76 1186.98,3747.65C961.629,3617.55 832.648,3394.14 832.648,3133.93L832.648,1445.92C832.648,1185.71 961.629,962.305 1186.98,832.199C1412.33,702.094 1670.29,702.094 1895.64,832.199L3357.51,1676.21C3582.86,1806.31 3711.84,2029.71 3711.84,2289.93C3711.84,2550.14 3582.86,2773.54 3357.51,2903.64ZM1721.48,3213.68L3098.31,2454.7C3163.9,2418.55 3193.45,2364.85 3193.45,2289.93C3193.45,2215 3163.93,2161.32 3098.31,2125.15L1721.48,1366.18C1655.87,1330.01 1596.09,1328.72 1531.21,1366.18C1466.34,1403.63 1436.08,1456.03 1436.08,1530.96L1436.08,3048.89C1436.08,3123.77 1466.35,3176.23 1531.21,3213.68C1596.08,3251.11 1655.89,3249.83 1721.48,3213.68" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
</g>
</g>
<defs>
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(2879.19,0,0,2879.19,832.651,2289.93)"><stop offset="0" style="stop-color:rgb(188,74,241);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(227,105,219);stop-opacity:1"/></linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 384 415" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g>
<g id="g10">
<path id="path88" d="M0,319.909L0,234C17.667,234.844 138.649,236.708 195,190C220.441,168.912 271.21,169.515 294.001,178.788C332.576,194.487 378.643,259.549 360,270.644C353.455,277.797 345.662,284.049 336.734,289.204L141.818,401.738C129.24,409 115.13,413.567 100.613,414.546C65.517,416.911 30.012,397.763 12.441,367.329C4.144,352.957 0,336.464 0,319.909Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
</g>
</g>
<defs>
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(2879.19,0,0,2879.19,832.651,2289.93)"><stop offset="0" style="stop-color:rgb(225,102,222);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(204,88,233);stop-opacity:1"/></linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 384 415" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.133333,0,0,-0.133333,-110.933,512.698)">
<g id="g10">
<path id="path28" d="M1427.29,1523.37C1427.29,1447.7 1457.85,1394.77 1523.38,1356.94C1588.91,1319.11 1649.28,1320.41 1715.55,1356.94L3106.14,2123.5C3172.42,2160.03 3202.24,2214.25 3202.24,2289.93C3202.24,2365.6 3172.39,2419.83 3106.14,2456.35L1715.55,3222.91C1649.31,3259.43 1588.89,3260.73 1523.38,3222.91C1457.87,3185.1 1427.29,3132.11 1427.29,3056.48L1427.29,1523.37" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
</g>
</g>
<defs>
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(1.17673e-13,-1921.74,1921.74,1.17673e-13,2314.76,3250.79)"><stop offset="0" style="stop-color:rgb(93,17,250);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(143,40,236);stop-opacity:1"/></linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1,184 +0,0 @@
{
"fill": {
"solid": "display-p3:0.18039,0.18039,0.18039,1.00000"
},
"groups": [
{
"blur-material": 0.3,
"layers": [
{
"fill-specializations": [
{
"value": "none"
},
{
"appearance": "tinted",
"value": {
"automatic-gradient": "display-p3:0.76482,0.76482,0.76482,0.84903"
}
}
],
"glass": true,
"hidden": false,
"image-name": "streamyfin_logo_layer1.svg",
"name": "streamyfin_logo_layer1"
}
],
"opacity": 1,
"position": {
"scale": 1.7,
"translation-in-points": [30, 0]
},
"shadow": {
"kind": "none",
"opacity": 1
},
"specular": true,
"translucency": {
"enabled": true,
"value": 0.6
}
},
{
"blend-mode": "normal",
"blur-material": 0.8,
"hidden": false,
"layers": [
{
"blend-mode": "normal",
"fill-specializations": [
{
"value": "none"
},
{
"appearance": "tinted",
"value": {
"automatic-gradient": "gray:0.75000,1.00000"
}
}
],
"hidden": false,
"image-name": "streamyfin_logo_layer2.svg",
"name": "streamyfin_logo_layer2",
"opacity": 1,
"position": {
"scale": 1,
"translation-in-points": [0, 0]
}
}
],
"lighting": "individual",
"name": "Group",
"opacity": 1,
"position": {
"scale": 1.7,
"translation-in-points": [30, -0.01613253252572302]
},
"shadow": {
"kind": "layer-color",
"opacity": 0.35
},
"specular": true,
"translucency-specializations": [
{
"value": {
"enabled": true,
"value": 0.5
}
},
{
"appearance": "tinted",
"value": {
"enabled": true,
"value": 0.8
}
}
]
},
{
"blend-mode": "normal",
"blur-material": 0.5,
"layers": [
{
"fill-specializations": [
{
"appearance": "tinted",
"value": {
"automatic-gradient": "gray:0.29000,1.00000"
}
}
],
"glass": true,
"hidden": false,
"image-name": "streamyfin_logo_layer3.svg",
"name": "streamyfin_logo_layer3",
"opacity": 0.9
}
],
"name": "Group",
"opacity": 0.8,
"position": {
"scale": 1.7,
"translation-in-points": [30, 0]
},
"shadow": {
"kind": "none",
"opacity": 0.5
},
"specular": true,
"translucency": {
"enabled": true,
"value": 0.7
}
},
{
"blur-material": 0.5,
"hidden": false,
"layers": [
{
"glass": true,
"hidden-specializations": [
{
"value": false
},
{
"appearance": "tinted",
"value": true
}
],
"image-name": "streamyfin_logo_layer4.svg",
"name": "streamyfin_logo_layer4",
"opacity-specializations": [
{
"value": 1
},
{
"appearance": "tinted",
"value": 0
}
]
}
],
"lighting": "combined",
"name": "Group",
"opacity": 0.9,
"position": {
"scale": 1.7,
"translation-in-points": [30, 0]
},
"shadow": {
"kind": "neutral",
"opacity": 0.5
},
"specular": false,
"translucency": {
"enabled": true,
"value": 0.5
}
}
],
"supported-platforms": {
"circles": ["watchOS"],
"squares": "shared"
}
}

View File

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

View File

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

642
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import type { FC } from "react";
import { View, type ViewProps } from "react-native";
import { Platform, View, type ViewProps } from "react-native";
import { RoundButton } from "@/components/RoundButton";
import { useFavorite } from "@/hooks/useFavorite";
@@ -11,11 +11,24 @@ interface Props extends ViewProps {
export const AddToFavorites: FC<Props> = ({ item, ...props }) => {
const { isFavorite, toggleFavorite } = useFavorite(item);
if (Platform.OS === "ios") {
return (
<View {...props}>
<RoundButton
size='large'
icon={isFavorite ? "heart" : "heart-outline"}
onPress={toggleFavorite}
/>
</View>
);
}
return (
<View {...props}>
<RoundButton
size='large'
icon={isFavorite ? "heart" : "heart-outline"}
fillColor={isFavorite ? "primary" : undefined}
onPress={toggleFavorite}
/>
</View>

View File

@@ -2,6 +2,7 @@ import type React from "react";
import {
type PropsWithChildren,
type ReactNode,
useMemo,
useRef,
useState,
} from "react";
@@ -17,58 +18,6 @@ import {
import { useHaptic } from "@/hooks/useHaptic";
import { Loader } from "./Loader";
const getColorClasses = (
color: "purple" | "red" | "black" | "transparent" | "white",
variant: "solid" | "border",
focused: boolean,
): string => {
if (variant === "border") {
switch (color) {
case "purple":
return focused
? "bg-transparent border-2 border-purple-400"
: "bg-transparent border-2 border-purple-600";
case "red":
return focused
? "bg-transparent border-2 border-red-400"
: "bg-transparent border-2 border-red-600";
case "black":
return focused
? "bg-transparent border-2 border-neutral-700"
: "bg-transparent border-2 border-neutral-900";
case "white":
return focused
? "bg-transparent border-2 border-gray-100"
: "bg-transparent border-2 border-white";
case "transparent":
return focused
? "bg-transparent border-2 border-gray-400"
: "bg-transparent border-2 border-gray-600";
default:
return "";
}
} else {
switch (color) {
case "purple":
return focused
? "bg-purple-500 border-2 border-white"
: "bg-purple-600 border border-purple-700";
case "red":
return "bg-red-600";
case "black":
return "bg-neutral-900";
case "white":
return focused
? "bg-gray-100 border-2 border-gray-300"
: "bg-white border border-gray-200";
case "transparent":
return "bg-transparent";
default:
return "";
}
}
};
export interface ButtonProps
extends React.ComponentProps<typeof TouchableOpacity> {
onPress?: () => void;
@@ -77,8 +26,7 @@ export interface ButtonProps
disabled?: boolean;
children?: string | ReactNode;
loading?: boolean;
color?: "purple" | "red" | "black" | "transparent" | "white";
variant?: "solid" | "border";
color?: "purple" | "red" | "black" | "transparent";
iconRight?: ReactNode;
iconLeft?: ReactNode;
justify?: "center" | "between";
@@ -91,7 +39,6 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
disabled = false,
loading = false,
color = "purple",
variant = "solid",
iconRight,
iconLeft,
children,
@@ -109,13 +56,23 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
useNativeDriver: true,
}).start();
const colorClasses = getColorClasses(color, variant, focused);
const colorClasses = useMemo(() => {
switch (color) {
case "purple":
return focused
? "bg-purple-500 border-2 border-white"
: "bg-purple-600 border border-purple-700";
case "red":
return "bg-red-600";
case "black":
return "bg-neutral-900";
case "transparent":
return "bg-transparent";
}
}, [color, focused]);
const lightHapticFeedback = useHaptic("light");
const textColorClass =
color === "white" && variant === "solid" ? "text-black" : "text-white";
return Platform.isTV ? (
<Pressable
className='w-full'
@@ -141,12 +98,10 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
>
<View
className={`rounded-2xl py-5 items-center justify-center
${colorClasses}
${focused ? "bg-purple-500 border-2 border-white" : "bg-purple-600 border border-purple-700"}
${className}`}
>
<Text className={`${textColorClass} text-xl font-bold`}>
{children}
</Text>
<Text className='text-white text-xl font-bold'>{children}</Text>
</View>
</Animated.View>
</Pressable>
@@ -180,7 +135,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
{iconLeft ? iconLeft : <View className='w-4' />}
<Text
className={`
${textColorClass} font-bold text-base
text-white font-bold text-base
${disabled ? "text-gray-300" : ""}
${textClassName}
${iconRight ? "mr-2" : ""}

View File

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

View File

@@ -89,8 +89,11 @@ export const DownloadItems: React.FC<DownloadProps> = ({
bottomSheetModalRef.current?.present();
}, []);
const handleSheetChanges = useCallback((_index: number) => {
// Modal state tracking handled by BottomSheetModal
const handleSheetChanges = useCallback((index: number) => {
// Ensure modal is fully dismissed when index is -1
if (index === -1) {
// Modal is fully closed
}
}, []);
const closeModal = useCallback(() => {
@@ -132,15 +135,13 @@ export const DownloadItems: React.FC<DownloadProps> = ({
return itemsNotDownloaded.length === 0;
}, [items, itemsNotDownloaded]);
const itemsProcesses = useMemo(
() =>
processes?.filter((p) => p?.item?.Id && itemIds.includes(p.item.Id)) ||
[],
() => processes?.filter((p) => itemIds.includes(p.item.Id)),
[processes, itemIds],
);
const progress = useMemo(() => {
if (itemIds.length === 1)
return itemsProcesses.reduce((acc, p) => acc + (p.progress || 0), 0);
return itemsProcesses.reduce((acc, p) => acc + p.progress, 0);
return (
((itemIds.length -
queue.filter((q) => itemIds.includes(q.item.Id)).length) /
@@ -155,13 +156,6 @@ export const DownloadItems: React.FC<DownloadProps> = ({
itemsNotDownloaded.every((p) => queue.some((q) => p.Id === q.item.Id))
);
}, [queue, itemsNotDownloaded]);
const itemsInProgressOrQueued = useMemo(() => {
const inProgress = itemsProcesses.length;
const inQueue = queue.filter((q) => itemIds.includes(q.item.Id)).length;
return inProgress + inQueue;
}, [itemsProcesses, queue, itemIds]);
const navigateToDownloads = () => router.push("/downloads");
const onDownloadedPress = () => {
@@ -261,12 +255,13 @@ export const DownloadItems: React.FC<DownloadProps> = ({
throw new Error("No item id");
}
closeModal();
// Ensure modal is dismissed before starting download
await closeModal();
// Wait for modal dismiss animation to complete
// Small delay to ensure modal is fully dismissed
setTimeout(() => {
initiateDownload(...itemsToDownload);
}, 300);
}, 100);
} else {
toast.error(
t("home.downloads.toasts.you_are_not_allowed_to_download_files"),
@@ -286,14 +281,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
);
const renderButtonContent = () => {
// For single item downloads, show progress if item is being processed
// For multi-item downloads (season/series), show progress only if 2+ items are in progress or queued
const shouldShowProgress =
itemIds.length === 1
? itemsProcesses.length > 0
: itemsInProgressOrQueued > 1;
if (processes.length > 0 && shouldShowProgress) {
if (processes.length > 0 && itemsProcesses.length > 0) {
return progress === 0 ? (
<Loader />
) : (
@@ -347,6 +335,9 @@ export const DownloadItems: React.FC<DownloadProps> = ({
backgroundColor: "#171717",
}}
onChange={handleSheetChanges}
onDismiss={() => {
// Ensure any pending state is cleared when modal is dismissed
}}
backdropComponent={renderBackdrop}
enablePanDownToClose
enableDismissOnClose

View File

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

View File

@@ -64,8 +64,6 @@ export const GlobalModal = () => {
backgroundStyle={modalOptions.backgroundStyle}
enablePanDownToClose={modalOptions.enablePanDownToClose}
enableDismissOnClose
stackBehavior='push'
style={{ zIndex: 1000 }}
>
{modalState.content}
</BottomSheetModal>

View File

@@ -6,12 +6,12 @@ import { Image } from "expo-image";
import { useNavigation } from "expo-router";
import { useAtom } from "jotai";
import React, { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { type Bitrate } from "@/components/BitrateSelector";
import { ItemImage } from "@/components/common/ItemImage";
import { DownloadSingleItem } from "@/components/DownloadItem";
import { MediaSourceButton } from "@/components/MediaSourceButton";
import { OverviewText } from "@/components/OverviewText";
import { ParallaxScrollView } from "@/components/ParallaxPage";
// const PlayButton = !Platform.isTV ? require("@/components/PlayButton") : null;
@@ -29,10 +29,13 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { AddToFavorites } from "./AddToFavorites";
import { BitrateSheet } from "./BitRateSheet";
import { ItemHeader } from "./ItemHeader";
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
import { MediaSourceSheet } from "./MediaSourceSheet";
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
import { PlayInRemoteSessionButton } from "./PlayInRemoteSession";
import { TrackSheet } from "./TrackSheet";
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
@@ -56,6 +59,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
const navigation = useNavigation();
const insets = useSafeAreaInsets();
const [user] = useAtom(userAtom);
const { t } = useTranslation();
const itemColors = useImageColorsReturn({ item });
@@ -71,7 +75,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
defaultBitrate,
defaultMediaSource,
defaultSubtitleIndex,
} = useDefaultPlaySettings(item, settings);
} = useDefaultPlaySettings(item!, settings);
const logoUrl = useMemo(
() => (item ? getLogoImageUrlById({ api, item }) : null),
@@ -86,7 +90,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
useEffect(() => {
setSelectedOptions(() => ({
bitrate: defaultBitrate,
mediaSource: defaultMediaSource ?? undefined,
mediaSource: defaultMediaSource,
subtitleIndex: defaultSubtitleIndex ?? -1,
audioIndex: defaultAudioIndex,
}));
@@ -98,7 +102,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
]);
useEffect(() => {
if (!Platform.isTV && item) {
if (!Platform.isTV) {
navigation.setOptions({
headerRight: () =>
item &&
@@ -139,7 +143,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
)),
});
}
}, [item, navigation, user, item]);
}, [item, navigation, user]);
useEffect(() => {
if (item) {
@@ -199,25 +203,74 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
<View className='flex flex-col bg-transparent shrink'>
<View className='flex flex-col px-4 w-full pt-2 mb-2 shrink'>
<ItemHeader item={item} className='mb-2' />
<View className='flex flex-row px-0 mb-2 justify-between space-x-2'>
<PlayButton
selectedOptions={selectedOptions}
item={item}
isOffline={isOffline}
colors={itemColors}
/>
<View className='w-1' />
{!isOffline && (
<MediaSourceButton
selectedOptions={selectedOptions}
setSelectedOptions={setSelectedOptions}
item={item}
colors={itemColors}
{item.Type !== "Program" && !Platform.isTV && !isOffline && (
<View className='flex flex-row items-center justify-start w-full h-16 mb-2'>
<BitrateSheet
className='mr-1'
onChange={(val) =>
setSelectedOptions(
(prev) => prev && { ...prev, bitrate: val },
)
}
selected={selectedOptions.bitrate}
/>
)}
</View>
<MediaSourceSheet
className='mr-1'
item={item}
onChange={(val) =>
setSelectedOptions(
(prev) =>
prev && {
...prev,
mediaSource: val,
},
)
}
selected={selectedOptions.mediaSource}
/>
<TrackSheet
className='mr-1'
streamType='Audio'
title={t("item_card.audio")}
source={selectedOptions.mediaSource}
onChange={(val) => {
setSelectedOptions(
(prev) =>
prev && {
...prev,
audioIndex: val,
},
);
}}
selected={selectedOptions.audioIndex}
/>
<TrackSheet
source={selectedOptions.mediaSource}
streamType='Subtitle'
title={t("item_card.subtitles")}
onChange={(val) =>
setSelectedOptions(
(prev) =>
prev && {
...prev,
subtitleIndex: val,
},
)
}
selected={selectedOptions.subtitleIndex}
/>
</View>
)}
<PlayButton
className='grow'
selectedOptions={selectedOptions}
item={item}
isOffline={isOffline}
colors={itemColors}
/>
</View>
{item.Type === "Episode" && (
<SeasonEpisodesCarousel
item={item}
@@ -226,32 +279,29 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
/>
)}
{!isOffline &&
selectedOptions.mediaSource?.MediaStreams &&
selectedOptions.mediaSource.MediaStreams.length > 0 && (
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
)}
{!isOffline && (
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
)}
<OverviewText text={item.Overview} className='px-4 mb-4' />
{item.Type !== "Program" && (
<>
{item.Type === "Episode" && !isOffline && (
<CurrentSeries item={item} className='mb-4' />
<CurrentSeries item={item} className='' />
)}
{!isOffline && (
<CastAndCrew item={item} className='mb-4' loading={loading} />
<CastAndCrew item={item} className='' loading={loading} />
)}
{item.People && item.People.length > 0 && !isOffline && (
<View className='mb-4'>
<View className=''>
{item.People.slice(0, 3).map((person, idx) => (
<MoreMoviesWithActor
currentItem={item}
key={idx}
actorId={person.Id!}
className='mb-4'
className=''
/>
))}
</View>

View File

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

View File

@@ -1,193 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import type {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
import { BITRATES } from "./BitRateSheet";
import type { SelectedOptions } from "./ItemContent";
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
interface Props extends React.ComponentProps<typeof TouchableOpacity> {
item?: BaseItemDto | null;
selectedOptions: SelectedOptions;
setSelectedOptions: React.Dispatch<
React.SetStateAction<SelectedOptions | undefined>
>;
colors?: ThemeColors;
}
export const MediaSourceButton: React.FC<Props> = ({
item,
selectedOptions,
setSelectedOptions,
colors,
}: Props) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const effectiveColors = colors || {
primary: "#7c3aed",
text: "#000000",
};
useEffect(() => {
const firstMediaSource = item?.MediaSources?.[0];
if (!firstMediaSource) return;
setSelectedOptions((prev) => {
if (!prev) return prev;
return {
...prev,
mediaSource: firstMediaSource,
};
});
}, [item, setSelectedOptions]);
const getMediaSourceDisplayName = useCallback((source: MediaSourceInfo) => {
const videoStream = source.MediaStreams?.find((x) => x.Type === "Video");
if (source.Name) return source.Name;
if (videoStream?.DisplayTitle) return videoStream.DisplayTitle;
return `Source ${source.Id}`;
}, []);
const audioStreams = useMemo(
() =>
selectedOptions.mediaSource?.MediaStreams?.filter(
(x) => x.Type === "Audio",
) || [],
[selectedOptions.mediaSource],
);
const subtitleStreams = useMemo(
() =>
selectedOptions.mediaSource?.MediaStreams?.filter(
(x) => x.Type === "Subtitle",
) || [],
[selectedOptions.mediaSource],
);
const optionGroups: OptionGroup[] = useMemo(() => {
const groups: OptionGroup[] = [];
// Bitrate group
groups.push({
title: t("item_card.quality"),
options: BITRATES.map((bitrate) => ({
type: "radio" as const,
label: bitrate.key,
value: bitrate,
selected: bitrate.value === selectedOptions.bitrate?.value,
onPress: () =>
setSelectedOptions((prev) => prev && { ...prev, bitrate }),
})),
});
// Media Source group (only if multiple sources)
if (item?.MediaSources && item.MediaSources.length > 1) {
groups.push({
title: t("item_card.video"),
options: item.MediaSources.map((source) => ({
type: "radio" as const,
label: getMediaSourceDisplayName(source),
value: source,
selected: source.Id === selectedOptions.mediaSource?.Id,
onPress: () =>
setSelectedOptions(
(prev) => prev && { ...prev, mediaSource: source },
),
})),
});
}
// Audio track group
if (audioStreams.length > 0) {
groups.push({
title: t("item_card.audio"),
options: audioStreams.map((stream) => ({
type: "radio" as const,
label: stream.DisplayTitle || `${t("common.track")} ${stream.Index}`,
value: stream.Index,
selected: stream.Index === selectedOptions.audioIndex,
onPress: () =>
setSelectedOptions(
(prev) => prev && { ...prev, audioIndex: stream.Index ?? 0 },
),
})),
});
}
// Subtitle track group (with None option)
if (subtitleStreams.length > 0) {
const noneOption = {
type: "radio" as const,
label: t("common.none"),
value: -1,
selected: selectedOptions.subtitleIndex === -1,
onPress: () =>
setSelectedOptions((prev) => prev && { ...prev, subtitleIndex: -1 }),
};
const subtitleOptions = subtitleStreams.map((stream) => ({
type: "radio" as const,
label: stream.DisplayTitle || `${t("common.track")} ${stream.Index}`,
value: stream.Index,
selected: stream.Index === selectedOptions.subtitleIndex,
onPress: () =>
setSelectedOptions(
(prev) => prev && { ...prev, subtitleIndex: stream.Index ?? -1 },
),
}));
groups.push({
title: t("item_card.subtitles"),
options: [noneOption, ...subtitleOptions],
});
}
return groups;
}, [
item,
selectedOptions,
audioStreams,
subtitleStreams,
getMediaSourceDisplayName,
t,
setSelectedOptions,
]);
const trigger = (
<TouchableOpacity
disabled={!item}
onPress={() => setOpen(true)}
className='relative'
>
<View
style={{ backgroundColor: effectiveColors.primary, opacity: 0.7 }}
className='absolute w-12 h-12 rounded-full'
/>
<View className='w-12 h-12 rounded-full z-10 items-center justify-center'>
{!item ? (
<ActivityIndicator size='small' color={effectiveColors.text} />
) : (
<Ionicons name='list' size={24} color={effectiveColors.text} />
)}
</View>
</TouchableOpacity>
);
return (
<PlatformDropdown
groups={optionGroups}
trigger={trigger}
title={t("item_card.media_options")}
open={open}
onOpenChange={setOpen}
bottomSheetConfig={{
enablePanDownToClose: true,
}}
/>
);
};

View File

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

View File

@@ -99,7 +99,7 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
style={{
top: -50,
}}
className='relative flex-1 bg-transparent pb-4'
className='relative flex-1 bg-transparent pb-24'
>
<LinearGradient
// Background Linear Gradient

View File

@@ -127,8 +127,7 @@ const BottomSheetContent: React.FC<{
title?: string;
groups: OptionGroup[];
onOptionSelect?: (value?: any) => void;
onClose?: () => void;
}> = ({ title, groups, onOptionSelect, onClose }) => {
}> = ({ title, groups, onOptionSelect }) => {
const insets = useSafeAreaInsets();
// Wrap the groups to call onOptionSelect when an option is pressed
@@ -141,7 +140,6 @@ const BottomSheetContent: React.FC<{
onPress: () => {
option.onPress();
onOptionSelect?.(option.value);
onClose?.();
},
};
}
@@ -178,26 +176,22 @@ const PlatformDropdownComponent = ({
trigger,
title,
groups,
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
open,
onOpenChange,
onOptionSelect,
expoUIConfig,
bottomSheetConfig,
}: PlatformDropdownProps) => {
const { showModal, hideModal, isVisible } = useGlobalModal();
const { showModal, hideModal } = useGlobalModal();
// Handle controlled open state for Android
useEffect(() => {
if (Platform.OS === "android" && controlledOpen === true) {
const handlePress = () => {
if (Platform.OS === "android") {
onOpenChange?.(true);
showModal(
<BottomSheetContent
title={title}
groups={groups}
onOptionSelect={onOptionSelect}
onClose={() => {
hideModal();
controlledOnOpenChange?.(false);
}}
/>,
{
snapPoints: ["90%"],
@@ -205,15 +199,14 @@ const PlatformDropdownComponent = ({
},
);
}
}, [controlledOpen]);
};
// Watch for modal dismissal on Android (e.g., swipe down, backdrop tap)
// and sync the controlled open state
// Close modal when open prop changes to false
useEffect(() => {
if (Platform.OS === "android" && controlledOpen === true && !isVisible) {
controlledOnOpenChange?.(false);
if (Platform.OS === "android" && open === false) {
hideModal();
}
}, [isVisible, controlledOpen, controlledOnOpenChange]);
}, [open, hideModal]);
if (Platform.OS === "ios") {
return (
@@ -307,24 +300,9 @@ const PlatformDropdownComponent = ({
);
}
// Android: Direct modal trigger
const handlePress = () => {
showModal(
<BottomSheetContent
title={title}
groups={groups}
onOptionSelect={onOptionSelect}
onClose={hideModal}
/>,
{
snapPoints: ["90%"],
enablePanDownToClose: bottomSheetConfig?.enablePanDownToClose ?? true,
},
);
};
// Android: Trigger button for bottom modal
return (
<TouchableOpacity onPress={handlePress} activeOpacity={0.7}>
<TouchableOpacity onPress={handlePress}>
{trigger || <Text className='text-white'>Open Menu</Text>}
</TouchableOpacity>
);

View File

@@ -1,6 +1,6 @@
import { useActionSheet } from "@expo/react-native-action-sheet";
import { Feather, Ionicons } from "@expo/vector-icons";
import { BottomSheetView } from "@gorhom/bottom-sheet";
import { Button, Host } from "@expo/ui/swift-ui";
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useRouter } from "expo-router";
import { useAtom, useAtomValue } from "jotai";
@@ -25,8 +25,6 @@ import Animated, {
} from "react-native-reanimated";
import { useHaptic } from "@/hooks/useHaptic";
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
import { getDownloadedItemById } from "@/providers/Downloads/database";
import { useGlobalModal } from "@/providers/GlobalModalProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings";
@@ -36,8 +34,6 @@ import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { chromecast } from "@/utils/profiles/chromecast";
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
import { runtimeTicksToMinutes } from "@/utils/time";
import { Button } from "./Button";
import { Text } from "./common/Text";
import type { SelectedOptions } from "./ItemContent";
interface Props extends React.ComponentProps<typeof TouchableOpacity> {
@@ -55,12 +51,12 @@ export const PlayButton: React.FC<Props> = ({
selectedOptions,
isOffline,
colors,
...props
}: Props) => {
const { showActionSheetWithOptions } = useActionSheet();
const client = useRemoteMediaClient();
const mediaStatus = useMediaStatus();
const { t } = useTranslation();
const { showModal, hideModal } = useGlobalModal();
const [globalColorAtom] = useAtom(itemThemeColorAtom);
const api = useAtomValue(apiAtom);
@@ -90,9 +86,12 @@ export const PlayButton: React.FC<Props> = ({
[router, isOffline],
);
const handleNormalPlayFlow = useCallback(async () => {
const onPress = useCallback(async () => {
console.log("onPress");
if (!item) return;
lightHapticFeedback();
const queryParams = new URLSearchParams({
itemId: item.Id!,
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
@@ -166,7 +165,7 @@ export const PlayButton: React.FC<Props> = ({
api,
item,
deviceProfile: enableH265 ? chromecasth265 : chromecast,
startTimeTicks: item?.UserData?.PlaybackPositionTicks ?? 0,
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
userId: user.Id,
audioStreamIndex: selectedOptions.audioIndex,
maxStreamingBitrate: selectedOptions.bitrate?.value,
@@ -274,117 +273,6 @@ export const PlayButton: React.FC<Props> = ({
showActionSheetWithOptions,
mediaStatus,
selectedOptions,
goToPlayer,
isOffline,
t,
]);
const onPress = useCallback(async () => {
if (!item) return;
lightHapticFeedback();
// Check if item is downloaded
const downloadedItem = item.Id ? getDownloadedItemById(item.Id) : undefined;
if (downloadedItem) {
if (Platform.OS === "android") {
// Show bottom sheet for Android
showModal(
<BottomSheetView>
<View className='px-4 mt-4 mb-12'>
<View className='pb-6'>
<Text className='text-2xl font-bold mb-2'>
{t("player.downloaded_file_title")}
</Text>
<Text className='opacity-70 text-base'>
{t("player.downloaded_file_message")}
</Text>
</View>
<View className='space-y-3'>
<Button
onPress={() => {
hideModal();
const queryParams = new URLSearchParams({
itemId: item.Id!,
offline: "true",
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
});
goToPlayer(queryParams.toString());
}}
color='purple'
>
{Platform.OS === "android"
? "Play downloaded file"
: t("player.downloaded_file_yes")}
</Button>
<Button
onPress={() => {
hideModal();
handleNormalPlayFlow();
}}
color='white'
variant='border'
>
{Platform.OS === "android"
? "Stream file"
: t("player.downloaded_file_no")}
</Button>
</View>
</View>
</BottomSheetView>,
{
snapPoints: ["35%"],
enablePanDownToClose: true,
},
);
} else {
// Show alert for iOS
Alert.alert(
t("player.downloaded_file_title"),
t("player.downloaded_file_message"),
[
{
text: t("player.downloaded_file_yes"),
onPress: () => {
const queryParams = new URLSearchParams({
itemId: item.Id!,
offline: "true",
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
});
goToPlayer(queryParams.toString());
},
isPreferred: true,
},
{
text: t("player.downloaded_file_no"),
onPress: () => {
handleNormalPlayFlow();
},
},
{
text: t("player.downloaded_file_cancel"),
style: "cancel",
},
],
);
}
return;
}
// If not downloaded, proceed with normal flow
handleNormalPlayFlow();
}, [
item,
lightHapticFeedback,
handleNormalPlayFlow,
goToPlayer,
t,
showModal,
hideModal,
effectiveColors,
]);
const derivedTargetWidth = useDerivedValue(() => {
@@ -472,6 +360,49 @@ export const PlayButton: React.FC<Props> = ({
[startColor.value.text, endColor.value.text],
),
}));
/**
* *********************
*/
if (Platform.OS === "ios")
return (
<Host
style={{
height: 50,
flex: 1,
}}
>
<Button
variant='glassProminent'
onPress={onPress}
color={effectiveColors.primary}
>
<View className='flex flex-row items-center gap-x-2 h-full w-full justify-center -mb-3.5 '>
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
{runtimeTicksToMinutes(item?.RunTimeTicks)}
</Animated.Text>
<Animated.Text style={animatedTextStyle}>
<Ionicons name='play-circle' size={24} />
</Animated.Text>
{client && (
<Animated.Text style={animatedTextStyle}>
<Feather name='cast' size={22} />
<CastButton tintColor='transparent' />
</Animated.Text>
)}
{!client && settings?.openInVLC && (
<Animated.Text style={animatedTextStyle}>
<MaterialCommunityIcons
name='vlc'
size={18}
color={animatedTextStyle.color}
/>
</Animated.Text>
)}
</View>
</Button>
</Host>
);
return (
<TouchableOpacity
@@ -479,7 +410,8 @@ export const PlayButton: React.FC<Props> = ({
accessibilityLabel='Play button'
accessibilityHint='Tap to play the media'
onPress={onPress}
className={"relative flex-1"}
className={"relative"}
{...props}
>
<View className='absolute w-full h-full top-0 left-0 rounded-full z-10 overflow-hidden'>
<Animated.View
@@ -507,11 +439,7 @@ export const PlayButton: React.FC<Props> = ({
>
<View className='flex flex-row items-center space-x-2'>
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
{runtimeTicksToMinutes(
(item?.RunTimeTicks || 0) -
(item?.UserData?.PlaybackPositionTicks || 0),
)}
{(item?.UserData?.PlaybackPositionTicks || 0) > 0 && " left"}
{runtimeTicksToMinutes(item?.RunTimeTicks)}
</Animated.Text>
<Animated.Text style={animatedTextStyle}>
<Ionicons name='play-circle' size={24} />
@@ -522,6 +450,15 @@ export const PlayButton: React.FC<Props> = ({
<CastButton tintColor='transparent' />
</Animated.Text>
)}
{!client && settings?.openInVLC && (
<Animated.Text style={animatedTextStyle}>
<MaterialCommunityIcons
name='vlc'
size={18}
color={animatedTextStyle.color}
/>
</Animated.Text>
)}
</View>
</View>
</TouchableOpacity>

View File

@@ -1,4 +1,4 @@
import { Ionicons } from "@expo/vector-icons";
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useRouter } from "expo-router";
import { useAtom } from "jotai";
@@ -17,6 +17,7 @@ import Animated, {
import { useHaptic } from "@/hooks/useHaptic";
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings";
import { runtimeTicksToMinutes } from "@/utils/time";
import type { Button } from "./Button";
import type { SelectedOptions } from "./ItemContent";
@@ -49,6 +50,7 @@ export const PlayButton: React.FC<Props> = ({
const startColor = useSharedValue(effectiveColors);
const widthProgress = useSharedValue(0);
const colorChangeProgress = useSharedValue(0);
const { settings } = useSettings();
const lightHapticFeedback = useHaptic("light");
const goToPlayer = useCallback(
@@ -59,6 +61,7 @@ export const PlayButton: React.FC<Props> = ({
);
const onPress = () => {
console.log("onpress");
if (!item) return;
lightHapticFeedback();
@@ -204,6 +207,15 @@ export const PlayButton: React.FC<Props> = ({
<Animated.Text style={animatedTextStyle}>
<Ionicons name='play-circle' size={24} />
</Animated.Text>
{settings?.openInVLC && (
<Animated.Text style={animatedTextStyle}>
<MaterialCommunityIcons
name='vlc'
size={18}
color={animatedTextStyle.color}
/>
</Animated.Text>
)}
</View>
</View>
</TouchableOpacity>

View File

@@ -1,6 +1,6 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import type React from "react";
import { View, type ViewProps } from "react-native";
import { Platform, View, type ViewProps } from "react-native";
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
import { RoundButton } from "./RoundButton";
@@ -14,10 +14,25 @@ export const PlayedStatus: React.FC<Props> = ({ items, ...props }) => {
const allPlayed = items.every((item) => item.UserData?.Played);
const toggle = useMarkAsPlayed(items);
if (Platform.OS === "ios") {
return (
<View {...props}>
<RoundButton
color={allPlayed ? "purple" : "white"}
icon={allPlayed ? "checkmark" : "checkmark"}
onPress={async () => {
await toggle(!allPlayed);
}}
size={props.size}
/>
</View>
);
}
return (
<View {...props}>
<RoundButton
color={allPlayed ? "purple" : "white"}
fillColor={allPlayed ? "primary" : undefined}
icon={allPlayed ? "checkmark" : "checkmark"}
onPress={async () => {
await toggle(!allPlayed);

View File

@@ -96,7 +96,7 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
<TouchableOpacity
onPress={handlePress}
className={`rounded-full ${buttonSize} flex items-center justify-center ${
fillColor ? fillColorClass : "bg-transparent"
fillColor ? fillColorClass : "bg-neutral-800/80"
}`}
{...(viewProps as any)}
>

View File

@@ -33,27 +33,12 @@ export const TrackSheet: React.FC<Props> = ({
() => streams?.find((x) => x.Index === selected),
[streams, selected],
);
const noneOption = useMemo(
() => ({ Index: -1, DisplayTitle: t("common.none") }),
[t],
);
// Creates a modified data array that includes a "None" option for subtitles
// We might want to possibly do this for other places, like audio?
const addNoneToSubtitles = useMemo(() => {
if (streamType === "Subtitle") {
const result = streams ? [noneOption, ...streams] : [noneOption];
return result;
}
return streams;
}, [streams, streamType, noneOption]);
const [open, setOpen] = useState(false);
if (isTv || (streams && streams.length === 0)) return null;
return (
<View className='flex shrink' style={{ minWidth: 60 }} {...props}>
<View className='flex shrink' style={{ minWidth: 25 }} {...props}>
<View className='flex flex-col'>
<Text className='opacity-50 mb-1 text-xs'>{title}</Text>
<TouchableOpacity
@@ -61,9 +46,7 @@ export const TrackSheet: React.FC<Props> = ({
onPress={() => setOpen(true)}
>
<Text numberOfLines={1}>
{selected === -1 && streamType === "Subtitle"
? t("common.none")
: selectedSteam?.DisplayTitle || t("common.select", "Select")}
{selectedSteam?.DisplayTitle || t("common.select", "Select")}
</Text>
</TouchableOpacity>
</View>
@@ -71,14 +54,8 @@ export const TrackSheet: React.FC<Props> = ({
open={open}
setOpen={setOpen}
title={title}
data={addNoneToSubtitles || []}
values={
selected === -1 && streamType === "Subtitle"
? [{ Index: -1, DisplayTitle: t("common.none") }]
: selectedSteam
? [selectedSteam]
: []
}
data={streams || []}
values={selectedSteam ? [selectedSteam] : []}
multiple={false}
searchFilter={(item, query) => {
const label = (item as any).DisplayTitle || "";

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

@@ -10,18 +10,11 @@ import { LinearGradient } from "expo-linear-gradient";
import { useRouter } from "expo-router";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Pressable,
TouchableOpacity,
useWindowDimensions,
View,
} from "react-native";
import { Dimensions, Pressable, TouchableOpacity, View } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
Easing,
interpolate,
runOnJS,
type SharedValue,
useAnimatedStyle,
useSharedValue,
withTiming,
@@ -42,10 +35,12 @@ import { MarkAsPlayedLargeButton } from "./MarkAsPlayedLargeButton";
interface AppleTVCarouselProps {
initialIndex?: number;
onItemChange?: (index: number) => void;
scrollOffset?: SharedValue<number>;
}
const { width: screenWidth, height: screenHeight } = Dimensions.get("window");
// Layout Constants
const CAROUSEL_HEIGHT = screenHeight / 1.45;
const GRADIENT_HEIGHT_TOP = 150;
const GRADIENT_HEIGHT_BOTTOM = 150;
const LOGO_HEIGHT = 80;
@@ -66,7 +61,7 @@ const PLAYED_STATUS_SKELETON_SIZE = 40;
const TEXT_SKELETON_HEIGHT = 20;
const TEXT_SKELETON_WIDTH = 250;
const OVERVIEW_SKELETON_HEIGHT = 16;
const OVERVIEW_SKELETON_WIDTH = 400;
const OVERVIEW_SKELETON_WIDTH = 300;
const _EMPTY_STATE_ICON_SIZE = 64;
// Spacing Constants
@@ -159,21 +154,14 @@ const DotIndicator = ({
export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
initialIndex = 0,
onItemChange,
scrollOffset,
}) => {
const { settings } = useSettings();
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const { isConnected, serverConnected } = useNetworkStatus();
const router = useRouter();
const { width: screenWidth, height: screenHeight } = useWindowDimensions();
const isLandscape = screenWidth >= screenHeight;
const carouselHeight = useMemo(
() => (isLandscape ? screenHeight * 0.9 : screenHeight / 1.45),
[isLandscape, screenHeight],
);
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const translateX = useSharedValue(-initialIndex * screenWidth);
const translateX = useSharedValue(-currentIndex * screenWidth);
const isQueryEnabled =
!!api && !!user?.Id && isConnected && serverConnected === true;
@@ -237,21 +225,11 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
const nextItems = nextUpData ?? [];
const recentItems = recentlyAddedData ?? [];
const allItems = [
return [
...continueItems.slice(0, 2),
...nextItems.slice(0, 2),
...recentItems.slice(0, 2),
];
// Deduplicate by item ID to prevent duplicate keys
const seen = new Set<string>();
return allItems.filter((item) => {
if (item.Id && !seen.has(item.Id)) {
seen.add(item.Id);
return true;
}
return false;
});
}, [continueWatchingData, nextUpData, recentlyAddedData]);
const isLoading =
@@ -310,11 +288,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
translateX.value = -newIndex * screenWidth;
return newIndex;
});
}, [hasItems, items, initialIndex, screenWidth, translateX]);
useEffect(() => {
translateX.value = -currentIndex * screenWidth;
}, [currentIndex, screenWidth, translateX]);
}, [hasItems, items, initialIndex, translateX]);
useEffect(() => {
if (hasItems) {
@@ -334,7 +308,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
setCurrentIndex(index);
onItemChange?.(index);
},
[hasItems, items, onItemChange, screenWidth, translateX],
[hasItems, items, onItemChange, translateX],
);
const navigateToItem = useCallback(
@@ -383,28 +357,6 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
const togglePlayedStatus = useMarkAsPlayed(items);
const headerAnimatedStyle = useAnimatedStyle(() => {
if (!scrollOffset) return {};
return {
transform: [
{
translateY: interpolate(
scrollOffset.value,
[-carouselHeight, 0, carouselHeight],
[-carouselHeight / 2, 0, carouselHeight * 0.75],
),
},
{
scale: interpolate(
scrollOffset.value,
[-carouselHeight, 0, carouselHeight],
[2, 1, 1],
),
},
],
};
});
const renderDots = () => {
if (!hasItems || items.length <= 1) return null;
@@ -438,7 +390,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
<View
style={{
width: screenWidth,
height: carouselHeight,
height: CAROUSEL_HEIGHT,
backgroundColor: "#000",
}}
>
@@ -636,30 +588,20 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
key={item.Id}
style={{
width: screenWidth,
height: carouselHeight,
height: CAROUSEL_HEIGHT,
position: "relative",
}}
>
{/* Background Backdrop */}
<Animated.View
style={[
{
width: "100%",
height: "100%",
position: "absolute",
},
headerAnimatedStyle,
]}
>
<ItemImage
item={item}
variant='Backdrop'
style={{
width: "100%",
height: "100%",
}}
/>
</Animated.View>
<ItemImage
item={item}
variant='Backdrop'
style={{
width: "100%",
height: "100%",
position: "absolute",
}}
/>
{/* Dark Overlay */}
<View
@@ -864,7 +806,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
return (
<View
style={{
height: carouselHeight,
height: CAROUSEL_HEIGHT,
backgroundColor: "#000",
overflow: "hidden",
}}
@@ -882,7 +824,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
return (
<View
style={{
height: carouselHeight, // Fixed height instead of flex: 1
height: CAROUSEL_HEIGHT, // Fixed height instead of flex: 1
backgroundColor: "#000",
overflow: "hidden",
}}
@@ -891,7 +833,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
<Animated.View
style={[
{
height: carouselHeight, // Fixed height instead of flex: 1
height: CAROUSEL_HEIGHT, // Fixed height instead of flex: 1
flexDirection: "row",
width: screenWidth * items.length,
},

View File

@@ -26,7 +26,7 @@ export const HeaderBackButton: React.FC<Props> = ({
className='flex items-center justify-center w-9 h-9'
{...touchableOpacityProps}
>
<Ionicons name='arrow-back' size={24} color='white' />
<Ionicons name='chevron-back' size={24} color='white' />
</TouchableOpacity>
);
}
@@ -55,7 +55,7 @@ export const HeaderBackButton: React.FC<Props> = ({
return (
<TouchableOpacity
onPress={() => router.back()}
className=' rounded-full p-2'
className=' bg-neutral-800/80 rounded-full p-2'
{...touchableOpacityProps}
>
<Ionicons

View File

@@ -8,7 +8,7 @@ export interface HorizontalScrollRef {
}
interface HorizontalScrollProps<T>
extends Omit<FlashListProps<T>, "renderItem" | "estimatedItemSize" | "data"> {
extends Omit<FlashListProps<T>, "renderItem" | "data"> {
data?: T[] | null;
renderItem: (item: T, index: number) => React.ReactNode;
keyExtractor?: (item: T, index: number) => string;

View File

@@ -59,7 +59,6 @@ export function InfiniteHorizontalScroll({
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey,
queryFn,
staleTime: 60 * 1000, // 1 minute
getNextPageParam: (lastPage, pages) => {
if (
!lastPage?.Items ||
@@ -120,6 +119,7 @@ export function InfiniteHorizontalScroll({
renderItem={({ item, index }) => (
<View className='mr-2'>{renderItem(item, index)}</View>
)}
estimatedItemSize={height}
horizontal
onEndReached={() => {
if (hasNextPage) {

View File

@@ -16,10 +16,7 @@ export function Input(props: InputProps) {
const [isFocused, setIsFocused] = useState(false);
return Platform.isTV ? (
<TouchableOpacity
onPress={() => inputRef?.current?.focus?.()}
activeOpacity={1}
>
<TouchableOpacity onFocus={() => inputRef?.current?.focus?.()}>
<TextInput
ref={inputRef}
className={`

View File

@@ -1,20 +1,29 @@
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)
return (
<RNText
allowFontScaling={false}
style={[{ color: "white" }, style]}
{...otherProps}
/>
<RNText allowFontScaling={false} className={clsx(className)} {...props} />
);
return (
<RNText
allowFontScaling={false}
style={[{ color: "white" }, style]}
{...otherProps}
/>
<RNText allowFontScaling={false} className={clsx(className)} {...props} />
);
}
const clsx = (className?: string) => {
const colorClassRegex = /\btext-[a-z]+-\d+\b/;
const hasColorClass = className ? colorClassRegex.test(className) : false;
const defaultClassName = "text-white";
const classes = [
...(hasColorClass ? [] : [defaultClassName]),
...(className ? [className] : []),
]
.filter(Boolean)
.join(" ");
return classes;
};

View File

@@ -9,11 +9,7 @@ interface ActiveDownloadsProps extends ViewProps {}
export default function ActiveDownloads({ ...props }: ActiveDownloadsProps) {
const { processes } = useDownload();
// Filter out any invalid processes before rendering
const validProcesses = processes?.filter((p) => p?.item?.Id) || [];
if (validProcesses.length === 0)
if (processes?.length === 0)
return (
<View {...props} className='bg-neutral-900 p-4 rounded-2xl'>
<Text className='text-lg font-bold'>
@@ -31,8 +27,8 @@ export default function ActiveDownloads({ ...props }: ActiveDownloadsProps) {
{t("home.downloads.active_downloads")}
</Text>
<View className='gap-y-2'>
{validProcesses.map((p: JobStatus) => (
<DownloadCard key={p.id} process={p} />
{processes?.map((p: JobStatus) => (
<DownloadCard key={p.item.Id} process={p} />
))}
</View>
</View>

View File

@@ -15,7 +15,6 @@ import { Text } from "@/components/common/Text";
import { useDownload } from "@/providers/DownloadProvider";
import { calculateSmoothedETA } from "@/providers/Downloads/hooks/useDownloadSpeedCalculator";
import { JobStatus } from "@/providers/Downloads/types";
import { estimateDownloadSize } from "@/utils/download";
import { storage } from "@/utils/mmkv";
import { formatTimeString } from "@/utils/time";
@@ -23,26 +22,19 @@ const bytesToMB = (bytes: number) => {
return bytes / 1024 / 1024;
};
const formatBytes = (bytes: number): string => {
if (bytes >= 1024 * 1024 * 1024) {
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
return `${(bytes / (1024 * 1024)).toFixed(0)} MB`;
};
interface DownloadCardProps extends TouchableOpacityProps {
process: JobStatus;
}
export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
const { cancelDownload } = useDownload();
const { removeProcess } = useDownload();
const router = useRouter();
const queryClient = useQueryClient();
const handleDelete = async (id: string) => {
try {
await cancelDownload(id);
// cancelDownload already shows a toast, so don't show another one
await removeProcess(id);
toast.success(t("home.downloads.toasts.download_deleted"));
queryClient.invalidateQueries({ queryKey: ["downloads"] });
} catch (error) {
console.error("Error deleting download:", error);
@@ -51,7 +43,7 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
};
const eta = useMemo(() => {
if (!process?.estimatedTotalSizeBytes || !process?.bytesDownloaded) {
if (!process.estimatedTotalSizeBytes || !process.bytesDownloaded) {
return null;
}
@@ -66,59 +58,22 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
}
return formatTimeString(secondsRemaining, "s");
}, [process?.id, process?.bytesDownloaded, process?.estimatedTotalSizeBytes]);
const estimatedSize = useMemo(() => {
if (process?.estimatedTotalSizeBytes)
return process.estimatedTotalSizeBytes;
// Calculate from bitrate + duration (only if bitrate value is defined)
if (process?.maxBitrate?.value && process?.item?.RunTimeTicks) {
return estimateDownloadSize(
process.maxBitrate.value,
process.item.RunTimeTicks,
);
}
return undefined;
}, [
process?.maxBitrate?.value,
process?.item?.RunTimeTicks,
process?.estimatedTotalSizeBytes,
]);
const isTranscoding = process?.isTranscoding || false;
const downloadedAmount = useMemo(() => {
if (!process?.bytesDownloaded) return null;
return formatBytes(process.bytesDownloaded);
}, [process?.bytesDownloaded]);
}, [process.id, process.bytesDownloaded, process.estimatedTotalSizeBytes]);
const base64Image = useMemo(() => {
try {
const itemId = process?.item?.Id;
if (!itemId) return undefined;
return storage.getString(itemId);
} catch {
return undefined;
}
}, [process?.item?.Id]);
return storage.getString(process.item.Id!);
}, []);
// Sanitize progress to ensure it's within valid bounds
const sanitizedProgress = useMemo(() => {
if (
typeof process?.progress !== "number" ||
typeof process.progress !== "number" ||
Number.isNaN(process.progress)
) {
return 0;
}
return Math.max(0, Math.min(100, process.progress));
}, [process?.progress]);
// Return null after all hooks have been called
if (!process || !process.item || !process.item.Id) {
return null;
}
}, [process.progress]);
return (
<TouchableOpacity
@@ -128,7 +83,9 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
>
{process.status === "downloading" && (
<View
className={`bg-purple-600 h-1 absolute bottom-0 left-0 ${isTranscoding ? "animate-pulse" : ""}`}
className={`
bg-purple-600 h-1 absolute bottom-0 left-0
`}
style={{
width:
sanitizedProgress > 0
@@ -164,51 +121,33 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
/>
</View>
)}
<View className='shrink mb-1 flex-1 pr-12'>
<View className='shrink mb-1 flex-1'>
<Text className='text-xs opacity-50'>{process.item.Type}</Text>
<Text className='font-semibold shrink'>{process.item.Name}</Text>
<Text className='text-xs opacity-50'>
{process.item.ProductionYear}
</Text>
{isTranscoding && (
<View className='bg-purple-600/20 px-2 py-0.5 rounded-md mt-1 self-start'>
<Text className='text-xs text-purple-400'>Transcoding</Text>
</View>
)}
{/* Row 1: Progress + Downloaded/Total */}
<View className='flex flex-row items-center gap-x-2 mt-1.5'>
<View className='flex flex-row items-center gap-x-2 mt-1 text-purple-600'>
{sanitizedProgress === 0 ? (
<ActivityIndicator size={"small"} color={"white"} />
) : (
<Text className='text-xs font-semibold'>
{sanitizedProgress.toFixed(0)}%
</Text>
<Text className='text-xs'>{sanitizedProgress.toFixed(0)}%</Text>
)}
{downloadedAmount && (
<Text className='text-xs opacity-75'>
{downloadedAmount}
{estimatedSize
? ` / ${isTranscoding ? "~" : ""}${formatBytes(estimatedSize)}`
: ""}
</Text>
)}
</View>
{/* Row 2: Speed + ETA */}
<View className='flex flex-row items-center gap-x-2 mt-0.5'>
{process.speed && process.speed > 0 && (
<Text className='text-xs text-purple-400'>
<Text className='text-xs'>
{bytesToMB(process.speed).toFixed(2)} MB/s
</Text>
)}
{eta && (
<Text className='text-xs text-green-400'>
<Text className='text-xs'>
{t("home.downloads.eta", { eta: eta })}
</Text>
)}
</View>
<View className='flex flex-row items-center gap-x-2 mt-1 text-purple-600'>
<Text className='text-xs capitalize'>{process.status}</Text>
</View>
</View>
</View>
</View>

View File

@@ -29,15 +29,15 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
const { showActionSheetWithOptions } = useActionSheet();
const base64Image = useMemo(() => {
return item?.Id ? storage.getString(item.Id) : undefined;
}, [item?.Id]);
return storage.getString(item?.Id!);
}, []);
/**
* Handles deleting the file with haptic feedback.
*/
const handleDeleteFile = useCallback(() => {
if (item.Id) {
deleteFile(item.Id);
deleteFile(item.Id, item.Type);
}
}, [deleteFile, item.Id]);

View File

@@ -109,22 +109,11 @@ export const FilterSheet = <T,>({
// to implement efficient "load more" functionality
useEffect(() => {
if (!_data || _data.length === 0) return;
const newData = [...data];
const tmp = new Set(data);
for (let i = offset; i < Math.min(_data.length, offset + LIMIT); i++) {
const item = _data[i];
// Check if this item already exists in our data array
// some dups happened with re-renders during testing
const exists = newData.some((existingItem) =>
isEqual(existingItem, item),
);
if (!exists) {
newData.push(item);
}
tmp.add(_data[i]);
}
setData(newData);
setData(Array.from(tmp));
}, [offset, _data]);
useEffect(() => {
@@ -244,7 +233,7 @@ export const FilterSheet = <T,>({
{data.length < (_data?.length || 0) && (
<Button
onPress={() => {
setOffset(offset + LIMIT);
setOffset(offset + 100);
}}
>
Load more

View File

@@ -1,506 +0,0 @@
import { Feather, Ionicons } from "@expo/vector-icons";
import type {
BaseItemDto,
BaseItemDtoQueryResult,
BaseItemKind,
} from "@jellyfin/sdk/lib/generated-client/models";
import {
getItemsApi,
getSuggestionsApi,
getTvShowsApi,
getUserLibraryApi,
getUserViewsApi,
} from "@jellyfin/sdk/lib/utils/api";
import { type QueryFunction, useQuery } from "@tanstack/react-query";
import { useNavigation, useRouter, useSegments } from "expo-router";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Platform,
RefreshControl,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList";
import { Loader } from "@/components/Loader";
import { MediaListSection } from "@/components/medialists/MediaListSection";
import { Colors } from "@/constants/Colors";
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus";
type InfiniteScrollingCollectionListSection = {
type: "InfiniteScrollingCollectionList";
title?: string;
queryKey: (string | undefined | null)[];
queryFn: QueryFunction<BaseItemDto[], any, number>;
orientation?: "horizontal" | "vertical";
pageSize?: number;
};
type MediaListSectionType = {
type: "MediaListSection";
queryKey: (string | undefined)[];
queryFn: QueryFunction<BaseItemDto>;
};
type Section = InfiniteScrollingCollectionListSection | MediaListSectionType;
export const Home = () => {
const router = useRouter();
const { t } = useTranslation();
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const insets = useSafeAreaInsets();
const [loading, setLoading] = useState(false);
const { settings, refreshStreamyfinPluginSettings } = useSettings();
const navigation = useNavigation();
const scrollRef = useRef<ScrollView>(null);
const { downloadedItems, cleanCacheDirectory } = useDownload();
const prevIsConnected = useRef<boolean | null>(false);
const {
isConnected,
serverConnected,
loading: retryLoading,
retryCheck,
} = useNetworkStatus();
const invalidateCache = useInvalidatePlaybackProgressCache();
const [scrollY, setScrollY] = useState(0);
useEffect(() => {
if (isConnected && !prevIsConnected.current) {
invalidateCache();
}
prevIsConnected.current = isConnected;
}, [isConnected, invalidateCache]);
const hasDownloads = useMemo(() => {
if (Platform.isTV) return false;
return downloadedItems.length > 0;
}, [downloadedItems]);
useEffect(() => {
if (Platform.isTV) {
navigation.setOptions({
headerLeft: () => null,
});
return;
}
navigation.setOptions({
headerLeft: () => (
<TouchableOpacity
onPress={() => {
router.push("/(auth)/downloads");
}}
className='ml-1.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather
name='download'
color={hasDownloads ? Colors.primary : "white"}
size={24}
/>
</TouchableOpacity>
),
});
}, [navigation, router, hasDownloads]);
useEffect(() => {
cleanCacheDirectory().catch((_e) =>
console.error("Something went wrong cleaning cache directory"),
);
}, []);
const segments = useSegments();
useEffect(() => {
const unsubscribe = eventBus.on("scrollToTop", () => {
if ((segments as string[])[2] === "(home)")
scrollRef.current?.scrollTo({
y: Platform.isTV ? -152 : -100,
animated: true,
});
});
return () => {
unsubscribe();
};
}, [segments]);
const {
data,
isError: e1,
isLoading: l1,
} = useQuery({
queryKey: ["home", "userViews", user?.Id],
queryFn: async () => {
if (!api || !user?.Id) {
return null;
}
const response = await getUserViewsApi(api).getUserViews({
userId: user.Id,
});
return response.data.Items || null;
},
enabled: !!api && !!user?.Id,
staleTime: 60 * 1000,
});
const userViews = useMemo(
() => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
[data, settings?.hiddenLibraries],
);
const collections = useMemo(() => {
const allow = ["movies", "tvshows"];
return (
userViews?.filter(
(c) => c.CollectionType && allow.includes(c.CollectionType),
) || []
);
}, [userViews]);
const refetch = async () => {
setLoading(true);
await refreshStreamyfinPluginSettings();
await invalidateCache();
setLoading(false);
};
const createCollectionConfig = useCallback(
(
title: string,
queryKey: string[],
includeItemTypes: BaseItemKind[],
parentId: string | undefined,
pageSize: number = 10,
): InfiniteScrollingCollectionListSection => ({
title,
queryKey,
queryFn: async ({ pageParam = 0 }) => {
if (!api) return [];
// getLatestMedia doesn't support startIndex, so we fetch all and slice client-side
const allData =
(
await getUserLibraryApi(api).getLatestMedia({
userId: user?.Id,
limit: 100, // Fetch a larger set for pagination
fields: ["PrimaryImageAspectRatio", "Path", "Genres"],
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
includeItemTypes,
parentId,
})
).data || [];
// Simulate pagination by slicing
return allData.slice(pageParam, pageParam + pageSize);
},
type: "InfiniteScrollingCollectionList",
pageSize,
}),
[api, user?.Id],
);
const defaultSections = useMemo(() => {
if (!api || !user?.Id) return [];
const latestMediaViews = collections.map((c) => {
const includeItemTypes: BaseItemKind[] =
c.CollectionType === "tvshows" || c.CollectionType === "movies"
? []
: ["Movie"];
const title = t("home.recently_added_in", { libraryName: c.Name });
const queryKey: string[] = [
"home",
`recentlyAddedIn${c.CollectionType}`,
user.Id!,
c.Id!,
];
return createCollectionConfig(
title || "",
queryKey,
includeItemTypes,
c.Id,
10,
);
});
const ss: Section[] = [
{
title: t("home.continue_watching"),
queryKey: ["home", "resumeItems"],
queryFn: async ({ pageParam = 0 }) =>
(
await getItemsApi(api).getResumeItems({
userId: user.Id,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
includeItemTypes: ["Movie", "Series", "Episode"],
fields: ["Genres"],
startIndex: pageParam,
limit: 10,
})
).data.Items || [],
type: "InfiniteScrollingCollectionList",
orientation: "horizontal",
pageSize: 10,
},
{
title: t("home.next_up"),
queryKey: ["home", "nextUp-all"],
queryFn: async ({ pageParam = 0 }) =>
(
await getTvShowsApi(api).getNextUp({
userId: user?.Id,
fields: ["MediaSourceCount", "Genres"],
startIndex: pageParam,
limit: 10,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
enableResumable: false,
})
).data.Items || [],
type: "InfiniteScrollingCollectionList",
orientation: "horizontal",
pageSize: 10,
},
...latestMediaViews,
{
title: t("home.suggested_movies"),
queryKey: ["home", "suggestedMovies", user?.Id],
queryFn: async ({ pageParam = 0 }) =>
(
await getSuggestionsApi(api).getSuggestions({
userId: user?.Id,
startIndex: pageParam,
limit: 10,
mediaType: ["Video"],
type: ["Movie"],
})
).data.Items || [],
type: "InfiniteScrollingCollectionList",
orientation: "vertical",
pageSize: 10,
},
];
return ss;
}, [api, user?.Id, collections, t, createCollectionConfig]);
const customSections = useMemo(() => {
if (!api || !user?.Id || !settings?.home?.sections) return [];
const ss: Section[] = [];
settings.home.sections.forEach((section, index) => {
const id = section.title || `section-${index}`;
const pageSize = 10;
ss.push({
title: t(`${id}`),
queryKey: ["home", "custom", String(index), section.title ?? null],
queryFn: async ({ pageParam = 0 }) => {
if (section.items) {
const response = await getItemsApi(api).getItems({
userId: user?.Id,
startIndex: pageParam,
limit: section.items?.limit || pageSize,
recursive: true,
includeItemTypes: section.items?.includeItemTypes,
sortBy: section.items?.sortBy,
sortOrder: section.items?.sortOrder,
filters: section.items?.filters,
parentId: section.items?.parentId,
});
return response.data.Items || [];
}
if (section.nextUp) {
const response = await getTvShowsApi(api).getNextUp({
userId: user?.Id,
fields: ["MediaSourceCount", "Genres"],
startIndex: pageParam,
limit: section.nextUp?.limit || pageSize,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
enableResumable: section.nextUp?.enableResumable,
enableRewatching: section.nextUp?.enableRewatching,
});
return response.data.Items || [];
}
if (section.latest) {
// getLatestMedia doesn't support startIndex, so we fetch all and slice client-side
const allData =
(
await getUserLibraryApi(api).getLatestMedia({
userId: user?.Id,
includeItemTypes: section.latest?.includeItemTypes,
limit: section.latest?.limit || 100, // Fetch larger set
isPlayed: section.latest?.isPlayed,
groupItems: section.latest?.groupItems,
})
).data || [];
// Simulate pagination by slicing
return allData.slice(pageParam, pageParam + pageSize);
}
if (section.custom) {
const response = await api.get<BaseItemDtoQueryResult>(
section.custom.endpoint,
{
params: {
...(section.custom.query || {}),
userId: user?.Id,
startIndex: pageParam,
limit: pageSize,
},
headers: section.custom.headers || {},
},
);
return response.data.Items || [];
}
return [];
},
type: "InfiniteScrollingCollectionList",
orientation: section?.orientation || "vertical",
pageSize,
});
});
return ss;
}, [api, user?.Id, settings?.home?.sections, t]);
const sections = settings?.home?.sections ? customSections : defaultSections;
if (!isConnected || serverConnected !== true) {
let title = "";
let subtitle = "";
if (!isConnected) {
title = t("home.no_internet");
subtitle = t("home.no_internet_message");
} else if (serverConnected === null) {
title = t("home.checking_server_connection");
subtitle = t("home.checking_server_connection_message");
} else if (!serverConnected) {
title = t("home.server_unreachable");
subtitle = t("home.server_unreachable_message");
}
return (
<View className='flex flex-col items-center justify-center h-full -mt-6 px-8'>
<Text className='text-3xl font-bold mb-2'>{title}</Text>
<Text className='text-center opacity-70'>{subtitle}</Text>
<View className='mt-4'>
{!Platform.isTV && (
<Button
color='purple'
onPress={() => router.push("/(auth)/downloads")}
justify='center'
iconRight={
<Ionicons name='arrow-forward' size={20} color='white' />
}
>
{t("home.go_to_downloads")}
</Button>
)}
<Button
color='black'
onPress={retryCheck}
justify='center'
className='mt-2'
iconRight={
retryLoading ? null : (
<Ionicons name='refresh' size={20} color='white' />
)
}
>
{retryLoading ? (
<ActivityIndicator size='small' color='white' />
) : (
t("home.retry")
)}
</Button>
</View>
</View>
);
}
if (e1)
return (
<View className='flex flex-col items-center justify-center h-full -mt-6'>
<Text className='text-3xl font-bold mb-2'>{t("home.oops")}</Text>
<Text className='text-center opacity-70'>
{t("home.error_message")}
</Text>
</View>
);
if (l1)
return (
<View className='justify-center items-center h-full'>
<Loader />
</View>
);
return (
<ScrollView
ref={scrollRef}
nestedScrollEnabled
contentInsetAdjustmentBehavior='automatic'
onScroll={(event) => {
setScrollY(event.nativeEvent.contentOffset.y - 500);
}}
scrollEventThrottle={16}
refreshControl={
<RefreshControl
refreshing={loading}
onRefresh={refetch}
tintColor='white'
colors={["white"]}
/>
}
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 16,
}}
>
<View
className='flex flex-col space-y-4'
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
>
{sections.map((section, index) => {
if (section.type === "InfiniteScrollingCollectionList") {
return (
<InfiniteScrollingCollectionList
key={index}
title={section.title}
queryKey={section.queryKey}
queryFn={section.queryFn}
orientation={section.orientation}
hideIfEmpty
pageSize={section.pageSize}
/>
);
}
if (section.type === "MediaListSection") {
return (
<MediaListSection
key={index}
queryKey={section.queryKey}
queryFn={section.queryFn}
scrollY={scrollY}
enableLazyLoading={true}
/>
);
}
return null;
})}
</View>
</ScrollView>
);
};

View File

@@ -1,7 +1,7 @@
import { Feather, Ionicons } from "@expo/vector-icons";
import type { Api } from "@jellyfin/sdk";
import type {
BaseItemDto,
BaseItemDtoQueryResult,
BaseItemKind,
} from "@jellyfin/sdk/lib/generated-client/models";
import {
@@ -19,17 +19,15 @@ import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Platform,
RefreshControl,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import Animated, {
useAnimatedRef,
useScrollViewOffset,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList";
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
import { Loader } from "@/components/Loader";
import { MediaListSection } from "@/components/medialists/MediaListSection";
import { Colors } from "@/constants/Colors";
@@ -41,13 +39,12 @@ import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus";
import { AppleTVCarousel } from "../apple-tv-carousel/AppleTVCarousel";
type InfiniteScrollingCollectionListSection = {
type: "InfiniteScrollingCollectionList";
type ScrollingCollectionListSection = {
type: "ScrollingCollectionList";
title?: string;
queryKey: (string | undefined | null)[];
queryFn: QueryFunction<BaseItemDto[], any, number>;
queryFn: QueryFunction<BaseItemDto[]>;
orientation?: "horizontal" | "vertical";
pageSize?: number;
};
type MediaListSectionType = {
@@ -56,20 +53,25 @@ type MediaListSectionType = {
queryFn: QueryFunction<BaseItemDto>;
};
type Section = InfiniteScrollingCollectionListSection | MediaListSectionType;
type Section = ScrollingCollectionListSection | MediaListSectionType;
export const HomeWithCarousel = () => {
export const HomeIndex = () => {
const router = useRouter();
const { t } = useTranslation();
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const insets = useSafeAreaInsets();
const [_loading, setLoading] = useState(false);
const [loading, setLoading] = useState(false);
const { settings, refreshStreamyfinPluginSettings } = useSettings();
const headerOverlayOffset = Platform.isTV ? 0 : 60;
const navigation = useNavigation();
const animatedScrollRef = useAnimatedRef<Animated.ScrollView>();
const scrollOffset = useScrollViewOffset(animatedScrollRef);
const insets = useSafeAreaInsets();
const scrollViewRef = useRef<ScrollView>(null);
const { downloadedItems, cleanCacheDirectory } = useDownload();
const prevIsConnected = useRef<boolean | null>(false);
const {
@@ -79,12 +81,12 @@ export const HomeWithCarousel = () => {
retryCheck,
} = useNetworkStatus();
const invalidateCache = useInvalidatePlaybackProgressCache();
const [scrollY, setScrollY] = useState(0);
useEffect(() => {
// Only invalidate cache when transitioning from offline to online
if (isConnected && !prevIsConnected.current) {
invalidateCache();
}
// Update the ref to the current state for the next render
prevIsConnected.current = isConnected;
}, [isConnected, invalidateCache]);
@@ -106,13 +108,12 @@ export const HomeWithCarousel = () => {
onPress={() => {
router.push("/(auth)/downloads");
}}
className='ml-1.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
className='p-2'
>
<Feather
name='download'
color={hasDownloads ? Colors.primary : "white"}
size={24}
size={22}
/>
</TouchableOpacity>
),
@@ -129,7 +130,7 @@ export const HomeWithCarousel = () => {
useEffect(() => {
const unsubscribe = eventBus.on("scrollToTop", () => {
if ((segments as string[])[2] === "(home)")
animatedScrollRef.current?.scrollTo({
scrollViewRef.current?.scrollTo({
y: Platform.isTV ? -152 : -100,
animated: true,
});
@@ -175,7 +176,7 @@ export const HomeWithCarousel = () => {
);
}, [userViews]);
const _refetch = async () => {
const refetch = async () => {
setLoading(true);
await refreshStreamyfinPluginSettings();
await invalidateCache();
@@ -188,48 +189,42 @@ export const HomeWithCarousel = () => {
queryKey: string[],
includeItemTypes: BaseItemKind[],
parentId: string | undefined,
pageSize: number = 10,
): InfiniteScrollingCollectionListSection => ({
): ScrollingCollectionListSection => ({
title,
queryKey,
queryFn: async ({ pageParam = 0 }) => {
queryFn: async () => {
if (!api) return [];
// getLatestMedia doesn't support startIndex, so we fetch all and slice client-side
const allData =
return (
(
await getUserLibraryApi(api).getLatestMedia({
userId: user?.Id,
limit: 100, // Fetch a larger set for pagination
limit: 20,
fields: ["PrimaryImageAspectRatio", "Path", "Genres"],
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
includeItemTypes,
parentId,
})
).data || [];
// Simulate pagination by slicing
return allData.slice(pageParam, pageParam + pageSize);
).data || []
);
},
type: "InfiniteScrollingCollectionList",
pageSize,
type: "ScrollingCollectionList",
}),
[api, user?.Id],
);
// Always call useMemo() at the top-level, using computed dependencies for both "default"/custom sections
const defaultSections = useMemo(() => {
if (!api || !user?.Id) return [];
const latestMediaViews = collections.map((c) => {
const includeItemTypes: BaseItemKind[] =
c.CollectionType === "tvshows" || c.CollectionType === "movies"
? []
: ["Movie"];
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"];
const title = t("home.recently_added_in", { libraryName: c.Name });
const queryKey: string[] = [
const queryKey = [
"home",
`recentlyAddedIn${c.CollectionType}`,
user.Id!,
user?.Id!,
c.Id!,
];
return createCollectionConfig(
@@ -237,7 +232,6 @@ export const HomeWithCarousel = () => {
queryKey,
includeItemTypes,
c.Id,
10,
);
});
@@ -245,56 +239,79 @@ export const HomeWithCarousel = () => {
{
title: t("home.continue_watching"),
queryKey: ["home", "resumeItems"],
queryFn: async ({ pageParam = 0 }) =>
queryFn: async () =>
(
await getItemsApi(api).getResumeItems({
userId: user.Id,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
includeItemTypes: ["Movie", "Series", "Episode"],
fields: ["Genres"],
startIndex: pageParam,
limit: 10,
})
).data.Items || [],
type: "InfiniteScrollingCollectionList",
type: "ScrollingCollectionList",
orientation: "horizontal",
pageSize: 10,
},
{
title: t("home.next_up"),
queryKey: ["home", "nextUp-all"],
queryFn: async ({ pageParam = 0 }) =>
queryFn: async () =>
(
await getTvShowsApi(api).getNextUp({
userId: user?.Id,
fields: ["MediaSourceCount", "Genres"],
startIndex: pageParam,
limit: 10,
limit: 20,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
enableResumable: false,
})
).data.Items || [],
type: "InfiniteScrollingCollectionList",
type: "ScrollingCollectionList",
orientation: "horizontal",
pageSize: 10,
},
...latestMediaViews,
// ...(mediaListCollections?.map(
// (ml) =>
// ({
// title: ml.Name,
// queryKey: ["home", "mediaList", ml.Id!],
// queryFn: async () => ml,
// type: "MediaListSection",
// orientation: "vertical",
// } as Section)
// ) || []),
{
title: t("home.suggested_movies"),
queryKey: ["home", "suggestedMovies", user?.Id],
queryFn: async ({ pageParam = 0 }) =>
queryFn: async () =>
(
await getSuggestionsApi(api).getSuggestions({
userId: user?.Id,
startIndex: pageParam,
limit: 10,
mediaType: ["Video"],
type: ["Movie"],
})
).data.Items || [],
type: "InfiniteScrollingCollectionList",
type: "ScrollingCollectionList",
orientation: "vertical",
pageSize: 10,
},
{
title: t("home.suggested_episodes"),
queryKey: ["home", "suggestedEpisodes", user?.Id],
queryFn: async () => {
try {
const suggestions = await getSuggestions(api, user.Id);
const nextUpPromises = suggestions.map((series) =>
getNextUp(api, user.Id, series.Id),
);
const nextUpResults = await Promise.all(nextUpPromises);
return nextUpResults.filter((item) => item !== null) || [];
} catch (error) {
console.error("Error fetching data:", error);
return [];
}
},
type: "ScrollingCollectionList",
orientation: "horizontal",
},
];
return ss;
@@ -303,18 +320,16 @@ export const HomeWithCarousel = () => {
const customSections = useMemo(() => {
if (!api || !user?.Id || !settings?.home?.sections) return [];
const ss: Section[] = [];
settings.home.sections.forEach((section, index) => {
for (const [index, section] of settings.home.sections.entries()) {
const id = section.title || `section-${index}`;
const pageSize = 10;
ss.push({
title: t(`${id}`),
queryKey: ["home", "custom", String(index), section.title ?? null],
queryFn: async ({ pageParam = 0 }) => {
queryFn: async () => {
if (section.items) {
const response = await getItemsApi(api).getItems({
userId: user?.Id,
startIndex: pageParam,
limit: section.items?.limit || pageSize,
limit: section.items?.limit || 25,
recursive: true,
includeItemTypes: section.items?.includeItemTypes,
sortBy: section.items?.sortBy,
@@ -328,8 +343,7 @@ export const HomeWithCarousel = () => {
const response = await getTvShowsApi(api).getNextUp({
userId: user?.Id,
fields: ["MediaSourceCount", "Genres"],
startIndex: pageParam,
limit: section.nextUp?.limit || pageSize,
limit: section.nextUp?.limit || 25,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
enableResumable: section.nextUp?.enableResumable,
enableRewatching: section.nextUp?.enableRewatching,
@@ -337,45 +351,23 @@ export const HomeWithCarousel = () => {
return response.data.Items || [];
}
if (section.latest) {
// getLatestMedia doesn't support startIndex, so we fetch all and slice client-side
const allData =
(
await getUserLibraryApi(api).getLatestMedia({
userId: user?.Id,
includeItemTypes: section.latest?.includeItemTypes,
limit: section.latest?.limit || 100, // Fetch larger set
isPlayed: section.latest?.isPlayed,
groupItems: section.latest?.groupItems,
})
).data || [];
// Simulate pagination by slicing
return allData.slice(pageParam, pageParam + pageSize);
}
if (section.custom) {
const response = await api.get<BaseItemDtoQueryResult>(
section.custom.endpoint,
{
params: {
...(section.custom.query || {}),
userId: user?.Id,
startIndex: pageParam,
limit: pageSize,
},
headers: section.custom.headers || {},
},
);
return response.data.Items || [];
const response = await getUserLibraryApi(api).getLatestMedia({
userId: user?.Id,
includeItemTypes: section.latest?.includeItemTypes,
limit: section.latest?.limit || 25,
isPlayed: section.latest?.isPlayed,
groupItems: section.latest?.groupItems,
});
return response.data || [];
}
return [];
},
type: "InfiniteScrollingCollectionList",
type: "ScrollingCollectionList",
orientation: section?.orientation || "vertical",
pageSize,
});
});
}
return ss;
}, [api, user?.Id, settings?.home?.sections, t]);
}, [api, user?.Id, settings?.home?.sections]);
const sections = settings?.home?.sections ? customSections : defaultSections;
@@ -384,12 +376,15 @@ export const HomeWithCarousel = () => {
let subtitle = "";
if (!isConnected) {
// No network connection
title = t("home.no_internet");
subtitle = t("home.no_internet_message");
} else if (serverConnected === null) {
// Network is up, but server is being checked
title = t("home.checking_server_connection");
subtitle = t("home.checking_server_connection_message");
} else if (!serverConnected) {
// Network is up, but server is unreachable
title = t("home.server_unreachable");
subtitle = t("home.server_unreachable_message");
}
@@ -452,41 +447,42 @@ export const HomeWithCarousel = () => {
);
return (
<Animated.ScrollView
<ScrollView
scrollToOverflowEnabled={true}
ref={animatedScrollRef}
ref={scrollViewRef}
nestedScrollEnabled
contentInsetAdjustmentBehavior='never'
scrollEventThrottle={16}
bounces={false}
overScrollMode='never'
style={{ marginTop: -headerOverlayOffset }}
contentContainerStyle={{ paddingTop: headerOverlayOffset }}
onScroll={(event) => {
setScrollY(event.nativeEvent.contentOffset.y);
}}
refreshControl={
<RefreshControl
refreshing={loading}
onRefresh={refetch}
tintColor='white' // For iOS
colors={["white"]} // For Android
progressViewOffset={200} // This offsets the refresh indicator to appear over the carousel
/>
}
style={{ marginTop: Platform.isTV ? 0 : -100 }}
contentContainerStyle={{ paddingTop: Platform.isTV ? 0 : 100 }}
>
<AppleTVCarousel initialIndex={0} scrollOffset={scrollOffset} />
<AppleTVCarousel initialIndex={0} />
<View
style={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 16,
paddingTop: 0,
}}
>
<View className='flex flex-col space-y-4'>
<View className='flex flex-col gap-4'>
{sections.map((section, index) => {
if (section.type === "InfiniteScrollingCollectionList") {
if (section.type === "ScrollingCollectionList") {
return (
<InfiniteScrollingCollectionList
<ScrollingCollectionList
key={index}
title={section.title}
queryKey={section.queryKey}
queryFn={section.queryFn}
orientation={section.orientation}
hideIfEmpty
pageSize={section.pageSize}
/>
);
}
@@ -496,8 +492,6 @@ export const HomeWithCarousel = () => {
key={index}
queryKey={section.queryKey}
queryFn={section.queryFn}
scrollY={scrollY}
enableLazyLoading={true}
/>
);
}
@@ -506,6 +500,33 @@ export const HomeWithCarousel = () => {
</View>
</View>
<View className='h-24' />
</Animated.ScrollView>
</ScrollView>
);
};
// Function to get suggestions
async function getSuggestions(api: Api, userId: string | undefined) {
if (!userId) return [];
const response = await getSuggestionsApi(api).getSuggestions({
userId,
limit: 10,
mediaType: ["Unknown"],
type: ["Series"],
});
return response.data.Items ?? [];
}
// Function to get the next up TV show for a series
async function getNextUp(
api: Api,
userId: string | undefined,
seriesId: string | undefined,
) {
if (!userId || !seriesId) return null;
const response = await getTvShowsApi(api).getNextUp({
userId,
seriesId,
limit: 1,
});
return response.data.Items?.[0] ?? null;
}

View File

@@ -4,7 +4,6 @@ import {
type QueryKey,
useInfiniteQuery,
} from "@tanstack/react-query";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
@@ -14,7 +13,6 @@ import {
} from "react-native";
import { Text } from "@/components/common/Text";
import MoviePoster from "@/components/posters/MoviePoster";
import { Colors } from "../../constants/Colors";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
import { ItemCardText } from "../ItemCardText";
@@ -37,7 +35,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
queryFn,
queryKey,
hideIfEmpty = false,
pageSize = 10,
pageSize = 20,
...props
}) => {
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
@@ -54,9 +52,9 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
return allPages.length * pageSize;
},
initialPageParam: 0,
staleTime: 60 * 1000, // 1 minute
refetchOnMount: false,
refetchOnWindowFocus: false,
staleTime: 0,
refetchOnMount: true,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
});
@@ -65,11 +63,6 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
// Flatten all pages into a single array
const allItems = data?.pages.flat() || [];
const snapOffsets = useMemo(() => {
const itemWidth = orientation === "horizontal" ? 184 : 120; // w-44 (176px) + mr-2 (8px) or w-28 (112px) + mr-2 (8px)
return allItems.map((_, index) => index * itemWidth);
}, [allItems, orientation]);
if (hideIfEmpty === true && allItems.length === 0 && !isLoading) return null;
if (disabled || !title) return null;
@@ -132,8 +125,6 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
showsHorizontalScrollIndicator={false}
onScroll={handleScroll}
scrollEventThrottle={16}
snapToOffsets={snapOffsets}
decelerationRate='fast'
>
<View className='px-4 flex flex-row'>
{allItems.map((item) => (
@@ -188,13 +179,8 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
))}
{/* Loading indicator for next page */}
{isFetchingNextPage && (
<View
style={{
marginLeft: 8,
marginTop: orientation === "horizontal" ? 37 : 70,
}}
>
<ActivityIndicator size='small' color={Colors.primary} />
<View className='justify-center items-center w-16'>
<ActivityIndicator size='small' color='#6366f1' />
</View>
)}
</View>

View File

@@ -8,7 +8,6 @@ import { useTranslation } from "react-i18next";
import { ScrollView, View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import MoviePoster from "@/components/posters/MoviePoster";
import { useInView } from "@/hooks/useInView";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
import { ItemCardText } from "../ItemCardText";
@@ -22,8 +21,6 @@ interface Props extends ViewProps {
queryFn: QueryFunction<BaseItemDto[]>;
hideIfEmpty?: boolean;
isOffline?: boolean;
scrollY?: number; // For lazy loading
enableLazyLoading?: boolean; // Enable/disable lazy loading
}
export const ScrollingCollectionList: React.FC<Props> = ({
@@ -34,44 +31,33 @@ export const ScrollingCollectionList: React.FC<Props> = ({
queryKey,
hideIfEmpty = false,
isOffline = false,
scrollY = 0,
enableLazyLoading = false,
...props
}) => {
const { ref, isInView, onLayout } = useInView(scrollY, {
enabled: enableLazyLoading,
});
const { data, isLoading } = useQuery({
queryKey: queryKey,
queryFn,
staleTime: 60 * 1000, // 1 minute
refetchOnMount: false,
refetchOnWindowFocus: false,
staleTime: 0,
refetchOnMount: true,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
enabled: enableLazyLoading ? isInView : true,
});
const { t } = useTranslation();
// Show skeleton if loading OR if lazy loading is enabled and not in view yet
const shouldShowSkeleton = isLoading || (enableLazyLoading && !isInView);
if (hideIfEmpty === true && data?.length === 0 && !shouldShowSkeleton)
return null;
if (hideIfEmpty === true && data?.length === 0) return null;
if (disabled || !title) return null;
return (
<View ref={ref} onLayout={onLayout} {...props}>
<View {...props}>
<Text className='px-4 text-lg font-bold mb-2 text-neutral-100'>
{title}
</Text>
{!shouldShowSkeleton && data?.length === 0 && (
{isLoading === false && data?.length === 0 && (
<View className='px-4'>
<Text className='text-neutral-500'>{t("home.no_items")}</Text>
</View>
)}
{shouldShowSkeleton ? (
{isLoading ? (
<View
className={`
flex flex-row gap-2 px-4

View File

@@ -23,6 +23,7 @@ const CastSlide: React.FC<
showsHorizontalScrollIndicator={false}
data={details?.credits.cast}
ItemSeparatorComponent={() => <View className='w-2' />}
estimatedItemSize={15}
keyExtractor={(item) => item?.id?.toString()}
contentContainerStyle={{ paddingHorizontal: 16 }}
renderItem={({ item }) => (

View File

@@ -143,6 +143,7 @@ const ParallaxSlideShow = <T,>({
renderItem={({ item, index }) => renderItem(item, index)}
keyExtractor={keyExtractor}
numColumns={3}
estimatedItemSize={214}
ItemSeparatorComponent={() => <View className='h-2 w-2' />}
/>
</View>

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