Compare commits

..

20 Commits

Author SHA1 Message Date
Gauvain
8e91eba5c4 Merge branch 'develop' into view-password 2025-09-04 00:19:17 +02:00
Gauvain
d563577c8f Merge branch 'develop' into view-password 2025-09-03 15:47:18 +02:00
Gauvain
5676359138 Merge branch 'develop' into view-password 2025-09-02 15:34:47 +02:00
Uruk
6ea93ef8f0 fix: correct wording for password visibility toggle in translations 2025-09-01 23:40:46 +02:00
Uruk
d5aa812e04 chore: replace postinstall-postinstall with postinstall package
Updates dependency from postinstall-postinstall v2.1.0 to postinstall v0.11.2

Also includes dependency version updates for glob, minimatch, jackspeak, and path-scurry packages with their associated nested dependencies

Adds @expo/vector-icons to trusted dependencies list for improved build reliability
2025-09-01 23:40:01 +02:00
Uruk
79a785a615 chore: update sonner-native to v0.21.1
Incorporates latest bug fixes and improvements from the sonner-native library to enhance toast notification functionality and stability.
2025-09-01 23:34:11 +02:00
Uruk
4b2fcee460 feat: add accessibility support for password toggle
Enhances password input component with proper accessibility attributes including role, label, hint, and state to improve screen reader experience.

Adjusts minimum height handling for Android platform compatibility in list items and replaces Tailwind classes with inline styles for gesture control settings to ensure consistent styling across platforms.

Adds translation keys for password visibility toggle functionality.
2025-09-01 23:19:12 +02:00
Uruk
a93b935df3 style: improve login form spacing and alignment
Adjusts margin classes and offsets to create better visual hierarchy and spacing between form elements.

Adds bottom margins to input fields and containers, increases top offset for password input, and adds top margins to button containers for improved layout consistency.
2025-09-01 22:57:17 +02:00
Uruk
29d3360a10 refactor: standardize Input component prop naming
Replaces `className` prop with `extraClassName` across Input and PasswordInput components for consistency.

Updates PasswordInput to use numeric `topOffset` instead of string `topPosition` for better type safety and clearer intent.

Adds uncontrolled mode support to PasswordInput with internal state management and optional `defaultShowPassword` prop.

Removes unnecessary margin classes from various View components to clean up spacing.
2025-09-01 22:54:11 +02:00
Uruk
be884ce6e6 fix: improve list item height consistency and accessibility
Replaces fixed height with minimum height to ensure proper content display and accessibility compliance.

Changes list items from fixed 44px height to minimum height, allowing content to expand naturally while maintaining the minimum touch target size.

Adds specific styling to gesture control settings items to accommodate longer descriptions and improve readability.
2025-09-01 22:49:30 +02:00
Uruk
a88e13b14f refactor: streamline pull request template by removing unnecessary sections 2025-09-01 21:27:14 +02:00
Uruk
ff6b1112b6 feat: add consistent focus state styling to input component
Improves visual feedback by adding focus state borders to all input variants.

Changes the unfocused border from transparent to neutral-800 for better visual consistency and adds focus/blur handlers to ensure proper state management across different input types.
2025-09-01 21:20:40 +02:00
Uruk
1156942e33 fix: adjust password field top position for better layout
Updates the top position value from 4 to 15 to improve the visual spacing and alignment of the password input field in the TV layout mode.
2025-09-01 21:05:26 +02:00
Uruk
e9effb46f6 chore: pin devDependency versions to exact releases
Removes caret ranges from development dependencies to ensure consistent builds across environments and prevent unexpected version drift during installation.

Updates multiple packages including Babel, Biome, React Native CLI, TypeScript tooling, and testing utilities to their exact versions.
2025-09-01 20:57:49 +02:00
Uruk
4dce87dfd3 refactor: simplify PasswordInput component and standardize usage
Removes unnecessary props and internal state management from PasswordInput component to make it more focused and reusable. Wraps all PasswordInput instances in relative positioned Views for consistent layout behavior.

Updates package.json to use caret version for @expo/vector-icons dependency for better version flexibility.
2025-09-01 19:18:54 +02:00
Uruk
2f2e5a2730 feat: add customizable icon color to PasswordInput
Adds iconColor prop with white default to allow customization of the eye/eye-off toggle icon color.

Also simplifies top position class construction by using template literal instead of conditional logic.
2025-09-01 16:00:04 +02:00
Uruk
614736ad4a feat: upgrade @expo/vector-icons to v15.0.2
Updates the vector icons package to the latest major version, which includes improved peer dependency constraints for expo-font (>=14.0.4) and enhanced icon support for the application.
2025-09-01 15:59:12 +02:00
Uruk
3919bb346f refactor: replace inline password inputs with reusable PasswordInput component
Consolidates duplicate password input implementations across login and settings screens into a single reusable component.

Improves code maintainability by eliminating redundant password visibility toggle logic and standardizing password input behavior throughout the application.

Adds consistent accessibility support and test identifiers across all password input instances.
2025-09-01 15:48:55 +02:00
Uruk
87eff6f80c fix: adjust password toggle button vertical alignment for TV
Improves visual positioning of the show/hide password toggle button by slightly adjusting the top margin from 3.5 to 4, ensuring better alignment with the password input field.
2025-09-01 15:30:21 +02:00
Uruk
15d0de806b feat: add password visibility toggle to login forms
Improves user experience by allowing users to show/hide password text in both main login form and Jellyseerr settings.

Adds eye icon button that toggles between masked and visible password input, making it easier for users to verify their password entries.
2025-09-01 15:05:40 +02:00
465 changed files with 11097 additions and 126280 deletions

View File

@@ -1,37 +0,0 @@
---
description: Reflect on this session to extract and store learned facts about the codebase
---
Analyze the current conversation to extract useful facts that should be remembered for future sessions. Focus on:
1. **Corrections**: Things the user corrected you about
2. **Clarifications**: Misunderstandings about how the codebase works
3. **Patterns**: Important conventions or patterns you learned
4. **Gotchas**: Surprising behaviors or edge cases discovered
5. **Locations**: Files or code that was hard to find
## Instructions
1. Read the existing facts file at `.claude/learned-facts.md`
2. Review this conversation for learnings worth preserving
3. For each new fact:
- Write it concisely (1-2 sentences max)
- Include context for why it matters
- Add today's date
4. Skip facts that duplicate existing entries
5. Append new facts to `.claude/learned-facts.md`
## Fact Format
Use this format for each fact:
```
- **[Brief Topic]**: [Concise description of the fact] _(YYYY-MM-DD)_
```
## Example Facts
- **State management**: Use Jotai atoms for global state, NOT React Context - atoms are in `utils/atoms/` _(2025-01-09)_
- **Package manager**: Always use `bun`, never npm or yarn - the project is configured for bun only _(2025-01-09)_
- **TV platform**: Check `Platform.isTV` for TV-specific code paths, not just OS checks _(2025-01-09)_
After updating the file, summarize what facts you added (or note if nothing new was learned this session).

View File

@@ -1,27 +0,0 @@
# Learned Facts
This file contains facts about the codebase learned from past sessions. These are things Claude got wrong or needed clarification on, stored here to prevent the same mistakes in future sessions.
This file is auto-imported into CLAUDE.md and loaded at the start of each session.
## Facts
<!-- New facts will be appended below this line -->
- **Native bottom tabs + useRouter conflict**: When using `@bottom-tabs/react-navigation` with Expo Router, avoid using the `useRouter()` hook in components rendered at the provider level (outside the tab navigator). The hook subscribes to navigation state changes and can cause unexpected tab switches. Use the static `router` import from `expo-router` instead. _(2025-01-09)_
- **IntroSheet rendering location**: The `IntroSheet` component is rendered inside `IntroSheetProvider` which wraps the entire navigation stack. Any hooks in IntroSheet that interact with navigation state can affect the native bottom tabs. _(2025-01-09)_
- **Intro modal trigger location**: The intro modal trigger logic should be in the `Home.tsx` component, not in the tabs `_layout.tsx`. Triggering modals from tab layout can interfere with native bottom tabs navigation. _(2025-01-09)_
- **Tab folder naming**: The tab folders use underscore prefix naming like `(_home)` instead of just `(home)` based on the project's file structure conventions. _(2025-01-09)_
- **macOS header buttons fix**: Header buttons (`headerRight`/`headerLeft`) don't respond to touches on macOS Catalyst builds when using standard React Native `TouchableOpacity`. Fix by using `Pressable` from `react-native-gesture-handler` instead. The library is already installed and `GestureHandlerRootView` wraps the app. _(2026-01-10)_
- **Header button locations**: Header buttons are defined in multiple places: `app/(auth)/(tabs)/(home)/_layout.tsx` (SettingsButton, SessionsButton, back buttons), `components/common/HeaderBackButton.tsx` (reusable), `components/Chromecast.tsx`, `components/RoundButton.tsx`, and dynamically via `navigation.setOptions()` in `components/home/Home.tsx` and `app/(auth)/(tabs)/(home)/downloads/index.tsx`. _(2026-01-10)_
- **useNetworkAwareQueryClient limitations**: The `useNetworkAwareQueryClient` hook uses `Object.create(queryClient)` which breaks QueryClient methods that use JavaScript private fields (like `getQueriesData`, `setQueriesData`, `setQueryData`). Only use it when you ONLY need `invalidateQueries`. For cache manipulation, use standard `useQueryClient` from `@tanstack/react-query`. _(2026-01-10)_
- **Mark as played flow**: The "mark as played" button uses `PlayedStatus` component → `useMarkAsPlayed` hook → `usePlaybackManager.markItemPlayed()`. The hook does optimistic updates via `setQueriesData` before calling the API. Located in `components/PlayedStatus.tsx` and `hooks/useMarkAsPlayed.ts`. _(2026-01-10)_
- **Stack screen header configuration**: Sub-pages under `(home)` need explicit `Stack.Screen` entries in `app/(auth)/(tabs)/(home)/_layout.tsx` with `headerTransparent: Platform.OS === "ios"`, `headerBlurEffect: "none"`, and a back button. Without this, pages show with wrong header styling. _(2026-01-10)_

View File

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

View File

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

View File

@@ -1,13 +1,11 @@
# Contributing to Streamyfin
Thank you for your interest in contributing to the Streamyfin project. This document outlines the guidelines for effective collaboration across the Streamyfin codebase and aims to ensure a smooth, productive experience for all contributors.
Thank you for your interest in contributing to the Streamyfin mobile app project! This document provides guidelines to smoothly collaborate on the Streamyfin codebase and help improve the app for all users.
---
## Table of Contents
- [AI Assistance Disclosure](#ai-assistance-disclosure)
- [Reporting Issues](#reporting-issues)
- [Reporting Security Vulnerabilities](#reporting-security-vulnerabilities)
- [Requesting Features & Enhancements](#requesting-features--enhancements)
@@ -21,25 +19,6 @@ Thank you for your interest in contributing to the Streamyfin project. This docu
---
## AI Assistance Disclosure
> [!IMPORTANT]
> If any AI tool was used while contributing to Streamyfin, it must be disclosed in the pull request.
State in your PR whether AI assistance was used and to what extent (for example, *docs only* or *code generation*).
If AI-generated text was used in PR discussions or responses, disclose that as well.
Minor autocomplete or keyword suggestions do not require disclosure.
### Examples
> This PR was written primarily by Claude Code.
> I used Cursor to explore parts of the codebase, but the implementation is fully manual.
Failing to disclose AI usage wastes maintainers time and complicates review efforts.
AI-assisted contributions are welcome, but contributors remain fully responsible for the code they submit.
Always disclose AI involvement to maintain transparency and respect for maintainers time.
## Reporting Issues
Streamyfin uses GitHub issues to track bugs and improvements. Before opening a new issue:
@@ -67,11 +46,11 @@ When creating a new feature request:
- Check if the idea or similar request exists.
- Use reactions like 👍 to support existing requests.
- Clearly describe the use case and potential benefits.
- Include screenshots when relevant.
- Provide a clear explanation of the use case and benefits.
---
## Developing Streamyfin
## Developing the Mobile App
### Codebase Overview
@@ -157,8 +136,6 @@ When opening a PR:
- Provide a detailed description in the PR body, explaining what, why, and any impacts.
- Include screenshots or recordings if UI changes are involved.
- Ensure CI checks are green (lint, type-check, build).
- Confirm that the branch is **up to date with `main`** before submission.
- Mention if AI-generated code or content was used (see [AI Assistance Disclosure](#ai-assistance-disclosure)).
- Do not include secrets, tokens, or production credentials. Redact sensitive data in logs and screenshots.
- Keep PRs focused; avoid bundling unrelated changes together.
@@ -182,4 +159,4 @@ PRs require review and approval by maintainers before merging.---
---
Thank you for contributing to make Streamyfin better for everyone!
Thank you for helping make Streamyfin a better app for everyone!

View File

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

View File

@@ -1,96 +0,0 @@
# Copilot Instructions for Streamyfin
## Project Overview
Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native).
It supports mobile (iOS/Android) and TV platforms, integrates with Jellyfin and Jellyseerr APIs,
and provides seamless media streaming with offline capabilities and Chromecast support.
## Main Technologies
- **Runtime**: Bun (JavaScript/TypeScript execution)
- **Framework**: React Native (Expo)
- **Language**: TypeScript (strict mode)
- **State Management**: Jotai (global state) + React Query (server state)
- **API SDK**: Jellyfin SDK (TypeScript)
- **Navigation**: Expo Router (file-based routing)
- **Code Quality**: BiomeJS (formatting/linting)
- **Build Platform**: EAS (Expo Application Services)
- **CI/CD**: GitHub Actions with Bun
## Package Management
**CRITICAL: ALWAYS use `bun` for all package management operations**
- **NEVER use `npm`, `yarn` or `npx` commands**
- Use `bun install` instead of `npm install` or `yarn install`
- Use `bun add <package>` instead of `npm install <package>`
- Use `bun remove <package>` instead of `npm uninstall <package>`
- Use `bun run <script>` instead of `npm run <script>`
- Use `bunx <command>` instead of `npx <command>`
- For Expo: use `bunx create-expo-app` or `bunx @expo/cli`
## Code Structure
- `app/` Main application code (screens, navigation, etc.)
- `components/` Reusable UI components
- `providers/` Context and API providers (e.g., JellyfinProvider.tsx)
- `utils/` Utility functions and Jotai atoms
- `assets/` Images and static assets
- `scripts/` Automation scripts (Node.js, Bash)
- `plugins/` Expo/Metro plugins
## Coding Standards
- Use TypeScript for ALL files (no .js files)
- Use descriptive English names for variables, functions, and components
- Prefer functional React components with hooks
- Use Jotai atoms for global state management
- Use React Query for server state and caching
- Follow BiomeJS formatting and linting rules
- Use `const` over `let`, avoid `var` entirely
- Implement proper error boundaries
- Use React.memo() for performance optimization
- Handle both mobile and TV navigation patterns
## API Integration
- Use Jellyfin SDK for all server interactions
- Access authenticated APIs via `apiAtom` and `userAtom` from JellyfinProvider
- Implement proper loading states and error handling
- Use React Query for caching and background updates
- Handle offline scenarios gracefully
## Performance Optimization
- Leverage Bun's superior runtime performance
- Optimize FlatList components with proper props
- Use lazy loading for non-critical components
- Implement proper image caching strategies
- Monitor bundle size and use tree-shaking effectively
## Testing
- Use Bun's built-in test runner when possible
- Test files: `*.test.ts` or `*.test.tsx`
- Run tests with: `bun test`
- Mock external APIs in tests
- Focus on testing business logic and custom hooks
## Commit Messages
Use Conventional Commits (https://www.conventionalcommits.org/):
Exemples:
- `feat(player): add Chromecast support`
- `fix(auth): handle expired JWT tokens`
- `chore(deps): update Jellyfin SDK`
## Special Instructions
- Prioritize cross-platform compatibility (mobile + TV)
- Ensure accessibility for TV remote navigation
- Use existing atoms, hooks, and utilities before creating new ones
- Maintain compatibility with Expo and EAS workflows
- Always verify Bun compatibility when suggesting new dependencies
**Copilot: Please use these instructions to provide context-aware suggestions and code completions for this repository.**

View File

@@ -5,8 +5,6 @@
and to ensure all necessary checks are completed before merging.
-->
# 📦 Pull Request
## 🔖 Summary
<!--
A concise description of the changes introduced by this PR.
@@ -36,12 +34,6 @@ Spec: https://www.conventionalcommits.org/ -->
- Short summary: what changed and why (12 lines)
-->
## 📋 Details
<!--
Provide more context or background. Explain any non-obvious decisions.
Include screenshots or GIFs for UI changes if applicable.
-->
### ⚠️ Breaking Changes
<!-- List any breaking API/contract changes and migration guidance. If none, write “None”. -->
@@ -52,7 +44,10 @@ Include screenshots or GIFs for UI changes if applicable.
<!-- Hot paths, memory/CPU/latency implications, benchmarks if available. -->
### 🖼️ Screenshots / GIFs (if UI)
<!-- Before/After, dark mode, responsive states. -->
<!--
Provide more context or background. Explain any non-obvious decisions.
Before/After, dark mode, responsive states.
-->
## ✅ Checklist
<!--

View File

@@ -1,478 +0,0 @@
name: 📝 Artifact Comment on PR
concurrency:
group: artifact-comment-${{ github.event.workflow_run.head_sha || github.sha }}
cancel-in-progress: true
on:
workflow_dispatch: # Allow manual testing
pull_request: # Show in PR checks and provide status updates
types: [opened, synchronize, reopened]
workflow_run: # Triggered when build workflows complete
workflows:
- "🏗️ Build Apps"
types:
- completed
jobs:
comment-artifacts:
if: github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request' || (github.event_name == 'workflow_run' && github.event.workflow_run.event == 'pull_request')
name: 📦 Post Build Artifacts
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
actions: read
steps:
- name: 🔍 Get PR and Artifacts
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: |
// Check if we're running from a fork (more precise detection)
const targetRepo = context.repo.owner + '/' + context.repo.repo;
const prHeadRepo = context.payload.pull_request?.head?.repo?.full_name;
const workflowHeadRepo = context.payload.workflow_run?.head_repository?.full_name;
// For debugging
console.log('🔍 Repository detection:');
console.log('- Target repository:', targetRepo);
console.log('- PR head repository:', prHeadRepo || 'N/A');
console.log('- Workflow head repository:', workflowHeadRepo || 'N/A');
console.log('- Event name:', context.eventName);
// Only skip if it's actually a different repository (fork)
const isFromFork = prHeadRepo && prHeadRepo !== targetRepo;
const workflowFromFork = workflowHeadRepo && workflowHeadRepo !== targetRepo;
if (isFromFork || workflowFromFork) {
console.log('🚫 Workflow running from fork - skipping comment creation to avoid permission errors');
console.log('Fork repository:', prHeadRepo || workflowHeadRepo);
console.log('Target repository:', targetRepo);
return;
}
console.log('✅ Same repository - proceeding with comment creation'); // Handle repository_dispatch, pull_request, and manual dispatch events
let pr;
let targetCommitSha;
if (context.eventName === 'workflow_run') {
// Find PR associated with this workflow run commit
console.log('Workflow run event:', context.payload.workflow_run.name);
const { data: pullRequests } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner: context.repo.owner,
repo: context.repo.repo,
commit_sha: context.payload.workflow_run.head_sha
});
if (pullRequests.length === 0) {
console.log('No pull request found for commit:', context.payload.workflow_run.head_sha);
return;
}
pr = pullRequests[0];
targetCommitSha = context.payload.workflow_run.head_sha;
} else if (context.eventName === 'pull_request') {
// Direct PR event
pr = context.payload.pull_request;
targetCommitSha = pr.head.sha;
} else if (context.eventName === 'workflow_dispatch') {
// For manual testing, try to find PR for current branch/commit
console.log('Manual workflow dispatch triggered');
// First, try to find PRs associated with current commit
try {
const { data: pullRequests } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner: context.repo.owner,
repo: context.repo.repo,
commit_sha: context.sha
});
if (pullRequests.length > 0) {
pr = pullRequests[0];
targetCommitSha = pr.head.sha;
console.log(`Found PR #${pr.number} for commit ${context.sha.substring(0, 7)}`);
} else {
// Fallback: get latest open PR
const { data: openPRs } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
sort: 'updated',
direction: 'desc',
per_page: 1
});
if (openPRs.length > 0) {
pr = openPRs[0];
targetCommitSha = pr.head.sha;
console.log(`Using latest open PR #${pr.number} for manual testing`);
} else {
console.log('No open PRs found for manual testing');
return;
}
}
} catch (error) {
console.log('Error finding PR for manual testing:', error.message);
return;
}
} else {
console.log('Unsupported event type:', context.eventName);
return;
}
console.log(`Processing PR #${pr.number} for commit ${targetCommitSha.substring(0, 7)}`);
// Get all recent workflow runs for this PR to collect artifacts from multiple builds
const { data: workflowRuns } = await github.rest.actions.listWorkflowRunsForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
head_sha: targetCommitSha,
per_page: 30
});
// Filter for build workflows only, include active runs even if marked as cancelled
const buildRuns = workflowRuns.workflow_runs
.filter(run =>
(run.name.includes('Build Apps') ||
run.name.includes('Android APK Build') ||
run.name.includes('iOS IPA Build'))
)
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
console.log(`Found ${buildRuns.length} non-cancelled build workflow runs for this commit`);
// Log current status of each build for debugging
buildRuns.forEach(run => {
console.log(`- ${run.name}: ${run.status} (${run.conclusion || 'no conclusion yet'}) - Created: ${run.created_at}`);
});
// Collect artifacts and statuses from builds - prioritize active runs over completed ones
let allArtifacts = [];
let buildStatuses = {};
// Get the most relevant run for each workflow type (prioritize active over cancelled)
const findBestRun = (nameFilter) => {
const matchingRuns = buildRuns.filter(run => run.name.includes(nameFilter));
// First try to find an in-progress run
const inProgressRun = matchingRuns.find(run => run.status === 'in_progress');
if (inProgressRun) return inProgressRun;
// Then try to find a queued run
const queuedRun = matchingRuns.find(run => run.status === 'queued');
if (queuedRun) return queuedRun;
// Check if the workflow is completed but has non-cancelled jobs
const completedRuns = matchingRuns.filter(run => run.status === 'completed');
for (const run of completedRuns) {
// We'll check individual jobs later to see if they're actually running
if (run.conclusion !== 'cancelled') {
return run;
}
}
// Finally fall back to most recent run (even if cancelled at workflow level)
return matchingRuns[0]; // Already sorted by most recent first
};
const latestAppsRun = findBestRun('Build Apps');
const latestAndroidRun = findBestRun('Android APK Build');
const latestIOSRun = findBestRun('iOS IPA Build');
// For the consolidated workflow, get individual job statuses
if (latestAppsRun) {
console.log(`Getting individual job statuses for run ${latestAppsRun.id} (status: ${latestAppsRun.status}, conclusion: ${latestAppsRun.conclusion || 'none'})`);
try {
// Get all jobs for this workflow run
const { data: jobs } = await github.rest.actions.listJobsForWorkflowRun({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: latestAppsRun.id
});
console.log(`Found ${jobs.jobs.length} jobs in workflow run`);
jobs.jobs.forEach(job => {
console.log(`- Job: ${job.name} | Status: ${job.status} | Conclusion: ${job.conclusion || 'none'}`);
});
// Check if we have any actually running jobs (not cancelled)
const activeJobs = jobs.jobs.filter(job =>
job.status === 'in_progress' ||
job.status === 'queued' ||
(job.status === 'completed' && job.conclusion !== 'cancelled')
);
console.log(`Found ${activeJobs.length} active (non-cancelled) jobs out of ${jobs.jobs.length} total jobs`);
// If no jobs are actually running, skip this workflow
if (activeJobs.length === 0 && latestAppsRun.conclusion === 'cancelled') {
console.log('All jobs are cancelled, skipping this workflow run');
return; // Exit early
}
// Map job names to our build targets
const jobMappings = {
'Android Phone': ['🤖 Build Android APK (Phone)', 'build-android-phone'],
'Android TV': ['🤖 Build Android APK (TV)', 'build-android-tv'],
'iOS Phone': ['🍎 Build iOS IPA (Phone)', 'build-ios-phone']
};
// Create individual status for each job
for (const [platform, jobNames] of Object.entries(jobMappings)) {
const job = jobs.jobs.find(j =>
jobNames.some(name => j.name.includes(name) || j.name === name)
);
if (job) {
buildStatuses[platform] = {
name: job.name,
status: job.status,
conclusion: job.conclusion,
url: job.html_url,
runId: latestAppsRun.id,
created_at: job.started_at || latestAppsRun.created_at
};
console.log(`Mapped ${platform} to job: ${job.name} (${job.status}/${job.conclusion || 'none'})`);
} else {
console.log(`No job found for ${platform}, using workflow status as fallback`);
buildStatuses[platform] = {
name: latestAppsRun.name,
status: latestAppsRun.status,
conclusion: latestAppsRun.conclusion,
url: latestAppsRun.html_url,
runId: latestAppsRun.id,
created_at: latestAppsRun.created_at
};
}
}
} catch (error) {
console.log(`Failed to get jobs for run ${latestAppsRun.id}:`, error.message);
// Fallback to workflow-level status
buildStatuses['Android Phone'] = buildStatuses['Android TV'] = buildStatuses['iOS Phone'] = {
name: latestAppsRun.name,
status: latestAppsRun.status,
conclusion: latestAppsRun.conclusion,
url: latestAppsRun.html_url,
runId: latestAppsRun.id,
created_at: latestAppsRun.created_at
};
}
// Collect artifacts if any job has completed successfully
if (latestAppsRun.status === 'completed' ||
Object.values(buildStatuses).some(status => status.conclusion === 'success')) {
try {
const { data: artifacts } = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: latestAppsRun.id
});
allArtifacts.push(...artifacts.artifacts);
} catch (error) {
console.log(`Failed to get apps artifacts for run ${latestAppsRun.id}:`, error.message);
}
}
} else {
// Fallback to separate workflows (for backward compatibility)
if (latestAndroidRun) {
buildStatuses['Android'] = {
name: latestAndroidRun.name,
status: latestAndroidRun.status,
conclusion: latestAndroidRun.conclusion,
url: latestAndroidRun.html_url,
runId: latestAndroidRun.id,
created_at: latestAndroidRun.created_at
};
if (latestAndroidRun.conclusion === 'success') {
try {
const { data: artifacts } = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: latestAndroidRun.id
});
allArtifacts.push(...artifacts.artifacts);
} catch (error) {
console.log(`Failed to get Android artifacts for run ${latestAndroidRun.id}:`, error.message);
}
}
}
if (latestIOSRun) {
buildStatuses['iOS'] = {
name: latestIOSRun.name,
status: latestIOSRun.status,
conclusion: latestIOSRun.conclusion,
url: latestIOSRun.html_url,
runId: latestIOSRun.id,
created_at: latestIOSRun.created_at
};
if (latestIOSRun.conclusion === 'success') {
try {
const { data: artifacts } = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: latestIOSRun.id
});
allArtifacts.push(...artifacts.artifacts);
} catch (error) {
console.log(`Failed to get iOS artifacts for run ${latestIOSRun.id}:`, error.message);
}
}
}
}
console.log(`Collected ${allArtifacts.length} total artifacts from all builds`);
// Debug: Show which workflow we're using and its status
if (latestAppsRun) {
console.log(`Using consolidated workflow: ${latestAppsRun.name} (${latestAppsRun.status}/${latestAppsRun.conclusion})`);
} else {
console.log(`Using separate workflows - Android: ${latestAndroidRun?.name || 'none'}, iOS: ${latestIOSRun?.name || 'none'}`);
}
// Debug: List all artifacts found
allArtifacts.forEach(artifact => {
console.log(`- Artifact: ${artifact.name} (from run ${artifact.workflow_run.id})`);
});
// Build comment body with progressive status for individual builds
let commentBody = `## 🔧 Build Status for PR #${pr.number}\n\n`;
commentBody += `🔗 **Commit**: [\`${targetCommitSha.substring(0, 7)}\`](https://github.com/${context.repo.owner}/${context.repo.repo}/commit/${targetCommitSha})\n\n`; // Progressive build status and downloads table
commentBody += `### 📦 Build Artifacts\n\n`;
commentBody += `| Platform | Device | Status | Download |\n`;
commentBody += `|----------|--------|--------|---------|\n`;
// Process each expected build target individually
const buildTargets = [
{ name: 'Android Phone', platform: '🤖', device: '📱', statusKey: 'Android Phone', artifactPattern: /android.*phone/i },
{ name: 'Android TV', platform: '🤖', device: '📺', statusKey: 'Android TV', artifactPattern: /android.*tv/i },
{ name: 'iOS Phone', platform: '🍎', device: '📱', statusKey: 'iOS Phone', artifactPattern: /ios.*phone/i },
{ name: 'iOS TV', platform: '🍎', device: '📺', statusKey: 'iOS TV', artifactPattern: /ios.*tv/i }
];
for (const target of buildTargets) {
// Find matching job status directly
const matchingStatus = buildStatuses[target.statusKey];
// Find matching artifact
const matchingArtifact = allArtifacts.find(artifact =>
target.artifactPattern.test(artifact.name)
);
let status = '⏳ Pending';
let downloadLink = '*Waiting for build...*';
// Special case for iOS TV - show as disabled
if (target.name === 'iOS TV') {
status = '💤 Disabled';
downloadLink = '*Disabled for now*';
} else if (matchingStatus) {
if (matchingStatus.conclusion === 'success' && matchingArtifact) {
status = '✅ Complete';
const directLink = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${matchingArtifact.workflow_run.id}/artifacts/${matchingArtifact.id}`;
const fileType = target.name.includes('Android') ? 'APK' : 'IPA';
downloadLink = `[📥 Download ${fileType}](${directLink})`;
} else if (matchingStatus.conclusion === 'failure') {
status = `❌ [Failed](${matchingStatus.url})`;
downloadLink = '*Build failed*';
} else if (matchingStatus.conclusion === 'cancelled') {
status = `⚪ [Cancelled](${matchingStatus.url})`;
downloadLink = '*Build cancelled*';
} else if (matchingStatus.status === 'in_progress') {
status = `🔄 [Building...](${matchingStatus.url})`;
downloadLink = '*Build in progress...*';
} else if (matchingStatus.status === 'queued') {
status = `⏳ [Queued](${matchingStatus.url})`;
downloadLink = '*Waiting to start...*';
} else if (matchingStatus.status === 'completed' && !matchingStatus.conclusion) {
// Workflow completed but conclusion not yet available (rare edge case)
status = `🔄 [Finishing...](${matchingStatus.url})`;
downloadLink = '*Finalizing build...*';
} else if (matchingStatus.status === 'completed' && matchingStatus.conclusion === 'success' && !matchingArtifact) {
// Build succeeded but artifacts not yet available
status = `⏳ [Processing artifacts...](${matchingStatus.url})`;
downloadLink = '*Preparing download...*';
} else {
// Fallback for any unexpected states
status = `❓ [${matchingStatus.status}/${matchingStatus.conclusion || 'pending'}](${matchingStatus.url})`;
downloadLink = `*Status: ${matchingStatus.status}, Conclusion: ${matchingStatus.conclusion || 'pending'}*`;
}
}
commentBody += `| ${target.platform} ${target.name.split(' ')[0]} | ${target.device} ${target.name.split(' ')[1]} | ${status} | ${downloadLink} |\n`;
}
commentBody += `\n`;
// Show installation instructions if we have any artifacts
if (allArtifacts.length > 0) {
commentBody += `### 🔧 Installation Instructions\n\n`;
commentBody += `- **Android APK**: Download and install directly on your device (enable "Install from unknown sources")\n`;
commentBody += `- **iOS IPA**: Install using [AltStore](https://altstore.io/), [Sideloadly](https://sideloadly.io/), or Xcode\n\n`;
commentBody += `> ⚠️ **Note**: Artifacts expire in 7 days from build date\n\n`;
} else {
commentBody += `⏳ **Builds are starting up...** This comment will update automatically as each build completes.\n\n`;
}
commentBody += `<sub>*Auto-generated by [GitHub Actions](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId})*</sub>`;
commentBody += `\n<!-- streamyfin-artifact-comment -->`;
// Try to find existing bot comment to update (with permission check)
try {
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number
});
const botComment = comments.find(comment =>
comment.user.type === 'Bot' &&
comment.body.includes('<!-- streamyfin-artifact-comment -->')
);
if (botComment) {
// Update existing comment
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body: commentBody
});
console.log(`✅ Updated comment ${botComment.id} on PR #${pr.number}`);
} else {
// Create new comment
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
body: commentBody
});
console.log(`✅ Created new comment on PR #${pr.number}`);
}
} catch (error) {
if (error.status === 403) {
console.log('🚫 Permission denied - likely running from a fork. Skipping comment creation.');
console.log('Error details:', error.message);
// Log the build status instead of commenting
console.log('📊 Build Status Summary:');
for (const target of buildTargets) {
const matchingStatus = buildStatuses[target.statusKey];
if (matchingStatus) {
console.log(`- ${target.name}: ${matchingStatus.status}/${matchingStatus.conclusion || 'none'}`);
}
}
} else {
// Re-throw other errors
throw error;
}
}

93
.github/workflows/build-android.yml vendored Normal file
View File

@@ -0,0 +1,93 @@
name: 🤖 Android APK Build (Phone + TV)
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
workflow_dispatch:
pull_request:
branches: [develop, master]
push:
branches: [develop, master]
jobs:
build-android:
if: (!contains(github.event.head_commit.message, '[skip ci]'))
runs-on: ubuntu-24.04
name: 🏗️ Build Android APK
permissions:
contents: read
strategy:
fail-fast: false
matrix:
target: [phone, tv]
steps:
- name: 📥 Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
submodules: recursive
show-progress: false
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
with:
bun-version: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-bun-develop
${{ runner.os }}-bun-develop
- name: 📦 Install dependencies and reload submodules
run: |
bun install --frozen-lockfile
bun run submodule-reload
- name: 💾 Cache Gradle global
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: ${{ runner.os }}-gradle-develop
- name: 🛠️ Generate project files
run: |
if [ "${{ matrix.target }}" = "tv" ]; then
bun run prebuild:tv
else
bun run prebuild
fi
- name: 💾 Cache project Gradle (.gradle)
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: android/.gradle
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: ${{ runner.os }}-android-gradle-develop
- name: 🚀 Build APK
env:
EXPO_TV: ${{ matrix.target == 'tv' && 1 || 0 }}
run: bun run build:android:local
- name: 📅 Set date tag
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
- name: 📤 Upload APK artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: streamyfin-android-${{ matrix.target }}-apk-${{ env.DATE_TAG }}
path: |
android/app/build/outputs/apk/release/*.apk
retention-days: 7

View File

@@ -1,290 +0,0 @@
name: 🏗️ Build Apps
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
workflow_dispatch:
pull_request:
branches: [develop, master]
push:
branches: [develop, master]
jobs:
build-android-phone:
if: (!contains(github.event.head_commit.message, '[skip ci]'))
runs-on: ubuntu-24.04
name: 🤖 Build Android APK (Phone)
permissions:
contents: read
steps:
- name: 📥 Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
submodules: recursive
show-progress: false
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
with:
bun-version: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-bun-develop
${{ runner.os }}-bun-develop
- name: 📦 Install dependencies and reload submodules
run: |
bun install --frozen-lockfile
bun run submodule-reload
- name: 💾 Cache Gradle global
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: 🛠️ Generate project files
run: bun run prebuild
- name: 💾 Cache project Gradle (.gradle)
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: android/.gradle
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: ${{ runner.os }}-android-gradle-develop
- name: 🚀 Build APK
env:
EXPO_TV: 0
run: bun run build:android:local
- name: 📅 Set date tag
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
- name: 📤 Upload APK artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: streamyfin-android-phone-apk-${{ env.DATE_TAG }}
path: |
android/app/build/outputs/apk/release/*.apk
retention-days: 7
build-android-tv:
if: (!contains(github.event.head_commit.message, '[skip ci]'))
runs-on: ubuntu-24.04
name: 🤖 Build Android APK (TV)
permissions:
contents: read
steps:
- name: 📥 Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
submodules: recursive
show-progress: false
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
with:
bun-version: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-bun-develop
${{ runner.os }}-bun-develop
- name: 📦 Install dependencies and reload submodules
run: |
bun install --frozen-lockfile
bun run submodule-reload
- name: 💾 Cache Gradle global
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: 🛠️ Generate project files
run: bun run prebuild:tv
- name: 💾 Cache project Gradle (.gradle)
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: android/.gradle
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: ${{ runner.os }}-android-gradle-develop
- name: 🚀 Build APK
env:
EXPO_TV: 1
run: bun run build:android:local
- name: 📅 Set date tag
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
- name: 📤 Upload APK artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: streamyfin-android-tv-apk-${{ env.DATE_TAG }}
path: |
android/app/build/outputs/apk/release/*.apk
retention-days: 7
build-ios-phone:
if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
runs-on: macos-26
name: 🍎 Build iOS IPA (Phone)
permissions:
contents: read
steps:
- name: 📥 Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
submodules: recursive
show-progress: false
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
with:
bun-version: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-cache
- name: 📦 Install dependencies and reload submodules
run: |
bun install --frozen-lockfile
bun run submodule-reload
- name: 🛠️ Generate project files
run: bun run prebuild
- name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1
with:
xcode-version: "26.0.1"
- name: 🏗️ Setup EAS
uses: expo/expo-github-action@main
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
eas-cache: true
- name: ⚙️ Ensure iOS SDKs installed
run: xcodebuild -downloadPlatform iOS
- name: 🚀 Build iOS app
env:
EXPO_TV: 0
run: eas build -p ios --local --non-interactive
- name: 📅 Set date tag
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
- name: 📤 Upload IPA artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: streamyfin-ios-phone-ipa-${{ env.DATE_TAG }}
path: build-*.ipa
retention-days: 7
# Disabled for now - uncomment when ready to build iOS TV
# build-ios-tv:
# if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
# runs-on: macos-26
# name: 🍎 Build iOS IPA (TV)
# permissions:
# contents: read
#
# steps:
# - name: 📥 Checkout code
# uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
# with:
# ref: ${{ github.event.pull_request.head.sha || github.sha }}
# fetch-depth: 0
# submodules: recursive
# show-progress: false
#
# - name: 🍞 Setup Bun
# uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
# with:
# bun-version: latest
#
# - name: 💾 Cache Bun dependencies
# uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
# with:
# path: ~/.bun/install/cache
# key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
# restore-keys: |
# ${{ runner.os }}-bun-cache
#
# - name: 📦 Install dependencies and reload submodules
# run: |
# bun install --frozen-lockfile
# bun run submodule-reload
#
# - name: 🛠️ Generate project files
# run: bun run prebuild:tv
#
# - name: 🔧 Setup Xcode
# uses: maxim-lobanov/setup-xcode@v1
# with:
# xcode-version: '26.0.1'
#
# - name: 🏗️ Setup EAS
# uses: expo/expo-github-action@main
# with:
# eas-version: latest
# token: ${{ secrets.EXPO_TOKEN }}
# eas-cache: true
#
# - name: ⚙️ Ensure tvOS SDKs installed
# run: xcodebuild -downloadPlatform tvOS
#
# - name: 🚀 Build iOS app
# env:
# EXPO_TV: 1
# run: eas build -p ios --local --non-interactive
#
# - name: 📅 Set date tag
# run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
#
# - name: 📤 Upload IPA artifact
# uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
# with:
# name: streamyfin-ios-tv-ipa-${{ env.DATE_TAG }}
# path: build-*.ipa
# retention-days: 7

95
.github/workflows/build-ios.yml vendored Normal file
View File

@@ -0,0 +1,95 @@
name: 🤖 iOS IPA Build (Phone + TV)
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
workflow_dispatch:
pull_request:
branches: [develop, master]
paths-ignore:
- '*.md'
push:
branches: [develop, master]
paths-ignore:
- '*.md'
jobs:
build-ios:
if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
runs-on: macos-15
name: 🏗️ Build iOS IPA
permissions:
contents: read
strategy:
fail-fast: false
matrix:
target: [phone]
# target: [phone, tv]
steps:
- name: 📥 Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
submodules: recursive
show-progress: false
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
with:
bun-version: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-cache
- name: 📦 Install dependencies and reload submodules
run: |
bun install --frozen-lockfile
bun run submodule-reload
- name: 🛠️ Generate project files
run: |
if [ "${{ matrix.target }}" = "tv" ]; then
bun run prebuild:tv
else
bun run prebuild
fi
- name: 🏗️ Setup EAS
uses: expo/expo-github-action@main
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
eas-cache: true
- name: ⚙️ Ensure iOS/tvOS SDKs installed
run: |
if [ "${{ matrix.target }}" = "tv" ]; then
xcodebuild -downloadPlatform tvOS
else
xcodebuild -downloadPlatform iOS
fi
- name: 🚀 Build iOS app
env:
EXPO_TV: ${{ matrix.target == 'tv' && 1 || 0 }}
run: eas build -p ios --local --non-interactive
- name: 📅 Set date tag
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
- name: 📤 Upload IPA artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: streamyfin-ios-${{ matrix.target }}-ipa-${{ env.DATE_TAG }}
path: build-*.ipa
retention-days: 7

View File

@@ -19,7 +19,7 @@ jobs:
steps:
- name: 📥 Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
show-progress: false
@@ -27,12 +27,12 @@ jobs:
fetch-depth: 0
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
with:
bun-version: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: |
~/.bun/install/cache

View File

@@ -24,16 +24,20 @@ jobs:
steps:
- name: 📥 Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
show-progress: false
fetch-depth: 0
- name: 🏁 Initialize CodeQL
uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
uses: github/codeql-action/init@2d92b76c45b91eb80fc44c74ce3fce0ee94e8f9d # v3.30.0
with:
languages: ${{ matrix.language }}
queries: +security-extended,security-and-quality
- name: 🛠️ Autobuild
uses: github/codeql-action/autobuild@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
uses: github/codeql-action/autobuild@2d92b76c45b91eb80fc44c74ce3fce0ee94e8f9d # v3.30.0
- name: 🧪 Perform CodeQL Analysis
uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
uses: github/codeql-action/analyze@2d92b76c45b91eb80fc44c74ce3fce0ee94e8f9d # v3.30.0

View File

@@ -1,51 +0,0 @@
name: 🌐 Translation Sync
on:
push:
branches: [develop]
paths:
- "translations/**"
- "crowdin.yml"
- "i18n.ts"
- ".github/workflows/crowdin.yml"
# Run weekly to pull new translations
schedule:
- cron: "0 2 * * 1" # Every Monday at 2 AM UTC
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
sync-translations:
runs-on: ubuntu-latest
steps:
- name: 📥 Checkout Repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
fetch-depth: 0
- name: 🌐 Sync Translations with Crowdin
uses: crowdin/github-action@60debf382ee245b21794321190ad0501db89d8c1 # v2.13.0
with:
upload_sources: true
upload_translations: true
download_translations: true
localization_branch_name: I10n_crowdin_translations
create_pull_request: true
pull_request_title: "feat: New Crowdin Translations"
pull_request_body: "New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)"
pull_request_base_branch_name: "develop"
pull_request_labels: "🌐 translation"
# Quality control options
skip_untranslated_strings: false
skip_untranslated_files: false
export_only_approved: false
# Commit customization
commit_message: "feat(i18n): update translations from Crowdin"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}

View File

@@ -51,13 +51,13 @@ jobs:
contents: read
steps:
- name: Checkout Repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
- name: Dependency Review
uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2
uses: actions/dependency-review-action@595b5aeba73380359d98a5e087f648dbb0edce1b # v4.7.3
with:
fail-on-severity: high
base-ref: ${{ github.event.pull_request.base.sha || 'develop' }}
@@ -65,18 +65,17 @@ jobs:
expo-doctor:
name: 🚑 Expo Doctor Check
if: false
runs-on: ubuntu-24.04
steps:
- name: 🛒 Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
submodules: recursive
fetch-depth: 0
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
with:
bun-version: latest
@@ -100,19 +99,19 @@ jobs:
steps:
- name: "📥 Checkout PR code"
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
submodules: recursive
fetch-depth: 0
- name: "🟢 Setup Node.js"
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: '24.x'
- name: "🍞 Setup Bun"
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
with:
bun-version: latest

View File

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

49
.github/workflows/stale.yml vendored Normal file
View File

@@ -0,0 +1,49 @@
name: 🕒 Handle Stale Issues
on:
schedule:
# Runs daily at 1:30 AM UTC (3:30 AM CEST - France time)
- cron: "30 1 * * *"
jobs:
stale-issues:
name: 🗑️ Cleanup Stale Issues
runs-on: ubuntu-24.04
permissions:
issues: write
pull-requests: write
steps:
- name: 🔄 Mark/Close Stale Issues
uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0
with:
# Global settings
repo-token: ${{ secrets.GITHUB_TOKEN }}
operations-per-run: 500 # Increase if you have >1000 issues
enable-statistics: true
# Issue configuration
days-before-issue-stale: 90
days-before-issue-close: 7
stale-issue-label: "🕰️ stale"
exempt-issue-labels: "Roadmap v1,help needed,enhancement"
# Notifications messages
stale-issue-message: |
⏳ This issue has been automatically marked as **stale** because it has had no activity for 90 days.
**Next steps:**
- If this is still relevant, add a comment to keep it open
- Otherwise, it will be closed in 7 days
Thank you for your contributions! 🙌
close-issue-message: |
🚮 This issue has been automatically closed due to inactivity (7 days since being marked stale).
**Need to reopen?**
Click "Reopen" and add a comment explaining why this should stay open.
# Disable PR handling
days-before-pr-stale: -1
days-before-pr-close: -1

View File

@@ -18,10 +18,10 @@ jobs:
steps:
- name: 📥 Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: "🟢 Setup Node.js"
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: '24.x'
cache: 'npm'
@@ -54,7 +54,7 @@ jobs:
dry_run: no-push
- name: 📬 Commit and create pull request
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e #v7.0.8
with:
add-paths: .github/ISSUE_TEMPLATE/bug_report.yml
branch: ci-update-bug-report

75
.gitignore vendored
View File

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

View File

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

35
.vscode/settings.json vendored
View File

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

121
CLAUDE.md
View File

@@ -1,121 +0,0 @@
# CLAUDE.md
@.claude/learned-facts.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native). It supports mobile (iOS/Android) and TV platforms, with features including offline downloads, Chromecast support, and Jellyseerr integration.
## Development Commands
**CRITICAL: Always use `bun` for package management. Never use `npm`, `yarn`, or `npx`.**
```bash
# Setup
bun i && bun run submodule-reload
# Development builds
bun run prebuild # Mobile prebuild
bun run ios # Run iOS
bun run android # Run Android
# TV builds (suffix with :tv)
bun run prebuild:tv
bun run ios:tv
bun run android:tv
# Code quality
bun run typecheck # TypeScript check
bun run check # BiomeJS check
bun run lint # BiomeJS lint + fix
bun run format # BiomeJS format
bun run test # Run all checks (typecheck, lint, format, doctor)
# iOS-specific
bun run ios:install-metal-toolchain # Fix "missing Metal Toolchain" build errors
```
## Tech Stack
- **Runtime**: Bun
- **Framework**: React Native (Expo SDK 54)
- **Language**: TypeScript (strict mode)
- **State Management**: Jotai (global state atoms) + React Query (server state)
- **API**: Jellyfin SDK (`@jellyfin/sdk`)
- **Navigation**: Expo Router (file-based)
- **Linting/Formatting**: BiomeJS
- **Storage**: react-native-mmkv
## Architecture
### File Structure
- `app/` - Expo Router screens with file-based routing
- `components/` - Reusable UI components
- `providers/` - React Context providers
- `hooks/` - Custom React hooks
- `utils/` - Utilities including Jotai atoms
- `modules/` - Native modules (vlc-player, mpv-player, background-downloader)
- `translations/` - i18n translation files
### Key Patterns
**State Management**:
- Global state uses Jotai atoms in `utils/atoms/`
- `settingsAtom` in `utils/atoms/settings.ts` for app settings
- `apiAtom` and `userAtom` in `providers/JellyfinProvider.tsx` for auth state
- Server state uses React Query with `@tanstack/react-query`
**Jellyfin API Access**:
- Use `apiAtom` from `JellyfinProvider` for authenticated API calls
- Access user via `userAtom`
- Use Jellyfin SDK utilities from `@jellyfin/sdk/lib/utils/api`
**Navigation**:
- File-based routing in `app/` directory
- Tab navigation: `(home)`, `(search)`, `(favorites)`, `(libraries)`, `(watchlists)`
- Shared routes use parenthesized groups like `(home,libraries,search,favorites,watchlists)`
**Providers** (wrapping order in `app/_layout.tsx`):
1. JotaiProvider
2. QueryClientProvider
3. JellyfinProvider (auth, API)
4. NetworkStatusProvider
5. PlaySettingsProvider
6. WebSocketProvider
7. DownloadProvider
8. MusicPlayerProvider
### Native Modules
Located in `modules/`:
- `vlc-player` - VLC video player integration
- `mpv-player` - MPV video player integration (iOS)
- `background-downloader` - Background download functionality
- `sf-player` - Swift player module
### Path Aliases
Use `@/` prefix for imports (configured in `tsconfig.json`):
```typescript
import { useSettings } from "@/utils/atoms/settings";
import { apiAtom } from "@/providers/JellyfinProvider";
```
## Coding Standards
- Use TypeScript for all files (no .js)
- Use functional React components with hooks
- Use Jotai atoms for global state, React Query for server state
- Follow BiomeJS formatting rules (2-space indent, semicolons, LF line endings)
- Handle both mobile and TV navigation patterns
- Use existing atoms, hooks, and utilities before creating new ones
- Use Conventional Commits: `feat(scope):`, `fix(scope):`, `chore(scope):`
## Platform Considerations
- TV version uses `:tv` suffix for scripts
- Platform checks: `Platform.isTV`, `Platform.OS === "android"` or `"ios"`
- Some features disabled on TV (e.g., notifications, Chromecast)

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

169
README.md
View File

@@ -5,142 +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.
### 🎬 MPV Player
Streamyfin uses [MPV](https://mpv.io/) as its primary video player on all platforms, powered by [MPVKit](https://github.com/mpvkit/MPVKit). MPV is a powerful, open-source media player known for its wide format support and high-quality playback.
Thanks to [@Alexk2309](https://github.com/Alexk2309) for the hard work building the native MPV module in Streamyfin.
### 🔍 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://apps.obtainium.imranr.dev/redirect.html?r=obtainium://add/https://github.com/streamyfin/streamyfin"><img height=50 alt="Add Streamyfin to Obtainium" src="./assets/Download_with_Obtainium.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/)
- If iOS builds fail with `missing Metal Toolchain` (KSPlayer shaders), run `npm run ios:install-metal-toolchain` once
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:
@@ -225,42 +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:
- [@Alexk2309](https://github.com/Alexk2309) for building the native MPV module that integrates [MPVKit](https://github.com/mpvkit/MPVKit) with React Native
- [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

@@ -6,16 +6,14 @@ module.exports = ({ config }) => {
"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.52.0",
"version": "0.35.1",
"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": 92,
"versionCode": 67,
"adaptiveIcon": {
"foregroundImage": "./assets/images/icon-android-plain.png",
"monochromeImage": "./assets/images/icon-android-themed.png",
@@ -46,14 +49,25 @@
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
"android.permission.WRITE_SETTINGS"
],
"blockedPermissions": ["android.permission.ACTIVITY_RECOGNITION"],
"googleServicesFile": "./google-services.json"
},
"plugins": [
"@react-native-tvos/config-tv",
"expo-router",
"expo-font",
"./plugins/withExcludeMedia3Dash.js",
[
"react-native-video",
{
"enableNotificationControls": true,
"enableBackgroundAudio": true,
"androidExtensions": {
"useExoplayerRtsp": false,
"useExoplayerSmoothStreaming": false,
"useExoplayerHls": true,
"useExoplayerDash": false
}
}
],
[
"expo-build-properties",
{
@@ -62,12 +76,11 @@
"useFrameworks": "static"
},
"android": {
"buildArchs": ["arm64-v8a", "x86_64"],
"compileSdkVersion": 36,
"compileSdkVersion": 35,
"targetSdkVersion": 35,
"buildToolsVersion": "35.0.0",
"kotlinVersion": "2.0.21",
"minSdkVersion": 26,
"minSdkVersion": 24,
"usesCleartextTraffic": true,
"packagingOptions": {
"jniLibs": {
@@ -85,6 +98,12 @@
"initialOrientation": "DEFAULT"
}
],
[
"expo-sensors",
{
"motionPermission": "Allow Streamyfin to access your device motion for landscape video watching."
}
],
"expo-localization",
"expo-asset",
[
@@ -95,10 +114,14 @@
}
}
],
["./plugins/withChangeNativeAndroidTextToWhite.js"],
["./plugins/withAndroidManifest.js"],
["./plugins/withTrustLocalCerts.js"],
["./plugins/withGradleProperties.js"],
[
"expo-splash-screen",
{
"backgroundColor": "#010101",
"backgroundColor": "#2e2e2e",
"image": "./assets/images/icon-ios-plain.png",
"imageWidth": 100
}
@@ -110,19 +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/withGitPod.js",
{
"podName": "MPVKit-GPL",
"podspecUrl": "https://raw.githubusercontent.com/streamyfin/MPVKit/0.40.0-av/MPVKit-GPL.podspec"
}
]
"./plugins/with-runtime-framework-headers.js",
"react-native-bottom-tabs"
],
"experiments": {
"typedRoutes": true
@@ -141,6 +153,7 @@
},
"updates": {
"url": "https://u.expo.dev/e79219d1-797f-4fbe-9fa1-cfd360690a68"
}
},
"newArchEnabled": false
}
}

View File

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

View File

@@ -29,7 +29,7 @@ export default function menuLinks() {
);
const config = response?.data;
if (!config || !Object.hasOwn(config, "menuLinks")) {
if (!config && !Object.hasOwn(config, "menuLinks")) {
console.error("Menu links not found");
return;
}

View File

@@ -11,8 +11,12 @@ export default function SearchLayout() {
name='index'
options={{
headerShown: !Platform.isTV,
headerLargeTitle: true,
headerTitle: t("tabs.favorites"),
headerBlurEffect: "none",
headerLargeStyle: {
backgroundColor: "black",
},
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
}}

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

@@ -1,212 +0,0 @@
import type { Api } from "@jellyfin/sdk";
import type {
BaseItemDto,
BaseItemKind,
} from "@jellyfin/sdk/lib/generated-client";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list";
import { useInfiniteQuery } from "@tanstack/react-query";
import { Stack, useLocalSearchParams } from "expo-router";
import { t } from "i18next";
import { useAtom } from "jotai";
import { useCallback, useMemo } from "react";
import { useWindowDimensions, 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 { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader";
import { ItemPoster } from "@/components/posters/ItemPoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
type FavoriteTypes =
| "Series"
| "Movie"
| "Episode"
| "Video"
| "BoxSet"
| "Playlist";
const favoriteTypes: readonly FavoriteTypes[] = [
"Series",
"Movie",
"Episode",
"Video",
"BoxSet",
"Playlist",
] as const;
function isFavoriteType(value: unknown): value is FavoriteTypes {
return (
typeof value === "string" &&
(favoriteTypes as readonly string[]).includes(value)
);
}
export default function FavoritesSeeAllScreen() {
const insets = useSafeAreaInsets();
const { width: screenWidth } = useWindowDimensions();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const searchParams = useLocalSearchParams<{
type?: string;
title?: string;
}>();
const typeParam = searchParams.type;
const titleParam = searchParams.title;
const itemType = useMemo(() => {
if (!isFavoriteType(typeParam)) return null;
return typeParam as BaseItemKind;
}, [typeParam]);
const headerTitle = useMemo(() => {
if (typeof titleParam === "string" && titleParam.trim().length > 0)
return titleParam;
return "";
}, [titleParam]);
const pageSize = 50;
const fetchItems = useCallback(
async ({ pageParam }: { pageParam: number }): Promise<BaseItemDto[]> => {
if (!api || !user?.Id || !itemType) return [];
const response = await getItemsApi(api as Api).getItems({
userId: user.Id,
sortBy: ["SeriesSortName", "SortName"],
sortOrder: ["Ascending"],
filters: ["IsFavorite"],
recursive: true,
fields: ["PrimaryImageAspectRatio"],
collapseBoxSetItems: false,
excludeLocationTypes: ["Virtual"],
enableTotalRecordCount: true,
startIndex: pageParam,
limit: pageSize,
includeItemTypes: [itemType],
});
return response.data.Items || [];
},
[api, itemType, user?.Id],
);
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
useInfiniteQuery({
queryKey: ["favorites", "see-all", itemType],
queryFn: ({ pageParam = 0 }) => fetchItems({ pageParam }),
getNextPageParam: (lastPage, pages) => {
if (!lastPage || lastPage.length < pageSize) return undefined;
return pages.reduce((acc, page) => acc + page.length, 0);
},
initialPageParam: 0,
enabled: !!api && !!user?.Id && !!itemType,
});
const flatData = useMemo(() => data?.pages.flat() ?? [], [data]);
const nrOfCols = useMemo(() => {
if (screenWidth < 350) return 2;
if (screenWidth < 600) return 3;
if (screenWidth < 900) return 5;
return 6;
}, [screenWidth]);
const renderItem = useCallback(
({ item, index }: { item: BaseItemDto; index: number }) => (
<TouchableItemRouter
item={item}
style={{
width: "100%",
}}
>
<View
style={{
alignSelf:
index % nrOfCols === 0
? "flex-end"
: (index + 1) % nrOfCols === 0
? "flex-start"
: "center",
width: "89%",
}}
>
<ItemPoster item={item} />
<ItemCardText item={item} />
</View>
</TouchableItemRouter>
),
[nrOfCols],
);
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
const handleEndReached = useCallback(() => {
if (hasNextPage) {
fetchNextPage();
}
}, [fetchNextPage, hasNextPage]);
return (
<>
<Stack.Screen
options={{
headerTitle: headerTitle,
headerBlurEffect: "none",
headerTransparent: true,
headerShadowVisible: false,
}}
/>
{!itemType ? (
<View className='flex-1 items-center justify-center px-6'>
<Text className='text-neutral-500'>
{t("favorites.noData", { defaultValue: "No items found." })}
</Text>
</View>
) : isLoading ? (
<View className='justify-center items-center h-full'>
<Loader />
</View>
) : (
<FlashList
data={flatData}
renderItem={renderItem}
keyExtractor={keyExtractor}
numColumns={nrOfCols}
onEndReached={handleEndReached}
onEndReachedThreshold={0.8}
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingBottom: 24,
paddingLeft: insets.left,
paddingRight: insets.right,
}}
ItemSeparatorComponent={() => (
<View
style={{
width: 10,
height: 10,
}}
/>
)}
ListEmptyComponent={
<View className='flex flex-col items-center justify-center h-full py-12'>
<Text className='font-bold text-xl text-neutral-500'>
{t("home.no_items", { defaultValue: "No items" })}
</Text>
</View>
}
ListFooterComponent={
isFetching ? (
<View style={{ paddingVertical: 16 }}>
<Loader />
</View>
) : null
}
/>
)}
</>
);
}

View File

@@ -1,8 +1,7 @@
import { Feather, Ionicons } from "@expo/vector-icons";
import { Stack, useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native";
import { Pressable } from "react-native-gesture-handler";
import { Platform, TouchableOpacity, View } from "react-native";
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
@@ -22,15 +21,19 @@ export default function IndexLayout() {
name='index'
options={{
headerShown: !Platform.isTV,
headerLargeTitle: true,
headerTitle: t("tabs.home"),
headerBlurEffect: "none",
headerBlurEffect: "prominent",
headerLargeStyle: {
backgroundColor: "black",
},
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerRight: () => (
<View className='flex flex-row items-center px-2'>
<View className='flex flex-row items-center space-x-2'>
{!Platform.isTV && (
<>
<Chromecast.Chromecast background='transparent' />
<Chromecast.Chromecast />
{user?.Policy?.IsAdministrator && <SessionsButton />}
<SettingsButton />
</>
@@ -42,291 +45,57 @@ 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: () => (
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}}
/>
<Stack.Screen
name='downloads/[seriesId]'
options={{
headerShown: true,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
title: t("home.downloads.tvseries"),
headerLeft: () => (
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}}
/>
<Stack.Screen
name='sessions/index'
options={{
title: t("home.sessions.title"),
headerShown: true,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}}
/>
<Stack.Screen
name='settings'
options={{
title: t("home.settings.settings_title"),
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}}
/>
<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: () => (
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
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: () => (
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
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: () => (
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}}
/>
<Stack.Screen
name='settings/music/page'
options={{
title: t("home.settings.music.title"),
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}}
/>
<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: () => (
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}}
/>
<Stack.Screen
name='settings/plugins/page'
options={{
title: t("home.settings.plugins.plugins_title"),
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}}
/>
<Stack.Screen
name='settings/plugins/marlin-search/page'
options={{
title: "Marlin Search",
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}}
/>
<Stack.Screen
name='settings/plugins/jellyseerr/page'
options={{
title: "Jellyseerr",
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}}
/>
<Stack.Screen
name='settings/plugins/streamystats/page'
options={{
title: "Streamystats",
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}}
/>
<Stack.Screen
name='settings/plugins/kefinTweaks/page'
options={{
title: "KefinTweaks",
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}}
/>
<Stack.Screen
name='settings/intro/page'
options={{
title: t("home.settings.intro.title"),
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
title: "",
}}
/>
<Stack.Screen
name='settings/logs/page'
options={{
title: t("home.settings.logs.logs_title"),
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
title: "",
}}
/>
<Stack.Screen
name='intro/page'
options={{
headerShown: false,
title: "",
presentation: "modal",
}}
/>
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
@@ -336,11 +105,6 @@ export default function IndexLayout() {
name='collections/[collectionId]'
options={{
title: "",
headerLeft: () => (
<Pressable onPress={() => _router.back()} className='pl-0.5'>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
headerShown: true,
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios",
@@ -355,13 +119,13 @@ const SettingsButton = () => {
const router = useRouter();
return (
<Pressable
<TouchableOpacity
onPress={() => {
router.push("/(auth)/settings");
}}
>
<Feather name='settings' color={"white"} size={22} />
</Pressable>
</TouchableOpacity>
);
};
@@ -370,17 +134,18 @@ const SessionsButton = () => {
const { sessions = [] } = useSessions({} as useSessionsProps);
return (
<Pressable
<TouchableOpacity
onPress={() => {
router.push("/(auth)/sessions");
}}
className='mr-4'
>
<Ionicons
name='play-circle'
color={sessions.length === 0 ? "white" : "#9333ea"}
size={28}
/>
</Pressable>
<View className='mr-4'>
<Ionicons
name='play-circle'
color={sessions.length === 0 ? "white" : "#9333ea"}
size={25}
/>
</View>
</TouchableOpacity>
);
};

View File

@@ -1,10 +1,8 @@
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { FlashList } from "@shopify/flash-list";
import { router, useLocalSearchParams, useNavigation } from "expo-router";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Alert, Platform, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
import { Text } from "@/components/common/Text";
import { EpisodeCard } from "@/components/downloads/EpisodeCard";
import {
@@ -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,12 +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, View } from "react-native";
import { Pressable } from "react-native-gesture-handler";
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";
@@ -21,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"),
@@ -54,7 +62,7 @@ export default function page() {
);
};
const downloadedFiles = useMemo(() => downloadedItems, [downloadedItems]);
const downloadedFiles = getDownloadedItems();
const movies = useMemo(() => {
try {
@@ -82,28 +90,12 @@ export default function page() {
}
}, [downloadedFiles]);
const otherMedia = useMemo(() => {
try {
return (
downloadedFiles?.filter(
(f) => f.item.Type !== "Movie" && f.item.Type !== "Episode",
) || []
);
} catch {
setShowMigration(true);
return [];
}
}, [downloadedFiles]);
useEffect(() => {
navigation.setOptions({
headerRight: () => (
<Pressable
onPress={bottomSheetModalRef.current?.present}
className='px-2'
>
<TouchableOpacity onPress={bottomSheetModalRef.current?.present}>
<DownloadSize items={downloadedFiles?.map((f) => f.item) || []} />
</Pressable>
</TouchableOpacity>
),
});
}, [downloadedFiles]);
@@ -114,7 +106,7 @@ export default function page() {
}
}, [showMigration]);
const _deleteMovies = () =>
const deleteMovies = () =>
deleteFileByType("Movie")
.then(() =>
toast.success(
@@ -125,7 +117,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(
@@ -136,39 +128,16 @@ export default function page() {
writeToLog("ERROR", reason);
toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
});
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,
}),
);
}),
),
);
const deleteAllMedia = async () =>
await Promise.all([deleteMovies(), deleteShows()]);
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>
@@ -210,96 +179,106 @@ 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>
)}
{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>
<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

@@ -0,0 +1,154 @@
import { Feather, Ionicons } from "@expo/vector-icons";
import { Image } from "expo-image";
import { useFocusEffect, useRouter } from "expo-router";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Linking, Platform, TouchableOpacity, View } from "react-native";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { storage } from "@/utils/mmkv";
export default function page() {
const router = useRouter();
const { t } = useTranslation();
useFocusEffect(
useCallback(() => {
storage.set("hasShownIntro", true);
}, []),
);
return (
<View
className={`bg-neutral-900 h-full ${Platform.isTV ? "py-5 space-y-4" : "py-16 space-y-8"} px-4`}
>
<View>
<Text className='text-3xl font-bold text-center mb-2'>
{t("home.intro.welcome_to_streamyfin")}
</Text>
<Text className='text-center'>
{t("home.intro.a_free_and_open_source_client_for_jellyfin")}
</Text>
</View>
<View>
<Text className='text-lg font-bold'>
{t("home.intro.features_title")}
</Text>
<Text className='text-xs'>{t("home.intro.features_description")}</Text>
<View className='flex flex-row items-center mt-4'>
<Image
source={require("@/assets/icons/jellyseerr-logo.svg")}
style={{
width: 50,
height: 50,
}}
/>
<View className='shrink ml-2'>
<Text className='font-bold mb-1'>Jellyseerr</Text>
<Text className='shrink text-xs'>
{t("home.intro.jellyseerr_feature_description")}
</Text>
</View>
</View>
{!Platform.isTV && (
<>
<View className='flex flex-row items-center mt-4'>
<View
style={{
width: 50,
height: 50,
}}
className='flex items-center justify-center'
>
<Ionicons
name='cloud-download-outline'
size={32}
color='white'
/>
</View>
<View className='shrink ml-2'>
<Text className='font-bold mb-1'>
{t("home.intro.downloads_feature_title")}
</Text>
<Text className='shrink text-xs'>
{t("home.intro.downloads_feature_description")}
</Text>
</View>
</View>
<View className='flex flex-row items-center mt-4'>
<View
style={{
width: 50,
height: 50,
}}
className='flex items-center justify-center'
>
<Feather name='cast' size={28} color={"white"} />
</View>
<View className='shrink ml-2'>
<Text className='font-bold mb-1'>Chromecast</Text>
<Text className='shrink text-xs'>
{t("home.intro.chromecast_feature_description")}
</Text>
</View>
</View>
</>
)}
<View className='flex flex-row items-center mt-4'>
<View
style={{
width: 50,
height: 50,
}}
className='flex items-center justify-center'
>
<Feather name='settings' size={28} color={"white"} />
</View>
<View className='shrink ml-2'>
<Text className='font-bold mb-1'>
{t("home.intro.centralised_settings_plugin_title")}
</Text>
<View className='flex-row flex-wrap items-baseline'>
<Text className='shrink text-xs'>
{t("home.intro.centralised_settings_plugin_description")}{" "}
</Text>
<TouchableOpacity
onPress={() => {
Linking.openURL(
"https://github.com/streamyfin/jellyfin-plugin-streamyfin",
);
}}
>
<Text className='text-xs text-purple-600 underline'>
{t("home.intro.read_more")}
</Text>
</TouchableOpacity>
</View>
</View>
</View>
</View>
<View>
<Button
onPress={() => {
router.back();
}}
className='mt-4'
>
{t("home.intro.done_button")}
</Button>
<TouchableOpacity
onPress={() => {
router.back();
router.push("/settings");
}}
className='mt-4'
>
<Text className='text-purple-600 text-center'>
{t("home.intro.go_to_settings_button")}
</Text>
</TouchableOpacity>
</View>
</View>
);
}

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}
/>
);
}
@@ -465,7 +468,6 @@ const TranscodingStreamView = ({
};
const TranscodingView = ({ session }: SessionCardProps) => {
const { t } = useTranslation();
const videoStream = useMemo(() => {
return session.NowPlayingItem?.MediaStreams?.filter(
(s) => s.Type === "Video",
@@ -499,7 +501,7 @@ const TranscodingView = ({ session }: SessionCardProps) => {
return (
<View className='flex flex-col bg-neutral-800 rounded-b-2xl p-4 pt-2'>
<TranscodingStreamView
title={t("common.video")}
title='Video'
properties={{
resolution: videoStreamTitle(),
bitrate: videoStream?.BitRate,
@@ -516,7 +518,7 @@ const TranscodingView = ({ session }: SessionCardProps) => {
/>
<TranscodingStreamView
title={t("common.audio")}
title='Audio'
properties={{
language: audioStream?.Language,
bitrate: audioStream?.BitRate,
@@ -534,7 +536,7 @@ const TranscodingView = ({ session }: SessionCardProps) => {
{subtitleStream && (
<TranscodingStreamView
title={t("common.subtitle")}
title='Subtitle'
isTranscoding={false}
properties={{
language: subtitleStream?.Language,

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,63 +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/music/page")}
showArrow
title={t("home.settings.music.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,31 +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 { MpvSubtitleSettings } from "@/components/settings/MpvSubtitleSettings";
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' />
<MpvSubtitleSettings className='mb-4' />
</MediaProvider>
</View>
</ScrollView>
);
}

View File

@@ -1,45 +0,0 @@
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 { useIntroSheet } from "@/providers/IntroSheetProvider";
import { storage } from "@/utils/mmkv";
export default function IntroPage() {
const { showIntro } = useIntroSheet();
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={() => {
showIntro();
}}
title={t("home.settings.intro.show_intro")}
/>
<ListItem
textColor='red'
onPress={() => {
storage.set("hasShownIntro", false);
}}
title={t("home.settings.intro.reset_intro")}
/>
</ListGroup>
<View className='h-24' />
</View>
</ScrollView>
);
}

View File

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

View File

@@ -1,21 +1,15 @@
import { File, Paths } from "expo-file-system";
import * as FileSystem from "expo-file-system";
import { useNavigation } from "expo-router";
import type * as SharingType from "expo-sharing";
import * as Sharing from "expo-sharing";
import { useCallback, useEffect, useId, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
import { ScrollView, TouchableOpacity, View } from "react-native";
import Collapsible from "react-native-collapsible";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { FilterButton } from "@/components/filters/FilterButton";
import { Loader } from "@/components/Loader";
import { LogLevel, useLog, writeErrorLog } from "@/utils/log";
// Conditionally import expo-sharing only on non-TV platforms
const Sharing = Platform.isTV
? null
: (require("expo-sharing") as typeof SharingType);
export default function Page() {
const navigation = useNavigation();
const { logs } = useLog();
@@ -39,7 +33,6 @@ export default function Page() {
const _orderId = useId();
const _levelsId = useId();
const insets = useSafeAreaInsets();
const filteredLogs = useMemo(
() =>
@@ -54,30 +47,27 @@ export default function Page() {
// Sharing it as txt while its formatted allows us to share it with many more applications
const share = useCallback(async () => {
if (!Sharing) return;
const logsFile = new File(Paths.document, "logs.txt");
const uri = `${FileSystem.documentDirectory}logs.txt`;
setLoading(true);
try {
logsFile.write(JSON.stringify(filteredLogs));
await Sharing.shareAsync(logsFile.uri, { mimeType: "txt", UTI: "txt" });
} catch (e: any) {
writeErrorLog("Something went wrong attempting to export", e);
} finally {
setLoading(false);
}
}, [filteredLogs, Sharing]);
FileSystem.writeAsStringAsync(uri, JSON.stringify(filteredLogs))
.then(() => {
setLoading(false);
Sharing.shareAsync(uri, { mimeType: "txt", UTI: "txt" });
})
.catch((e) =>
writeErrorLog("Something went wrong attempting to export", e),
)
.finally(() => setLoading(false));
}, [filteredLogs]);
useEffect(() => {
if (Platform.isTV) return;
navigation.setOptions({
headerRight: () =>
loading ? (
<Loader />
) : (
<TouchableOpacity onPress={share} className='px-2'>
<TouchableOpacity onPress={share}>
<Text>{t("home.settings.logs.export_logs")}</Text>
</TouchableOpacity>
),
@@ -85,12 +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}
@@ -172,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,251 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import { useQuery } from "@tanstack/react-query";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Platform, ScrollView, View } from "react-native";
import { Switch } from "react-native-gesture-handler";
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 { PlatformDropdown } from "@/components/PlatformDropdown";
import { useHaptic } from "@/hooks/useHaptic";
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
import {
clearCache,
clearPermanentDownloads,
getStorageStats,
} from "@/providers/AudioStorage";
import { useSettings } from "@/utils/atoms/settings";
const CACHE_SIZE_OPTIONS = [
{ label: "100 MB", value: 100 },
{ label: "250 MB", value: 250 },
{ label: "500 MB", value: 500 },
{ label: "1 GB", value: 1024 },
{ label: "2 GB", value: 2048 },
];
const LOOKAHEAD_COUNT_OPTIONS = [
{ label: "1 song", value: 1 },
{ label: "2 songs", value: 2 },
{ label: "3 songs", value: 3 },
{ label: "5 songs", value: 5 },
];
export default function MusicSettingsPage() {
const insets = useSafeAreaInsets();
const { settings, updateSettings, pluginSettings } = useSettings();
const { t } = useTranslation();
const queryClient = useNetworkAwareQueryClient();
const successHapticFeedback = useHaptic("success");
const errorHapticFeedback = useHaptic("error");
const { data: musicCacheStats } = useQuery({
queryKey: ["musicCacheStats"],
queryFn: () => getStorageStats(),
});
const onClearMusicCacheClicked = useCallback(async () => {
try {
await clearCache();
queryClient.invalidateQueries({ queryKey: ["musicCacheStats"] });
queryClient.invalidateQueries({ queryKey: ["appSize"] });
successHapticFeedback();
toast.success(t("home.settings.storage.music_cache_cleared"));
} catch (_e) {
errorHapticFeedback();
toast.error(t("home.settings.toasts.error_deleting_files"));
}
}, [queryClient, successHapticFeedback, errorHapticFeedback, t]);
const onDeleteDownloadedSongsClicked = useCallback(async () => {
try {
await clearPermanentDownloads();
queryClient.invalidateQueries({ queryKey: ["musicCacheStats"] });
queryClient.invalidateQueries({ queryKey: ["appSize"] });
successHapticFeedback();
toast.success(t("home.settings.storage.downloaded_songs_deleted"));
} catch (_e) {
errorHapticFeedback();
toast.error(t("home.settings.toasts.error_deleting_files"));
}
}, [queryClient, successHapticFeedback, errorHapticFeedback, t]);
const cacheSizeOptions = useMemo(
() => [
{
options: CACHE_SIZE_OPTIONS.map((option) => ({
type: "radio" as const,
label: option.label,
value: String(option.value),
selected: option.value === settings?.audioMaxCacheSizeMB,
onPress: () => updateSettings({ audioMaxCacheSizeMB: option.value }),
})),
},
],
[settings?.audioMaxCacheSizeMB, updateSettings],
);
const currentCacheSizeLabel =
CACHE_SIZE_OPTIONS.find((o) => o.value === settings?.audioMaxCacheSizeMB)
?.label ?? `${settings?.audioMaxCacheSizeMB} MB`;
const lookaheadCountOptions = useMemo(
() => [
{
options: LOOKAHEAD_COUNT_OPTIONS.map((option) => ({
type: "radio" as const,
label: option.label,
value: String(option.value),
selected: option.value === settings?.audioLookaheadCount,
onPress: () => updateSettings({ audioLookaheadCount: option.value }),
})),
},
],
[settings?.audioLookaheadCount, updateSettings],
);
const currentLookaheadLabel =
LOOKAHEAD_COUNT_OPTIONS.find(
(o) => o.value === settings?.audioLookaheadCount,
)?.label ?? `${settings?.audioLookaheadCount} songs`;
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.music.playback_title")}
description={
<Text className='text-[#8E8D91] text-xs'>
{t("home.settings.music.playback_description")}
</Text>
}
>
<ListItem
title={t("home.settings.music.prefer_downloaded")}
disabled={pluginSettings?.preferLocalAudio?.locked}
>
<Switch
value={settings.preferLocalAudio}
disabled={pluginSettings?.preferLocalAudio?.locked}
onValueChange={(value) =>
updateSettings({ preferLocalAudio: value })
}
/>
</ListItem>
</ListGroup>
<View className='mt-4'>
<ListGroup
title={t("home.settings.music.caching_title")}
description={
<Text className='text-[#8E8D91] text-xs'>
{t("home.settings.music.caching_description")}
</Text>
}
>
<ListItem
title={t("home.settings.music.lookahead_enabled")}
disabled={pluginSettings?.audioLookaheadEnabled?.locked}
>
<Switch
value={settings.audioLookaheadEnabled}
disabled={pluginSettings?.audioLookaheadEnabled?.locked}
onValueChange={(value) =>
updateSettings({ audioLookaheadEnabled: value })
}
/>
</ListItem>
<ListItem
title={t("home.settings.music.lookahead_count")}
disabled={
pluginSettings?.audioLookaheadCount?.locked ||
!settings.audioLookaheadEnabled
}
>
<PlatformDropdown
groups={lookaheadCountOptions}
trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{currentLookaheadLabel}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
}
title={t("home.settings.music.lookahead_count")}
/>
</ListItem>
<ListItem
title={t("home.settings.music.max_cache_size")}
disabled={pluginSettings?.audioMaxCacheSizeMB?.locked}
>
<PlatformDropdown
groups={cacheSizeOptions}
trigger={
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{currentCacheSizeLabel}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</View>
}
title={t("home.settings.music.max_cache_size")}
/>
</ListItem>
</ListGroup>
</View>
{!Platform.isTV && (
<View className='mt-4'>
<ListGroup
title={t("home.settings.storage.music_cache_title")}
description={
<Text className='text-[#8E8D91] text-xs'>
{t("home.settings.storage.music_cache_description")}
</Text>
}
>
<ListItem
onPress={onClearMusicCacheClicked}
title={t("home.settings.storage.clear_music_cache")}
subtitle={t("home.settings.storage.music_cache_size", {
size: (musicCacheStats?.cacheSize ?? 0).bytesToReadable(),
})}
/>
</ListGroup>
<ListGroup>
<ListItem
textColor='red'
onPress={onDeleteDownloadedSongsClicked}
title={t("home.settings.storage.delete_all_downloaded_songs")}
subtitle={t("home.settings.storage.downloaded_songs_size", {
size: (musicCacheStats?.permanentSize ?? 0).bytesToReadable(),
})}
/>
</ListGroup>
</View>
)}
</View>
</ScrollView>
);
}

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,27 +0,0 @@
import { ScrollView } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import DisabledSetting from "@/components/settings/DisabledSetting";
import { KefinTweaksSettings } from "@/components/settings/KefinTweaks";
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?.useKefinTweaks?.locked === true}
className='px-4'
>
<KefinTweaksSettings />
</DisabledSetting>
</ScrollView>
);
}

View File

@@ -1,142 +0,0 @@
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 { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
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 = useNetworkAwareQueryClient();
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, pluginSettings?.marlinServerUrl?.locked, t]);
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 ||
!!pluginSettings?.streamyStatsServerUrl?.value
}
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"}
disabled={!!pluginSettings?.streamyStatsServerUrl?.value}
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

@@ -1,262 +0,0 @@
import { useNavigation } from "expo-router";
import { useCallback, useEffect, 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 { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
import { useSettings } from "@/utils/atoms/settings";
export default function page() {
const { t } = useTranslation();
const navigation = useNavigation();
const insets = useSafeAreaInsets();
const {
settings,
updateSettings,
pluginSettings,
refreshStreamyfinPluginSettings,
} = useSettings();
const queryClient = useNetworkAwareQueryClient();
// Local state for all editable fields
const [url, setUrl] = useState<string>(settings?.streamyStatsServerUrl || "");
const [useForSearch, setUseForSearch] = useState<boolean>(
settings?.searchEngine === "Streamystats",
);
const [movieRecs, setMovieRecs] = useState<boolean>(
settings?.streamyStatsMovieRecommendations ?? false,
);
const [seriesRecs, setSeriesRecs] = useState<boolean>(
settings?.streamyStatsSeriesRecommendations ?? false,
);
const [promotedWatchlists, setPromotedWatchlists] = useState<boolean>(
settings?.streamyStatsPromotedWatchlists ?? false,
);
const [hideWatchlistsTab, setHideWatchlistsTab] = useState<boolean>(
settings?.hideWatchlistsTab ?? false,
);
const isUrlLocked = pluginSettings?.streamyStatsServerUrl?.locked === true;
const isStreamystatsEnabled = !!url;
const onSave = useCallback(() => {
const cleanUrl = url.endsWith("/") ? url.slice(0, -1) : url;
updateSettings({
streamyStatsServerUrl: cleanUrl,
searchEngine: useForSearch ? "Streamystats" : "Jellyfin",
streamyStatsMovieRecommendations: movieRecs,
streamyStatsSeriesRecommendations: seriesRecs,
streamyStatsPromotedWatchlists: promotedWatchlists,
hideWatchlistsTab: hideWatchlistsTab,
});
queryClient.invalidateQueries({ queryKey: ["search"] });
queryClient.invalidateQueries({ queryKey: ["streamystats"] });
toast.success(t("home.settings.plugins.streamystats.toasts.saved"));
}, [
url,
useForSearch,
movieRecs,
seriesRecs,
promotedWatchlists,
hideWatchlistsTab,
updateSettings,
queryClient,
t,
]);
// Set up header save button
useEffect(() => {
navigation.setOptions({
headerRight: () => (
<TouchableOpacity onPress={onSave}>
<Text className='text-blue-500 font-medium'>
{t("home.settings.plugins.streamystats.save")}
</Text>
</TouchableOpacity>
),
});
}, [navigation, onSave, t]);
const handleClearStreamystats = useCallback(() => {
setUrl("");
setUseForSearch(false);
setMovieRecs(false);
setSeriesRecs(false);
setPromotedWatchlists(false);
setHideWatchlistsTab(false);
updateSettings({
streamyStatsServerUrl: "",
searchEngine: "Jellyfin",
streamyStatsMovieRecommendations: false,
streamyStatsSeriesRecommendations: false,
streamyStatsPromotedWatchlists: false,
hideWatchlistsTab: false,
});
queryClient.invalidateQueries({ queryKey: ["streamystats"] });
queryClient.invalidateQueries({ queryKey: ["search"] });
toast.success(t("home.settings.plugins.streamystats.toasts.disabled"));
}, [updateSettings, queryClient, t]);
const handleOpenLink = () => {
Linking.openURL("https://github.com/fredrikburmester/streamystats");
};
const handleRefreshFromServer = useCallback(async () => {
const newPluginSettings = await refreshStreamyfinPluginSettings(true);
// Update local state with new values
const newUrl = newPluginSettings?.streamyStatsServerUrl?.value || "";
setUrl(newUrl);
if (newUrl) {
setUseForSearch(true);
}
toast.success(t("home.settings.plugins.streamystats.toasts.refreshed"));
}, [refreshStreamyfinPluginSettings, t]);
if (!settings) return null;
return (
<ScrollView
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<View className='px-4'>
<ListGroup className='flex-1'>
<ListItem
title={t("home.settings.plugins.streamystats.url")}
disabledByAdmin={isUrlLocked}
>
<TextInput
editable={!isUrlLocked}
className='text-white text-right flex-1'
placeholder={t(
"home.settings.plugins.streamystats.server_url_placeholder",
)}
value={url}
keyboardType='url'
returnKeyType='done'
autoCapitalize='none'
textContentType='URL'
onChangeText={setUrl}
/>
</ListItem>
</ListGroup>
<Text className='px-4 text-xs text-neutral-500 mt-1'>
{t("home.settings.plugins.streamystats.streamystats_search_hint")}{" "}
<Text className='text-blue-500' onPress={handleOpenLink}>
{t(
"home.settings.plugins.streamystats.read_more_about_streamystats",
)}
</Text>
</Text>
<ListGroup
title={t("home.settings.plugins.streamystats.features_title")}
className='mt-4'
>
<ListItem
title={t("home.settings.plugins.streamystats.enable_search")}
disabledByAdmin={pluginSettings?.searchEngine?.locked === true}
>
<Switch
value={useForSearch}
disabled={!isStreamystatsEnabled}
onValueChange={setUseForSearch}
/>
</ListItem>
<ListItem
title={t(
"home.settings.plugins.streamystats.enable_movie_recommendations",
)}
disabledByAdmin={
pluginSettings?.streamyStatsMovieRecommendations?.locked === true
}
>
<Switch
value={movieRecs}
onValueChange={setMovieRecs}
disabled={!isStreamystatsEnabled}
/>
</ListItem>
<ListItem
title={t(
"home.settings.plugins.streamystats.enable_series_recommendations",
)}
disabledByAdmin={
pluginSettings?.streamyStatsSeriesRecommendations?.locked === true
}
>
<Switch
value={seriesRecs}
onValueChange={setSeriesRecs}
disabled={!isStreamystatsEnabled}
/>
</ListItem>
<ListItem
title={t(
"home.settings.plugins.streamystats.enable_promoted_watchlists",
)}
disabledByAdmin={
pluginSettings?.streamyStatsPromotedWatchlists?.locked === true
}
>
<Switch
value={promotedWatchlists}
onValueChange={setPromotedWatchlists}
disabled={!isStreamystatsEnabled}
/>
</ListItem>
<ListItem
title={t("home.settings.plugins.streamystats.hide_watchlists_tab")}
disabledByAdmin={pluginSettings?.hideWatchlistsTab?.locked === true}
>
<Switch
value={hideWatchlistsTab}
onValueChange={setHideWatchlistsTab}
disabled={!isStreamystatsEnabled}
/>
</ListItem>
</ListGroup>
<Text className='px-4 text-xs text-neutral-500 mt-1'>
{t("home.settings.plugins.streamystats.home_sections_hint")}
</Text>
<TouchableOpacity
onPress={handleRefreshFromServer}
className='mt-6 py-3 rounded-xl bg-neutral-800'
>
<Text className='text-center text-blue-500'>
{t("home.settings.plugins.streamystats.refresh_from_server")}
</Text>
</TouchableOpacity>
{/* Disable button - only show if URL is not locked and Streamystats is enabled */}
{!isUrlLocked && isStreamystatsEnabled && (
<TouchableOpacity
onPress={handleClearStreamystats}
className='mt-3 mb-4 py-3 rounded-xl bg-neutral-800'
>
<Text className='text-center text-red-500'>
{t("home.settings.plugins.streamystats.disable_streamystats")}
</Text>
</TouchableOpacity>
)}
</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

@@ -1,4 +1,3 @@
import { ItemFields } from "@jellyfin/sdk/lib/generated-client/models";
import { useLocalSearchParams } from "expo-router";
import type React from "react";
import { useEffect } from "react";
@@ -21,16 +20,7 @@ const Page: React.FC = () => {
const { offline } = useLocalSearchParams() as { offline?: string };
const isOffline = offline === "true";
// Exclude MediaSources/MediaStreams from initial fetch for faster loading
// (especially important for plugins like Gelato)
const { data: item, isError } = useItemQuery(id, isOffline, undefined, [
ItemFields.MediaSources,
ItemFields.MediaSourceCount,
ItemFields.MediaStreams,
]);
// Lazily preload item with full media sources in background
const { data: itemWithSources } = useItemQuery(id, isOffline, undefined, []);
const { data: item, isError } = useItemQuery(id, isOffline);
const opacity = useSharedValue(1);
const animatedStyle = useAnimatedStyle(() => {
@@ -100,13 +90,7 @@ const Page: React.FC = () => {
<View className='h-12 bg-neutral-900 rounded-lg w-full mb-2' />
<View className='h-24 bg-neutral-900 rounded-lg mb-1 w-full' />
</Animated.View>
{item && (
<ItemContent
item={item}
isOffline={isOffline}
itemWithSources={itemWithSources}
/>
)}
{item && <ItemContent item={item} isOffline={isOffline} />}
</View>
);
};

View File

@@ -21,18 +21,19 @@ export default function page() {
companyId: string;
name: string;
image: string;
type: DiscoverSliderType; //This gets converted to a string because it's a url param
type: DiscoverSliderType;
};
const { data, fetchNextPage, hasNextPage, isLoading } = useInfiniteQuery({
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ["jellyseerr", "company", type, companyId],
queryFn: async ({ pageParam }) => {
const params: any = {
page: Number(pageParam),
};
return jellyseerrApi?.discover(
`${
Number(type) === DiscoverSliderType.NETWORKS
type === DiscoverSliderType.NETWORKS
? Endpoints.DISCOVER_TV_NETWORK
: Endpoints.DISCOVER_MOVIES_STUDIO
}/${companyId}`,
@@ -85,7 +86,6 @@ export default function page() {
fetchNextPage();
}
}}
isLoading={isLoading}
logo={
<Image
id={companyId}

View File

@@ -14,43 +14,36 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { GenreTags } from "@/components/GenreTags";
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 {
MediaRequestStatus,
MediaType,
} from "@/utils/jellyseerr/server/constants/media";
import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
import {
hasPermission,
Permission,
} from "@/utils/jellyseerr/server/lib/permissions";
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
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();
@@ -67,12 +60,11 @@ const Page: React.FC = () => {
} & Partial<MovieResult | TvResult | MovieDetails | TvDetails>;
const navigation = useNavigation();
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
const { jellyseerrApi, requestMedia } = useJellyseerr();
const [issueType, setIssueType] = useState<IssueType>();
const [issueMessage, setIssueMessage] = useState<string>();
const [requestBody, _setRequestBody] = useState<MediaRequestBody>();
const [issueTypeDropdownOpen, setIssueTypeDropdownOpen] = useState(false);
const advancedReqModalRef = useRef<BottomSheetModal>(null);
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
@@ -100,46 +92,6 @@ const Page: React.FC = () => {
const [canRequest, hasAdvancedRequestPermission] =
useJellyseerrCanRequest(details);
const canManageRequests = useMemo(() => {
if (!jellyseerrUser) return false;
return hasPermission(
Permission.MANAGE_REQUESTS,
jellyseerrUser.permissions,
);
}, [jellyseerrUser]);
const pendingRequest = useMemo(() => {
return details?.mediaInfo?.requests?.find(
(r: MediaRequest) => r.status === MediaRequestStatus.PENDING,
);
}, [details]);
const handleApproveRequest = useCallback(async () => {
if (!pendingRequest?.id) return;
try {
await jellyseerrApi?.approveRequest(pendingRequest.id);
toast.success(t("jellyseerr.toasts.request_approved"));
refetch();
} catch (error) {
toast.error(t("jellyseerr.toasts.failed_to_approve_request"));
console.error("Failed to approve request:", error);
}
}, [jellyseerrApi, pendingRequest, refetch, t]);
const handleDeclineRequest = useCallback(async () => {
if (!pendingRequest?.id) return;
try {
await jellyseerrApi?.declineRequest(pendingRequest.id);
toast.success(t("jellyseerr.toasts.request_declined"));
refetch();
} catch (error) {
toast.error(t("jellyseerr.toasts.failed_to_decline_request"));
console.error("Failed to decline request:", error);
}
}, [jellyseerrApi, pendingRequest, refetch, t]);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
@@ -163,10 +115,6 @@ const Page: React.FC = () => {
}
}, [jellyseerrApi, details, result, issueType, issueMessage]);
const handleIssueModalDismiss = useCallback(() => {
setIssueTypeDropdownOpen(false);
}, []);
const setRequestBody = useCallback(
(body: MediaRequestBody) => {
_setRequestBody(body);
@@ -180,11 +128,9 @@ const Page: React.FC = () => {
mediaId: Number(result.id!),
mediaType: mediaType!,
tvdbId: details?.externalIds?.tvdbId,
...(mediaType === MediaType.TV && {
seasons: (details as TvDetails)?.seasons
?.filter?.((s) => s.seasonNumber !== 0)
?.map?.((s) => s.seasonNumber),
}),
seasons: (details as TvDetails)?.seasons
?.filter?.((s) => s.seasonNumber !== 0)
?.map?.((s) => s.seasonNumber),
};
if (hasAdvancedRequestPermission) {
@@ -193,15 +139,7 @@ const Page: React.FC = () => {
}
requestMedia(mediaTitle, body, refetch);
}, [
details,
result,
requestMedia,
hasAdvancedRequestPermission,
mediaTitle,
refetch,
mediaType,
]);
}, [details, result, requestMedia, hasAdvancedRequestPermission]);
const isAnime = useMemo(
() =>
@@ -210,31 +148,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>
),
@@ -359,16 +277,12 @@ const Page: React.FC = () => {
<Button
className='flex-1 bg-purple-600/50 border-purple-400 ring-purple-400 text-purple-100'
onPress={() => {
router.push({
pathname:
mediaType === MediaType.MOVIE
? "/(auth)/(tabs)/(search)/items/page"
: "/(auth)/(tabs)/(search)/series/[id]",
params:
mediaType === MediaType.MOVIE
? { id: details?.mediaInfo.jellyfinMediaId }
: { id: details?.mediaInfo.jellyfinMediaId },
});
const url =
mediaType === MediaType.MOVIE
? `/(auth)/(tabs)/(search)/items/page?id=${details?.mediaInfo.jellyfinMediaId}`
: `/(auth)/(tabs)/(search)/series/${details?.mediaInfo.jellyfinMediaId}`;
// @ts-expect-error
router.push(url);
}}
iconLeft={
<Ionicons name='play-outline' size={20} color='white' />
@@ -378,65 +292,11 @@ const Page: React.FC = () => {
borderStyle: "solid",
}}
>
<Text className='text-sm'>{t("common.play")}</Text>
<Text className='text-sm'>Play</Text>
</Button>
</View>
)
)}
{canManageRequests && pendingRequest && (
<View className='flex flex-col space-y-2 mt-4'>
<View className='flex flex-row items-center space-x-2'>
<Ionicons name='person-outline' size={16} color='#9CA3AF' />
<Text className='text-sm text-neutral-400'>
{t("jellyseerr.requested_by", {
user:
pendingRequest.requestedBy?.displayName ||
pendingRequest.requestedBy?.username ||
pendingRequest.requestedBy?.jellyfinUsername ||
t("jellyseerr.unknown_user"),
})}
</Text>
</View>
<View className='flex flex-row space-x-2'>
<Button
className='flex-1 bg-green-600/50 border-green-400 ring-green-400 text-green-100'
color='transparent'
onPress={handleApproveRequest}
iconLeft={
<Ionicons
name='checkmark-outline'
size={20}
color='white'
/>
}
style={{
borderWidth: 1,
borderStyle: "solid",
}}
>
<Text className='text-sm'>{t("jellyseerr.approve")}</Text>
</Button>
<Button
className='flex-1 bg-red-600/50 border-red-400 ring-red-400 text-red-100'
color='transparent'
onPress={handleDeclineRequest}
iconLeft={
<Ionicons
name='close-outline'
size={20}
color='white'
/>
}
style={{
borderWidth: 1,
borderStyle: "solid",
}}
>
<Text className='text-sm'>{t("jellyseerr.decline")}</Text>
</Button>
</View>
</View>
)}
<OverviewText text={result.overview} className='mt-4' />
</View>
@@ -483,8 +343,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'>
@@ -494,25 +352,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

@@ -1,300 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Dimensions,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
import { MusicTrackItem } from "@/components/music/MusicTrackItem";
import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet";
import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet";
import {
downloadTrack,
isPermanentlyDownloaded,
} from "@/providers/AudioStorage";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
import { getAudioStreamUrl } from "@/utils/jellyfin/audio/getAudioStreamUrl";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { runtimeTicksToMinutes } from "@/utils/time";
const { width: SCREEN_WIDTH } = Dimensions.get("window");
const ARTWORK_SIZE = SCREEN_WIDTH * 0.5;
export default function AlbumDetailScreen() {
const { albumId } = useLocalSearchParams<{ albumId: string }>();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const insets = useSafeAreaInsets();
const navigation = useNavigation();
const { t } = useTranslation();
const { playQueue } = useMusicPlayer();
const [selectedTrack, setSelectedTrack] = useState<BaseItemDto | null>(null);
const [trackOptionsOpen, setTrackOptionsOpen] = useState(false);
const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false);
const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
const handleTrackOptionsPress = useCallback((track: BaseItemDto) => {
setSelectedTrack(track);
setTrackOptionsOpen(true);
}, []);
const handleAddToPlaylist = useCallback(() => {
setPlaylistPickerOpen(true);
}, []);
const handleCreateNewPlaylist = useCallback(() => {
setCreatePlaylistOpen(true);
}, []);
const { data: album, isLoading: loadingAlbum } = useQuery({
queryKey: ["music-album", albumId, user?.Id],
queryFn: async () => {
const response = await getUserLibraryApi(api!).getItem({
userId: user?.Id,
itemId: albumId!,
});
return response.data;
},
enabled: !!api && !!user?.Id && !!albumId,
});
const { data: tracks, isLoading: loadingTracks } = useQuery({
queryKey: ["music-album-tracks", albumId, user?.Id],
queryFn: async () => {
const response = await getItemsApi(api!).getItems({
userId: user?.Id,
parentId: albumId,
sortBy: ["IndexNumber"],
sortOrder: ["Ascending"],
});
return response.data.Items || [];
},
enabled: !!api && !!user?.Id && !!albumId,
});
useEffect(() => {
navigation.setOptions({
title: album?.Name ?? "",
headerTransparent: true,
headerStyle: { backgroundColor: "transparent" },
headerShadowVisible: false,
});
}, [album?.Name, navigation]);
const imageUrl = useMemo(
() => (album ? getPrimaryImageUrl({ api, item: album }) : null),
[api, album],
);
const totalDuration = useMemo(() => {
if (!tracks) return "";
const totalTicks = tracks.reduce(
(acc, track) => acc + (track.RunTimeTicks || 0),
0,
);
return runtimeTicksToMinutes(totalTicks);
}, [tracks]);
const handlePlayAll = useCallback(() => {
if (tracks && tracks.length > 0) {
playQueue(tracks, 0);
}
}, [playQueue, tracks]);
const handleShuffle = useCallback(() => {
if (tracks && tracks.length > 0) {
const shuffled = [...tracks].sort(() => Math.random() - 0.5);
playQueue(shuffled, 0);
}
}, [playQueue, tracks]);
// Check if all tracks are already permanently downloaded
const allTracksDownloaded = useMemo(() => {
if (!tracks || tracks.length === 0) return false;
return tracks.every((track) => isPermanentlyDownloaded(track.Id));
}, [tracks]);
const handleDownloadAlbum = useCallback(async () => {
if (!tracks || !api || !user?.Id || isDownloading) return;
setIsDownloading(true);
try {
for (const track of tracks) {
if (!track.Id || isPermanentlyDownloaded(track.Id)) continue;
const result = await getAudioStreamUrl(api, user.Id, track.Id);
if (result?.url && !result.isTranscoding) {
await downloadTrack(track.Id, result.url, {
permanent: true,
container: result.mediaSource?.Container || undefined,
});
}
}
} catch {
// Silent fail
}
setIsDownloading(false);
}, [tracks, api, user?.Id, isDownloading]);
const isLoading = loadingAlbum || loadingTracks;
// Only show loading if we have no cached data to display
if (isLoading && !album) {
return (
<View className='flex-1 justify-center items-center bg-black'>
<Loader />
</View>
);
}
if (!album) {
return (
<View className='flex-1 justify-center items-center bg-black'>
<Text className='text-neutral-500'>{t("music.album_not_found")}</Text>
</View>
);
}
return (
<FlashList
data={tracks || []}
contentContainerStyle={{
paddingBottom: insets.bottom + 100,
}}
ListHeaderComponent={
<View
className='items-center px-4 pb-6 bg-black'
style={{ paddingTop: insets.top + 60 }}
>
{/* Album artwork */}
<View
style={{
width: ARTWORK_SIZE,
height: ARTWORK_SIZE,
borderRadius: 8,
overflow: "hidden",
backgroundColor: "#1a1a1a",
shadowColor: "#000",
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.3,
shadowRadius: 12,
elevation: 8,
}}
>
{imageUrl ? (
<Image
source={{ uri: imageUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
cachePolicy='memory-disk'
/>
) : (
<View className='flex-1 items-center justify-center bg-neutral-800'>
<Ionicons name='disc' size={60} color='#666' />
</View>
)}
</View>
{/* Album info */}
<Text className='text-white text-xl font-bold mt-4 text-center'>
{album.Name}
</Text>
<Text className='text-purple-400 text-base mt-1'>
{album.AlbumArtist || album.Artists?.join(", ")}
</Text>
<Text className='text-neutral-500 text-sm mt-1'>
{album.ProductionYear && `${album.ProductionYear}`}
{tracks?.length} tracks {totalDuration}
</Text>
{/* Play buttons */}
<View className='flex flex-row mt-4 items-center'>
<TouchableOpacity
onPress={handlePlayAll}
className='flex flex-row items-center bg-purple-600 px-6 py-3 rounded-full mr-3'
>
<Ionicons name='play' size={20} color='white' />
<Text className='text-white font-medium ml-2'>
{t("music.play")}
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={handleShuffle}
className='flex flex-row items-center bg-neutral-800 px-6 py-3 rounded-full mr-3'
>
<Ionicons name='shuffle' size={20} color='white' />
<Text className='text-white font-medium ml-2'>
{t("music.shuffle")}
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={handleDownloadAlbum}
disabled={allTracksDownloaded || isDownloading}
className='flex items-center justify-center bg-neutral-800 p-3 rounded-full'
>
{isDownloading ? (
<ActivityIndicator size={20} color='white' />
) : (
<Ionicons
name={
allTracksDownloaded
? "checkmark-circle"
: "download-outline"
}
size={20}
color={allTracksDownloaded ? "#22c55e" : "white"}
/>
)}
</TouchableOpacity>
</View>
</View>
}
renderItem={({ item, index }) => (
<MusicTrackItem
track={item}
index={index + 1}
queue={tracks}
showArtwork={false}
onOptionsPress={handleTrackOptionsPress}
/>
)}
keyExtractor={(item) => item.Id!}
ListFooterComponent={
<>
<TrackOptionsSheet
open={trackOptionsOpen}
setOpen={setTrackOptionsOpen}
track={selectedTrack}
onAddToPlaylist={handleAddToPlaylist}
/>
<PlaylistPickerSheet
open={playlistPickerOpen}
setOpen={setPlaylistPickerOpen}
trackToAdd={selectedTrack}
onCreateNew={handleCreateNewPlaylist}
/>
<CreatePlaylistModal
open={createPlaylistOpen}
setOpen={setCreatePlaylistOpen}
initialTrackId={selectedTrack?.Id}
/>
</>
}
/>
);
}

View File

@@ -1,273 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Dimensions, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { HorizontalScroll } from "@/components/common/HorizontalScroll";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
import { MusicAlbumCard } from "@/components/music/MusicAlbumCard";
import { MusicTrackItem } from "@/components/music/MusicTrackItem";
import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet";
import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
const { width: SCREEN_WIDTH } = Dimensions.get("window");
const ARTWORK_SIZE = SCREEN_WIDTH * 0.4;
export default function ArtistDetailScreen() {
const { artistId } = useLocalSearchParams<{ artistId: string }>();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const insets = useSafeAreaInsets();
const navigation = useNavigation();
const { t } = useTranslation();
const { playQueue } = useMusicPlayer();
const [selectedTrack, setSelectedTrack] = useState<BaseItemDto | null>(null);
const [trackOptionsOpen, setTrackOptionsOpen] = useState(false);
const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false);
const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false);
const handleTrackOptionsPress = useCallback((track: BaseItemDto) => {
setSelectedTrack(track);
setTrackOptionsOpen(true);
}, []);
const handleAddToPlaylist = useCallback(() => {
setPlaylistPickerOpen(true);
}, []);
const handleCreateNewPlaylist = useCallback(() => {
setCreatePlaylistOpen(true);
}, []);
const { data: artist, isLoading: loadingArtist } = useQuery({
queryKey: ["music-artist", artistId, user?.Id],
queryFn: async () => {
const response = await getUserLibraryApi(api!).getItem({
userId: user?.Id,
itemId: artistId!,
});
return response.data;
},
enabled: !!api && !!user?.Id && !!artistId,
});
const { data: albums, isLoading: loadingAlbums } = useQuery({
queryKey: ["music-artist-albums", artistId, user?.Id],
queryFn: async () => {
const response = await getItemsApi(api!).getItems({
userId: user?.Id,
artistIds: [artistId!],
includeItemTypes: ["MusicAlbum"],
sortBy: ["ProductionYear", "SortName"],
sortOrder: ["Descending", "Ascending"],
recursive: true,
});
return response.data.Items || [];
},
enabled: !!api && !!user?.Id && !!artistId,
});
const { data: topTracks, isLoading: loadingTracks } = useQuery({
queryKey: ["music-artist-top-tracks", artistId, user?.Id],
queryFn: async () => {
const response = await getItemsApi(api!).getItems({
userId: user?.Id,
artistIds: [artistId!],
includeItemTypes: ["Audio"],
sortBy: ["PlayCount"],
sortOrder: ["Descending"],
limit: 10,
recursive: true,
filters: ["IsPlayed"],
});
return response.data.Items || [];
},
enabled: !!api && !!user?.Id && !!artistId,
});
useEffect(() => {
navigation.setOptions({
title: artist?.Name ?? "",
headerTransparent: true,
headerStyle: { backgroundColor: "transparent" },
headerShadowVisible: false,
});
}, [artist?.Name, navigation]);
const imageUrl = useMemo(
() => (artist ? getPrimaryImageUrl({ api, item: artist }) : null),
[api, artist],
);
const handlePlayAllTracks = useCallback(() => {
if (topTracks && topTracks.length > 0) {
playQueue(topTracks, 0);
}
}, [playQueue, topTracks]);
const isLoading = loadingArtist || loadingAlbums || loadingTracks;
// Only show loading if we have no cached data to display
if (isLoading && !artist) {
return (
<View className='flex-1 justify-center items-center bg-black'>
<Loader />
</View>
);
}
if (!artist) {
return (
<View className='flex-1 justify-center items-center bg-black'>
<Text className='text-neutral-500'>{t("music.artist_not_found")}</Text>
</View>
);
}
const sections = [];
// Top tracks section
if (topTracks && topTracks.length > 0) {
sections.push({
id: "top-tracks",
title: t("music.top_tracks"),
type: "tracks" as const,
data: topTracks,
});
}
// Albums section
if (albums && albums.length > 0) {
sections.push({
id: "albums",
title: t("music.tabs.albums"),
type: "albums" as const,
data: albums,
});
}
return (
<FlashList
data={sections}
contentContainerStyle={{
paddingBottom: insets.bottom + 100,
}}
ListHeaderComponent={
<View
className='items-center px-4 pb-6 bg-black'
style={{ paddingTop: insets.top + 50 }}
>
{/* Artist image */}
<View
style={{
width: ARTWORK_SIZE,
height: ARTWORK_SIZE,
borderRadius: ARTWORK_SIZE / 2,
overflow: "hidden",
backgroundColor: "#1a1a1a",
shadowColor: "#000",
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.3,
shadowRadius: 12,
elevation: 8,
}}
>
{imageUrl ? (
<Image
source={{ uri: imageUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
cachePolicy='memory-disk'
/>
) : (
<View className='flex-1 items-center justify-center bg-neutral-800'>
<Ionicons name='person' size={60} color='#666' />
</View>
)}
</View>
{/* Artist info */}
<Text className='text-white text-2xl font-bold mt-4 text-center'>
{artist.Name}
</Text>
<Text className='text-neutral-500 text-sm mt-1'>
{albums?.length || 0} {t("music.tabs.albums").toLowerCase()}
</Text>
{/* Play button */}
{topTracks && topTracks.length > 0 && (
<TouchableOpacity
onPress={handlePlayAllTracks}
className='flex flex-row items-center bg-purple-600 px-6 py-3 rounded-full mt-4'
>
<Ionicons name='play' size={20} color='white' />
<Text className='text-white font-medium ml-2'>
{t("music.play_top_tracks")}
</Text>
</TouchableOpacity>
)}
</View>
}
renderItem={({ item: section }) => (
<View className='mb-6'>
<Text className='text-lg font-bold px-4 mb-3'>{section.title}</Text>
{section.type === "albums" ? (
<HorizontalScroll
data={section.data}
height={178}
keyExtractor={(item) => item.Id!}
renderItem={(item) => <MusicAlbumCard album={item} />}
/>
) : (
section.data
.slice(0, 5)
.map((track, index) => (
<MusicTrackItem
key={track.Id}
track={track}
index={index + 1}
queue={section.data}
onOptionsPress={handleTrackOptionsPress}
/>
))
)}
</View>
)}
keyExtractor={(item) => item.id}
ListFooterComponent={
<>
<TrackOptionsSheet
open={trackOptionsOpen}
setOpen={setTrackOptionsOpen}
track={selectedTrack}
onAddToPlaylist={handleAddToPlaylist}
/>
<PlaylistPickerSheet
open={playlistPickerOpen}
setOpen={setPlaylistPickerOpen}
trackToAdd={selectedTrack}
onCreateNew={handleCreateNewPlaylist}
/>
<CreatePlaylistModal
open={createPlaylistOpen}
setOpen={setCreatePlaylistOpen}
initialTrackId={selectedTrack?.Id}
/>
</>
}
/>
);
}

View File

@@ -1,315 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
import { MusicTrackItem } from "@/components/music/MusicTrackItem";
import { PlaylistOptionsSheet } from "@/components/music/PlaylistOptionsSheet";
import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet";
import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet";
import { useRemoveFromPlaylist } from "@/hooks/usePlaylistMutations";
import { downloadTrack, getLocalPath } from "@/providers/AudioStorage";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
import { getAudioStreamUrl } from "@/utils/jellyfin/audio/getAudioStreamUrl";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { runtimeTicksToMinutes } from "@/utils/time";
const ARTWORK_SIZE = 120;
export default function PlaylistDetailScreen() {
const { playlistId } = useLocalSearchParams<{ playlistId: string }>();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const insets = useSafeAreaInsets();
const navigation = useNavigation();
const { t } = useTranslation();
const { playQueue } = useMusicPlayer();
const [selectedTrack, setSelectedTrack] = useState<BaseItemDto | null>(null);
const [trackOptionsOpen, setTrackOptionsOpen] = useState(false);
const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false);
const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false);
const [playlistOptionsOpen, setPlaylistOptionsOpen] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
const removeFromPlaylist = useRemoveFromPlaylist();
const handleTrackOptionsPress = useCallback((track: BaseItemDto) => {
setSelectedTrack(track);
setTrackOptionsOpen(true);
}, []);
const handleAddToPlaylist = useCallback(() => {
setPlaylistPickerOpen(true);
}, []);
const handleCreateNewPlaylist = useCallback(() => {
setCreatePlaylistOpen(true);
}, []);
const handleRemoveFromPlaylist = useCallback(() => {
if (selectedTrack?.Id && playlistId) {
removeFromPlaylist.mutate({
playlistId,
entryIds: [selectedTrack.PlaylistItemId ?? selectedTrack.Id],
});
}
}, [selectedTrack, playlistId, removeFromPlaylist]);
const { data: playlist, isLoading: loadingPlaylist } = useQuery({
queryKey: ["music-playlist", playlistId, user?.Id],
queryFn: async () => {
const response = await getUserLibraryApi(api!).getItem({
userId: user?.Id,
itemId: playlistId!,
});
return response.data;
},
enabled: !!api && !!user?.Id && !!playlistId,
});
const { data: tracks, isLoading: loadingTracks } = useQuery({
queryKey: ["music-playlist-tracks", playlistId, user?.Id],
queryFn: async () => {
const response = await getItemsApi(api!).getItems({
userId: user?.Id,
parentId: playlistId,
});
return response.data.Items || [];
},
enabled: !!api && !!user?.Id && !!playlistId,
});
useEffect(() => {
navigation.setOptions({
title: playlist?.Name ?? "",
headerTransparent: true,
headerStyle: { backgroundColor: "transparent" },
headerShadowVisible: false,
headerRight: () => (
<TouchableOpacity
onPress={() => setPlaylistOptionsOpen(true)}
className='p-1.5'
>
<Ionicons name='ellipsis-horizontal' size={24} color='white' />
</TouchableOpacity>
),
});
}, [playlist?.Name, navigation]);
const imageUrl = useMemo(
() => (playlist ? getPrimaryImageUrl({ api, item: playlist }) : null),
[api, playlist],
);
const totalDuration = useMemo(() => {
if (!tracks) return "";
const totalTicks = tracks.reduce(
(acc, track) => acc + (track.RunTimeTicks || 0),
0,
);
return runtimeTicksToMinutes(totalTicks);
}, [tracks]);
const handlePlayAll = useCallback(() => {
if (tracks && tracks.length > 0) {
playQueue(tracks, 0);
}
}, [playQueue, tracks]);
const handleShuffle = useCallback(() => {
if (tracks && tracks.length > 0) {
const shuffled = [...tracks].sort(() => Math.random() - 0.5);
playQueue(shuffled, 0);
}
}, [playQueue, tracks]);
// Check if all tracks are already downloaded
const allTracksDownloaded = useMemo(() => {
if (!tracks || tracks.length === 0) return false;
return tracks.every((track) => !!getLocalPath(track.Id));
}, [tracks]);
const handleDownloadPlaylist = useCallback(async () => {
if (!tracks || !api || !user?.Id || isDownloading) return;
setIsDownloading(true);
try {
for (const track of tracks) {
if (!track.Id || getLocalPath(track.Id)) continue;
const result = await getAudioStreamUrl(api, user.Id, track.Id);
if (result?.url && !result.isTranscoding) {
await downloadTrack(track.Id, result.url, {
permanent: true,
container: result.mediaSource?.Container || undefined,
});
}
}
} catch {
// Silent fail
}
setIsDownloading(false);
}, [tracks, api, user?.Id, isDownloading]);
const isLoading = loadingPlaylist || loadingTracks;
// Only show loading if we have no cached data to display
if (isLoading && !playlist) {
return (
<View className='flex-1 justify-center items-center bg-black'>
<Loader />
</View>
);
}
if (!playlist) {
return (
<View className='flex-1 justify-center items-center bg-black'>
<Text className='text-neutral-500'>
{t("music.playlist_not_found")}
</Text>
</View>
);
}
return (
<FlashList
data={tracks || []}
contentContainerStyle={{
paddingBottom: insets.bottom + 100,
}}
ListHeaderComponent={
<View
className='items-center px-4 pb-6 bg-black'
style={{ paddingTop: insets.top + 50 }}
>
{/* Playlist artwork */}
<View
style={{
width: ARTWORK_SIZE,
height: ARTWORK_SIZE,
borderRadius: 8,
overflow: "hidden",
backgroundColor: "#1a1a1a",
shadowColor: "#000",
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.3,
shadowRadius: 12,
elevation: 8,
}}
>
{imageUrl ? (
<Image
source={{ uri: imageUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
cachePolicy='memory-disk'
/>
) : (
<View className='flex-1 items-center justify-center bg-neutral-800'>
<Ionicons name='list' size={60} color='#666' />
</View>
)}
</View>
{/* Playlist info */}
<Text className='text-white text-xl font-bold mt-4 text-center'>
{playlist.Name}
</Text>
<Text className='text-neutral-500 text-sm mt-1'>
{tracks?.length} tracks {totalDuration}
</Text>
{/* Play buttons */}
<View className='flex flex-row mt-4 items-center'>
<TouchableOpacity
onPress={handlePlayAll}
className='flex flex-row items-center bg-purple-600 px-6 py-3 rounded-full mr-3'
>
<Ionicons name='play' size={20} color='white' />
<Text className='text-white font-medium ml-2'>
{t("music.play")}
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={handleShuffle}
className='flex flex-row items-center bg-neutral-800 px-6 py-3 rounded-full mr-3'
>
<Ionicons name='shuffle' size={20} color='white' />
<Text className='text-white font-medium ml-2'>
{t("music.shuffle")}
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={handleDownloadPlaylist}
disabled={allTracksDownloaded || isDownloading}
className='flex items-center justify-center bg-neutral-800 p-3 rounded-full'
>
{isDownloading ? (
<ActivityIndicator size={20} color='white' />
) : (
<Ionicons
name={
allTracksDownloaded
? "checkmark-circle"
: "download-outline"
}
size={20}
color={allTracksDownloaded ? "#22c55e" : "white"}
/>
)}
</TouchableOpacity>
</View>
</View>
}
renderItem={({ item, index }) => (
<MusicTrackItem
track={item}
index={index + 1}
queue={tracks}
onOptionsPress={handleTrackOptionsPress}
/>
)}
keyExtractor={(item) => item.Id!}
ListFooterComponent={
<>
<TrackOptionsSheet
open={trackOptionsOpen}
setOpen={setTrackOptionsOpen}
track={selectedTrack}
onAddToPlaylist={handleAddToPlaylist}
playlistId={playlistId}
onRemoveFromPlaylist={handleRemoveFromPlaylist}
/>
<PlaylistPickerSheet
open={playlistPickerOpen}
setOpen={setPlaylistPickerOpen}
trackToAdd={selectedTrack}
onCreateNew={handleCreateNewPlaylist}
/>
<CreatePlaylistModal
open={createPlaylistOpen}
setOpen={setCreatePlaylistOpen}
initialTrackId={selectedTrack?.Id}
/>
<PlaylistOptionsSheet
open={playlistOptionsOpen}
setOpen={setPlaylistOptionsOpen}
playlist={playlist}
/>
</>
}
/>
);
}

View File

@@ -2,7 +2,6 @@ import type {
BaseItemDto,
BaseItemDtoQueryResult,
BaseItemKind,
ItemFilter,
} from "@jellyfin/sdk/lib/generated-client/models";
import {
getFilterApi,
@@ -28,11 +27,7 @@ import { useOrientation } from "@/hooks/useOrientation";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
FilterByOption,
FilterByPreferenceAtom,
filterByAtom,
genreFilterAtom,
getFilterByPreference,
getSortByPreference,
getSortOrderPreference,
SortByOption,
@@ -44,19 +39,12 @@ import {
sortOrderOptions,
sortOrderPreferenceAtom,
tagsFilterAtom,
useFilterOptions,
yearFilterAtom,
} from "@/utils/atoms/filters";
import { useSettings } from "@/utils/atoms/settings";
const Page = () => {
const searchParams = useLocalSearchParams() as {
libraryId: string;
sortBy?: string;
sortOrder?: string;
filterBy?: string;
};
const { libraryId } = searchParams;
const searchParams = useLocalSearchParams();
const { libraryId } = searchParams as { libraryId: string };
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
@@ -66,13 +54,9 @@ const Page = () => {
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
const [sortBy, _setSortBy] = useAtom(sortByAtom);
const [filterBy, _setFilterBy] = useAtom(filterByAtom);
const [sortOrder, _setSortOrder] = useAtom(sortOrderAtom);
const [sortByPreference, setSortByPreference] = useAtom(sortByPreferenceAtom);
const [filterByPreference, setFilterByPreference] = useAtom(
FilterByPreferenceAtom,
);
const [sortOrderPreference, setOrderByPreference] = useAtom(
const [sortOrderPreference, setOderByPreference] = useAtom(
sortOrderPreferenceAtom,
);
@@ -81,46 +65,19 @@ const Page = () => {
const { t } = useTranslation();
useEffect(() => {
// Check for URL params first (from "See All" navigation)
const urlSortBy = searchParams.sortBy as SortByOption | undefined;
const urlSortOrder = searchParams.sortOrder as SortOrderOption | undefined;
const urlFilterBy = searchParams.filterBy as FilterByOption | undefined;
// Apply sortOrder: URL param > saved preference > default
if (urlSortOrder && Object.values(SortOrderOption).includes(urlSortOrder)) {
_setSortOrder([urlSortOrder]);
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
if (sop) {
_setSortOrder([sop]);
} else {
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
_setSortOrder([sop || SortOrderOption.Ascending]);
_setSortOrder([SortOrderOption.Ascending]);
}
// Apply sortBy: URL param > saved preference > default
if (urlSortBy && Object.values(SortByOption).includes(urlSortBy)) {
_setSortBy([urlSortBy]);
const obp = getSortByPreference(libraryId, sortByPreference);
if (obp) {
_setSortBy([obp]);
} else {
const obp = getSortByPreference(libraryId, sortByPreference);
_setSortBy([obp || SortByOption.SortName]);
_setSortBy([SortByOption.SortName]);
}
// Apply filterBy: URL param > saved preference > default
if (urlFilterBy && Object.values(FilterByOption).includes(urlFilterBy)) {
_setFilterBy([urlFilterBy]);
} else {
const fp = getFilterByPreference(libraryId, filterByPreference);
_setFilterBy(fp ? [fp] : []);
}
}, [
libraryId,
sortOrderPreference,
sortByPreference,
_setSortOrder,
_setSortBy,
filterByPreference,
_setFilterBy,
searchParams.sortBy,
searchParams.sortOrder,
searchParams.filterBy,
]);
}, []);
const setSortBy = useCallback(
(sortBy: SortByOption[]) => {
@@ -130,35 +87,21 @@ const Page = () => {
}
_setSortBy(sortBy);
},
[libraryId, sortByPreference, setSortByPreference, _setSortBy],
[libraryId, sortByPreference],
);
const setSortOrder = useCallback(
(sortOrder: SortOrderOption[]) => {
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
if (sortOrder[0] !== sop) {
setOrderByPreference({
setOderByPreference({
...sortOrderPreference,
[libraryId]: sortOrder[0],
});
}
_setSortOrder(sortOrder);
},
[libraryId, sortOrderPreference, setOrderByPreference, _setSortOrder],
);
const setFilter = useCallback(
(filterBy: FilterByOption[]) => {
const fp = getFilterByPreference(libraryId, filterByPreference);
if (filterBy[0] !== fp) {
setFilterByPreference({
...filterByPreference,
[libraryId]: filterBy[0],
});
}
_setFilterBy(filterBy);
},
[libraryId, filterByPreference, setFilterByPreference, _setFilterBy],
[libraryId, sortOrderPreference],
);
const nrOfCols = useMemo(() => {
@@ -219,7 +162,6 @@ const Page = () => {
sortBy: [sortBy[0], "SortName", "ProductionYear"],
sortOrder: [sortOrder[0]],
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
filters: filterBy as ItemFilter[],
// true is needed for merged versions
recursive: true,
imageTypeLimit: 1,
@@ -242,7 +184,6 @@ const Page = () => {
selectedTags,
sortBy,
sortOrder,
filterBy,
],
);
@@ -256,7 +197,6 @@ const Page = () => {
selectedTags,
sortBy,
sortOrder,
filterBy,
],
queryFn: fetchItems,
getNextPageParam: (lastPage, pages) => {
@@ -322,167 +262,148 @@ const Page = () => {
);
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
const generalFilters = useFilterOptions();
const settings = useSettings();
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())
}
/>
),
},
{
key: "filterOptions",
component: (
<FilterButton
className='mr-1'
id={libraryId}
queryKey='filters'
queryFn={async () => generalFilters.map((s) => s.key)}
set={setFilter}
values={filterBy}
title={t("library.filters.filter_by")}
renderItemLabel={(item) =>
generalFilters.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,
@@ -499,9 +420,6 @@ const Page = () => {
sortOrder,
setSortOrder,
isFetching,
filterBy,
setFilter,
settings,
],
);
@@ -529,6 +447,7 @@ const Page = () => {
renderItem={renderItem}
extraData={[orientation, nrOfCols]}
keyExtractor={keyExtractor}
estimatedItemSize={244}
numColumns={nrOfCols}
onEndReached={() => {
if (hasNextPage) {

View File

@@ -1,152 +1,18 @@
import { Ionicons } from "@expo/vector-icons";
import { Stack } from "expo-router";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native";
import { PlatformDropdown } from "@/components/PlatformDropdown";
import { Platform } from "react-native";
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import { useSettings } from "@/utils/atoms/settings";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { useTranslation } from "react-i18next";
export default function IndexLayout() {
const { settings, updateSettings, pluginSettings } = useSettings();
const [dropdownOpen, setDropdownOpen] = useState(false);
const { t } = useTranslation();
// Reset dropdown state when component unmounts or navigates away
useEffect(() => {
return () => {
setDropdownOpen(false);
};
}, []);
// Memoize callbacks to prevent recreating on every render
const handleDisplayRow = useCallback(() => {
updateSettings({
libraryOptions: {
...settings.libraryOptions,
display: "row",
},
});
}, [settings.libraryOptions, updateSettings]);
const handleDisplayList = useCallback(() => {
updateSettings({
libraryOptions: {
...settings.libraryOptions,
display: "list",
},
});
}, [settings.libraryOptions, updateSettings]);
const handleImageStylePoster = useCallback(() => {
updateSettings({
libraryOptions: {
...settings.libraryOptions,
imageStyle: "poster",
},
});
}, [settings.libraryOptions, updateSettings]);
const handleImageStyleCover = useCallback(() => {
updateSettings({
libraryOptions: {
...settings.libraryOptions,
imageStyle: "cover",
},
});
}, [settings.libraryOptions, updateSettings]);
const handleToggleTitles = useCallback(() => {
updateSettings({
libraryOptions: {
...settings.libraryOptions,
showTitles: !settings.libraryOptions.showTitles,
},
});
}, [settings.libraryOptions, updateSettings]);
const handleToggleStats = useCallback(() => {
updateSettings({
libraryOptions: {
...settings.libraryOptions,
showStats: !settings.libraryOptions.showStats,
},
});
}, [settings.libraryOptions, updateSettings]);
// Memoize groups to prevent recreating the array on every render
const dropdownGroups = useMemo(
() => [
{
title: t("library.options.display"),
options: [
{
type: "radio" as const,
label: t("library.options.row"),
value: "row",
selected: settings.libraryOptions.display === "row",
onPress: handleDisplayRow,
},
{
type: "radio" as const,
label: t("library.options.list"),
value: "list",
selected: settings.libraryOptions.display === "list",
onPress: handleDisplayList,
},
],
},
{
title: t("library.options.image_style"),
options: [
{
type: "radio" as const,
label: t("library.options.poster"),
value: "poster",
selected: settings.libraryOptions.imageStyle === "poster",
onPress: handleImageStylePoster,
},
{
type: "radio" as const,
label: t("library.options.cover"),
value: "cover",
selected: settings.libraryOptions.imageStyle === "cover",
onPress: handleImageStyleCover,
},
],
},
{
title: "Options",
options: [
{
type: "toggle" as const,
label: t("library.options.show_titles"),
value: settings.libraryOptions.showTitles,
onToggle: handleToggleTitles,
disabled: settings.libraryOptions.imageStyle === "poster",
},
{
type: "toggle" as const,
label: t("library.options.show_stats"),
value: settings.libraryOptions.showStats,
onToggle: handleToggleStats,
},
],
},
],
[
t,
settings.libraryOptions,
handleDisplayRow,
handleDisplayList,
handleImageStylePoster,
handleImageStyleCover,
handleToggleTitles,
handleToggleStats,
],
);
if (!settings?.libraryOptions) return null;
return (
@@ -155,28 +21,178 @@ export default function IndexLayout() {
name='index'
options={{
headerShown: !Platform.isTV,
headerLargeTitle: true,
headerTitle: t("tabs.library"),
headerBlurEffect: "none",
headerBlurEffect: "prominent",
headerLargeStyle: {
backgroundColor: "black",
},
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}
/>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Ionicons
name='ellipsis-horizontal-outline'
size={24}
color='white'
/>
</DropdownMenu.Trigger>
<DropdownMenu.Content
align={"end"}
alignOffset={-10}
avoidCollisions={false}
collisionPadding={0}
loop={false}
side={"bottom"}
sideOffset={10}
>
<DropdownMenu.Label>
{t("library.options.display")}
</DropdownMenu.Label>
<DropdownMenu.Group key='display-group'>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key='image-style-trigger'>
{t("library.options.display")}
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
alignOffset={-10}
avoidCollisions={true}
collisionPadding={0}
loop={true}
sideOffset={10}
>
<DropdownMenu.CheckboxItem
key='display-option-1'
value={settings.libraryOptions.display === "row"}
onValueChange={() =>
updateSettings({
libraryOptions: {
...settings.libraryOptions,
display: "row",
},
})
}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key='display-title-1'>
{t("library.options.row")}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
key='display-option-2'
value={settings.libraryOptions.display === "list"}
onValueChange={() =>
updateSettings({
libraryOptions: {
...settings.libraryOptions,
display: "list",
},
})
}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key='display-title-2'>
{t("library.options.list")}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key='image-style-trigger'>
{t("library.options.image_style")}
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
alignOffset={-10}
avoidCollisions={true}
collisionPadding={0}
loop={true}
sideOffset={10}
>
<DropdownMenu.CheckboxItem
key='poster-option'
value={
settings.libraryOptions.imageStyle === "poster"
}
onValueChange={() =>
updateSettings({
libraryOptions: {
...settings.libraryOptions,
imageStyle: "poster",
},
})
}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key='poster-title'>
{t("library.options.poster")}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
key='cover-option'
value={settings.libraryOptions.imageStyle === "cover"}
onValueChange={() =>
updateSettings({
libraryOptions: {
...settings.libraryOptions,
imageStyle: "cover",
},
})
}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key='cover-title'>
{t("library.options.cover")}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
</DropdownMenu.Group>
<DropdownMenu.Group key='show-titles-group'>
<DropdownMenu.CheckboxItem
disabled={settings.libraryOptions.imageStyle === "poster"}
key='show-titles-option'
value={settings.libraryOptions.showTitles}
onValueChange={(newValue: string) => {
if (settings.libraryOptions.imageStyle === "poster")
return;
updateSettings({
libraryOptions: {
...settings.libraryOptions,
showTitles: newValue === "on",
},
});
}}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key='show-titles-title'>
{t("library.options.show_titles")}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
key='show-stats-option'
value={settings.libraryOptions.showStats}
onValueChange={(newValue: string) => {
updateSettings({
libraryOptions: {
...settings.libraryOptions,
showStats: newValue === "on",
},
});
}}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key='show-stats-title'>
{t("library.options.show_stats")}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
</DropdownMenu.Group>
<DropdownMenu.Separator />
</DropdownMenu.Content>
</DropdownMenu.Root>
),
}}
/>
@@ -185,7 +201,7 @@ export default function IndexLayout() {
options={{
title: "",
headerShown: !Platform.isTV,
headerBlurEffect: "none",
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
}}
@@ -198,7 +214,7 @@ export default function IndexLayout() {
options={{
title: "",
headerShown: !Platform.isTV,
headerBlurEffect: "none",
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
}}

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";
@@ -39,6 +39,7 @@ export default function index() {
() =>
data
?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!))
.filter((l) => l.CollectionType !== "music")
.filter((l) => l.CollectionType !== "books") || [],
[data, settings?.hiddenLibraries],
);
@@ -83,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} />}
@@ -104,6 +105,7 @@ export default function index() {
<View className='h-4' />
)
}
estimatedItemSize={200}
/>
);
}

View File

@@ -1,85 +0,0 @@
import {
createMaterialTopTabNavigator,
MaterialTopTabNavigationEventMap,
MaterialTopTabNavigationOptions,
} from "@react-navigation/material-top-tabs";
import type {
ParamListBase,
TabNavigationState,
} from "@react-navigation/native";
import { Stack, useLocalSearchParams, withLayoutContext } from "expo-router";
import { useTranslation } from "react-i18next";
const { Navigator } = createMaterialTopTabNavigator();
const TAB_LABEL_FONT_SIZE = 13;
const TAB_ITEM_HORIZONTAL_PADDING = 12;
export const Tab = withLayoutContext<
MaterialTopTabNavigationOptions,
typeof Navigator,
TabNavigationState<ParamListBase>,
MaterialTopTabNavigationEventMap
>(Navigator);
const Layout = () => {
const { libraryId } = useLocalSearchParams<{ libraryId: string }>();
const { t } = useTranslation();
return (
<>
<Stack.Screen
options={{
title: t("music.title"),
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Tab
initialRouteName='suggestions'
keyboardDismissMode='none'
screenOptions={{
tabBarBounces: true,
tabBarLabelStyle: {
fontSize: TAB_LABEL_FONT_SIZE,
fontWeight: "600",
flexWrap: "nowrap",
},
tabBarItemStyle: {
width: "auto",
paddingHorizontal: TAB_ITEM_HORIZONTAL_PADDING,
},
tabBarStyle: { backgroundColor: "black" },
animationEnabled: true,
lazy: true,
swipeEnabled: true,
tabBarIndicatorStyle: { backgroundColor: "#9334E9" },
tabBarScrollEnabled: true,
}}
>
<Tab.Screen
name='suggestions'
initialParams={{ libraryId }}
options={{ title: t("music.tabs.suggestions") }}
/>
<Tab.Screen
name='albums'
initialParams={{ libraryId }}
options={{ title: t("music.tabs.albums") }}
/>
<Tab.Screen
name='artists'
initialParams={{ libraryId }}
options={{ title: t("music.tabs.artists") }}
/>
<Tab.Screen
name='playlists'
initialParams={{ libraryId }}
options={{ title: t("music.tabs.playlists") }}
/>
</Tab>
</>
);
};
export default Layout;

View File

@@ -1,120 +0,0 @@
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useRoute } from "@react-navigation/native";
import { FlashList } from "@shopify/flash-list";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { RefreshControl, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { MusicAlbumRowCard } from "@/components/music/MusicAlbumRowCard";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
const ITEMS_PER_PAGE = 40;
export default function AlbumsScreen() {
const localParams = useLocalSearchParams<{ libraryId?: string | string[] }>();
const route = useRoute<any>();
const libraryId =
(Array.isArray(localParams.libraryId)
? localParams.libraryId[0]
: localParams.libraryId) ?? route?.params?.libraryId;
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const {
data,
isLoading,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
refetch,
} = useInfiniteQuery({
queryKey: ["music-albums", libraryId, user?.Id],
queryFn: async ({ pageParam = 0 }) => {
const response = await getItemsApi(api!).getItems({
userId: user?.Id,
parentId: libraryId,
includeItemTypes: ["MusicAlbum"],
sortBy: ["SortName"],
sortOrder: ["Ascending"],
limit: ITEMS_PER_PAGE,
startIndex: pageParam,
recursive: true,
});
return {
items: response.data.Items || [],
totalCount: response.data.TotalRecordCount || 0,
startIndex: pageParam,
};
},
getNextPageParam: (lastPage) => {
const nextStart = lastPage.startIndex + ITEMS_PER_PAGE;
return nextStart < lastPage.totalCount ? nextStart : undefined;
},
initialPageParam: 0,
enabled: !!api && !!user?.Id && !!libraryId,
});
const albums = useMemo(() => {
return data?.pages.flatMap((page) => page.items) || [];
}, [data]);
const handleEndReached = useCallback(() => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
if (isLoading) {
return (
<View className='flex-1 justify-center items-center bg-black'>
<Loader />
</View>
);
}
if (albums.length === 0) {
return (
<View className='flex-1 justify-center items-center bg-black'>
<Text className='text-neutral-500'>{t("music.no_albums")}</Text>
</View>
);
}
return (
<View className='flex-1 bg-black'>
<FlashList
data={albums}
contentContainerStyle={{
paddingBottom: insets.bottom + 100,
paddingTop: 8,
paddingHorizontal: 16,
}}
refreshControl={
<RefreshControl
refreshing={false}
onRefresh={refetch}
tintColor='#9334E9'
/>
}
onEndReached={handleEndReached}
onEndReachedThreshold={0.5}
renderItem={({ item }) => <MusicAlbumRowCard album={item} />}
keyExtractor={(item) => item.Id!}
ListFooterComponent={
isFetchingNextPage ? (
<View className='py-4'>
<Loader />
</View>
) : null
}
/>
</View>
);
}

View File

@@ -1,157 +0,0 @@
import { getArtistsApi } from "@jellyfin/sdk/lib/utils/api";
import { useRoute } from "@react-navigation/native";
import { FlashList } from "@shopify/flash-list";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { RefreshControl, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { MusicArtistCard } from "@/components/music/MusicArtistCard";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
// Web uses Limit=100
const ITEMS_PER_PAGE = 100;
export default function ArtistsScreen() {
const localParams = useLocalSearchParams<{ libraryId?: string | string[] }>();
const route = useRoute<any>();
const libraryId =
(Array.isArray(localParams.libraryId)
? localParams.libraryId[0]
: localParams.libraryId) ?? route?.params?.libraryId;
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const isReady = Boolean(api && user?.Id && libraryId);
const {
data,
isLoading,
isError,
error,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
refetch,
} = useInfiniteQuery({
queryKey: ["music-artists", libraryId, user?.Id],
queryFn: async ({ pageParam = 0 }) => {
const response = await getArtistsApi(api!).getArtists({
userId: user?.Id,
parentId: libraryId,
sortBy: ["SortName"],
sortOrder: ["Ascending"],
fields: ["PrimaryImageAspectRatio", "SortName"],
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
limit: ITEMS_PER_PAGE,
startIndex: pageParam,
});
return {
items: response.data.Items || [],
totalCount: response.data.TotalRecordCount || 0,
startIndex: pageParam,
};
},
getNextPageParam: (lastPage) => {
const nextStart = lastPage.startIndex + ITEMS_PER_PAGE;
return nextStart < lastPage.totalCount ? nextStart : undefined;
},
initialPageParam: 0,
enabled: isReady,
});
const artists = useMemo(() => {
return data?.pages.flatMap((page) => page.items) || [];
}, [data]);
const handleEndReached = useCallback(() => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
if (!api || !user?.Id) {
return (
<View className='flex-1 justify-center items-center bg-black'>
<Loader />
</View>
);
}
if (!libraryId) {
return (
<View className='flex-1 justify-center items-center bg-black px-6'>
<Text className='text-neutral-500 text-center'>
Missing music library id.
</Text>
</View>
);
}
// Only show loading if we have no cached data to display
if (isLoading && artists.length === 0) {
return (
<View className='flex-1 justify-center items-center bg-black'>
<Loader />
</View>
);
}
// Only show error if we have no cached data to display
// This allows offline access to previously cached artists
if (isError && artists.length === 0) {
return (
<View className='flex-1 justify-center items-center bg-black px-6'>
<Text className='text-neutral-500 text-center'>
Failed to load artists: {(error as Error)?.message || "Unknown error"}
</Text>
</View>
);
}
if (artists.length === 0) {
return (
<View className='flex-1 justify-center items-center bg-black'>
<Text className='text-neutral-500'>{t("music.no_artists")}</Text>
</View>
);
}
return (
<View className='flex-1 bg-black'>
<FlashList
data={artists}
contentContainerStyle={{
paddingBottom: insets.bottom + 100,
paddingTop: 8,
paddingHorizontal: 16,
}}
refreshControl={
<RefreshControl
refreshing={false}
onRefresh={refetch}
tintColor='#9334E9'
/>
}
onEndReached={handleEndReached}
onEndReachedThreshold={0.5}
renderItem={({ item }) => <MusicArtistCard artist={item} />}
keyExtractor={(item) => item.Id!}
ListFooterComponent={
isFetchingNextPage ? (
<View className='py-4'>
<Loader />
</View>
) : null
}
/>
</View>
);
}

View File

@@ -1,234 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useNavigation, useRoute } from "@react-navigation/native";
import { FlashList } from "@shopify/flash-list";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useLayoutEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { RefreshControl, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
import { MusicPlaylistCard } from "@/components/music/MusicPlaylistCard";
import {
type PlaylistSortOption,
type PlaylistSortOrder,
PlaylistSortSheet,
} from "@/components/music/PlaylistSortSheet";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
const ITEMS_PER_PAGE = 40;
export default function PlaylistsScreen() {
const localParams = useLocalSearchParams<{ libraryId?: string | string[] }>();
const route = useRoute<any>();
const navigation = useNavigation();
const libraryId =
(Array.isArray(localParams.libraryId)
? localParams.libraryId[0]
: localParams.libraryId) ?? route?.params?.libraryId;
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const [createModalOpen, setCreateModalOpen] = useState(false);
const [sortSheetOpen, setSortSheetOpen] = useState(false);
const [sortBy, setSortBy] = useState<PlaylistSortOption>("SortName");
const [sortOrder, setSortOrder] = useState<PlaylistSortOrder>("Ascending");
const isReady = Boolean(api && user?.Id && libraryId);
const handleSortChange = useCallback(
(newSortBy: PlaylistSortOption, newSortOrder: PlaylistSortOrder) => {
setSortBy(newSortBy);
setSortOrder(newSortOrder);
},
[],
);
useLayoutEffect(() => {
navigation.setOptions({
headerRight: () => (
<TouchableOpacity
onPress={() => setCreateModalOpen(true)}
className='mr-4'
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name='add' size={28} color='white' />
</TouchableOpacity>
),
});
}, [navigation]);
const {
data,
isLoading,
isError,
error,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
refetch,
} = useInfiniteQuery({
queryKey: ["music-playlists", libraryId, user?.Id, sortBy, sortOrder],
queryFn: async ({ pageParam = 0 }) => {
const response = await getItemsApi(api!).getItems({
userId: user?.Id,
includeItemTypes: ["Playlist"],
sortBy: [sortBy],
sortOrder: [sortOrder],
limit: ITEMS_PER_PAGE,
startIndex: pageParam,
recursive: true,
mediaTypes: ["Audio"],
});
return {
items: response.data.Items || [],
totalCount: response.data.TotalRecordCount || 0,
startIndex: pageParam,
};
},
getNextPageParam: (lastPage) => {
const nextStart = lastPage.startIndex + ITEMS_PER_PAGE;
return nextStart < lastPage.totalCount ? nextStart : undefined;
},
initialPageParam: 0,
enabled: isReady,
});
const playlists = useMemo(() => {
return data?.pages.flatMap((page) => page.items) || [];
}, [data]);
const handleEndReached = useCallback(() => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
if (!api || !user?.Id) {
return (
<View className='flex-1 justify-center items-center bg-black'>
<Loader />
</View>
);
}
if (!libraryId) {
return (
<View className='flex-1 justify-center items-center bg-black px-6'>
<Text className='text-neutral-500 text-center'>
Missing music library id.
</Text>
</View>
);
}
// Only show loading if we have no cached data to display
if (isLoading && playlists.length === 0) {
return (
<View className='flex-1 justify-center items-center bg-black'>
<Loader />
</View>
);
}
// Only show error if we have no cached data to display
// This allows offline access to previously cached playlists
if (isError && playlists.length === 0) {
return (
<View className='flex-1 justify-center items-center bg-black px-6'>
<Text className='text-neutral-500 text-center'>
Failed to load playlists:{" "}
{(error as Error)?.message || "Unknown error"}
</Text>
</View>
);
}
if (playlists.length === 0) {
return (
<View className='flex-1 justify-center items-center bg-black'>
<Text className='text-neutral-500 mb-4'>{t("music.no_playlists")}</Text>
<TouchableOpacity
onPress={() => setCreateModalOpen(true)}
className='flex-row items-center bg-purple-600 px-6 py-3 rounded-full'
>
<Ionicons name='add' size={20} color='white' />
<Text className='text-white font-semibold ml-2'>
{t("music.playlists.create_playlist")}
</Text>
</TouchableOpacity>
<CreatePlaylistModal
open={createModalOpen}
setOpen={setCreateModalOpen}
/>
</View>
);
}
return (
<View className='flex-1 bg-black'>
<FlashList
data={playlists}
contentContainerStyle={{
paddingBottom: insets.bottom + 100,
paddingTop: 8,
paddingHorizontal: 16,
}}
refreshControl={
<RefreshControl
refreshing={false}
onRefresh={refetch}
tintColor='#9334E9'
/>
}
onEndReached={handleEndReached}
onEndReachedThreshold={0.5}
ListHeaderComponent={
<TouchableOpacity
onPress={() => setSortSheetOpen(true)}
className='flex-row items-center mb-2 py-1'
>
<Ionicons name='swap-vertical' size={18} color='#9334E9' />
<Text className='text-purple-500 text-sm ml-1.5'>
{t(
`music.sort.${sortBy === "SortName" ? "alphabetical" : "date_created"}`,
)}
</Text>
<Ionicons
name={sortOrder === "Ascending" ? "arrow-up" : "arrow-down"}
size={14}
color='#9334E9'
style={{ marginLeft: 4 }}
/>
</TouchableOpacity>
}
renderItem={({ item }) => <MusicPlaylistCard playlist={item} />}
keyExtractor={(item) => item.Id!}
ListFooterComponent={
isFetchingNextPage ? (
<View className='py-4'>
<Loader />
</View>
) : null
}
/>
<CreatePlaylistModal
open={createModalOpen}
setOpen={setCreateModalOpen}
/>
<PlaylistSortSheet
open={sortSheetOpen}
setOpen={setSortSheetOpen}
sortBy={sortBy}
sortOrder={sortOrder}
onSortChange={handleSortChange}
/>
</View>
);
}

View File

@@ -1,333 +0,0 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useRoute } from "@react-navigation/native";
import { FlashList } from "@shopify/flash-list";
import { useQuery } from "@tanstack/react-query";
import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { RefreshControl, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { HorizontalScroll } from "@/components/common/HorizontalScroll";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
import { MusicAlbumCard } from "@/components/music/MusicAlbumCard";
import { MusicTrackItem } from "@/components/music/MusicTrackItem";
import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet";
import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { writeDebugLog } from "@/utils/log";
export default function SuggestionsScreen() {
const localParams = useLocalSearchParams<{ libraryId?: string | string[] }>();
const route = useRoute<any>();
const libraryId =
(Array.isArray(localParams.libraryId)
? localParams.libraryId[0]
: localParams.libraryId) ?? route?.params?.libraryId;
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const [selectedTrack, setSelectedTrack] = useState<BaseItemDto | null>(null);
const [trackOptionsOpen, setTrackOptionsOpen] = useState(false);
const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false);
const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false);
const handleTrackOptionsPress = useCallback((track: BaseItemDto) => {
setSelectedTrack(track);
setTrackOptionsOpen(true);
}, []);
const handleAddToPlaylist = useCallback(() => {
setPlaylistPickerOpen(true);
}, []);
const handleCreateNewPlaylist = useCallback(() => {
setCreatePlaylistOpen(true);
}, []);
const isReady = Boolean(api && user?.Id && libraryId);
writeDebugLog("Music suggestions params", {
libraryId,
localParams,
routeParams: route?.params,
isReady,
});
// Latest audio - uses the same endpoint as web: /Users/{userId}/Items/Latest
// This returns the most recently added albums
const {
data: latestAlbums,
isLoading: loadingLatest,
isError: isLatestError,
error: latestError,
refetch: refetchLatest,
} = useQuery({
queryKey: ["music-latest", libraryId, user?.Id],
queryFn: async () => {
// Prefer the exact endpoint the Web client calls (HAR):
// /Users/{userId}/Items/Latest?IncludeItemTypes=Audio&ParentId=...
// IMPORTANT: must use api.get(...) (not axiosInstance.get(fullUrl)) so the auth header is attached.
const res = await api!.get<BaseItemDto[]>(
`/Users/${user!.Id}/Items/Latest`,
{
params: {
IncludeItemTypes: "Audio",
Limit: 20,
Fields: "PrimaryImageAspectRatio",
ParentId: libraryId,
ImageTypeLimit: 1,
EnableImageTypes: "Primary,Backdrop,Banner,Thumb",
EnableTotalRecordCount: false,
},
},
);
if (Array.isArray(res.data) && res.data.length > 0) {
return res.data;
}
// Fallback: ask for albums directly via /Items (more reliable across server variants)
const fallback = await getItemsApi(api!).getItems({
userId: user!.Id,
parentId: libraryId,
includeItemTypes: ["MusicAlbum"],
sortBy: ["DateCreated"],
sortOrder: ["Descending"],
limit: 20,
recursive: true,
fields: ["PrimaryImageAspectRatio", "SortName"],
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
enableTotalRecordCount: false,
});
return fallback.data.Items || [];
},
enabled: isReady,
});
// Recently played - matches web: SortBy=DatePlayed, Filters=IsPlayed
const {
data: recentlyPlayed,
isLoading: loadingRecentlyPlayed,
isError: isRecentlyPlayedError,
error: recentlyPlayedError,
refetch: refetchRecentlyPlayed,
} = useQuery({
queryKey: ["music-recently-played", libraryId, user?.Id],
queryFn: async () => {
const response = await getItemsApi(api!).getItems({
userId: user?.Id,
parentId: libraryId,
includeItemTypes: ["Audio"],
sortBy: ["DatePlayed"],
sortOrder: ["Descending"],
limit: 10,
recursive: true,
fields: ["PrimaryImageAspectRatio", "SortName"],
filters: ["IsPlayed"],
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
enableTotalRecordCount: false,
});
return response.data.Items || [];
},
enabled: isReady,
});
// Frequently played - matches web: SortBy=PlayCount, Filters=IsPlayed
const {
data: frequentlyPlayed,
isLoading: loadingFrequent,
isError: isFrequentError,
error: frequentError,
refetch: refetchFrequent,
} = useQuery({
queryKey: ["music-frequently-played", libraryId, user?.Id],
queryFn: async () => {
const response = await getItemsApi(api!).getItems({
userId: user?.Id,
parentId: libraryId,
includeItemTypes: ["Audio"],
sortBy: ["PlayCount"],
sortOrder: ["Descending"],
limit: 10,
recursive: true,
fields: ["PrimaryImageAspectRatio", "SortName"],
filters: ["IsPlayed"],
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
enableTotalRecordCount: false,
});
return response.data.Items || [];
},
enabled: isReady,
});
const isLoading = loadingLatest || loadingRecentlyPlayed || loadingFrequent;
const handleRefresh = useCallback(() => {
refetchLatest();
refetchRecentlyPlayed();
refetchFrequent();
}, [refetchLatest, refetchRecentlyPlayed, refetchFrequent]);
const sections = useMemo(() => {
const result: {
title: string;
data: BaseItemDto[];
type: "albums" | "tracks";
}[] = [];
// Latest albums section
if (latestAlbums && latestAlbums.length > 0) {
result.push({
title: t("music.recently_added"),
data: latestAlbums,
type: "albums",
});
}
// Recently played tracks
if (recentlyPlayed && recentlyPlayed.length > 0) {
result.push({
title: t("music.recently_played"),
data: recentlyPlayed,
type: "tracks",
});
}
// Frequently played tracks
if (frequentlyPlayed && frequentlyPlayed.length > 0) {
result.push({
title: t("music.frequently_played"),
data: frequentlyPlayed,
type: "tracks",
});
}
return result;
}, [latestAlbums, frequentlyPlayed, recentlyPlayed, t]);
if (!api || !user?.Id) {
return (
<View className='flex-1 justify-center items-center bg-black'>
<Loader />
</View>
);
}
if (!libraryId) {
return (
<View className='flex-1 justify-center items-center bg-black px-6'>
<Text className='text-neutral-500 text-center'>
Missing music library id.
</Text>
</View>
);
}
// Only show loading if we have no cached data to display
if (isLoading && sections.length === 0) {
return (
<View className='flex-1 justify-center items-center bg-black'>
<Loader />
</View>
);
}
// Only show error if we have no cached data to display
// This allows offline access to previously cached suggestions
if (
(isLatestError || isRecentlyPlayedError || isFrequentError) &&
sections.length === 0
) {
const msg =
(latestError as Error | undefined)?.message ||
(recentlyPlayedError as Error | undefined)?.message ||
(frequentError as Error | undefined)?.message ||
"Unknown error";
return (
<View className='flex-1 justify-center items-center bg-black px-6'>
<Text className='text-neutral-500 text-center'>
Failed to load music: {msg}
</Text>
</View>
);
}
if (sections.length === 0) {
return (
<View className='flex-1 justify-center items-center bg-black'>
<Text className='text-neutral-500'>{t("music.no_suggestions")}</Text>
</View>
);
}
return (
<View className='flex-1 bg-black'>
<FlashList
data={sections}
contentContainerStyle={{
paddingBottom: insets.bottom + 100,
paddingTop: 16,
}}
refreshControl={
<RefreshControl
refreshing={false}
onRefresh={handleRefresh}
tintColor='#9334E9'
/>
}
renderItem={({ item: section }) => (
<View className='mb-6'>
<Text className='text-lg font-bold px-4 mb-3'>{section.title}</Text>
{section.type === "albums" ? (
<HorizontalScroll
data={section.data}
height={178}
keyExtractor={(item) => item.Id!}
renderItem={(item) => <MusicAlbumCard album={item} />}
/>
) : (
section.data
.slice(0, 5)
.map((track, index, _tracks) => (
<MusicTrackItem
key={track.Id}
track={track}
index={index + 1}
queue={section.data}
onOptionsPress={handleTrackOptionsPress}
/>
))
)}
</View>
)}
keyExtractor={(item) => item.title}
/>
<TrackOptionsSheet
open={trackOptionsOpen}
setOpen={setTrackOptionsOpen}
track={selectedTrack}
onAddToPlaylist={handleAddToPlaylist}
/>
<PlaylistPickerSheet
open={playlistPickerOpen}
setOpen={setPlaylistPickerOpen}
trackToAdd={selectedTrack}
onCreateNew={handleCreateNewPlaylist}
/>
<CreatePlaylistModal
open={createPlaylistOpen}
setOpen={setCreatePlaylistOpen}
initialTrackId={selectedTrack?.Id}
/>
</View>
);
}

View File

@@ -14,8 +14,12 @@ export default function SearchLayout() {
name='index'
options={{
headerShown: !Platform.isTV,
headerLargeTitle: true,
headerTitle: t("tabs.search"),
headerBlurEffect: "none",
headerLargeStyle: {
backgroundColor: "black",
},
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
}}
@@ -28,7 +32,7 @@ export default function SearchLayout() {
options={{
title: "",
headerShown: !Platform.isTV,
headerBlurEffect: "none",
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
}}

View File

@@ -3,10 +3,8 @@ import type {
BaseItemKind,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useAsyncDebouncer } from "@tanstack/react-pacer";
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import { Image } from "expo-image";
import { router, useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import {
@@ -21,10 +19,13 @@ import {
import { useTranslation } from "react-i18next";
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useDebounce } from "use-debounce";
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,
@@ -32,16 +33,12 @@ 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";
import { eventBus } from "@/utils/eventBus";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { createStreamystatsApi } from "@/utils/streamystats";
type SearchType = "Library" | "Discover";
@@ -70,23 +67,7 @@ export default function search() {
const [searchType, setSearchType] = useState<SearchType>("Library");
const [search, setSearch] = useState<string>("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const abortControllerRef = useRef<AbortController | null>(null);
const searchDebouncer = useAsyncDebouncer(
async (query: string) => {
// Cancel previous in-flight requests
abortControllerRef.current?.abort();
abortControllerRef.current = new AbortController();
setDebouncedSearch(query);
return query;
},
{ wait: 200 },
);
useEffect(() => {
searchDebouncer.maybeExecute(search);
}, [search]);
const [debouncedSearch] = useDebounce(search, 500);
const [api] = useAtom(apiAtom);
@@ -116,11 +97,9 @@ export default function search() {
async ({
types,
query,
signal,
}: {
types: BaseItemKind[];
query: string;
signal?: AbortSignal;
}): Promise<BaseItemDto[]> => {
if (!api || !query) {
return [];
@@ -128,71 +107,16 @@ export default function search() {
try {
if (searchEngine === "Jellyfin") {
const searchApi = await getItemsApi(api).getItems(
{
searchTerm: query,
limit: 10,
includeItemTypes: types,
recursive: true,
userId: user?.Id,
},
{ signal },
);
const searchApi = await getItemsApi(api).getItems({
searchTerm: query,
limit: 10,
includeItemTypes: types,
recursive: true,
userId: user?.Id,
});
return (searchApi.data.Items as BaseItemDto[]) || [];
}
if (searchEngine === "Streamystats") {
if (!settings?.streamyStatsServerUrl || !api.accessToken) {
return [];
}
const streamyStatsApi = createStreamystatsApi({
serverUrl: settings.streamyStatsServerUrl,
jellyfinToken: api.accessToken,
});
const typeMap: Record<BaseItemKind, string> = {
Movie: "movies",
Series: "series",
Episode: "episodes",
Person: "actors",
BoxSet: "movies",
Audio: "audio",
} as Record<BaseItemKind, string>;
const searchType = types.length === 1 ? typeMap[types[0]] : "media";
const response = await streamyStatsApi.searchIds(
query,
searchType as "movies" | "series" | "episodes" | "actors" | "media",
10,
signal,
);
const allIds: string[] = [
...(response.data.movies || []),
...(response.data.series || []),
...(response.data.episodes || []),
...(response.data.actors || []),
...(response.data.audio || []),
];
if (!allIds.length) {
return [];
}
const itemsResponse = await getItemsApi(api).getItems(
{
ids: allIds,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
},
{ signal },
);
return (itemsResponse.data.Items as BaseItemDto[]) || [];
}
// Marlin search
if (!settings?.marlinServerUrl) {
return [];
}
@@ -203,7 +127,7 @@ export default function search() {
.map((type) => encodeURIComponent(type))
.join("&includeItemTypes=")}`;
const response1 = await axios.get(url, { signal });
const response1 = await axios.get(url);
const ids = response1.data.ids;
@@ -211,63 +135,18 @@ export default function search() {
return [];
}
const response2 = await getItemsApi(api).getItems(
{
ids,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
},
{ signal },
);
const response2 = await getItemsApi(api).getItems({
ids,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
});
return (response2.data.Items as BaseItemDto[]) || [];
} catch (error) {
// Silently handle aborted requests
if (error instanceof Error && error.name === "AbortError") {
return [];
}
return [];
console.error("Error during search:", error);
return []; // Ensure an empty array is returned in case of an error
}
},
[api, searchEngine, settings, user?.Id],
);
// Separate search function for music types - always uses Jellyfin since Streamystats doesn't support music
const jellyfinSearchFn = useCallback(
async ({
types,
query,
signal,
}: {
types: BaseItemKind[];
query: string;
signal?: AbortSignal;
}): Promise<BaseItemDto[]> => {
if (!api || !query) {
return [];
}
try {
const searchApi = await getItemsApi(api).getItems(
{
searchTerm: query,
limit: 10,
includeItemTypes: types,
recursive: true,
userId: user?.Id,
},
{ signal },
);
return (searchApi.data.Items as BaseItemDto[]) || [];
} catch (error) {
// Silently handle aborted requests
if (error instanceof Error && error.name === "AbortError") {
return [];
}
return [];
}
},
[api, user?.Id],
[api, searchEngine, settings],
);
type HeaderSearchBarRef = {
@@ -316,7 +195,6 @@ export default function search() {
searchFn({
query: debouncedSearch,
types: ["Movie"],
signal: abortControllerRef.current?.signal,
}),
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
@@ -327,7 +205,6 @@ export default function search() {
searchFn({
query: debouncedSearch,
types: ["Series"],
signal: abortControllerRef.current?.signal,
}),
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
@@ -338,7 +215,6 @@ export default function search() {
searchFn({
query: debouncedSearch,
types: ["Episode"],
signal: abortControllerRef.current?.signal,
}),
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
@@ -349,7 +225,6 @@ export default function search() {
searchFn({
query: debouncedSearch,
types: ["BoxSet"],
signal: abortControllerRef.current?.signal,
}),
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
@@ -360,52 +235,6 @@ export default function search() {
searchFn({
query: debouncedSearch,
types: ["Person"],
signal: abortControllerRef.current?.signal,
}),
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
// Music search queries - always use Jellyfin since Streamystats doesn't support music
const { data: artists, isFetching: l9 } = useQuery({
queryKey: ["search", "artists", debouncedSearch],
queryFn: () =>
jellyfinSearchFn({
query: debouncedSearch,
types: ["MusicArtist"],
signal: abortControllerRef.current?.signal,
}),
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
const { data: albums, isFetching: l10 } = useQuery({
queryKey: ["search", "albums", debouncedSearch],
queryFn: () =>
jellyfinSearchFn({
query: debouncedSearch,
types: ["MusicAlbum"],
signal: abortControllerRef.current?.signal,
}),
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
const { data: songs, isFetching: l11 } = useQuery({
queryKey: ["search", "songs", debouncedSearch],
queryFn: () =>
jellyfinSearchFn({
query: debouncedSearch,
types: ["Audio"],
signal: abortControllerRef.current?.signal,
}),
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
const { data: playlists, isFetching: l12 } = useQuery({
queryKey: ["search", "playlists", debouncedSearch],
queryFn: () =>
jellyfinSearchFn({
query: debouncedSearch,
types: ["Playlist"],
signal: abortControllerRef.current?.signal,
}),
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
@@ -416,27 +245,13 @@ export default function search() {
episodes?.length ||
series?.length ||
collections?.length ||
actors?.length ||
artists?.length ||
albums?.length ||
songs?.length ||
playlists?.length
actors?.length
);
}, [
episodes,
movies,
series,
collections,
actors,
artists,
albums,
songs,
playlists,
]);
}, [episodes, movies, series, collections, actors]);
const loading = useMemo(() => {
return l1 || l2 || l3 || l7 || l8 || l9 || l10 || l11 || l12;
}, [l1, l2, l3, l7, l8, l9, l10, l11, l12]);
return l1 || l2 || l3 || l7 || l8;
}, [l1, l2, l3, l7, l8]);
return (
<ScrollView
@@ -445,7 +260,6 @@ export default function search() {
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 60,
}}
>
{/* <View
@@ -470,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'>
@@ -584,172 +435,6 @@ export default function search() {
</TouchableItemRouter>
)}
/>
{/* Music search results */}
<SearchItemWrapper
items={artists}
header={t("search.artists")}
renderItem={(item: BaseItemDto) => {
const imageUrl = getPrimaryImageUrl({ api, item });
return (
<TouchableItemRouter
item={item}
key={item.Id}
className='flex flex-col w-24 mr-2 items-center'
>
<View
style={{
width: 80,
height: 80,
borderRadius: 40,
overflow: "hidden",
backgroundColor: "#1a1a1a",
}}
>
{imageUrl ? (
<Image
source={{ uri: imageUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
) : (
<View className='flex-1 items-center justify-center bg-neutral-800'>
<Text className='text-xl'>👤</Text>
</View>
)}
</View>
<Text numberOfLines={2} className='mt-2 text-center'>
{item.Name}
</Text>
</TouchableItemRouter>
);
}}
/>
<SearchItemWrapper
items={albums}
header={t("search.albums")}
renderItem={(item: BaseItemDto) => {
const imageUrl = getPrimaryImageUrl({ api, item });
return (
<TouchableItemRouter
item={item}
key={item.Id}
className='flex flex-col w-28 mr-2'
>
<View
style={{
width: 112,
height: 112,
borderRadius: 8,
overflow: "hidden",
backgroundColor: "#1a1a1a",
}}
>
{imageUrl ? (
<Image
source={{ uri: imageUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
) : (
<View className='flex-1 items-center justify-center bg-neutral-800'>
<Text className='text-4xl'>🎵</Text>
</View>
)}
</View>
<Text numberOfLines={2} className='mt-2'>
{item.Name}
</Text>
<Text className='opacity-50 text-xs' numberOfLines={1}>
{item.AlbumArtist || item.Artists?.join(", ")}
</Text>
</TouchableItemRouter>
);
}}
/>
<SearchItemWrapper
items={songs}
header={t("search.songs")}
renderItem={(item: BaseItemDto) => {
const imageUrl = getPrimaryImageUrl({ api, item });
return (
<TouchableItemRouter
item={item}
key={item.Id}
className='flex flex-col w-28 mr-2'
>
<View
style={{
width: 112,
height: 112,
borderRadius: 8,
overflow: "hidden",
backgroundColor: "#1a1a1a",
}}
>
{imageUrl ? (
<Image
source={{ uri: imageUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
) : (
<View className='flex-1 items-center justify-center bg-neutral-800'>
<Text className='text-4xl'>🎵</Text>
</View>
)}
</View>
<Text numberOfLines={2} className='mt-2'>
{item.Name}
</Text>
<Text className='opacity-50 text-xs' numberOfLines={1}>
{item.Artists?.join(", ") || item.AlbumArtist}
</Text>
</TouchableItemRouter>
);
}}
/>
<SearchItemWrapper
items={playlists}
header={t("search.playlists")}
renderItem={(item: BaseItemDto) => {
const imageUrl = getPrimaryImageUrl({ api, item });
return (
<TouchableItemRouter
item={item}
key={item.Id}
className='flex flex-col w-28 mr-2'
>
<View
style={{
width: 112,
height: 112,
borderRadius: 8,
overflow: "hidden",
backgroundColor: "#1a1a1a",
}}
>
{imageUrl ? (
<Image
source={{ uri: imageUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
) : (
<View className='flex-1 items-center justify-center bg-neutral-800'>
<Text className='text-4xl'>🎶</Text>
</View>
)}
</View>
<Text numberOfLines={2} className='mt-2'>
{item.Name}
</Text>
<Text className='opacity-50 text-xs'>
{item.ChildCount} tracks
</Text>
</TouchableItemRouter>
);
}}
/>
</View>
) : (
<JellyserrIndexPage

View File

@@ -1,297 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { FlashList } from "@shopify/flash-list";
import { useLocalSearchParams, useNavigation, useRouter } from "expo-router";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Alert,
RefreshControl,
TouchableOpacity,
useWindowDimensions,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { ItemCardText } from "@/components/ItemCardText";
import { ItemPoster } from "@/components/posters/ItemPoster";
import { useOrientation } from "@/hooks/useOrientation";
import {
useDeleteWatchlist,
useRemoveFromWatchlist,
} from "@/hooks/useWatchlistMutations";
import {
useWatchlistDetailQuery,
useWatchlistItemsQuery,
} from "@/hooks/useWatchlists";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { userAtom } from "@/providers/JellyfinProvider";
export default function WatchlistDetailScreen() {
const { t } = useTranslation();
const router = useRouter();
const navigation = useNavigation();
const insets = useSafeAreaInsets();
const { watchlistId } = useLocalSearchParams<{ watchlistId: string }>();
const user = useAtomValue(userAtom);
const { width: screenWidth } = useWindowDimensions();
const { orientation } = useOrientation();
const watchlistIdNum = watchlistId
? Number.parseInt(watchlistId, 10)
: undefined;
const nrOfCols = useMemo(() => {
if (screenWidth < 300) return 2;
if (screenWidth < 500) return 3;
if (screenWidth < 800) return 5;
if (screenWidth < 1000) return 6;
if (screenWidth < 1500) return 7;
return 6;
}, [screenWidth]);
const {
data: watchlist,
isLoading: watchlistLoading,
refetch: refetchWatchlist,
} = useWatchlistDetailQuery(watchlistIdNum);
const {
data: items,
isLoading: itemsLoading,
refetch: refetchItems,
} = useWatchlistItemsQuery(watchlistIdNum);
const deleteWatchlist = useDeleteWatchlist();
const removeFromWatchlist = useRemoveFromWatchlist();
const [refreshing, setRefreshing] = useState(false);
const isOwner = useMemo(
() => watchlist?.userId === user?.Id,
[watchlist?.userId, user?.Id],
);
// Set up header
useEffect(() => {
navigation.setOptions({
headerTitle: watchlist?.name || "",
headerLeft: () => <HeaderBackButton />,
headerRight: isOwner
? () => (
<View className='flex-row gap-2'>
<TouchableOpacity
onPress={() =>
router.push(`/(auth)/(tabs)/(watchlists)/edit/${watchlistId}`)
}
className='p-2'
>
<Ionicons name='pencil' size={20} color='white' />
</TouchableOpacity>
<TouchableOpacity onPress={handleDelete} className='p-2'>
<Ionicons name='trash-outline' size={20} color='#ef4444' />
</TouchableOpacity>
</View>
)
: undefined,
});
}, [navigation, watchlist?.name, isOwner, watchlistId]);
const handleRefresh = useCallback(async () => {
setRefreshing(true);
await Promise.all([refetchWatchlist(), refetchItems()]);
setRefreshing(false);
}, [refetchWatchlist, refetchItems]);
const handleDelete = useCallback(() => {
Alert.alert(
t("watchlists.delete_confirm_title"),
t("watchlists.delete_confirm_message", { name: watchlist?.name }),
[
{ text: t("watchlists.cancel_button"), style: "cancel" },
{
text: t("watchlists.delete_button"),
style: "destructive",
onPress: async () => {
if (watchlistIdNum) {
await deleteWatchlist.mutateAsync(watchlistIdNum);
router.back();
}
},
},
],
);
}, [deleteWatchlist, watchlistIdNum, watchlist?.name, router, t]);
const handleRemoveItem = useCallback(
(item: BaseItemDto) => {
if (!watchlistIdNum || !item.Id) return;
Alert.alert(
t("watchlists.remove_item_title"),
t("watchlists.remove_item_message", { name: item.Name }),
[
{ text: t("watchlists.cancel_button"), style: "cancel" },
{
text: t("watchlists.remove_button"),
style: "destructive",
onPress: async () => {
await removeFromWatchlist.mutateAsync({
watchlistId: watchlistIdNum,
itemId: item.Id!,
watchlistName: watchlist?.name,
});
},
},
],
);
},
[removeFromWatchlist, watchlistIdNum, watchlist?.name, t],
);
const renderItem = useCallback(
({ item, index }: { item: BaseItemDto; index: number }) => (
<TouchableItemRouter
key={item.Id}
style={{
width: "100%",
marginBottom: 4,
}}
item={item}
onLongPress={isOwner ? () => handleRemoveItem(item) : undefined}
>
<View
style={{
alignSelf:
orientation === ScreenOrientation.OrientationLock.PORTRAIT_UP
? index % nrOfCols === 0
? "flex-end"
: (index + 1) % nrOfCols === 0
? "flex-start"
: "center"
: "center",
width: "89%",
}}
>
<ItemPoster item={item} />
<ItemCardText item={item} />
</View>
</TouchableItemRouter>
),
[isOwner, handleRemoveItem, orientation, nrOfCols],
);
const ListHeader = useMemo(
() =>
watchlist ? (
<View className='px-4 pt-4 pb-6 mb-4 border-b border-neutral-800'>
{watchlist.description && (
<Text className='text-neutral-400 mb-2'>
{watchlist.description}
</Text>
)}
<View className='flex-row items-center gap-4'>
<View className='flex-row items-center gap-1'>
<Ionicons name='film-outline' size={14} color='#9ca3af' />
<Text className='text-neutral-400 text-sm'>
{items?.length ?? 0}{" "}
{(items?.length ?? 0) === 1
? t("watchlists.item")
: t("watchlists.items")}
</Text>
</View>
<View className='flex-row items-center gap-1'>
<Ionicons
name={
watchlist.isPublic ? "globe-outline" : "lock-closed-outline"
}
size={14}
color='#9ca3af'
/>
<Text className='text-neutral-400 text-sm'>
{watchlist.isPublic
? t("watchlists.public")
: t("watchlists.private")}
</Text>
</View>
{!isOwner && (
<Text className='text-neutral-500 text-sm'>
{t("watchlists.by_owner")}
</Text>
)}
</View>
</View>
) : null,
[watchlist, items?.length, isOwner, t],
);
const EmptyComponent = useMemo(
() => (
<View className='flex-1 items-center justify-center px-8 py-16'>
<Ionicons name='film-outline' size={48} color='#4b5563' />
<Text className='text-neutral-400 text-center mt-4'>
{t("watchlists.empty_watchlist")}
</Text>
{isOwner && (
<Text className='text-neutral-500 text-center mt-2 text-sm'>
{t("watchlists.empty_watchlist_hint")}
</Text>
)}
</View>
),
[isOwner, t],
);
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
if (watchlistLoading || itemsLoading) {
return (
<View className='flex-1 items-center justify-center'>
<ActivityIndicator size='large' />
</View>
);
}
if (!watchlist) {
return (
<View className='flex-1 items-center justify-center px-8'>
<Text className='text-lg text-neutral-400'>
{t("watchlists.not_found")}
</Text>
</View>
);
}
return (
<FlashList
key={orientation}
data={items ?? []}
numColumns={nrOfCols}
contentInsetAdjustmentBehavior='automatic'
ListHeaderComponent={ListHeader}
ListEmptyComponent={EmptyComponent}
extraData={[orientation, nrOfCols]}
keyExtractor={keyExtractor}
contentContainerStyle={{
paddingBottom: 24,
paddingLeft: insets.left,
paddingRight: insets.right,
}}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
}
renderItem={renderItem}
ItemSeparatorComponent={() => (
<View
style={{
width: 10,
height: 10,
}}
/>
)}
/>
);
}

View File

@@ -1,75 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import { Stack, useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { Platform } from "react-native";
import { Pressable } from "react-native-gesture-handler";
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import { useStreamystatsEnabled } from "@/hooks/useWatchlists";
export default function WatchlistsLayout() {
const { t } = useTranslation();
const router = useRouter();
const streamystatsEnabled = useStreamystatsEnabled();
return (
<Stack>
<Stack.Screen
name='index'
options={{
headerShown: !Platform.isTV,
headerTitle: t("watchlists.title"),
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerRight: streamystatsEnabled
? () => (
<Pressable
onPress={() =>
router.push("/(auth)/(tabs)/(watchlists)/create")
}
className='p-1.5'
>
<Ionicons name='add' size={24} color='white' />
</Pressable>
)
: undefined,
}}
/>
<Stack.Screen
name='[watchlistId]'
options={{
title: "",
headerShown: true,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
}}
/>
<Stack.Screen
name='create'
options={{
title: t("watchlists.create_title"),
presentation: "modal",
headerShown: true,
headerStyle: { backgroundColor: "#171717" },
headerTintColor: "white",
contentStyle: { backgroundColor: "#171717" },
}}
/>
<Stack.Screen
name='edit/[watchlistId]'
options={{
title: t("watchlists.edit_title"),
presentation: "modal",
headerShown: true,
headerStyle: { backgroundColor: "#171717" },
headerTintColor: "white",
contentStyle: { backgroundColor: "#171717" },
}}
/>
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
<Stack.Screen key={name} name={name} options={options} />
))}
</Stack>
);
}

View File

@@ -1,221 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
KeyboardAvoidingView,
Platform,
ScrollView,
Switch,
TextInput,
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 { useCreateWatchlist } from "@/hooks/useWatchlistMutations";
import type {
StreamystatsWatchlistAllowedItemType,
StreamystatsWatchlistSortOrder,
} from "@/utils/streamystats/types";
const ITEM_TYPES: Array<{
value: StreamystatsWatchlistAllowedItemType;
label: string;
}> = [
{ value: null, label: "All Types" },
{ value: "Movie", label: "Movies Only" },
{ value: "Series", label: "Series Only" },
{ value: "Episode", label: "Episodes Only" },
];
const SORT_OPTIONS: Array<{
value: StreamystatsWatchlistSortOrder;
label: string;
}> = [
{ value: "custom", label: "Custom Order" },
{ value: "name", label: "Name" },
{ value: "dateAdded", label: "Date Added" },
{ value: "releaseDate", label: "Release Date" },
];
export default function CreateWatchlistScreen() {
const { t } = useTranslation();
const router = useRouter();
const insets = useSafeAreaInsets();
const createWatchlist = useCreateWatchlist();
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [isPublic, setIsPublic] = useState(false);
const [allowedItemType, setAllowedItemType] =
useState<StreamystatsWatchlistAllowedItemType>(null);
const [defaultSortOrder, setDefaultSortOrder] =
useState<StreamystatsWatchlistSortOrder>("custom");
const handleCreate = useCallback(async () => {
if (!name.trim()) return;
try {
await createWatchlist.mutateAsync({
name: name.trim(),
description: description.trim() || undefined,
isPublic,
allowedItemType,
defaultSortOrder,
});
router.back();
} catch {
// Error handled by mutation
}
}, [
name,
description,
isPublic,
allowedItemType,
defaultSortOrder,
createWatchlist,
router,
]);
return (
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
className='flex-1'
style={{ backgroundColor: "#171717" }}
>
<ScrollView
className='flex-1'
contentContainerStyle={{
paddingBottom: insets.bottom + 20,
}}
keyboardShouldPersistTaps='handled'
>
{/* Name */}
<View className='px-4 py-4'>
<Text className='text-sm font-medium text-neutral-400 mb-2'>
{t("watchlists.name_label")} *
</Text>
<TextInput
value={name}
onChangeText={setName}
placeholder={t("watchlists.name_placeholder")}
placeholderTextColor='#6b7280'
className='bg-neutral-800 text-white px-4 py-3 rounded-lg text-base'
autoFocus
/>
</View>
{/* Description */}
<View className='px-4 py-4'>
<Text className='text-sm font-medium text-neutral-400 mb-2'>
{t("watchlists.description_label")}
</Text>
<TextInput
value={description}
onChangeText={setDescription}
placeholder={t("watchlists.description_placeholder")}
placeholderTextColor='#6b7280'
className='bg-neutral-800 text-white px-4 py-3 rounded-lg text-base'
multiline
numberOfLines={3}
textAlignVertical='top'
style={{ minHeight: 80 }}
/>
</View>
{/* Public Toggle */}
<View className='px-4 py-4 flex-row items-center justify-between'>
<View className='flex-1 mr-4'>
<Text className='text-base font-medium text-white'>
{t("watchlists.is_public_label")}
</Text>
<Text className='text-sm text-neutral-400 mt-1'>
{t("watchlists.is_public_description")}
</Text>
</View>
<Switch
value={isPublic}
onValueChange={setIsPublic}
trackColor={{ false: "#374151", true: "#7c3aed" }}
thumbColor={isPublic ? "#a78bfa" : "#9ca3af"}
/>
</View>
{/* Content Type */}
<View className='px-4 py-4'>
<Text className='text-sm font-medium text-neutral-400 mb-2'>
{t("watchlists.allowed_type_label")}
</Text>
<View className='flex-row flex-wrap gap-2'>
{ITEM_TYPES.map((type) => (
<TouchableOpacity
key={type.value ?? "all"}
onPress={() => setAllowedItemType(type.value)}
className={`px-4 py-2 rounded-lg ${allowedItemType === type.value ? "bg-purple-600" : "bg-neutral-800"}`}
>
<Text
className={
allowedItemType === type.value
? "text-white font-medium"
: "text-neutral-300"
}
>
{type.label}
</Text>
</TouchableOpacity>
))}
</View>
</View>
{/* Sort Order */}
<View className='px-4 py-4'>
<Text className='text-sm font-medium text-neutral-400 mb-2'>
{t("watchlists.sort_order_label")}
</Text>
<View className='flex-row flex-wrap gap-2'>
{SORT_OPTIONS.map((sort) => (
<TouchableOpacity
key={sort.value}
onPress={() => setDefaultSortOrder(sort.value)}
className={`px-4 py-2 rounded-lg ${defaultSortOrder === sort.value ? "bg-purple-600" : "bg-neutral-800"}`}
>
<Text
className={
defaultSortOrder === sort.value
? "text-white font-medium"
: "text-neutral-300"
}
>
{sort.label}
</Text>
</TouchableOpacity>
))}
</View>
</View>
{/* Create Button */}
<View className='px-4 pt-4'>
<Button
onPress={handleCreate}
disabled={!name.trim() || createWatchlist.isPending}
className={`py-3 ${!name.trim() ? "opacity-50" : ""}`}
>
{createWatchlist.isPending ? (
<ActivityIndicator color='white' />
) : (
<View className='flex-row items-center'>
<Ionicons name='add' size={20} color='white' />
<Text className='text-white font-semibold text-base'>
{t("watchlists.create_button")}
</Text>
</View>
)}
</Button>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
}

View File

@@ -1,273 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
KeyboardAvoidingView,
Platform,
ScrollView,
Switch,
TextInput,
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 { useUpdateWatchlist } from "@/hooks/useWatchlistMutations";
import { useWatchlistDetailQuery } from "@/hooks/useWatchlists";
import type {
StreamystatsWatchlistAllowedItemType,
StreamystatsWatchlistSortOrder,
} from "@/utils/streamystats/types";
const ITEM_TYPES: Array<{
value: StreamystatsWatchlistAllowedItemType;
label: string;
}> = [
{ value: null, label: "All Types" },
{ value: "Movie", label: "Movies Only" },
{ value: "Series", label: "Series Only" },
{ value: "Episode", label: "Episodes Only" },
];
const SORT_OPTIONS: Array<{
value: StreamystatsWatchlistSortOrder;
label: string;
}> = [
{ value: "custom", label: "Custom Order" },
{ value: "name", label: "Name" },
{ value: "dateAdded", label: "Date Added" },
{ value: "releaseDate", label: "Release Date" },
];
export default function EditWatchlistScreen() {
const { t } = useTranslation();
const router = useRouter();
const insets = useSafeAreaInsets();
const { watchlistId } = useLocalSearchParams<{ watchlistId: string }>();
const watchlistIdNum = watchlistId
? Number.parseInt(watchlistId, 10)
: undefined;
const { data: watchlist, isLoading } =
useWatchlistDetailQuery(watchlistIdNum);
const updateWatchlist = useUpdateWatchlist();
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [isPublic, setIsPublic] = useState(false);
const [allowedItemType, setAllowedItemType] =
useState<StreamystatsWatchlistAllowedItemType>(null);
const [defaultSortOrder, setDefaultSortOrder] =
useState<StreamystatsWatchlistSortOrder>("custom");
// Initialize form with watchlist data
useEffect(() => {
if (watchlist) {
setName(watchlist.name);
setDescription(watchlist.description ?? "");
setIsPublic(watchlist.isPublic);
setAllowedItemType(
(watchlist.allowedItemType as StreamystatsWatchlistAllowedItemType) ??
null,
);
setDefaultSortOrder(
(watchlist.defaultSortOrder as StreamystatsWatchlistSortOrder) ??
"custom",
);
}
}, [watchlist]);
const handleSave = useCallback(async () => {
if (!name.trim() || !watchlistIdNum) return;
try {
await updateWatchlist.mutateAsync({
watchlistId: watchlistIdNum,
data: {
name: name.trim(),
description: description.trim() || undefined,
isPublic,
allowedItemType,
defaultSortOrder,
},
});
router.back();
} catch {
// Error handled by mutation
}
}, [
name,
description,
isPublic,
allowedItemType,
defaultSortOrder,
watchlistIdNum,
updateWatchlist,
router,
]);
if (isLoading) {
return (
<View
className='flex-1 items-center justify-center'
style={{ backgroundColor: "#171717" }}
>
<ActivityIndicator size='large' />
</View>
);
}
if (!watchlist) {
return (
<View
className='flex-1 items-center justify-center px-8'
style={{ backgroundColor: "#171717" }}
>
<Text className='text-lg text-neutral-400'>
{t("watchlists.not_found")}
</Text>
</View>
);
}
return (
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
className='flex-1'
style={{ backgroundColor: "#171717" }}
>
<ScrollView
className='flex-1'
contentContainerStyle={{
paddingBottom: insets.bottom + 20,
}}
keyboardShouldPersistTaps='handled'
>
{/* Name */}
<View className='px-4 py-4'>
<Text className='text-sm font-medium text-neutral-400 mb-2'>
{t("watchlists.name_label")} *
</Text>
<TextInput
value={name}
onChangeText={setName}
placeholder={t("watchlists.name_placeholder")}
placeholderTextColor='#6b7280'
className='bg-neutral-800 text-white px-4 py-3 rounded-lg text-base'
/>
</View>
{/* Description */}
<View className='px-4 py-4'>
<Text className='text-sm font-medium text-neutral-400 mb-2'>
{t("watchlists.description_label")}
</Text>
<TextInput
value={description}
onChangeText={setDescription}
placeholder={t("watchlists.description_placeholder")}
placeholderTextColor='#6b7280'
className='bg-neutral-800 text-white px-4 py-3 rounded-lg text-base'
multiline
numberOfLines={3}
textAlignVertical='top'
style={{ minHeight: 80 }}
/>
</View>
{/* Public Toggle */}
<View className='px-4 py-4 flex-row items-center justify-between'>
<View className='flex-1 mr-4'>
<Text className='text-base font-medium text-white'>
{t("watchlists.is_public_label")}
</Text>
<Text className='text-sm text-neutral-400 mt-1'>
{t("watchlists.is_public_description")}
</Text>
</View>
<Switch
value={isPublic}
onValueChange={setIsPublic}
trackColor={{ false: "#374151", true: "#7c3aed" }}
thumbColor={isPublic ? "#a78bfa" : "#9ca3af"}
/>
</View>
{/* Content Type */}
<View className='px-4 py-4'>
<Text className='text-sm font-medium text-neutral-400 mb-2'>
{t("watchlists.allowed_type_label")}
</Text>
<View className='flex-row flex-wrap gap-2'>
{ITEM_TYPES.map((type) => (
<TouchableOpacity
key={type.value ?? "all"}
onPress={() => setAllowedItemType(type.value)}
className={`px-4 py-2 rounded-lg ${allowedItemType === type.value ? "bg-purple-600" : "bg-neutral-800"}`}
>
<Text
className={
allowedItemType === type.value
? "text-white font-medium"
: "text-neutral-300"
}
>
{type.label}
</Text>
</TouchableOpacity>
))}
</View>
</View>
{/* Sort Order */}
<View className='px-4 py-4'>
<Text className='text-sm font-medium text-neutral-400 mb-2'>
{t("watchlists.sort_order_label")}
</Text>
<View className='flex-row flex-wrap gap-2'>
{SORT_OPTIONS.map((sort) => (
<TouchableOpacity
key={sort.value}
onPress={() => setDefaultSortOrder(sort.value)}
className={`px-4 py-2 rounded-lg ${defaultSortOrder === sort.value ? "bg-purple-600" : "bg-neutral-800"}`}
>
<Text
className={
defaultSortOrder === sort.value
? "text-white font-medium"
: "text-neutral-300"
}
>
{sort.label}
</Text>
</TouchableOpacity>
))}
</View>
</View>
{/* Save Button */}
<View className='px-4 pt-4'>
<Button
onPress={handleSave}
disabled={!name.trim() || updateWatchlist.isPending}
className={`py-3 ${!name.trim() ? "opacity-50" : ""}`}
>
{updateWatchlist.isPending ? (
<ActivityIndicator color='white' />
) : (
<View className='flex-row items-center'>
<Ionicons name='checkmark' size={20} color='white' />
<Text className='text-white font-semibold text-base'>
{t("watchlists.save_button")}
</Text>
</View>
)}
</Button>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
}

View File

@@ -1,239 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import { FlashList } from "@shopify/flash-list";
import { useRouter } from "expo-router";
import { useAtomValue } from "jotai";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, RefreshControl, 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 {
useStreamystatsEnabled,
useWatchlistsQuery,
} from "@/hooks/useWatchlists";
import { userAtom } from "@/providers/JellyfinProvider";
import type { StreamystatsWatchlist } from "@/utils/streamystats/types";
interface WatchlistCardProps {
watchlist: StreamystatsWatchlist;
isOwner: boolean;
onPress: () => void;
}
const WatchlistCard: React.FC<WatchlistCardProps> = ({
watchlist,
isOwner,
onPress,
}) => {
const { t } = useTranslation();
return (
<TouchableOpacity
onPress={onPress}
className='bg-neutral-900 rounded-xl p-4 mx-4 mb-3'
activeOpacity={0.7}
>
<View className='flex-row items-center justify-between mb-2'>
<Text className='text-lg font-semibold flex-1' numberOfLines={1}>
{watchlist.name}
</Text>
<View className='flex-row items-center gap-2'>
{isOwner && (
<View className='bg-purple-600/20 px-2 py-1 rounded'>
<Text className='text-purple-400 text-xs'>
{t("watchlists.you")}
</Text>
</View>
)}
<Ionicons
name={watchlist.isPublic ? "globe-outline" : "lock-closed-outline"}
size={16}
color='#9ca3af'
/>
</View>
</View>
{watchlist.description && (
<Text className='text-neutral-400 text-sm mb-2' numberOfLines={2}>
{watchlist.description}
</Text>
)}
<View className='flex-row items-center gap-4'>
<View className='flex-row items-center gap-1'>
<Ionicons name='film-outline' size={14} color='#9ca3af' />
<Text className='text-neutral-400 text-sm'>
{watchlist.itemCount ?? 0}{" "}
{(watchlist.itemCount ?? 0) === 1
? t("watchlists.item")
: t("watchlists.items")}
</Text>
</View>
{watchlist.allowedItemType && (
<View className='bg-neutral-800 px-2 py-0.5 rounded'>
<Text className='text-neutral-400 text-xs'>
{watchlist.allowedItemType}
</Text>
</View>
)}
</View>
</TouchableOpacity>
);
};
const EmptyState: React.FC<{ onCreatePress: () => void }> = ({
onCreatePress: _onCreatePress,
}) => {
const { t } = useTranslation();
return (
<View className='flex-1 items-center justify-center px-8'>
<Ionicons name='list-outline' size={64} color='#4b5563' />
<Text className='text-xl font-semibold mt-4 text-center'>
{t("watchlists.empty_title")}
</Text>
<Text className='text-neutral-400 text-center mt-2 mb-6'>
{t("watchlists.empty_description")}
</Text>
</View>
);
};
const NotConfiguredState: React.FC = () => {
const { t } = useTranslation();
const router = useRouter();
return (
<View className='flex-1 items-center justify-center px-8'>
<Ionicons name='settings-outline' size={64} color='#4b5563' />
<Text className='text-xl font-semibold mt-4 text-center'>
{t("watchlists.not_configured_title")}
</Text>
<Text className='text-neutral-400 text-center mt-2 mb-6'>
{t("watchlists.not_configured_description")}
</Text>
<Button
onPress={() =>
router.push(
"/(auth)/(tabs)/(home)/settings/plugins/streamystats/page",
)
}
className='px-6'
>
<Text className='font-semibold'>{t("watchlists.go_to_settings")}</Text>
</Button>
</View>
);
};
export default function WatchlistsScreen() {
const { t } = useTranslation();
const router = useRouter();
const insets = useSafeAreaInsets();
const user = useAtomValue(userAtom);
const streamystatsEnabled = useStreamystatsEnabled();
const { data: watchlists, isLoading, refetch } = useWatchlistsQuery();
const [refreshing, setRefreshing] = useState(false);
const handleRefresh = useCallback(async () => {
setRefreshing(true);
await refetch();
setRefreshing(false);
}, [refetch]);
const handleCreatePress = useCallback(() => {
router.push("/(auth)/(tabs)/(watchlists)/create");
}, [router]);
const handleWatchlistPress = useCallback(
(watchlistId: number) => {
router.push(`/(auth)/(tabs)/(watchlists)/${watchlistId}`);
},
[router],
);
// Separate watchlists into "mine" and "public"
const { myWatchlists, publicWatchlists } = useMemo(() => {
if (!watchlists) return { myWatchlists: [], publicWatchlists: [] };
const mine: StreamystatsWatchlist[] = [];
const pub: StreamystatsWatchlist[] = [];
for (const w of watchlists) {
if (w.userId === user?.Id) {
mine.push(w);
} else {
pub.push(w);
}
}
return { myWatchlists: mine, publicWatchlists: pub };
}, [watchlists, user?.Id]);
// Combine into sections for FlashList
const sections = useMemo(() => {
const result: Array<
| { type: "header"; title: string }
| { type: "watchlist"; data: StreamystatsWatchlist; isOwner: boolean }
> = [];
if (myWatchlists.length > 0) {
result.push({ type: "header", title: t("watchlists.my_watchlists") });
for (const w of myWatchlists) {
result.push({ type: "watchlist", data: w, isOwner: true });
}
}
if (publicWatchlists.length > 0) {
result.push({ type: "header", title: t("watchlists.public_watchlists") });
for (const w of publicWatchlists) {
result.push({ type: "watchlist", data: w, isOwner: false });
}
}
return result;
}, [myWatchlists, publicWatchlists, t]);
if (!streamystatsEnabled) {
return <NotConfiguredState />;
}
if (!isLoading && (!watchlists || watchlists.length === 0)) {
return <EmptyState onCreatePress={handleCreatePress} />;
}
return (
<FlashList
data={sections}
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingTop: Platform.OS === "android" ? 10 : 0,
paddingBottom: 100,
paddingLeft: insets.left,
paddingRight: insets.right,
}}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
}
renderItem={({ item }) => {
if (item.type === "header") {
return (
<Text className='text-lg font-bold px-4 pt-4 pb-2'>
{item.title}
</Text>
);
}
return (
<WatchlistCard
watchlist={item.data}
isOwner={item.isOwner}
onPress={() => handleWatchlistPress(item.data.id)}
/>
);
}}
getItemType={(item) => item.type}
/>
);
}

View File

@@ -7,15 +7,15 @@ import type {
ParamListBase,
TabNavigationState,
} from "@react-navigation/native";
import { withLayoutContext } from "expo-router";
import { useFocusEffect, useRouter, withLayoutContext } from "expo-router";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native";
import { Platform } from "react-native";
import { SystemBars } from "react-native-edge-to-edge";
import { MiniPlayerBar } from "@/components/music/MiniPlayerBar";
import { MusicPlaybackEngine } from "@/components/music/MusicPlaybackEngine";
import { Colors } from "@/constants/Colors";
import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus";
import { storage } from "@/utils/mmkv";
const { Navigator } = createNativeBottomTabNavigator();
@@ -29,9 +29,25 @@ export const NativeTabs = withLayoutContext<
export default function TabLayout() {
const { settings } = useSettings();
const { t } = useTranslation();
const router = useRouter();
useFocusEffect(
useCallback(() => {
const hasShownIntro = storage.getBoolean("hasShownIntro");
if (!hasShownIntro) {
const timer = setTimeout(() => {
router.push("/intro/page");
}, 1000);
return () => {
clearTimeout(timer);
};
}
}, []),
);
return (
<View style={{ flex: 1 }}>
<>
<SystemBars hidden={false} style='light' />
<NativeTabs
sidebarAdaptable={false}
@@ -39,7 +55,6 @@ export default function TabLayout() {
backgroundColor: "#121212",
}}
tabBarActiveTintColor={Colors.primary}
activeIndicatorColor={"#392c3b"}
scrollEdgeAppearance='default'
>
<NativeTabs.Screen redirect name='index' />
@@ -55,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
@@ -66,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
@@ -80,20 +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" }),
}}
/>
<NativeTabs.Screen
name='(watchlists)'
options={{
title: t("watchlists.title"),
tabBarItemHidden:
!settings?.streamyStatsServerUrl || settings?.hideWatchlistsTab,
tabBarIcon:
Platform.OS === "android"
? (_e) => require("@/assets/icons/list.png")
: (_e) => ({ sfSymbol: "list.bullet.rectangle" }),
? ({ focused }) =>
focused
? require("@/assets/icons/heart.fill.png")
: require("@/assets/icons/heart.png")
: ({ focused }) =>
focused
? { sfSymbol: "heart.fill" }
: { sfSymbol: "heart" },
}}
/>
<NativeTabs.Screen
@@ -103,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
@@ -114,12 +131,13 @@ 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>
<MiniPlayerBar />
<MusicPlaybackEngine />
</View>
</>
);
}

View File

@@ -1,845 +0,0 @@
import { ExpoAvRoutePickerView } from "@douglowder/expo-av-route-picker-view";
import { Ionicons } from "@expo/vector-icons";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import type {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useRouter } from "expo-router";
import { useAtom } from "jotai";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
ActivityIndicator,
Dimensions,
Platform,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import { Slider } from "react-native-awesome-slider";
import DraggableFlatList, {
type RenderItemParams,
ScaleDecorator,
} from "react-native-draggable-flatlist";
import { CastButton, CastState } from "react-native-google-cast";
import { useSharedValue } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import TextTicker from "react-native-text-ticker";
import type { VolumeResult } from "react-native-volume-manager";
import { Badge } from "@/components/Badge";
import { Text } from "@/components/common/Text";
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet";
import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet";
import { useFavorite } from "@/hooks/useFavorite";
import { useMusicCast } from "@/hooks/useMusicCast";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
type RepeatMode,
useMusicPlayer,
} from "@/providers/MusicPlayerProvider";
import { formatBitrate } from "@/utils/bitrate";
import { formatDuration } from "@/utils/time";
// Conditionally require VolumeManager (not available on TV)
const VolumeManager = Platform.isTV
? null
: require("react-native-volume-manager");
const formatFileSize = (bytes?: number | null) => {
if (!bytes) return null;
const sizes = ["B", "KB", "MB", "GB"];
if (bytes === 0) return "0 B";
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${Math.round((bytes / 1024 ** i) * 100) / 100} ${sizes[i]}`;
};
const formatSampleRate = (sampleRate?: number | null) => {
if (!sampleRate) return null;
return `${(sampleRate / 1000).toFixed(1)} kHz`;
};
const { width: SCREEN_WIDTH } = Dimensions.get("window");
const ARTWORK_SIZE = SCREEN_WIDTH - 80;
type ViewMode = "player" | "queue";
export default function NowPlayingScreen() {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const router = useRouter();
const insets = useSafeAreaInsets();
const [viewMode, setViewMode] = useState<ViewMode>("player");
const [trackOptionsOpen, setTrackOptionsOpen] = useState(false);
const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false);
const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false);
const {
isConnected: isCastConnected,
castQueue,
castState,
} = useMusicCast({
api,
userId: user?.Id,
});
const {
currentTrack,
queue,
queueIndex,
isPlaying,
isLoading,
progress,
duration,
repeatMode,
shuffleEnabled,
mediaSource,
isTranscoding,
togglePlayPause,
next,
previous,
seek,
setRepeatMode,
toggleShuffle,
jumpToIndex,
removeFromQueue,
reorderQueue,
stop,
pause,
} = useMusicPlayer();
const { isFavorite, toggleFavorite } = useFavorite(
currentTrack ?? ({ Id: "" } as BaseItemDto),
);
const sliderProgress = useSharedValue(0);
const sliderMin = useSharedValue(0);
const sliderMax = useSharedValue(1);
useEffect(() => {
sliderProgress.value = progress;
}, [progress, sliderProgress]);
useEffect(() => {
sliderMax.value = duration > 0 ? duration : 1;
}, [duration, sliderMax]);
// Auto-cast queue when Chromecast becomes connected and pause local playback
const prevCastState = useRef<CastState | null | undefined>(null);
useEffect(() => {
if (
castState === CastState.CONNECTED &&
prevCastState.current !== CastState.CONNECTED &&
queue.length > 0
) {
// Just connected - pause local playback and cast the queue
pause();
castQueue({ queue, startIndex: queueIndex });
}
prevCastState.current = castState;
}, [castState, queue, queueIndex, castQueue, pause]);
const imageUrl = useMemo(() => {
if (!api || !currentTrack) return null;
const albumId = currentTrack.AlbumId || currentTrack.ParentId;
if (albumId) {
return `${api.basePath}/Items/${albumId}/Images/Primary?maxHeight=600&maxWidth=600`;
}
return `${api.basePath}/Items/${currentTrack.Id}/Images/Primary?maxHeight=600&maxWidth=600`;
}, [api, currentTrack]);
const progressText = useMemo(() => {
const progressTicks = progress * 10000000;
return formatDuration(progressTicks);
}, [progress]);
const _durationText = useMemo(() => {
const durationTicks = duration * 10000000;
return formatDuration(durationTicks);
}, [duration]);
const remainingText = useMemo(() => {
const remaining = Math.max(0, duration - progress);
const remainingTicks = remaining * 10000000;
return `-${formatDuration(remainingTicks)}`;
}, [duration, progress]);
const handleSliderComplete = useCallback(
(value: number) => {
seek(value);
},
[seek],
);
const handleClose = useCallback(() => {
router.back();
}, [router]);
const _handleStop = useCallback(() => {
stop();
router.back();
}, [stop, router]);
const cycleRepeatMode = useCallback(() => {
const modes: RepeatMode[] = ["off", "all", "one"];
const currentIndex = modes.indexOf(repeatMode);
const nextMode = modes[(currentIndex + 1) % modes.length];
setRepeatMode(nextMode);
}, [repeatMode, setRepeatMode]);
const handleOptionsPress = useCallback(() => {
setTrackOptionsOpen(true);
}, []);
const handleAddToPlaylist = useCallback(() => {
setPlaylistPickerOpen(true);
}, []);
const handleCreateNewPlaylist = useCallback(() => {
setCreatePlaylistOpen(true);
}, []);
const getRepeatIcon = (): string => {
switch (repeatMode) {
case "one":
return "repeat";
case "all":
return "repeat";
default:
return "repeat";
}
};
const canGoNext = queueIndex < queue.length - 1 || repeatMode === "all";
const canGoPrevious = queueIndex > 0 || progress > 3 || repeatMode === "all";
if (!currentTrack) {
return (
<BottomSheetModalProvider>
<View
className='flex-1 bg-[#121212] items-center justify-center'
style={{
paddingTop: Platform.OS === "android" ? insets.top : 0,
paddingBottom: Platform.OS === "android" ? insets.bottom : 0,
}}
>
<Text className='text-neutral-500'>No track playing</Text>
</View>
</BottomSheetModalProvider>
);
}
return (
<BottomSheetModalProvider>
<View
className='flex-1 bg-[#121212]'
style={{
paddingTop: Platform.OS === "android" ? insets.top : 0,
paddingBottom: Platform.OS === "android" ? insets.bottom : 0,
}}
>
{/* Header */}
<View className='flex-row items-center justify-between px-4 pt-3 pb-2'>
<TouchableOpacity
onPress={handleClose}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
className='p-2'
>
<Ionicons name='chevron-down' size={28} color='white' />
</TouchableOpacity>
<View className='flex-row'>
<TouchableOpacity
onPress={() => setViewMode("player")}
className='px-3 py-1'
>
<Text
className={
viewMode === "player"
? "text-white font-semibold"
: "text-neutral-500"
}
>
Now Playing
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => setViewMode("queue")}
className='px-3 py-1'
>
<Text
className={
viewMode === "queue"
? "text-white font-semibold"
: "text-neutral-500"
}
>
Queue ({queue.length})
</Text>
</TouchableOpacity>
</View>
{/* Empty placeholder to balance header layout */}
<View className='p-2' style={{ width: 44 }} />
</View>
{viewMode === "player" ? (
<PlayerView
api={api}
currentTrack={currentTrack}
imageUrl={imageUrl}
sliderProgress={sliderProgress}
sliderMin={sliderMin}
sliderMax={sliderMax}
progressText={progressText}
remainingText={remainingText}
isPlaying={isPlaying}
isLoading={isLoading}
repeatMode={repeatMode}
shuffleEnabled={shuffleEnabled}
canGoNext={canGoNext}
canGoPrevious={canGoPrevious}
onSliderComplete={handleSliderComplete}
onTogglePlayPause={togglePlayPause}
onNext={next}
onPrevious={previous}
onCycleRepeat={cycleRepeatMode}
onToggleShuffle={toggleShuffle}
getRepeatIcon={getRepeatIcon}
mediaSource={mediaSource}
isTranscoding={isTranscoding}
isFavorite={isFavorite}
onToggleFavorite={toggleFavorite}
onOptionsPress={handleOptionsPress}
isCastConnected={isCastConnected}
/>
) : (
<QueueView
api={api}
queue={queue}
queueIndex={queueIndex}
onJumpToIndex={jumpToIndex}
onRemoveFromQueue={removeFromQueue}
onReorderQueue={reorderQueue}
/>
)}
<TrackOptionsSheet
open={trackOptionsOpen}
setOpen={setTrackOptionsOpen}
track={currentTrack}
onAddToPlaylist={handleAddToPlaylist}
/>
<PlaylistPickerSheet
open={playlistPickerOpen}
setOpen={setPlaylistPickerOpen}
trackToAdd={currentTrack}
onCreateNew={handleCreateNewPlaylist}
/>
<CreatePlaylistModal
open={createPlaylistOpen}
setOpen={setCreatePlaylistOpen}
initialTrackId={currentTrack?.Id}
/>
</View>
</BottomSheetModalProvider>
);
}
interface PlayerViewProps {
api: any;
currentTrack: BaseItemDto;
imageUrl: string | null;
sliderProgress: any;
sliderMin: any;
sliderMax: any;
progressText: string;
remainingText: string;
isPlaying: boolean;
isLoading: boolean;
repeatMode: RepeatMode;
shuffleEnabled: boolean;
canGoNext: boolean;
canGoPrevious: boolean;
onSliderComplete: (value: number) => void;
onTogglePlayPause: () => void;
onNext: () => void;
onPrevious: () => void;
onCycleRepeat: () => void;
onToggleShuffle: () => void;
getRepeatIcon: () => string;
mediaSource: MediaSourceInfo | null;
isTranscoding: boolean;
isFavorite: boolean | undefined;
onToggleFavorite: () => void;
onOptionsPress: () => void;
isCastConnected: boolean;
}
const PlayerView: React.FC<PlayerViewProps> = ({
currentTrack,
imageUrl,
sliderProgress,
sliderMin,
sliderMax,
progressText,
remainingText,
isPlaying,
isLoading,
repeatMode,
shuffleEnabled,
canGoNext,
canGoPrevious,
onSliderComplete,
onTogglePlayPause,
onNext,
onPrevious,
onCycleRepeat,
onToggleShuffle,
getRepeatIcon,
mediaSource,
isTranscoding,
isFavorite,
onToggleFavorite,
onOptionsPress,
isCastConnected,
}) => {
const audioStream = useMemo(() => {
return mediaSource?.MediaStreams?.find((stream) => stream.Type === "Audio");
}, [mediaSource]);
// Volume slider state
const volumeProgress = useSharedValue(0);
const volumeMin = useSharedValue(0);
const volumeMax = useSharedValue(1);
const isTv = Platform.isTV;
useEffect(() => {
if (isTv || !VolumeManager) return;
// Get initial volume
VolumeManager.getVolume().then(({ volume }: { volume: number }) => {
volumeProgress.value = volume;
});
// Listen to volume changes
const listener = VolumeManager.addVolumeListener((result: VolumeResult) => {
volumeProgress.value = result.volume;
});
return () => listener.remove();
}, [isTv, volumeProgress]);
const handleVolumeChange = useCallback((value: number) => {
if (VolumeManager) {
VolumeManager.setVolume(value);
}
}, []);
const fileSize = formatFileSize(mediaSource?.Size);
const codec = audioStream?.Codec?.toUpperCase();
const bitrate = formatBitrate(audioStream?.BitRate);
const sampleRate = formatSampleRate(audioStream?.SampleRate);
const playbackMethod = isTranscoding ? "Transcoding" : "Direct";
const hasAudioStats =
mediaSource && (fileSize || codec || bitrate || sampleRate);
return (
<ScrollView className='flex-1 px-6' showsVerticalScrollIndicator={false}>
{/* Album artwork */}
<View
className='self-center mb-8 mt-4'
style={{
width: ARTWORK_SIZE,
height: ARTWORK_SIZE,
borderRadius: 12,
overflow: "hidden",
backgroundColor: "#1a1a1a",
shadowColor: "#000",
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.4,
shadowRadius: 16,
elevation: 10,
}}
>
{imageUrl ? (
<Image
source={{ uri: imageUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
cachePolicy='memory-disk'
/>
) : (
<View className='flex-1 items-center justify-center bg-neutral-800'>
<Ionicons name='musical-note' size={80} color='#666' />
</View>
)}
</View>
{/* Track info with actions */}
<View className='mb-6'>
<View className='flex-row items-start justify-between'>
<View className='flex-1 mr-4'>
<TextTicker
style={{ color: "white", fontSize: 24, fontWeight: "bold" }}
duration={Math.max(4000, (currentTrack.Name?.length || 0) * 250)}
loop
bounce={false}
repeatSpacer={80}
marqueeDelay={1500}
scroll={false}
animationType='scroll'
easing={(t) => t}
>
{currentTrack.Name}
</TextTicker>
<TextTicker
style={{ color: "#a3a3a3", fontSize: 18 }}
duration={Math.max(
4000,
(
currentTrack.Artists?.join(", ") ||
currentTrack.AlbumArtist ||
""
).length * 250,
)}
loop
bounce={false}
repeatSpacer={80}
marqueeDelay={2000}
scroll={false}
animationType='scroll'
easing={(t) => t}
>
{currentTrack.Artists?.join(", ") || currentTrack.AlbumArtist}
</TextTicker>
</View>
<TouchableOpacity
onPress={onToggleFavorite}
className='p-2'
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
activeOpacity={0.7}
>
<Ionicons
name={isFavorite ? "heart" : "heart-outline"}
size={24}
color={isFavorite ? "#ec4899" : "white"}
/>
</TouchableOpacity>
<TouchableOpacity onPress={onOptionsPress} className='p-2'>
<Ionicons name='ellipsis-horizontal' size={24} color='white' />
</TouchableOpacity>
</View>
{/* Audio Stats */}
{hasAudioStats && (
<View className='flex-row flex-wrap gap-1.5 mt-3'>
{fileSize && <Badge variant='gray' text={fileSize} />}
{codec && <Badge variant='gray' text={codec} />}
<Badge
variant='gray'
text={playbackMethod}
iconLeft={
<Ionicons
name={isTranscoding ? "swap-horizontal" : "play"}
size={12}
color='white'
/>
}
/>
{bitrate && bitrate !== "N/A" && (
<Badge variant='gray' text={bitrate} />
)}
{sampleRate && <Badge variant='gray' text={sampleRate} />}
</View>
)}
</View>
{/* Progress slider */}
<View className='mb-4'>
<Slider
theme={{
maximumTrackTintColor: "rgba(255,255,255,0.2)",
minimumTrackTintColor: "#fff",
bubbleBackgroundColor: "#fff",
bubbleTextColor: "#666",
}}
progress={sliderProgress}
minimumValue={sliderMin}
maximumValue={sliderMax}
onSlidingComplete={onSliderComplete}
renderThumb={() => null}
sliderHeight={8}
containerStyle={{ borderRadius: 100 }}
renderBubble={() => null}
/>
<View className='flex flex-row justify-between mt-2'>
<Text className='text-neutral-500 text-xs'>{progressText}</Text>
<Text className='text-neutral-500 text-xs'>{remainingText}</Text>
</View>
</View>
{/* Main Controls with Shuffle & Repeat */}
<View className='flex flex-row items-center justify-center mb-6'>
<TouchableOpacity onPress={onToggleShuffle} className='p-3'>
<Ionicons
name='shuffle'
size={24}
color={shuffleEnabled ? "#9334E9" : "#666"}
/>
</TouchableOpacity>
<TouchableOpacity
onPress={onPrevious}
disabled={!canGoPrevious || isLoading}
className='p-4'
style={{ opacity: canGoPrevious && !isLoading ? 1 : 0.3 }}
>
<Ionicons name='play-skip-back' size={32} color='white' />
</TouchableOpacity>
<TouchableOpacity
onPress={onTogglePlayPause}
disabled={isLoading}
className='mx-4 bg-white rounded-full p-4'
>
{isLoading ? (
<ActivityIndicator size={36} color='#121212' />
) : (
<Ionicons
name={isPlaying ? "pause" : "play"}
size={36}
color='#121212'
style={isPlaying ? {} : { marginLeft: 4 }}
/>
)}
</TouchableOpacity>
<TouchableOpacity
onPress={onNext}
disabled={!canGoNext || isLoading}
className='p-4'
style={{ opacity: canGoNext && !isLoading ? 1 : 0.3 }}
>
<Ionicons name='play-skip-forward' size={32} color='white' />
</TouchableOpacity>
<TouchableOpacity onPress={onCycleRepeat} className='p-3 relative'>
<Ionicons
name={getRepeatIcon() as any}
size={24}
color={repeatMode !== "off" ? "#9334E9" : "#666"}
/>
{repeatMode === "one" && (
<View className='absolute right-0 top-1 bg-purple-600 rounded-full w-4 h-4 items-center justify-center'>
<Text className='text-white text-[10px] font-bold'>1</Text>
</View>
)}
</TouchableOpacity>
</View>
{/* Volume Slider */}
{!isTv && VolumeManager && (
<View className='flex-row items-center mb-6'>
<Ionicons name='volume-low' size={20} color='#666' />
<View className='flex-1 mx-3'>
<Slider
theme={{
maximumTrackTintColor: "rgba(255,255,255,0.2)",
minimumTrackTintColor: "#fff",
}}
progress={volumeProgress}
minimumValue={volumeMin}
maximumValue={volumeMax}
onSlidingComplete={handleVolumeChange}
renderThumb={() => null}
sliderHeight={8}
containerStyle={{ borderRadius: 100 }}
renderBubble={() => null}
/>
</View>
<Ionicons name='volume-high' size={20} color='#666' />
</View>
)}
{/* AirPlay & Chromecast Buttons */}
{!isTv && (
<View
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 32,
marginBottom: 16,
}}
>
{/* AirPlay (iOS only) */}
{Platform.OS === "ios" && (
<View style={{ transform: [{ scale: 2.8 }] }}>
<ExpoAvRoutePickerView
style={{ width: 24, height: 24 }}
tintColor='#666666'
activeTintColor='#9334E9'
/>
</View>
)}
{/* Chromecast */}
<CastButton
style={{
width: 24,
height: 24,
tintColor: isCastConnected ? "#9334E9" : "#666",
transform: [{ translateY: 1 }],
}}
/>
</View>
)}
</ScrollView>
);
};
interface QueueViewProps {
api: any;
queue: BaseItemDto[];
queueIndex: number;
onJumpToIndex: (index: number) => void;
onRemoveFromQueue: (index: number) => void;
onReorderQueue: (newQueue: BaseItemDto[]) => void;
}
const QueueView: React.FC<QueueViewProps> = ({
api,
queue,
queueIndex,
onJumpToIndex,
onRemoveFromQueue,
onReorderQueue,
}) => {
const renderQueueItem = useCallback(
({ item, drag, isActive, getIndex }: RenderItemParams<BaseItemDto>) => {
const index = getIndex() ?? 0;
const isCurrentTrack = index === queueIndex;
const isPast = index < queueIndex;
const albumId = item.AlbumId || item.ParentId;
const imageUrl = api
? albumId
? `${api.basePath}/Items/${albumId}/Images/Primary?maxHeight=80&maxWidth=80`
: `${api.basePath}/Items/${item.Id}/Images/Primary?maxHeight=80&maxWidth=80`
: null;
return (
<ScaleDecorator>
<TouchableOpacity
onPress={() => onJumpToIndex(index)}
onLongPress={drag}
disabled={isActive}
className='flex-row items-center px-4 py-3'
style={{
opacity: isPast && !isActive ? 0.5 : 1,
backgroundColor: isActive
? "#2a2a2a"
: isCurrentTrack
? "rgba(147, 52, 233, 0.3)"
: "#121212",
}}
>
{/* Drag handle */}
<TouchableOpacity
onPressIn={drag}
disabled={isActive}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
className='pr-2'
>
<Ionicons
name='reorder-three'
size={20}
color={isActive ? "#9334E9" : "#666"}
/>
</TouchableOpacity>
{/* Album art */}
<View className='w-12 h-12 rounded overflow-hidden bg-neutral-800 mr-3'>
{imageUrl ? (
<Image
source={{ uri: imageUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
cachePolicy='memory-disk'
/>
) : (
<View className='flex-1 items-center justify-center'>
<Ionicons name='musical-note' size={16} color='#666' />
</View>
)}
</View>
{/* Track info */}
<View className='flex-1 mr-2'>
<Text
numberOfLines={1}
className={`text-base ${isCurrentTrack ? "text-purple-400 font-semibold" : "text-white"}`}
>
{item.Name}
</Text>
<Text numberOfLines={1} className='text-neutral-500 text-sm'>
{item.Artists?.join(", ") || item.AlbumArtist}
</Text>
</View>
{/* Now playing indicator */}
{isCurrentTrack && (
<Ionicons name='musical-note' size={16} color='#9334E9' />
)}
{/* Remove button (not for current track) */}
{!isCurrentTrack && (
<TouchableOpacity
onPress={() => onRemoveFromQueue(index)}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
className='p-2'
>
<Ionicons name='close' size={20} color='#666' />
</TouchableOpacity>
)}
</TouchableOpacity>
</ScaleDecorator>
);
},
[api, queueIndex, onJumpToIndex, onRemoveFromQueue],
);
const handleDragEnd = useCallback(
({ data }: { data: BaseItemDto[] }) => {
onReorderQueue(data);
},
[onReorderQueue],
);
const history = queue.slice(0, queueIndex);
return (
<DraggableFlatList
data={queue}
keyExtractor={(item, index) => `${item.Id}-${index}`}
renderItem={renderQueueItem}
onDragEnd={handleDragEnd}
showsVerticalScrollIndicator={false}
ListHeaderComponent={
<View className='px-4 py-2'>
<Text className='text-neutral-400 text-xs uppercase tracking-wider'>
{history.length > 0 ? "Playing from queue" : "Up next"}
</Text>
</View>
}
ListEmptyComponent={
<View className='flex-1 items-center justify-center py-20'>
<Text className='text-neutral-500'>Queue is empty</Text>
</View>
}
/>
);
};

View File

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

View File

@@ -2,7 +2,7 @@ import {
type BaseItemDto,
type MediaSourceInfo,
PlaybackOrder,
PlaybackProgressInfo,
PlaybackStartInfo,
RepeatMode,
} from "@jellyfin/sdk/lib/generated-client";
import {
@@ -14,70 +14,51 @@ import { router, useGlobalSearchParams, useNavigation } from "expo-router";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, Platform, useWindowDimensions, View } from "react-native";
import { Alert, Platform, View } from "react-native";
import { useAnimatedReaction, useSharedValue } from "react-native-reanimated";
import { BITRATES } from "@/components/BitrateSelector";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { Controls } from "@/components/video-player/controls/Controls";
import { PlayerProvider } from "@/components/video-player/controls/contexts/PlayerContext";
import { VideoProvider } from "@/components/video-player/controls/contexts/VideoContext";
import {
PlaybackSpeedScope,
updatePlaybackSpeedSettings,
} from "@/components/video-player/controls/utils/playback-speed-settings";
import { useHaptic } from "@/hooks/useHaptic";
import { useOrientation } from "@/hooks/useOrientation";
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
import usePlaybackSpeed from "@/hooks/usePlaybackSpeed";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useWebSocket } from "@/hooks/useWebsockets";
import {
type MpvOnErrorEventPayload,
type MpvOnPlaybackStateChangePayload,
type MpvOnProgressEventPayload,
MpvPlayerView,
type MpvPlayerViewRef,
type MpvVideoSource,
} from "@/modules";
import { VlcPlayerView } from "@/modules";
import type {
PlaybackStatePayload,
ProgressUpdatePayload,
VlcPlayerViewRef,
} from "@/modules/VlcPlayer.types";
import { useDownload } from "@/providers/DownloadProvider";
import { DownloadedItem } from "@/providers/Downloads/types";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import {
getMpvAudioId,
getMpvSubtitleId,
} from "@/utils/jellyfin/subtitleUtils";
import { writeToLog } from "@/utils/log";
import { generateDeviceProfile } from "@/utils/profiles/native";
import { msToTicks, ticksToSeconds } from "@/utils/time";
export default function page() {
const videoRef = useRef<MpvPlayerViewRef>(null);
const videoRef = useRef<VlcPlayerViewRef>(null);
const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom);
const { t } = useTranslation();
const navigation = useNavigation();
const { settings, updateSettings } = useSettings();
const { width: screenWidth, height: screenHeight } = useWindowDimensions();
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
const [showControls, _setShowControls] = useState(true);
const [isPipMode, setIsPipMode] = useState(false);
const [aspectRatio] = useState<"default" | "16:9" | "4:3" | "1:1" | "21:9">(
"default",
);
const [isZoomedToFill, setIsZoomedToFill] = useState(false);
const [aspectRatio, setAspectRatio] = useState<
"default" | "16:9" | "4:3" | "1:1" | "21:9"
>("default");
const [scaleFactor, setScaleFactor] = useState<
1.0 | 1.1 | 1.2 | 1.3 | 1.4 | 1.5 | 1.6 | 1.7 | 1.8 | 1.9 | 2.0
>(1.0);
const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(false);
const [isBuffering, setIsBuffering] = useState(true);
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
const [tracksReady, setTracksReady] = useState(false);
const [hasPlaybackStarted, setHasPlaybackStarted] = useState(false);
const [currentPlaybackSpeed, setCurrentPlaybackSpeed] = useState(1.0);
const progress = useSharedValue(0);
const isSeeking = useSharedValue(false);
@@ -87,10 +68,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();
@@ -119,14 +97,12 @@ export default function page() {
/** Playback position in ticks. */
playbackPosition?: string;
}>();
const { lockOrientation, unlockOrientation } = useOrientation();
useSettings();
const offline = offlineStr === "true";
const playbackManager = usePlaybackManager({ isOffline: offline });
const playbackManager = usePlaybackManager();
// Audio index: use URL param if provided, otherwise use stored index for offline playback
// This is computed after downloadedItem is available, see audioIndexResolved below
const audioIndexFromUrl = audioIndexStr
const audioIndex = audioIndexStr
? Number.parseInt(audioIndexStr, 10)
: undefined;
const subtitleIndex = subtitleIndexStr
@@ -145,49 +121,13 @@ export default function page() {
isError: false,
});
// Resolve audio index: use URL param if provided, otherwise use stored index for offline playback
const audioIndex = useMemo(() => {
if (audioIndexFromUrl !== undefined) {
return audioIndexFromUrl;
}
if (offline && downloadedItem?.userData?.audioStreamIndex !== undefined) {
return downloadedItem.userData.audioStreamIndex;
}
return undefined;
}, [audioIndexFromUrl, offline, downloadedItem?.userData?.audioStreamIndex]);
// Get the playback speed for this item based on settings
const { playbackSpeed: initialPlaybackSpeed } = usePlaybackSpeed(
item,
settings,
);
// Handler for changing playback speed
const handleSetPlaybackSpeed = useCallback(
async (speed: number, scope: PlaybackSpeedScope) => {
// Update settings based on scope
updatePlaybackSpeedSettings(
speed,
scope,
item ?? undefined,
settings,
updateSettings,
);
// Apply speed to the current player (MPV)
setCurrentPlaybackSpeed(speed);
await videoRef.current?.setSpeed?.(speed);
},
[item, settings, updateSettings],
);
/** Gets the initial playback position from the URL. */
const getInitialPlaybackTicks = useCallback((): number => {
if (playbackPositionFromUrl) {
return Number.parseInt(playbackPositionFromUrl, 10);
}
return item?.UserData?.PlaybackPositionTicks ?? 0;
}, [playbackPositionFromUrl, item?.UserData?.PlaybackPositionTicks]);
}, [playbackPositionFromUrl]);
useEffect(() => {
const fetchItemData = async () => {
@@ -220,17 +160,6 @@ export default function page() {
}
}, [itemId, offline, api, user?.Id]);
// Lock orientation based on user settings
useEffect(() => {
if (settings?.defaultVideoOrientation) {
lockOrientation(settings.defaultVideoOrientation);
}
return () => {
unlockOrientation();
};
}, [settings?.defaultVideoOrientation, lockOrientation, unlockOrientation]);
interface Stream {
mediaSource: MediaSourceInfo;
sessionId: string;
@@ -277,25 +206,21 @@ export default function page() {
return;
}
// Calculate start ticks directly from item to avoid stale closure
const startTicks = playbackPositionFromUrl
? Number.parseInt(playbackPositionFromUrl, 10)
: (item?.UserData?.PlaybackPositionTicks ?? 0);
const native = generateDeviceProfile();
const transcoding = generateDeviceProfile({ transcode: true });
const res = await getStreamUrl({
api,
item,
startTimeTicks: startTicks,
startTimeTicks: getInitialPlaybackTicks(),
userId: user.Id,
audioStreamIndex: audioIndex,
maxStreamingBitrate: bitrateValue,
mediaSourceId: mediaSourceId,
subtitleStreamIndex: subtitleIndex,
deviceProfile: generateDeviceProfile(),
deviceProfile: bitrateValue ? transcoding : native,
});
if (!res) return;
const { mediaSource, sessionId, url } = res;
if (!sessionId || !mediaSource || !url) {
Alert.alert(
t("player.error"),
@@ -324,47 +249,43 @@ export default function page() {
]);
useEffect(() => {
if (!stream || !api || offline) return;
if (!stream || !api) return;
const reportPlaybackStart = async () => {
const progressInfo = currentPlayStateInfo();
if (progressInfo) {
await getPlaystateApi(api).reportPlaybackStart({
playbackStartInfo: progressInfo,
});
}
await getPlaystateApi(api).reportPlaybackStart({
playbackStartInfo: currentPlayStateInfo() as PlaybackStartInfo,
});
};
reportPlaybackStart();
}, [stream, api, offline]);
}, [stream, api]);
const togglePlay = async () => {
lightHapticFeedback();
setIsPlaying(!isPlaying);
if (isPlaying) {
await videoRef.current?.pause();
const progressInfo = currentPlayStateInfo();
if (progressInfo) {
playbackManager.reportPlaybackProgress(progressInfo);
}
playbackManager.reportPlaybackProgress(
item?.Id!,
msToTicks(progress.get()),
{
AudioStreamIndex: audioIndex ?? -1,
SubtitleStreamIndex: subtitleIndex ?? -1,
},
);
} else {
videoRef.current?.play();
const progressInfo = currentPlayStateInfo();
if (!offline && api) {
await getPlaystateApi(api).reportPlaybackStart({
playbackStartInfo: progressInfo,
});
}
await getPlaystateApi(api!).reportPlaybackStart({
playbackStartInfo: currentPlayStateInfo() as PlaybackStartInfo,
});
}
};
const reportPlaybackStopped = useCallback(async () => {
if (!item?.Id || !stream?.sessionId || offline || !api) return;
const currentTimeInTicks = msToTicks(progress.get());
await getPlaystateApi(api).onPlaybackStopped({
itemId: item.Id,
await getPlaystateApi(api!).onPlaybackStopped({
itemId: item?.Id!,
mediaSourceId: mediaSourceId,
positionTicks: currentTimeInTicks,
playSessionId: stream.sessionId,
playSessionId: stream?.sessionId!,
});
}, [
api,
@@ -383,7 +304,7 @@ export default function page() {
});
reportPlaybackStopped();
setIsPlaybackStopped(true);
videoRef.current?.pause();
videoRef.current?.stop();
revalidateProgressCache();
}, [videoRef, reportPlaybackStopped, progress]);
@@ -394,24 +315,21 @@ export default function page() {
};
}, [navigation, stop]);
const currentPlayStateInfo = useCallback(():
| PlaybackProgressInfo
| undefined => {
if (!stream || !item?.Id) return;
const currentPlayStateInfo = useCallback(() => {
if (!stream) return;
return {
ItemId: item.Id,
AudioStreamIndex: audioIndex ? audioIndex : undefined,
SubtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
MediaSourceId: mediaSourceId,
PositionTicks: msToTicks(progress.get()),
IsPaused: !isPlaying,
PlayMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
PlaySessionId: stream.sessionId,
IsMuted: isMuted,
CanSeek: true,
RepeatMode: RepeatMode.RepeatNone,
PlaybackOrder: PlaybackOrder.Default,
itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: msToTicks(progress.get()),
isPaused: !isPlaying,
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream.sessionId,
isMuted: isMuted,
canSeek: true,
repeatMode: RepeatMode.RepeatNone,
playbackOrder: PlaybackOrder.Default,
};
}, [
stream,
@@ -440,15 +358,11 @@ export default function page() {
[],
);
/** Progress handler for MPV - position in seconds */
const onProgress = useCallback(
async (data: { nativeEvent: MpvOnProgressEventPayload }) => {
async (data: ProgressUpdatePayload) => {
if (isSeeking.get() || isPlaybackStopped) return;
const { position } = data.nativeEvent;
// MPV reports position in seconds, convert to ms
const currentTime = position * 1000;
const { currentTime } = data.nativeEvent;
if (isBuffering) {
setIsBuffering(false);
}
@@ -473,7 +387,12 @@ export default function page() {
if (!item?.Id) return;
playbackManager.reportPlaybackProgress(
currentPlayStateInfo() as PlaybackProgressInfo,
item.Id,
msToTicks(progress.get()),
{
AudioStreamIndex: audioIndex ?? -1,
SubtitleStreamIndex: subtitleIndex ?? -1,
},
);
},
[
@@ -490,85 +409,10 @@ export default function page() {
);
/** Gets the initial playback position in seconds. */
const _startPosition = useMemo(() => {
const startPosition = useMemo(() => {
return ticksToSeconds(getInitialPlaybackTicks());
}, [getInitialPlaybackTicks]);
/** Build video source config for MPV */
const videoSource = useMemo<MpvVideoSource | undefined>(() => {
if (!stream?.url) return undefined;
const mediaSource = stream.mediaSource;
const isTranscoding = Boolean(mediaSource?.TranscodingUrl);
// Get external subtitle URLs
// - Online: prepend API base path to server URLs
// - Offline: use local file paths (stored in DeliveryUrl during download)
let externalSubs: string[] | undefined;
if (!offline && api?.basePath) {
externalSubs = mediaSource?.MediaStreams?.filter(
(s) =>
s.Type === "Subtitle" &&
s.DeliveryMethod === "External" &&
s.DeliveryUrl,
).map((s) => `${api.basePath}${s.DeliveryUrl}`);
} else if (offline) {
externalSubs = mediaSource?.MediaStreams?.filter(
(s) =>
s.Type === "Subtitle" &&
s.DeliveryMethod === "External" &&
s.DeliveryUrl,
).map((s) => s.DeliveryUrl!);
}
// Calculate track IDs for initial selection
const initialSubtitleId = getMpvSubtitleId(
mediaSource,
subtitleIndex,
isTranscoding,
);
const initialAudioId = getMpvAudioId(mediaSource, audioIndex);
// Calculate start position directly here to avoid timing issues
const startTicks = playbackPositionFromUrl
? Number.parseInt(playbackPositionFromUrl, 10)
: (item?.UserData?.PlaybackPositionTicks ?? 0);
const startPos = ticksToSeconds(startTicks);
// Build source config - headers only needed for online streaming
const source: MpvVideoSource = {
url: stream.url,
startPosition: startPos,
autoplay: true,
initialSubtitleId,
initialAudioId,
};
// Add external subtitles only for online playback
if (externalSubs && externalSubs.length > 0) {
source.externalSubtitles = externalSubs;
}
// Add auth headers only for online streaming (not for local file:// URLs)
if (!offline && api?.accessToken) {
source.headers = {
Authorization: `MediaBrowser Token="${api.accessToken}"`,
};
}
return source;
}, [
stream?.url,
stream?.mediaSource,
item?.UserData?.PlaybackPositionTicks,
playbackPositionFromUrl,
api?.basePath,
api?.accessToken,
subtitleIndex,
audioIndex,
offline,
]);
const volumeUpCb = useCallback(async () => {
if (Platform.isTV) return;
@@ -648,54 +492,95 @@ export default function page() {
setVolume: setVolumeCb,
});
/** Playback state handler for MPV */
const onPlaybackStateChanged = useCallback(
async (e: { nativeEvent: MpvOnPlaybackStateChangePayload }) => {
const { isPaused, isPlaying: playing, isLoading } = e.nativeEvent;
if (playing) {
async (e: PlaybackStatePayload) => {
const { state, isBuffering, isPlaying } = e.nativeEvent;
if (state === "Playing") {
setIsPlaying(true);
setIsBuffering(false);
setHasPlaybackStarted(true);
if (item?.Id) {
playbackManager.reportPlaybackProgress(
currentPlayStateInfo() as PlaybackProgressInfo,
item.Id,
msToTicks(progress.get()),
{
AudioStreamIndex: audioIndex ?? -1,
SubtitleStreamIndex: subtitleIndex ?? -1,
},
);
}
if (!Platform.isTV) await activateKeepAwakeAsync();
return;
}
if (isPaused) {
if (state === "Paused") {
setIsPlaying(false);
if (item?.Id) {
playbackManager.reportPlaybackProgress(
currentPlayStateInfo() as PlaybackProgressInfo,
item.Id,
msToTicks(progress.get()),
{
AudioStreamIndex: audioIndex ?? -1,
SubtitleStreamIndex: subtitleIndex ?? -1,
},
);
}
if (!Platform.isTV) await deactivateKeepAwake();
return;
}
if (isLoading) {
if (isPlaying) {
setIsPlaying(true);
setIsBuffering(false);
} else if (isBuffering) {
setIsBuffering(true);
}
},
[playbackManager, item?.Id, progress],
);
/** PiP handler for MPV */
const _onPictureInPictureChange = useCallback(
(e: { nativeEvent: { isActive: boolean } }) => {
const { isActive } = e.nativeEvent;
setIsPipMode(isActive);
// Hide controls when entering PiP
if (isActive) {
_setShowControls(false);
}
},
[],
const allAudio =
stream?.mediaSource.MediaStreams?.filter(
(audio) => audio.Type === "Audio",
) || [];
// Move all the external subtitles last, because vlc places them last.
const allSubs =
stream?.mediaSource.MediaStreams?.filter(
(sub) => sub.Type === "Subtitle",
).sort((a, b) => Number(a.IsExternal) - Number(b.IsExternal)) || [];
const externalSubtitles = allSubs
.filter((sub: any) => sub.DeliveryMethod === "External")
.map((sub: any) => ({
name: sub.DisplayTitle,
DeliveryUrl: offline ? sub.DeliveryUrl : api?.basePath + sub.DeliveryUrl,
}));
/** The text based subtitle tracks */
const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream);
/** The user chosen subtitle track from the server */
const chosenSubtitleTrack = allSubs.find(
(sub) => sub.Index === subtitleIndex,
);
/** The user chosen audio track from the server */
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
/** Whether the stream we're playing is not transcoding*/
const notTranscoding = !stream?.mediaSource.TranscodingUrl;
/** The initial options to pass to the VLC Player */
const initOptions = [``];
if (
chosenSubtitleTrack &&
(notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
) {
// If not transcoding, we can the index as normal.
// If transcoding, we need to reverse the text based subtitles, because VLC reverses the HLS subtitles.
const finalIndex = notTranscoding
? allSubs.indexOf(chosenSubtitleTrack)
: [...textSubs].reverse().indexOf(chosenSubtitleTrack);
initOptions.push(`--sub-track=${finalIndex}`);
}
if (notTranscoding && chosenAudioTrack) {
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
}
const [isMounted, setIsMounted] = useState(false);
@@ -709,7 +594,6 @@ export default function page() {
const startPictureInPicture = useCallback(async () => {
return videoRef.current?.startPictureInPicture?.();
}, []);
const play = useCallback(() => {
videoRef.current?.play?.();
}, []);
@@ -719,92 +603,46 @@ export default function page() {
}, []);
const seek = useCallback((position: number) => {
// MPV expects seconds, convert from ms
videoRef.current?.seekTo?.(position / 1000);
videoRef.current?.seekTo?.(position);
}, []);
const getAudioTracks = useCallback(async () => {
return videoRef.current?.getAudioTracks?.() || null;
}, []);
const handleZoomToggle = useCallback(async () => {
const newZoomState = !isZoomedToFill;
await videoRef.current?.setZoomedToFill?.(newZoomState);
setIsZoomedToFill(newZoomState);
const getSubtitleTracks = useCallback(async () => {
return videoRef.current?.getSubtitleTracks?.() || null;
}, []);
// Adjust subtitle position to compensate for video cropping when zoomed
if (newZoomState) {
// Get video dimensions from mediaSource
const videoStream = stream?.mediaSource?.MediaStreams?.find(
(s) => s.Type === "Video",
const setSubtitleTrack = useCallback((index: number) => {
videoRef.current?.setSubtitleTrack?.(index);
}, []);
const setSubtitleURL = useCallback((url: string, _customName?: string) => {
// Note: VlcPlayer type only expects url parameter
videoRef.current?.setSubtitleURL?.(url);
}, []);
const setAudioTrack = useCallback((index: number) => {
videoRef.current?.setAudioTrack?.(index);
}, []);
const setVideoAspectRatio = useCallback(
async (aspectRatio: string | null) => {
return (
videoRef.current?.setVideoAspectRatio?.(aspectRatio) ||
Promise.resolve()
);
const videoWidth = videoStream?.Width ?? 1920;
const videoHeight = videoStream?.Height ?? 1080;
},
[],
);
const videoAR = videoWidth / videoHeight;
const screenAR = screenWidth / screenHeight;
const setVideoScaleFactor = useCallback(async (scaleFactor: number) => {
return (
videoRef.current?.setVideoScaleFactor?.(scaleFactor) || Promise.resolve()
);
}, []);
if (screenAR > videoAR) {
// Screen is wider than video - video height extends beyond screen
// Calculate how much of the video is cropped at the bottom (as % of video height)
const bottomCropPercent = 50 * (1 - videoAR / screenAR);
// Only adjust by 70% of the crop to keep a comfortable margin from the edge
// (subtitles already have some built-in padding from the bottom)
const adjustmentFactor = 0.7;
const newSubPos = Math.round(
100 - bottomCropPercent * adjustmentFactor,
);
await videoRef.current?.setSubtitlePosition?.(newSubPos);
}
// If videoAR >= screenAR, sides are cropped but bottom is visible, no adjustment needed
} else {
// Restore to default position (bottom of video frame)
await videoRef.current?.setSubtitlePosition?.(100);
}
}, [isZoomedToFill, stream?.mediaSource, screenWidth, screenHeight]);
// Apply subtitle settings when video loads
useEffect(() => {
if (!isVideoLoaded || !videoRef.current) return;
const applySubtitleSettings = async () => {
if (settings.mpvSubtitleScale !== undefined) {
await videoRef.current?.setSubtitleScale?.(settings.mpvSubtitleScale);
}
if (settings.mpvSubtitleMarginY !== undefined) {
await videoRef.current?.setSubtitleMarginY?.(
settings.mpvSubtitleMarginY,
);
}
if (settings.mpvSubtitleAlignX !== undefined) {
await videoRef.current?.setSubtitleAlignX?.(settings.mpvSubtitleAlignX);
}
if (settings.mpvSubtitleAlignY !== undefined) {
await videoRef.current?.setSubtitleAlignY?.(settings.mpvSubtitleAlignY);
}
if (settings.mpvSubtitleFontSize !== undefined) {
await videoRef.current?.setSubtitleFontSize?.(
settings.mpvSubtitleFontSize,
);
}
// Apply subtitle size from general settings
if (settings.subtitleSize) {
await videoRef.current?.setSubtitleFontSize?.(settings.subtitleSize);
}
};
applySubtitleSettings();
}, [isVideoLoaded, settings]);
// Apply initial playback speed when video loads
useEffect(() => {
if (!isVideoLoaded || !videoRef.current) return;
const applyInitialPlaybackSpeed = async () => {
if (initialPlaybackSpeed !== 1.0) {
setCurrentPlaybackSpeed(initialPlaybackSpeed);
await videoRef.current?.setSpeed?.(initialPlaybackSpeed);
}
};
applyInitialPlaybackSpeed();
}, [isVideoLoaded, initialPlaybackSpeed]);
console.log("Debug: component render"); // Uncomment to debug re-renders
// Show error UI first, before checking loading/missingdata
if (itemStatus.isError || streamStatus.isError) {
@@ -815,7 +653,7 @@ export default function page() {
);
}
// Then show loader while either side is still fetching or data isn't present
// Then show loader while either side is still fetching or data isnt present
if (itemStatus.isLoading || streamStatus.isLoading || !item || !stream) {
// …loader UI…
return (
@@ -833,99 +671,86 @@ export default function page() {
);
return (
<PlayerProvider
playerRef={videoRef}
item={item}
mediaSource={stream?.mediaSource}
isVideoLoaded={isVideoLoaded}
tracksReady={tracksReady}
offline={offline}
downloadedItem={downloadedItem}
<View
style={{
flex: 1,
backgroundColor: "black",
height: "100%",
width: "100%",
}}
>
<VideoProvider>
<View
style={{
flex: 1,
backgroundColor: "black",
height: "100%",
width: "100%",
<View
style={{
display: "flex",
width: "100%",
height: "100%",
position: "relative",
flexDirection: "column",
justifyContent: "center",
}}
>
<VlcPlayerView
ref={videoRef}
source={{
uri: stream?.url || "",
autoplay: true,
isNetwork: !offline,
startPosition,
externalSubtitles,
initOptions,
}}
>
<View
style={{
display: "flex",
width: "100%",
height: "100%",
position: "relative",
flexDirection: "column",
justifyContent: "center",
}}
>
<MpvPlayerView
ref={videoRef}
source={videoSource}
style={{ width: "100%", height: "100%" }}
onProgress={onProgress}
onPlaybackStateChange={onPlaybackStateChanged}
onLoad={() => setIsVideoLoaded(true)}
onError={(e: { nativeEvent: MpvOnErrorEventPayload }) => {
console.error("Video Error:", e.nativeEvent);
Alert.alert(
t("player.error"),
t("player.an_error_occured_while_playing_the_video"),
);
writeToLog("ERROR", "Video Error", e.nativeEvent);
}}
onTracksReady={() => {
setTracksReady(true);
}}
/>
{!hasPlaybackStarted && (
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "black",
justifyContent: "center",
alignItems: "center",
}}
>
<Loader />
</View>
)}
</View>
{isMounted === true && item && !isPipMode && (
<Controls
mediaSource={stream?.mediaSource}
item={item}
togglePlay={togglePlay}
isPlaying={isPlaying}
isSeeking={isSeeking}
progress={progress}
cacheProgress={cacheProgress}
isBuffering={isBuffering}
showControls={showControls}
setShowControls={setShowControls}
startPictureInPicture={startPictureInPicture}
play={play}
pause={pause}
seek={seek}
enableTrickplay={true}
offline={offline}
aspectRatio={aspectRatio}
isZoomedToFill={isZoomedToFill}
onZoomToggle={handleZoomToggle}
api={api}
downloadedFiles={downloadedFiles}
playbackSpeed={currentPlaybackSpeed}
setPlaybackSpeed={handleSetPlaybackSpeed}
/>
)}
</View>
</VideoProvider>
</PlayerProvider>
style={{ width: "100%", height: "100%" }}
onVideoProgress={onProgress}
progressUpdateInterval={1000}
onVideoStateChange={onPlaybackStateChanged}
onVideoLoadEnd={() => {
setIsVideoLoaded(true);
}}
onVideoError={(e) => {
console.error("Video Error:", e.nativeEvent);
Alert.alert(
t("player.error"),
t("player.an_error_occured_while_playing_the_video"),
);
writeToLog("ERROR", "Video Error", e.nativeEvent);
}}
/>
</View>
{isMounted === true && item && (
<Controls
mediaSource={stream?.mediaSource}
item={item}
videoRef={videoRef}
togglePlay={togglePlay}
isPlaying={isPlaying}
isSeeking={isSeeking}
progress={progress}
cacheProgress={cacheProgress}
isBuffering={isBuffering}
showControls={showControls}
setShowControls={setShowControls}
isVideoLoaded={isVideoLoaded}
startPictureInPicture={startPictureInPicture}
play={play}
pause={pause}
seek={seek}
enableTrickplay={true}
getAudioTracks={getAudioTracks}
getSubtitleTracks={getSubtitleTracks}
offline={offline}
setSubtitleTrack={setSubtitleTrack}
setSubtitleURL={setSubtitleURL}
setAudioTrack={setAudioTrack}
setVideoAspectRatio={setVideoAspectRatio}
setVideoScaleFactor={setVideoScaleFactor}
aspectRatio={aspectRatio}
scaleFactor={scaleFactor}
setAspectRatio={setAspectRatio}
setScaleFactor={setScaleFactor}
isVlc
downloadedFiles={downloadedFiles}
/>
)}
</View>
);
}

View File

@@ -1,29 +1,18 @@
import "@/augmentations";
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import NetInfo from "@react-native-community/netinfo";
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
import { onlineManager, QueryClient } from "@tanstack/react-query";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
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 { IntroSheetProvider } from "@/providers/IntroSheetProvider";
import {
apiAtom,
getOrSetDeviceId,
getTokenFromStorage,
JellyfinProvider,
} from "@/providers/JellyfinProvider";
import { MusicPlayerProvider } from "@/providers/MusicPlayerProvider";
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,
@@ -31,35 +20,50 @@ import {
} from "@/utils/background-tasks";
import {
LogProvider,
writeDebugLog,
writeErrorLog,
writeInfoLog,
writeToLog,
} 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({
@@ -80,19 +84,19 @@ SplashScreen.setOptions({
fade: true,
});
function redirect(notification: typeof Notifications.Notification) {
const url = notification.request.content.data?.url;
if (url) {
router.push(url);
}
}
function useNotificationObserver() {
useEffect(() => {
if (Platform.isTV) return;
let isMounted = true;
function redirect(notification: typeof Notifications.Notification) {
const url = notification.request.content.data?.url;
if (url) {
router.push(url);
}
}
Notifications.getLastNotificationResponseAsync().then(
(response: { notification: any }) => {
if (!isMounted || !response?.notification) {
@@ -102,8 +106,15 @@ function useNotificationObserver() {
},
);
const subscription = Notifications.addNotificationResponseReceivedListener(
(response: { notification: any }) => {
redirect(response.notification);
},
);
return () => {
isMounted = false;
subscription.remove();
};
}, []);
}
@@ -127,7 +138,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;
});
}
@@ -189,39 +217,15 @@ export default function RootLayout() {
);
}
// Set up online manager for network-aware query behavior
onlineManager.setEventListener((setOnline) => {
return NetInfo.addEventListener((state) => {
setOnline(!!state.isConnected);
});
});
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 0, // Always stale - triggers background refetch on mount
gcTime: 1000 * 60 * 60 * 24, // 24 hours - keep in cache for offline
networkMode: "offlineFirst", // Return cache first, refetch if online
refetchOnMount: true, // Refetch when component mounts
refetchOnReconnect: true, // Refetch when network reconnects
refetchOnWindowFocus: false, // Not needed for mobile
retry: (failureCount) => {
if (!onlineManager.isOnline()) return false;
return failureCount < 3;
},
staleTime: 0,
refetchOnMount: true,
refetchOnReconnect: true,
refetchOnWindowFocus: true,
retryOnMount: true,
},
mutations: {
networkMode: "online", // Only run mutations when online
},
},
});
// Create MMKV-based persister for offline support
const mmkvPersister = createSyncStoragePersister({
storage: {
getItem: (key) => storage.getString(key) ?? null,
setItem: (key, value) => storage.set(key, value),
removeItem: (key) => storage.remove(key),
},
});
@@ -229,7 +233,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(
@@ -258,7 +263,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", {
@@ -289,21 +294,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) {
@@ -322,42 +317,38 @@ function Layout() {
responseListener.current =
Notifications?.addNotificationResponseReceivedListener(
(response: NotificationResponse) => {
// redirect if internal notification
redirect(response?.notification);
// Currently the notifications supported by the plugin will send data for deep links.
const { title, data } = response.notification.request.content;
writeInfoLog(`Notification ${title} opened`, data);
writeDebugLog(
`Notification ${title} opened`,
response.notification.request.content,
);
if (data && Object.keys(data).length > 0) {
const type = (data?.type ?? "").toString().toLowerCase();
const itemId = data?.id;
let url: any;
const type = (data?.type ?? "").toString().toLowerCase();
const itemId = data?.id;
switch (type) {
case "movie":
url = `/(auth)/(tabs)/home/items/page?id=${itemId}`;
break;
case "episode":
// `/(auth)/(tabs)/${from}/items/page?id=${item.Id}`;
// We just clicked a notification for an individual episode.
if (itemId) {
url = `/(auth)/(tabs)/home/items/page?id=${itemId}`;
// summarized season notification for multiple episodes. Bring them to series season
} else {
const seriesId = data.seriesId;
const seasonIndex = data.seasonIndex;
if (seasonIndex) {
url = `/(auth)/(tabs)/home/series/${seriesId}?seasonIndex=${seasonIndex}`;
switch (type) {
case "movie":
router.push(`/(auth)/(tabs)/home/items/page?id=${itemId}`);
break;
case "episode":
// We just clicked a notification for an individual episode.
if (itemId) {
router.push(`/(auth)/(tabs)/home/items/page?id=${itemId}`);
// summarized season notification for multiple episodes. Bring them to series season
} else {
url = `/(auth)/(tabs)/home/series/${seriesId}`;
const seriesId = data.seriesId;
const seasonIndex = data.seasonIndex;
if (seasonIndex) {
router.push(
`/(auth)/(tabs)/home/series/${seriesId}?seasonIndex=${seasonIndex}`,
);
} else {
router.push(`/(auth)/(tabs)/home/series/${seriesId}`);
}
}
}
break;
}
writeInfoLog(`Notification attempting to redirect to ${url}`);
if (url) {
router.push(url);
break;
}
}
},
);
@@ -367,95 +358,115 @@ 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();
}
});
BackGroundDownloader.checkForExistingDownloads();
return () => {
subscription.remove();
};
}, []);
return (
<PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister: mmkvPersister,
maxAge: 1000 * 60 * 60 * 24, // 24 hours max cache age
dehydrateOptions: {
shouldDehydrateQuery: (query) => {
// Only persist successful queries
return query.state.status === "success";
},
},
}}
>
<QueryClientProvider client={queryClient}>
<JellyfinProvider>
<NetworkStatusProvider>
<PlaySettingsProvider>
<LogProvider>
<WebSocketProvider>
<DownloadProvider>
<MusicPlayerProvider>
<GlobalModalProvider>
<BottomSheetModalProvider>
<IntroSheetProvider>
<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='(auth)/now-playing'
options={{
headerShown: false,
presentation: "modal",
gestureEnabled: true,
}}
/>
<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>
</IntroSheetProvider>
</BottomSheetModalProvider>
</GlobalModalProvider>
</MusicPlayerProvider>
</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>
</PersistQueryClientProvider>
</QueryClientProvider>
);
}

View File

@@ -4,30 +4,26 @@ 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,
Switch,
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";
import { Text } from "@/components/common/Text";
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
import { PasswordInput } from "@/components/PasswordInput";
import { PreviousServersList } from "@/components/PreviousServersList";
import { SaveAccountModal } from "@/components/SaveAccountModal";
import { Colors } from "@/constants/Colors";
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
import type {
AccountSecurityType,
SavedServer,
} from "@/utils/secureCredentials";
const CredentialsSchema = z.object({
username: z.string().min(1, t("login.username_required")),
@@ -37,14 +33,8 @@ const Login: React.FC = () => {
const api = useAtomValue(apiAtom);
const navigation = useNavigation();
const params = useLocalSearchParams();
const {
setServer,
login,
removeServer,
initiateQuickConnect,
loginWithSavedCredential,
loginWithPassword,
} = useJellyfin();
const { setServer, login, removeServer, initiateQuickConnect } =
useJellyfin();
const {
apiUrl: _apiUrl,
@@ -54,23 +44,16 @@ const Login: React.FC = () => {
const [loadingServerCheck, setLoadingServerCheck] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(false);
const [serverURL, setServerURL] = useState<string>(_apiUrl || "");
const [serverURL, setServerURL] = useState<string>(_apiUrl);
const [serverName, setServerName] = useState<string>("");
const [credentials, setCredentials] = useState<{
username: string;
password: string;
}>({
username: _username || "",
password: _password || "",
username: _username,
password: _password,
});
// Save account state
const [saveAccount, setSaveAccount] = useState(false);
const [showSaveModal, setShowSaveModal] = useState(false);
const [pendingLogin, setPendingLogin] = useState<{
username: string;
password: string;
} | null>(null);
const [showPassword, setShowPassword] = useState<boolean>(false);
/**
* A way to auto login based on a link
@@ -82,13 +65,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]);
@@ -102,10 +84,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>
@@ -116,34 +98,12 @@ const Login: React.FC = () => {
const handleLogin = async () => {
Keyboard.dismiss();
const result = CredentialsSchema.safeParse(credentials);
if (!result.success) return;
if (saveAccount) {
// Show save account modal to choose security type
setPendingLogin({
username: credentials.username,
password: credentials.password,
});
setShowSaveModal(true);
} else {
// Login without saving
await performLogin(credentials.username, credentials.password);
}
};
const performLogin = async (
username: string,
password: string,
options?: {
saveAccount?: boolean;
securityType?: AccountSecurityType;
pinCode?: string;
},
) => {
setLoading(true);
try {
await login(username, password, serverName, options);
const result = CredentialsSchema.safeParse(credentials);
if (result.success) {
await login(credentials.username, credentials.password);
}
} catch (error) {
if (error instanceof Error) {
Alert.alert(t("login.connection_failed"), error.message);
@@ -155,44 +115,6 @@ const Login: React.FC = () => {
}
} finally {
setLoading(false);
setPendingLogin(null);
}
};
const handleSaveAccountConfirm = async (
securityType: AccountSecurityType,
pinCode?: string,
) => {
setShowSaveModal(false);
if (pendingLogin) {
await performLogin(pendingLogin.username, pendingLogin.password, {
saveAccount: true,
securityType,
pinCode,
});
}
};
const handleQuickLoginWithSavedCredential = async (
serverUrl: string,
userId: string,
) => {
await loginWithSavedCredential(serverUrl, userId);
};
const handlePasswordLogin = async (
serverUrl: string,
username: string,
password: string,
) => {
await loginWithPassword(serverUrl, username, password);
};
const handleAddAccount = (server: SavedServer) => {
// Server is already selected, go to credential entry
setServer({ address: server.address });
if (server.name) {
setServerName(server.name);
}
};
@@ -342,61 +264,36 @@ const Login: React.FC = () => {
<Input
placeholder={t("login.username_placeholder")}
onChangeText={(text: string) =>
setCredentials((prev) => ({ ...prev, username: text }))
setCredentials({ ...credentials, username: text })
}
onEndEditing={(e) => {
const newValue = e.nativeEvent.text;
if (newValue && newValue !== credentials.username) {
setCredentials((prev) => ({ ...prev, username: newValue }));
}
}}
value={credentials.username}
keyboardType='default'
returnKeyType='done'
autoCapitalize='none'
autoCorrect={false}
textContentType='username'
textContentType='oneTimeCode'
clearButtonMode='while-editing'
maxLength={500}
extraClassName='mb-4'
autoFocus={false}
blurOnSubmit={true}
extraClassName='mb-2'
/>
{/* Password */}
<Input
placeholder={t("login.password_placeholder")}
onChangeText={(text: string) =>
setCredentials((prev) => ({ ...prev, password: text }))
}
onEndEditing={(e) => {
const newValue = e.nativeEvent.text;
if (newValue && newValue !== credentials.password) {
setCredentials((prev) => ({ ...prev, password: newValue }));
<View className='relative mb-2'>
<PasswordInput
value={credentials.password}
onChangeText={(text: string) =>
setCredentials({ ...credentials, password: text })
}
}}
value={credentials.password}
secureTextEntry
keyboardType='default'
returnKeyType='done'
autoCapitalize='none'
textContentType='password'
clearButtonMode='while-editing'
maxLength={500}
extraClassName='mb-4'
autoFocus={false}
blurOnSubmit={true}
/>
<View className='mt-4'>
<Button
onPress={handleLogin}
disabled={!credentials.username.trim()}
>
{t("login.login_button")}
</Button>
placeholder={t("login.password_placeholder")}
showPassword={showPassword}
onShowPasswordChange={setShowPassword}
topOffset={16}
layout='tv'
/>
</View>
<View className='mt-3'>
<Button onPress={handleLogin}>{t("login.login_button")}</Button>
</View>
<View className='mt-2'>
<Button
onPress={handleQuickConnect}
className='bg-neutral-800 border border-neutral-700'
@@ -436,8 +333,6 @@ const Login: React.FC = () => {
autoCapitalize='none'
textContentType='URL'
maxLength={500}
autoFocus={false}
blurOnSubmit={true}
/>
{/* Full-width primary button */}
@@ -452,7 +347,7 @@ const Login: React.FC = () => {
</View>
{/* Lists stay full width but inside max width container */}
<View className='mt-2'>
<View className='mt-4'>
<JellyfinServerDiscovery
onServerSelect={async (server: any) => {
setServerURL(server.address);
@@ -461,12 +356,9 @@ const Login: React.FC = () => {
}}
/>
<PreviousServersList
onServerSelect={async (s) => {
onServerSelect={async (s: any) => {
await handleConnect(s.address);
}}
onQuickLogin={handleQuickLoginWithSavedCredential}
onPasswordLogin={handlePasswordLogin}
onAddAccount={handleAddAccount}
/>
</View>
</View>
@@ -478,12 +370,11 @@ 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 justify-center'>
<View className='px-4 w-full'>
<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'>
{serverName ? (
@@ -499,77 +390,44 @@ const Login: React.FC = () => {
<Input
placeholder={t("login.username_placeholder")}
onChangeText={(text) =>
setCredentials((prev) => ({ ...prev, username: text }))
setCredentials({ ...credentials, username: text })
}
onEndEditing={(e) => {
const newValue = e.nativeEvent.text;
if (newValue && newValue !== credentials.username) {
setCredentials((prev) => ({
...prev,
username: newValue,
}));
}
}}
value={credentials.username}
keyboardType='default'
returnKeyType='done'
autoCapitalize='none'
autoCorrect={false}
textContentType='username'
// Changed from username to oneTimeCode because it is a known issue in RN
// https://github.com/facebook/react-native/issues/47106#issuecomment-2521270037
textContentType='oneTimeCode'
clearButtonMode='while-editing'
maxLength={500}
extraClassName=''
/>
<Input
placeholder={t("login.password_placeholder")}
onChangeText={(text) =>
setCredentials((prev) => ({ ...prev, password: text }))
}
onEndEditing={(e) => {
const newValue = e.nativeEvent.text;
if (newValue && newValue !== credentials.password) {
setCredentials((prev) => ({
...prev,
password: newValue,
}));
<View className='relative'>
<PasswordInput
value={credentials.password}
onChangeText={(text) =>
setCredentials({ ...credentials, password: text })
}
}}
value={credentials.password}
secureTextEntry
keyboardType='default'
returnKeyType='done'
autoCapitalize='none'
textContentType='password'
clearButtonMode='while-editing'
maxLength={500}
/>
<TouchableOpacity
onPress={() => setSaveAccount(!saveAccount)}
className='flex flex-row items-center py-2'
activeOpacity={0.7}
>
<Switch
value={saveAccount}
onValueChange={setSaveAccount}
trackColor={{ false: "#3f3f46", true: Colors.primary }}
thumbColor='white'
placeholder={t("login.password_placeholder")}
showPassword={showPassword}
onShowPasswordChange={setShowPassword}
topOffset={12}
layout='mobile'
/>
<Text className='ml-3 text-neutral-300'>
{t("save_account.save_for_later")}
</Text>
</TouchableOpacity>
</View>
<View className='flex flex-row items-center justify-between'>
<Button
onPress={handleLogin}
loading={loading}
disabled={!credentials.username.trim()}
className='flex-1 mr-2'
>
{t("login.login_button")}
</Button>
<TouchableOpacity
onPress={handleQuickConnect}
className='p-2 bg-neutral-900 rounded-xl h-12 w-12 flex items-center justify-center'
className='p-3 bg-neutral-900 rounded-xl h-13 w-13 flex items-center justify-center'
>
<MaterialCommunityIcons
name='cellphone-lock'
@@ -584,7 +442,7 @@ const Login: React.FC = () => {
<View className='absolute bottom-0 left-0 w-full px-4 mb-2' />
</View>
) : (
<View className='flex flex-col flex-1 items-center justify-center w-full'>
<View className='flex flex-col h-full items-center justify-center w-full'>
<View className='flex flex-col gap-y-2 px-4 w-full -mt-36'>
<Image
style={{
@@ -633,25 +491,11 @@ const Login: React.FC = () => {
onServerSelect={async (s) => {
await handleConnect(s.address);
}}
onQuickLogin={handleQuickLoginWithSavedCredential}
onPasswordLogin={handlePasswordLogin}
onAddAccount={handleAddAccount}
/>
</View>
</View>
)}
</KeyboardAvoidingView>
{/* Save Account Modal */}
<SaveAccountModal
visible={showSaveModal}
onClose={() => {
setShowSaveModal(false);
setPendingLogin(null);
}}
onSave={handleSaveAccountConfirm}
username={pendingLogin?.username || credentials.username}
/>
</SafeAreaView>
);
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

View File

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

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

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

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

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

Before

Width:  |  Height:  |  Size: 1.6 KiB

View File

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

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

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

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

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

View File

@@ -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"],
};
};

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