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
106 changed files with 1527 additions and 6247 deletions

View File

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

View File

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

View File

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

View File

@@ -1,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.**

12
.github/crowdin.yml vendored
View File

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

View File

@@ -5,8 +5,6 @@
and to ensure all necessary checks are completed before merging. and to ensure all necessary checks are completed before merging.
--> -->
# 📦 Pull Request
## 🔖 Summary ## 🔖 Summary
<!-- <!--
A concise description of the changes introduced by this PR. 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) - 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 ### ⚠️ Breaking Changes
<!-- List any breaking API/contract changes and migration guidance. If none, write “None”. --> <!-- 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. --> <!-- Hot paths, memory/CPU/latency implications, benchmarks if available. -->
### 🖼️ Screenshots / GIFs (if UI) ### 🖼️ 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 ## ✅ 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,280 +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@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 }}-${{ 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@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
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@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
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@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
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@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 }}-${{ 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@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
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@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
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@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
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-15
name: 🍎 Build iOS IPA (Phone)
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
- 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@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
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-15
# 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 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

@@ -32,7 +32,7 @@ jobs:
bun-version: latest bun-version: latest
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with: with:
path: | path: |
~/.bun/install/cache ~/.bun/install/cache

View File

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

View File

@@ -1,50 +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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
fetch-depth: 0
- name: 🌐 Sync Translations with Crowdin
uses: crowdin/github-action@0749939f635900a2521aa6aac7a3766642b2dc71 # v2.11.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: true
export_only_approved: false
# Commit customization
commit_message: "feat(i18n): update translations from Crowdin"
env:
GITHUB_TOKEN: ${{ secrets.CROWDIN_GITHUB_TOKEN }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}

View File

@@ -57,7 +57,7 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: Dependency Review - name: Dependency Review
uses: actions/dependency-review-action@56339e523c0409420f6c2c9a2f4292bbb3c07dd3 # v4.8.0 uses: actions/dependency-review-action@595b5aeba73380359d98a5e087f648dbb0edce1b # v4.7.3
with: with:
fail-on-severity: high fail-on-severity: high
base-ref: ${{ github.event.pull_request.base.sha || 'develop' }} base-ref: ${{ github.event.pull_request.base.sha || 'develop' }}
@@ -65,7 +65,6 @@ jobs:
expo-doctor: expo-doctor:
name: 🚑 Expo Doctor Check name: 🚑 Expo Doctor Check
if: false
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- name: 🛒 Checkout repository - name: 🛒 Checkout repository
@@ -107,7 +106,7 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: "🟢 Setup Node.js" - name: "🟢 Setup Node.js"
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with: with:
node-version: '24.x' node-version: '24.x'

View File

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

View File

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

View File

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

72
.gitignore vendored
View File

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

View File

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

176
.vscode/settings.json vendored
View File

@@ -1,178 +1,24 @@
{ {
// ==========================================
// FORMATTING & LINTING
// ==========================================
// Biome as default formatter
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true,
"editor.formatOnPaste": true,
"editor.formatOnType": false,
// Language-specific formatters
"[javascript]": { "[javascript]": {
"editor.defaultFormatter": "biomejs.biome", "editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true "editor.formatOnSave": true
}, },
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true
},
"[typescriptreact]": { "[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome", "editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true "editor.formatOnSave": true
}, },
"prettier.printWidth": 120,
"[swift]": {
"editor.defaultFormatter": "sswg.swift-lang"
},
"editor.formatOnSave": true,
"editor.defaultFormatter": "biomejs.biome",
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true
},
"[javascriptreact]": { "[javascriptreact]": {
"editor.defaultFormatter": "biomejs.biome", "editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true "editor.formatOnSave": true
}, }
"[json]": {
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true
},
"[jsonc]": {
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true
},
"[swift]": {
"editor.insertSpaces": true,
"editor.tabSize": 2
},
// ==========================================
// TYPESCRIPT & JAVASCRIPT
// ==========================================
// TypeScript performance optimizations
"typescript.preferences.includePackageJsonAutoImports": "auto",
"typescript.suggest.autoImports": true,
"typescript.updateImportsOnFileMove.enabled": "always",
"typescript.preferences.preferTypeOnlyAutoImports": true,
"typescript.preferences.importModuleSpecifier": "relative",
"typescript.preferences.includeCompletionsForImportStatements": true,
"typescript.preferences.includeCompletionsWithSnippetText": true,
// JavaScript settings
"javascript.preferences.importModuleSpecifier": "relative",
"javascript.suggest.autoImports": true,
"javascript.updateImportsOnFileMove.enabled": "always",
// ==========================================
// REACT NATIVE & EXPO
// ==========================================
// File associations for React Native
"files.associations": {
"*.expo.ts": "typescript",
"*.expo.tsx": "typescriptreact",
"*.expo.js": "javascript",
"*.expo.jsx": "javascriptreact",
"metro.config.js": "javascript",
"babel.config.js": "javascript",
"app.config.js": "javascript",
"eas.json": "jsonc"
},
// React Native specific settings
"emmet.includeLanguages": {
"typescriptreact": "html",
"javascriptreact": "html"
},
"emmet.triggerExpansionOnTab": true,
// Exclude build directories from search
"search.exclude": {
"**/node_modules": true
},
// ==========================================
// EDITOR PERFORMANCE & UX
// ==========================================
// Performance optimizations
"editor.largeFileOptimizations": true,
"files.watcherExclude": {
"**/.git/objects/**": true,
"**/.git/subtree-cache/**": true,
"**/node_modules/**": true,
"**/.expo/**": true,
"**/ios/**": true,
"**/android/**": true,
"**/build/**": true,
"**/dist/**": true
},
// Better editor behavior
"editor.suggestSelection": "first",
"editor.quickSuggestions": {
"strings": true,
"comments": true,
"other": true
},
"editor.snippetSuggestions": "top",
"editor.tabCompletion": "on",
"editor.wordBasedSuggestions": "off",
// ==========================================
// TERMINAL & DEVELOPMENT
// ==========================================
// Terminal settings for Bun (Windows-specific)
"terminal.integrated.profiles.windows": {
"Command Prompt": {
"path": "C:\\Windows\\System32\\cmd.exe",
"env": {
"PATH": "${env:PATH};./node_modules/.bin"
}
}
},
// ==========================================
// WORKSPACE & NAVIGATION
// ==========================================
// Better workspace navigation
"explorer.fileNesting.enabled": true,
"explorer.fileNesting.expand": false,
"explorer.fileNesting.patterns": {
"*.ts": "${capture}.js",
"*.tsx": "${capture}.js",
"*.js": "${capture}.js,${capture}.js.map,${capture}.min.js,${capture}.d.ts",
"*.jsx": "${capture}.js",
"package.json": "package-lock.json,yarn.lock,bun.lock,bun.lockb,.yarnrc,.yarnrc.yml",
"tsconfig.json": "tsconfig.*.json",
".env": ".env.*",
"app.json": "app.config.js,eas.json,expo-env.d.ts",
"README.md": "LICENSE.txt,SECURITY.md,CODE_OF_CONDUCT.md,CONTRIBUTING.md"
},
// Better breadcrumbs and navigation
"breadcrumbs.enabled": true,
"outline.showVariables": true,
"outline.showConstants": true,
// ==========================================
// GIT & VERSION CONTROL
// ==========================================
// Git integration
"git.autofetch": true,
"git.enableSmartCommit": true,
"git.confirmSync": false,
"git.ignoreLimitWarning": true,
// ==========================================
// CODE QUALITY & ERRORS
// ==========================================
// Better error detection
"typescript.validate.enable": true,
"javascript.validate.enable": true,
"editor.codeActionsOnSave": {
"source.fixAll": "explicit",
"source.organizeImports": "explicit"
},
// Problem matcher for better error display
"typescript.tsc.autoDetect": "on"
} }

View File

@@ -1,9 +1,5 @@
module.exports = ({ config }) => { module.exports = ({ config }) => {
if (process.env.EXPO_TV === "1") { if (process.env.EXPO_TV !== "1") {
// Add TV-specific plugin for TV builds
config.plugins.push("@react-native-tvos/config-tv");
} else {
// Add non-TV specific plugins for phone builds
config.plugins.push("expo-background-task"); config.plugins.push("expo-background-task");
config.plugins.push([ config.plugins.push([

View File

@@ -2,7 +2,7 @@
"expo": { "expo": {
"name": "Streamyfin", "name": "Streamyfin",
"slug": "streamyfin", "slug": "streamyfin",
"version": "0.39.0", "version": "0.35.1",
"orientation": "default", "orientation": "default",
"icon": "./assets/images/icon.png", "icon": "./assets/images/icon.png",
"scheme": "streamyfin", "scheme": "streamyfin",
@@ -37,7 +37,7 @@
}, },
"android": { "android": {
"jsEngine": "hermes", "jsEngine": "hermes",
"versionCode": 71, "versionCode": 67,
"adaptiveIcon": { "adaptiveIcon": {
"foregroundImage": "./assets/images/icon-android-plain.png", "foregroundImage": "./assets/images/icon-android-plain.png",
"monochromeImage": "./assets/images/icon-android-themed.png", "monochromeImage": "./assets/images/icon-android-themed.png",
@@ -49,10 +49,10 @@
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK", "android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
"android.permission.WRITE_SETTINGS" "android.permission.WRITE_SETTINGS"
], ],
"blockedPermissions": ["android.permission.ACTIVITY_RECOGNITION"],
"googleServicesFile": "./google-services.json" "googleServicesFile": "./google-services.json"
}, },
"plugins": [ "plugins": [
"@react-native-tvos/config-tv",
"expo-router", "expo-router",
"expo-font", "expo-font",
[ [
@@ -121,7 +121,7 @@
[ [
"expo-splash-screen", "expo-splash-screen",
{ {
"backgroundColor": "#010101", "backgroundColor": "#2e2e2e",
"image": "./assets/images/icon-ios-plain.png", "image": "./assets/images/icon-ios-plain.png",
"imageWidth": 100 "imageWidth": 100
} }

View File

@@ -12,7 +12,7 @@ export default function CustomMenuLayout() {
headerShown: true, headerShown: true,
headerLargeTitle: true, headerLargeTitle: true,
headerTitle: t("tabs.custom_links"), headerTitle: t("tabs.custom_links"),
headerBlurEffect: "none", headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
}} }}

View File

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

View File

@@ -21,16 +21,19 @@ export default function IndexLayout() {
name='index' name='index'
options={{ options={{
headerShown: !Platform.isTV, headerShown: !Platform.isTV,
headerLargeTitle: true,
headerTitle: t("tabs.home"), headerTitle: t("tabs.home"),
headerBlurEffect: "none", headerBlurEffect: "prominent",
headerLargeStyle: {
backgroundColor: "black",
},
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
headerRight: () => ( headerRight: () => (
<View className='flex flex-row items-center px-2'> <View className='flex flex-row items-center space-x-2'>
{!Platform.isTV && ( {!Platform.isTV && (
<> <>
<Chromecast.Chromecast background='transparent' /> <Chromecast.Chromecast />
{user?.Policy?.IsAdministrator && <SessionsButton />} {user?.Policy?.IsAdministrator && <SessionsButton />}
<SettingsButton /> <SettingsButton />
</> </>
@@ -135,13 +138,14 @@ const SessionsButton = () => {
onPress={() => { onPress={() => {
router.push("/(auth)/sessions"); router.push("/(auth)/sessions");
}} }}
className='mr-4'
> >
<Ionicons <View className='mr-4'>
name='play-circle' <Ionicons
color={sessions.length === 0 ? "white" : "#9333ea"} name='play-circle'
size={28} color={sessions.length === 0 ? "white" : "#9333ea"}
/> size={25}
/>
</View>
</TouchableOpacity> </TouchableOpacity>
); );
}; };

View File

@@ -90,19 +90,6 @@ export default function page() {
} }
}, [downloadedFiles]); }, [downloadedFiles]);
const otherMedia = useMemo(() => {
try {
return (
downloadedFiles?.filter(
(f) => f.item.Type !== "Movie" && f.item.Type !== "Episode",
) || []
);
} catch {
setShowMigration(true);
return [];
}
}, [downloadedFiles]);
useEffect(() => { useEffect(() => {
navigation.setOptions({ navigation.setOptions({
headerRight: () => ( headerRight: () => (
@@ -141,30 +128,8 @@ export default function page() {
writeToLog("ERROR", reason); writeToLog("ERROR", reason);
toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries")); toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
}); });
const deleteOtherMedia = () =>
Promise.all(
otherMedia.map((item) =>
deleteFileByType(item.item.Type)
.then(() =>
toast.success(
t("home.downloads.toasts.deleted_media_successfully", {
type: item.item.Type,
}),
),
)
.catch((reason) => {
writeToLog("ERROR", reason);
toast.error(
t("home.downloads.toasts.failed_to_delete_media", {
type: item.item.Type,
}),
);
}),
),
);
const deleteAllMedia = async () => const deleteAllMedia = async () =>
await Promise.all([deleteMovies(), deleteShows(), deleteOtherMedia()]); await Promise.all([deleteMovies(), deleteShows()]);
return ( return (
<> <>
@@ -273,34 +238,6 @@ export default function page() {
</ScrollView> </ScrollView>
</View> </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 && ( {downloadedFiles?.length === 0 && (
<View className='flex px-4'> <View className='flex px-4'>
<Text className='opacity-50'> <Text className='opacity-50'>
@@ -336,11 +273,6 @@ export default function page() {
<Button color='purple' onPress={deleteShows}> <Button color='purple' onPress={deleteShows}>
{t("home.downloads.delete_all_tvseries_button")} {t("home.downloads.delete_all_tvseries_button")}
</Button> </Button>
{otherMedia.length > 0 && (
<Button color='purple' onPress={deleteOtherMedia}>
{t("home.downloads.delete_all_other_media_button")}
</Button>
)}
<Button color='red' onPress={deleteAllMedia}> <Button color='red' onPress={deleteAllMedia}>
{t("home.downloads.delete_all_button")} {t("home.downloads.delete_all_button")}
</Button> </Button>

View File

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

View File

@@ -139,15 +139,7 @@ const Page: React.FC = () => {
} }
requestMedia(mediaTitle, body, refetch); requestMedia(mediaTitle, body, refetch);
}, [ }, [details, result, requestMedia, hasAdvancedRequestPermission]);
details,
result,
requestMedia,
hasAdvancedRequestPermission,
mediaTitle,
refetch,
mediaType,
]);
const isAnime = useMemo( const isAnime = useMemo(
() => () =>
@@ -285,16 +277,12 @@ const Page: React.FC = () => {
<Button <Button
className='flex-1 bg-purple-600/50 border-purple-400 ring-purple-400 text-purple-100' className='flex-1 bg-purple-600/50 border-purple-400 ring-purple-400 text-purple-100'
onPress={() => { onPress={() => {
router.push({ const url =
pathname: mediaType === MediaType.MOVIE
mediaType === MediaType.MOVIE ? `/(auth)/(tabs)/(search)/items/page?id=${details?.mediaInfo.jellyfinMediaId}`
? "/(auth)/(tabs)/(search)/items/page" : `/(auth)/(tabs)/(search)/series/${details?.mediaInfo.jellyfinMediaId}`;
: "/(auth)/(tabs)/(search)/series/[id]", // @ts-expect-error
params: router.push(url);
mediaType === MediaType.MOVIE
? { id: details?.mediaInfo.jellyfinMediaId }
: { id: details?.mediaInfo.jellyfinMediaId },
});
}} }}
iconLeft={ iconLeft={
<Ionicons name='play-outline' size={20} color='white' /> <Ionicons name='play-outline' size={20} color='white' />
@@ -304,7 +292,7 @@ const Page: React.FC = () => {
borderStyle: "solid", borderStyle: "solid",
}} }}
> >
<Text className='text-sm'>{t("common.play")}</Text> <Text className='text-sm'>Play</Text>
</Button> </Button>
</View> </View>
) )

View File

@@ -77,13 +77,7 @@ const Page = () => {
} else { } else {
_setSortBy([SortByOption.SortName]); _setSortBy([SortByOption.SortName]);
} }
}, [ }, []);
libraryId,
sortOrderPreference,
sortByPreference,
_setSortOrder,
_setSortBy,
]);
const setSortBy = useCallback( const setSortBy = useCallback(
(sortBy: SortByOption[]) => { (sortBy: SortByOption[]) => {
@@ -93,7 +87,7 @@ const Page = () => {
} }
_setSortBy(sortBy); _setSortBy(sortBy);
}, },
[libraryId, sortByPreference, setSortByPreference, _setSortBy], [libraryId, sortByPreference],
); );
const setSortOrder = useCallback( const setSortOrder = useCallback(
@@ -107,7 +101,7 @@ const Page = () => {
} }
_setSortOrder(sortOrder); _setSortOrder(sortOrder);
}, },
[libraryId, sortOrderPreference, setOderByPreference, _setSortOrder], [libraryId, sortOrderPreference],
); );
const nrOfCols = useMemo(() => { const nrOfCols = useMemo(() => {

View File

@@ -1,85 +1,224 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { Stack } from "expo-router"; import { Stack } from "expo-router";
import { useState } from "react"; import { Platform } from "react-native";
import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity } from "react-native";
import { LibraryOptionsSheet } from "@/components/settings/LibraryOptionsSheet";
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack"; import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { useTranslation } from "react-i18next";
export default function IndexLayout() { export default function IndexLayout() {
const { settings, updateSettings, pluginSettings } = useSettings(); const { settings, updateSettings, pluginSettings } = useSettings();
const [optionsSheetOpen, setOptionsSheetOpen] = useState(false);
const { t } = useTranslation(); const { t } = useTranslation();
if (!settings?.libraryOptions) return null; if (!settings?.libraryOptions) return null;
return ( return (
<> <Stack>
<Stack> <Stack.Screen
<Stack.Screen name='index'
name='index' options={{
options={{ headerShown: !Platform.isTV,
headerShown: !Platform.isTV, headerLargeTitle: true,
headerTitle: t("tabs.library"), headerTitle: t("tabs.library"),
headerBlurEffect: "none", headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios", headerLargeStyle: {
headerShadowVisible: false, backgroundColor: "black",
headerRight: () => },
!pluginSettings?.libraryOptions?.locked && headerTransparent: Platform.OS === "ios",
!Platform.isTV && ( headerShadowVisible: false,
<TouchableOpacity headerRight: () =>
onPress={() => setOptionsSheetOpen(true)} !pluginSettings?.libraryOptions?.locked &&
className='flex flex-row items-center justify-center w-9 h-9' !Platform.isTV && (
> <DropdownMenu.Root>
<DropdownMenu.Trigger>
<Ionicons <Ionicons
name='ellipsis-horizontal-outline' name='ellipsis-horizontal-outline'
size={24} size={24}
color='white' color='white'
/> />
</TouchableOpacity> </DropdownMenu.Trigger>
), <DropdownMenu.Content
}} align={"end"}
/> alignOffset={-10}
<Stack.Screen avoidCollisions={false}
name='[libraryId]' collisionPadding={0}
options={{ loop={false}
title: "", side={"bottom"}
headerShown: !Platform.isTV, sideOffset={10}
headerBlurEffect: "none", >
headerTransparent: Platform.OS === "ios", <DropdownMenu.Label>
headerShadowVisible: false, {t("library.options.display")}
}} </DropdownMenu.Label>
/> <DropdownMenu.Group key='display-group'>
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => ( <DropdownMenu.Sub>
<Stack.Screen key={name} name={name} options={options} /> <DropdownMenu.SubTrigger key='image-style-trigger'>
))} {t("library.options.display")}
<Stack.Screen </DropdownMenu.SubTrigger>
name='collections/[collectionId]' <DropdownMenu.SubContent
options={{ alignOffset={-10}
title: "", avoidCollisions={true}
headerShown: !Platform.isTV, collisionPadding={0}
headerBlurEffect: "none", loop={true}
headerTransparent: Platform.OS === "ios", sideOffset={10}
headerShadowVisible: false, >
}} <DropdownMenu.CheckboxItem
/> key='display-option-1'
</Stack> value={settings.libraryOptions.display === "row"}
<LibraryOptionsSheet onValueChange={() =>
open={optionsSheetOpen} updateSettings({
setOpen={setOptionsSheetOpen} libraryOptions: {
settings={settings.libraryOptions} ...settings.libraryOptions,
updateSettings={(options) => display: "row",
updateSettings({ },
libraryOptions: { })
...settings.libraryOptions, }
...options, >
}, <DropdownMenu.ItemIndicator />
}) <DropdownMenu.ItemTitle key='display-title-1'>
} {t("library.options.row")}
disabled={pluginSettings?.libraryOptions?.locked} </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>
),
}}
/> />
</> <Stack.Screen
name='[libraryId]'
options={{
title: "",
headerShown: !Platform.isTV,
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
}}
/>
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
<Stack.Screen key={name} name={name} options={options} />
))}
<Stack.Screen
name='collections/[collectionId]'
options={{
title: "",
headerShown: !Platform.isTV,
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
}}
/>
</Stack>
); );
} }

View File

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

View File

@@ -2,7 +2,6 @@ import {
type BaseItemDto, type BaseItemDto,
type MediaSourceInfo, type MediaSourceInfo,
PlaybackOrder, PlaybackOrder,
PlaybackProgressInfo,
PlaybackStartInfo, PlaybackStartInfo,
RepeatMode, RepeatMode,
} from "@jellyfin/sdk/lib/generated-client"; } from "@jellyfin/sdk/lib/generated-client";
@@ -22,12 +21,6 @@ import { BITRATES } from "@/components/BitrateSelector";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
import { Controls } from "@/components/video-player/controls/Controls"; import { Controls } from "@/components/video-player/controls/Controls";
import {
OUTLINE_THICKNESS,
OutlineThickness,
VLC_COLORS,
VLCColor,
} from "@/constants/SubtitleConstants";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
import { usePlaybackManager } from "@/hooks/usePlaybackManager"; import { usePlaybackManager } from "@/hooks/usePlaybackManager";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
@@ -104,7 +97,7 @@ export default function page() {
/** Playback position in ticks. */ /** Playback position in ticks. */
playbackPosition?: string; playbackPosition?: string;
}>(); }>();
const { settings } = useSettings(); useSettings();
const offline = offlineStr === "true"; const offline = offlineStr === "true";
const playbackManager = usePlaybackManager(); const playbackManager = usePlaybackManager();
@@ -271,7 +264,12 @@ export default function page() {
if (isPlaying) { if (isPlaying) {
await videoRef.current?.pause(); await videoRef.current?.pause();
playbackManager.reportPlaybackProgress( playbackManager.reportPlaybackProgress(
currentPlayStateInfo() as PlaybackProgressInfo, item?.Id!,
msToTicks(progress.get()),
{
AudioStreamIndex: audioIndex ?? -1,
SubtitleStreamIndex: subtitleIndex ?? -1,
},
); );
} else { } else {
videoRef.current?.play(); videoRef.current?.play();
@@ -389,7 +387,12 @@ export default function page() {
if (!item?.Id) return; if (!item?.Id) return;
playbackManager.reportPlaybackProgress( playbackManager.reportPlaybackProgress(
currentPlayStateInfo() as PlaybackProgressInfo, item.Id,
msToTicks(progress.get()),
{
AudioStreamIndex: audioIndex ?? -1,
SubtitleStreamIndex: subtitleIndex ?? -1,
},
); );
}, },
[ [
@@ -496,7 +499,12 @@ export default function page() {
setIsPlaying(true); setIsPlaying(true);
if (item?.Id) { if (item?.Id) {
playbackManager.reportPlaybackProgress( playbackManager.reportPlaybackProgress(
currentPlayStateInfo() as PlaybackProgressInfo, item.Id,
msToTicks(progress.get()),
{
AudioStreamIndex: audioIndex ?? -1,
SubtitleStreamIndex: subtitleIndex ?? -1,
},
); );
} }
if (!Platform.isTV) await activateKeepAwakeAsync(); if (!Platform.isTV) await activateKeepAwakeAsync();
@@ -507,7 +515,12 @@ export default function page() {
setIsPlaying(false); setIsPlaying(false);
if (item?.Id) { if (item?.Id) {
playbackManager.reportPlaybackProgress( playbackManager.reportPlaybackProgress(
currentPlayStateInfo() as PlaybackProgressInfo, item.Id,
msToTicks(progress.get()),
{
AudioStreamIndex: audioIndex ?? -1,
SubtitleStreamIndex: subtitleIndex ?? -1,
},
); );
} }
if (!Platform.isTV) await deactivateKeepAwake(); if (!Platform.isTV) await deactivateKeepAwake();
@@ -563,34 +576,8 @@ export default function page() {
? allSubs.indexOf(chosenSubtitleTrack) ? allSubs.indexOf(chosenSubtitleTrack)
: [...textSubs].reverse().indexOf(chosenSubtitleTrack); : [...textSubs].reverse().indexOf(chosenSubtitleTrack);
initOptions.push(`--sub-track=${finalIndex}`); initOptions.push(`--sub-track=${finalIndex}`);
// Add VLC subtitle styling options from settings
const textColor = (settings.vlcTextColor ?? "White") as VLCColor;
const backgroundColor = (settings.vlcBackgroundColor ??
"Black") as VLCColor;
const outlineColor = (settings.vlcOutlineColor ?? "Black") as VLCColor;
const outlineThickness = (settings.vlcOutlineThickness ??
"Normal") as OutlineThickness;
const backgroundOpacity = settings.vlcBackgroundOpacity ?? 128;
const outlineOpacity = settings.vlcOutlineOpacity ?? 255;
const isBold = settings.vlcIsBold ?? false;
// Add subtitle styling options
initOptions.push(`--freetype-color=${VLC_COLORS[textColor]}`);
initOptions.push(`--freetype-background-opacity=${backgroundOpacity}`);
initOptions.push(
`--freetype-background-color=${VLC_COLORS[backgroundColor]}`,
);
initOptions.push(`--freetype-outline-opacity=${outlineOpacity}`);
initOptions.push(`--freetype-outline-color=${VLC_COLORS[outlineColor]}`);
initOptions.push(
`--freetype-outline-thickness=${OUTLINE_THICKNESS[outlineThickness]}`,
);
initOptions.push(`--sub-text-scale=${settings.subtitleSize}`);
initOptions.push("--sub-margin=40");
if (isBold) {
initOptions.push("--freetype-bold");
}
} }
if (notTranscoding && chosenAudioTrack) { if (notTranscoding && chosenAudioTrack) {
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`); initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
} }

View File

@@ -20,8 +20,8 @@ import {
} from "@/utils/background-tasks"; } from "@/utils/background-tasks";
import { import {
LogProvider, LogProvider,
writeDebugLog,
writeErrorLog, writeErrorLog,
writeInfoLog,
writeToLog, writeToLog,
} from "@/utils/log"; } from "@/utils/log";
import { storage } from "@/utils/mmkv"; import { storage } from "@/utils/mmkv";
@@ -84,19 +84,19 @@ SplashScreen.setOptions({
fade: true, fade: true,
}); });
function redirect(notification: typeof Notifications.Notification) {
const url = notification.request.content.data?.url;
if (url) {
router.push(url);
}
}
function useNotificationObserver() { function useNotificationObserver() {
useEffect(() => { useEffect(() => {
if (Platform.isTV) return; if (Platform.isTV) return;
let isMounted = true; let isMounted = true;
function redirect(notification: typeof Notifications.Notification) {
const url = notification.request.content.data?.url;
if (url) {
router.push(url);
}
}
Notifications.getLastNotificationResponseAsync().then( Notifications.getLastNotificationResponseAsync().then(
(response: { notification: any }) => { (response: { notification: any }) => {
if (!isMounted || !response?.notification) { if (!isMounted || !response?.notification) {
@@ -106,8 +106,15 @@ function useNotificationObserver() {
}, },
); );
const subscription = Notifications.addNotificationResponseReceivedListener(
(response: { notification: any }) => {
redirect(response.notification);
},
);
return () => { return () => {
isMounted = false; isMounted = false;
subscription.remove();
}; };
}, []); }, []);
} }
@@ -310,42 +317,38 @@ function Layout() {
responseListener.current = responseListener.current =
Notifications?.addNotificationResponseReceivedListener( Notifications?.addNotificationResponseReceivedListener(
(response: NotificationResponse) => { (response: NotificationResponse) => {
// redirect if internal notification
redirect(response?.notification);
// Currently the notifications supported by the plugin will send data for deep links. // Currently the notifications supported by the plugin will send data for deep links.
const { title, data } = response.notification.request.content; 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; switch (type) {
const type = (data?.type ?? "").toString().toLowerCase(); case "movie":
const itemId = data?.id; router.push(`/(auth)/(tabs)/home/items/page?id=${itemId}`);
break;
switch (type) { case "episode":
case "movie": // We just clicked a notification for an individual episode.
url = `/(auth)/(tabs)/home/items/page?id=${itemId}`; if (itemId) {
break; router.push(`/(auth)/(tabs)/home/items/page?id=${itemId}`);
case "episode": // summarized season notification for multiple episodes. Bring them to series season
// `/(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}`;
} else { } 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;
break; }
}
writeInfoLog(`Notification attempting to redirect to ${url}`);
if (url) {
router.push(url);
} }
}, },
); );
@@ -395,17 +398,12 @@ function Layout() {
appState.current.match(/inactive|background/) && appState.current.match(/inactive|background/) &&
nextAppState === "active" nextAppState === "active"
) { ) {
BackGroundDownloader.checkForExistingDownloads().catch( BackGroundDownloader.checkForExistingDownloads();
(error: unknown) => {
writeErrorLog("Failed to resume background downloads", error);
},
);
} }
}); });
BackGroundDownloader.checkForExistingDownloads().catch((error: unknown) => { BackGroundDownloader.checkForExistingDownloads();
writeErrorLog("Failed to resume background downloads", error);
});
return () => { return () => {
subscription.remove(); subscription.remove();
}; };

View File

@@ -20,6 +20,7 @@ import { Button } from "@/components/Button";
import { Input } from "@/components/common/Input"; import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery"; import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
import { PasswordInput } from "@/components/PasswordInput";
import { PreviousServersList } from "@/components/PreviousServersList"; import { PreviousServersList } from "@/components/PreviousServersList";
import { Colors } from "@/constants/Colors"; import { Colors } from "@/constants/Colors";
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider"; import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
@@ -52,6 +53,7 @@ const Login: React.FC = () => {
username: _username, username: _username,
password: _password, password: _password,
}); });
const [showPassword, setShowPassword] = useState<boolean>(false);
/** /**
* A way to auto login based on a link * A way to auto login based on a link
@@ -271,30 +273,27 @@ const Login: React.FC = () => {
textContentType='oneTimeCode' textContentType='oneTimeCode'
clearButtonMode='while-editing' clearButtonMode='while-editing'
maxLength={500} maxLength={500}
extraClassName='mb-4' extraClassName='mb-2'
/> />
{/* Password */} {/* Password */}
<Input <View className='relative mb-2'>
placeholder={t("login.password_placeholder")} <PasswordInput
onChangeText={(text: string) => value={credentials.password}
setCredentials({ ...credentials, password: text }) onChangeText={(text: string) =>
} setCredentials({ ...credentials, password: text })
value={credentials.password} }
secureTextEntry placeholder={t("login.password_placeholder")}
keyboardType='default' showPassword={showPassword}
returnKeyType='done' onShowPasswordChange={setShowPassword}
autoCapitalize='none' topOffset={16}
textContentType='password' layout='tv'
clearButtonMode='while-editing' />
maxLength={500}
extraClassName='mb-4'
/>
<View className='mt-4'>
<Button onPress={handleLogin}>{t("login.login_button")}</Button>
</View> </View>
<View className='mt-3'> <View className='mt-3'>
<Button onPress={handleLogin}>{t("login.login_button")}</Button>
</View>
<View className='mt-2'>
<Button <Button
onPress={handleQuickConnect} onPress={handleQuickConnect}
className='bg-neutral-800 border border-neutral-700' className='bg-neutral-800 border border-neutral-700'
@@ -348,7 +347,7 @@ const Login: React.FC = () => {
</View> </View>
{/* Lists stay full width but inside max width container */} {/* Lists stay full width but inside max width container */}
<View className='mt-2'> <View className='mt-4'>
<JellyfinServerDiscovery <JellyfinServerDiscovery
onServerSelect={async (server: any) => { onServerSelect={async (server: any) => {
setServerURL(server.address); setServerURL(server.address);
@@ -402,22 +401,22 @@ const Login: React.FC = () => {
textContentType='oneTimeCode' textContentType='oneTimeCode'
clearButtonMode='while-editing' clearButtonMode='while-editing'
maxLength={500} maxLength={500}
extraClassName=''
/> />
<Input <View className='relative'>
placeholder={t("login.password_placeholder")} <PasswordInput
onChangeText={(text) => value={credentials.password}
setCredentials({ ...credentials, password: text }) onChangeText={(text) =>
} setCredentials({ ...credentials, password: text })
value={credentials.password} }
secureTextEntry placeholder={t("login.password_placeholder")}
keyboardType='default' showPassword={showPassword}
returnKeyType='done' onShowPasswordChange={setShowPassword}
autoCapitalize='none' topOffset={12}
textContentType='password' layout='mobile'
clearButtonMode='while-editing' />
maxLength={500} </View>
/>
<View className='flex flex-row items-center justify-between'> <View className='flex flex-row items-center justify-between'>
<Button <Button
onPress={handleLogin} onPress={handleLogin}
@@ -428,7 +427,7 @@ const Login: React.FC = () => {
</Button> </Button>
<TouchableOpacity <TouchableOpacity
onPress={handleQuickConnect} 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 <MaterialCommunityIcons
name='cellphone-lock' name='cellphone-lock'

View File

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

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

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

681
bun.lock

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,776 +0,0 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import {
getItemsApi,
getTvShowsApi,
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { LinearGradient } from "expo-linear-gradient";
import { useRouter } from "expo-router";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Dimensions, Pressable, TouchableOpacity, View } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
Easing,
runOnJS,
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { ItemImage } from "./common/ItemImage";
import { getItemNavigation } from "./common/TouchableItemRouter";
import type { SelectedOptions } from "./ItemContent";
import { PlayButton } from "./PlayButton";
import { PlayedStatus } from "./PlayedStatus";
interface AppleTVCarouselProps {
initialIndex?: number;
onItemChange?: (index: number) => void;
}
const { width: screenWidth, height: screenHeight } = Dimensions.get("window");
// Layout Constants
const CAROUSEL_HEIGHT = screenHeight / 1.45;
const GRADIENT_HEIGHT_TOP = 150;
const GRADIENT_HEIGHT_BOTTOM = 150;
const LOGO_HEIGHT = 80;
// Position Constants
const LOGO_BOTTOM_POSITION = 210;
const GENRES_BOTTOM_POSITION = 170;
const CONTROLS_BOTTOM_POSITION = 100;
const DOTS_BOTTOM_POSITION = 60;
// Size Constants
const DOT_HEIGHT = 6;
const DOT_ACTIVE_WIDTH = 20;
const DOT_INACTIVE_WIDTH = 12;
const PLAY_BUTTON_SKELETON_HEIGHT = 50;
const PLAYED_STATUS_SKELETON_SIZE = 40;
const TEXT_SKELETON_HEIGHT = 20;
const TEXT_SKELETON_WIDTH = 250;
const _EMPTY_STATE_ICON_SIZE = 64;
// Spacing Constants
const HORIZONTAL_PADDING = 40;
const DOT_PADDING = 2;
const DOT_GAP = 4;
const CONTROLS_GAP = 20;
const _TEXT_MARGIN_TOP = 16;
// Border Radius Constants
const DOT_BORDER_RADIUS = 3;
const LOGO_SKELETON_BORDER_RADIUS = 8;
const TEXT_SKELETON_BORDER_RADIUS = 4;
const PLAY_BUTTON_BORDER_RADIUS = 25;
const PLAYED_STATUS_BORDER_RADIUS = 20;
// Animation Constants
const DOT_ANIMATION_DURATION = 300;
const CAROUSEL_TRANSITION_DURATION = 250;
const PAN_ACTIVE_OFFSET = 10;
const TRANSLATION_THRESHOLD = 0.2;
const VELOCITY_THRESHOLD = 400;
// Text Constants
const GENRES_FONT_SIZE = 16;
const _EMPTY_STATE_FONT_SIZE = 18;
const TEXT_SHADOW_RADIUS = 2;
const MAX_GENRES_COUNT = 2;
const MAX_BUTTON_WIDTH = 300;
// Opacity Constants
const OVERLAY_OPACITY = 0.4;
const DOT_INACTIVE_OPACITY = 0.6;
const TEXT_OPACITY = 0.9;
// Color Constants
const SKELETON_BACKGROUND_COLOR = "#1a1a1a";
const SKELETON_ELEMENT_COLOR = "#333";
const SKELETON_ACTIVE_DOT_COLOR = "#666";
const _EMPTY_STATE_COLOR = "#666";
const TEXT_SHADOW_COLOR = "rgba(0, 0, 0, 0.8)";
const LOGO_WIDTH_PERCENTAGE = "80%";
const DotIndicator = ({
index,
currentIndex,
onPress,
}: {
index: number;
currentIndex: number;
onPress: (index: number) => void;
}) => {
const isActive = index === currentIndex;
const animatedStyle = useAnimatedStyle(() => ({
width: withTiming(isActive ? DOT_ACTIVE_WIDTH : DOT_INACTIVE_WIDTH, {
duration: DOT_ANIMATION_DURATION,
easing: Easing.out(Easing.quad),
}),
opacity: withTiming(isActive ? 1 : DOT_INACTIVE_OPACITY, {
duration: DOT_ANIMATION_DURATION,
easing: Easing.out(Easing.quad),
}),
}));
return (
<Pressable
onPress={() => onPress(index)}
style={{
padding: DOT_PADDING, // Increase touch area
}}
>
<Animated.View
style={[
{
height: DOT_HEIGHT,
backgroundColor: isActive ? "white" : "rgba(255, 255, 255, 0.4)",
borderRadius: DOT_BORDER_RADIUS,
},
animatedStyle,
]}
/>
</Pressable>
);
};
export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
initialIndex = 0,
onItemChange,
}) => {
const { settings } = useSettings();
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const { isConnected, serverConnected } = useNetworkStatus();
const router = useRouter();
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const translateX = useSharedValue(-currentIndex * screenWidth);
const isQueryEnabled =
!!api && !!user?.Id && isConnected && serverConnected === true;
const { data: continueWatchingData, isLoading: continueWatchingLoading } =
useQuery({
queryKey: ["appleTVCarousel", "continueWatching", user?.Id],
queryFn: async () => {
if (!api || !user?.Id) return [];
const response = await getItemsApi(api).getResumeItems({
userId: user.Id,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
includeItemTypes: ["Movie", "Series", "Episode"],
fields: ["Genres"],
limit: 2,
});
return response.data.Items || [];
},
enabled: isQueryEnabled,
staleTime: 60 * 1000,
});
const { data: nextUpData, isLoading: nextUpLoading } = useQuery({
queryKey: ["appleTVCarousel", "nextUp", user?.Id],
queryFn: async () => {
if (!api || !user?.Id) return [];
const response = await getTvShowsApi(api).getNextUp({
userId: user.Id,
fields: ["MediaSourceCount", "Genres"],
limit: 2,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
enableResumable: false,
});
return response.data.Items || [];
},
enabled: isQueryEnabled,
staleTime: 60 * 1000,
});
const { data: recentlyAddedData, isLoading: recentlyAddedLoading } = useQuery(
{
queryKey: ["appleTVCarousel", "recentlyAdded", user?.Id],
queryFn: async () => {
if (!api || !user?.Id) return [];
const response = await getUserLibraryApi(api).getLatestMedia({
userId: user.Id,
limit: 2,
fields: ["PrimaryImageAspectRatio", "Path", "Genres"],
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
});
return response.data || [];
},
enabled: isQueryEnabled,
staleTime: 60 * 1000,
},
);
const items = useMemo(() => {
const continueItems = continueWatchingData ?? [];
const nextItems = nextUpData ?? [];
const recentItems = recentlyAddedData ?? [];
return [
...continueItems.slice(0, 2),
...nextItems.slice(0, 2),
...recentItems.slice(0, 2),
];
}, [continueWatchingData, nextUpData, recentlyAddedData]);
const isLoading =
continueWatchingLoading || nextUpLoading || recentlyAddedLoading;
const hasItems = items.length > 0;
// Only get play settings if we have valid items
const currentItem = hasItems ? items[currentIndex] : null;
// Extract colors for the current item only (for performance)
const currentItemColors = useImageColorsReturn({ item: currentItem });
// Create a fallback empty item for useDefaultPlaySettings when no item is available
const itemForPlaySettings = currentItem || { MediaSources: [] };
const {
defaultAudioIndex,
defaultBitrate,
defaultMediaSource,
defaultSubtitleIndex,
} = useDefaultPlaySettings(itemForPlaySettings as BaseItemDto, settings);
const [selectedOptions, setSelectedOptions] = useState<
SelectedOptions | undefined
>(undefined);
useEffect(() => {
// Only set options if we have valid current item
if (currentItem) {
setSelectedOptions({
bitrate: defaultBitrate,
mediaSource: defaultMediaSource,
subtitleIndex: defaultSubtitleIndex ?? -1,
audioIndex: defaultAudioIndex,
});
} else {
setSelectedOptions(undefined);
}
}, [
defaultAudioIndex,
defaultBitrate,
defaultSubtitleIndex,
defaultMediaSource,
currentIndex,
currentItem,
]);
useEffect(() => {
if (!hasItems) {
setCurrentIndex(initialIndex);
translateX.value = -initialIndex * screenWidth;
return;
}
setCurrentIndex((prev) => {
const newIndex = Math.min(prev, items.length - 1);
translateX.value = -newIndex * screenWidth;
return newIndex;
});
}, [hasItems, items, initialIndex, translateX]);
useEffect(() => {
if (hasItems) {
onItemChange?.(currentIndex);
}
}, [hasItems, currentIndex, onItemChange]);
const goToIndex = useCallback(
(index: number) => {
if (!hasItems || index < 0 || index >= items.length) return;
translateX.value = withTiming(-index * screenWidth, {
duration: CAROUSEL_TRANSITION_DURATION, // Slightly longer for smoother feel
easing: Easing.bezier(0.25, 0.46, 0.45, 0.94), // iOS-like smooth deceleration curve
});
setCurrentIndex(index);
onItemChange?.(index);
},
[hasItems, items, onItemChange, translateX],
);
const navigateToItem = useCallback(
(item: BaseItemDto) => {
const navigation = getItemNavigation(item, "(home)");
router.push(navigation as any);
},
[router],
);
const panGesture = Gesture.Pan()
.activeOffsetX([-PAN_ACTIVE_OFFSET, PAN_ACTIVE_OFFSET])
.onUpdate((event) => {
translateX.value = -currentIndex * screenWidth + event.translationX;
})
.onEnd((event) => {
const velocity = event.velocityX;
const translation = event.translationX;
let newIndex = currentIndex;
// Improved thresholds for more responsive navigation
if (
Math.abs(translation) > screenWidth * TRANSLATION_THRESHOLD ||
Math.abs(velocity) > VELOCITY_THRESHOLD
) {
if (translation > 0 && currentIndex > 0) {
newIndex = currentIndex - 1;
} else if (
translation < 0 &&
items &&
currentIndex < items.length - 1
) {
newIndex = currentIndex + 1;
}
}
runOnJS(goToIndex)(newIndex);
});
const containerAnimatedStyle = useAnimatedStyle(() => {
return {
transform: [{ translateX: translateX.value }],
};
});
const renderDots = () => {
if (!hasItems || items.length <= 1) return null;
return (
<View
style={{
position: "absolute",
bottom: DOTS_BOTTOM_POSITION,
left: 0,
right: 0,
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
gap: DOT_GAP,
}}
>
{items.map((_, index) => (
<DotIndicator
key={index}
index={index}
currentIndex={currentIndex}
onPress={goToIndex}
/>
))}
</View>
);
};
const renderSkeletonLoader = () => {
return (
<View
style={{
width: screenWidth,
height: CAROUSEL_HEIGHT,
backgroundColor: "#000",
}}
>
{/* Background Skeleton */}
<View
style={{
width: "100%",
height: "100%",
backgroundColor: SKELETON_BACKGROUND_COLOR,
position: "absolute",
}}
/>
{/* Dark Overlay Skeleton */}
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: `rgba(0, 0, 0, ${OVERLAY_OPACITY})`,
}}
/>
{/* Gradient Fade to Black Top Skeleton */}
<LinearGradient
colors={["rgba(0,0,0,1)", "rgba(0,0,0,0.8)", "transparent"]}
style={{
position: "absolute",
left: 0,
right: 0,
top: 0,
height: GRADIENT_HEIGHT_TOP,
}}
/>
{/* Gradient Fade to Black Bottom Skeleton */}
<LinearGradient
colors={["transparent", "rgba(0,0,0,0.8)", "rgba(0,0,0,1)"]}
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
height: GRADIENT_HEIGHT_BOTTOM,
}}
/>
{/* Logo Skeleton */}
<View
style={{
position: "absolute",
bottom: LOGO_BOTTOM_POSITION,
left: 0,
right: 0,
paddingHorizontal: HORIZONTAL_PADDING,
alignItems: "center",
}}
>
<View
style={{
height: LOGO_HEIGHT,
width: LOGO_WIDTH_PERCENTAGE,
backgroundColor: SKELETON_ELEMENT_COLOR,
borderRadius: LOGO_SKELETON_BORDER_RADIUS,
}}
/>
</View>
{/* Type and Genres Skeleton */}
<View
style={{
position: "absolute",
bottom: GENRES_BOTTOM_POSITION,
left: 0,
right: 0,
paddingHorizontal: HORIZONTAL_PADDING,
alignItems: "center",
}}
>
<View
style={{
height: TEXT_SKELETON_HEIGHT,
width: TEXT_SKELETON_WIDTH,
backgroundColor: SKELETON_ELEMENT_COLOR,
borderRadius: TEXT_SKELETON_BORDER_RADIUS,
}}
/>
</View>
{/* Controls Skeleton */}
<View
style={{
position: "absolute",
bottom: CONTROLS_BOTTOM_POSITION,
left: 0,
right: 0,
paddingHorizontal: HORIZONTAL_PADDING,
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
gap: CONTROLS_GAP,
}}
>
{/* Play Button Skeleton */}
<View
style={{
height: PLAY_BUTTON_SKELETON_HEIGHT,
flex: 1,
maxWidth: MAX_BUTTON_WIDTH,
backgroundColor: SKELETON_ELEMENT_COLOR,
borderRadius: PLAY_BUTTON_BORDER_RADIUS,
}}
/>
{/* Played Status Skeleton */}
<View
style={{
width: PLAYED_STATUS_SKELETON_SIZE,
height: PLAYED_STATUS_SKELETON_SIZE,
backgroundColor: SKELETON_ELEMENT_COLOR,
borderRadius: PLAYED_STATUS_BORDER_RADIUS,
}}
/>
</View>
{/* Dots Skeleton */}
<View
style={{
position: "absolute",
bottom: DOTS_BOTTOM_POSITION,
left: 0,
right: 0,
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
gap: DOT_GAP,
}}
>
{[1, 2, 3].map((_, index) => (
<View
key={index}
style={{
width: index === 0 ? DOT_ACTIVE_WIDTH : DOT_INACTIVE_WIDTH,
height: DOT_HEIGHT,
backgroundColor:
index === 0
? SKELETON_ACTIVE_DOT_COLOR
: SKELETON_ELEMENT_COLOR,
borderRadius: DOT_BORDER_RADIUS,
}}
/>
))}
</View>
</View>
);
};
const renderItem = (item: BaseItemDto, _index: number) => {
const itemLogoUrl = api ? getLogoImageUrlById({ api, item }) : null;
return (
<View
key={item.Id}
style={{
width: screenWidth,
height: CAROUSEL_HEIGHT,
position: "relative",
}}
>
{/* Background Backdrop */}
<ItemImage
item={item}
variant='Backdrop'
style={{
width: "100%",
height: "100%",
position: "absolute",
}}
/>
{/* Dark Overlay */}
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: `rgba(0, 0, 0, ${OVERLAY_OPACITY})`,
}}
/>
{/* Gradient Fade to Black at Top */}
<LinearGradient
colors={["rgba(0,0,0,1)", "rgba(0,0,0,0.2)", "transparent"]}
style={{
position: "absolute",
left: 0,
right: 0,
top: 0,
height: GRADIENT_HEIGHT_TOP,
}}
/>
{/* Gradient Fade to Black at Bottom */}
<LinearGradient
colors={["transparent", "rgba(0,0,0,0.8)", "rgba(0,0,0,1)"]}
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
height: GRADIENT_HEIGHT_BOTTOM,
}}
/>
{/* Logo Section */}
{itemLogoUrl && (
<TouchableOpacity
onPress={() => navigateToItem(item)}
style={{
position: "absolute",
bottom: LOGO_BOTTOM_POSITION,
left: 0,
right: 0,
paddingHorizontal: HORIZONTAL_PADDING,
alignItems: "center",
}}
>
<Image
source={{
uri: itemLogoUrl,
}}
style={{
height: LOGO_HEIGHT,
width: LOGO_WIDTH_PERCENTAGE,
}}
contentFit='contain'
/>
</TouchableOpacity>
)}
{/* Type and Genres Section */}
<View
style={{
position: "absolute",
bottom: GENRES_BOTTOM_POSITION,
left: 0,
right: 0,
paddingHorizontal: HORIZONTAL_PADDING,
alignItems: "center",
}}
>
<TouchableOpacity onPress={() => navigateToItem(item)}>
<Animated.Text
style={{
color: `rgba(255, 255, 255, ${TEXT_OPACITY})`,
fontSize: GENRES_FONT_SIZE,
fontWeight: "500",
textAlign: "center",
textShadowColor: TEXT_SHADOW_COLOR,
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: TEXT_SHADOW_RADIUS,
}}
>
{(() => {
let typeLabel = "";
if (item.Type === "Episode") {
// For episodes, show season and episode number
const season = item.ParentIndexNumber;
const episode = item.IndexNumber;
if (season && episode) {
typeLabel = `S${season} • E${episode}`;
} else {
typeLabel = "Episode";
}
} else {
typeLabel =
item.Type === "Series"
? "TV Show"
: item.Type === "Movie"
? "Movie"
: item.Type || "";
}
const genres =
item.Genres && item.Genres.length > 0
? item.Genres.slice(0, MAX_GENRES_COUNT).join(" • ")
: "";
if (typeLabel && genres) {
return `${typeLabel}${genres}`;
} else if (typeLabel) {
return typeLabel;
} else if (genres) {
return genres;
} else {
return "";
}
})()}
</Animated.Text>
</TouchableOpacity>
</View>
{/* Controls Section */}
<View
style={{
position: "absolute",
bottom: CONTROLS_BOTTOM_POSITION,
left: 0,
right: 0,
paddingHorizontal: HORIZONTAL_PADDING,
}}
>
<View
style={{
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
gap: CONTROLS_GAP,
}}
>
{/* Play Button */}
<View style={{ flex: 1, maxWidth: MAX_BUTTON_WIDTH }}>
{selectedOptions && (
<PlayButton
item={item}
selectedOptions={selectedOptions}
colors={currentItemColors}
/>
)}
</View>
{/* Mark as Played */}
<PlayedStatus items={[item]} size='large' />
</View>
</View>
</View>
);
};
// Handle loading state
if (isLoading) {
return (
<View
style={{
height: CAROUSEL_HEIGHT,
backgroundColor: "#000",
overflow: "hidden",
}}
>
{renderSkeletonLoader()}
</View>
);
}
// Handle empty items
if (!hasItems) {
return null;
}
return (
<View
style={{
height: CAROUSEL_HEIGHT, // Fixed height instead of flex: 1
backgroundColor: "#000",
overflow: "hidden",
}}
>
<GestureDetector gesture={panGesture}>
<Animated.View
style={[
{
height: CAROUSEL_HEIGHT, // Fixed height instead of flex: 1
flexDirection: "row",
width: screenWidth * items.length,
},
containerAnimatedStyle,
]}
>
{items.map((item, index) => renderItem(item, index))}
</Animated.View>
</GestureDetector>
{/* Animated Dots Indicator */}
{renderDots()}
</View>
);
};

View File

@@ -1,6 +1,6 @@
import { Feather } from "@expo/vector-icons"; import { Feather } from "@expo/vector-icons";
import { useCallback, useEffect } from "react"; import { useCallback, useEffect } from "react";
import { Platform, TouchableOpacity } from "react-native"; import { Platform } from "react-native";
import GoogleCast, { import GoogleCast, {
CastButton, CastButton,
CastContext, CastContext,
@@ -42,22 +42,6 @@ export function Chromecast({
[Platform.OS], [Platform.OS],
); );
if (Platform.OS === "ios") {
return (
<TouchableOpacity
className='mr-4'
onPress={() => {
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
else CastContext.showCastDialog();
}}
{...props}
>
<AndroidCastButton />
<Feather name='cast' size={22} color={"white"} />
</TouchableOpacity>
);
}
if (background === "transparent") if (background === "transparent")
return ( return (
<RoundButton <RoundButton

View File

@@ -225,7 +225,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
if (!mediaSource) { if (!mediaSource) {
console.error(`Could not get download URL for ${item.Name}`); console.error(`Could not get download URL for ${item.Name}`);
toast.error( toast.error(
t("home.downloads.toasts.could_not_get_download_url_for_item", { t("Could not get download URL for {{itemName}}", {
itemName: item.Name, itemName: item.Name,
}), }),
); );

View File

@@ -22,7 +22,7 @@ import { CastAndCrew } from "@/components/series/CastAndCrew";
import { CurrentSeries } from "@/components/series/CurrentSeries"; import { CurrentSeries } from "@/components/series/CurrentSeries";
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel"; import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings"; import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn"; import { useImageColors } from "@/hooks/useImageColors";
import { useOrientation } from "@/hooks/useOrientation"; import { useOrientation } from "@/hooks/useOrientation";
import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
@@ -61,7 +61,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const { t } = useTranslation(); const { t } = useTranslation();
const itemColors = useImageColorsReturn({ item }); useImageColors({ item });
const [loadingLogo, setLoadingLogo] = useState(true); const [loadingLogo, setLoadingLogo] = useState(true);
const [headerHeight, setHeaderHeight] = useState(350); const [headerHeight, setHeaderHeight] = useState(350);
@@ -105,27 +105,13 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
if (!Platform.isTV) { if (!Platform.isTV) {
navigation.setOptions({ navigation.setOptions({
headerRight: () => headerRight: () =>
item && item && (
(Platform.OS === "ios" ? (
<View className='flex flex-row items-center pl-2'>
<Chromecast.Chromecast width={22} height={22} />
{item.Type !== "Program" && (
<View className='flex flex-row items-center'>
{!Platform.isTV && (
<DownloadSingleItem item={item} size='large' />
)}
{user?.Policy?.IsAdministrator && (
<PlayInRemoteSessionButton item={item} size='large' />
)}
<PlayedStatus items={[item]} size='large' />
<AddToFavorites item={item} />
</View>
)}
</View>
) : (
<View className='flex flex-row items-center space-x-2'> <View className='flex flex-row items-center space-x-2'>
<Chromecast.Chromecast width={22} height={22} /> <Chromecast.Chromecast
background='blur'
width={22}
height={22}
/>
{item.Type !== "Program" && ( {item.Type !== "Program" && (
<View className='flex flex-row items-center space-x-2'> <View className='flex flex-row items-center space-x-2'>
{!Platform.isTV && ( {!Platform.isTV && (
@@ -140,7 +126,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
</View> </View>
)} )}
</View> </View>
)), ),
}); });
} }
}, [item, navigation, user]); }, [item, navigation, user]);
@@ -267,7 +253,6 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
selectedOptions={selectedOptions} selectedOptions={selectedOptions}
item={item} item={item}
isOffline={isOffline} isOffline={isOffline}
colors={itemColors}
/> />
</View> </View>

View File

@@ -36,7 +36,7 @@ export const MediaSourceSheet: React.FC<Props> = ({
return getDisplayName(selected); return getDisplayName(selected);
}, [selected, getDisplayName]); }, [selected, getDisplayName]);
if (isTv || (item.MediaSources && item.MediaSources.length <= 1)) return null; if (isTv || (item.MediaStreams && item.MediaStreams.length <= 1)) return null;
return ( return (
<View className='flex shrink' style={{ minWidth: 75 }}> <View className='flex shrink' style={{ minWidth: 75 }}>

View File

@@ -0,0 +1,111 @@
import { Ionicons } from "@expo/vector-icons";
import type React from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { TouchableOpacity } from "react-native";
import { Input } from "./common/Input";
// Discriminated union for password visibility control
type PasswordVisibilityControlled = {
value?: string;
onChangeText: (text: string) => void;
placeholder: string;
showPassword: boolean;
onShowPasswordChange: (show: boolean) => void;
topOffset?: number;
layout?: "tv" | "mobile";
};
type PasswordVisibilityUncontrolled = {
value?: string;
onChangeText: (text: string) => void;
placeholder: string;
showPassword?: never;
onShowPasswordChange?: never;
topOffset?: number;
layout?: "tv" | "mobile";
defaultShowPassword?: boolean;
};
type PasswordInputProps =
| PasswordVisibilityControlled
| PasswordVisibilityUncontrolled;
export const PasswordInput: React.FC<PasswordInputProps> = (props) => {
const { t } = useTranslation();
const {
value = "",
onChangeText,
placeholder,
topOffset = 14, // Default 14px for mobile
layout = "mobile",
} = props;
// Type guard to check if we're in controlled mode
const isControlled =
"showPassword" in props && "onShowPasswordChange" in props;
// Internal state for uncontrolled mode
const [internalShowPassword, setInternalShowPassword] = useState(() =>
!isControlled && "defaultShowPassword" in props
? ((props as PasswordVisibilityUncontrolled).defaultShowPassword ?? false)
: false,
);
// Use controlled value if available, otherwise use internal state
const showPassword = isControlled
? (props as PasswordVisibilityControlled).showPassword
: internalShowPassword;
const handleTogglePassword = () => {
if (isControlled) {
(props as PasswordVisibilityControlled).onShowPasswordChange(
!showPassword,
);
} else {
// For uncontrolled mode, toggle internal state
setInternalShowPassword(!showPassword);
}
};
// Generate top position style with validation
const getTopStyle = () => {
if (typeof topOffset !== "number" || Number.isNaN(topOffset)) {
console.warn(`Invalid topOffset value: ${topOffset}`);
return { top: 14 }; // Default fallback (14px for mobile)
}
return { top: topOffset };
};
return (
<>
<Input
placeholder={placeholder}
onChangeText={onChangeText}
value={value}
secureTextEntry={!showPassword}
extraClassName='pr-4'
/>
<TouchableOpacity
onPress={handleTogglePassword}
className={`absolute right-3 p-1 ${
layout === "tv" ? "h-10 justify-center" : ""
}`}
style={getTopStyle()}
accessible={true}
accessibilityRole='button'
accessibilityLabel={
showPassword ? t("login.hide_password") : t("login.show_password")
}
accessibilityHint={t("login.toggle_password_visibility")}
accessibilityState={{ checked: showPassword }}
>
<Ionicons
name={showPassword ? "eye-off" : "eye"}
size={24}
color='white'
/>
</TouchableOpacity>
</>
);
};

View File

@@ -23,7 +23,6 @@ import Animated, {
withTiming, withTiming,
} from "react-native-reanimated"; } from "react-native-reanimated";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor"; import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
@@ -40,7 +39,6 @@ interface Props extends React.ComponentProps<typeof Button> {
item: BaseItemDto; item: BaseItemDto;
selectedOptions: SelectedOptions; selectedOptions: SelectedOptions;
isOffline?: boolean; isOffline?: boolean;
colors?: ThemeColors;
} }
const ANIMATION_DURATION = 500; const ANIMATION_DURATION = 500;
@@ -50,7 +48,6 @@ export const PlayButton: React.FC<Props> = ({
item, item,
selectedOptions, selectedOptions,
isOffline, isOffline,
colors,
...props ...props
}: Props) => { }: Props) => {
const { showActionSheetWithOptions } = useActionSheet(); const { showActionSheetWithOptions } = useActionSheet();
@@ -58,19 +55,16 @@ export const PlayButton: React.FC<Props> = ({
const mediaStatus = useMediaStatus(); const mediaStatus = useMediaStatus();
const { t } = useTranslation(); const { t } = useTranslation();
const [globalColorAtom] = useAtom(itemThemeColorAtom); const [colorAtom] = useAtom(itemThemeColorAtom);
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom); const user = useAtomValue(userAtom);
// Use colors prop if provided, otherwise fallback to global atom
const effectiveColors = colors || globalColorAtom;
const router = useRouter(); const router = useRouter();
const startWidth = useSharedValue(0); const startWidth = useSharedValue(0);
const targetWidth = useSharedValue(0); const targetWidth = useSharedValue(0);
const endColor = useSharedValue(effectiveColors); const endColor = useSharedValue(colorAtom);
const startColor = useSharedValue(effectiveColors); const startColor = useSharedValue(colorAtom);
const widthProgress = useSharedValue(0); const widthProgress = useSharedValue(0);
const colorChangeProgress = useSharedValue(0); const colorChangeProgress = useSharedValue(0);
const { settings, updateSettings } = useSettings(); const { settings, updateSettings } = useSettings();
@@ -303,7 +297,7 @@ export const PlayButton: React.FC<Props> = ({
); );
useAnimatedReaction( useAnimatedReaction(
() => effectiveColors, () => colorAtom,
(newColor) => { (newColor) => {
endColor.value = newColor; endColor.value = newColor;
colorChangeProgress.value = 0; colorChangeProgress.value = 0;
@@ -312,19 +306,19 @@ export const PlayButton: React.FC<Props> = ({
easing: Easing.bezier(0.9, 0, 0.31, 0.99), easing: Easing.bezier(0.9, 0, 0.31, 0.99),
}); });
}, },
[effectiveColors], [colorAtom],
); );
useEffect(() => { useEffect(() => {
const timeout_2 = setTimeout(() => { const timeout_2 = setTimeout(() => {
startColor.value = effectiveColors; startColor.value = colorAtom;
startWidth.value = targetWidth.value; startWidth.value = targetWidth.value;
}, ANIMATION_DURATION); }, ANIMATION_DURATION);
return () => { return () => {
clearTimeout(timeout_2); clearTimeout(timeout_2);
}; };
}, [effectiveColors, item]); }, [colorAtom, item]);
/** /**
* ANIMATED STYLES * ANIMATED STYLES
@@ -373,7 +367,7 @@ export const PlayButton: React.FC<Props> = ({
className={"relative"} className={"relative"}
{...props} {...props}
> >
<View className='absolute w-full h-full top-0 left-0 rounded-full z-10 overflow-hidden'> <View className='absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden'>
<Animated.View <Animated.View
style={[ style={[
animatedPrimaryStyle, animatedPrimaryStyle,
@@ -387,15 +381,15 @@ export const PlayButton: React.FC<Props> = ({
<Animated.View <Animated.View
style={[animatedAverageStyle, { opacity: 0.5 }]} style={[animatedAverageStyle, { opacity: 0.5 }]}
className='absolute w-full h-full top-0 left-0 rounded-full' className='absolute w-full h-full top-0 left-0 rounded-xl'
/> />
<View <View
style={{ style={{
borderWidth: 1, borderWidth: 1,
borderColor: effectiveColors.primary, borderColor: colorAtom.primary,
borderStyle: "solid", borderStyle: "solid",
}} }}
className='flex flex-row items-center justify-center bg-transparent rounded-full z-20 h-12 w-full ' className='flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full '
> >
<View className='flex flex-row items-center space-x-2'> <View className='flex flex-row items-center space-x-2'>
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}> <Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>

View File

@@ -15,7 +15,6 @@ import Animated, {
withTiming, withTiming,
} from "react-native-reanimated"; } from "react-native-reanimated";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor"; import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { runtimeTicksToMinutes } from "@/utils/time"; import { runtimeTicksToMinutes } from "@/utils/time";
@@ -25,7 +24,6 @@ import type { SelectedOptions } from "./ItemContent";
interface Props extends React.ComponentProps<typeof Button> { interface Props extends React.ComponentProps<typeof Button> {
item: BaseItemDto; item: BaseItemDto;
selectedOptions: SelectedOptions; selectedOptions: SelectedOptions;
colors?: ThemeColors;
} }
const ANIMATION_DURATION = 500; const ANIMATION_DURATION = 500;
@@ -34,20 +32,16 @@ const MIN_PLAYBACK_WIDTH = 15;
export const PlayButton: React.FC<Props> = ({ export const PlayButton: React.FC<Props> = ({
item, item,
selectedOptions, selectedOptions,
colors,
...props ...props
}: Props) => { }: Props) => {
const [globalColorAtom] = useAtom(itemThemeColorAtom); const [colorAtom] = useAtom(itemThemeColorAtom);
// Use colors prop if provided, otherwise fallback to global atom
const effectiveColors = colors || globalColorAtom;
const router = useRouter(); const router = useRouter();
const startWidth = useSharedValue(0); const startWidth = useSharedValue(0);
const targetWidth = useSharedValue(0); const targetWidth = useSharedValue(0);
const endColor = useSharedValue(effectiveColors); const endColor = useSharedValue(colorAtom);
const startColor = useSharedValue(effectiveColors); const startColor = useSharedValue(colorAtom);
const widthProgress = useSharedValue(0); const widthProgress = useSharedValue(0);
const colorChangeProgress = useSharedValue(0); const colorChangeProgress = useSharedValue(0);
const { settings } = useSettings(); const { settings } = useSettings();
@@ -107,7 +101,7 @@ export const PlayButton: React.FC<Props> = ({
); );
useAnimatedReaction( useAnimatedReaction(
() => effectiveColors, () => colorAtom,
(newColor) => { (newColor) => {
endColor.value = newColor; endColor.value = newColor;
colorChangeProgress.value = 0; colorChangeProgress.value = 0;
@@ -116,19 +110,19 @@ export const PlayButton: React.FC<Props> = ({
easing: Easing.bezier(0.9, 0, 0.31, 0.99), easing: Easing.bezier(0.9, 0, 0.31, 0.99),
}); });
}, },
[effectiveColors], [colorAtom],
); );
useEffect(() => { useEffect(() => {
const timeout_2 = setTimeout(() => { const timeout_2 = setTimeout(() => {
startColor.value = effectiveColors; startColor.value = colorAtom;
startWidth.value = targetWidth.value; startWidth.value = targetWidth.value;
}, ANIMATION_DURATION); }, ANIMATION_DURATION);
return () => { return () => {
clearTimeout(timeout_2); clearTimeout(timeout_2);
}; };
}, [effectiveColors, item]); }, [colorAtom, item]);
/** /**
* ANIMATED STYLES * ANIMATED STYLES
@@ -195,7 +189,7 @@ export const PlayButton: React.FC<Props> = ({
<View <View
style={{ style={{
borderWidth: 1, borderWidth: 1,
borderColor: effectiveColors.primary, borderColor: colorAtom.primary,
borderStyle: "solid", borderStyle: "solid",
}} }}
className='flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full ' className='flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full '

View File

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

View File

@@ -3,6 +3,7 @@ import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { View } from "react-native"; import { View } from "react-native";
import { useMMKVString } from "react-native-mmkv"; import { useMMKVString } from "react-native-mmkv";
import { storage } from "@/utils/mmkv";
import { ListGroup } from "./list/ListGroup"; import { ListGroup } from "./list/ListGroup";
import { ListItem } from "./list/ListItem"; import { ListItem } from "./list/ListItem";
@@ -17,8 +18,7 @@ interface PreviousServersListProps {
export const PreviousServersList: React.FC<PreviousServersListProps> = ({ export const PreviousServersList: React.FC<PreviousServersListProps> = ({
onServerSelect, onServerSelect,
}) => { }) => {
const [_previousServers, setPreviousServers] = const [_previousServers] = useMMKVString("previousServers");
useMMKVString("previousServers");
const previousServers = useMemo(() => { const previousServers = useMemo(() => {
return JSON.parse(_previousServers || "[]") as Server[]; return JSON.parse(_previousServers || "[]") as Server[];
@@ -37,14 +37,16 @@ export const PreviousServersList: React.FC<PreviousServersListProps> = ({
onPress={() => onServerSelect(s)} onPress={() => onServerSelect(s)}
title={s.address} title={s.address}
showArrow showArrow
className='min-h-[48px] py-2'
/> />
))} ))}
<ListItem <ListItem
onPress={() => { onPress={() => {
setPreviousServers("[]"); storage.delete("previousServers");
}} }}
title={t("server.clear_button")} title={t("server.clear_button")}
textColor='red' textColor='red'
className='min-h-[48px] py-2'
/> />
</ListGroup> </ListGroup>
</View> </View>

View File

@@ -10,7 +10,6 @@ interface Props extends ViewProps {
background?: boolean; background?: boolean;
size?: "default" | "large"; size?: "default" | "large";
fillColor?: "primary"; fillColor?: "primary";
color?: "white" | "purple";
hapticFeedback?: boolean; hapticFeedback?: boolean;
} }
@@ -21,7 +20,6 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
children, children,
size = "default", size = "default",
fillColor, fillColor,
color = "white",
hapticFeedback = true, hapticFeedback = true,
...viewProps ...viewProps
}) => { }) => {
@@ -36,25 +34,6 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
onPress?.(); onPress?.();
}; };
if (Platform.OS === "ios") {
return (
<TouchableOpacity
onPress={handlePress}
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`}
{...(viewProps as any)}
>
{icon ? (
<Ionicons
name={icon}
size={size === "large" ? 22 : 18}
color={color === "white" ? "white" : "#9334E9"}
/>
) : null}
{children ? children : null}
</TouchableOpacity>
);
}
if (fillColor) if (fillColor)
return ( return (
<TouchableOpacity <TouchableOpacity

12
components/_template.tsx Normal file
View File

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

View File

@@ -19,18 +19,6 @@ export const HeaderBackButton: React.FC<Props> = ({
}) => { }) => {
const router = useRouter(); const router = useRouter();
if (Platform.OS === "ios") {
return (
<TouchableOpacity
onPress={() => router.back()}
className='flex items-center justify-center w-9 h-9'
{...touchableOpacityProps}
>
<Ionicons name='arrow-back' size={24} color='white' />
</TouchableOpacity>
);
}
if (background === "transparent" && Platform.OS !== "android") if (background === "transparent" && Platform.OS !== "android")
return ( return (
<TouchableOpacity <TouchableOpacity

View File

@@ -20,8 +20,8 @@ export function Input(props: InputProps) {
<TextInput <TextInput
ref={inputRef} ref={inputRef}
className={` className={`
w-full text-lg px-5 py-4 rounded-2xl w-full text-lg px-5 py-5 rounded-2xl
${isFocused ? "bg-neutral-700 border-2 border-white" : "bg-neutral-900 border-2 border-transparent"} ${isFocused ? "bg-neutral-700 border-2 border-white" : "bg-neutral-900 border-2 border-neutral-800"}
text-white ${extraClassName} text-white ${extraClassName}
`} `}
allowFontScaling={false} allowFontScaling={false}
@@ -41,11 +41,15 @@ export function Input(props: InputProps) {
) : ( ) : (
<TextInput <TextInput
ref={inputRef} ref={inputRef}
className='p-4 rounded-xl bg-neutral-900' className={`p-3 rounded-xl bg-neutral-900 ${
isFocused ? "border-2 border-white" : "border-2 border-neutral-800"
} ${extraClassName}`}
allowFontScaling={false} allowFontScaling={false}
style={[{ color: "white" }, style]} style={[{ color: "white" }, style]}
placeholderTextColor={"#9CA3AF"} placeholderTextColor={"#9CA3AF"}
clearButtonMode='while-editing' clearButtonMode='while-editing'
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
{...otherProps} {...otherProps}
/> />
); );

View File

@@ -40,7 +40,7 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
const segments = useSegments(); const segments = useSegments();
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr(); const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
const from = (segments as string[])[2] || "(home)"; const from = segments[2] || "(home)";
const autoApprove = useMemo(() => { const autoApprove = useMemo(() => {
return ( return (

View File

@@ -43,48 +43,6 @@ export const itemRouter = (item: BaseItemDto, from: string) => {
return `/(auth)/(tabs)/${from}/items/page?id=${item.Id}`; return `/(auth)/(tabs)/${from}/items/page?id=${item.Id}`;
}; };
export const getItemNavigation = (item: BaseItemDto, _from: string) => {
if ("CollectionType" in item && item.CollectionType === "livetv") {
return {
pathname: "/livetv" as const,
};
}
if (item.Type === "Series") {
return {
pathname: "/series/[id]" as const,
params: { id: item.Id! },
};
}
if (item.Type === "Person") {
return {
pathname: "/persons/[personId]" as const,
params: { personId: item.Id! },
};
}
if (item.Type === "BoxSet" || item.Type === "UserView") {
return {
pathname: "/collections/[collectionId]" as const,
params: { collectionId: item.Id! },
};
}
if (item.Type === "CollectionFolder" || item.Type === "Playlist") {
return {
pathname: "/[libraryId]" as const,
params: { libraryId: item.Id! },
};
}
// Default case - items page
return {
pathname: "/items/page" as const,
params: { id: item.Id! },
};
};
export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
item, item,
isOffline = false, isOffline = false,
@@ -97,7 +55,7 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
const markAsPlayedStatus = useMarkAsPlayed([item]); const markAsPlayedStatus = useMarkAsPlayed([item]);
const { isFavorite, toggleFavorite } = useFavorite(item); const { isFavorite, toggleFavorite } = useFavorite(item);
const from = (segments as string[])[2] || "(home)"; const from = segments[2] || "(home)";
const showActionSheet = useCallback(() => { const showActionSheet = useCallback(() => {
if ( if (
@@ -143,15 +101,12 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
<TouchableOpacity <TouchableOpacity
onLongPress={showActionSheet} onLongPress={showActionSheet}
onPress={() => { onPress={() => {
let url = itemRouter(item, from);
if (isOffline) { if (isOffline) {
// For offline mode, we still need to use query params url += `&offline=true`;
const url = `${itemRouter(item, from)}&offline=true`;
router.push(url as any);
return;
} }
// @ts-expect-error
const navigation = getItemNavigation(item, from); router.push(url);
router.push(navigation as any);
}} }}
{...props} {...props}
> >

View File

@@ -6,7 +6,6 @@ import { t } from "i18next";
import { useMemo } from "react"; import { useMemo } from "react";
import { import {
ActivityIndicator, ActivityIndicator,
Platform,
TouchableOpacity, TouchableOpacity,
type TouchableOpacityProps, type TouchableOpacityProps,
View, View,
@@ -110,9 +109,9 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
/> />
)} )}
{/* Action buttons in bottom right corner */} {/* Action buttons in top right corner */}
<View className='absolute bottom-2 right-2 flex flex-row items-center space-x-2 z-10'> <View className='absolute top-2 right-2 flex flex-row items-center space-x-2 z-10'>
{process.status === "downloading" && Platform.OS !== "ios" && ( {process.status === "downloading" && (
<TouchableOpacity <TouchableOpacity
onPress={() => handlePause(process.id)} onPress={() => handlePause(process.id)}
className='p-1' className='p-1'
@@ -120,7 +119,7 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
<Ionicons name='pause' size={20} color='white' /> <Ionicons name='pause' size={20} color='white' />
</TouchableOpacity> </TouchableOpacity>
)} )}
{process.status === "paused" && Platform.OS !== "ios" && ( {process.status === "paused" && (
<TouchableOpacity <TouchableOpacity
onPress={() => handleResume(process.id)} onPress={() => handleResume(process.id)}
className='p-1' className='p-1'

View File

@@ -37,7 +37,7 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
*/ */
const handleDeleteFile = useCallback(() => { const handleDeleteFile = useCallback(() => {
if (item.Id) { if (item.Id) {
deleteFile(item.Id, item.Type); deleteFile(item.Id, "Movie");
} }
}, [deleteFile, item.Id]); }, [deleteFile, item.Id]);

View File

@@ -177,7 +177,7 @@ export const FilterSheet = <T,>({
{showSearch && ( {showSearch && (
<Input <Input
placeholder={t("search.search")} placeholder={t("search.search")}
className='my-2 border-neutral-800 border' extraClassName='my-2 border-neutral-800 border'
value={search} value={search}
onChangeText={(text) => { onChangeText={(text) => {
setSearch(text); setSearch(text);

View File

@@ -21,7 +21,7 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { getItemNavigation } from "../common/TouchableItemRouter"; import { itemRouter } from "../common/TouchableItemRouter";
interface Props extends ViewProps {} interface Props extends ViewProps {}
@@ -88,24 +88,22 @@ export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
if (!popularItems) return null; if (!popularItems) return null;
return ( return (
<View className='flex flex-col items-center' {...props}> <View className='flex flex-col items-center mt-2' {...props}>
<Carousel <Carousel
ref={ref} ref={ref}
autoPlay={false} autoPlay={false}
loop={true} loop={true}
snapEnabled={true} snapEnabled={true}
vertical={false}
mode='parallax' mode='parallax'
modeConfig={{ modeConfig={{
parallaxScrollingScale: 1, parallaxScrollingScale: 0.86,
parallaxScrollingOffset: 0, parallaxScrollingOffset: 100,
}} }}
width={width} width={width}
height={500} height={204}
data={popularItems} data={popularItems}
onProgressChange={progress} onProgressChange={progress}
renderItem={({ item, index }) => <RenderItem key={index} item={item} />} renderItem={({ item, index }) => <RenderItem key={index} item={item} />}
scrollAnimationDuration={1000}
/> />
<Pagination.Basic <Pagination.Basic
progress={progress} progress={progress}
@@ -148,20 +146,20 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
}, [item]); }, [item]);
const segments = useSegments(); const segments = useSegments();
const from = (segments as string[])[2] || "(home)"; const from = segments[2] || "(home)";
const opacity = useSharedValue(1); const opacity = useSharedValue(1);
const handleRoute = useCallback(() => { const handleRoute = useCallback(() => {
if (!from) return; if (!from) return;
const url = itemRouter(item, from);
lightHapticFeedback(); lightHapticFeedback();
const navigation = getItemNavigation(item, from); // @ts-expect-error
router.push(navigation as any); if (url) router.push(url);
}, [item, from]); }, [item, from]);
const tap = Gesture.Tap() const tap = Gesture.Tap()
.maxDuration(2000) .maxDuration(2000)
.shouldCancelWhenOutside(true)
.onBegin(() => { .onBegin(() => {
opacity.value = withTiming(0.8, { duration: 100 }); opacity.value = withTiming(0.8, { duration: 100 });
}) })
@@ -176,19 +174,25 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
return ( return (
<GestureDetector gesture={tap}> <GestureDetector gesture={tap}>
<Animated.View style={{ opacity }}> <Animated.View
<View className='relative flex justify-center overflow-hidden border border-neutral-800'> style={{
opacity: opacity,
}}
className='px-4'
>
<View className='relative flex justify-center rounded-2xl overflow-hidden border border-neutral-800'>
<Image <Image
source={{ source={{
uri, uri,
}} }}
style={{ style={{
width: "100%", width: "100%",
height: 500, height: 200,
borderRadius: 16,
overflow: "hidden", overflow: "hidden",
}} }}
/> />
<View className='absolute bottom-0 left-0 w-full flex items-center'> <View className='absolute bottom-0 left-0 w-full h-24 p-4 flex items-center'>
<Image <Image
source={{ source={{
uri: logoUri, uri: logoUri,

View File

@@ -1,131 +0,0 @@
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
import React, { useCallback, useImperativeHandle, useRef } from "react";
import {
type StyleProp,
StyleSheet,
Text,
type TextInputProps,
View,
type ViewStyle,
} from "react-native";
interface PinInputProps
extends Omit<TextInputProps, "value" | "onChangeText" | "style"> {
value: string;
onChangeText: (text: string) => void;
length?: number;
autoFocus?: boolean;
style?: StyleProp<ViewStyle>;
}
export interface PinInputRef {
focus: () => void;
}
const PinInputComponent = React.forwardRef<PinInputRef, PinInputProps>(
(props, ref) => {
const {
value,
onChangeText,
length = 6,
style,
autoFocus,
...rest
} = props;
const inputRef = useRef<any>(null);
const activeIndex = value.length;
const handlePress = useCallback(() => {
inputRef.current?.focus();
}, []);
useImperativeHandle(
ref,
() => ({
focus: () => inputRef.current?.focus(),
}),
[],
);
return (
<View style={[styles.container, style]}>
<BottomSheetTextInput
ref={inputRef}
value={value}
onChangeText={onChangeText}
keyboardType='number-pad'
maxLength={length}
style={styles.hiddenInput}
autoFocus={autoFocus}
{...rest}
/>
<View style={styles.cells} onTouchStart={handlePress}>
{Array(length)
.fill(0)
.map((_, i) => (
<View
key={i}
style={[
styles.cell,
i === activeIndex && styles.activeCell,
i === activeIndex - 1 && styles.filledCell,
]}
>
<Text style={styles.digit}>{value[i]}</Text>
{i === activeIndex && <View style={styles.cursor} />}
</View>
))}
</View>
</View>
);
},
);
PinInputComponent.displayName = "PinInput";
export const PinInput = PinInputComponent;
const styles = StyleSheet.create({
container: {
width: "100%",
},
hiddenInput: {
position: "absolute",
width: 1,
height: 1,
opacity: 0,
},
cells: {
flexDirection: "row",
justifyContent: "space-between",
width: "100%",
},
cell: {
width: 40,
height: 48,
borderWidth: 1,
borderColor: "#374151",
borderRadius: 8,
alignItems: "center",
justifyContent: "center",
backgroundColor: "#1F2937",
},
activeCell: {
borderColor: "#6366F1",
},
filledCell: {
borderColor: "#4B5563",
},
digit: {
fontSize: 24,
color: "white",
fontWeight: "500",
},
cursor: {
position: "absolute",
width: 2,
height: 24,
backgroundColor: "#6366F1",
},
});

View File

@@ -22,7 +22,7 @@ const PersonPoster: React.FC<Props & ViewProps> = ({
const { jellyseerrApi } = useJellyseerr(); const { jellyseerrApi } = useJellyseerr();
const router = useRouter(); const router = useRouter();
const segments = useSegments(); const segments = useSegments();
const from = (segments as string[])[2] || "(home)"; const from = segments[2] || "(home)";
if (from === "(home)" || from === "(search)" || from === "(libraries)") if (from === "(home)" || from === "(search)" || from === "(libraries)")
return ( return (

View File

@@ -16,12 +16,13 @@ const CompanySlide: React.FC<
> = ({ slide, data, ...props }) => { > = ({ slide, data, ...props }) => {
const segments = useSegments(); const segments = useSegments();
const { jellyseerrApi } = useJellyseerr(); const { jellyseerrApi } = useJellyseerr();
const from = (segments as string[])[2] || "(home)"; const from = segments[2] || "(home)";
const navigate = useCallback( const navigate = useCallback(
({ id, image, name }: Network | Studio) => ({ id, image, name }: Network | Studio) =>
router.push({ router.push({
pathname: `/(auth)/(tabs)/${from}/jellyseerr/company/${id}` as any, // @ts-expect-error - Dynamic pathname for jellyseerr routing
pathname: `/(auth)/(tabs)/${from}/jellyseerr/company/${id}`,
params: { id, image, name, type: slide.type }, params: { id, image, name, type: slide.type },
}), }),
[slide], [slide],

View File

@@ -13,12 +13,13 @@ import { genreColorMap } from "@/utils/jellyseerr/src/components/Discover/consta
const GenreSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => { const GenreSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
const segments = useSegments(); const segments = useSegments();
const { jellyseerrApi } = useJellyseerr(); const { jellyseerrApi } = useJellyseerr();
const from = (segments as string[])[2] || "(home)"; const from = segments[2] || "(home)";
const navigate = useCallback( const navigate = useCallback(
(genre: GenreSliderItem) => (genre: GenreSliderItem) =>
router.push({ router.push({
pathname: `/(auth)/(tabs)/${from}/jellyseerr/genre/${genre.id}` as any, // @ts-expect-error - Dynamic pathname for jellyseerr routing
pathname: `/(auth)/(tabs)/${from}/jellyseerr/genre/${genre.id}`,
params: { type: slide.type, name: genre.name }, params: { type: slide.type, name: genre.name },
}), }),
[slide], [slide],

View File

@@ -8,14 +8,7 @@ import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest"; import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
import type { NonFunctionProperties } from "@/utils/jellyseerr/server/interfaces/api/common"; import type { NonFunctionProperties } from "@/utils/jellyseerr/server/interfaces/api/common";
type ExtendedMediaRequest = NonFunctionProperties<MediaRequest> & { const RequestCard: React.FC<{ request: MediaRequest }> = ({ request }) => {
profileName: string;
canRemove: boolean;
};
const RequestCard: React.FC<{ request: ExtendedMediaRequest }> = ({
request,
}) => {
const { jellyseerrApi } = useJellyseerr(); const { jellyseerrApi } = useJellyseerr();
const { data: details } = useQuery({ const { data: details } = useQuery({
@@ -74,15 +67,9 @@ const RecentRequestsSlide: React.FC<SlideProps & ViewProps> = ({
<Slide <Slide
{...props} {...props}
slide={slide} slide={slide}
data={ data={requests.results}
requests.results.map((item) => ({
...item,
profileName: item.profileName ?? "Unknown",
canRemove: Boolean(item.canRemove),
})) as ExtendedMediaRequest[]
}
keyExtractor={(item) => item.id.toString()} keyExtractor={(item) => item.id.toString()}
renderItem={(item: ExtendedMediaRequest) => ( renderItem={(item: NonFunctionProperties<MediaRequest>) => (
<RequestCard request={item} /> <RequestCard request={item} />
)} )}
/> />

View File

@@ -1,6 +1,6 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import type { PropsWithChildren, ReactNode } from "react"; import type { PropsWithChildren, ReactNode } from "react";
import { TouchableOpacity, View, type ViewProps } from "react-native"; import { Platform, TouchableOpacity, View, type ViewProps } from "react-native";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
interface Props extends ViewProps { interface Props extends ViewProps {

View File

@@ -12,6 +12,7 @@ import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { HorizontalScroll } from "../common/HorizontalScroll"; import { HorizontalScroll } from "../common/HorizontalScroll";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
import { itemRouter } from "../common/TouchableItemRouter";
import Poster from "../posters/Poster"; import Poster from "../posters/Poster";
interface Props extends ViewProps { interface Props extends ViewProps {
@@ -23,7 +24,7 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const segments = useSegments(); const segments = useSegments();
const { t } = useTranslation(); const { t } = useTranslation();
const from = (segments as string[])[2]; const from = segments[2];
const destinctPeople = useMemo(() => { const destinctPeople = useMemo(() => {
const people: Record<string, BaseItemPerson> = {}; const people: Record<string, BaseItemPerson> = {};
@@ -55,12 +56,15 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
renderItem={(i) => ( renderItem={(i) => (
<TouchableOpacity <TouchableOpacity
onPress={() => { onPress={() => {
if (i.Id) { const url = itemRouter(
router.push({ {
pathname: "/persons/[personId]", Id: i.Id,
params: { personId: i.Id }, Type: "Person",
}); },
} from,
);
// @ts-expect-error
router.push(url);
}} }}
className='flex flex-col w-28' className='flex flex-col w-28'
> >

View File

@@ -1,12 +1,13 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api"; import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { router } from "expo-router"; import { router } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useEffect, useMemo, useRef } from "react"; import { useEffect, useMemo, useRef } from "react";
import { TouchableOpacity, type ViewProps } from "react-native"; import { TouchableOpacity, type ViewProps } from "react-native";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import ContinueWatchingPoster from "../ContinueWatchingPoster"; import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { import {
HorizontalScroll, HorizontalScroll,
@@ -56,7 +57,6 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
userId: user.Id, userId: user.Id,
seasonId: seasonId || undefined, seasonId: seasonId || undefined,
seriesId: item.SeriesId, seriesId: item.SeriesId,
enableUserData: true,
fields: [ fields: [
"ItemCounts", "ItemCounts",
"PrimaryImageAspectRatio", "PrimaryImageAspectRatio",
@@ -70,6 +70,48 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
enabled: !!api && !!user?.Id && !!seasonId, enabled: !!api && !!user?.Id && !!seasonId,
}); });
/**
* Prefetch previous and next episode
*/
const queryClient = useQueryClient();
useEffect(() => {
if (!item?.Id || !item.IndexNumber || !episodes || episodes.length === 0) {
return;
}
const previousId = episodes?.find(
(ep) => ep.IndexNumber === item.IndexNumber! - 1,
)?.Id;
if (previousId) {
queryClient.prefetchQuery({
queryKey: ["item", previousId],
queryFn: async () =>
await getUserItemData({
api,
userId: user?.Id,
itemId: previousId,
}),
staleTime: 60 * 1000 * 5,
});
}
const nextId = episodes?.find(
(ep) => ep.IndexNumber === item.IndexNumber! + 1,
)?.Id;
if (nextId) {
queryClient.prefetchQuery({
queryKey: ["item", nextId],
queryFn: async () =>
await getUserItemData({
api,
userId: user?.Id,
itemId: nextId,
}),
staleTime: 60 * 1000 * 5,
});
}
}, [episodes, api, user?.Id, item]);
useEffect(() => { useEffect(() => {
if (item?.Type === "Episode" && item.Id) { if (item?.Type === "Episode" && item.Id) {
const index = episodes?.findIndex((ep) => ep.Id === item.Id); const index = episodes?.findIndex((ep) => ep.Id === item.Id);

View File

@@ -1,7 +1,7 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api"; import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { atom, useAtom } from "jotai"; import { atom, useAtom } from "jotai";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -11,6 +11,7 @@ import {
type SeasonIndexState, type SeasonIndexState,
} from "@/components/series/SeasonDropdown"; } from "@/components/series/SeasonDropdown";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { runtimeTicksToSeconds } from "@/utils/time"; import { runtimeTicksToSeconds } from "@/utils/time";
import ContinueWatchingPoster from "../ContinueWatchingPoster"; import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
@@ -86,7 +87,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
seasonId: selectedSeasonId, seasonId: selectedSeasonId,
enableUserData: true, enableUserData: true,
// Note: Including trick play is necessary to enable trick play downloads // Note: Including trick play is necessary to enable trick play downloads
fields: ["Overview", "Trickplay"], fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
}); });
if (res.data.TotalRecordCount === 0) if (res.data.TotalRecordCount === 0)
@@ -100,6 +101,25 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
enabled: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId, enabled: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
}); });
const queryClient = useQueryClient();
useEffect(() => {
for (const e of episodes || []) {
queryClient.prefetchQuery({
queryKey: ["item", e.Id],
queryFn: async () => {
if (!e.Id) return;
const res = await getUserItemData({
api,
userId: user?.Id,
itemId: e.Id,
});
return res;
},
staleTime: 60 * 5 * 1000,
});
}
}, [episodes]);
// Used for height calculation // Used for height calculation
const [nrOfEpisodes, setNrOfEpisodes] = useState(0); const [nrOfEpisodes, setNrOfEpisodes] = useState(0);
useEffect(() => { useEffect(() => {

View File

@@ -1,7 +1,6 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { import {
Alert, Alert,
Linking, Linking,
@@ -17,7 +16,6 @@ interface Props extends ViewProps {
} }
export const ItemActions = ({ item, ...props }: Props) => { export const ItemActions = ({ item, ...props }: Props) => {
const { t } = useTranslation();
const trailerLink = useMemo(() => { const trailerLink = useMemo(() => {
if ("RemoteTrailers" in item && item.RemoteTrailers?.[0]?.Url) { if ("RemoteTrailers" in item && item.RemoteTrailers?.[0]?.Url) {
return item.RemoteTrailers[0].Url; return item.RemoteTrailers[0].Url;
@@ -32,7 +30,7 @@ export const ItemActions = ({ item, ...props }: Props) => {
const openTrailer = useCallback(async () => { const openTrailer = useCallback(async () => {
if (!trailerLink) { if (!trailerLink) {
Alert.alert(t("common.no_trailer_available")); Alert.alert("No trailer available");
return; return;
} }
@@ -41,7 +39,7 @@ export const ItemActions = ({ item, ...props }: Props) => {
} catch (err) { } catch (err) {
console.error("Failed to open trailer link:", err); console.error("Failed to open trailer link:", err);
} }
}, [trailerLink, t]); }, [trailerLink]);
return ( return (
<View className='' {...props}> <View className='' {...props}>

View File

@@ -13,7 +13,7 @@ export default function DownloadSettings({ ...props }) {
const allDisabled = useMemo( const allDisabled = useMemo(
() => () =>
pluginSettings?.remuxConcurrentLimit?.locked === true && pluginSettings?.remuxConcurrentLimit?.locked === true &&
pluginSettings?.autoDownload?.locked === true, pluginSettings?.autoDownload.locked === true,
[pluginSettings], [pluginSettings],
); );

View File

@@ -36,6 +36,7 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
"home.settings.gesture_controls.horizontal_swipe_skip_description", "home.settings.gesture_controls.horizontal_swipe_skip_description",
)} )}
disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked} disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked}
style={{ minHeight: 72, paddingTop: 12, paddingBottom: 12 }}
> >
<Switch <Switch
value={settings.enableHorizontalSwipeSkip} value={settings.enableHorizontalSwipeSkip}
@@ -52,6 +53,7 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
"home.settings.gesture_controls.left_side_brightness_description", "home.settings.gesture_controls.left_side_brightness_description",
)} )}
disabled={pluginSettings?.enableLeftSideBrightnessSwipe?.locked} disabled={pluginSettings?.enableLeftSideBrightnessSwipe?.locked}
style={{ minHeight: 72, paddingTop: 12, paddingBottom: 12 }}
> >
<Switch <Switch
value={settings.enableLeftSideBrightnessSwipe} value={settings.enableLeftSideBrightnessSwipe}
@@ -68,6 +70,7 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
"home.settings.gesture_controls.right_side_volume_description", "home.settings.gesture_controls.right_side_volume_description",
)} )}
disabled={pluginSettings?.enableRightSideVolumeSwipe?.locked} disabled={pluginSettings?.enableRightSideVolumeSwipe?.locked}
style={{ minHeight: 72, paddingTop: 12, paddingBottom: 12 }}
> >
<Switch <Switch
value={settings.enableRightSideVolumeSwipe} value={settings.enableRightSideVolumeSwipe}

View File

@@ -2,7 +2,6 @@ import { Feather, Ionicons } from "@expo/vector-icons";
import type { Api } from "@jellyfin/sdk"; import type { Api } from "@jellyfin/sdk";
import type { import type {
BaseItemDto, BaseItemDto,
BaseItemDtoQueryResult,
BaseItemKind, BaseItemKind,
} from "@jellyfin/sdk/lib/generated-client/models"; } from "@jellyfin/sdk/lib/generated-client/models";
import { import {
@@ -28,6 +27,7 @@ import {
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel";
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList"; import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
import { MediaListSection } from "@/components/medialists/MediaListSection"; import { MediaListSection } from "@/components/medialists/MediaListSection";
@@ -38,7 +38,6 @@ import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus"; import { eventBus } from "@/utils/eventBus";
import { AppleTVCarousel } from "../AppleTVCarousel";
type ScrollingCollectionListSection = { type ScrollingCollectionListSection = {
type: "ScrollingCollectionList"; type: "ScrollingCollectionList";
@@ -75,12 +74,7 @@ export const HomeIndex = () => {
const { getDownloadedItems, cleanCacheDirectory } = useDownload(); const { getDownloadedItems, cleanCacheDirectory } = useDownload();
const prevIsConnected = useRef<boolean | null>(false); const prevIsConnected = useRef<boolean | null>(false);
const { const { isConnected, loading: retryLoading, retryCheck } = useNetworkStatus();
isConnected,
serverConnected,
loading: retryLoading,
retryCheck,
} = useNetworkStatus();
const invalidateCache = useInvalidatePlaybackProgressCache(); const invalidateCache = useInvalidatePlaybackProgressCache();
useEffect(() => { useEffect(() => {
// Only invalidate cache when transitioning from offline to online // Only invalidate cache when transitioning from offline to online
@@ -126,11 +120,8 @@ export const HomeIndex = () => {
const segments = useSegments(); const segments = useSegments();
useEffect(() => { useEffect(() => {
const unsubscribe = eventBus.on("scrollToTop", () => { const unsubscribe = eventBus.on("scrollToTop", () => {
if ((segments as string[])[2] === "(home)") if (segments[2] === "(home)")
scrollViewRef.current?.scrollTo({ scrollViewRef.current?.scrollTo({ y: -152, animated: true });
y: Platform.isTV ? -152 : -100,
animated: true,
});
}); });
return () => { return () => {
@@ -196,9 +187,9 @@ export const HomeIndex = () => {
await getUserLibraryApi(api).getLatestMedia({ await getUserLibraryApi(api).getLatestMedia({
userId: user?.Id, userId: user?.Id,
limit: 20, limit: 20,
fields: ["PrimaryImageAspectRatio", "Path", "Genres"], fields: ["PrimaryImageAspectRatio", "Path"],
imageTypeLimit: 1, imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], enableImageTypes: ["Primary", "Backdrop", "Thumb"],
includeItemTypes, includeItemTypes,
parentId, parentId,
}) })
@@ -240,9 +231,8 @@ export const HomeIndex = () => {
( (
await getItemsApi(api).getResumeItems({ await getItemsApi(api).getResumeItems({
userId: user.Id, userId: user.Id,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], enableImageTypes: ["Primary", "Backdrop", "Thumb"],
includeItemTypes: ["Movie", "Series", "Episode"], includeItemTypes: ["Movie", "Series", "Episode"],
fields: ["Genres"],
}) })
).data.Items || [], ).data.Items || [],
type: "ScrollingCollectionList", type: "ScrollingCollectionList",
@@ -255,9 +245,9 @@ export const HomeIndex = () => {
( (
await getTvShowsApi(api).getNextUp({ await getTvShowsApi(api).getNextUp({
userId: user?.Id, userId: user?.Id,
fields: ["MediaSourceCount", "Genres"], fields: ["MediaSourceCount"],
limit: 20, limit: 20,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], enableImageTypes: ["Primary", "Backdrop", "Thumb"],
enableResumable: false, enableResumable: false,
}) })
).data.Items || [], ).data.Items || [],
@@ -318,10 +308,10 @@ export const HomeIndex = () => {
if (!api || !user?.Id || !settings?.home?.sections) return []; if (!api || !user?.Id || !settings?.home?.sections) return [];
const ss: Section[] = []; const ss: Section[] = [];
for (const [index, section] of settings.home.sections.entries()) { for (const [index, section] of settings.home.sections.entries()) {
const id = section.title || `section-${index}`; const id = section.items?.title || `section-${index}`;
ss.push({ ss.push({
title: t(`${id}`), title: t(`${id}`),
queryKey: ["home", "custom", String(index), section.title ?? null], queryKey: ["home", id],
queryFn: async () => { queryFn: async () => {
if (section.items) { if (section.items) {
const response = await getItemsApi(api).getItems({ const response = await getItemsApi(api).getItems({
@@ -339,9 +329,9 @@ export const HomeIndex = () => {
if (section.nextUp) { if (section.nextUp) {
const response = await getTvShowsApi(api).getNextUp({ const response = await getTvShowsApi(api).getNextUp({
userId: user?.Id, userId: user?.Id,
fields: ["MediaSourceCount", "Genres"], fields: ["MediaSourceCount"],
limit: section.nextUp?.limit || 25, limit: section.nextUp?.limit || 25,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], enableImageTypes: ["Primary", "Backdrop", "Thumb"],
enableResumable: section.nextUp?.enableResumable, enableResumable: section.nextUp?.enableResumable,
enableRewatching: section.nextUp?.enableRewatching, enableRewatching: section.nextUp?.enableRewatching,
}); });
@@ -357,16 +347,6 @@ export const HomeIndex = () => {
}); });
return response.data || []; return response.data || [];
} }
if (section.custom) {
const response = await api.get<BaseItemDtoQueryResult>(
section.custom.endpoint,
{
params: { ...(section.custom.query || {}), userId: user?.Id },
headers: section.custom.headers || {},
},
);
return response.data.Items || [];
}
return []; return [];
}, },
type: "ScrollingCollectionList", type: "ScrollingCollectionList",
@@ -378,28 +358,13 @@ export const HomeIndex = () => {
const sections = settings?.home?.sections ? customSections : defaultSections; const sections = settings?.home?.sections ? customSections : defaultSections;
if (!isConnected || serverConnected !== true) { if (isConnected === false) {
let title = "";
let subtitle = "";
if (!isConnected) {
// No network connection
title = t("home.no_internet");
subtitle = t("home.no_internet_message");
} else if (serverConnected === null) {
// Network is up, but server is being checked
title = t("home.checking_server_connection");
subtitle = t("home.checking_server_connection_message");
} else if (!serverConnected) {
// Network is up, but server is unreachable
title = t("home.server_unreachable");
subtitle = t("home.server_unreachable_message");
}
return ( return (
<View className='flex flex-col items-center justify-center h-full -mt-6 px-8'> <View className='flex flex-col items-center justify-center h-full -mt-6 px-8'>
<Text className='text-3xl font-bold mb-2'>{title}</Text> <Text className='text-3xl font-bold mb-2'>{t("home.no_internet")}</Text>
<Text className='text-center opacity-70'>{subtitle}</Text> <Text className='text-center opacity-70'>
{t("home.no_internet_message")}
</Text>
<View className='mt-4'> <View className='mt-4'>
{!Platform.isTV && ( {!Platform.isTV && (
<Button <Button
@@ -413,7 +378,6 @@ export const HomeIndex = () => {
{t("home.go_to_downloads")} {t("home.go_to_downloads")}
</Button> </Button>
)} )}
<Button <Button
color='black' color='black'
onPress={retryCheck} onPress={retryCheck}
@@ -426,9 +390,9 @@ export const HomeIndex = () => {
} }
> >
{retryLoading ? ( {retryLoading ? (
<ActivityIndicator size='small' color='white' /> <ActivityIndicator size={"small"} color={"white"} />
) : ( ) : (
t("home.retry") "Retry"
)} )}
</Button> </Button>
</View> </View>
@@ -458,55 +422,44 @@ export const HomeIndex = () => {
scrollToOverflowEnabled={true} scrollToOverflowEnabled={true}
ref={scrollViewRef} ref={scrollViewRef}
nestedScrollEnabled nestedScrollEnabled
contentInsetAdjustmentBehavior='never' contentInsetAdjustmentBehavior='automatic'
refreshControl={ refreshControl={
<RefreshControl <RefreshControl refreshing={loading} onRefresh={refetch} />
refreshing={loading}
onRefresh={refetch}
tintColor='white' // For iOS
colors={["white"]} // For Android
progressViewOffset={200} // This offsets the refresh indicator to appear over the carousel
/>
} }
style={{ marginTop: Platform.isTV ? 0 : -100 }} contentContainerStyle={{
contentContainerStyle={{ paddingTop: Platform.isTV ? 0 : 100 }} paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 16,
}}
> >
<AppleTVCarousel initialIndex={0} /> <View className='flex flex-col space-y-4'>
<View <LargeMovieCarousel />
style={{
paddingLeft: insets.left, {sections.map((section, index) => {
paddingRight: insets.right, if (section.type === "ScrollingCollectionList") {
paddingBottom: 16, return (
}} <ScrollingCollectionList
> key={index}
<View className='flex flex-col space-y-4'> title={section.title}
{sections.map((section, index) => { queryKey={section.queryKey}
if (section.type === "ScrollingCollectionList") { queryFn={section.queryFn}
return ( orientation={section.orientation}
<ScrollingCollectionList hideIfEmpty
key={index} />
title={section.title} );
queryKey={section.queryKey} }
queryFn={section.queryFn} if (section.type === "MediaListSection") {
orientation={section.orientation} return (
hideIfEmpty <MediaListSection
/> key={index}
); queryKey={section.queryKey}
} queryFn={section.queryFn}
if (section.type === "MediaListSection") { />
return ( );
<MediaListSection }
key={index} return null;
queryKey={section.queryKey} })}
queryFn={section.queryFn}
/>
);
}
return null;
})}
</View>
</View> </View>
<View className='h-24' />
</ScrollView> </ScrollView>
); );
}; };

View File

@@ -4,6 +4,7 @@ import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { View } from "react-native"; import { View } from "react-native";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import { PasswordInput } from "@/components/PasswordInput";
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr"; import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
import { userAtom } from "@/providers/JellyfinProvider"; import { userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
@@ -30,6 +31,9 @@ export const JellyseerrSettings = () => {
string | undefined string | undefined
>(settings?.jellyseerrServerUrl || undefined); >(settings?.jellyseerrServerUrl || undefined);
const [showJellyseerrPassword, setShowJellyseerrPassword] =
useState<boolean>(false);
const loginToJellyseerrMutation = useMutation({ const loginToJellyseerrMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
if (!jellyseerrServerUrl && !settings?.jellyseerrServerUrl) if (!jellyseerrServerUrl && !settings?.jellyseerrServerUrl)
@@ -127,7 +131,7 @@ export const JellyseerrSettings = () => {
</Text> </Text>
</View> </View>
<Input <Input
className='border border-neutral-800 mb-2' extraClassName='border border-neutral-800 mb-2'
placeholder={t( placeholder={t(
"home.settings.plugins.jellyseerr.server_url_placeholder", "home.settings.plugins.jellyseerr.server_url_placeholder",
)} )}
@@ -146,23 +150,20 @@ export const JellyseerrSettings = () => {
<Text className='font-bold mb-2'> <Text className='font-bold mb-2'>
{t("home.settings.plugins.jellyseerr.password")} {t("home.settings.plugins.jellyseerr.password")}
</Text> </Text>
<Input <View className='relative'>
className='border border-neutral-800' <PasswordInput
autoFocus={true} value={jellyseerrPassword}
focusable={true} onChangeText={setJellyseerrPassword}
placeholder={t( placeholder={t(
"home.settings.plugins.jellyseerr.password_placeholder", "home.settings.plugins.jellyseerr.password_placeholder",
{ username: user?.Name }, { username: user?.Name },
)} )}
value={jellyseerrPassword} showPassword={showJellyseerrPassword}
keyboardType='default' onShowPasswordChange={setShowJellyseerrPassword}
secureTextEntry={true} layout='mobile'
returnKeyType='done' topOffset={11}
autoCapitalize='none' />
textContentType='password' </View>
onChangeText={setJellyseerrPassword}
editable={!loginToJellyseerrMutation.isPending}
/>
<Button <Button
loading={loginToJellyseerrMutation.isPending} loading={loginToJellyseerrMutation.isPending}
disabled={loginToJellyseerrMutation.isPending} disabled={loginToJellyseerrMutation.isPending}

View File

@@ -1,254 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import type React from "react";
import { useCallback, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import {
StyleSheet,
TouchableOpacity,
View,
type ViewProps,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
interface LibraryOptions {
display: "row" | "list";
imageStyle: "poster" | "cover";
showTitles: boolean;
showStats: boolean;
}
interface Props extends ViewProps {
open: boolean;
setOpen: (open: boolean) => void;
settings: LibraryOptions;
updateSettings: (options: Partial<LibraryOptions>) => void;
disabled?: boolean;
}
const OptionGroup: React.FC<{ title: string; children: React.ReactNode }> = ({
title,
children,
}) => (
<View className='mb-6'>
<Text className='text-lg font-semibold mb-3 text-neutral-300'>{title}</Text>
<View
style={{
borderRadius: 12,
overflow: "hidden",
}}
className='bg-neutral-800 rounded-xl overflow-hidden'
>
{children}
</View>
</View>
);
const OptionItem: React.FC<{
label: string;
selected: boolean;
onPress: () => void;
disabled?: boolean;
isLast?: boolean;
}> = ({ label, selected, onPress, disabled: itemDisabled, isLast }) => (
<>
<TouchableOpacity
onPress={onPress}
disabled={itemDisabled}
className={`px-4 py-3 flex flex-row items-center justify-between ${
itemDisabled ? "opacity-50" : ""
}`}
>
<Text className='flex-1 text-white'>{label}</Text>
{selected ? (
<Ionicons name='checkmark-circle' size={24} color='#9333ea' />
) : (
<Ionicons name='ellipse-outline' size={24} color='#6b7280' />
)}
</TouchableOpacity>
{!isLast && (
<View
style={{
height: StyleSheet.hairlineWidth,
}}
className='bg-neutral-700 mx-4'
/>
)}
</>
);
const ToggleItem: React.FC<{
label: string;
value: boolean;
onToggle: () => void;
disabled?: boolean;
isLast?: boolean;
}> = ({ label, value, onToggle, disabled: itemDisabled, isLast }) => (
<>
<TouchableOpacity
onPress={onToggle}
disabled={itemDisabled}
className={`px-4 py-3 flex flex-row items-center justify-between ${
itemDisabled ? "opacity-50" : ""
}`}
>
<Text className='flex-1 text-white'>{label}</Text>
<View
className={`w-12 h-7 rounded-full ${value ? "bg-purple-600" : "bg-neutral-600"} flex-row items-center`}
>
<View
className={`w-5 h-5 rounded-full bg-white shadow-md transform transition-transform ${
value ? "translate-x-6" : "translate-x-1"
}`}
/>
</View>
</TouchableOpacity>
{!isLast && (
<View
style={{
height: StyleSheet.hairlineWidth,
}}
className='bg-neutral-700 mx-4'
/>
)}
</>
);
/**
* LibraryOptionsSheet Component
*
* This component creates a bottom sheet modal for managing library display options.
*/
export const LibraryOptionsSheet: React.FC<Props> = ({
open,
setOpen,
settings,
updateSettings,
disabled = false,
}) => {
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const handlePresentModal = useCallback(() => {
bottomSheetModalRef.current?.present();
}, []);
const handleDismissModal = useCallback(() => {
bottomSheetModalRef.current?.dismiss();
}, []);
useEffect(() => {
if (open) {
handlePresentModal();
} else {
handleDismissModal();
}
}, [open, handlePresentModal, handleDismissModal]);
const handleSheetChanges = useCallback(
(index: number) => {
if (index === -1) {
setOpen(false);
}
},
[setOpen],
);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
if (disabled) return null;
return (
<BottomSheetModal
ref={bottomSheetModalRef}
enableDynamicSizing
onChange={handleSheetChanges}
backdropComponent={renderBackdrop}
handleIndicatorStyle={{
backgroundColor: "white",
}}
backgroundStyle={{
backgroundColor: "#171717",
}}
enablePanDownToClose
enableDismissOnClose
>
<BottomSheetView>
<View
className='px-4 pb-8 pt-2'
style={{
paddingLeft: Math.max(16, insets.left),
paddingRight: Math.max(16, insets.right),
}}
>
<Text className='font-bold text-2xl mb-6'>
{t("library.options.display")}
</Text>
<OptionGroup title={t("library.options.display")}>
<OptionItem
label={t("library.options.row")}
selected={settings.display === "row"}
onPress={() => updateSettings({ display: "row" })}
/>
<OptionItem
label={t("library.options.list")}
selected={settings.display === "list"}
onPress={() => updateSettings({ display: "list" })}
isLast
/>
</OptionGroup>
<OptionGroup title={t("library.options.image_style")}>
<OptionItem
label={t("library.options.poster")}
selected={settings.imageStyle === "poster"}
onPress={() => updateSettings({ imageStyle: "poster" })}
/>
<OptionItem
label={t("library.options.cover")}
selected={settings.imageStyle === "cover"}
onPress={() => updateSettings({ imageStyle: "cover" })}
isLast
/>
</OptionGroup>
<OptionGroup title='Options'>
<ToggleItem
label={t("library.options.show_titles")}
value={settings.showTitles}
onToggle={() =>
updateSettings({ showTitles: !settings.showTitles })
}
disabled={settings.imageStyle === "poster"}
/>
<ToggleItem
label={t("library.options.show_stats")}
value={settings.showStats}
onToggle={() =>
updateSettings({ showStats: !settings.showStats })
}
isLast
/>
</OptionGroup>
</View>
</BottomSheetView>
</BottomSheetModal>
);
};

View File

@@ -41,10 +41,10 @@ export const OtherSettings: React.FC = () => {
if (settings?.autoDownload === true && !registered) { if (settings?.autoDownload === true && !registered) {
registerBackgroundFetchAsync(); registerBackgroundFetchAsync();
toast.success(t("home.settings.toasts.background_downloads_enabled")); toast.success("Background downloads enabled");
} else if (settings?.autoDownload === false && registered) { } else if (settings?.autoDownload === false && registered) {
unregisterBackgroundFetchAsync(); unregisterBackgroundFetchAsync();
toast.info(t("home.settings.toasts.background_downloads_disabled")); toast.info("Background downloads disabled");
} else if (settings?.autoDownload === true && registered) { } else if (settings?.autoDownload === true && registered) {
// Don't to anything // Don't to anything
} else if (settings?.autoDownload === false && !registered) { } else if (settings?.autoDownload === false && !registered) {

View File

@@ -2,6 +2,7 @@ import {
BottomSheetBackdrop, BottomSheetBackdrop,
type BottomSheetBackdropProps, type BottomSheetBackdropProps,
BottomSheetModal, BottomSheetModal,
BottomSheetTextInput,
BottomSheetView, BottomSheetView,
} from "@gorhom/bottom-sheet"; } from "@gorhom/bottom-sheet";
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api"; import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
@@ -9,19 +10,17 @@ import { useAtom } from "jotai";
import type React from "react"; import type React from "react";
import { useCallback, useRef, useState } from "react"; import { useCallback, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Alert, Platform, View, type ViewProps } from "react-native"; import { Alert, View, type ViewProps } from "react-native";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { Button } from "../Button"; import { Button } from "../Button";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
import { PinInput } from "../inputs/PinInput";
import { ListGroup } from "../list/ListGroup"; import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem"; import { ListItem } from "../list/ListItem";
interface Props extends ViewProps {} interface Props extends ViewProps {}
export const QuickConnect: React.FC<Props> = ({ ...props }) => { export const QuickConnect: React.FC<Props> = ({ ...props }) => {
const isTv = Platform.isTV;
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const [quickConnectCode, setQuickConnectCode] = useState<string>(); const [quickConnectCode, setQuickConnectCode] = useState<string>();
@@ -74,17 +73,11 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
} }
}, [api, user, quickConnectCode]); }, [api, user, quickConnectCode]);
if (isTv) return null;
return ( return (
<View {...props}> <View {...props}>
<ListGroup title={t("home.settings.quick_connect.quick_connect_title")}> <ListGroup title={t("home.settings.quick_connect.quick_connect_title")}>
<ListItem <ListItem
onPress={() => { onPress={() => bottomSheetModalRef?.current?.present()}
// Reset the code when opening the sheet
setQuickConnectCode("");
bottomSheetModalRef?.current?.present();
}}
title={t("home.settings.quick_connect.authorize_button")} title={t("home.settings.quick_connect.authorize_button")}
textColor='blue' textColor='blue'
/> />
@@ -100,9 +93,6 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
backgroundColor: "#171717", backgroundColor: "#171717",
}} }}
backdropComponent={renderBackdrop} backdropComponent={renderBackdrop}
keyboardBehavior='interactive'
keyboardBlurBehavior='restore'
android_keyboardInputMode='adjustResize'
> >
<BottomSheetView> <BottomSheetView>
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'> <View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
@@ -112,17 +102,16 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
</Text> </Text>
</View> </View>
<View className='flex flex-col space-y-2'> <View className='flex flex-col space-y-2'>
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full space-y-4'> <View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full'>
<Text className='text-neutral-400 text-center'> <BottomSheetTextInput
{t( style={{ color: "white" }}
clearButtonMode='always'
placeholder={t(
"home.settings.quick_connect.enter_the_quick_connect_code", "home.settings.quick_connect.enter_the_quick_connect_code",
)} )}
</Text> placeholderTextColor='#9CA3AF'
<PinInput value={quickConnectCode}
value={quickConnectCode || ""}
onChangeText={setQuickConnectCode} onChangeText={setQuickConnectCode}
style={{ paddingHorizontal: 16 }}
autoFocus
/> />
</View> </View>
</View> </View>

View File

@@ -40,6 +40,7 @@ export const StorageSettings = () => {
}; };
const calculatePercentage = (value: number, total: number) => { const calculatePercentage = (value: number, total: number) => {
console.log("usage", value, total);
return ((value / total) * 100).toFixed(2); return ((value / total) * 100).toFixed(2);
}; };

View File

@@ -16,8 +16,6 @@ import { useMedia } from "./MediaContext";
interface Props extends ViewProps {} interface Props extends ViewProps {}
import { OUTLINE_THICKNESS, VLC_COLORS } from "@/constants/SubtitleConstants";
export const SubtitleToggles: React.FC<Props> = ({ ...props }) => { export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
const isTv = Platform.isTV; const isTv = Platform.isTV;
@@ -27,15 +25,6 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
const cultures = media.cultures; const cultures = media.cultures;
const { t } = useTranslation(); const { t } = useTranslation();
// Get VLC subtitle settings from the settings system
const textColor = settings?.vlcTextColor ?? "White";
const backgroundColor = settings?.vlcBackgroundColor ?? "Black";
const outlineColor = settings?.vlcOutlineColor ?? "Black";
const outlineThickness = settings?.vlcOutlineThickness ?? "Normal";
const backgroundOpacity = settings?.vlcBackgroundOpacity ?? 128;
const outlineOpacity = settings?.vlcOutlineOpacity ?? 255;
const isBold = settings?.vlcIsBold ?? false;
if (isTv) return null; if (isTv) return null;
if (!settings) return null; if (!settings) return null;
@@ -158,148 +147,6 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
onUpdate={(subtitleSize) => updateSettings({ subtitleSize })} onUpdate={(subtitleSize) => updateSettings({ subtitleSize })}
/> />
</ListItem> </ListItem>
<ListItem title={t("home.settings.subtitles.text_color")}>
<Dropdown
data={Object.keys(VLC_COLORS)}
keyExtractor={(item) => item}
titleExtractor={(item) =>
t(`home.settings.subtitles.colors.${item}`)
}
title={
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{t(`home.settings.subtitles.colors.${textColor}`)}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</TouchableOpacity>
}
label={t("home.settings.subtitles.text_color")}
onSelected={(value) => updateSettings({ vlcTextColor: value })}
/>
</ListItem>
<ListItem title={t("home.settings.subtitles.background_color")}>
<Dropdown
data={Object.keys(VLC_COLORS)}
keyExtractor={(item) => item}
titleExtractor={(item) =>
t(`home.settings.subtitles.colors.${item}`)
}
title={
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{t(`home.settings.subtitles.colors.${backgroundColor}`)}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</TouchableOpacity>
}
label={t("home.settings.subtitles.background_color")}
onSelected={(value) =>
updateSettings({ vlcBackgroundColor: value })
}
/>
</ListItem>
<ListItem title={t("home.settings.subtitles.outline_color")}>
<Dropdown
data={Object.keys(VLC_COLORS)}
keyExtractor={(item) => item}
titleExtractor={(item) =>
t(`home.settings.subtitles.colors.${item}`)
}
title={
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{t(`home.settings.subtitles.colors.${outlineColor}`)}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</TouchableOpacity>
}
label={t("home.settings.subtitles.outline_color")}
onSelected={(value) => updateSettings({ vlcOutlineColor: value })}
/>
</ListItem>
<ListItem title={t("home.settings.subtitles.outline_thickness")}>
<Dropdown
data={Object.keys(OUTLINE_THICKNESS)}
keyExtractor={(item) => item}
titleExtractor={(item) =>
t(`home.settings.subtitles.thickness.${item}`)
}
title={
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>
{t(`home.settings.subtitles.thickness.${outlineThickness}`)}
</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</TouchableOpacity>
}
label={t("home.settings.subtitles.outline_thickness")}
onSelected={(value) =>
updateSettings({ vlcOutlineThickness: value })
}
/>
</ListItem>
<ListItem title={t("home.settings.subtitles.background_opacity")}>
<Dropdown
data={[0, 32, 64, 96, 128, 160, 192, 224, 255]}
keyExtractor={String}
titleExtractor={(item) => `${Math.round((item / 255) * 100)}%`}
title={
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>{`${Math.round((backgroundOpacity / 255) * 100)}%`}</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</TouchableOpacity>
}
label={t("home.settings.subtitles.background_opacity")}
onSelected={(value) =>
updateSettings({ vlcBackgroundOpacity: value })
}
/>
</ListItem>
<ListItem title={t("home.settings.subtitles.outline_opacity")}>
<Dropdown
data={[0, 32, 64, 96, 128, 160, 192, 224, 255]}
keyExtractor={String}
titleExtractor={(item) => `${Math.round((item / 255) * 100)}%`}
title={
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>{`${Math.round((outlineOpacity / 255) * 100)}%`}</Text>
<Ionicons
name='chevron-expand-sharp'
size={18}
color='#5A5960'
/>
</TouchableOpacity>
}
label={t("home.settings.subtitles.outline_opacity")}
onSelected={(value) => updateSettings({ vlcOutlineOpacity: value })}
/>
</ListItem>
<ListItem title={t("home.settings.subtitles.bold_text")}>
<Switch
value={isBold}
onValueChange={(value) => updateSettings({ vlcIsBold: value })}
/>
</ListItem>
</ListGroup> </ListGroup>
</View> </View>
); );

View File

@@ -1,6 +1,5 @@
import type { ParamListBase, RouteProp } from "@react-navigation/native"; import type { ParamListBase, RouteProp } from "@react-navigation/native";
import type { NativeStackNavigationOptions } from "@react-navigation/native-stack"; import type { NativeStackNavigationOptions } from "@react-navigation/native-stack";
import { Platform } from "react-native";
import { HeaderBackButton } from "../common/HeaderBackButton"; import { HeaderBackButton } from "../common/HeaderBackButton";
type ICommonScreenOptions = type ICommonScreenOptions =
@@ -13,9 +12,8 @@ type ICommonScreenOptions =
export const commonScreenOptions: ICommonScreenOptions = { export const commonScreenOptions: ICommonScreenOptions = {
title: "", title: "",
headerShown: true, headerShown: true,
headerTransparent: Platform.OS === "ios", headerTransparent: true,
headerShadowVisible: false, headerShadowVisible: false,
headerBlurEffect: "none",
headerLeft: () => <HeaderBackButton />, headerLeft: () => <HeaderBackButton />,
}; };

View File

@@ -379,7 +379,8 @@ export const Controls: FC<Props> = ({
console.log("queryParams", queryParams); console.log("queryParams", queryParams);
router.replace(`player/direct-player?${queryParams}` as any); // @ts-expect-error
router.replace(`player/direct-player?${queryParams}`);
}, },
[settings, subtitleIndex, audioIndex, mediaSource, bitrateValue, router], [settings, subtitleIndex, audioIndex, mediaSource, bitrateValue, router],
); );

View File

@@ -95,7 +95,8 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
playbackPosition: playbackPosition, playbackPosition: playbackPosition,
}).toString(); }).toString();
router.replace(`player/direct-player?${queryParams}` as any); //@ts-expect-error
router.replace(`player/direct-player?${queryParams}`);
}; };
const setTrackParams = ( const setTrackParams = (

View File

@@ -51,7 +51,8 @@ const DropdownView = () => {
bitrateValue: bitrate.toString(), bitrateValue: bitrate.toString(),
playbackPosition: playbackPosition, playbackPosition: playbackPosition,
}).toString(); }).toString();
router.replace(`player/direct-player?${queryParams}` as any); // @ts-expect-error
router.replace(`player/direct-player?${queryParams}`);
}, },
[item, mediaSource, subtitleIndex, audioIndex, playbackPosition], [item, mediaSource, subtitleIndex, audioIndex, playbackPosition],
); );

View File

@@ -1,45 +0,0 @@
export type VLCColor =
| "Black"
| "Gray"
| "Silver"
| "White"
| "Maroon"
| "Red"
| "Fuchsia"
| "Yellow"
| "Olive"
| "Green"
| "Teal"
| "Lime"
| "Purple"
| "Navy"
| "Blue"
| "Aqua";
export type OutlineThickness = "None" | "Thin" | "Normal" | "Thick";
export const VLC_COLORS: Record<VLCColor, number> = {
Black: 0,
Gray: 8421504,
Silver: 12632256,
White: 16777215,
Maroon: 8388608,
Red: 16711680,
Fuchsia: 16711935,
Yellow: 16776960,
Olive: 8421376,
Green: 32768,
Teal: 32896,
Lime: 65280,
Purple: 8388736,
Navy: 128,
Blue: 255,
Aqua: 65535,
};
export const OUTLINE_THICKNESS: Record<OutlineThickness, number> = {
None: 0,
Thin: 2,
Normal: 4,
Thick: 6,
};

View File

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

View File

@@ -4,17 +4,6 @@
}, },
"build": { "build": {
"development": { "development": {
"resourceClass": "medium",
"cache": {
"key": "dev-{{ checksum \"bun.lock\" \"app.config.js\" }}",
"paths": [
"~/.bun/install/cache",
"node_modules",
".expo",
"android/.gradle",
"ios/Pods"
]
},
"environment": "development", "environment": "development",
"developmentClient": true, "developmentClient": true,
"distribution": "internal", "distribution": "internal",
@@ -26,11 +15,6 @@
} }
}, },
"development_tv": { "development_tv": {
"resourceClass": "medium",
"cache": {
"key": "development-tv-{{ checksum \"bun.lock\" \"package.json\" }}",
"paths": ["~/.bun/install/cache", "node_modules", ".expo"]
},
"environment": "development", "environment": "development",
"developmentClient": true, "developmentClient": true,
"distribution": "internal", "distribution": "internal",
@@ -43,11 +27,6 @@
} }
}, },
"development-simulator": { "development-simulator": {
"resourceClass": "medium",
"cache": {
"key": "development-simulator-{{ checksum \"bun.lock\" \"package.json\" }}",
"paths": ["~/.bun/install/cache", "node_modules", ".expo"]
},
"environment": "development", "environment": "development",
"developmentClient": true, "developmentClient": true,
"distribution": "internal", "distribution": "internal",
@@ -59,72 +38,29 @@
} }
}, },
"preview": { "preview": {
"resourceClass": "large",
"cache": {
"key": "preview-{{ checksum \"bun.lock\" \"app.config.js\" }}",
"paths": [
"~/.bun/install/cache",
"node_modules",
".expo",
"android/.gradle",
"ios/Pods",
".next"
]
},
"distribution": "internal", "distribution": "internal",
"env": { "env": {
"EXPO_OPTIMIZE_BUNDLE_SIZE": "1",
"NODE_ENV": "production",
"EXPO_PUBLIC_WRITE_DEBUG": "1" "EXPO_PUBLIC_WRITE_DEBUG": "1"
} }
}, },
"production": { "production": {
"resourceClass": "large",
"cache": {
"key": "production-{{ checksum \"bun.lock\" \"app.config.js\" }}",
"paths": [
"~/.bun/install/cache",
"node_modules",
".expo",
"android/.gradle",
"ios/Pods"
]
},
"environment": "production", "environment": "production",
"channel": "0.39.0", "channel": "0.35.1",
"android": { "android": {
"buildType": "app-bundle",
"image": "latest" "image": "latest"
},
"ios": {
"image": "latest"
},
"env": {
"EXPO_OPTIMIZE_BUNDLE_SIZE": "1",
"NODE_ENV": "production"
} }
}, },
"production-apk": { "production-apk": {
"resourceClass": "large",
"cache": {
"key": "production-apk-{{ checksum \"bun.lock\" \"package.json\" }}",
"paths": ["~/.bun/install/cache", "node_modules", ".expo"]
},
"environment": "production", "environment": "production",
"channel": "0.39.0", "channel": "0.35.1",
"android": { "android": {
"buildType": "apk", "buildType": "apk",
"image": "latest" "image": "latest"
} }
}, },
"production-apk-tv": { "production-apk-tv": {
"resourceClass": "large",
"cache": {
"key": "production-apk-tv-{{ checksum \"bun.lock\" \"package.json\" }}",
"paths": ["~/.bun/install/cache", "node_modules", ".expo"]
},
"environment": "production", "environment": "production",
"channel": "0.39.0", "channel": "0.35.1",
"android": { "android": {
"buildType": "apk", "buildType": "apk",
"image": "latest" "image": "latest"

View File

@@ -1,131 +0,0 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useAtomValue } from "jotai";
import { useEffect, useMemo, useState } from "react";
import { Platform } from "react-native";
import { getColors, ImageColorsResult } from "react-native-image-colors";
import { apiAtom } from "@/providers/JellyfinProvider";
import {
adjustToNearBlack,
calculateTextColor,
isCloseToBlack,
} from "@/utils/atoms/primaryColor";
import { getItemImage } from "@/utils/getItemImage";
import { storage } from "@/utils/mmkv";
export interface ThemeColors {
primary: string;
text: string;
}
const DEFAULT_COLORS: ThemeColors = {
primary: "#FFFFFF",
text: "#000000",
};
/**
* Custom hook to extract and return image colors for a given item.
* Returns colors as state instead of updating global atom.
*
* @param item - The BaseItemDto object representing the item.
* @param disabled - A boolean flag to disable color extraction.
* @returns ThemeColors object with primary and text colors
*/
export const useImageColorsReturn = ({
item,
url,
disabled,
}: {
item?: BaseItemDto | null;
url?: string | null;
disabled?: boolean;
}): ThemeColors => {
const api = useAtomValue(apiAtom);
const [colors, setColors] = useState<ThemeColors>(DEFAULT_COLORS);
const isTv = Platform.isTV;
const source = useMemo(() => {
if (!api) return;
if (url) return { uri: url };
if (item)
return getItemImage({
item,
api,
variant: "Primary",
quality: 80,
width: 300,
});
return null;
}, [api, item, url]);
useEffect(() => {
// Reset to default colors when item changes
if (!item && !url) {
setColors(DEFAULT_COLORS);
return;
}
if (isTv) return;
if (disabled) return;
if (source?.uri) {
const _primary = storage.getString(`${source.uri}-primary`);
const _text = storage.getString(`${source.uri}-text`);
if (_primary && _text) {
setColors({
primary: _primary,
text: _text,
});
return;
}
// Extract colors from the image
getColors(source.uri, {
fallback: "#fff",
cache: false,
})
.then((colors: ImageColorsResult) => {
let primary = "#fff";
let text = "#000";
let backup = "#fff";
// Select the appropriate color based on the platform
if (colors.platform === "android") {
primary = colors.dominant;
backup = colors.vibrant;
} else if (colors.platform === "ios") {
primary = colors.detail;
backup = colors.primary;
}
// Adjust the primary color if it's too close to black
if (primary && isCloseToBlack(primary)) {
if (backup && !isCloseToBlack(backup)) primary = backup;
primary = adjustToNearBlack(primary);
}
// Calculate the text color based on the primary color
if (primary) text = calculateTextColor(primary);
const newColors = {
primary,
text,
};
setColors(newColors);
// Cache the colors in storage
if (source.uri && primary) {
storage.set(`${source.uri}-primary`, primary);
storage.set(`${source.uri}-text`, text);
}
})
.catch((error: any) => {
console.error("Error getting colors", error);
setColors(DEFAULT_COLORS);
});
}
}, [isTv, source?.uri, disabled, item, url]);
return colors;
};

View File

@@ -387,7 +387,7 @@ export class JellyseerrApi {
`Jellyseerr response error\nerror: ${error.toString()}\nurl: ${error?.config?.url}`, `Jellyseerr response error\nerror: ${error.toString()}\nurl: ${error?.config?.url}`,
error.response?.data, error.response?.data,
); );
if (error.response?.status === 403) { if (error.status === 403) {
clearJellyseerrStorageData(); clearJellyseerrStorageData();
} }
return Promise.reject(error); return Promise.reject(error);
@@ -512,7 +512,7 @@ export const useJellyseerr = () => {
}; };
const jellyseerrRegion = useMemo( const jellyseerrRegion = useMemo(
() => jellyseerrUser?.settings?.discoverRegion || "US", () => jellyseerrUser?.settings?.region || "US",
[jellyseerrUser], [jellyseerrUser],
); );

View File

@@ -1,58 +1,30 @@
import NetInfo from "@react-native-community/netinfo"; import NetInfo from "@react-native-community/netinfo";
import { useAtom } from "jotai";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { apiAtom } from "@/providers/JellyfinProvider";
async function checkApiReachable(basePath?: string): Promise<boolean> {
if (!basePath) return false;
try {
const response = await fetch(basePath, { method: "HEAD" });
return response.ok;
} catch {
return false;
}
}
export function useNetworkStatus() { export function useNetworkStatus() {
const [isConnected, setIsConnected] = useState(false); const [isConnected, setIsConnected] = useState(false);
const [serverConnected, setServerConnected] = useState<boolean | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [api] = useAtom(apiAtom);
const validateConnection = useCallback(async () => {
if (!api?.basePath) return false;
const reachable = await checkApiReachable(api.basePath);
setServerConnected(reachable);
return reachable;
}, [api?.basePath]);
// Manual check (optional)
const retryCheck = useCallback(async () => { const retryCheck = useCallback(async () => {
setLoading(true); setLoading(true);
await validateConnection(); const state = await NetInfo.fetch();
setIsConnected(!!state.isConnected && !!state.isInternetReachable);
setLoading(false); setLoading(false);
}, [validateConnection]); }, []);
useEffect(() => { useEffect(() => {
const unsubscribe = NetInfo.addEventListener(async (state) => { const unsubscribe = NetInfo.addEventListener((state) => {
setIsConnected(!!state.isConnected); setIsConnected(!!state.isConnected && !!state.isInternetReachable);
if (state.isConnected) {
await validateConnection();
} else {
setServerConnected(false);
}
}); });
// Initial check: wait for NetInfo first // Initial state
NetInfo.fetch().then((state) => { NetInfo.fetch().then((state) => {
if (state.isConnected) { setIsConnected(!!state.isConnected && !!state.isInternetReachable);
validateConnection();
} else {
setServerConnected(false);
}
}); });
return () => unsubscribe(); return () => unsubscribe();
}, [validateConnection]); }, []);
return { isConnected, serverConnected, loading, retryCheck }; return { isConnected, loading, retryCheck };
} }

View File

@@ -1,7 +1,4 @@
import type { import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
BaseItemDto,
PlaybackProgressInfo,
} from "@jellyfin/sdk/lib/generated-client";
import { getPlaystateApi, getTvShowsApi } from "@jellyfin/sdk/lib/utils/api"; import { getPlaystateApi, getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
@@ -144,10 +141,13 @@ export const usePlaybackManager = ({
* @param positionTicks The current playback position in ticks. * @param positionTicks The current playback position in ticks.
*/ */
const reportPlaybackProgress = async ( const reportPlaybackProgress = async (
playbackProgressInfo: PlaybackProgressInfo, itemId: string,
positionTicks: number,
metadata?: {
AudioStreamIndex: number;
SubtitleStreamIndex: number;
},
) => { ) => {
const positionTicks = playbackProgressInfo.PositionTicks || 0;
const itemId = playbackProgressInfo.ItemId!;
const localItem = getDownloadedItemById(itemId); const localItem = getDownloadedItemById(itemId);
// Handle local state update for downloaded items // Handle local state update for downloaded items
@@ -192,7 +192,14 @@ export const usePlaybackManager = ({
if (isOnline && api) { if (isOnline && api) {
try { try {
await getPlaystateApi(api).reportPlaybackProgress({ await getPlaystateApi(api).reportPlaybackProgress({
playbackProgressInfo, playbackProgressInfo: {
ItemId: itemId,
PositionTicks: Math.floor(positionTicks),
...(metadata && { AudioStreamIndex: metadata.AudioStreamIndex }),
...(metadata && {
SubtitleStreamIndex: metadata.SubtitleStreamIndex,
}),
},
}); });
} catch (error) { } catch (error) {
console.error("Failed to report playback progress", error); console.error("Failed to report playback progress", error);

View File

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

6
login.yaml Normal file
View File

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

View File

@@ -1,243 +1,28 @@
// Learn more https://docs.expo.io/guides/customizing-metro // Learn more https://docs.expo.io/guides/customizing-metro
const { getDefaultConfig } = require("expo/metro-config"); const { getDefaultConfig } = require("expo/metro-config");
const path = require("node:path");
/** @type {import('expo/metro-config').MetroConfig} */ /** @type {import('expo/metro-config').MetroConfig} */
const config = getDefaultConfig(__dirname); const config = getDefaultConfig(__dirname); // eslint-disable-line no-undef
// ======================================================= // Add Hermes parser
// STREAMYFIN METRO CONFIG - PERFORMANCE OPTIMIZED 🚀
// =======================================================
// Advanced configuration for multi-platform Jellyfin client
// Optimized for media streaming, TV support, and Bun
// =======================================================
// HERMES + ADVANCED PERFORMANCE
// ==============================
config.transformer.hermesParser = true; config.transformer.hermesParser = true;
// CPU optimization (your existing setting) // When enabled, the optional code below will allow Metro to resolve
const os = require("node:os"); // and bundle source files with TV-specific extensions
config.maxWorkers = Math.max(1, os.cpus().length - 1); // (e.g., *.ios.tv.tsx, *.android.tv.tsx, *.tv.tsx)
//
// JAVASCRIPT OPTIMIZATION (Safe & Stable) // Metro will still resolve source files with standard extensions
// ======================================== // as usual if TV-specific files are not found for a module.
config.transformer = { //
...config.transformer,
hermesParser: true,
// NEW: Inline requires for 15-30% startup improvement
inlineRequires: true,
// ADVANCED: Hermes-optimized minification for streaming apps
minifierConfig: {
mangle: {
keep_fnames: process.env.NODE_ENV === "development",
},
output: {
ascii_only: true,
beautify: false,
semicolons: false,
},
compress: {
// Production-only optimizations
drop_console: process.env.NODE_ENV === "production",
dead_code: true,
drop_debugger: true,
conditionals: true,
evaluate: true,
unused: true,
reduce_vars: true,
// Keep class names for error reporting
keep_classnames: true,
// Preserve function names for performance profiling in dev
keep_fnames: process.env.NODE_ENV === "development",
},
},
};
// RESOLVER OPTIMIZATIONS ENHANCED
// ===============================
config.resolver = {
...config.resolver,
// NEW: Package exports (stable in recent Metro)
unstable_enablePackageExports: true,
// ENHANCED: Extensions optimized for Streamyfin
assetExts: [
...config.resolver.assetExts,
// Video formats (enhanced for Jellyfin)
"mkv",
"mp4",
"avi",
"mov",
"wmv",
"flv",
"webm",
"m4v",
"mpg",
"mpeg",
// Audio formats
"mp3",
"wav",
"flac",
"aac",
"m4a",
"ogg",
"wma",
"opus",
// Subtitle files (complete for media)
"srt",
"vtt",
"ass",
"ssa",
"sub",
"idx",
"sbv",
"ttml",
// Database/Cache
"db",
"sqlite",
"realm",
"json5",
// Fonts (TV optimized)
"woff2",
"woff",
"eot",
"otf",
// Images (enhanced for thumbnails)
"avif",
"heic",
"heif", // Modern formats
],
sourceExts: [
...config.resolver.sourceExts,
"mjs",
"cjs", // Modern JS support
],
// NEW: Platform prioritization for performance
platforms: ["ios", "android", "native", "web", "tv"],
};
// SERIALIZER OPTIMIZATIONS (Production)
// ====================================
if (process.env.NODE_ENV === "production") {
config.serializer = {
...config.serializer,
// NEW: Module IDs optimized for caching
createModuleIdFactory: () => {
return (path) => {
// Shorter module IDs for smaller bundles
return require("node:crypto")
.createHash("sha1")
.update(path)
.digest("hex")
.substring(0, 8);
};
},
// NEW: Bundle pre-loading for streaming apps
getModulesRunBeforeMainModule: (_entryFilePath) => [
// Pre-load critical modules for faster TTI
require.resolve("react-native/Libraries/Core/InitializeCore"),
],
// Web bundle splitting (if applicable)
...(process.env.EXPO_PLATFORM === "web" && {
// Experimental code splitting for web
experimentalSerializerHook: () => {},
}),
};
}
// TV PLATFORM ENHANCEMENTS
// ========================
if (process.env?.EXPO_TV === "1") { if (process.env?.EXPO_TV === "1") {
const originalSourceExts = config.resolver.sourceExts; const originalSourceExts = config.resolver.sourceExts;
const tvSourceExts = [ const tvSourceExts = [
...originalSourceExts.map((ext) => `tv.${ext}`), ...originalSourceExts.map((e) => `tv.${e}`),
...originalSourceExts, ...originalSourceExts,
]; ];
config.resolver.sourceExts = tvSourceExts; config.resolver.sourceExts = tvSourceExts;
// NEW: TV-specific optimizations
config.transformer = {
...config.transformer,
// Optimize transforms for TV hardware
experimentalImportSupport: false, // Reduce complexity on TV
// Use legacy transformer for better TV compatibility
allowOptionalDependencies: true,
};
console.log("📺 TV platform optimized for Streamyfin");
console.log(`📁 TV extensions: ${tvSourceExts.slice(0, 6).join(", ")}...`);
} }
// DEVELOPMENT ENHANCEMENTS // config.resolver.unstable_enablePackageExports = false;
// ========================
if (process.env.NODE_ENV === "development") {
// NEW: Enhanced error reporting
config.reporter = {
update: (event) => {
if (event.type === "bundle_build_failed") {
console.error(
"🔴 Streamyfin Bundle Build Failed:",
event.error.message,
);
} else if (event.type === "bundle_build_done") {
console.log(
"✅ Streamyfin Bundle Ready:",
event.bundleDetails?.bundleSize || "Unknown size",
);
}
},
};
console.log("🚀 Streamyfin Metro Config - OPTIMIZED VERSION");
console.log(`📦 Workers: ${config.maxWorkers}`);
console.log(`🎯 Hermes: ${config.transformer.hermesParser}`);
console.log(`⚡ Inline requires: ${config.transformer.inlineRequires}`);
console.log(
`📺 TV support: ${process.env.EXPO_TV === "1" ? "ENABLED" : "DISABLED"}`,
);
}
// STREAMING APP SPECIFIC OPTIMIZATIONS
// ===================================
// NEW: Cache hints for better performance
if (typeof config.cacheStores === "undefined") {
// Only add if not causing issues
try {
const MetroCache = require("metro-cache");
config.cacheStores = [
new MetroCache.FileStore({
root: path.join(os.tmpdir(), "streamyfin-metro-cache"),
}),
];
} catch (_e) {
// Fallback: use default cache
console.log(" Using default Metro cache (custom cache not available)");
}
}
// NEW: Network request optimizations for streaming
config.server = {
...config.server,
// Enhanced request handling for media assets
enhanceMiddleware: (middleware, _server) => {
// Add caching headers for static assets
return (req, res, next) => {
if (req.url?.match(/\.(mp4|mkv|jpg|jpeg|png|webp)$/)) {
res.setHeader("Cache-Control", "public, max-age=31536000");
}
return middleware(req, res, next);
};
},
};
module.exports = config; module.exports = config;

View File

@@ -1,4 +1,4 @@
import type { import {
ChapterInfo, ChapterInfo,
PlaybackStatePayload, PlaybackStatePayload,
ProgressUpdatePayload, ProgressUpdatePayload,
@@ -12,20 +12,16 @@ import type {
} from "./VlcPlayer.types"; } from "./VlcPlayer.types";
import VlcPlayerView from "./VlcPlayerView"; import VlcPlayerView from "./VlcPlayerView";
// Component export {
export { VlcPlayerView }; VlcPlayerView,
VlcPlayerViewProps,
// Component Types VlcPlayerViewRef,
export type { VlcPlayerViewProps, VlcPlayerViewRef };
// Media Types
export type { ChapterInfo, TrackInfo, VlcPlayerSource };
// Playback Events (alphabetically sorted)
export type {
PlaybackStatePayload, PlaybackStatePayload,
ProgressUpdatePayload, ProgressUpdatePayload,
VideoLoadStartPayload, VideoLoadStartPayload,
VideoProgressPayload,
VideoStateChangePayload, VideoStateChangePayload,
VideoProgressPayload,
VlcPlayerSource,
TrackInfo,
ChapterInfo,
}; };

View File

@@ -14,7 +14,7 @@
"android:tv": "cross-env EXPO_TV=1 expo run:android", "android:tv": "cross-env EXPO_TV=1 expo run:android",
"build:android:local": "cd android && cross-env NODE_ENV=production ./gradlew assembleRelease", "build:android:local": "cd android && cross-env NODE_ENV=production ./gradlew assembleRelease",
"prepare": "husky", "prepare": "husky",
"typecheck": "node scripts/typecheck.js", "typecheck": "tsc -p tsconfig.json --noEmit | grep -v \"utils/jellyseerr\"",
"check": "biome check . --max-diagnostics 1000", "check": "biome check . --max-diagnostics 1000",
"lint": "biome check --write --unsafe --max-diagnostics 1000", "lint": "biome check --write --unsafe --max-diagnostics 1000",
"format": "biome format --write .", "format": "biome format --write .",
@@ -22,29 +22,29 @@
"test": "bun run typecheck && bun run lint && bun run format && bun run doctor" "test": "bun run typecheck && bun run lint && bun run format && bun run doctor"
}, },
"dependencies": { "dependencies": {
"@bottom-tabs/react-navigation": "^0.11.2", "@bottom-tabs/react-navigation": "^0.9.2",
"@expo/metro-runtime": "~5.0.5", "@expo/metro-runtime": "~5.0.4",
"@expo/react-native-action-sheet": "^4.1.1", "@expo/react-native-action-sheet": "^4.1.1",
"@expo/vector-icons": "^14.1.0", "@expo/vector-icons": "^15.0.2",
"@gorhom/bottom-sheet": "^5.1.0", "@gorhom/bottom-sheet": "^5.1.0",
"@jellyfin/sdk": "^0.11.0", "@jellyfin/sdk": "^0.11.0",
"@kesha-antonov/react-native-background-downloader": "^3.2.6", "@kesha-antonov/react-native-background-downloader": "^3.2.6",
"@react-native-community/netinfo": "^11.4.1", "@react-native-community/netinfo": "^11.4.1",
"@react-native-menu/menu": "1.2.3", "@react-native-menu/menu": "^1.2.3",
"@react-navigation/material-top-tabs": "^7.2.14", "@react-navigation/material-top-tabs": "^7.2.14",
"@react-navigation/native": "^7.0.14", "@react-navigation/native": "^7.0.14",
"@shopify/flash-list": "^1.8.3", "@shopify/flash-list": "^1.8.3",
"@tanstack/react-query": "^5.66.0", "@tanstack/react-query": "^5.66.0",
"axios": "^1.7.9", "axios": "^1.7.9",
"expo": "^53.0.23", "expo": "^53.0.22",
"expo-application": "~6.1.4", "expo-application": "~6.1.4",
"expo-asset": "~11.1.7", "expo-asset": "~11.1.7",
"expo-atlas": "^0.4.0",
"expo-background-task": "~0.2.8", "expo-background-task": "~0.2.8",
"expo-blur": "~14.1.4", "expo-blur": "~14.1.4",
"expo-brightness": "~13.1.4", "expo-brightness": "~13.1.4",
"expo-build-properties": "~0.14.6", "expo-build-properties": "~0.14.6",
"expo-constants": "~17.1.5", "expo-constants": "~17.1.5",
"expo-dev-client": "^5.2.0",
"expo-device": "~7.1.4", "expo-device": "~7.1.4",
"expo-font": "~13.3.1", "expo-font": "~13.3.1",
"expo-haptics": "~14.1.4", "expo-haptics": "~14.1.4",
@@ -53,7 +53,7 @@
"expo-linking": "~7.1.4", "expo-linking": "~7.1.4",
"expo-localization": "~16.1.5", "expo-localization": "~16.1.5",
"expo-notifications": "~0.31.2", "expo-notifications": "~0.31.2",
"expo-router": "~5.1.7", "expo-router": "~5.1.5",
"expo-screen-orientation": "~8.1.6", "expo-screen-orientation": "~8.1.6",
"expo-sensors": "~14.1.4", "expo-sensors": "~14.1.4",
"expo-sharing": "~13.1.5", "expo-sharing": "~13.1.5",
@@ -71,7 +71,7 @@
"react-i18next": "^15.4.0", "react-i18next": "^15.4.0",
"react-native": "npm:react-native-tvos@0.79.5-0", "react-native": "npm:react-native-tvos@0.79.5-0",
"react-native-awesome-slider": "^2.9.0", "react-native-awesome-slider": "^2.9.0",
"react-native-bottom-tabs": "^0.11.2", "react-native-bottom-tabs": "^0.9.2",
"react-native-circular-progress": "^1.4.1", "react-native-circular-progress": "^1.4.1",
"react-native-collapsible": "^1.6.2", "react-native-collapsible": "^1.6.2",
"react-native-country-flag": "^2.0.2", "react-native-country-flag": "^2.0.2",
@@ -83,7 +83,7 @@
"react-native-ios-utilities": "5.1.8", "react-native-ios-utilities": "5.1.8",
"react-native-mmkv": "2.12.2", "react-native-mmkv": "2.12.2",
"react-native-pager-view": "^6.9.1", "react-native-pager-view": "^6.9.1",
"react-native-reanimated": "~3.19.1", "react-native-reanimated": "~3.17.4",
"react-native-reanimated-carousel": "4.0.2", "react-native-reanimated-carousel": "4.0.2",
"react-native-safe-area-context": "5.4.0", "react-native-safe-area-context": "5.4.0",
"react-native-screens": "~4.11.1", "react-native-screens": "~4.11.1",
@@ -94,26 +94,26 @@
"react-native-video": "6.14.1", "react-native-video": "6.14.1",
"react-native-volume-manager": "^2.0.8", "react-native-volume-manager": "^2.0.8",
"react-native-web": "^0.20.0", "react-native-web": "^0.20.0",
"sonner-native": "^0.21.0", "sonner-native": "^0.21.1",
"tailwindcss": "3.3.2", "tailwindcss": "3.3.2",
"use-debounce": "^10.0.4", "use-debounce": "^10.0.4",
"zeego": "^3.0.6", "zeego": "^3.0.6",
"zod": "^4.1.3" "zod": "^4.1.3"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "7.28.4", "@babel/core": "7.28.3",
"@biomejs/biome": "2.2.5", "@biomejs/biome": "2.2.2",
"@react-native-community/cli": "20.0.2", "@react-native-community/cli": "20.0.1",
"@react-native-tvos/config-tv": "0.1.4", "@react-native-tvos/config-tv": "0.1.3",
"@types/jest": "30.0.0", "@types/jest": "29.5.14",
"@types/lodash": "4.17.20", "@types/lodash": "4.17.20",
"@types/react": "~19.0.10", "@types/react": "~19.0.10",
"@types/react-test-renderer": "19.1.0", "@types/react-test-renderer": "19.1.0",
"cross-env": "10.1.0", "expo-doctor": "1.17.2",
"expo-dev-client": "5.2.4", "cross-env": "10.0.0",
"expo-doctor": "1.17.9",
"husky": "9.1.7", "husky": "9.1.7",
"lint-staged": "16.2.3", "lint-staged": "16.1.6",
"postinstall": "0.11.2",
"react-test-renderer": "19.1.1", "react-test-renderer": "19.1.1",
"typescript": "5.8.3" "typescript": "5.8.3"
}, },
@@ -123,7 +123,8 @@
"react-native", "react-native",
"@shopify/flash-list", "@shopify/flash-list",
"react-native-reanimated", "react-native-reanimated",
"react-native-pager-view" "react-native-pager-view",
"@expo/vector-icons"
] ]
}, },
"doctor": { "doctor": {
@@ -149,6 +150,7 @@
] ]
}, },
"trustedDependencies": [ "trustedDependencies": [
"postinstall",
"unrs-resolver" "unrs-resolver"
] ]
} }

View File

@@ -50,11 +50,9 @@ function withRNBackgroundDownloader(config) {
// Expo 53's xcodejs doesn't expose pbxTargets(). // Expo 53's xcodejs doesn't expose pbxTargets().
// Setting the property once at the project level is sufficient. // Setting the property once at the project level is sufficient.
["Debug", "Release"].forEach((cfg) => { ["Debug", "Release"].forEach((cfg) => {
// Use the detected projectName to set the bridging header path instead of a hardcoded value
const bridgingHeaderPath = `${projectName}/${projectName}-Bridging-Header.h`;
project.updateBuildProperty( project.updateBuildProperty(
"SWIFT_OBJC_BRIDGING_HEADER", "SWIFT_OBJC_BRIDGING_HEADER",
bridgingHeaderPath, "Streamyfin/Streamyfin-Bridging-Header.h",
cfg, cfg,
); );
}); });

View File

@@ -25,7 +25,7 @@ import { useSettings } from "@/utils/atoms/settings";
import { getOrSetDeviceId } from "@/utils/device"; import { getOrSetDeviceId } from "@/utils/device";
import useDownloadHelper from "@/utils/download"; import useDownloadHelper from "@/utils/download";
import { getItemImage } from "@/utils/getItemImage"; import { getItemImage } from "@/utils/getItemImage";
import { dumpDownloadDiagnostics, writeToLog } from "@/utils/log"; import { writeToLog } from "@/utils/log";
import { storage } from "@/utils/mmkv"; import { storage } from "@/utils/mmkv";
import { fetchAndParseSegments } from "@/utils/segments"; import { fetchAndParseSegments } from "@/utils/segments";
import { generateTrickplayUrl, getTrickplayInfo } from "@/utils/trickplay"; import { generateTrickplayUrl, getTrickplayInfo } from "@/utils/trickplay";
@@ -42,60 +42,37 @@ const BackGroundDownloader = !Platform.isTV
? require("@kesha-antonov/react-native-background-downloader") ? require("@kesha-antonov/react-native-background-downloader")
: null; : null;
// Cap progress at 99% to avoid showing 100% before the download is actually complete
const MAX_PROGRESS_BEFORE_COMPLETION = 99;
// Estimate the total download size in bytes for a job. If the media source
// provides a Size, use that. Otherwise, if we have a bitrate and run time
// (RunTimeTicks), approximate size = (bitrate bits/sec * seconds) / 8.
const calculateEstimatedSize = (p: JobStatus): number => { const calculateEstimatedSize = (p: JobStatus): number => {
const size = p.mediaSource?.Size || 0; let size = p.mediaSource.Size;
const maxBitrate = p.maxBitrate?.value; const maxBitrate = p.maxBitrate.value;
const runTimeTicks = (p.item?.RunTimeTicks || 0) as number; if (
maxBitrate &&
if (!size && maxBitrate && runTimeTicks > 0) { size &&
// Jellyfin RunTimeTicks are in 10,000,000 ticks per second p.mediaSource.Bitrate &&
const seconds = runTimeTicks / 10000000; maxBitrate < p.mediaSource.Bitrate
if (seconds > 0) { ) {
// maxBitrate is in bits per second; convert to bytes size = (size / p.mediaSource.Bitrate) * maxBitrate;
return Math.round((maxBitrate / 8) * seconds);
}
} }
// This function is for estimated size, so just return the adjusted size
return size || 0; return size ?? 0;
}; };
// Calculate download speed in bytes/sec based on a job's last update time // Helper to calculate download speed
// and previously recorded bytesDownloaded.
const calculateSpeed = ( const calculateSpeed = (
p: JobStatus, process: JobStatus,
currentBytesDownloaded?: number, newBytesDownloaded: number,
): number | undefined => { ): number | undefined => {
// Prefer session-only deltas when available: lastSessionBytes + lastSessionUpdateTime const { bytesDownloaded: oldBytes = 0, lastProgressUpdateTime } = process;
const now = Date.now(); const deltaBytes = newBytesDownloaded - oldBytes;
if (p.lastSessionUpdateTime && p.lastSessionBytes !== undefined) { if (lastProgressUpdateTime && deltaBytes > 0) {
const last = new Date(p.lastSessionUpdateTime).getTime(); const deltaTimeInSeconds =
const deltaTime = (now - last) / 1000; (Date.now() - new Date(lastProgressUpdateTime).getTime()) / 1000;
if (deltaTime > 0) { if (deltaTimeInSeconds > 0) {
const current = return deltaBytes / deltaTimeInSeconds;
currentBytesDownloaded ?? p.bytesDownloaded ?? p.lastSessionBytes;
const deltaBytes = current - p.lastSessionBytes;
if (deltaBytes > 0) return deltaBytes / deltaTime;
} }
} }
return undefined;
// Fallback to total-based deltas for compatibility
if (!p.lastProgressUpdateTime || p.bytesDownloaded === undefined)
return undefined;
const last = new Date(p.lastProgressUpdateTime).getTime();
const deltaTime = (now - last) / 1000;
if (deltaTime <= 0) return undefined;
const prev = p.bytesDownloaded || 0;
const current = currentBytesDownloaded ?? prev;
const deltaBytes = current - prev;
if (deltaBytes <= 0) return undefined;
return deltaBytes / deltaTime;
}; };
export const processesAtom = atom<JobStatus[]>([]); export const processesAtom = atom<JobStatus[]>([]);
@@ -193,96 +170,27 @@ function useDownloadProvider() {
const currentProcesses = [...processes, ...missingProcesses]; const currentProcesses = [...processes, ...missingProcesses];
const updatedProcesses = currentProcesses.map((p) => { const updatedProcesses = currentProcesses.map((p) => {
// Enhanced filtering to prevent iOS zombie task interference // fallback. Doesn't really work for transcodes as they may be a lot smaller.
// Only update progress for downloads that are actively downloading // We make an wild guess by comparing bitrates
if (p.status !== "downloading") {
return p;
}
// Find task for this process
const task = tasks.find((s: any) => s.id === p.id); const task = tasks.find((s: any) => s.id === p.id);
if (!task) {
return p; // No task found, keep current state
}
/*
// TODO: Uncomment this block to re-enable iOS zombie task detection
// iOS: Extra validation to prevent zombie task interference
if (Platform.OS === "ios") {
// Check if we have multiple tasks for same ID (zombie detection)
const tasksForId = tasks.filter((t: any) => t.id === p.id);
if (tasksForId.length > 1) {
console.warn(
`[UPDATE] Detected ${tasksForId.length} zombie tasks for ${p.id}, ignoring progress update`,
);
return p; // Don't update progress from potentially conflicting tasks
}
// If task state looks suspicious (e.g., iOS task stuck in background), be conservative
if (
task.state &&
["SUSPENDED", "PAUSED"].includes(task.state) &&
p.status === "downloading"
) {
console.warn(
`[UPDATE] Task ${p.id} has suspicious state ${task.state}, ignoring progress update`,
);
return p;
}
}
*/
if (task && p.status === "downloading") { if (task && p.status === "downloading") {
const estimatedSize = calculateEstimatedSize(p); const estimatedSize = calculateEstimatedSize(p);
let progress = p.progress; let progress = p.progress;
if (estimatedSize > 0) {
// If we have a pausedProgress snapshot then merge current session progress = (100 / estimatedSize) * task.bytesDownloaded;
// progress into it. We accept pausedProgress === 0 as valid because
// users can pause immediately after starting.
if (p.pausedProgress !== undefined) {
const totalBytesDownloaded =
(p.pausedBytes ?? 0) + task.bytesDownloaded;
// Calculate progress based on total bytes downloaded vs estimated size
progress =
estimatedSize > 0
? (totalBytesDownloaded / estimatedSize) * 100
: 0;
// Use the total accounted bytes when computing speed so the
// displayed speed and progress remain consistent after resume.
const speed = calculateSpeed(p, totalBytesDownloaded);
return {
...p,
progress: Math.min(progress, MAX_PROGRESS_BEFORE_COMPLETION),
speed,
bytesDownloaded: totalBytesDownloaded,
lastProgressUpdateTime: new Date(),
estimatedTotalSizeBytes: estimatedSize,
// Set session bytes to total bytes downloaded
lastSessionBytes: totalBytesDownloaded,
lastSessionUpdateTime: new Date(),
};
} else {
if (estimatedSize > 0) {
progress = (100 / estimatedSize) * task.bytesDownloaded;
}
if (progress >= 100) {
progress = MAX_PROGRESS_BEFORE_COMPLETION;
}
const speed = calculateSpeed(p, task.bytesDownloaded);
return {
...p,
progress,
speed,
bytesDownloaded: task.bytesDownloaded,
lastProgressUpdateTime: new Date(),
estimatedTotalSizeBytes: estimatedSize,
lastSessionBytes: task.bytesDownloaded,
lastSessionUpdateTime: new Date(),
};
} }
if (progress >= 100) {
progress = 99;
}
const speed = calculateSpeed(p, task.bytesDownloaded);
return {
...p,
progress,
speed,
bytesDownloaded: task.bytesDownloaded,
lastProgressUpdateTime: new Date(),
estimatedTotalSizeBytes: estimatedSize,
};
} }
return p; return p;
}); });
@@ -301,7 +209,7 @@ function useDownloadProvider() {
return db.movies[id]; return db.movies[id];
} }
// Check episodes // If not in movies, check episodes
for (const series of Object.values(db.series)) { for (const series of Object.values(db.series)) {
for (const season of Object.values(series.seasons)) { for (const season of Object.values(series.seasons)) {
for (const episode of Object.values(season.episodes)) { for (const episode of Object.values(season.episodes)) {
@@ -312,11 +220,6 @@ function useDownloadProvider() {
} }
} }
// Check other media types
if (db.other[id]) {
return db.other[id];
}
return undefined; return undefined;
}; };
@@ -353,7 +256,7 @@ function useDownloadProvider() {
if (file) { if (file) {
return JSON.parse(file) as DownloadsDatabase; return JSON.parse(file) as DownloadsDatabase;
} }
return { movies: {}, series: {}, other: {} }; // Initialize other media types storage return { movies: {}, series: {} };
}; };
const getDownloadedItems = () => { const getDownloadedItems = () => {
@@ -365,7 +268,6 @@ function useDownloadProvider() {
Object.values(season.episodes), Object.values(season.episodes),
), ),
), ),
...Object.values(db.other), // Include other media types in results
]; ];
return allItems; return allItems;
}; };
@@ -470,76 +372,10 @@ function useDownloadProvider() {
async (process: JobStatus) => { async (process: JobStatus) => {
if (!process?.item.Id || !authHeader) throw new Error("No item id"); if (!process?.item.Id || !authHeader) throw new Error("No item id");
// Enhanced cleanup for existing tasks to prevent duplicates
try {
const allTasks = await BackGroundDownloader.checkForExistingDownloads();
const existingTasks = allTasks?.filter((t: any) => t.id === process.id);
if (existingTasks && existingTasks.length > 0) {
console.log(
`[START] Found ${existingTasks.length} existing task(s) for ${process.id}, cleaning up...`,
);
for (let i = 0; i < existingTasks.length; i++) {
const existingTask = existingTasks[i];
console.log(
`[START] Cleaning up task ${i + 1}/${existingTasks.length} for ${process.id}`,
);
try {
/*
// TODO: Uncomment this block to re-enable iOS-specific cleanup
// iOS: More aggressive cleanup sequence
if (Platform.OS === "ios") {
try {
await existingTask.pause();
await new Promise((resolve) => setTimeout(resolve, 50));
} catch (_pauseErr) {
// Ignore pause errors
}
await existingTask.stop();
await new Promise((resolve) => setTimeout(resolve, 50));
// Multiple complete handler calls to ensure cleanup
BackGroundDownloader.completeHandler(process.id);
await new Promise((resolve) => setTimeout(resolve, 25));
} else {
*/
// Simple cleanup for all platforms (currently Android only)
await existingTask.stop();
BackGroundDownloader.completeHandler(process.id);
/* } // End of iOS block - uncomment when re-enabling iOS functionality */
console.log(
`[START] Successfully cleaned up task ${i + 1} for ${process.id}`,
);
} catch (taskError) {
console.warn(
`[START] Failed to cleanup task ${i + 1} for ${process.id}:`,
taskError,
);
}
}
// Cleanup delay (simplified for Android)
const cleanupDelay = 200; // Platform.OS === "ios" ? 500 : 200;
await new Promise((resolve) => setTimeout(resolve, cleanupDelay));
console.log(`[START] Cleanup completed for ${process.id}`);
}
} catch (error) {
console.warn(
`[START] Failed to check/cleanup existing tasks for ${process.id}:`,
error,
);
}
updateProcess(process.id, { updateProcess(process.id, {
speed: undefined, speed: undefined,
status: "downloading", status: "downloading",
progress: process.progress || 0, // Preserve existing progress for resume progress: 0,
}); });
BackGroundDownloader?.setConfig({ BackGroundDownloader?.setConfig({
@@ -560,42 +396,21 @@ function useDownloadProvider() {
.begin(() => { .begin(() => {
updateProcess(process.id, { updateProcess(process.id, {
status: "downloading", status: "downloading",
progress: process.progress || 0, progress: 0,
bytesDownloaded: process.bytesDownloaded || 0, bytesDownloaded: 0,
lastProgressUpdateTime: new Date(), lastProgressUpdateTime: new Date(),
lastSessionBytes: process.lastSessionBytes || 0,
lastSessionUpdateTime: new Date(),
}); });
}) })
.progress( .progress(
throttle((data) => { throttle((data) => {
updateProcess(process.id, (currentProcess) => { updateProcess(process.id, (currentProcess) => {
// If this is a resumed download, add the paused bytes to current session bytes const percent = (data.bytesDownloaded / data.bytesTotal) * 100;
const resumedBytes = currentProcess.pausedBytes || 0;
const totalBytes = data.bytesDownloaded + resumedBytes;
// Calculate progress based on total bytes if we have resumed bytes
let percent: number;
if (resumedBytes > 0 && data.bytesTotal > 0) {
// For resumed downloads, calculate based on estimated total size
const estimatedTotal =
currentProcess.estimatedTotalSizeBytes ||
data.bytesTotal + resumedBytes;
percent = (totalBytes / estimatedTotal) * 100;
} else {
// For fresh downloads, use normal calculation
percent = (data.bytesDownloaded / data.bytesTotal) * 100;
}
return { return {
speed: calculateSpeed(currentProcess, totalBytes), speed: calculateSpeed(currentProcess, data.bytesDownloaded),
status: "downloading", status: "downloading",
progress: Math.min(percent, MAX_PROGRESS_BEFORE_COMPLETION), progress: percent,
bytesDownloaded: totalBytes, bytesDownloaded: data.bytesDownloaded,
lastProgressUpdateTime: new Date(), lastProgressUpdateTime: new Date(),
// update session-only counters - use current session bytes only for speed calc
lastSessionBytes: data.bytesDownloaded,
lastSessionUpdateTime: new Date(),
}; };
}); });
}, 500), }, 500),
@@ -664,9 +479,6 @@ function useDownloadProvider() {
db.series[item.SeriesId].seasons[seasonNumber].episodes[ db.series[item.SeriesId].seasons[seasonNumber].episodes[
episodeNumber episodeNumber
] = downloadedItem; ] = downloadedItem;
} else if (item.Id) {
// Handle other media types
db.other[item.Id] = downloadedItem;
} }
await saveDownloadsDatabase(db); await saveDownloadsDatabase(db);
@@ -730,17 +542,7 @@ function useDownloadProvider() {
if (activeDownloads < concurrentLimit) { if (activeDownloads < concurrentLimit) {
const queuedDownload = processes.find((p) => p.status === "queued"); const queuedDownload = processes.find((p) => p.status === "queued");
if (queuedDownload) { if (queuedDownload) {
// Reserve the slot immediately to avoid race where startDownload's startDownload(queuedDownload);
// asynchronous begin callback hasn't executed yet and multiple
// downloads are started, bypassing the concurrent limit.
updateProcess(queuedDownload.id, { status: "downloading" });
startDownload(queuedDownload).catch((error) => {
console.error("Failed to start download:", error);
updateProcess(queuedDownload.id, { status: "error" });
toast.error(t("home.downloads.toasts.failed_to_start_download"), {
description: error.message || "Unknown error",
});
});
} }
} }
}, [processes, settings?.remuxConcurrentLimit, startDownload]); }, [processes, settings?.remuxConcurrentLimit, startDownload]);
@@ -749,38 +551,8 @@ function useDownloadProvider() {
async (id: string) => { async (id: string) => {
const tasks = await BackGroundDownloader.checkForExistingDownloads(); const tasks = await BackGroundDownloader.checkForExistingDownloads();
const task = tasks?.find((t: any) => t.id === id); const task = tasks?.find((t: any) => t.id === id);
if (task) { task?.stop();
// On iOS, suspended tasks need to be cancelled properly BackGroundDownloader.completeHandler(id);
if (Platform.OS === "ios") {
const state = task.state || task.state?.();
if (
state === "PAUSED" ||
state === "paused" ||
state === "SUSPENDED" ||
state === "suspended"
) {
// For suspended tasks, we need to resume first, then stop
try {
await task.resume();
// Small delay to allow resume to take effect
await new Promise((resolve) => setTimeout(resolve, 100));
} catch (_resumeError) {
// Resume might fail, continue with stop
}
}
}
try {
task.stop();
} catch (_err) {
// ignore stop errors
}
try {
BackGroundDownloader.completeHandler(id);
} catch (_err) {
// ignore
}
}
setProcesses((prev) => prev.filter((process) => process.id !== id)); setProcesses((prev) => prev.filter((process) => process.id !== id));
manageDownloadQueue(); manageDownloadQueue();
}, },
@@ -803,7 +575,7 @@ function useDownloadProvider() {
intermediates: true, intermediates: true,
}); });
} catch (_error) { } catch (_error) {
toast.error(t("home.downloads.toasts.failed_to_clean_cache_directory")); toast.error(t("Failed to clean cache directory."));
} }
}; };
@@ -839,13 +611,9 @@ function useDownloadProvider() {
status: "queued", status: "queued",
timestamp: new Date(), timestamp: new Date(),
}; };
setProcesses((prev) => { setProcesses((prev) => [...prev, job]);
// Remove any existing processes for this item to prevent duplicates
const filtered = prev.filter((p) => p.id !== item.Id);
return [...filtered, job];
});
toast.success( toast.success(
t("home.downloads.toasts.download_started_for_item", { t("home.downloads.toasts.download_stated_for_item", {
item: item.Name, item: item.Name,
}), }),
{ {
@@ -865,16 +633,16 @@ function useDownloadProvider() {
[authHeader, startDownload], [authHeader, startDownload],
); );
const deleteFile = async (id: string, type: BaseItemDto["Type"]) => { const deleteFile = async (id: string, type: "Movie" | "Episode") => {
const db = getDownloadsDatabase(); const db = getDownloadsDatabase();
let downloadedItem: DownloadedItem | undefined; let downloadedItem: DownloadedItem | undefined;
if (type === "Movie" && Object.entries(db.movies).length !== 0) { if (type === "Movie") {
downloadedItem = db.movies[id]; downloadedItem = db.movies[id];
if (downloadedItem) { if (downloadedItem) {
delete db.movies[id]; delete db.movies[id];
} }
} else if (type === "Episode" && Object.entries(db.series).length !== 0) { } else if (type === "Episode") {
const cleanUpEmptyParents = ( const cleanUpEmptyParents = (
series: any, series: any,
seasonNumber: string, seasonNumber: string,
@@ -904,12 +672,6 @@ function useDownloadProvider() {
} }
if (downloadedItem) break; if (downloadedItem) break;
} }
} else {
// Handle other media types
downloadedItem = db.other[id];
if (downloadedItem) {
delete db.other[id];
}
} }
if (downloadedItem?.videoFilePath) { if (downloadedItem?.videoFilePath) {
@@ -943,7 +705,7 @@ function useDownloadProvider() {
const deleteItems = async (items: BaseItemDto[]) => { const deleteItems = async (items: BaseItemDto[]) => {
for (const item of items) { for (const item of items) {
if (item.Id) { if (item.Id && (item.Type === "Movie" || item.Type === "Episode")) {
await deleteFile(item.Id, item.Type); await deleteFile(item.Id, item.Type);
} }
} }
@@ -985,8 +747,6 @@ function useDownloadProvider() {
const db = getDownloadsDatabase(); const db = getDownloadsDatabase();
if (db.movies[itemId]) { if (db.movies[itemId]) {
db.movies[itemId] = updatedItem; db.movies[itemId] = updatedItem;
} else if (db.other[itemId]) {
db.other[itemId] = updatedItem;
} else { } else {
for (const series of Object.values(db.series)) { for (const series of Object.values(db.series)) {
for (const season of Object.values(series.seasons)) { for (const season of Object.values(series.seasons)) {
@@ -1031,99 +791,12 @@ function useDownloadProvider() {
const process = processes.find((p) => p.id === id); const process = processes.find((p) => p.id === id);
if (!process) throw new Error("No active download"); if (!process) throw new Error("No active download");
// TODO: iOS pause functionality temporarily disabled due to background task issues
// Remove this check to re-enable iOS pause functionality in the future
if (Platform.OS === "ios") {
console.warn(
`[PAUSE] Pause functionality temporarily disabled on iOS for ${id}`,
);
throw new Error("Pause functionality is currently disabled on iOS");
}
const tasks = await BackGroundDownloader.checkForExistingDownloads(); const tasks = await BackGroundDownloader.checkForExistingDownloads();
const task = tasks?.find((t: any) => t.id === id); const task = tasks?.find((t: any) => t.id === id);
if (!task) throw new Error("No task found"); if (!task) throw new Error("No task found");
// Get current progress before stopping task.pause();
const currentProgress = process.progress; updateProcess(id, { status: "paused" });
const currentBytes = process.bytesDownloaded || task.bytesDownloaded || 0;
console.log(
`[PAUSE] Starting pause for ${id}. Current bytes: ${currentBytes}, Progress: ${currentProgress}%`,
);
try {
/*
// TODO: Uncomment this block to re-enable iOS pause functionality
// iOS-specific aggressive cleanup approach based on GitHub issue #26
if (Platform.OS === "ios") {
// Get ALL tasks for this ID - there might be multiple zombie tasks
const allTasks =
await BackGroundDownloader.checkForExistingDownloads();
const tasksForId = allTasks?.filter((t: any) => t.id === id) || [];
console.log(`[PAUSE] Found ${tasksForId.length} task(s) for ${id}`);
// Stop ALL tasks for this ID to prevent zombie processes
for (let i = 0; i < tasksForId.length; i++) {
const taskToStop = tasksForId[i];
console.log(
`[PAUSE] Stopping task ${i + 1}/${tasksForId.length} for ${id}`,
);
try {
// iOS: pause → stop sequence with delays (based on issue research)
await taskToStop.pause();
await new Promise((resolve) => setTimeout(resolve, 100));
await taskToStop.stop();
await new Promise((resolve) => setTimeout(resolve, 100));
console.log(
`[PAUSE] Successfully stopped task ${i + 1} for ${id}`,
);
} catch (taskError) {
console.warn(
`[PAUSE] Failed to stop task ${i + 1} for ${id}:`,
taskError,
);
}
}
// Extra cleanup delay for iOS NSURLSession to fully stop
await new Promise((resolve) => setTimeout(resolve, 500));
} else {
*/
// Android: simpler approach (currently the only active platform)
await task.stop();
/* } // End of iOS block - uncomment when re-enabling iOS functionality */
// Clean up the native task handler
try {
BackGroundDownloader.completeHandler(id);
} catch (_err) {
console.warn(`[PAUSE] Handler cleanup warning for ${id}:`, _err);
}
// Update process state to paused
updateProcess(id, {
status: "paused",
progress: currentProgress,
bytesDownloaded: currentBytes,
pausedAt: new Date(),
pausedProgress: currentProgress,
pausedBytes: currentBytes,
lastSessionBytes: process.lastSessionBytes ?? currentBytes,
lastSessionUpdateTime: process.lastSessionUpdateTime ?? new Date(),
});
console.log(`Download paused successfully: ${id}`);
} catch (error) {
console.error("Error pausing task:", error);
throw error;
}
}, },
[processes, updateProcess], [processes, updateProcess],
); );
@@ -1133,78 +806,37 @@ function useDownloadProvider() {
const process = processes.find((p) => p.id === id); const process = processes.find((p) => p.id === id);
if (!process) throw new Error("No active download"); if (!process) throw new Error("No active download");
// TODO: iOS resume functionality temporarily disabled due to background task issues const tasks = await BackGroundDownloader.checkForExistingDownloads();
// Remove this check to re-enable iOS resume functionality in the future const task = tasks?.find((t: any) => t.id === id);
if (Platform.OS === "ios") { if (!task) throw new Error("No task found");
// Check if task state allows resuming
if (task.state === "FAILED") {
console.warn( console.warn(
`[RESUME] Resume functionality temporarily disabled on iOS for ${id}`, "Download task failed, cannot resume. Restarting download.",
); );
throw new Error("Resume functionality is currently disabled on iOS"); // For failed tasks, we need to restart rather than resume
}
console.log(
`[RESUME] Attempting to resume ${id}. Paused bytes: ${process.pausedBytes}, Progress: ${process.pausedProgress}%`,
);
/*
// TODO: Uncomment this block to re-enable iOS resume functionality
// Enhanced cleanup for iOS based on GitHub issue research
if (Platform.OS === "ios") {
try {
// Clean up any lingering zombie tasks first (critical for iOS)
const allTasks =
await BackGroundDownloader.checkForExistingDownloads();
const existingTasks = allTasks?.filter((t: any) => t.id === id) || [];
if (existingTasks.length > 0) {
console.log(
`[RESUME] Found ${existingTasks.length} lingering task(s), cleaning up...`,
);
for (const task of existingTasks) {
try {
await task.stop();
BackGroundDownloader.completeHandler(id);
} catch (cleanupError) {
console.warn(`[RESUME] Cleanup error:`, cleanupError);
}
}
// Wait for iOS cleanup to complete
await new Promise((resolve) => setTimeout(resolve, 500));
}
} catch (error) {
console.warn(`[RESUME] Pre-resume cleanup failed:`, error);
}
}
*/
// Simple approach: always restart the download from where we left off
// This works consistently across all platforms (currently Android only)
if (
process.pausedProgress !== undefined &&
process.pausedBytes !== undefined
) {
// We have saved pause state - restore it and restart
updateProcess(id, {
progress: process.pausedProgress,
bytesDownloaded: process.pausedBytes,
status: "downloading",
// Reset session counters for proper speed calculation
lastSessionBytes: process.pausedBytes,
lastSessionUpdateTime: new Date(),
});
// Small delay to ensure any cleanup in startDownload completes
await new Promise((resolve) => setTimeout(resolve, 100));
const updatedProcess = processes.find((p) => p.id === id);
await startDownload(updatedProcess || process);
console.log(`Download resumed successfully: ${id}`);
} else {
// No pause state - start from beginning
await startDownload(process); await startDownload(process);
return;
}
try {
task.resume();
updateProcess(id, { status: "downloading" });
} catch (error: any) {
// Handle specific ERROR_CANNOT_RESUME error
if (
error?.error === "ERROR_CANNOT_RESUME" ||
error?.errorCode === 1008
) {
console.warn("Cannot resume download, attempting to restart instead");
await startDownload(process);
return; // Return early to prevent error from bubbling up
} else {
// Only log error for non-handled cases
console.error("Error resuming download:", error);
throw error; // Re-throw other errors
}
} }
}, },
[processes, updateProcess, startDownload], [processes, updateProcess, startDownload],
@@ -1229,21 +861,6 @@ function useDownloadProvider() {
cleanCacheDirectory, cleanCacheDirectory,
updateDownloadedItem, updateDownloadedItem,
appSizeUsage, appSizeUsage,
dumpDownloadDiagnostics: async (id?: string) => {
// Collect JS-side processes and native task info (best-effort)
const tasks = BackGroundDownloader
? await BackGroundDownloader.checkForExistingDownloads()
: [];
const extra: any = {
processes,
nativeTasks: tasks || [],
};
if (id) {
const p = processes.find((x) => x.id === id);
extra.focusedProcess = p || null;
}
return dumpDownloadDiagnostics(extra);
},
}; };
} }

View File

@@ -88,8 +88,6 @@ export interface DownloadsDatabase {
movies: Record<string, DownloadedItem>; movies: Record<string, DownloadedItem>;
/** A map of series IDs to their downloaded series data. */ /** A map of series IDs to their downloaded series data. */
series: Record<string, DownloadedSeries>; series: Record<string, DownloadedSeries>;
/** A map of IDs to downloaded items that are neither movies nor episodes */
other: Record<string, DownloadedItem>;
} }
/** /**
@@ -131,14 +129,4 @@ export type JobStatus = {
/** Estimated total size of the download in bytes (optional) this is used when we /** Estimated total size of the download in bytes (optional) this is used when we
* download transcoded content because we don't know the size of the file until it's downloaded */ * download transcoded content because we don't know the size of the file until it's downloaded */
estimatedTotalSizeBytes?: number; estimatedTotalSizeBytes?: number;
/** Timestamp when the download was paused (optional) */
pausedAt?: Date;
/** Progress percentage when download was paused (optional) */
pausedProgress?: number;
/** Bytes downloaded when download was paused (optional) */
pausedBytes?: number;
/** Bytes downloaded in the current session (since last resume). Used for session-only speed calculation. */
lastSessionBytes?: number;
/** Timestamp when the session-only bytes were last updated. */
lastSessionUpdateTime?: Date;
}; };

View File

@@ -64,7 +64,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setJellyfin( setJellyfin(
() => () =>
new Jellyfin({ new Jellyfin({
clientInfo: { name: "Streamyfin", version: "0.39.0" }, clientInfo: { name: "Streamyfin", version: "0.35.1" },
deviceInfo: { deviceInfo: {
name: deviceName, name: deviceName,
id, id,
@@ -87,7 +87,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
return { return {
authorization: `MediaBrowser Client="Streamyfin", Device=${ authorization: `MediaBrowser Client="Streamyfin", Device=${
Platform.OS === "android" ? "Android" : "iOS" Platform.OS === "android" ? "Android" : "iOS"
}, DeviceId="${deviceId}", Version="0.39.0"`, }, DeviceId="${deviceId}", Version="0.35.1"`,
}; };
}, [deviceId]); }, [deviceId]);
@@ -374,7 +374,7 @@ function useProtectedRoute(user: UserDto | null, loaded = false) {
useEffect(() => { useEffect(() => {
if (loaded === false) return; if (loaded === false) return;
const inAuthGroup = segments.length > 1 && segments[0] === "(auth)"; const inAuthGroup = segments[0] === "(auth)";
if (!user?.Id && inAuthGroup) { if (!user?.Id && inAuthGroup) {
console.log("Redirected to login"); console.log("Redirected to login");

View File

@@ -1,256 +0,0 @@
const { execFileSync } = require("node:child_process");
const process = require("node:process");
// Enhanced ANSI color codes and styles
const colors = {
red: "\x1b[31m",
green: "\x1b[32m",
yellow: "\x1b[33m",
blue: "\x1b[34m",
magenta: "\x1b[35m",
cyan: "\x1b[36m",
white: "\x1b[37m",
gray: "\x1b[90m",
reset: "\x1b[0m",
bold: "\x1b[1m",
dim: "\x1b[2m",
underline: "\x1b[4m",
bg: {
red: "\x1b[41m",
green: "\x1b[42m",
yellow: "\x1b[43m",
blue: "\x1b[44m",
},
};
const border = "━".repeat(80);
// Center the title within the border
const title = "🔥 STREAMYFIN TYPESCRIPT CHECK";
const titlePadding = Math.floor((80 - title.length) / 2);
const centeredTitle = " ".repeat(titlePadding) + title;
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
function log(message, color = "") {
if (useColor && color) {
console.log(`${color}${message}${colors.reset}`);
} else {
console.log(String(message));
}
}
function formatError(errorLine) {
if (!useColor) return errorLine;
// Color file paths in cyan
let formatted = errorLine.replace(
/^([^(]+\([^)]+\):)/,
`${colors.cyan}$1${colors.reset}`,
);
// Color error codes in red bold
formatted = formatted.replace(
/(error TS\d+:)/g,
`${colors.red}${colors.bold}$1${colors.reset}`,
);
// Color type names in yellow
formatted = formatted.replace(
/(Type '[^']*')/g,
`${colors.yellow}$1${colors.reset}`,
);
// Color property names in magenta
formatted = formatted.replace(
/(Property '[^']*')/g,
`${colors.magenta}$1${colors.reset}`,
);
return formatted;
}
function parseErrorsAndCreateSummary(errorOutput) {
const lines = errorOutput.split("\n").filter((line) => line.trim());
const errorsByFile = new Map();
const formattedErrors = [];
let currentError = [];
for (const line of lines) {
const trimmedLine = line.trim();
if (!trimmedLine) continue;
// Check if this is the start of a new error (has file path and error code)
const errorMatch = line.match(/^([^(]+\([^)]+\):)\s*(error TS\d+:)/);
if (errorMatch) {
// If we have a previous error, add it to the list
if (currentError.length > 0) {
formattedErrors.push(currentError.join("\n"));
currentError = [];
}
// Extract file info for summary
const filePath = errorMatch[1].split("(")[0];
if (!errorsByFile.has(filePath)) {
errorsByFile.set(filePath, 0);
}
errorsByFile.set(filePath, errorsByFile.get(filePath) + 1);
// Start new error
currentError.push(formatError(line));
} else if (currentError.length > 0) {
// This is a continuation of the current error
currentError.push(` ${colors.gray}${line}${colors.reset}`);
} else if (line.match(/Found \d+ errors? in \d+ files?/)) {
// Skip the summary line; no action needed for this line
} else {
// Standalone line
formattedErrors.push(formatError(line));
}
}
// Add the last error if exists
if (currentError.length > 0) {
formattedErrors.push(currentError.join("\n"));
}
return { formattedErrors, errorsByFile };
}
function createErrorSummaryTable(errorsByFile) {
if (errorsByFile.size === 0) return "";
const sortedFiles = Array.from(errorsByFile.entries()).sort(
(a, b) => b[1] - a[1],
); // Sort by error count descending
let table = `\n${colors.gray}${colors.bold}Errors Files${colors.reset}\n`;
for (const [file, count] of sortedFiles) {
const paddedCount = String(count).padStart(6);
table += `${colors.red}${paddedCount}${colors.reset} ${colors.cyan}${file}${colors.reset}\n`;
}
return table;
}
function runTypeCheck() {
const extraArgs = process.argv.slice(2);
// Prefer local TypeScript binary when available
const runnerArgs = ["-p", "tsconfig.json", "--noEmit", ...extraArgs];
let execArgs = null;
try {
const tscBin = require.resolve("typescript/bin/tsc");
execArgs = { cmd: process.execPath, args: [tscBin, ...runnerArgs] };
} catch {
// fallback to PATH tsc
execArgs = {
cmd: "tsc",
args: ["-p", "tsconfig.json", "--noEmit", ...extraArgs],
};
}
try {
log(
`🔍 ${colors.bold}Running TypeScript type check...${colors.reset} ${colors.gray}${extraArgs.join(" ")}${colors.reset}`.trim(),
colors.blue,
);
const MAX_BUFFER_SIZE = 64 * 1024 * 1024; // 64MB
execFileSync(execArgs.cmd, execArgs.args, {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
maxBuffer: MAX_BUFFER_SIZE,
env: { ...process.env, FORCE_COLOR: "0" },
});
log(
`${colors.bold}TypeScript check passed${colors.reset} - no errors found!`,
colors.green,
);
return { ok: true };
} catch (error) {
const errorOutput = (error && (error.stderr || error.stdout)) || "";
// Filter out jellyseerr utils errors - this is a third-party git submodule
// that generates a large volume of known type errors
const filteredLines = errorOutput.split("\n").filter((line) => {
const trimmedLine = line.trim();
return trimmedLine && !trimmedLine.includes("utils/jellyseerr");
});
if (filteredLines.length > 0) {
// Count TypeScript error occurrences (TS####)
const remainingMatches = (
filteredLines.join("\n").match(/\berror\s+TS\d+:/gi) || []
).length;
// Parse errors and create formatted output with summary
const { formattedErrors, errorsByFile } = parseErrorsAndCreateSummary(
filteredLines.join("\n"),
);
// Enhanced error header
log(
`\n${colors.bg.red} ERROR ${colors.reset} ${colors.red}${colors.bold}TypeScript errors found:${colors.reset}`,
);
console.log();
// Display errors with spacing between each error
for (let i = 0; i < formattedErrors.length; i++) {
console.log(formattedErrors[i]);
// Add spacing between errors (but not after the last one)
if (i < formattedErrors.length - 1) {
console.log(); // Empty line between errors
}
}
// Create and display summary table
const summaryTable = createErrorSummaryTable(errorsByFile);
if (summaryTable) {
console.log(summaryTable);
}
// Clean summary - just the error count
const errorIcon = "🚨";
log(
`${errorIcon} ${colors.red}${colors.bold}${remainingMatches} TypeScript error${remainingMatches !== 1 ? "s" : ""}${colors.reset}`,
"",
);
return { ok: false };
}
log(
`${colors.bold}TypeScript check passed${colors.reset} ${colors.gray}(jellyseerr utils errors ignored)${colors.reset}`,
colors.green,
);
return { ok: true };
}
}
// Enhanced header
console.log(`${colors.blue}${colors.bold}${border}${colors.reset}`);
console.log(`${colors.blue}${colors.bold}${centeredTitle}${colors.reset}`);
console.log(`${colors.blue}${colors.bold}${border}${colors.reset}`);
console.log();
// Main execution
const result = runTypeCheck();
console.log();
if (!result.ok) {
log(
`${colors.red}${colors.bold}🔥 Typecheck failed - please fix the errors above${colors.reset}`,
);
process.exitCode = 1;
} else {
log(
`${colors.green}${colors.bold}🎉 All checks passed! Ready to ship 🚀${colors.reset}`,
);
}

View File

@@ -1,500 +0,0 @@
{
"login": {
"username_required": "اسم المستخدم مطلوب",
"error_title": "خطأ",
"login_title": "تسجيل الدخول",
"login_to_title": "تسجيل الدخول إلى",
"username_placeholder": "اسم المستخدم",
"password_placeholder": "كلمة المرور",
"login_button": "تسجيل الدخول",
"quick_connect": "اتصال سريع",
"enter_code_to_login": "أدخل الرمز {{code}} لتسجيل الدخول",
"failed_to_initiate_quick_connect": "فشل في بدء الاتصال السريع",
"got_it": "حسنًا",
"connection_failed": "فشل الاتصال",
"could_not_connect_to_server": "تعذر الاتصال بالخادم. يرجى التحقق من الرابط واتصال الشبكة.",
"an_unexpected_error_occured": "حدث خطأ غير متوقع",
"change_server": "تغيير الخادم",
"invalid_username_or_password": "اسم المستخدم أو كلمة المرور غير صالحة",
"user_does_not_have_permission_to_log_in": "ليس لدى المستخدم صلاحية تسجيل الدخول",
"server_is_taking_too_long_to_respond_try_again_later": "يستغرق الخادم وقتًا طويلاً للرد، يرجى المحاولة مرة أخرى لاحقًا",
"server_received_too_many_requests_try_again_later": "تلقى الخادم عددًا كبيرًا جدًا من الطلبات، يرجى المحاولة مرة أخرى لاحقًا.",
"there_is_a_server_error": "هناك خطأ في الخادم",
"an_unexpected_error_occured_did_you_enter_the_correct_url": "حدث خطأ غير متوقع. هل أدخلت رابط الخادم بشكل صحيح؟",
"too_old_server_text": "تم اكتشاف خادم jellyfin غير مدعوم",
"too_old_server_description": "يرجى تحديث jellyfin إلى أحدث إصدار"
},
"server": {
"enter_url_to_jellyfin_server": "أدخل رابط خادم Jellyfin الخاص بك",
"server_url_placeholder": "http(s)://your-server.com",
"connect_button": "اتصال",
"previous_servers": "الخوادم السابقة",
"clear_button": "مسح",
"search_for_local_servers": "البحث عن الخوادم المحلية",
"searching": "يبحث...",
"servers": "الخوادم"
},
"home": {
"no_internet": "لا يوجد اتصال بالإنترنت",
"no_items": "لا توجد عناصر",
"no_internet_message": "لا تقلق، لا يزال بإمكانك مشاهدة المحتوى الذي تم تنزيله.",
"go_to_downloads": "الذهاب إلى التنزيلات",
"oops": "عفوًا!",
"error_message": "حدث خطأ ما.\nيرجى تسجيل الخروج ثم الدخول مرة أخرى.",
"continue_watching": "متابعة المشاهدة",
"next_up": "التالي",
"recently_added_in": "أضيف مؤخراً في {{libraryName}}",
"suggested_movies": "أفلام مقترحة",
"suggested_episodes": "حلقات مقترحة",
"intro": {
"welcome_to_streamyfin": "مرحبًا بك في Streamyfin",
"a_free_and_open_source_client_for_jellyfin": "عميل مجاني ومفتوح المصدر لـ Jellyfin.",
"features_title": "الميزات",
"features_description": "يحتوي Streamyfin على مجموعة من الميزات ويتكامل مع مجموعة واسعة من البرامج التي يمكنك العثور عليها في قائمة الإعدادات، وتشمل:",
"jellyseerr_feature_description": "اتصل بمثيل Jellyseerr الخاص بك واطلب الأفلام مباشرة في التطبيق.",
"downloads_feature_title": "التنزيلات",
"downloads_feature_description": "قم بتنزيل الأفلام والمسلسلات التلفزيونية لمشاهدتها في وضع عدم الاتصال. استخدم إما الطريقة الافتراضية أو قم بتثبيت الخادم المحسن لتنزيل الملفات في الخلفية.",
"chromecast_feature_description": "قم ببث الأفلام والبرامج التلفزيونية على أجهزة Chromecast الخاصة بك.",
"centralised_settings_plugin_title": "إضافة الإعدادات المركزية",
"centralised_settings_plugin_description": "قم بتكوين الإعدادات من موقع مركزي على خادم Jellyfin الخاص بك. ستتم مزامنة جميع إعدادات العميل لجميع المستخدمين تلقائيًا.",
"done_button": "تم",
"go_to_settings_button": "الذهاب إلى الإعدادات",
"read_more": "اقرأ المزيد"
},
"settings": {
"settings_title": "الإعدادات",
"log_out_button": "تسجيل الخروج",
"user_info": {
"user_info_title": "معلومات المستخدم",
"user": "المستخدم",
"server": "الخادم",
"token": "الرمز",
"app_version": "إصدار التطبيق"
},
"quick_connect": {
"quick_connect_title": "اتصال سريع",
"authorize_button": "تفويض الاتصال السريع",
"enter_the_quick_connect_code": "أدخل رمز الاتصال السريع...",
"success": "نجاح",
"quick_connect_autorized": "تم تفويض الاتصال السريع",
"error": "خطأ",
"invalid_code": "رمز غير صالح",
"authorize": "تفويض"
},
"media_controls": {
"media_controls_title": "عناصر التحكم بالوسائط",
"forward_skip_length": "مدة التقديم السريع",
"rewind_length": "مدة الترجيع",
"seconds_unit": "ث"
},
"gesture_controls": {
"gesture_controls_title": "التحكم بالإيماءات",
"horizontal_swipe_skip": "السحب الأفقي للتخطي",
"horizontal_swipe_skip_description": "اسحب لليسار/لليمين عندما تكون عناصر التحكم مخفية للتخطي",
"left_side_brightness": "التحكم في السطوع من الجانب الأيسر",
"left_side_brightness_description": "اسحب لأعلى/لأسفل على الجانب الأيسر لضبط السطوع",
"right_side_volume": "التحكم في مستوى الصوت من الجانب الأيمن",
"right_side_volume_description": "اسحب لأعلى/لأسفل على الجانب الأيمن لضبط مستوى الصوت"
},
"audio": {
"audio_title": "الصوت",
"set_audio_track": "تعيين مسار الصوت من العنصر السابق",
"audio_language": "لغة الصوت",
"audio_hint": "اختر لغة صوت افتراضية.",
"none": "لا شيء",
"language": "اللغة"
},
"subtitles": {
"subtitle_title": "الترجمة",
"subtitle_language": "لغة الترجمة",
"subtitle_mode": "وضع الترجمة",
"set_subtitle_track": "تعيين مسار الترجمة من العنصر السابق",
"subtitle_size": "حجم الترجمة",
"subtitle_hint": "تكوين تفضيلات الترجمة.",
"none": "لا شيء",
"language": "اللغة",
"loading": "جار التحميل",
"modes": {
"Default": "افتراضي",
"Smart": "ذكي",
"Always": "دائماً",
"None": "لا شيء",
"OnlyForced": "فقط الإجبارية"
}
},
"other": {
"other_title": "أخرى",
"follow_device_orientation": "تدوير تلقائي",
"video_orientation": "اتجاه الفيديو",
"orientation": "الاتجاه",
"orientations": {
"DEFAULT": "افتراضي",
"ALL": "الكل",
"PORTRAIT": "عمودي",
"PORTRAIT_UP": "عمودي لأعلى",
"PORTRAIT_DOWN": "عمودي لأسفل",
"LANDSCAPE": "أفقي",
"LANDSCAPE_LEFT": "أفقي لليسار",
"LANDSCAPE_RIGHT": "أفقي لليمين",
"OTHER": "أخرى",
"UNKNOWN": "غير معروف"
},
"safe_area_in_controls": "مساحة آمنة في عناصر التحكم",
"video_player": "مشغل الفيديو",
"video_players": {
"VLC_3": "VLC 3",
"VLC_4": "VLC 4 (تجريبي + صورة داخل صورة)"
},
"show_custom_menu_links": "إظهار روابط القائمة المخصصة",
"hide_libraries": "إخفاء المكتبات",
"select_liraries_you_want_to_hide": "حدد المكتبات التي تريد إخفاءها من علامة تبويب المكتبة وأقسام الصفحة الرئيسية.",
"disable_haptic_feedback": "تعطيل ردود الفعل اللمسية",
"default_quality": "الجودة الافتراضية",
"max_auto_play_episode_count": "الحد الأقصى لعدد الحلقات التي يتم تشغيلها تلقائيًا",
"disabled": "معطل"
},
"downloads": {
"downloads_title": "التنزيلات",
"download_method": "طريقة التنزيل",
"remux_max_download": "الحد الأقصى لتنزيل الريمكس",
"auto_download": "تنزيل تلقائي",
"optimized_versions_server": "خادم الإصدارات المحسّنة",
"save_button": "حفظ",
"optimized_server": "الخادم المحسن",
"optimized": "محسن",
"default": "افتراضي",
"optimized_version_hint": "أدخل رابط الخادم المحسن. يجب أن يتضمن الرابط http أو https ويمكن أن يتضمن المنفذ اختياريًا.",
"read_more_about_optimized_server": "اقرأ المزيد عن الخادم المحسن.",
"url": "الرابط",
"server_url_placeholder": "http(s)://domain.org:port"
},
"plugins": {
"plugins_title": "الإضافات",
"jellyseerr": {
"jellyseerr_warning": "هذا التكامل في مراحله الأولى. توقع أن تتغير الأمور.",
"server_url": "رابط الخادم",
"server_url_hint": "مثال: http(s)://your-host.url\n(أضف المنفذ إذا لزم الأمر)",
"server_url_placeholder": "رابط Jellyseerr...",
"password": "كلمة المرور",
"password_placeholder": "أدخل كلمة المرور لمستخدم Jellyfin {{username}}",
"save_button": "حفظ",
"clear_button": "مسح",
"login_button": "تسجيل الدخول",
"total_media_requests": "إجمالي طلبات الوسائط",
"movie_quota_limit": "حد حصة الأفلام",
"movie_quota_days": "أيام حصة الأفلام",
"tv_quota_limit": "حد حصة المسلسلات",
"tv_quota_days": "أيام حصة المسلسلات",
"reset_jellyseerr_config_button": "إعادة تعيين تكوين Jellyseerr",
"unlimited": "غير محدود",
"plus_n_more": "+{{n}} المزيد",
"order_by": {
"DEFAULT": "افتراضي",
"VOTE_COUNT_AND_AVERAGE": "عدد الأصوات والمعدل",
"POPULARITY": "الشعبية"
}
},
"marlin_search": {
"enable_marlin_search": "تمكين بحث مارلن",
"url": "الرابط",
"server_url_placeholder": "http(s)://domain.org:port",
"marlin_search_hint": "أدخل رابط خادم مارلن. يجب أن يتضمن الرابط http أو https ويمكن أن يتضمن المنفذ اختياريًا.",
"read_more_about_marlin": "اقرأ المزيد عن مارلن.",
"save_button": "حفظ",
"toasts": {
"saved": "تم الحفظ"
}
}
},
"storage": {
"storage_title": "التخزين",
"app_usage": "التطبيق {{usedSpace}}%",
"device_usage": "الجهاز {{availableSpace}}%",
"size_used": "تم استخدام {{used}} من {{total}}",
"delete_all_downloaded_files": "حذف جميع الملفات التي تم تنزيلها"
},
"intro": {
"show_intro": "إظهار المقدمة",
"reset_intro": "إعادة تعيين المقدمة"
},
"logs": {
"logs_title": "السجلات",
"export_logs": "تصدير السجلات",
"click_for_more_info": "انقر لمزيد من المعلومات",
"level": "المستوى",
"no_logs_available": "لا توجد سجلات متاحة",
"delete_all_logs": "حذف جميع السجلات"
},
"languages": {
"title": "اللغات",
"app_language": "لغة التطبيق",
"app_language_description": "حدد لغة التطبيق.",
"system": "النظام"
},
"toasts": {
"error_deleting_files": "خطأ في حذف الملفات",
"background_downloads_enabled": "تمكين التنزيلات في الخلفية",
"background_downloads_disabled": "تعطيل التنزيلات في الخلفية",
"connected": "متصل",
"could_not_connect": "تعذر الاتصال",
"invalid_url": "رابط غير صالح"
}
},
"sessions": {
"title": "الجلسات",
"no_active_sessions": "لا توجد جلسات نشطة"
},
"downloads": {
"downloads_title": "التنزيلات",
"tvseries": "مسلسلات",
"movies": "أفلام",
"queue": "قائمة الانتظار",
"queue_hint": "ستفقد قائمة الانتظار والتنزيلات عند إعادة تشغيل التطبيق",
"no_items_in_queue": "لا توجد عناصر في قائمة الانتظار",
"no_downloaded_items": "لا توجد عناصر تم تنزيلها",
"delete_all_movies_button": "حذف جميع الأفلام",
"delete_all_tvseries_button": "حذف جميع المسلسلات",
"delete_all_button": "حذف الكل",
"active_download": "تنزيل نشط",
"no_active_downloads": "لا توجد تنزيلات نشطة",
"active_downloads": "تنزيلات نشطة",
"new_app_version_requires_re_download": "يتطلب إصدار التطبيق الجديد إعادة التنزيل",
"new_app_version_requires_re_download_description": "يتطلب التحديث الجديد تنزيل المحتوى مرة أخرى. يرجى إزالة كل المحتوى الذي تم تنزيله والمحاولة مرة أخرى.",
"back": "رجوع",
"delete": "حذف",
"something_went_wrong": "حدث خطأ ما",
"could_not_get_stream_url_from_jellyfin": "تعذر الحصول على رابط البث من Jellyfin",
"eta": "الوقت المتبقي {{eta}}",
"methods": "الطرق",
"toasts": {
"you_are_not_allowed_to_download_files": "غير مسموح لك بتنزيل الملفات.",
"deleted_all_movies_successfully": "تم حذف جميع الأفلام بنجاح!",
"failed_to_delete_all_movies": "فشل حذف جميع الأفلام",
"deleted_all_tvseries_successfully": "تم حذف جميع المسلسلات بنجاح!",
"failed_to_delete_all_tvseries": "فشل حذف جميع المسلسلات",
"download_deleted": "تم حذف التنزيل",
"could_not_delete_download": "تعذر حذف التنزيل",
"download_paused": "تم إيقاف التنزيل مؤقتًا",
"could_not_pause_download": "تعذر إيقاف التنزيل مؤقتًا",
"download_resumed": "تم استئناف التنزيل",
"could_not_resume_download": "تعذر استئناف التنزيل",
"download_completed": "اكتمل التنزيل",
"download_started_for": "بدأ تنزيل {{item}}",
"item_is_ready_to_be_downloaded": "{{item}} جاهز للتنزيل",
"download_stated_for_item": "بدأ تنزيل {{item}}",
"download_failed_for_item": "فشل تنزيل {{item}} - {{error}}",
"download_completed_for_item": "اكتمل تنزيل {{item}}",
"queued_item_for_optimization": "تمت إضافة {{item}} إلى قائمة الانتظار للتحسين",
"failed_to_start_download_for_item": "فشل بدء تنزيل {{item}}: {{message}}",
"server_responded_with_status_code": "استجاب الخادم بالحالة {{statusCode}}",
"no_response_received_from_server": "لم يتم تلقي أي رد من الخادم",
"error_setting_up_the_request": "خطأ في إعداد الطلب",
"failed_to_start_download_for_item_unexpected_error": "فشل بدء تنزيل {{item}}: خطأ غير متوقع",
"all_files_folders_and_jobs_deleted_successfully": "تم حذف جميع الملفات والمجلدات والمهام بنجاح",
"an_error_occured_while_deleting_files_and_jobs": "حدث خطأ أثناء حذف الملفات والمهام",
"go_to_downloads": "الذهاب إلى التنزيلات"
}
}
},
"search": {
"search_here": "ابحث هنا...",
"search": "بحث...",
"x_items": "{{count}} عناصر",
"library": "المكتبة",
"discover": "اكتشف",
"no_results": "لا توجد نتائج",
"no_results_found_for": "لم يتم العثور على نتائج لـ",
"movies": "أفلام",
"series": "مسلسلات",
"episodes": "حلقات",
"collections": "مجموعات",
"actors": "ممثلون",
"request_movies": "طلب أفلام",
"request_series": "طلب مسلسلات",
"recently_added": "أضيف مؤخراً",
"recent_requests": "الطلبات الأخيرة",
"plex_watchlist": "قائمة مشاهدة Plex",
"trending": "شائع",
"popular_movies": "أفلام شائعة",
"movie_genres": "أنواع الأفلام",
"upcoming_movies": "أفلام قادمة",
"studios": "استوديوهات",
"popular_tv": "مسلسلات شائعة",
"tv_genres": "أنواع المسلسلات",
"upcoming_tv": "مسلسلات قادمة",
"networks": "شبكات",
"tmdb_movie_keyword": "كلمة مفتاحية لفيلم TMDB",
"tmdb_movie_genre": "نوع فيلم TMDB",
"tmdb_tv_keyword": "كلمة مفتاحية لمسلسل TMDB",
"tmdb_tv_genre": "نوع مسلسل TMDB",
"tmdb_search": "بحث TMDB",
"tmdb_studio": "استوديو TMDB",
"tmdb_network": "شبكة TMDB",
"tmdb_movie_streaming_services": "خدمات بث الأفلام TMDB",
"tmdb_tv_streaming_services": "خدمات بث المسلسلات TMDB"
},
"library": {
"no_items_found": "لم يتم العثور على عناصر",
"no_results": "لا توجد نتائج",
"no_libraries_found": "لم يتم العثور على مكتبات",
"item_types": {
"movies": "أفلام",
"series": "مسلسلات",
"boxsets": "مجموعات",
"items": "عناصر"
},
"options": {
"display": "عرض",
"row": "صف",
"list": "قائمة",
"image_style": "نمط الصورة",
"poster": "ملصق",
"cover": "غلاف",
"show_titles": "إظهار العناوين",
"show_stats": "إظهار الإحصائيات"
},
"filters": {
"genres": "الأنواع",
"years": "السنوات",
"sort_by": "ترتيب حسب",
"sort_order": "ترتيب",
"asc": "تصاعدي",
"desc": "تنازلي",
"tags": "الوسوم"
}
},
"favorites": {
"series": "مسلسلات",
"movies": "أفلام",
"episodes": "حلقات",
"videos": "فيديوهات",
"boxsets": "مجموعات",
"playlists": "قوائم التشغيل",
"noDataTitle": "لا توجد مفضلات بعد",
"noData": "ضع علامة على العناصر كمفضلة لتظهر هنا للوصول السريع."
},
"custom_links": {
"no_links": "لا توجد روابط"
},
"player": {
"error": "خطأ",
"failed_to_get_stream_url": "فشل في الحصول على رابط البث",
"an_error_occured_while_playing_the_video": "حدث خطأ أثناء تشغيل الفيديو. تحقق من السجلات في الإعدادات.",
"client_error": "خطأ في العميل",
"could_not_create_stream_for_chromecast": "تعذر إنشاء بث لـ Chromecast",
"message_from_server": "رسالة من الخادم: {{message}}",
"video_has_finished_playing": "انتهى تشغيل الفيديو!",
"no_video_source": "لا يوجد مصدر فيديو...",
"next_episode": "الحلقة التالية",
"refresh_tracks": "تحديث المسارات",
"subtitle_tracks": "مسارات الترجمة:",
"audio_tracks": "مسارات الصوت:",
"playback_state": "حالة التشغيل:",
"no_data_available": "لا توجد بيانات متاحة",
"index": "الفهرس:",
"continue_watching": "متابعة المشاهدة",
"go_back": "رجوع"
},
"item_card": {
"next_up": "التالي",
"no_items_to_display": "لا توجد عناصر لعرضها",
"cast_and_crew": "طاقم العمل",
"series": "مسلسلات",
"seasons": "مواسم",
"season": "موسم",
"no_episodes_for_this_season": "لا توجد حلقات لهذا الموسم",
"overview": "نظرة عامة",
"more_with": "المزيد مع {{name}}",
"similar_items": "عناصر مشابهة",
"no_similar_items_found": "لم يتم العثور على عناصر مشابهة",
"video": "فيديو",
"more_details": "المزيد من التفاصيل",
"quality": "الجودة",
"audio": "الصوت",
"subtitles": "الترجمة",
"show_more": "عرض المزيد",
"show_less": "عرض أقل",
"appeared_in": "ظهر في",
"could_not_load_item": "تعذر تحميل العنصر",
"none": "لا شيء",
"download": {
"download_season": "تنزيل الموسم",
"download_series": "تنزيل المسلسل",
"download_episode": "تنزيل الحلقة",
"download_movie": "تنزيل الفيلم",
"download_x_item": "تنزيل {{item_count}} عناصر",
"download_unwatched_only": "فقط غير المشاهدة",
"download_button": "تنزيل",
"using_optimized_server": "استخدام الخادم المحسن",
"using_default_method": "استخدام الطريقة الافتراضية"
}
},
"live_tv": {
"next": "التالي",
"previous": "السابق",
"live_tv": "بث مباشر",
"coming_soon": "قريباً",
"on_now": "يعرض الآن",
"shows": "برامج",
"movies": "أفلام",
"sports": "رياضة",
"for_kids": "للأطفال",
"news": "أخبار"
},
"jellyseerr": {
"confirm": "تأكيد",
"cancel": "إلغاء",
"yes": "نعم",
"whats_wrong": "ما المشكلة؟",
"issue_type": "نوع المشكلة",
"select_an_issue": "حدد مشكلة",
"types": "الأنواع",
"describe_the_issue": "(اختياري) صف المشكلة...",
"submit_button": "إرسال",
"report_issue_button": "الإبلاغ عن مشكلة",
"request_button": "طلب",
"are_you_sure_you_want_to_request_all_seasons": "هل أنت متأكد أنك تريد طلب جميع المواسم؟",
"failed_to_login": "فشل تسجيل الدخول",
"cast": "طاقم العمل",
"details": "التفاصيل",
"status": "الحالة",
"original_title": "العنوان الأصلي",
"series_type": "نوع المسلسل",
"release_dates": "تواريخ الإصدار",
"first_air_date": "تاريخ أول عرض",
"next_air_date": "تاريخ العرض التالي",
"revenue": "الإيرادات",
"budget": "الميزانية",
"original_language": "اللغة الأصلية",
"production_country": "بلد الإنتاج",
"studios": "استوديوهات",
"network": "شبكة",
"currently_streaming_on": "يتم بثه حاليًا على",
"advanced": "متقدم",
"request_as": "طلب باسم",
"tags": "الوسوم",
"quality_profile": "ملف تعريف الجودة",
"root_folder": "المجلد الجذر",
"season_all": "الموسم (الكل)",
"season_number": "الموسم {{season_number}}",
"number_episodes": "{{episode_number}} حلقات",
"born": "مواليد",
"appearances": "المشاركات",
"toasts": {
"jellyseer_does_not_meet_requirements": "خادم Jellyseerr لا يفي بالحد الأدنى من متطلبات الإصدار! يرجى التحديث إلى 2.0.0 على الأقل",
"jellyseerr_test_failed": "فشل اختبار Jellyseerr. يرجى المحاولة مرة أخرى.",
"failed_to_test_jellyseerr_server_url": "فشل اختبار رابط خادم jellyseerr",
"issue_submitted": "تم إرسال المشكلة!",
"requested_item": "تم طلب {{item}}!",
"you_dont_have_permission_to_request": "ليس لديك إذن للطلب!",
"something_went_wrong_requesting_media": "حدث خطأ ما أثناء طلب الوسائط!"
}
},
"tabs": {
"home": "الرئيسية",
"search": "بحث",
"library": "المكتبة",
"custom_links": "روابط مخصصة",
"favorites": "المفضلة"
}
}

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