Compare commits

..

21 Commits

Author SHA1 Message Date
Gauvain
d02007c213 Merge branch 'develop' into build-performance 2025-10-09 16:32:11 +02:00
Gauvain
a5552db377 Merge branch 'develop' into build-performance 2025-10-09 16:19:42 +02:00
Gauvain
cf203a7c28 Merge branch 'develop' into build-performance 2025-10-09 16:10:23 +02:00
Gauvain
d7958296a5 Merge branch 'develop' into build-performance 2025-10-09 15:57:22 +02:00
Gauvain
3f3f95571c Merge branch 'develop' into build-performance 2025-10-03 01:05:22 +02:00
Gauvain
9e26196fb3 Merge branch 'develop' into build-performance 2025-09-30 12:44:26 +02:00
Gauvain
e6f69e0c7b Merge branch 'develop' into build-performance 2025-09-30 12:40:40 +02:00
Gauvain
e8bf2b721e Merge branch 'develop' into build-performance 2025-09-26 19:45:31 +02:00
Uruk
84d7ad72a6 fix: conditionally load TV plugin based on build target
Moves TV-specific plugin configuration from static app.json to dynamic loading in app.config.js based on EXPO_TV environment variable.

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

@@ -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

@@ -79,7 +79,7 @@ and provides seamless media streaming with offline capabilities and Chromecast s
## Commit Messages
Use Conventional Commits (https://www.conventionalcommits.org/):
Use [Conventional Commits](https://www.conventionalcommits.org/):
Exemples:
- `feat(player): add Chromecast support`
- `fix(auth): handle expired JWT tokens`

46
.github/renovate.json vendored Normal file
View File

@@ -0,0 +1,46 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"description": "Renovate configuration for Streamyfin - Expo React Native Jellyfin client",
"extends": [
"config:best-practices",
":dependencyDashboard",
":enableVulnerabilityAlertsWithLabel(security)",
":semanticCommits",
":timezone(Etc/UTC)",
"group:testNonMajor",
"group:monorepos",
"helpers:pinGitHubActionDigests",
"customManagers:biomeVersions",
":automergeBranch",
":automergeRequireAllStatusChecks"
],
"addLabels": ["dependencies"],
"rebaseWhen": "conflicted",
"ignorePaths": ["**/node_modules/**"],
"ignoreUnstable": true,
"minimumReleaseAge": "3 days",
"schedule": ["before 6am on Sunday"],
"branchPrefix": "renovate/",
"commitMessagePrefix": "chore(deps):",
"osvVulnerabilityAlerts": true,
"configMigration": true,
"separateMinorPatch": true,
"lockFileMaintenance": {
"vulnerabilityAlerts": {
"enabled": true,
"addLabels": ["security", "vulnerability"],
"assigneesFromCodeOwners": true,
"commitMessageSuffix": " [SECURITY]"
},
"packageRules": [
{
"description": "Group minor and patch GitHub Action updates into a single PR",
"matchManagers": ["github-actions"],
"groupName": "CI dependencies",
"groupSlug": "ci-deps",
"matchUpdateTypes": ["minor", "patch", "digest", "pin"],
"automerge": true
}
]
}
}

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

View File

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

View File

@@ -28,7 +28,7 @@ jobs:
fetch-depth: 0
- name: 🌐 Sync Translations with Crowdin
uses: crowdin/github-action@08713f00a50548bfe39b37e8f44afb53e7a802d4 # v2.12.0
uses: crowdin/github-action@0749939f635900a2521aa6aac7a3766642b2dc71 # v2.11.0
with:
upload_sources: true
upload_translations: true

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

@@ -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'

3
.gitignore vendored
View File

@@ -64,5 +64,4 @@ credentials.json
streamyfin-4fec1-firebase-adminsdk.json
# Version and Backup Files
/version-backup-*
modules/background-downloader/android/build/*
/version-backup-*

View File

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

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

@@ -1,21 +1,23 @@
module.exports = ({ config }) => {
if (process.env.EXPO_TV !== "1") {
if (process.env.EXPO_TV === "1") {
// Add TV-specific plugin for TV builds
config.plugins.push("@react-native-tvos/config-tv");
} else {
// Add non-TV specific plugins for phone builds
config.plugins.push("expo-background-task");
config.plugins.push([
"react-native-google-cast",
{ useDefaultExpandedMediaControls: true },
]);
}
// Only override googleServicesFile if env var is set
const androidConfig = {};
if (process.env.GOOGLE_SERVICES_JSON) {
androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON;
// Add the background downloader plugin only for non-TV builds
config.plugins.push("./plugins/withRNBackgroundDownloader.js");
}
return {
...(Object.keys(androidConfig).length > 0 && { android: androidConfig }),
android: {
googleServicesFile: process.env.GOOGLE_SERVICES_JSON,
},
...config,
};
};

View File

@@ -2,13 +2,12 @@
"expo": {
"name": "Streamyfin",
"slug": "streamyfin",
"version": "0.47.1",
"version": "0.39.0",
"orientation": "default",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",
"userInterfaceStyle": "dark",
"jsEngine": "hermes",
"newArchEnabled": true,
"assetBundlePatterns": ["**/*"],
"ios": {
"requireFullScreen": true,
@@ -29,12 +28,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": 84,
"versionCode": 71,
"adaptiveIcon": {
"foregroundImage": "./assets/images/icon-android-plain.png",
"monochromeImage": "./assets/images/icon-android-themed.png",
@@ -50,7 +53,6 @@
"googleServicesFile": "./google-services.json"
},
"plugins": [
"@react-native-tvos/config-tv",
"expo-router",
"expo-font",
[
@@ -74,7 +76,6 @@
"useFrameworks": "static"
},
"android": {
"buildArchs": ["arm64-v8a", "x86_64"],
"compileSdkVersion": 35,
"targetSdkVersion": 35,
"buildToolsVersion": "35.0.0",
@@ -113,6 +114,10 @@
}
}
],
["./plugins/withChangeNativeAndroidTextToWhite.js"],
["./plugins/withAndroidManifest.js"],
["./plugins/withTrustLocalCerts.js"],
["./plugins/withGradleProperties.js"],
[
"expo-splash-screen",
{
@@ -128,12 +133,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
@@ -152,6 +153,7 @@
},
"updates": {
"url": "https://u.expo.dev/e79219d1-797f-4fbe-9fa1-cfd360690a68"
}
},
"newArchEnabled": false
}
}

View File

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

View File

@@ -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

@@ -30,6 +30,7 @@ export default function IndexLayout() {
{!Platform.isTV && (
<>
<Chromecast.Chromecast background='transparent' />
{user?.Policy?.IsAdministrator && <SessionsButton />}
<SettingsButton />
</>
@@ -41,237 +42,49 @@ 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 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
}}
/>
<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 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
}}
/>
<Stack.Screen
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 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
}}
/>
<Stack.Screen
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 }}
>
<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,
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>
),
title: "",
}}
/>
<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,
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>
),
title: "",
}}
/>
<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,
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 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
title: "",
}}
/>
<Stack.Screen
name='settings/logs/page'
options={{
title: t("home.settings.logs.logs_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>
),
title: "",
}}
/>
<Stack.Screen
@@ -279,11 +92,6 @@ export default function IndexLayout() {
options={{
headerShown: false,
title: "",
headerLeft: () => (
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
presentation: "modal",
}}
/>
@@ -294,11 +102,6 @@ export default function IndexLayout() {
name='collections/[collectionId]'
options={{
title: "",
headerLeft: () => (
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
headerShown: true,
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios",

View File

@@ -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 {
@@ -25,23 +23,21 @@ export default function page() {
const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>(
{},
);
const { downloadedItems, deleteItems } = useDownload();
const insets = useSafeAreaInsets();
const { getDownloadedItems, deleteItems } = useDownload();
const series = useMemo(() => {
try {
return (
downloadedItems
getDownloadedItems()
?.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 {
return [];
}
}, [downloadedItems, seriesId]);
}, [getDownloadedItems]);
// Group episodes by season in a single pass
const seasonGroups = useMemo(() => {
@@ -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(() => {
@@ -96,7 +91,7 @@ export default function page() {
title: series[0].item.SeriesName,
});
} else {
storage.remove(seriesId);
storage.delete(seriesId);
router.back();
}
}, [series]);
@@ -112,70 +107,44 @@ export default function page() {
},
{
text: "Delete",
onPress: () =>
deleteItems(
groupBySeason
.map((item) => item.Id)
.filter((id) => id !== undefined),
),
onPress: () => deleteItems(groupBySeason),
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} className='px-4'>
{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,18 @@ 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,
getDownloadedItems,
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 +62,7 @@ export default function page() {
);
};
const downloadedFiles = useMemo(() => downloadedItems, [downloadedItems]);
const downloadedFiles = getDownloadedItems();
const movies = useMemo(() => {
try {
@@ -103,10 +106,7 @@ export default function page() {
useEffect(() => {
navigation.setOptions({
headerRight: () => (
<TouchableOpacity
onPress={bottomSheetModalRef.current?.present}
className='px-2'
>
<TouchableOpacity onPress={bottomSheetModalRef.current?.present}>
<DownloadSize items={downloadedFiles?.map((f) => f.item) || []} />
</TouchableOpacity>
),
@@ -119,7 +119,7 @@ export default function page() {
}
}, [showMigration]);
const _deleteMovies = () =>
const deleteMovies = () =>
deleteFileByType("Movie")
.then(() =>
toast.success(
@@ -130,7 +130,7 @@ export default function page() {
writeToLog("ERROR", reason);
toast.error(t("home.downloads.toasts.failed_to_delete_all_movies"));
});
const _deleteShows = () =>
const deleteShows = () =>
deleteFileByType("Episode")
.then(() =>
toast.success(
@@ -141,39 +141,38 @@ 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!)
.then(() =>
toast.success(
t("home.downloads.toasts.deleted_media_successfully", {
type: item.item.Type,
}),
),
)
.catch((reason) => {
writeToLog("ERROR", reason);
toast.error(
t("home.downloads.toasts.failed_to_delete_media", {
type: item.item.Type,
}),
);
}),
),
otherMedia.map((item) =>
deleteFileByType(item.item.Type)
.then(() =>
toast.success(
t("home.downloads.toasts.deleted_media_successfully", {
type: item.item.Type,
}),
),
)
.catch((reason) => {
writeToLog("ERROR", reason);
toast.error(
t("home.downloads.toasts.failed_to_delete_media", {
type: item.item.Type,
}),
);
}),
),
);
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 space-y-4 px-4'>
<View className='bg-neutral-900 p-4 rounded-2xl'>
<Text className='text-lg font-bold'>
{t("home.downloads.queue")}
</Text>
@@ -215,96 +214,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/settings/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}
/>
);
}

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(() => {
@@ -28,7 +46,7 @@ export default function settings() {
logout();
}}
>
<Text className='text-red-600 px-2'>
<Text className='text-red-600'>
{t("home.settings.log_out_button")}
</Text>
</TouchableOpacity>
@@ -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,11 +1,10 @@
import { File, Paths } from "expo-file-system";
import * as FileSystem from "expo-file-system";
import { useNavigation } from "expo-router";
import * as Sharing from "expo-sharing";
import { useCallback, useEffect, useId, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { ScrollView, TouchableOpacity, View } from "react-native";
import Collapsible from "react-native-collapsible";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { FilterButton } from "@/components/filters/FilterButton";
import { Loader } from "@/components/Loader";
@@ -34,7 +33,6 @@ export default function Page() {
const _orderId = useId();
const _levelsId = useId();
const insets = useSafeAreaInsets();
const filteredLogs = useMemo(
() =>
@@ -49,17 +47,18 @@ export default function Page() {
// Sharing it as txt while its formatted allows us to share it with many more applications
const share = useCallback(async () => {
const 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);
}
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(() => {
@@ -68,7 +67,7 @@ export default function Page() {
loading ? (
<Loader />
) : (
<TouchableOpacity onPress={share} className='px-2'>
<TouchableOpacity onPress={share}>
<Text>{t("home.settings.logs.export_logs")}</Text>
</TouchableOpacity>
),
@@ -76,12 +75,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}
@@ -163,6 +157,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,
@@ -394,6 +393,7 @@ const page: React.FC = () => {
data={flatData}
renderItem={renderItem}
keyExtractor={keyExtractor}
estimatedItemSize={255}
numColumns={
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5
}

View File

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

View File

@@ -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

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

View File

@@ -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,11 +84,11 @@ 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,
paddingRight: insets.right + 17,
paddingLeft: insets.left,
paddingRight: insets.right,
}}
data={libraries}
renderItem={({ item }) => <LibraryItemCard library={item} />}
@@ -105,6 +105,7 @@ export default function index() {
<View className='h-4' />
)
}
estimatedItemSize={200}
/>
);
}

View File

@@ -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

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

View File

@@ -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

@@ -29,7 +29,6 @@ import {
VLCColor,
} from "@/constants/SubtitleConstants";
import { useHaptic } from "@/hooks/useHaptic";
import { useOrientation } from "@/hooks/useOrientation";
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useWebSocket } from "@/hooks/useWebsockets";
@@ -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");
@@ -77,10 +75,7 @@ export default function page() {
: require("react-native-volume-manager");
const downloadUtils = useDownload();
const downloadedFiles = useMemo(
() => downloadUtils.getDownloadedItems(),
[downloadUtils.getDownloadedItems],
);
const downloadedFiles = downloadUtils.getDownloadedItems();
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
@@ -110,7 +105,6 @@ export default function page() {
playbackPosition?: string;
}>();
const { settings } = useSettings();
const { lockOrientation, unlockOrientation } = useOrientation();
const offline = offlineStr === "true";
const playbackManager = usePlaybackManager();
@@ -173,16 +167,6 @@ export default function page() {
}
}, [itemId, offline, api, user?.Id]);
useEffect(() => {
if (settings?.defaultVideoOrientation) {
lockOrientation(settings.defaultVideoOrientation);
}
return () => {
unlockOrientation();
};
}, [settings?.defaultVideoOrientation]);
interface Stream {
mediaSource: MediaSourceInfo;
sessionId: string;
@@ -298,14 +282,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,
@@ -336,9 +318,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,
@@ -745,12 +727,9 @@ export default function page() {
);
writeToLog("ERROR", "Video Error", e.nativeEvent);
}}
onPipStarted={(e) => {
setIsPipMode(e.nativeEvent.pipStarted);
}}
/>
</View>
{isMounted === true && item && !isPipMode && (
{isMounted === true && item && (
<Controls
mediaSource={stream?.mediaSource}
item={item}
@@ -782,7 +761,6 @@ export default function page() {
setAspectRatio={setAspectRatio}
setScaleFactor={setScaleFactor}
isVlc
api={api}
downloadedFiles={downloadedFiles}
/>
)}

View File

@@ -1,24 +1,18 @@
import "@/augmentations";
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import * as BackgroundTask from "expo-background-task";
import * as Device from "expo-device";
import { Platform } from "react-native";
import { GlobalModal } from "@/components/GlobalModal";
import i18n from "@/i18n";
import { DownloadProvider } from "@/providers/DownloadProvider";
import { GlobalModalProvider } from "@/providers/GlobalModalProvider";
import {
apiAtom,
getOrSetDeviceId,
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,
@@ -32,29 +26,44 @@ import {
} from "@/utils/log";
import { storage } from "@/utils/mmkv";
const BackGroundDownloader = !Platform.isTV
? require("@kesha-antonov/react-native-background-downloader")
: null;
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import * as BackgroundTask from "expo-background-task";
import * as Device from "expo-device";
import * as FileSystem from "expo-file-system";
const Notifications = !Platform.isTV ? require("expo-notifications") : null;
import { 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, AppState } from "react-native";
import { SystemBars } from "react-native-edge-to-edge";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
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";
if (!Platform.isTV) {
Notifications.setNotificationHandler({
@@ -122,7 +131,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 = FileSystem.documentDirectory;
if (!token || !deviceId || !baseDirectory)
return BackgroundTask.BackgroundTaskResult.Failed;
// Be sure to return the successful result type!
return BackgroundTask.BackgroundTaskResult.Success;
});
}
@@ -187,7 +213,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 +226,8 @@ function Layout() {
const { settings } = useSettings();
const [user] = useAtom(userAtom);
const [api] = useAtom(apiAtom);
const _segments = useSegments();
const appState = useRef(AppState.currentState);
const segments = useSegments();
useEffect(() => {
i18n.changeLanguage(
@@ -225,7 +256,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 +287,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 +355,119 @@ 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,
]);
useEffect(() => {
if (Platform.isTV) {
return;
}
const subscription = AppState.addEventListener("change", (nextAppState) => {
if (
appState.current.match(/inactive|background/) &&
nextAppState === "active"
) {
BackGroundDownloader.checkForExistingDownloads().catch(
(error: unknown) => {
writeErrorLog("Failed to resume background downloads", error);
},
);
}
});
BackGroundDownloader.checkForExistingDownloads().catch((error: unknown) => {
writeErrorLog("Failed to resume background downloads", error);
});
return () => {
subscription.remove();
};
}, []);
return (
<QueryClientProvider client={queryClient}>
<JellyfinProvider>
<NetworkStatusProvider>
<PlaySettingsProvider>
<LogProvider>
<WebSocketProvider>
<DownloadProvider>
<GlobalModalProvider>
<BottomSheetModalProvider>
<ThemeProvider value={DarkTheme}>
<SystemBars style='light' hidden={false} />
<Stack initialRouteName='(auth)/(tabs)'>
<Stack.Screen
name='(auth)/(tabs)'
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name='(auth)/player'
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name='login'
options={{
headerShown: true,
title: "",
headerTransparent: Platform.OS === "ios",
}}
/>
<Stack.Screen name='+not-found' />
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}}
closeButton
/>
<GlobalModal />
</ThemeProvider>
</BottomSheetModalProvider>
</GlobalModalProvider>
</DownloadProvider>
</WebSocketProvider>
</LogProvider>
</PlaySettingsProvider>
</NetworkStatusProvider>
<PlaySettingsProvider>
<LogProvider>
<WebSocketProvider>
<DownloadProvider>
<BottomSheetModalProvider>
<SystemBars style='light' hidden={false} />
<ThemeProvider value={DarkTheme}>
<Stack initialRouteName='(auth)/(tabs)'>
<Stack.Screen
name='(auth)/(tabs)'
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name='(auth)/player'
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name='login'
options={{
headerShown: true,
title: "",
headerTransparent: true,
}}
/>
<Stack.Screen name='+not-found' />
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}}
closeButton
/>
</ThemeProvider>
</BottomSheetModalProvider>
</DownloadProvider>
</WebSocketProvider>
</LogProvider>
</PlaySettingsProvider>
</JellyfinProvider>
</QueryClientProvider>
);

View File

@@ -4,16 +4,17 @@ import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { t } from "i18next";
import { useAtomValue } from "jotai";
import type React from "react";
import { useCallback, useEffect, useState } from "react";
import {
Alert,
Keyboard,
KeyboardAvoidingView,
Platform,
SafeAreaView,
TouchableOpacity,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { z } from "zod";
import { Button } from "@/components/Button";
import { Input } from "@/components/common/Input";
@@ -62,13 +63,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]);
@@ -82,10 +82,10 @@ const Login: React.FC = () => {
onPress={() => {
removeServer();
}}
className='flex flex-row items-center pr-2 pl-1'
className='flex flex-row items-center'
>
<Ionicons name='chevron-back' size={18} color={Colors.primary} />
<Text className=' ml-1 text-purple-600'>
<Text className='ml-2 text-purple-600'>
{t("login.change_server")}
</Text>
</TouchableOpacity>
@@ -371,11 +371,10 @@ 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'>
<Text className='text-2xl font-bold -mb-2'>
@@ -444,7 +443,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

@@ -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

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

View File

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

View File

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

1207
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

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

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

View File

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

View File

@@ -64,8 +64,9 @@ export const DownloadItems: React.FC<DownloadProps> = ({
const { settings } = useSettings();
const [downloadUnwatchedOnly, setDownloadUnwatchedOnly] = useState(false);
const { processes, startBackgroundDownload, downloadedItems } = useDownload();
const downloadedFiles = downloadedItems;
const { processes, startBackgroundDownload, getDownloadedItems } =
useDownload();
const downloadedFiles = getDownloadedItems();
const [selectedOptions, setSelectedOptions] = useState<
SelectedOptions | undefined
@@ -89,8 +90,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(() => {
@@ -153,13 +157,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 = () => {
@@ -259,12 +256,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
requestAnimationFrame(() => {
// Small delay to ensure modal is fully dismissed
setTimeout(() => {
initiateDownload(...itemsToDownload);
});
}, 100);
} else {
toast.error(
t("home.downloads.toasts.you_are_not_allowed_to_download_files"),
@@ -284,14 +282,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 />
) : (
@@ -345,6 +336,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
@@ -365,18 +359,16 @@ export const DownloadItems: React.FC<DownloadProps> = ({
})}
</Text>
</View>
<View className='flex flex-col space-y-2 w-full'>
<View className='items-start'>
<BitrateSelector
inverted
onChange={(val) =>
setSelectedOptions(
(prev) => prev && { ...prev, bitrate: val },
)
}
selected={selectedOptions?.bitrate}
/>
</View>
<View className='flex flex-col space-y-2 w-full items-start'>
<BitrateSelector
inverted
onChange={(val) =>
setSelectedOptions(
(prev) => prev && { ...prev, bitrate: val },
)
}
selected={selectedOptions?.bitrate}
/>
{itemsNotDownloaded.length > 1 && (
<View className='flex flex-row items-center justify-between w-full py-2'>
<Text>{t("item_card.download.download_unwatched_only")}</Text>
@@ -388,23 +380,21 @@ export const DownloadItems: React.FC<DownloadProps> = ({
)}
{itemsNotDownloaded.length === 1 && (
<View>
<View className='items-start'>
<MediaSourceSelector
item={items[0]}
onChange={(val) =>
setSelectedOptions(
(prev) =>
prev && {
...prev,
mediaSource: val,
},
)
}
selected={selectedOptions?.mediaSource}
/>
</View>
<MediaSourceSelector
item={items[0]}
onChange={(val) =>
setSelectedOptions(
(prev) =>
prev && {
...prev,
mediaSource: val,
},
)
}
selected={selectedOptions?.mediaSource}
/>
{selectedOptions?.mediaSource && (
<View className='flex flex-col space-y-2 items-start'>
<View className='flex flex-col space-y-2'>
<AudioTrackSelector
source={selectedOptions.mediaSource}
onChange={(val) => {
@@ -437,7 +427,11 @@ export const DownloadItems: React.FC<DownloadProps> = ({
)}
</View>
<Button onPress={acceptDownloadOptions} color='purple'>
<Button
className='mt-auto'
onPress={acceptDownloadOptions}
color='purple'
>
{t("item_card.download.download_button")}
</Button>
</View>

View File

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

View File

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

View File

@@ -204,7 +204,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
<View className='flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink'>
<ItemHeader item={item} className='mb-2' />
{item.Type !== "Program" && !Platform.isTV && !isOffline && (
<View className='flex flex-row items-center justify-start w-full h-16 mb-2'>
<View className='flex flex-row items-center justify-start w-full h-16'>
<BitrateSheet
className='mr-1'
onChange={(val) =>

View File

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

View File

@@ -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

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

View File

@@ -33,9 +33,10 @@ import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { chromecast } from "@/utils/profiles/chromecast";
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
import { runtimeTicksToMinutes } from "@/utils/time";
import type { Button } from "./Button";
import type { SelectedOptions } from "./ItemContent";
interface Props extends React.ComponentProps<typeof TouchableOpacity> {
interface Props extends React.ComponentProps<typeof Button> {
item: BaseItemDto;
selectedOptions: SelectedOptions;
isOffline?: boolean;
@@ -50,6 +51,7 @@ export const PlayButton: React.FC<Props> = ({
selectedOptions,
isOffline,
colors,
...props
}: Props) => {
const { showActionSheetWithOptions } = useActionSheet();
const client = useRemoteMediaClient();
@@ -163,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,
@@ -362,52 +364,6 @@ export const PlayButton: React.FC<Props> = ({
* *********************
*/
// if (Platform.OS === "ios")
// return (
// <Host
// style={{
// height: 50,
// flex: 1,
// flexShrink: 0,
// }}
// >
// <Button
// variant='glassProminent'
// onPress={onPress}
// color={effectiveColors.primary}
// modifiers={[fixedSize()]}
// >
// <View className='flex flex-row items-center space-x-2 h-full w-full justify-center -mb-3.5 '>
// <Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
// {runtimeTicksToMinutes(
// (item?.RunTimeTicks || 0) -
// (item?.UserData?.PlaybackPositionTicks || 0),
// )}
// {(item?.UserData?.PlaybackPositionTicks || 0) > 0 && " left"}
// </Animated.Text>
// <Animated.Text style={animatedTextStyle}>
// <Ionicons name='play-circle' size={24} />
// </Animated.Text>
// {client && (
// <Animated.Text style={animatedTextStyle}>
// <Feather name='cast' size={22} />
// <CastButton tintColor='transparent' />
// </Animated.Text>
// )}
// {!client && settings?.openInVLC && (
// <Animated.Text style={animatedTextStyle}>
// <MaterialCommunityIcons
// name='vlc'
// size={18}
// color={animatedTextStyle.color}
// />
// </Animated.Text>
// )}
// </View>
// </Button>
// </Host>
// );
return (
<TouchableOpacity
disabled={!item}
@@ -415,6 +371,7 @@ export const PlayButton: React.FC<Props> = ({
accessibilityHint='Tap to play the media'
onPress={onPress}
className={"relative"}
{...props}
>
<View className='absolute w-full h-full top-0 left-0 rounded-full z-10 overflow-hidden'>
<Animated.View
@@ -442,11 +399,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} />

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

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

View File

@@ -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 || "";

View File

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

View File

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

View File

@@ -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

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

View File

@@ -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

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

View File

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

View File

@@ -6,6 +6,7 @@ import { t } from "i18next";
import { useMemo } from "react";
import {
ActivityIndicator,
Platform,
TouchableOpacity,
type TouchableOpacityProps,
View,
@@ -13,36 +14,49 @@ import {
import { toast } from "sonner-native";
import { Text } from "@/components/common/Text";
import { useDownload } from "@/providers/DownloadProvider";
import { calculateSmoothedETA } from "@/providers/Downloads/hooks/useDownloadSpeedCalculator";
import { JobStatus } from "@/providers/Downloads/types";
import { estimateDownloadSize } from "@/utils/download";
import { storage } from "@/utils/mmkv";
import { formatTimeString } from "@/utils/time";
import { Button } from "../Button";
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 { startDownload, pauseDownload, resumeDownload, removeProcess } =
useDownload();
const router = useRouter();
const queryClient = useQueryClient();
const handlePause = async (id: string) => {
try {
await pauseDownload(id);
toast.success(t("home.downloads.toasts.download_paused"));
} catch (error) {
console.error("Error pausing download:", error);
toast.error(t("home.downloads.toasts.could_not_pause_download"));
}
};
const handleResume = async (id: string) => {
try {
await resumeDownload(id);
toast.success(t("home.downloads.toasts.download_resumed"));
} catch (error) {
console.error("Error resuming download:", error);
toast.error(t("home.downloads.toasts.could_not_resume_download"));
}
};
const handleDelete = async (id: string) => {
try {
await 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);
@@ -50,48 +64,16 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
}
};
const eta = useMemo(() => {
if (!process.estimatedTotalSizeBytes || !process.bytesDownloaded) {
return null;
}
const eta = (p: JobStatus) => {
if (!p.speed || p.speed <= 0 || !p.estimatedTotalSizeBytes) return null;
const secondsRemaining = calculateSmoothedETA(
process.id,
process.bytesDownloaded,
process.estimatedTotalSizeBytes,
);
const bytesRemaining = p.estimatedTotalSizeBytes - (p.bytesDownloaded || 0);
if (bytesRemaining <= 0) return null;
if (!secondsRemaining || secondsRemaining <= 0) {
return null;
}
const secondsRemaining = bytesRemaining / p.speed;
return formatTimeString(secondsRemaining, "s");
}, [process.id, process.bytesDownloaded, process.estimatedTotalSizeBytes]);
const estimatedSize = useMemo(() => {
if (process.estimatedTotalSizeBytes) return process.estimatedTotalSizeBytes;
// Calculate from bitrate + duration (only if bitrate value is defined)
if (process.maxBitrate.value) {
return estimateDownloadSize(
process.maxBitrate.value,
process.item.RunTimeTicks,
);
}
return undefined;
}, [
process.maxBitrate.value,
process.item.RunTimeTicks,
process.estimatedTotalSizeBytes,
]);
const isTranscoding = process.isTranscoding || false;
const downloadedAmount = useMemo(() => {
if (!process.bytesDownloaded) return null;
return formatBytes(process.bytesDownloaded);
}, [process.bytesDownloaded]);
};
const base64Image = useMemo(() => {
return storage.getString(process.item.Id!);
@@ -116,7 +98,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
@@ -127,10 +111,26 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
)}
{/* Action buttons in bottom right corner */}
<View className='absolute bottom-2 right-2 flex flex-row items-center z-10'>
<View className='absolute bottom-2 right-2 flex flex-row items-center space-x-2 z-10'>
{process.status === "downloading" && Platform.OS !== "ios" && (
<TouchableOpacity
onPress={() => handlePause(process.id)}
className='p-1'
>
<Ionicons name='pause' size={20} color='white' />
</TouchableOpacity>
)}
{process.status === "paused" && Platform.OS !== "ios" && (
<TouchableOpacity
onPress={() => handleResume(process.id)}
className='p-1'
>
<Ionicons name='play' size={20} color='white' />
</TouchableOpacity>
)}
<TouchableOpacity
onPress={() => handleDelete(process.id)}
className='p-2 bg-neutral-800 rounded-full'
className='p-1'
>
<Ionicons name='close' size={20} color='red' />
</TouchableOpacity>
@@ -152,53 +152,47 @@ 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 space-x-2 mt-1 text-purple-600'>
{sanitizedProgress === 0 ? (
<ActivityIndicator size={"small"} color={"white"} />
) : (
<Text className='text-xs font-semibold'>
{sanitizedProgress.toFixed(0)}%
<Text className='text-xs'>{sanitizedProgress.toFixed(0)}%</Text>
)}
{process.speed && process.speed > 0 && (
<Text className='text-xs'>
{bytesToMB(process.speed).toFixed(2)} MB/s
</Text>
)}
{downloadedAmount && (
<Text className='text-xs opacity-75'>
{downloadedAmount}
{estimatedSize
? ` / ${isTranscoding ? "~" : ""}${formatBytes(estimatedSize)}`
: ""}
{eta(process) && (
<Text className='text-xs'>
{t("home.downloads.eta", { eta: eta(process) })}
</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'>
{bytesToMB(process.speed).toFixed(2)} MB/s
</Text>
)}
{eta && (
<Text className='text-xs text-green-400'>
{t("home.downloads.eta", { eta: eta })}
</Text>
)}
<View className='flex flex-row items-center space-x-2 mt-1 text-purple-600'>
<Text className='text-xs capitalize'>{process.status}</Text>
</View>
</View>
</View>
{process.status === "completed" && (
<View className='flex flex-row mt-4 space-x-4'>
<Button
onPress={() => {
startDownload(process);
}}
className='w-full'
>
Download now
</Button>
</View>
)}
</View>
</TouchableOpacity>
);

View File

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

View File

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

View File

@@ -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

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

View File

@@ -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,510 +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(() => {
console.log("scrollY", scrollY);
}, [scrollY]);
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

@@ -13,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";
@@ -36,7 +35,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
queryFn,
queryKey,
hideIfEmpty = false,
pageSize = 10,
pageSize = 20,
...props
}) => {
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
@@ -53,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,
});
@@ -180,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>

View File

@@ -10,8 +10,8 @@ import { forwardRef, useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { View, type ViewProps } from "react-native";
import { Button } from "@/components/Button";
import Dropdown from "@/components/common/Dropdown";
import { Text } from "@/components/common/Text";
import { PlatformDropdown } from "@/components/PlatformDropdown";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import type {
QualityProfile,
@@ -48,22 +48,8 @@ const RequestModal = forwardRef<
userId: jellyseerrUser?.id,
});
const [qualityProfileOpen, setQualityProfileOpen] = useState(false);
const [rootFolderOpen, setRootFolderOpen] = useState(false);
const [tagsOpen, setTagsOpen] = useState(false);
const [usersOpen, setUsersOpen] = useState(false);
const { t } = useTranslation();
// Reset all dropdown states when modal closes
const handleDismiss = useCallback(() => {
setQualityProfileOpen(false);
setRootFolderOpen(false);
setTagsOpen(false);
setUsersOpen(false);
onDismiss?.();
}, [onDismiss]);
const { data: serviceSettings } = useQuery({
queryKey: ["jellyseerr", "request", type, "service"],
queryFn: async () =>
@@ -152,109 +138,6 @@ const RequestModal = forwardRef<
});
}, [requestBody?.seasons]);
const pathTitleExtractor = (item: RootFolder) =>
`${item.path} (${item.freeSpace.bytesToReadable()})`;
const qualityProfileOptions = useMemo(
() => [
{
options:
defaultServiceDetails?.profiles.map((profile) => ({
type: "radio" as const,
label: profile.name,
value: profile.id.toString(),
selected:
(requestOverrides.profileId || defaultProfile?.id) ===
profile.id,
onPress: () =>
setRequestOverrides((prev) => ({
...prev,
profileId: profile.id,
})),
})) || [],
},
],
[
defaultServiceDetails?.profiles,
defaultProfile,
requestOverrides.profileId,
],
);
const rootFolderOptions = useMemo(
() => [
{
options:
defaultServiceDetails?.rootFolders.map((folder) => ({
type: "radio" as const,
label: pathTitleExtractor(folder),
value: folder.id.toString(),
selected:
(requestOverrides.rootFolder || defaultFolder?.path) ===
folder.path,
onPress: () =>
setRequestOverrides((prev) => ({
...prev,
rootFolder: folder.path,
})),
})) || [],
},
],
[
defaultServiceDetails?.rootFolders,
defaultFolder,
requestOverrides.rootFolder,
],
);
const tagsOptions = useMemo(
() => [
{
options:
defaultServiceDetails?.tags.map((tag) => ({
type: "toggle" as const,
label: tag.label,
value:
requestOverrides.tags?.includes(tag.id) ||
defaultTags.some((dt) => dt.id === tag.id),
onToggle: () =>
setRequestOverrides((prev) => {
const currentTags = prev.tags || defaultTags.map((t) => t.id);
const hasTag = currentTags.includes(tag.id);
return {
...prev,
tags: hasTag
? currentTags.filter((id) => id !== tag.id)
: [...currentTags, tag.id],
};
}),
})) || [],
},
],
[defaultServiceDetails?.tags, defaultTags, requestOverrides.tags],
);
const usersOptions = useMemo(
() => [
{
options:
users?.map((user) => ({
type: "radio" as const,
label: user.displayName,
value: user.id.toString(),
selected:
(requestOverrides.userId || jellyseerrUser?.id) === user.id,
onPress: () =>
setRequestOverrides((prev) => ({
...prev,
userId: user.id,
})),
})) || [],
},
],
[users, jellyseerrUser, requestOverrides.userId],
);
const request = useCallback(() => {
const body = {
is4k: defaultService?.is4k || defaultServiceDetails?.server.is4k,
@@ -280,12 +163,15 @@ const RequestModal = forwardRef<
defaultTags,
]);
const pathTitleExtractor = (item: RootFolder) =>
`${item.path} (${item.freeSpace.bytesToReadable()})`;
return (
<BottomSheetModal
ref={ref}
enableDynamicSizing
enableDismissOnClose
onDismiss={handleDismiss}
onDismiss={onDismiss}
handleIndicatorStyle={{
backgroundColor: "white",
}}
@@ -299,7 +185,6 @@ const RequestModal = forwardRef<
appearsOnIndex={0}
/>
)}
stackBehavior='push'
>
<BottomSheetView>
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
@@ -314,112 +199,70 @@ const RequestModal = forwardRef<
<View className='flex flex-col space-y-2'>
{defaultService && defaultServiceDetails && users && (
<>
<View className='flex flex-col'>
<Text className='opacity-50 mb-1 text-xs'>
{t("jellyseerr.quality_profile")}
</Text>
<PlatformDropdown
groups={qualityProfileOptions}
trigger={
<View className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
<Text numberOfLines={1}>
{defaultServiceDetails.profiles.find(
(p) =>
p.id ===
(requestOverrides.profileId ||
defaultProfile?.id),
)?.name || defaultProfile?.name}
</Text>
</View>
}
title={t("jellyseerr.quality_profile")}
open={qualityProfileOpen}
onOpenChange={setQualityProfileOpen}
/>
</View>
<View className='flex flex-col'>
<Text className='opacity-50 mb-1 text-xs'>
{t("jellyseerr.root_folder")}
</Text>
<PlatformDropdown
groups={rootFolderOptions}
trigger={
<View className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
<Text numberOfLines={1}>
{defaultServiceDetails.rootFolders.find(
(f) =>
f.path ===
(requestOverrides.rootFolder ||
defaultFolder?.path),
)
? pathTitleExtractor(
defaultServiceDetails.rootFolders.find(
(f) =>
f.path ===
(requestOverrides.rootFolder ||
defaultFolder?.path),
)!,
)
: pathTitleExtractor(defaultFolder!)}
</Text>
</View>
}
title={t("jellyseerr.root_folder")}
open={rootFolderOpen}
onOpenChange={setRootFolderOpen}
/>
</View>
<View className='flex flex-col'>
<Text className='opacity-50 mb-1 text-xs'>
{t("jellyseerr.tags")}
</Text>
<PlatformDropdown
groups={tagsOptions}
trigger={
<View className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
<Text numberOfLines={1}>
{requestOverrides.tags
? defaultServiceDetails.tags
.filter((t) =>
requestOverrides.tags!.includes(t.id),
)
.map((t) => t.label)
.join(", ") ||
defaultTags.map((t) => t.label).join(", ")
: defaultTags.map((t) => t.label).join(", ")}
</Text>
</View>
}
title={t("jellyseerr.tags")}
open={tagsOpen}
onOpenChange={setTagsOpen}
/>
</View>
<View className='flex flex-col'>
<Text className='opacity-50 mb-1 text-xs'>
{t("jellyseerr.request_as")}
</Text>
<PlatformDropdown
groups={usersOptions}
trigger={
<View className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
<Text numberOfLines={1}>
{users.find(
(u) =>
u.id ===
(requestOverrides.userId || jellyseerrUser?.id),
)?.displayName || jellyseerrUser!.displayName}
</Text>
</View>
}
title={t("jellyseerr.request_as")}
open={usersOpen}
onOpenChange={setUsersOpen}
/>
</View>
<Dropdown
data={defaultServiceDetails.profiles}
titleExtractor={(item) => item.name}
placeholderText={
requestOverrides.profileName || defaultProfile.name
}
keyExtractor={(item) => item.id.toString()}
label={t("jellyseerr.quality_profile")}
onSelected={(item) =>
item &&
setRequestOverrides((prev) => ({
...prev,
profileId: item?.id,
}))
}
title={t("jellyseerr.quality_profile")}
/>
<Dropdown
data={defaultServiceDetails.rootFolders}
titleExtractor={pathTitleExtractor}
placeholderText={
defaultFolder ? pathTitleExtractor(defaultFolder) : ""
}
keyExtractor={(item) => item.id.toString()}
label={t("jellyseerr.root_folder")}
onSelected={(item) =>
item &&
setRequestOverrides((prev) => ({
...prev,
rootFolder: item.path,
}))
}
title={t("jellyseerr.root_folder")}
/>
<Dropdown
multiple
data={defaultServiceDetails.tags}
titleExtractor={(item) => item.label}
placeholderText={defaultTags.map((t) => t.label).join(",")}
keyExtractor={(item) => item.id.toString()}
label={t("jellyseerr.tags")}
onSelected={(...selected) =>
setRequestOverrides((prev) => ({
...prev,
tags: selected.map((i) => i.id),
}))
}
title={t("jellyseerr.tags")}
/>
<Dropdown
data={users}
titleExtractor={(item) => item.displayName}
placeholderText={jellyseerrUser!.displayName}
keyExtractor={(item) => item.id.toString() || ""}
label={t("jellyseerr.request_as")}
onSelected={(item) =>
item &&
setRequestOverrides((prev) => ({
...prev,
userId: item?.id,
}))
}
title={t("jellyseerr.request_as")}
/>
</>
)}
</View>

View File

@@ -1,15 +1,16 @@
import { FlashList } from "@shopify/flash-list";
import type { ContentStyle } from "@shopify/flash-list/src/FlashListProps";
import { t } from "i18next";
import type React from "react";
import type { PropsWithChildren } from "react";
import { View, type ViewProps, type ViewStyle } from "react-native";
import { View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
export interface SlideProps {
slide: DiscoverSlider;
contentContainerStyle?: ViewStyle;
contentContainerStyle?: ContentStyle;
}
interface Props<T> extends SlideProps {
@@ -44,6 +45,7 @@ const Slide = <T,>({
}}
showsHorizontalScrollIndicator={false}
keyExtractor={keyExtractor}
estimatedItemSize={250}
data={data}
onEndReachedThreshold={1}
onEndReached={onEndReached}

View File

@@ -11,7 +11,6 @@ import {
import { useAtom } from "jotai";
import { useCallback } from "react";
import { View, type ViewProps } from "react-native";
import { useInView } from "@/hooks/useInView";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { InfiniteHorizontalScroll } from "../common/InfiniteHorizontalScroll";
import { Text } from "../common/Text";
@@ -22,29 +21,20 @@ import MoviePoster from "../posters/MoviePoster";
interface Props extends ViewProps {
queryKey: QueryKey;
queryFn: QueryFunction<BaseItemDto>;
scrollY?: number; // For lazy loading
enableLazyLoading?: boolean; // Enable/disable lazy loading
}
export const MediaListSection: React.FC<Props> = ({
queryFn,
queryKey,
scrollY = 0,
enableLazyLoading = false,
...props
}) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { ref, isInView, onLayout } = useInView(scrollY, {
enabled: enableLazyLoading,
});
const { data: collection } = useQuery({
queryKey,
queryFn,
staleTime: 60 * 1000, // 1 minute
enabled: enableLazyLoading ? isInView : true,
staleTime: 0,
});
const fetchItems = useCallback(
@@ -70,7 +60,7 @@ export const MediaListSection: React.FC<Props> = ({
if (!collection) return null;
return (
<View ref={ref} onLayout={onLayout} {...props}>
<View {...props}>
<Text className='px-4 text-lg font-bold mb-2 text-neutral-100'>
{collection.Name}
</Text>

View File

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

View File

@@ -34,6 +34,7 @@ export const SearchItemWrapper = <T,>({
}}
showsHorizontalScrollIndicator={false}
keyExtractor={(_, index) => index.toString()}
estimatedItemSize={250}
data={items}
onEndReachedThreshold={1}
onEndReached={onEndReached}

View File

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

View File

@@ -47,6 +47,7 @@ const JellyseerrSeasonEpisodes: React.FC<{
horizontal
loading={isLoading}
showsHorizontalScrollIndicator={false}
estimatedItemSize={50}
data={seasonWithEpisodes?.episodes}
keyExtractor={(item) => item.id.toString()}
renderItem={(item, index) => (
@@ -283,6 +284,7 @@ const JellyseerrSeasons: React.FC<{
</View>
)}
ItemSeparatorComponent={() => <View className='h-2' />}
estimatedItemSize={250}
renderItem={({ item: season }) => (
<>
<TouchableOpacity

View File

@@ -49,6 +49,7 @@ export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
<FlashList
contentContainerStyle={{ paddingLeft: 16 }}
horizontal
estimatedItemSize={172}
showsHorizontalScrollIndicator={false}
data={items}
renderItem={({ item, index }) => (

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