Compare commits

..

3 Commits

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

View File

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

@@ -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@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: streamyfin-android-phone-apk-${{ env.DATE_TAG }}
path: |
android/app/build/outputs/apk/release/*.apk
retention-days: 7
build-android-tv:
if: (!contains(github.event.head_commit.message, '[skip ci]'))
runs-on: ubuntu-24.04
name: 🤖 Build Android APK (TV)
permissions:
contents: read
steps:
- name: 📥 Checkout code
uses: actions/checkout@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@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: streamyfin-android-tv-apk-${{ env.DATE_TAG }}
path: |
android/app/build/outputs/apk/release/*.apk
retention-days: 7
build-ios-phone:
if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
runs-on: macos-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@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: streamyfin-ios-phone-ipa-${{ env.DATE_TAG }}
path: build-*.ipa
retention-days: 7
# Disabled for now - uncomment when ready to build iOS TV
# build-ios-tv:
# if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
# runs-on: macos-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
- name: 💾 Cache Bun dependencies
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: |
~/.bun/install/cache

View File

@@ -31,13 +31,13 @@ jobs:
fetch-depth: 0
- name: 🏁 Initialize CodeQL
uses: github/codeql-action/init@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0
uses: github/codeql-action/init@2d92b76c45b91eb80fc44c74ce3fce0ee94e8f9d # v3.30.0
with:
languages: ${{ matrix.language }}
queries: +security-extended,security-and-quality
- name: 🛠️ Autobuild
uses: github/codeql-action/autobuild@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0
uses: github/codeql-action/autobuild@2d92b76c45b91eb80fc44c74ce3fce0ee94e8f9d # v3.30.0
- name: 🧪 Perform CodeQL Analysis
uses: github/codeql-action/analyze@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0
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@08713f00a50548bfe39b37e8f44afb53e7a802d4 # v2.12.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
- name: Dependency Review
uses: actions/dependency-review-action@40c09b7dc99638e5ddb0bfd91c1673effc064d8a # v4.8.1
uses: actions/dependency-review-action@595b5aeba73380359d98a5e087f648dbb0edce1b # v4.7.3
with:
fail-on-severity: high
base-ref: ${{ github.event.pull_request.base.sha || 'develop' }}
@@ -65,7 +65,6 @@ jobs:
expo-doctor:
name: 🚑 Expo Doctor Check
if: false
runs-on: ubuntu-24.04
steps:
- name: 🛒 Checkout repository
@@ -107,7 +106,7 @@ jobs:
fetch-depth: 0
- name: "🟢 Setup Node.js"
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: '24.x'

View File

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

View File

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

View File

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

61
.gitignore vendored
View File

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

View File

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

183
.vscode/settings.json vendored
View File

@@ -1,185 +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]": {
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true
},
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome",
"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]": {
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true
},
// SonarLint connected mode (kept from HEAD)
"sonarlint.connectedMode.project": {
"connectionId": "gauvino",
"projectKey": "Gauvino_streamyfin"
},
"[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"
}
}

161
README.md
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,7 +7,7 @@ import {
} from "@gorhom/bottom-sheet";
import { useNavigation, useRouter } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
import { toast } from "sonner-native";
@@ -23,26 +23,7 @@ import { type DownloadedItem } from "@/providers/Downloads/types";
import { queueAtom } from "@/utils/atoms/queue";
import { writeToLog } from "@/utils/log";
interface HeaderRightProps {
readonly downloadedFiles: DownloadedItem[] | null;
readonly onPress: () => void;
}
function HeaderRight({ downloadedFiles, onPress }: HeaderRightProps) {
return (
<TouchableOpacity onPress={onPress}>
<DownloadSize items={downloadedFiles?.map((f) => f.item) || []} />
</TouchableOpacity>
);
}
function CustomBottomSheetBackdrop(props: Readonly<BottomSheetBackdropProps>) {
return (
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} />
);
}
const DownloadsPage = () => {
export default function page() {
const navigation = useNavigation();
const { t } = useTranslation();
const [queue, setQueue] = useAtom(queueAtom);
@@ -55,17 +36,6 @@ const DownloadsPage = () => {
const router = useRouter();
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const handleRemoveQueueItem = useCallback(
(queueItemId: string) => {
removeProcess(queueItemId);
setQueue((prev) => {
if (!prev) return [];
return prev.filter((i) => i.id !== queueItemId);
});
},
[removeProcess, setQueue],
);
const [showMigration, setShowMigration] = useState(false);
const migration_20241124 = () => {
@@ -83,13 +53,9 @@ const DownloadsPage = () => {
{
text: t("home.downloads.delete"),
style: "destructive",
onPress: () => {
deleteAllFiles()
.then(() => setShowMigration(false))
.catch((error) => {
console.error("Failed to delete all files:", error);
setShowMigration(false);
});
onPress: async () => {
await deleteAllFiles();
setShowMigration(false);
},
},
],
@@ -124,34 +90,15 @@ const DownloadsPage = () => {
}
}, [downloadedFiles]);
const otherMedia = useMemo(() => {
try {
return (
downloadedFiles?.filter(
(f) => f.item.Type !== "Movie" && f.item.Type !== "Episode",
) || []
);
} catch {
setShowMigration(true);
return [];
}
}, [downloadedFiles]);
const headerRightComponent = useMemo(
() => (
<HeaderRight
downloadedFiles={downloadedFiles}
onPress={() => bottomSheetModalRef.current?.present()}
/>
),
[downloadedFiles],
);
useEffect(() => {
navigation.setOptions({
headerRight: () => headerRightComponent,
headerRight: () => (
<TouchableOpacity onPress={bottomSheetModalRef.current?.present}>
<DownloadSize items={downloadedFiles?.map((f) => f.item) || []} />
</TouchableOpacity>
),
});
}, [headerRightComponent, navigation]);
}, [downloadedFiles]);
useEffect(() => {
if (showMigration) {
@@ -181,30 +128,8 @@ const DownloadsPage = () => {
writeToLog("ERROR", reason);
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 () =>
await Promise.all([deleteMovies(), deleteShows(), deleteOtherMedia()]);
await Promise.all([deleteMovies(), deleteShows()]);
return (
<>
@@ -220,13 +145,13 @@ const DownloadsPage = () => {
{t("home.downloads.queue_hint")}
</Text>
<View className='flex flex-col space-y-2 mt-2'>
{queue.map((q) => (
{queue.map((q, index) => (
<TouchableOpacity
onPress={() =>
router.push(`/(auth)/items/page?id=${q.item.Id}`)
}
className='relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between'
key={q.id}
key={index}
>
<View>
<Text className='font-semibold'>{q.item.Name}</Text>
@@ -235,7 +160,13 @@ const DownloadsPage = () => {
</Text>
</View>
<TouchableOpacity
onPress={() => handleRemoveQueueItem(q.id)}
onPress={() => {
removeProcess(q.id);
setQueue((prev) => {
if (!prev) return [];
return [...prev.filter((i) => i.id !== q.id)];
});
}}
>
<Ionicons name='close' size={24} color='red' />
</TouchableOpacity>
@@ -307,34 +238,6 @@ const DownloadsPage = () => {
</ScrollView>
</View>
)}
{otherMedia.length > 0 && (
<View className='mb-4'>
<View className='flex flex-row items-center justify-between mb-2 px-4'>
<Text className='text-lg font-bold'>
{t("home.downloads.other_media")}
</Text>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
<Text className='text-xs font-bold'>
{otherMedia?.length}
</Text>
</View>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className='px-4 flex flex-row'>
{otherMedia?.map((item) => (
<TouchableItemRouter
item={item.item}
isOffline
key={item.item.Id}
>
<MovieCard item={item.item} />
</TouchableItemRouter>
))}
</View>
</ScrollView>
</View>
)}
{downloadedFiles?.length === 0 && (
<View className='flex px-4'>
<Text className='opacity-50'>
@@ -354,7 +257,13 @@ const DownloadsPage = () => {
backgroundStyle={{
backgroundColor: "#171717",
}}
backdropComponent={CustomBottomSheetBackdrop}
backdropComponent={(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
)}
>
<BottomSheetView>
<View className='p-4 space-y-4 mb-4'>
@@ -364,11 +273,6 @@ const DownloadsPage = () => {
<Button color='purple' onPress={deleteShows}>
{t("home.downloads.delete_all_tvseries_button")}
</Button>
{otherMedia.length > 0 && (
<Button color='purple' onPress={deleteOtherMedia}>
{t("home.downloads.delete_all_other_media_button")}
</Button>
)}
<Button color='red' onPress={deleteAllMedia}>
{t("home.downloads.delete_all_button")}
</Button>
@@ -377,6 +281,4 @@ const DownloadsPage = () => {
</BottomSheetModal>
</>
);
};
export default DownloadsPage;
}

View File

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

View File

@@ -1,7 +1,7 @@
import { useNavigation, useRouter } from "expo-router";
import { t } from "i18next";
import { useAtom } from "jotai";
import { useEffect, useMemo } from "react";
import { useEffect } from "react";
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
@@ -25,19 +25,7 @@ import { useJellyfin, userAtom } from "@/providers/JellyfinProvider";
import { clearLogs } from "@/utils/log";
import { storage } from "@/utils/mmkv";
interface LogoutButtonProps {
readonly onPress: () => void;
}
function LogoutButton({ onPress }: LogoutButtonProps) {
return (
<TouchableOpacity onPress={onPress}>
<Text className='text-red-600'>{t("home.settings.log_out_button")}</Text>
</TouchableOpacity>
);
}
const SettingsPage = () => {
export default function settings() {
const router = useRouter();
const insets = useSafeAreaInsets();
const [_user] = useAtom(userAtom);
@@ -49,17 +37,22 @@ const SettingsPage = () => {
successHapticFeedback();
};
const headerRightComponent = useMemo(
() => <LogoutButton onPress={() => logout()} />,
[logout],
);
const navigation = useNavigation();
useEffect(() => {
navigation.setOptions({
headerRight: () => headerRightComponent,
headerRight: () => (
<TouchableOpacity
onPress={() => {
logout();
}}
>
<Text className='text-red-600'>
{t("home.settings.log_out_button")}
</Text>
</TouchableOpacity>
),
});
}, [headerRightComponent, navigation]);
}, []);
return (
<ScrollView
@@ -125,6 +118,4 @@ const SettingsPage = () => {
</View>
</ScrollView>
);
};
export default SettingsPage;
}

View File

@@ -12,7 +12,7 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
export default function page() {
const { settings, updateSettings, pluginSettings } = useSettings();
const [settings, updateSettings, pluginSettings] = useSettings(null);
const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom);

View File

@@ -3,7 +3,7 @@ import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
import { useSettings } from "@/utils/atoms/settings";
export default function page() {
const { pluginSettings } = useSettings();
const [_settings, _updateSettings, pluginSettings] = useSettings(null);
return (
<DisabledSetting

View File

@@ -21,7 +21,7 @@ export default function page() {
const { t } = useTranslation();
const { settings, updateSettings, pluginSettings } = useSettings();
const [settings, updateSettings, pluginSettings] = useSettings(null);
const queryClient = useQueryClient();
const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");

View File

@@ -7,7 +7,7 @@ import { useAtom } from "jotai";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { InfiniteHorizontalScroll } from "@/components/common/InfiniteHorizontalScroll";
import { InfiniteHorizontalScroll } from "@/components/common/InfiniteHorrizontalScroll";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { ItemCardText } from "@/components/ItemCardText";
@@ -22,21 +22,21 @@ import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
const page: React.FC = () => {
const local = useLocalSearchParams();
const { personId } = local as { personId: string };
const { actorId } = local as { actorId: string };
const { t } = useTranslation();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { data: item, isLoading: l1 } = useQuery({
queryKey: ["item", personId],
queryKey: ["item", actorId],
queryFn: async () =>
await getUserItemData({
api,
userId: user?.Id,
itemId: personId,
itemId: actorId,
}),
enabled: !!personId && !!api,
enabled: !!actorId && !!api,
staleTime: 60,
});
@@ -50,7 +50,7 @@ const page: React.FC = () => {
const response = await getItemsApi(api).getItems({
userId: user.Id,
personIds: [personId],
personIds: [actorId],
startIndex: pageParam,
limit: 16,
sortOrder: ["Descending", "Descending", "Ascending"],
@@ -68,7 +68,7 @@ const page: React.FC = () => {
return response.data;
},
[api, user?.Id, personId],
[api, user?.Id, actorId],
);
const backdropUrl = useMemo(
@@ -131,7 +131,7 @@ const page: React.FC = () => {
</TouchableItemRouter>
)}
queryFn={fetchItems}
queryKey={["actor", "movies", personId]}
queryKey={["actor", "movies", actorId]}
/>
<View className='h-12' />
</View>

View File

@@ -15,7 +15,7 @@ import { COMPANY_LOGO_IMAGE_FILTER } from "@/utils/jellyseerr/src/components/Dis
export default function page() {
const local = useLocalSearchParams();
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
const { jellyseerrApi } = useJellyseerr();
const { companyId, image, type } = local as unknown as {
companyId: string;
@@ -53,10 +53,7 @@ export default function page() {
uniqBy(
data?.pages
?.filter((p) => p?.results.length)
.flatMap(
(p) =>
p?.results.filter((r) => isJellyseerrMovieOrTvResult(r)) ?? [],
),
.flatMap((p) => p?.results ?? []),
"id",
) ?? [],
[data],
@@ -101,7 +98,9 @@ export default function page() {
}}
/>
}
renderItem={(item, _index) => <JellyseerrPoster item={item} />}
renderItem={(item, _index) => (
<JellyseerrPoster item={item as MovieResult | TvResult} />
)}
/>
);
}

View File

@@ -8,10 +8,14 @@ import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
import {
type MovieResult,
type TvResult,
} from "@/utils/jellyseerr/server/models/Search";
export default function page() {
const local = useLocalSearchParams();
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
const { jellyseerrApi } = useJellyseerr();
const { genreId, name, type } = local as unknown as {
genreId: string;
@@ -47,10 +51,7 @@ export default function page() {
uniqBy(
data?.pages
?.filter((p) => p?.results.length)
.flatMap(
(p) =>
p?.results.filter((r) => isJellyseerrMovieOrTvResult(r)) ?? [],
),
.flatMap((p) => p?.results ?? []),
"id",
) ?? [],
[data],
@@ -61,7 +62,7 @@ export default function page() {
jellyseerrApi
? flatData.map((r) =>
jellyseerrApi.imageProxy(
r.backdropPath,
(r as TvResult | MovieResult).backdropPath,
"w1920_and_h800_multi_faces",
),
)
@@ -91,7 +92,9 @@ export default function page() {
{name}
</Text>
}
renderItem={(item, _index) => <JellyseerrPoster item={item} />}
renderItem={(item, _index) => (
<JellyseerrPoster item={item as MovieResult | TvResult} />
)}
/>
);
}

View File

@@ -1,18 +1,3 @@
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
interface HeaderRightProps {
readonly details: MovieDetails | TvDetails | null | undefined;
}
function HeaderRight({ details }: HeaderRightProps) {
if (!details) return null;
return (
<TouchableOpacity className='rounded-full p-2 bg-neutral-800/80'>
<ItemActions item={details} />
</TouchableOpacity>
);
}
import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
@@ -57,6 +42,7 @@ const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import RequestModal from "@/components/jellyseerr/RequestModal";
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
const Page: React.FC = () => {
const insets = useSafeAreaInsets();
@@ -78,7 +64,7 @@ const Page: React.FC = () => {
const [issueType, setIssueType] = useState<IssueType>();
const [issueMessage, setIssueMessage] = useState<string>();
const [requestBody, setRequestBody] = useState<MediaRequestBody>();
const [requestBody, _setRequestBody] = useState<MediaRequestBody>();
const advancedReqModalRef = useRef<BottomSheetModal>(null);
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
@@ -129,18 +115,18 @@ const Page: React.FC = () => {
}
}, [jellyseerrApi, details, result, issueType, issueMessage]);
const handleSetRequestBody = useCallback(
const setRequestBody = useCallback(
(body: MediaRequestBody) => {
setRequestBody(body);
_setRequestBody(body);
advancedReqModalRef?.current?.present?.();
},
[requestBody, setRequestBody, advancedReqModalRef],
[requestBody, _setRequestBody, advancedReqModalRef],
);
const request = useCallback(async () => {
const body: MediaRequestBody = {
mediaId: Number(result.id!),
mediaType: mediaType,
mediaType: mediaType!,
tvdbId: details?.externalIds?.tvdbId,
seasons: (details as TvDetails)?.seasons
?.filter?.((s) => s.seasonNumber !== 0)
@@ -148,7 +134,7 @@ const Page: React.FC = () => {
};
if (hasAdvancedRequestPermission) {
handleSetRequestBody(body);
setRequestBody(body);
return;
}
@@ -162,81 +148,17 @@ const Page: React.FC = () => {
[details],
);
const headerRightComponent = useMemo(
() => <HeaderRight details={details as MovieDetails | TvDetails | null} />,
[details],
);
useEffect(() => {
navigation.setOptions({
headerRight: () => headerRightComponent,
});
}, [headerRightComponent, navigation]);
const renderActionButton = () => {
if (isLoading || isFetching) {
return (
<Button
loading={true}
disabled={true}
color='purple'
className='mt-4'
/>
);
if (details) {
navigation.setOptions({
headerRight: () => (
<TouchableOpacity className='rounded-full p-2 bg-neutral-800/80'>
<ItemActions item={details} />
</TouchableOpacity>
),
});
}
if (canRequest) {
return (
<Button color='purple' onPress={request} className='mt-4'>
{t("jellyseerr.request_button")}
</Button>
);
}
if (details?.mediaInfo?.jellyfinMediaId) {
return (
<View className='flex flex-row space-x-2 mt-4'>
{!Platform.isTV && (
<Button
className='flex-1 bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100'
color='transparent'
onPress={() => bottomSheetModalRef?.current?.present()}
iconLeft={
<Ionicons name='warning-outline' size={20} color='white' />
}
style={{
borderWidth: 1,
borderStyle: "solid",
}}
>
<Text className='text-sm'>
{t("jellyseerr.report_issue_button")}
</Text>
</Button>
)}
<Button
className='flex-1 bg-purple-600/50 border-purple-400 ring-purple-400 text-purple-100'
onPress={() => {
const url =
mediaType === MediaType.MOVIE
? `/(auth)/(tabs)/(search)/items/page?id=${details?.mediaInfo.jellyfinMediaId}`
: `/(auth)/(tabs)/(search)/series/${details?.mediaInfo.jellyfinMediaId}`;
router.push(url as any);
}}
iconLeft={<Ionicons name='play-outline' size={20} color='white' />}
style={{
borderWidth: 1,
borderStyle: "solid",
}}
>
<Text className='text-sm'>Play</Text>
</Button>
</View>
);
}
return null;
};
}, [details]);
return (
<View
@@ -355,16 +277,12 @@ const Page: React.FC = () => {
<Button
className='flex-1 bg-purple-600/50 border-purple-400 ring-purple-400 text-purple-100'
onPress={() => {
router.push({
pathname:
mediaType === MediaType.MOVIE
? "/(auth)/(tabs)/(search)/items/page"
: "/(auth)/(tabs)/(search)/series/[id]",
params:
mediaType === MediaType.MOVIE
? { id: details?.mediaInfo.jellyfinMediaId }
: { id: details?.mediaInfo.jellyfinMediaId },
});
const url =
mediaType === MediaType.MOVIE
? `/(auth)/(tabs)/(search)/items/page?id=${details?.mediaInfo.jellyfinMediaId}`
: `/(auth)/(tabs)/(search)/series/${details?.mediaInfo.jellyfinMediaId}`;
// @ts-expect-error
router.push(url);
}}
iconLeft={
<Ionicons name='play-outline' size={20} color='white' />
@@ -374,7 +292,7 @@ const Page: React.FC = () => {
borderStyle: "solid",
}}
>
<Text className='text-sm'>{t("common.play")}</Text>
<Text className='text-sm'>Play</Text>
</Button>
</View>
)
@@ -388,7 +306,7 @@ const Page: React.FC = () => {
details={details as TvDetails}
refetch={refetch}
hasAdvancedRequest={hasAdvancedRequestPermission}
onAdvancedRequest={(data) => handleSetRequestBody(data)}
onAdvancedRequest={(data) => setRequestBody(data)}
/>
)}
<DetailFacts
@@ -407,11 +325,11 @@ const Page: React.FC = () => {
type={mediaType}
isAnime={isAnime}
onRequested={() => {
setRequestBody(undefined);
_setRequestBody(undefined);
advancedReqModalRef?.current?.close();
refetch();
}}
onDismiss={() => setRequestBody(undefined)}
onDismiss={() => _setRequestBody(undefined)}
/>
{!Platform.isTV && (
// This is till it's fixed because the menu isn't selectable on TV

View File

@@ -10,6 +10,10 @@ import { OverviewText } from "@/components/OverviewText";
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import type { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
import type {
MovieResult,
TvResult,
} from "@/utils/jellyseerr/server/models/Search";
export default function page() {
const local = useLocalSearchParams();
@@ -102,7 +106,9 @@ export default function page() {
MainContent={() => (
<OverviewText text={data?.details?.biography} className='mt-4' />
)}
renderItem={(item, _index) => <JellyseerrPoster item={item} />}
renderItem={(item, _index) => (
<JellyseerrPoster item={item as MovieResult | TvResult} />
)}
/>
);
}

View File

@@ -19,49 +19,9 @@ import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
function MissingDownloadIcon() {
return <Ionicons name='download' size={22} color='white' />;
}
function DownloadedIcon() {
return <Ionicons name='checkmark-done-outline' size={24} color='#9333ea' />;
}
interface SeriesHeaderRightProps {
readonly isLoading: boolean;
readonly item: any;
readonly allEpisodes: any[];
}
function SeriesHeaderRight({
isLoading,
item,
allEpisodes,
}: SeriesHeaderRightProps) {
const { t } = useTranslation();
if (isLoading || !item || !allEpisodes || allEpisodes.length === 0) {
return null;
}
return (
<View className='flex flex-row items-center space-x-2'>
<AddToFavorites item={item} />
{!Platform.isTV && (
<DownloadItems
size='large'
title={t("item_card.download.download_series")}
items={allEpisodes}
MissingDownloadIconComponent={MissingDownloadIcon}
DownloadedIconComponent={DownloadedIcon}
/>
)}
</View>
);
}
const SeriesPage: React.FC = () => {
const page: React.FC = () => {
const navigation = useNavigation();
const { t } = useTranslation();
const params = useLocalSearchParams();
const { id: seriesId, seasonIndex } = params as {
id: string;
@@ -125,22 +85,36 @@ const SeriesPage: React.FC = () => {
enabled: !!api && !!user?.Id && !!item?.Id,
});
const headerRightComponent = useMemo(
() => (
<SeriesHeaderRight
isLoading={isLoading}
item={item}
allEpisodes={allEpisodes || []}
/>
),
[isLoading, item, allEpisodes],
);
useEffect(() => {
navigation.setOptions({
headerRight: () => headerRightComponent,
headerRight: () =>
!isLoading &&
item &&
allEpisodes &&
allEpisodes.length > 0 && (
<View className='flex flex-row items-center space-x-2'>
<AddToFavorites item={item} />
{!Platform.isTV && (
<DownloadItems
size='large'
title={t("item_card.download.download_series")}
items={allEpisodes || []}
MissingDownloadIconComponent={() => (
<Ionicons name='download' size={22} color='white' />
)}
DownloadedIconComponent={() => (
<Ionicons
name='checkmark-done-outline'
size={24}
color='#9333ea'
/>
)}
/>
)}
</View>
),
});
}, [headerRightComponent, navigation]);
}, [allEpisodes, isLoading, item]);
if (!item || !backdropUrl) return null;
@@ -184,4 +158,4 @@ const SeriesPage: React.FC = () => {
);
};
export default SeriesPage;
export default page;

View File

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

View File

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

@@ -19,7 +19,7 @@ export default function index() {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const queryClient = useQueryClient();
const { settings } = useSettings();
const [settings] = useSettings(null);
const { t } = useTranslation();

View File

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

View File

@@ -71,7 +71,7 @@ export default function search() {
const [api] = useAtom(apiAtom);
const { settings } = useSettings();
const [settings] = useSettings(null);
const { jellyseerrApi } = useJellyseerr();
const [jellyseerrOrderBy, setJellyseerrOrderBy] =
useState<JellyseerrSearchSort>(

View File

@@ -27,7 +27,7 @@ export const NativeTabs = withLayoutContext<
>(Navigator);
export default function TabLayout() {
const { settings } = useSettings();
const [settings] = useSettings(null);
const { t } = useTranslation();
const router = useRouter();

View File

@@ -2,7 +2,6 @@ import {
type BaseItemDto,
type MediaSourceInfo,
PlaybackOrder,
PlaybackProgressInfo,
PlaybackStartInfo,
RepeatMode,
} from "@jellyfin/sdk/lib/generated-client";
@@ -22,12 +21,6 @@ import { BITRATES } from "@/components/BitrateSelector";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { Controls } from "@/components/video-player/controls/Controls";
import {
OUTLINE_THICKNESS,
OutlineThickness,
VLC_COLORS,
VLCColor,
} from "@/constants/SubtitleConstants";
import { useHaptic } from "@/hooks/useHaptic";
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
@@ -47,7 +40,7 @@ import { writeToLog } from "@/utils/log";
import { generateDeviceProfile } from "@/utils/profiles/native";
import { msToTicks, ticksToSeconds } from "@/utils/time";
export default function Page() {
export default function page() {
const videoRef = useRef<VlcPlayerViewRef>(null);
const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom);
@@ -55,7 +48,7 @@ export default function Page() {
const navigation = useNavigation();
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
const [showControls, setShowControls] = useState(true);
const [showControls, _setShowControls] = useState(true);
const [aspectRatio, setAspectRatio] = useState<
"default" | "16:9" | "4:3" | "1:1" | "21:9"
>("default");
@@ -81,13 +74,10 @@ export default function Page() {
const lightHapticFeedback = useHaptic("light");
const setShowControlsWithHaptic = useCallback(
(show: boolean) => {
setShowControls(show);
lightHapticFeedback();
},
[lightHapticFeedback, setShowControls],
);
const setShowControls = useCallback((show: boolean) => {
_setShowControls(show);
lightHapticFeedback();
}, []);
const {
itemId,
@@ -107,7 +97,7 @@ export default function Page() {
/** Playback position in ticks. */
playbackPosition?: string;
}>();
const { settings } = useSettings();
const [_settings] = useSettings(null);
const offline = offlineStr === "true";
const playbackManager = usePlaybackManager();
@@ -147,7 +137,7 @@ export default function Page() {
if (offline && !Platform.isTV) {
const data = downloadUtils.getDownloadedItemById(itemId);
if (data) {
fetchedItem = data.item;
fetchedItem = data.item as BaseItemDto;
setDownloadedItem(data);
}
} else {
@@ -182,82 +172,64 @@ export default function Page() {
isError: false,
});
const createOfflineStream = useCallback(() => {
if (!downloadedItem?.mediaSource || !item) return null;
return {
mediaSource: downloadedItem.mediaSource,
sessionId: "",
url: downloadedItem.videoFilePath,
};
}, [downloadedItem, item]);
const validateStreamingRequirements = useCallback(() => {
if (!api) {
console.warn("API not available for streaming");
return false;
}
if (!user?.Id) {
console.warn("User not authenticated for streaming");
return false;
}
return true;
}, [api, user?.Id]);
const fetchOnlineStream = useCallback(async () => {
if (!validateStreamingRequirements() || !user?.Id) return null;
const native = generateDeviceProfile();
const transcoding = generateDeviceProfile({ transcode: true });
const res = await getStreamUrl({
api,
item,
startTimeTicks: getInitialPlaybackTicks(),
userId: user.Id,
audioStreamIndex: audioIndex,
maxStreamingBitrate: bitrateValue,
mediaSourceId: mediaSourceId,
subtitleStreamIndex: subtitleIndex,
deviceProfile: bitrateValue ? transcoding : native,
});
if (!res) return null;
const { mediaSource, sessionId, url } = res;
if (!sessionId || !mediaSource || !url) {
Alert.alert(t("player.error"), t("player.failed_to_get_stream_url"));
return null;
}
return { mediaSource, sessionId, url };
}, [
validateStreamingRequirements,
api,
item,
getInitialPlaybackTicks,
user?.Id,
audioIndex,
bitrateValue,
mediaSourceId,
subtitleIndex,
t,
]);
useEffect(() => {
const fetchStreamData = async () => {
setStreamStatus({ isLoading: true, isError: false });
try {
// Don't attempt to fetch stream data if item is not available
if (!item?.Id) {
console.log("Item not loaded yet, skipping stream data fetch");
setStreamStatus({ isLoading: false, isError: false });
return;
}
const result = offline
? createOfflineStream()
: await fetchOnlineStream();
let result: Stream | null = null;
if (offline && downloadedItem && downloadedItem.mediaSource) {
const url = downloadedItem.videoFilePath;
if (item) {
result = {
mediaSource: downloadedItem.mediaSource,
sessionId: "",
url: url,
};
}
} else {
// Validate required parameters before calling getStreamUrl
if (!api) {
console.warn("API not available for streaming");
setStreamStatus({ isLoading: false, isError: true });
return;
}
if (!user?.Id) {
console.warn("User not authenticated for streaming");
setStreamStatus({ isLoading: false, isError: true });
return;
}
const native = generateDeviceProfile();
const transcoding = generateDeviceProfile({ transcode: true });
const res = await getStreamUrl({
api,
item,
startTimeTicks: getInitialPlaybackTicks(),
userId: user.Id,
audioStreamIndex: audioIndex,
maxStreamingBitrate: bitrateValue,
mediaSourceId: mediaSourceId,
subtitleStreamIndex: subtitleIndex,
deviceProfile: bitrateValue ? transcoding : native,
});
if (!res) return;
const { mediaSource, sessionId, url } = res;
if (!sessionId || !mediaSource || !url) {
Alert.alert(
t("player.error"),
t("player.failed_to_get_stream_url"),
);
return;
}
result = { mediaSource, sessionId, url };
}
setStream(result);
setStreamStatus({ isLoading: false, isError: false });
} catch (error) {
@@ -274,9 +246,6 @@ export default function Page() {
item,
user?.Id,
downloadedItem,
offline,
createOfflineStream,
fetchOnlineStream,
]);
useEffect(() => {
@@ -295,7 +264,12 @@ export default function Page() {
if (isPlaying) {
await videoRef.current?.pause();
playbackManager.reportPlaybackProgress(
currentPlayStateInfo() as PlaybackProgressInfo,
item?.Id!,
msToTicks(progress.get()),
{
AudioStreamIndex: audioIndex ?? -1,
SubtitleStreamIndex: subtitleIndex ?? -1,
},
);
} else {
videoRef.current?.play();
@@ -345,8 +319,8 @@ export default function Page() {
if (!stream) return;
return {
itemId: item?.Id!,
audioStreamIndex: audioIndex || undefined,
subtitleStreamIndex: subtitleIndex || undefined,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: msToTicks(progress.get()),
isPaused: !isPlaying,
@@ -413,7 +387,12 @@ export default function Page() {
if (!item?.Id) return;
playbackManager.reportPlaybackProgress(
currentPlayStateInfo() as PlaybackProgressInfo,
item.Id,
msToTicks(progress.get()),
{
AudioStreamIndex: audioIndex ?? -1,
SubtitleStreamIndex: subtitleIndex ?? -1,
},
);
},
[
@@ -502,46 +481,15 @@ export default function Page() {
}
}, []);
const handleTogglePlay = useCallback(() => {
togglePlay().catch((error) => console.error("Error toggling play:", error));
}, [togglePlay]);
const handleToggleMute = useCallback(() => {
toggleMuteCb().catch((error) =>
console.error("Error toggling mute:", error),
);
}, [toggleMuteCb]);
const handleVolumeUp = useCallback(() => {
volumeUpCb().catch((error) =>
console.error("Error increasing volume:", error),
);
}, [volumeUpCb]);
const handleVolumeDown = useCallback(() => {
volumeDownCb().catch((error) =>
console.error("Error decreasing volume:", error),
);
}, [volumeDownCb]);
const handleSetVolume = useCallback(
(volume: number) => {
setVolumeCb(volume).catch((error) =>
console.error("Error setting volume:", error),
);
},
[setVolumeCb],
);
useWebSocket({
isPlaying: isPlaying,
togglePlay: handleTogglePlay,
togglePlay: togglePlay,
stopPlayback: stop,
offline,
toggleMute: handleToggleMute,
volumeUp: handleVolumeUp,
volumeDown: handleVolumeDown,
setVolume: handleSetVolume,
toggleMute: toggleMuteCb,
volumeUp: volumeUpCb,
volumeDown: volumeDownCb,
setVolume: setVolumeCb,
});
const onPlaybackStateChanged = useCallback(
@@ -551,7 +499,12 @@ export default function Page() {
setIsPlaying(true);
if (item?.Id) {
playbackManager.reportPlaybackProgress(
currentPlayStateInfo() as PlaybackProgressInfo,
item.Id,
msToTicks(progress.get()),
{
AudioStreamIndex: audioIndex ?? -1,
SubtitleStreamIndex: subtitleIndex ?? -1,
},
);
}
if (!Platform.isTV) await activateKeepAwakeAsync();
@@ -562,7 +515,12 @@ export default function Page() {
setIsPlaying(false);
if (item?.Id) {
playbackManager.reportPlaybackProgress(
currentPlayStateInfo() as PlaybackProgressInfo,
item.Id,
msToTicks(progress.get()),
{
AudioStreamIndex: audioIndex ?? -1,
SubtitleStreamIndex: subtitleIndex ?? -1,
},
);
}
if (!Platform.isTV) await deactivateKeepAwake();
@@ -618,34 +576,8 @@ export default function Page() {
? allSubs.indexOf(chosenSubtitleTrack)
: [...textSubs].reverse().indexOf(chosenSubtitleTrack);
initOptions.push(`--sub-track=${finalIndex}`);
// Add VLC subtitle styling options from settings
const textColor = (settings.vlcTextColor ?? "White") as VLCColor;
const backgroundColor = (settings.vlcBackgroundColor ??
"Black") as VLCColor;
const outlineColor = (settings.vlcOutlineColor ?? "Black") as VLCColor;
const outlineThickness = (settings.vlcOutlineThickness ??
"Normal") as OutlineThickness;
const backgroundOpacity = settings.vlcBackgroundOpacity ?? 128;
const outlineOpacity = settings.vlcOutlineOpacity ?? 255;
const isBold = settings.vlcIsBold ?? false;
// Add subtitle styling options
initOptions.push(`--freetype-color=${VLC_COLORS[textColor]}`);
initOptions.push(`--freetype-background-opacity=${backgroundOpacity}`);
initOptions.push(
`--freetype-background-color=${VLC_COLORS[backgroundColor]}`,
);
initOptions.push(`--freetype-outline-opacity=${outlineOpacity}`);
initOptions.push(`--freetype-outline-color=${VLC_COLORS[outlineColor]}`);
initOptions.push(
`--freetype-outline-thickness=${OUTLINE_THICKNESS[outlineThickness]}`,
);
initOptions.push(`--sub-text-scale=${settings.subtitleSize}`);
initOptions.push("--sub-margin=40");
if (isBold) {
initOptions.push("--freetype-bold");
}
}
if (notTranscoding && chosenAudioTrack) {
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
}
@@ -788,6 +720,7 @@ export default function Page() {
<Controls
mediaSource={stream?.mediaSource}
item={item}
videoRef={videoRef}
togglePlay={togglePlay}
isPlaying={isPlaying}
isSeeking={isSeeking}
@@ -795,12 +728,13 @@ export default function Page() {
cacheProgress={cacheProgress}
isBuffering={isBuffering}
showControls={showControls}
setShowControls={setShowControlsWithHaptic}
setShowControls={setShowControls}
isVideoLoaded={isVideoLoaded}
startPictureInPicture={startPictureInPicture}
play={play}
pause={pause}
seek={seek}
enableTrickplay={true}
getAudioTracks={getAudioTracks}
getSubtitleTracks={getSubtitleTracks}
offline={offline}
@@ -814,6 +748,7 @@ export default function Page() {
setAspectRatio={setAspectRatio}
setScaleFactor={setScaleFactor}
isVlc
api={api}
downloadedFiles={downloadedFiles}
/>
)}

View File

@@ -5,7 +5,7 @@ import { type PropsWithChildren } from "react";
* This file is web-only and used to configure the root HTML for every web page during static rendering.
* The contents of this function only run in Node.js environments and do not have access to the DOM or browser APIs.
*/
export default function Root({ children }: Readonly<PropsWithChildren>) {
export default function Root({ children }: PropsWithChildren) {
return (
<html lang='en'>
<head>

View File

@@ -1,7 +1,7 @@
import "@/augmentations";
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { Appearance, AppState, Platform } from "react-native";
import { Platform } from "react-native";
import i18n from "@/i18n";
import { DownloadProvider } from "@/providers/DownloadProvider";
import {
@@ -9,7 +9,6 @@ import {
getOrSetDeviceId,
getTokenFromStorage,
JellyfinProvider,
userAtom,
} from "@/providers/JellyfinProvider";
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
import { WebSocketProvider } from "@/providers/WebSocketProvider";
@@ -21,8 +20,8 @@ import {
} from "@/utils/background-tasks";
import {
LogProvider,
writeDebugLog,
writeErrorLog,
writeInfoLog,
writeToLog,
} from "@/utils/log";
import { storage } from "@/utils/mmkv";
@@ -46,9 +45,10 @@ import { router, Stack, useSegments } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import * as TaskManager from "expo-task-manager";
import { Provider as JotaiProvider, useAtom } from "jotai";
import { Provider as JotaiProvider } from "jotai";
import { useEffect, useRef, useState } from "react";
import { I18nextProvider } from "react-i18next";
import { Appearance, AppState } from "react-native";
import { SystemBars } from "react-native-edge-to-edge";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
@@ -60,7 +60,9 @@ import type {
NotificationResponse,
} from "expo-notifications/build/Notifications.types";
import type { ExpoPushToken } from "expo-notifications/build/Tokens.types";
import { useAtom } from "jotai";
import { Toaster } from "sonner-native";
import { userAtom } from "@/providers/JellyfinProvider";
import { store } from "@/utils/store";
if (!Platform.isTV) {
@@ -82,19 +84,19 @@ SplashScreen.setOptions({
fade: true,
});
function redirect(notification: typeof Notifications.Notification) {
const url = notification.request.content.data?.url;
if (url) {
router.push(url);
}
}
function useNotificationObserver() {
useEffect(() => {
if (Platform.isTV) return;
let isMounted = true;
function redirect(notification: typeof Notifications.Notification) {
const url = notification.request.content.data?.url;
if (url) {
router.push(url);
}
}
Notifications.getLastNotificationResponseAsync().then(
(response: { notification: any }) => {
if (!isMounted || !response?.notification) {
@@ -104,8 +106,15 @@ function useNotificationObserver() {
},
);
const subscription = Notifications.addNotificationResponseReceivedListener(
(response: { notification: any }) => {
redirect(response.notification);
},
);
return () => {
isMounted = false;
subscription.remove();
};
}, []);
}
@@ -221,7 +230,7 @@ const queryClient = new QueryClient({
});
function Layout() {
const { settings } = useSettings();
const [settings] = useSettings(null);
const [user] = useAtom(userAtom);
const [api] = useAtom(apiAtom);
const appState = useRef(AppState.currentState);
@@ -260,15 +269,6 @@ function Layout() {
await Notifications?.setNotificationChannelAsync("default", {
name: "default",
});
// Create dedicated channel for download notifications
console.log("Setting android notification channel 'downloads'");
await Notifications?.setNotificationChannelAsync("downloads", {
name: "Downloads",
importance: Notifications.AndroidImportance.DEFAULT,
vibrationPattern: [0, 250, 250, 250],
lightColor: "#FF231F7C",
});
}
const granted = await checkAndRequestPermissions();
@@ -279,7 +279,7 @@ function Layout() {
return;
}
if (!Platform.isTV && user?.Policy?.IsAdministrator) {
if (!Platform.isTV && user && user.Policy?.IsAdministrator) {
await registerBackgroundFetchAsyncSessions();
}
@@ -308,42 +308,38 @@ function Layout() {
responseListener.current =
Notifications?.addNotificationResponseReceivedListener(
(response: NotificationResponse) => {
// redirect if internal notification
redirect(response?.notification);
// Currently the notifications supported by the plugin will send data for deep links.
const { title, data } = response.notification.request.content;
writeInfoLog(`Notification ${title} opened`, data);
writeDebugLog(
`Notification ${title} opened`,
response.notification.request.content,
);
if (data && Object.keys(data).length > 0) {
const type = (data?.type ?? "").toString().toLowerCase();
const itemId = data?.id;
let url: any;
const type = String(data?.type ?? "").toLowerCase();
const itemId = data?.id;
switch (type) {
case "movie":
url = `/(auth)/(tabs)/home/items/page?id=${itemId}`;
break;
case "episode":
// `/(auth)/(tabs)/${from}/items/page?id=${item.Id}`;
// We just clicked a notification for an individual episode.
if (itemId) {
url = `/(auth)/(tabs)/home/items/page?id=${String(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/${String(seriesId)}?seasonIndex=${String(seasonIndex)}`;
switch (type) {
case "movie":
router.push(`/(auth)/(tabs)/home/items/page?id=${itemId}`);
break;
case "episode":
// We just clicked a notification for an individual episode.
if (itemId) {
router.push(`/(auth)/(tabs)/home/items/page?id=${itemId}`);
// summarized season notification for multiple episodes. Bring them to series season
} else {
url = `/(auth)/(tabs)/home/series/${seriesId}`;
const seriesId = data.seriesId;
const seasonIndex = data.seasonIndex;
if (seasonIndex) {
router.push(
`/(auth)/(tabs)/home/series/${seriesId}?seasonIndex=${seasonIndex}`,
);
} else {
router.push(`/(auth)/(tabs)/home/series/${seriesId}`);
}
}
}
break;
}
writeInfoLog(`Notification attempting to redirect to ${url}`);
if (url) {
router.push(url);
break;
}
}
},
);
@@ -393,17 +389,12 @@ function Layout() {
appState.current.match(/inactive|background/) &&
nextAppState === "active"
) {
BackGroundDownloader.checkForExistingDownloads().catch(
(error: unknown) => {
writeErrorLog("Failed to resume background downloads", error);
},
);
BackGroundDownloader.checkForExistingDownloads();
}
});
BackGroundDownloader.checkForExistingDownloads().catch((error: unknown) => {
writeErrorLog("Failed to resume background downloads", error);
});
BackGroundDownloader.checkForExistingDownloads();
return () => {
subscription.remove();
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

View File

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

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

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

620
bun.lock

File diff suppressed because it is too large Load Diff

View File

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

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 `/(auth)/(tabs)/${string}`);
},
[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

@@ -56,7 +56,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
useNativeDriver: true,
}).start();
const getColorClasses = (color: string, focused: boolean) => {
const colorClasses = useMemo(() => {
switch (color) {
case "purple":
return focused
@@ -68,38 +68,12 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
return "bg-neutral-900";
case "transparent":
return "bg-transparent";
default:
return "bg-purple-600 border border-purple-700";
}
};
const colorClasses = useMemo(
() => getColorClasses(color, focused),
[color, focused],
);
}, [color, focused]);
const lightHapticFeedback = useHaptic("light");
const handlePress = () => {
if (!loading && !disabled && onPress) {
onPress();
lightHapticFeedback();
}
};
const getTextClasses = () => {
const baseClasses = "text-white font-bold text-base";
const disabledClass = disabled ? " text-gray-300" : "";
const rightMargin = iconRight ? " mr-2" : "";
const leftMargin = iconLeft ? " ml-2" : "";
return `${baseClasses}${disabledClass} ${textClassName}${rightMargin}${leftMargin}`;
};
const getJustifyClass = () => {
return justify === "between" ? "justify-between" : "justify-center";
};
const renderTVButton = () => (
return Platform.isTV ? (
<Pressable
className='w-full'
onPress={onPress}
@@ -119,7 +93,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.9 : 0,
shadowRadius: focused ? 18 : 0,
elevation: focused ? 12 : 0,
elevation: focused ? 12 : 0, // Android glow
}}
>
<View
@@ -131,9 +105,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
</View>
</Animated.View>
</Pressable>
);
const renderTouchButton = () => (
) : (
<TouchableOpacity
className={`
p-3 rounded-xl items-center justify-center
@@ -141,7 +113,12 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
${colorClasses}
${className}
`}
onPress={handlePress}
onPress={() => {
if (!loading && !disabled && onPress) {
onPress();
lightHapticFeedback();
}
}}
disabled={disabled || loading}
{...props}
>
@@ -151,15 +128,25 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
</View>
) : (
<View
className={`flex flex-row items-center justify-between w-full ${getJustifyClass()}`}
className={`
flex flex-row items-center justify-between w-full
${justify === "between" ? "justify-between" : "justify-center"}`}
>
{iconLeft || <View className='w-4' />}
<Text className={getTextClasses()}>{children}</Text>
{iconRight || <View className='w-4' />}
{iconLeft ? iconLeft : <View className='w-4' />}
<Text
className={`
text-white font-bold text-base
${disabled ? "text-gray-300" : ""}
${textClassName}
${iconRight ? "mr-2" : ""}
${iconLeft ? "ml-2" : ""}
`}
>
{children}
</Text>
{iconRight ? iconRight : <View className='w-4' />}
</View>
)}
</TouchableOpacity>
);
return Platform.isTV ? renderTVButton() : renderTouchButton();
};

View File

@@ -1,6 +1,6 @@
import { Feather } from "@expo/vector-icons";
import { useCallback, useEffect } from "react";
import { Platform, TouchableOpacity } from "react-native";
import { Platform } from "react-native";
import GoogleCast, {
CastButton,
CastContext,
@@ -42,22 +42,6 @@ export function Chromecast({
[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")
return (
<RoundButton

View File

@@ -61,7 +61,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [queue, _setQueue] = useAtom(queueAtom);
const { settings } = useSettings();
const [settings] = useSettings(null);
const [downloadUnwatchedOnly, setDownloadUnwatchedOnly] = useState(false);
const { processes, startBackgroundDownload, getDownloadedItems } =
@@ -225,7 +225,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
if (!mediaSource) {
console.error(`Could not get download URL for ${item.Name}`);
toast.error(
t("home.downloads.toasts.could_not_get_download_url_for_item", {
t("Could not get download URL for {{itemName}}", {
itemName: item.Name,
}),
);

View File

@@ -22,7 +22,7 @@ import { CastAndCrew } from "@/components/series/CastAndCrew";
import { CurrentSeries } from "@/components/series/CurrentSeries";
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
import { useImageColors } from "@/hooks/useImageColors";
import { useOrientation } from "@/hooks/useOrientation";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
@@ -54,14 +54,14 @@ interface ItemContentProps {
export const ItemContent: React.FC<ItemContentProps> = React.memo(
({ item, isOffline }) => {
const [api] = useAtom(apiAtom);
const { settings } = useSettings();
const [settings] = useSettings(null);
const { orientation } = useOrientation();
const navigation = useNavigation();
const insets = useSafeAreaInsets();
const [user] = useAtom(userAtom);
const { t } = useTranslation();
const itemColors = useImageColorsReturn({ item });
useImageColors({ item });
const [loadingLogo, setLoadingLogo] = useState(true);
const [headerHeight, setHeaderHeight] = useState(350);
@@ -105,27 +105,13 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
if (!Platform.isTV) {
navigation.setOptions({
headerRight: () =>
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>
) : (
item && (
<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" && (
<View className='flex flex-row items-center space-x-2'>
{!Platform.isTV && (
@@ -140,7 +126,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
</View>
)}
</View>
)),
),
});
}
}, [item, navigation, user]);
@@ -267,7 +253,6 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
selectedOptions={selectedOptions}
item={item}
isOffline={isOffline}
colors={itemColors}
/>
</View>

View File

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

View File

@@ -5,9 +5,9 @@ import { useAtom } from "jotai";
import type React from "react";
import { useTranslation } from "react-i18next";
import { View, type ViewProps } from "react-native";
import { HorizontalScroll } from "@/components/common/HorizontalScroll";
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
import { SimpleTouchableItemRouter } from "@/components/common/SimpleTouchableItemRouter";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { ItemCardText } from "@/components/ItemCardText";
import MoviePoster from "@/components/posters/MoviePoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
@@ -83,8 +83,9 @@ export const MoreMoviesWithActor: React.FC<Props> = ({
data={items}
loading={isLoading}
height={247}
estimatedItemSize={112}
renderItem={(item: BaseItemDto, idx: number) => (
<TouchableItemRouter
<SimpleTouchableItemRouter
key={idx}
item={item}
className='flex flex-col w-28'
@@ -93,7 +94,7 @@ export const MoreMoviesWithActor: React.FC<Props> = ({
<MoviePoster item={item} />
<ItemCardText item={item} />
</View>
</TouchableItemRouter>
</SimpleTouchableItemRouter>
)}
/>
</View>

View File

@@ -6,9 +6,8 @@ import { useAtom, useAtomValue } from "jotai";
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Alert, TouchableOpacity, View } from "react-native";
import {
import CastContext, {
CastButton,
CastContext,
PlayServicesState,
useMediaStatus,
useRemoteMediaClient,
@@ -24,7 +23,6 @@ import Animated, {
withTiming,
} from "react-native-reanimated";
import { useHaptic } from "@/hooks/useHaptic";
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings";
@@ -41,74 +39,15 @@ interface Props extends React.ComponentProps<typeof Button> {
item: BaseItemDto;
selectedOptions: SelectedOptions;
isOffline?: boolean;
colors?: ThemeColors;
}
const ANIMATION_DURATION = 500;
const MIN_PLAYBACK_WIDTH = 15;
// Helper function to create media metadata for Chromecast
const createMediaMetadata = (item: BaseItemDto, api: any) => {
if (item.Type === "Episode") {
return {
type: "tvShow" as const,
title: item.Name || "",
episodeNumber: item.IndexNumber || 0,
seasonNumber: item.ParentIndexNumber || 0,
seriesTitle: item.SeriesName || "",
images: [
{
url: getParentBackdropImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
};
}
if (item.Type === "Movie") {
return {
type: "movie" as const,
title: item.Name || "",
subtitle: item.Overview || "",
images: [
{
url: getPrimaryImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
};
}
return {
type: "generic" as const,
title: item.Name || "",
subtitle: item.Overview || "",
images: [
{
url: getPrimaryImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
};
};
export const PlayButton: React.FC<Props> = ({
item,
selectedOptions,
isOffline,
colors,
...props
}: Props) => {
const { showActionSheetWithOptions } = useActionSheet();
@@ -116,22 +55,19 @@ export const PlayButton: React.FC<Props> = ({
const mediaStatus = useMediaStatus();
const { t } = useTranslation();
const [globalColorAtom] = useAtom(itemThemeColorAtom);
const [colorAtom] = useAtom(itemThemeColorAtom);
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
// Use colors prop if provided, otherwise fallback to global atom
const effectiveColors = colors || globalColorAtom;
const router = useRouter();
const startWidth = useSharedValue(0);
const targetWidth = useSharedValue(0);
const endColor = useSharedValue(effectiveColors);
const startColor = useSharedValue(effectiveColors);
const endColor = useSharedValue(colorAtom);
const startColor = useSharedValue(colorAtom);
const widthProgress = useSharedValue(0);
const colorChangeProgress = useSharedValue(0);
const { settings, updateSettings } = useSettings();
const [settings, updateSettings] = useSettings(null);
const lightHapticFeedback = useHaptic("light");
const goToPlayer = useCallback(
@@ -144,86 +80,12 @@ export const PlayButton: React.FC<Props> = ({
[router, isOffline],
);
const handleChromecast = useCallback(
async (params: {
item: BaseItemDto;
api: any;
user: any;
selectedOptions: SelectedOptions;
client: any;
t: any;
settings: any;
isOpeningCurrentlyPlayingMedia: boolean;
}) => {
const {
item,
api,
user,
selectedOptions,
client,
t,
settings,
isOpeningCurrentlyPlayingMedia,
} = params;
const enableH265 = settings.enableH265ForChromecast;
if (!api) {
console.warn("API not available for Chromecast streaming");
Alert.alert(t("player.client_error"), t("player.missing_parameters"));
return;
}
if (!user?.Id) {
console.warn("User not authenticated for Chromecast streaming");
Alert.alert(t("player.client_error"), t("player.missing_parameters"));
return;
}
if (!item?.Id) {
console.warn("Item not available for Chromecast streaming");
Alert.alert(t("player.client_error"), t("player.missing_parameters"));
return;
}
try {
const data = await getStreamUrl({
api,
item,
deviceProfile: enableH265 ? chromecasth265 : chromecast,
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
userId: user.Id,
audioStreamIndex: selectedOptions.audioIndex,
maxStreamingBitrate: selectedOptions.bitrate?.value,
mediaSourceId: selectedOptions.mediaSource?.Id,
subtitleStreamIndex: selectedOptions.subtitleIndex,
});
if (!data?.url) {
console.warn("No URL returned from getStreamUrl", data);
Alert.alert(
t("player.client_error"),
t("player.could_not_create_stream_for_chromecast"),
);
return;
}
client
.loadMedia({
mediaInfo: {
contentUrl: data?.url,
contentType: "video/mp4",
metadata: createMediaMetadata(item, api),
},
startTime: 0,
})
.then(() => {
if (isOpeningCurrentlyPlayingMedia) return;
CastContext.showExpandedControls();
});
} catch (e) {
console.log(e);
}
},
[],
);
const onPress = useCallback(async () => {
console.log("onPress");
if (!item) return;
lightHapticFeedback();
const queryParams = new URLSearchParams({
itemId: item.Id!,
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
@@ -233,11 +95,14 @@ export const PlayButton: React.FC<Props> = ({
playbackPosition: item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
offline: isOffline ? "true" : "false",
});
const queryString = queryParams.toString();
if (!client) {
goToPlayer(queryString);
return;
}
const options = ["Chromecast", "Device", "Cancel"];
const cancelButtonIndex = 2;
showActionSheetWithOptions(
@@ -250,23 +115,137 @@ export const PlayButton: React.FC<Props> = ({
const currentTitle = mediaStatus?.mediaInfo?.metadata?.title;
const isOpeningCurrentlyPlayingMedia =
currentTitle && currentTitle === item?.Name;
switch (selectedIndex) {
case 0:
await CastContext.getPlayServicesState().then(async (state) => {
if (state && state !== PlayServicesState.SUCCESS) {
CastContext.showPlayServicesErrorDialog(state);
} else {
await handleChromecast({
item,
api,
user,
selectedOptions,
client,
t,
settings,
isOpeningCurrentlyPlayingMedia:
!!isOpeningCurrentlyPlayingMedia,
});
// Check if user wants H265 for Chromecast
const enableH265 = settings.enableH265ForChromecast;
// Validate required parameters before calling getStreamUrl
if (!api) {
console.warn("API not available for Chromecast streaming");
Alert.alert(
t("player.client_error"),
t("player.missing_parameters"),
);
return;
}
if (!user?.Id) {
console.warn(
"User not authenticated for Chromecast streaming",
);
Alert.alert(
t("player.client_error"),
t("player.missing_parameters"),
);
return;
}
if (!item?.Id) {
console.warn("Item not available for Chromecast streaming");
Alert.alert(
t("player.client_error"),
t("player.missing_parameters"),
);
return;
}
// Get a new URL with the Chromecast device profile
try {
const data = await getStreamUrl({
api,
item,
deviceProfile: enableH265 ? chromecasth265 : chromecast,
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
userId: user.Id,
audioStreamIndex: selectedOptions.audioIndex,
maxStreamingBitrate: selectedOptions.bitrate?.value,
mediaSourceId: selectedOptions.mediaSource?.Id,
subtitleStreamIndex: selectedOptions.subtitleIndex,
});
console.log("URL: ", data?.url, enableH265);
if (!data?.url) {
console.warn("No URL returned from getStreamUrl", data);
Alert.alert(
t("player.client_error"),
t("player.could_not_create_stream_for_chromecast"),
);
return;
}
client
.loadMedia({
mediaInfo: {
contentUrl: data?.url,
contentType: "video/mp4",
metadata:
item.Type === "Episode"
? {
type: "tvShow",
title: item.Name || "",
episodeNumber: item.IndexNumber || 0,
seasonNumber: item.ParentIndexNumber || 0,
seriesTitle: item.SeriesName || "",
images: [
{
url: getParentBackdropImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
}
: item.Type === "Movie"
? {
type: "movie",
title: item.Name || "",
subtitle: item.Overview || "",
images: [
{
url: getPrimaryImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
}
: {
type: "generic",
title: item.Name || "",
subtitle: item.Overview || "",
images: [
{
url: getPrimaryImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
},
},
startTime: 0,
})
.then(() => {
// state is already set when reopening current media, so skip it here.
if (isOpeningCurrentlyPlayingMedia) {
return;
}
CastContext.showExpandedControls();
});
} catch (e) {
console.log(e);
}
}
});
break;
@@ -288,14 +267,10 @@ export const PlayButton: React.FC<Props> = ({
showActionSheetWithOptions,
mediaStatus,
selectedOptions,
lightHapticFeedback,
goToPlayer,
isOffline,
handleChromecast,
]);
const derivedTargetWidth = useDerivedValue(() => {
if (!item?.RunTimeTicks) return 0;
if (!item || !item.RunTimeTicks) return 0;
const userData = item.UserData;
if (userData?.PlaybackPositionTicks) {
return userData.PlaybackPositionTicks > 0
@@ -322,7 +297,7 @@ export const PlayButton: React.FC<Props> = ({
);
useAnimatedReaction(
() => effectiveColors,
() => colorAtom,
(newColor) => {
endColor.value = newColor;
colorChangeProgress.value = 0;
@@ -331,19 +306,19 @@ export const PlayButton: React.FC<Props> = ({
easing: Easing.bezier(0.9, 0, 0.31, 0.99),
});
},
[effectiveColors],
[colorAtom],
);
useEffect(() => {
const timeout_2 = setTimeout(() => {
startColor.value = effectiveColors;
startColor.value = colorAtom;
startWidth.value = targetWidth.value;
}, ANIMATION_DURATION);
return () => {
clearTimeout(timeout_2);
};
}, [effectiveColors, item]);
}, [colorAtom, item]);
/**
* ANIMATED STYLES
@@ -392,7 +367,7 @@ export const PlayButton: React.FC<Props> = ({
className={"relative"}
{...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
style={[
animatedPrimaryStyle,
@@ -406,15 +381,15 @@ export const PlayButton: React.FC<Props> = ({
<Animated.View
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
style={{
borderWidth: 1,
borderColor: effectiveColors.primary,
borderColor: colorAtom.primary,
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'>
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>

View File

@@ -15,7 +15,6 @@ import Animated, {
withTiming,
} from "react-native-reanimated";
import { useHaptic } from "@/hooks/useHaptic";
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings";
import { runtimeTicksToMinutes } from "@/utils/time";
@@ -25,7 +24,6 @@ import type { SelectedOptions } from "./ItemContent";
interface Props extends React.ComponentProps<typeof Button> {
item: BaseItemDto;
selectedOptions: SelectedOptions;
colors?: ThemeColors;
}
const ANIMATION_DURATION = 500;
@@ -34,23 +32,19 @@ const MIN_PLAYBACK_WIDTH = 15;
export const PlayButton: React.FC<Props> = ({
item,
selectedOptions,
colors,
...props
}: Props) => {
const [globalColorAtom] = useAtom(itemThemeColorAtom);
// Use colors prop if provided, otherwise fallback to global atom
const effectiveColors = colors || globalColorAtom;
const [colorAtom] = useAtom(itemThemeColorAtom);
const router = useRouter();
const startWidth = useSharedValue(0);
const targetWidth = useSharedValue(0);
const endColor = useSharedValue(effectiveColors);
const startColor = useSharedValue(effectiveColors);
const endColor = useSharedValue(colorAtom);
const startColor = useSharedValue(colorAtom);
const widthProgress = useSharedValue(0);
const colorChangeProgress = useSharedValue(0);
const { settings } = useSettings();
const [settings] = useSettings(null);
const lightHapticFeedback = useHaptic("light");
const goToPlayer = useCallback(
@@ -76,10 +70,11 @@ export const PlayButton: React.FC<Props> = ({
const queryString = queryParams.toString();
goToPlayer(queryString);
return;
};
const derivedTargetWidth = useDerivedValue(() => {
if (!item?.RunTimeTicks) return 0;
if (!item || !item.RunTimeTicks) return 0;
const userData = item.UserData;
if (userData?.PlaybackPositionTicks) {
return userData.PlaybackPositionTicks > 0
@@ -106,7 +101,7 @@ export const PlayButton: React.FC<Props> = ({
);
useAnimatedReaction(
() => effectiveColors,
() => colorAtom,
(newColor) => {
endColor.value = newColor;
colorChangeProgress.value = 0;
@@ -115,19 +110,19 @@ export const PlayButton: React.FC<Props> = ({
easing: Easing.bezier(0.9, 0, 0.31, 0.99),
});
},
[effectiveColors],
[colorAtom],
);
useEffect(() => {
const timeout_2 = setTimeout(() => {
startColor.value = effectiveColors;
startColor.value = colorAtom;
startWidth.value = targetWidth.value;
}, ANIMATION_DURATION);
return () => {
clearTimeout(timeout_2);
};
}, [effectiveColors, item]);
}, [colorAtom, item]);
/**
* ANIMATED STYLES
@@ -194,7 +189,7 @@ export const PlayButton: React.FC<Props> = ({
<View
style={{
borderWidth: 1,
borderColor: effectiveColors.primary,
borderColor: colorAtom.primary,
borderStyle: "solid",
}}
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 React from "react";
import { Platform, View, type ViewProps } from "react-native";
import { View, type ViewProps } from "react-native";
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
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 toggle = useMarkAsPlayed(items);
if (Platform.OS === "ios") {
return (
<View {...props}>
<RoundButton
color={allPlayed ? "purple" : "white"}
icon={allPlayed ? "checkmark" : "checkmark"}
onPress={async () => {
await toggle(!allPlayed);
}}
size={props.size}
/>
</View>
);
}
return (
<View {...props}>
<RoundButton

View File

@@ -10,7 +10,6 @@ interface Props extends ViewProps {
background?: boolean;
size?: "default" | "large";
fillColor?: "primary";
color?: "white" | "purple";
hapticFeedback?: boolean;
}
@@ -21,7 +20,6 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
children,
size = "default",
fillColor,
color = "white",
hapticFeedback = true,
...viewProps
}) => {
@@ -36,25 +34,6 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
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)
return (
<TouchableOpacity

View File

@@ -7,9 +7,9 @@ import { useTranslation } from "react-i18next";
import { View, type ViewProps } from "react-native";
import MoviePoster from "@/components/posters/MoviePoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { HorizontalScroll } from "./common/HorizontalScroll";
import { HorizontalScroll } from "./common/HorrizontalScroll";
import { SimpleTouchableItemRouter } from "./common/SimpleTouchableItemRouter";
import { Text } from "./common/Text";
import { TouchableItemRouter } from "./common/TouchableItemRouter";
import { ItemCardText } from "./ItemCardText";
interface SimilarItemsProps extends ViewProps {
@@ -54,9 +54,10 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({
data={movies}
loading={isLoading}
height={247}
estimatedItemSize={112}
noItemsText={t("item_card.no_similar_items_found")}
renderItem={(item: BaseItemDto, idx: number) => (
<TouchableItemRouter
<SimpleTouchableItemRouter
key={idx}
item={item}
className='flex flex-col w-28'
@@ -65,7 +66,7 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({
<MoviePoster item={item} />
<ItemCardText item={item} />
</View>
</TouchableItemRouter>
</SimpleTouchableItemRouter>
)}
/>
</View>

12
components/_template.tsx Normal file
View File

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

View File

@@ -19,18 +19,6 @@ export const HeaderBackButton: React.FC<Props> = ({
}) => {
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")
return (
<TouchableOpacity

View File

@@ -45,7 +45,7 @@ export function InfiniteHorizontalScroll({
loading = false,
height = 164,
...props
}: Readonly<HorizontalScrollProps>): React.ReactElement {
}: HorizontalScrollProps): React.ReactElement {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);

View File

@@ -10,16 +10,14 @@ import {
Permission,
} from "@/utils/jellyseerr/server/lib/permissions";
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
import type {
MovieResult,
TvResult,
} from "@/utils/jellyseerr/server/models/Search";
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
import { getCurrentTab } from "@/utils/navigation";
interface Props extends TouchableOpacityProps {
result?: MovieResult | TvResult | MovieDetails | TvDetails | PersonCreditCast;
result?: MovieResult | TvResult | MovieDetails | TvDetails;
mediaTitle: string;
releaseYear: number;
canRequest: boolean;
@@ -41,8 +39,7 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
const segments = useSegments();
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
// Some segment arrays may have fewer than 3 elements; fall back to home tab.
const from = getCurrentTab(segments as string[]);
const from = segments[2] || "(home)";
const autoApprove = useMemo(() => {
return (
@@ -71,13 +68,14 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
router.push({
pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`,
// @ts-expect-error
params: {
...result,
mediaTitle,
releaseYear: releaseYear.toString(),
canRequest: canRequest ? "true" : "false",
releaseYear,
canRequest: canRequest.toString(),
posterSrc,
mediaType: mediaType.toString(),
id: result.id?.toString?.() ?? undefined,
mediaType,
},
});
}}

View File

@@ -0,0 +1,86 @@
import type {
BaseItemDto,
BaseItemPerson,
} from "@jellyfin/sdk/lib/generated-client/models";
import { useRouter, useSegments } from "expo-router";
import { type PropsWithChildren } from "react";
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
interface Props extends TouchableOpacityProps {
item: BaseItemDto;
isOffline?: boolean;
}
export const itemRouter = (
item: BaseItemDto | BaseItemPerson,
from: string,
) => {
if ("CollectionType" in item && item.CollectionType === "livetv") {
return `/(auth)/(tabs)/${from}/livetv`;
}
if (item.Type === "Series") {
return `/(auth)/(tabs)/${from}/series/${item.Id}`;
}
if (item.Type === "Person" || item.Type === "Actor") {
return `/(auth)/(tabs)/${from}/actors/${item.Id}`;
}
if (item.Type === "BoxSet") {
return `/(auth)/(tabs)/${from}/collections/${item.Id}`;
}
if (item.Type === "UserView") {
return `/(auth)/(tabs)/${from}/collections/${item.Id}`;
}
if (item.Type === "CollectionFolder") {
return `/(auth)/(tabs)/(libraries)/${item.Id}`;
}
if (item.Type === "Playlist") {
return `/(auth)/(tabs)/(libraries)/${item.Id}`;
}
return `/(auth)/(tabs)/${from}/items/page?id=${item.Id}`;
};
/**
* Simplified TouchableItemRouter that doesn't use hooks that might trigger API calls.
* Intended for use in similar items rows where we want to avoid unnecessary requests.
*/
export const SimpleTouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
item,
isOffline = false,
children,
...props
}) => {
const router = useRouter();
const segments = useSegments();
const from = segments[2] || "(home)";
if (
from === "(home)" ||
from === "(search)" ||
from === "(libraries)" ||
from === "(favorites)"
)
return (
<TouchableOpacity
onPress={() => {
let url = itemRouter(item, from);
if (isOffline) {
url += `&offline=true`;
}
// @ts-expect-error
router.push(url);
}}
{...props}
>
{children}
</TouchableOpacity>
);
return null;
};

View File

@@ -1,5 +1,5 @@
import { Platform, Text as RNText, type TextProps } from "react-native";
export function Text(props: Readonly<TextProps>) {
export function Text(props: TextProps) {
const { style, ...otherProps } = props;
if (Platform.isTV)
return (

View File

@@ -1,18 +1,23 @@
import { useActionSheet } from "@expo/react-native-action-sheet";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import type {
BaseItemDto,
BaseItemPerson,
} from "@jellyfin/sdk/lib/generated-client/models";
import { useRouter, useSegments } from "expo-router";
import { type PropsWithChildren, useCallback } from "react";
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
import { useFavorite } from "@/hooks/useFavorite";
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
import { getCurrentTab } from "@/utils/navigation";
interface Props extends TouchableOpacityProps {
item: BaseItemDto;
isOffline?: boolean;
}
export const itemRouter = (item: BaseItemDto, from: string) => {
export const itemRouter = (
item: BaseItemDto | BaseItemPerson,
from: string,
) => {
if ("CollectionType" in item && item.CollectionType === "livetv") {
return `/(auth)/(tabs)/${from}/livetv`;
}
@@ -21,8 +26,8 @@ export const itemRouter = (item: BaseItemDto, from: string) => {
return `/(auth)/(tabs)/${from}/series/${item.Id}`;
}
if (item.Type === "Person") {
return `/(auth)/(tabs)/${from}/persons/${item.Id}`;
if (item.Type === "Person" || item.Type === "Actor") {
return `/(auth)/(tabs)/${from}/actors/${item.Id}`;
}
if (item.Type === "BoxSet") {
@@ -44,9 +49,6 @@ export const itemRouter = (item: BaseItemDto, from: string) => {
return `/(auth)/(tabs)/${from}/items/page?id=${item.Id}`;
};
// Export alias for backward compatibility
export const getItemNavigation = itemRouter;
export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
item,
isOffline = false,
@@ -59,7 +61,7 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
const markAsPlayedStatus = useMarkAsPlayed([item]);
const { isFavorite, toggleFavorite } = useFavorite(item);
const from = getCurrentTab(segments as string[]);
const from = segments[2] || "(home)";
const showActionSheet = useCallback(() => {
if (
@@ -109,7 +111,8 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
if (isOffline) {
url += `&offline=true`;
}
router.push(url as `/(auth)/(tabs)/${string}`);
// @ts-expect-error
router.push(url);
}}
{...props}
>

View File

@@ -1,9 +1,27 @@
import { Ionicons } from "@expo/vector-icons";
import { useQueryClient } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useRouter } from "expo-router";
import { t } from "i18next";
import { View, type ViewProps } from "react-native";
import { useMemo } from "react";
import {
ActivityIndicator,
TouchableOpacity,
type TouchableOpacityProps,
View,
type ViewProps,
} from "react-native";
import { toast } from "sonner-native";
import { Text } from "@/components/common/Text";
import { useDownload } from "@/providers/DownloadProvider";
import { JobStatus } from "@/providers/Downloads/types";
import { DownloadCard } from "./DownloadCard";
import { storage } from "@/utils/mmkv";
import { formatTimeString } from "@/utils/time";
import { Button } from "../Button";
const bytesToMB = (bytes: number) => {
return bytes / 1024 / 1024;
};
interface ActiveDownloadsProps extends ViewProps {}
@@ -34,3 +52,163 @@ export default function ActiveDownloads({ ...props }: ActiveDownloadsProps) {
</View>
);
}
interface DownloadCardProps extends TouchableOpacityProps {
process: JobStatus;
}
const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
const { startDownload, pauseDownload, resumeDownload, removeProcess } =
useDownload();
const router = useRouter();
const queryClient = useQueryClient();
const handlePause = async (id: string) => {
try {
await pauseDownload(id);
toast.success(t("home.downloads.toasts.download_paused"));
} catch (error) {
console.error("Error pausing download:", error);
toast.error(t("home.downloads.toasts.could_not_pause_download"));
}
};
const handleResume = async (id: string) => {
try {
await resumeDownload(id);
toast.success(t("home.downloads.toasts.download_resumed"));
} catch (error) {
console.error("Error resuming download:", error);
toast.error(t("home.downloads.toasts.could_not_resume_download"));
}
};
const handleDelete = async (id: string) => {
try {
await removeProcess(id);
toast.success(t("home.downloads.toasts.download_deleted"));
queryClient.invalidateQueries({ queryKey: ["downloads"] });
} catch (error) {
console.error("Error deleting download:", error);
toast.error(t("home.downloads.toasts.could_not_delete_download"));
}
};
const eta = (p: JobStatus) => {
if (!p.speed || p.speed <= 0 || !p.estimatedTotalSizeBytes) return null;
const bytesRemaining = p.estimatedTotalSizeBytes - (p.bytesDownloaded || 0);
if (bytesRemaining <= 0) return null;
const secondsRemaining = bytesRemaining / p.speed;
return formatTimeString(secondsRemaining, "s");
};
const base64Image = useMemo(() => {
return storage.getString(process.item.Id!);
}, []);
return (
<TouchableOpacity
onPress={() => router.push(`/(auth)/items/page?id=${process.item.Id}`)}
className='relative bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden'
{...props}
>
{process.status === "downloading" && (
<View
className={`
bg-purple-600 h-1 absolute bottom-0 left-0
`}
style={{
width: process.progress
? `${Math.max(5, process.progress)}%`
: "5%",
}}
/>
)}
<View className='px-3 py-1.5 flex flex-col w-full'>
<View className='flex flex-row items-center w-full'>
{base64Image && (
<View className='w-14 aspect-[10/15] rounded-lg overflow-hidden mr-4'>
<Image
source={{
uri: `data:image/jpeg;base64,${base64Image}`,
}}
style={{
width: "100%",
height: "100%",
}}
contentFit='cover'
/>
</View>
)}
<View className='shrink mb-1'>
<Text className='text-xs opacity-50'>{process.item.Type}</Text>
<Text className='font-semibold shrink'>{process.item.Name}</Text>
<Text className='text-xs opacity-50'>
{process.item.ProductionYear}
</Text>
<View className='flex flex-row items-center space-x-2 mt-1 text-purple-600'>
{process.progress === 0 ? (
<ActivityIndicator size={"small"} color={"white"} />
) : (
<Text className='text-xs'>{process.progress.toFixed(0)}%</Text>
)}
{process.speed && process.speed > 0 && (
<Text className='text-xs'>
{bytesToMB(process.speed).toFixed(2)} MB/s
</Text>
)}
{eta(process) && (
<Text className='text-xs'>
{t("home.downloads.eta", { eta: eta(process) })}
</Text>
)}
</View>
<View className='flex flex-row items-center space-x-2 mt-1 text-purple-600'>
<Text className='text-xs capitalize'>{process.status}</Text>
</View>
</View>
<View className='ml-auto flex flex-row items-center space-x-2'>
{process.status === "downloading" && (
<TouchableOpacity
onPress={() => handlePause(process.id)}
className='p-2 rounded-full bg-yellow-600'
>
<Ionicons name='pause' size={20} color='white' />
</TouchableOpacity>
)}
{process.status === "paused" && (
<TouchableOpacity
onPress={() => handleResume(process.id)}
className='p-2 rounded-full bg-green-600'
>
<Ionicons name='play' size={20} color='white' />
</TouchableOpacity>
)}
<TouchableOpacity
onPress={() => handleDelete(process.id)}
className='p-2 rounded-full bg-red-600'
>
<Ionicons name='close' size={20} color='white' />
</TouchableOpacity>
</View>
</View>
{process.status === "completed" && (
<View className='flex flex-row mt-4 space-x-4'>
<Button
onPress={() => {
startDownload(process);
}}
className='w-full'
>
Download now
</Button>
</View>
)}
</View>
</TouchableOpacity>
);
};

View File

@@ -1,199 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import { useQueryClient } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useRouter } from "expo-router";
import { t } from "i18next";
import { useMemo } from "react";
import {
ActivityIndicator,
Platform,
TouchableOpacity,
type TouchableOpacityProps,
View,
} from "react-native";
import { toast } from "sonner-native";
import { Text } from "@/components/common/Text";
import { useDownload } from "@/providers/DownloadProvider";
import { JobStatus } from "@/providers/Downloads/types";
import { storage } from "@/utils/mmkv";
import { formatTimeString } from "@/utils/time";
import { Button } from "../Button";
const bytesToMB = (bytes: number) => {
return bytes / 1024 / 1024;
};
interface DownloadCardProps extends TouchableOpacityProps {
process: JobStatus;
}
export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
const { startDownload, pauseDownload, resumeDownload, removeProcess } =
useDownload();
const router = useRouter();
const queryClient = useQueryClient();
const handlePause = async (id: string) => {
try {
await pauseDownload(id);
toast.success(t("home.downloads.toasts.download_paused"));
} catch (error) {
console.error("Error pausing download:", error);
toast.error(t("home.downloads.toasts.could_not_pause_download"));
}
};
const handleResume = async (id: string) => {
try {
await resumeDownload(id);
toast.success(t("home.downloads.toasts.download_resumed"));
} catch (error) {
console.error("Error resuming download:", error);
toast.error(t("home.downloads.toasts.could_not_resume_download"));
}
};
const handleDelete = async (id: string) => {
try {
await removeProcess(id);
toast.success(t("home.downloads.toasts.download_deleted"));
queryClient.invalidateQueries({ queryKey: ["downloads"] });
} catch (error) {
console.error("Error deleting download:", error);
toast.error(t("home.downloads.toasts.could_not_delete_download"));
}
};
const eta = (p: JobStatus) => {
if (!p.speed || p.speed <= 0 || !p.estimatedTotalSizeBytes) return null;
const bytesRemaining = p.estimatedTotalSizeBytes - (p.bytesDownloaded || 0);
if (bytesRemaining <= 0) return null;
const secondsRemaining = bytesRemaining / p.speed;
return formatTimeString(secondsRemaining, "s");
};
const base64Image = useMemo(() => {
return storage.getString(process.item.Id!);
}, []);
// Sanitize progress to ensure it's within valid bounds
const sanitizedProgress = useMemo(() => {
if (
typeof process.progress !== "number" ||
Number.isNaN(process.progress)
) {
return 0;
}
return Math.max(0, Math.min(100, process.progress));
}, [process.progress]);
return (
<TouchableOpacity
onPress={() => router.push(`/(auth)/items/page?id=${process.item.Id}`)}
className='relative bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden'
{...props}
>
{process.status === "downloading" && (
<View
className={`
bg-purple-600 h-1 absolute bottom-0 left-0
`}
style={{
width:
sanitizedProgress > 0
? `${Math.max(5, sanitizedProgress)}%`
: "5%",
}}
/>
)}
{/* Action buttons in bottom right corner */}
<View className='absolute bottom-2 right-2 flex flex-row items-center space-x-2 z-10'>
{process.status === "downloading" && Platform.OS !== "ios" && (
<TouchableOpacity
onPress={() => handlePause(process.id)}
className='p-1'
>
<Ionicons name='pause' size={20} color='white' />
</TouchableOpacity>
)}
{process.status === "paused" && Platform.OS !== "ios" && (
<TouchableOpacity
onPress={() => handleResume(process.id)}
className='p-1'
>
<Ionicons name='play' size={20} color='white' />
</TouchableOpacity>
)}
<TouchableOpacity
onPress={() => handleDelete(process.id)}
className='p-1'
>
<Ionicons name='close' size={20} color='red' />
</TouchableOpacity>
</View>
<View className='px-3 py-1.5 flex flex-col w-full'>
<View className='flex flex-row items-center w-full'>
{base64Image && (
<View className='w-14 aspect-[10/15] rounded-lg overflow-hidden mr-4'>
<Image
source={{
uri: `data:image/jpeg;base64,${base64Image}`,
}}
style={{
width: "100%",
height: "100%",
}}
contentFit='cover'
/>
</View>
)}
<View className='shrink mb-1 flex-1'>
<Text className='text-xs opacity-50'>{process.item.Type}</Text>
<Text className='font-semibold shrink'>{process.item.Name}</Text>
<Text className='text-xs opacity-50'>
{process.item.ProductionYear}
</Text>
<View className='flex flex-row items-center space-x-2 mt-1 text-purple-600'>
{sanitizedProgress === 0 ? (
<ActivityIndicator size={"small"} color={"white"} />
) : (
<Text className='text-xs'>{sanitizedProgress.toFixed(0)}%</Text>
)}
{process.speed && process.speed > 0 && (
<Text className='text-xs'>
{bytesToMB(process.speed).toFixed(2)} MB/s
</Text>
)}
{eta(process) && (
<Text className='text-xs'>
{t("home.downloads.eta", { eta: eta(process) })}
</Text>
)}
</View>
<View className='flex flex-row items-center space-x-2 mt-1 text-purple-600'>
<Text className='text-xs capitalize'>{process.status}</Text>
</View>
</View>
</View>
{process.status === "completed" && (
<View className='flex flex-row mt-4 space-x-4'>
<Button
onPress={() => {
startDownload(process);
}}
className='w-full'
>
Download now
</Button>
</View>
)}
</View>
</TouchableOpacity>
);
};

View File

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

View File

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

View File

@@ -1,129 +0,0 @@
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
import React, { useCallback, useImperativeHandle, useRef } from "react";
import {
type StyleProp,
StyleSheet,
Text,
View,
type ViewStyle,
} from "react-native";
interface PinInputProps {
readonly length: number;
readonly value: string;
readonly onChangeText: (text: string) => void;
readonly style?: StyleProp<ViewStyle>;
readonly autoFocus?: boolean;
}
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={`pin-input-cell-${i}-${length}`}
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

@@ -18,9 +18,7 @@ const BACKDROP_DURATION = 5000;
type Render = React.ComponentType<any> | React.ReactElement | null | undefined;
const ItemSeparator = () => <View className='h-2 w-2' />;
interface ParallaxSlideShowProps<T> {
interface Props<T> {
data: T[];
images: string[];
logo?: React.ReactElement;
@@ -29,7 +27,7 @@ interface ParallaxSlideShowProps<T> {
listHeader: string;
renderItem: (item: T, index: number) => Render;
keyExtractor: (item: T) => string;
onEndReached?: (() => void) | null;
onEndReached?: (() => void) | null | undefined;
}
const ParallaxSlideShow = <T,>({
@@ -42,7 +40,7 @@ const ParallaxSlideShow = <T,>({
renderItem,
keyExtractor,
onEndReached,
}: PropsWithChildren<ParallaxSlideShowProps<T> & ViewProps>) => {
}: PropsWithChildren<Props<T> & ViewProps>) => {
const insets = useSafeAreaInsets();
const [currentIndex, setCurrentIndex] = useState(0);
@@ -68,21 +66,15 @@ const ParallaxSlideShow = <T,>({
[fadeAnim],
);
const handleAnimationComplete = useCallback(() => {
fadeAnim.setValue(0);
setCurrentIndex((prevIndex) => (prevIndex + 1) % images?.length);
}, [fadeAnim, images?.length, setCurrentIndex]);
const createSlideSequence = useCallback(() => {
return Animated.sequence([enterAnimation(), exitAnimation()]);
}, [enterAnimation, exitAnimation]);
useEffect(() => {
if (images?.length) {
enterAnimation().start();
const intervalId = setInterval(() => {
createSlideSequence().start(handleAnimationComplete);
Animated.sequence([enterAnimation(), exitAnimation()]).start(() => {
fadeAnim.setValue(0);
setCurrentIndex((prevIndex) => (prevIndex + 1) % images?.length);
});
}, BACKDROP_DURATION);
return () => {
@@ -96,8 +88,6 @@ const ParallaxSlideShow = <T,>({
exitAnimation,
setCurrentIndex,
currentIndex,
createSlideSequence,
handleAnimationComplete,
]);
return (
@@ -149,20 +139,12 @@ const ParallaxSlideShow = <T,>({
}
nestedScrollEnabled
showsVerticalScrollIndicator={false}
renderItem={({ item, index }) => {
const rendered = renderItem(item as any, index);
if (!rendered) return null;
// If the result is a component type, instantiate it
if (typeof rendered === "function") {
const Comp: any = rendered;
return <Comp />;
}
return rendered as React.ReactElement;
}}
//@ts-expect-error
renderItem={({ item, index }) => renderItem(item, index)}
keyExtractor={keyExtractor}
numColumns={3}
estimatedItemSize={214}
ItemSeparatorComponent={ItemSeparator}
ItemSeparatorComponent={() => <View className='h-2 w-2' />}
/>
</View>
</View>

View File

@@ -4,7 +4,6 @@ import { TouchableOpacity, View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import Poster from "@/components/posters/Poster";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { getCurrentTab } from "@/utils/navigation";
interface Props {
id: string;
@@ -22,8 +21,8 @@ const PersonPoster: React.FC<Props & ViewProps> = ({
}) => {
const { jellyseerrApi } = useJellyseerr();
const router = useRouter();
const segments = useSegments() as string[];
const from = getCurrentTab(segments);
const segments = useSegments();
const from = segments[2] || "(home)";
if (from === "(home)" || from === "(search)" || from === "(libraries)")
return (

View File

@@ -10,19 +10,19 @@ import {
type Network,
} from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
import type { Studio } from "@/utils/jellyseerr/src/components/Discover/StudioSlider";
import { getCurrentTab } from "@/utils/navigation";
const CompanySlide: React.FC<
{ data: Network[] | Studio[] } & SlideProps & ViewProps
> = ({ slide, data, ...props }) => {
const segments = useSegments() as string[];
const segments = useSegments();
const { jellyseerrApi } = useJellyseerr();
const from = getCurrentTab(segments);
const from = segments[2] || "(home)";
const navigate = useCallback(
({ id, image, name }: Network | Studio) =>
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 },
}),
[slide],

View File

@@ -34,34 +34,37 @@ const GenericSlideCard: React.FC<
contentFit = "contain",
...props
}) => (
<LinearGradient
colors={colors}
start={{ x: 0.5, y: 1.75 }}
end={{ x: 0.5, y: 0 }}
className='rounded-xl'
>
<View className='rounded-xl' {...props}>
<Image
key={id}
id={id}
source={url ? { uri: url } : null}
cachePolicy={"memory-disk"}
contentFit={contentFit}
style={{
aspectRatio: "4/3",
}}
/>
{title && (
<View className='absolute justify-center top-0 left-0 right-0 bottom-0 items-center'>
<Text
className='text-center font-bold'
style={textShadowStyle.shadow}
>
{title}
</Text>
</View>
)}
</View>
</LinearGradient>
<>
<LinearGradient
colors={colors}
start={{ x: 0.5, y: 1.75 }}
end={{ x: 0.5, y: 0 }}
className='rounded-xl'
>
<View className='rounded-xl' {...props}>
<Image
key={id}
id={id}
source={url ? { uri: url } : null}
cachePolicy={"memory-disk"}
contentFit={contentFit}
style={{
aspectRatio: "4/3",
}}
/>
{title && (
<View className='absolute justify-center top-0 left-0 right-0 bottom-0 items-center'>
<Text
className='text-center font-bold'
style={textShadowStyle.shadow}
>
{title}
</Text>
</View>
)}
</View>
</LinearGradient>
</>
);
export default GenericSlideCard;

View File

@@ -9,17 +9,17 @@ import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
import type { GenreSliderItem } from "@/utils/jellyseerr/server/interfaces/api/discoverInterfaces";
import { genreColorMap } from "@/utils/jellyseerr/src/components/Discover/constants";
import { getCurrentTab } from "@/utils/navigation";
const GenreSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
const segments = useSegments() as string[];
const segments = useSegments();
const { jellyseerrApi } = useJellyseerr();
const from = getCurrentTab(segments);
const from = segments[2] || "(home)";
const navigate = useCallback(
(genre: GenreSliderItem) =>
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 },
}),
[slide],

View File

@@ -11,12 +11,16 @@ import {
useJellyseerr,
} from "@/hooks/useJellyseerr";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
import type {
MovieResult,
TvResult,
} from "@/utils/jellyseerr/server/models/Search";
const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({
slide,
...props
}) => {
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
const { jellyseerrApi } = useJellyseerr();
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ["jellyseerr", "discover", slide.id],
@@ -65,9 +69,7 @@ const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({
uniqBy(
data?.pages
?.filter((p) => p?.results.length)
.flatMap((p) =>
p?.results.filter((r) => isJellyseerrMovieOrTvResult(r)),
),
.flatMap((p) => p?.results),
"id",
),
[data],
@@ -84,7 +86,12 @@ const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({
onEndReached={() => {
if (hasNextPage) fetchNextPage();
}}
renderItem={(item) => <JellyseerrPoster item={item} key={item?.id} />}
renderItem={(item) => (
<JellyseerrPoster
item={item as MovieResult | TvResult}
key={item?.id}
/>
)}
/>
)
);

View File

@@ -6,6 +6,7 @@ import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
import type { NonFunctionProperties } from "@/utils/jellyseerr/server/interfaces/api/common";
const RequestCard: React.FC<{ request: MediaRequest }> = ({ request }) => {
const { jellyseerrApi } = useJellyseerr();
@@ -66,9 +67,11 @@ const RecentRequestsSlide: React.FC<SlideProps & ViewProps> = ({
<Slide
{...props}
slide={slide}
data={requests.results as MediaRequest[]}
data={requests.results}
keyExtractor={(item) => item.id.toString()}
renderItem={(item: MediaRequest) => <RequestCard request={item} />}
renderItem={(item: NonFunctionProperties<MediaRequest>) => (
<RequestCard request={item} />
)}
/>
)
);

View File

@@ -20,7 +20,7 @@ interface Props<T> extends SlideProps {
index: number,
) => React.ComponentType<any> | React.ReactElement | null | undefined;
keyExtractor: (item: T) => string;
onEndReached?: (() => void) | null;
onEndReached?: (() => void) | null | undefined;
}
const Slide = <T,>({
@@ -41,7 +41,7 @@ const Slide = <T,>({
horizontal
contentContainerStyle={{
paddingHorizontal: 16,
...(contentContainerStyle ?? {}),
...(contentContainerStyle ? contentContainerStyle : {}),
}}
showsHorizontalScrollIndicator={false}
keyExtractor={keyExtractor}
@@ -49,16 +49,10 @@ const Slide = <T,>({
data={data}
onEndReachedThreshold={1}
onEndReached={onEndReached}
renderItem={({ item, index }) => {
if (!item) return null;
const rendered = renderItem(item, index);
if (!rendered) return null;
if (typeof rendered === "function") {
const Comp: any = rendered;
return <Comp />;
}
return rendered;
}}
//@ts-expect-error
renderItem={({ item, index }) =>
item ? renderItem(item, index) : null
}
/>
</View>
);

View File

@@ -41,7 +41,7 @@ const icons: Record<CollectionType, IconName> = {
export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { settings } = useSettings();
const [settings] = useSettings(null);
const { t } = useTranslation();

View File

@@ -34,7 +34,7 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
<TouchableOpacity
disabled={disabled}
onPress={onPress}
className={`flex flex-row items-center justify-between bg-neutral-900 min-h-[42px] py-2 pr-4 pl-4 ${
className={`flex flex-row items-center justify-between bg-neutral-900 h-11 pr-4 pl-4 ${
disabled ? "opacity-50" : ""
}`}
{...(viewProps as any)}
@@ -54,7 +54,7 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
);
return (
<View
className={`flex flex-row items-center justify-between bg-neutral-900 min-h-[42px] py-2 pr-4 pl-4 ${
className={`flex flex-row items-center justify-between bg-neutral-900 h-11 pr-4 pl-4 ${
disabled ? "opacity-50" : ""
}`}
{...viewProps}
@@ -106,10 +106,7 @@ const ListItemContent = ({
{title}
</Text>
{subtitle && (
<Text
className='text-[#9899A1] text-[12px] mt-0.5'
numberOfLines={2}
>
<Text className='text-[#9899A1] text-sm mt-0.5' numberOfLines={2}>
{subtitle}
</Text>
)}

View File

@@ -12,7 +12,7 @@ import { useAtom } from "jotai";
import { useCallback } from "react";
import { View, type ViewProps } from "react-native";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { InfiniteHorizontalScroll } from "../common/InfiniteHorizontalScroll";
import { InfiniteHorizontalScroll } from "../common/InfiniteHorrizontalScroll";
import { Text } from "../common/Text";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
import { ItemCardText } from "../ItemCardText";

View File

@@ -16,14 +16,10 @@ import JellyseerrStatusIcon from "@/components/jellyseerr/JellyseerrStatusIcon";
import { Colors } from "@/constants/Colors";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
import {
MediaStatus,
MediaType,
} from "@/utils/jellyseerr/server/constants/media";
import { MediaStatus } from "@/utils/jellyseerr/server/constants/media";
import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
import type { DownloadingItem } from "@/utils/jellyseerr/server/lib/downloadtracker";
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
import type {
MovieResult,
TvResult,
@@ -31,7 +27,7 @@ import type {
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
interface Props extends ViewProps {
item?: MovieResult | TvResult | MovieDetails | TvDetails | PersonCreditCast;
item?: MovieResult | TvResult | MovieDetails | TvDetails;
horizontal?: boolean;
showDownloadInfo?: boolean;
mediaRequest?: MediaRequest;
@@ -126,11 +122,11 @@ const JellyseerrPoster: React.FC<Props> = ({
return (
<TouchableJellyseerrRouter
result={item}
mediaTitle={title || ""}
mediaTitle={title}
releaseYear={releaseYear}
canRequest={canRequest}
posterSrc={posterSrc || ""}
mediaType={mediaType || MediaType.MOVIE}
posterSrc={posterSrc!}
mediaType={mediaType}
>
<View className={"flex flex-col mr-2 h-auto"}>
<View
@@ -194,7 +190,7 @@ const JellyseerrPoster: React.FC<Props> = ({
/>
<JellyseerrMediaIcon
className='absolute top-1 left-1'
mediaType={mediaType as "movie" | "tv"}
mediaType={mediaType}
/>
</View>
</View>

View File

@@ -10,8 +10,7 @@ import { useTranslation } from "react-i18next";
import { TouchableOpacity, View, type ViewProps } from "react-native";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getCurrentTab } from "@/utils/navigation";
import { HorizontalScroll } from "../common/HorizontalScroll";
import { HorizontalScroll } from "../common/HorrizontalScroll";
import { Text } from "../common/Text";
import { itemRouter } from "../common/TouchableItemRouter";
import Poster from "../posters/Poster";
@@ -23,23 +22,21 @@ interface Props extends ViewProps {
export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
const [api] = useAtom(apiAtom);
const segments = useSegments() as string[];
const segments = useSegments();
const { t } = useTranslation();
const from = getCurrentTab(segments);
const from = segments[2];
const destinctPeople = useMemo(() => {
const people: Record<string, BaseItemPerson> = {};
const people: BaseItemPerson[] = [];
item?.People?.forEach((person) => {
if (!person.Id) return;
const existingPerson = people[person.Id];
const existingPerson = people.find((p) => p.Id === person.Id);
if (existingPerson) {
existingPerson.Role = `${existingPerson.Role}, ${person.Role}`;
} else {
people[person.Id] = person;
people.push(person);
}
});
return Object.values(people);
return people;
}, [item?.People]);
if (!from) return null;
@@ -57,14 +54,9 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
renderItem={(i) => (
<TouchableOpacity
onPress={() => {
const url = itemRouter(
{
Id: i.Id,
Type: "Person",
},
from,
);
router.push(url as any);
const url = itemRouter(i, from);
// @ts-expect-error
router.push(url);
}}
className='flex flex-col w-28'
>

View File

@@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next";
import { TouchableOpacity, View, type ViewProps } from "react-native";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
import { HorizontalScroll } from "../common/HorizontalScroll";
import { HorizontalScroll } from "../common/HorrizontalScroll";
import { Text } from "../common/Text";
import Poster from "../posters/Poster";

View File

@@ -11,7 +11,7 @@ import { orderBy } from "lodash";
import type React from "react";
import { useCallback, useMemo, useState } from "react";
import { Alert, TouchableOpacity, View } from "react-native";
import { HorizontalScroll } from "@/components/common/HorizontalScroll";
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
import { Text } from "@/components/common/Text";
import { Tags } from "@/components/GenreTags";
import { dateOpts } from "@/components/jellyseerr/DetailFacts";

View File

@@ -1,17 +1,18 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
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 { useAtom } from "jotai";
import { useEffect, useMemo, useRef } from "react";
import { TouchableOpacity, type ViewProps } from "react-native";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
import {
HorizontalScroll,
type HorizontalScrollRef,
} from "../common/HorizontalScroll";
} from "../common/HorrizontalScroll";
import { ItemCardText } from "../ItemCardText";
interface Props extends ViewProps {
@@ -41,7 +42,11 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
return item?.SeasonId;
}, [item]);
const { data: episodes, isPending } = useQuery({
const {
data: episodes,
isLoading,
isFetching,
} = useQuery({
queryKey: ["episodes", seasonId, isOffline],
queryFn: async () => {
if (isOffline) {
@@ -56,7 +61,6 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
userId: user.Id,
seasonId: seasonId || undefined,
seriesId: item.SeriesId,
enableUserData: true,
fields: [
"ItemCounts",
"PrimaryImageAspectRatio",
@@ -70,6 +74,48 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
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(() => {
if (item?.Type === "Episode" && item.Id) {
const index = episodes?.findIndex((ep) => ep.Id === item.Id);
@@ -86,7 +132,7 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
ref={scrollRef}
data={episodes}
extraData={item}
loading={loading || isPending}
loading={loading || isLoading || isFetching}
renderItem={(_item, _idx) => (
<TouchableOpacity
key={_item.Id}

View File

@@ -1,10 +1,3 @@
const MissingDownloadIconComponent = () => (
<Ionicons name='download' size={20} color='white' />
);
const DownloadedIconComponent = () => (
<Ionicons name='download' size={20} color='#9333ea' />
);
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
@@ -18,6 +11,7 @@ import {
type SeasonIndexState,
} from "@/components/series/SeasonDropdown";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { runtimeTicksToSeconds } from "@/utils/time";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { Text } from "../common/Text";
@@ -77,10 +71,10 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
if (!season?.Id) return null;
return season.Id;
return season.Id!;
}, [seasons, seasonIndex]);
const { data: episodes, isPending } = useQuery({
const { data: episodes, isFetching } = useQuery({
queryKey: ["episodes", item.Id, selectedSeasonId],
queryFn: async () => {
if (!api || !user?.Id || !item.Id || !selectedSeasonId) {
@@ -93,7 +87,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
seasonId: selectedSeasonId,
enableUserData: true,
// Note: Including trick play is necessary to enable trick play downloads
fields: ["Overview", "Trickplay"],
fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
});
if (res.data.TotalRecordCount === 0)
@@ -104,10 +98,31 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
return res.data.Items;
},
select: (data) =>
[...(data || [])].sort(
(a, b) => (a.IndexNumber ?? 0) - (b.IndexNumber ?? 0),
),
enabled: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
});
const _queryClient = useQueryClient();
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
const [nrOfEpisodes, setNrOfEpisodes] = useState(0);
@@ -142,15 +157,19 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
title={t("item_card.download.download_season")}
className='ml-2'
items={episodes || []}
MissingDownloadIconComponent={MissingDownloadIconComponent}
DownloadedIconComponent={DownloadedIconComponent}
MissingDownloadIconComponent={() => (
<Ionicons name='download' size={20} color='white' />
)}
DownloadedIconComponent={() => (
<Ionicons name='download' size={20} color='#9333ea' />
)}
/>
<PlayedStatus items={episodes || []} />
</View>
) : null}
</View>
<View className='px-4 flex flex-col mt-4'>
{isPending ? (
{isFetching ? (
<View
style={{
minHeight: 144 * nrOfEpisodes,

View File

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

View File

@@ -12,7 +12,7 @@ interface Props extends ViewProps {}
export const AppLanguageSelector: React.FC<Props> = () => {
const isTv = Platform.isTV;
const { settings, updateSettings } = useSettings();
const [settings, updateSettings] = useSettings(null);
const { t } = useTranslation();
if (isTv) return null;

View File

@@ -17,7 +17,7 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
const isTv = Platform.isTV;
const media = useMedia();
const { pluginSettings } = useSettings();
const [_, __, pluginSettings] = useSettings(null);
const { settings, updateSettings } = media;
const cultures = media.cultures;
const { t } = useTranslation();

View File

@@ -4,7 +4,7 @@ import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
export const ChromecastSettings: React.FC = ({ ...props }) => {
const { settings, updateSettings } = useSettings();
const [settings, updateSettings] = useSettings(null);
return (
<View {...props}>
<ListGroup title={"Chromecast"}>

View File

@@ -7,7 +7,7 @@ import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
export const Dashboard = () => {
const { settings } = useSettings();
const [settings, _updateSettings] = useSettings(null);
const { sessions = [] } = useSessions({} as useSessionsProps);
const router = useRouter();

View File

@@ -7,13 +7,13 @@ import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
export default function DownloadSettings({ ...props }) {
const { settings, updateSettings, pluginSettings } = useSettings();
const [settings, updateSettings, pluginSettings] = useSettings(null);
const { t } = useTranslation();
const allDisabled = useMemo(
() =>
pluginSettings?.remuxConcurrentLimit?.locked === true &&
pluginSettings?.autoDownload?.locked === true,
pluginSettings?.autoDownload.locked === true,
[pluginSettings],
);

View File

@@ -13,7 +13,7 @@ interface Props extends ViewProps {}
export const GestureControls: React.FC<Props> = ({ ...props }) => {
const { t } = useTranslation();
const { settings, updateSettings, pluginSettings } = useSettings();
const [settings, updateSettings, pluginSettings] = useSettings(null);
const disabled = useMemo(
() =>

View File

@@ -1,28 +1,7 @@
function renderHeaderLeft(hasDownloads: boolean, onPress: () => void) {
return (
<DownloadsHeaderButton hasDownloads={hasDownloads} onPress={onPress} />
);
}
// ...imports...
const DownloadsHeaderButton: React.FC<{
hasDownloads: boolean;
onPress: () => void;
}> = ({ hasDownloads, onPress }) => (
<TouchableOpacity onPress={onPress} className='p-2'>
<Feather
name='download'
color={hasDownloads ? Colors.primary : "white"}
size={22}
/>
</TouchableOpacity>
);
import { Feather, Ionicons } from "@expo/vector-icons";
import type { Api } from "@jellyfin/sdk";
import type {
BaseItemDto,
BaseItemDtoQueryResult,
BaseItemKind,
} from "@jellyfin/sdk/lib/generated-client/models";
import {
@@ -32,6 +11,7 @@ import {
getUserLibraryApi,
getUserViewsApi,
} from "@jellyfin/sdk/lib/utils/api";
import NetInfo from "@react-native-community/netinfo";
import { type QueryFunction, useQuery } from "@tanstack/react-query";
import { useNavigation, useRouter, useSegments } from "expo-router";
import { useAtomValue } from "jotai";
@@ -48,17 +28,16 @@ import {
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel";
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
import { Loader } from "@/components/Loader";
import { MediaListSection } from "@/components/medialists/MediaListSection";
import { Colors } from "@/constants/Colors";
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus";
import { AppleTVCarousel } from "../AppleTVCarousel";
type ScrollingCollectionListSection = {
type: "ScrollingCollectionList";
@@ -85,7 +64,16 @@ export const HomeIndex = () => {
const user = useAtomValue(userAtom);
const [loading, setLoading] = useState(false);
const { settings, refreshStreamyfinPluginSettings } = useSettings();
const [
settings,
_updateSettings,
_pluginSettings,
_setPluginSettings,
refreshStreamyfinPluginSettings,
] = useSettings(null);
const [isConnected, setIsConnected] = useState<boolean | null>(null);
const [loadingRetry, setLoadingRetry] = useState(false);
const navigation = useNavigation();
@@ -95,12 +83,6 @@ export const HomeIndex = () => {
const { getDownloadedItems, cleanCacheDirectory } = useDownload();
const prevIsConnected = useRef<boolean | null>(false);
const {
isConnected,
serverConnected,
loading: retryLoading,
retryCheck,
} = useNetworkStatus();
const invalidateCache = useInvalidatePlaybackProgressCache();
useEffect(() => {
// Only invalidate cache when transitioning from offline to online
@@ -120,8 +102,20 @@ export const HomeIndex = () => {
}
const hasDownloads = getDownloadedItems().length > 0;
navigation.setOptions({
headerLeft: () =>
renderHeaderLeft(hasDownloads, () => router.push("/(auth)/downloads")),
headerLeft: () => (
<TouchableOpacity
onPress={() => {
router.push("/(auth)/downloads");
}}
className='p-2'
>
<Feather
name='download'
color={hasDownloads ? Colors.primary : "white"}
size={22}
/>
</TouchableOpacity>
),
});
}, [navigation, router]);
@@ -131,14 +125,11 @@ export const HomeIndex = () => {
);
}, []);
const segments = useSegments() as string[];
const segments = useSegments();
useEffect(() => {
const unsubscribe = eventBus.on("scrollToTop", () => {
if ((segments as string[])[2] === "(home)")
scrollViewRef.current?.scrollTo({
y: Platform.isTV ? -152 : -100,
animated: true,
});
if (segments[2] === "(home)")
scrollViewRef.current?.scrollTo({ y: -152, animated: true });
});
return () => {
@@ -146,6 +137,29 @@ export const HomeIndex = () => {
};
}, [segments]);
const checkConnection = useCallback(async () => {
setLoadingRetry(true);
const state = await NetInfo.fetch();
setIsConnected(state.isConnected);
setLoadingRetry(false);
}, []);
useEffect(() => {
const unsubscribe = NetInfo.addEventListener((state) => {
if (state.isConnected === false || state.isInternetReachable === false)
setIsConnected(false);
else setIsConnected(true);
});
NetInfo.fetch().then((state) => {
setIsConnected(state.isConnected);
});
return () => {
unsubscribe();
};
}, []);
const {
data,
isError: e1,
@@ -204,9 +218,9 @@ export const HomeIndex = () => {
await getUserLibraryApi(api).getLatestMedia({
userId: user?.Id,
limit: 20,
fields: ["PrimaryImageAspectRatio", "Path", "Genres"],
fields: ["PrimaryImageAspectRatio", "Path"],
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
includeItemTypes,
parentId,
})
@@ -248,9 +262,8 @@ export const HomeIndex = () => {
(
await getItemsApi(api).getResumeItems({
userId: user.Id,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
includeItemTypes: ["Movie", "Series", "Episode"],
fields: ["Genres"],
})
).data.Items || [],
type: "ScrollingCollectionList",
@@ -263,9 +276,9 @@ export const HomeIndex = () => {
(
await getTvShowsApi(api).getNextUp({
userId: user?.Id,
fields: ["MediaSourceCount", "Genres"],
fields: ["MediaSourceCount"],
limit: 20,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
enableResumable: false,
})
).data.Items || [],
@@ -326,9 +339,9 @@ export const HomeIndex = () => {
if (!api || !user?.Id || !settings?.home?.sections) return [];
const ss: Section[] = [];
for (const [index, section] of settings.home.sections.entries()) {
const id = `section-${index}`;
const id = section.items?.title || `section-${index}`;
ss.push({
title: t(`${id}`),
title: id,
queryKey: ["home", id],
queryFn: async () => {
if (section.items) {
@@ -347,9 +360,9 @@ export const HomeIndex = () => {
if (section.nextUp) {
const response = await getTvShowsApi(api).getNextUp({
userId: user?.Id,
fields: ["MediaSourceCount", "Genres"],
fields: ["MediaSourceCount"],
limit: section.nextUp?.limit || 25,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
enableResumable: section.nextUp?.enableResumable,
enableRewatching: section.nextUp?.enableRewatching,
});
@@ -365,16 +378,6 @@ export const HomeIndex = () => {
});
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 [];
},
type: "ScrollingCollectionList",
@@ -386,57 +389,41 @@ export const HomeIndex = () => {
const sections = settings?.home?.sections ? customSections : defaultSections;
if (!isConnected || serverConnected !== true) {
let title: string = "";
let subtitle: string = "";
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");
}
if (isConnected === false) {
return (
<View className='flex flex-col items-center justify-center h-full -mt-6 px-8'>
<Text className='text-3xl font-bold mb-2'>{title}</Text>
<Text className='text-center opacity-70'>{subtitle}</Text>
<Text className='text-3xl font-bold mb-2'>{t("home.no_internet")}</Text>
<Text className='text-center opacity-70'>
{t("home.no_internet_message")}
</Text>
<View className='mt-4'>
{!Platform.isTV && (
<Button
color='purple'
onPress={() => router.push("/(auth)/downloads")}
justify='center'
iconRight={
<Ionicons name='arrow-forward' size={20} color='white' />
}
>
{t("home.go_to_downloads")}
</Button>
)}
<Button
color='purple'
onPress={() => router.push("/(auth)/downloads")}
justify='center'
iconRight={
<Ionicons name='arrow-forward' size={20} color='white' />
}
>
{t("home.go_to_downloads")}
</Button>
<Button
color='black'
onPress={retryCheck}
onPress={() => {
checkConnection();
}}
justify='center'
className='mt-2'
iconRight={
retryLoading ? null : (
loadingRetry ? null : (
<Ionicons name='refresh' size={20} color='white' />
)
}
>
{retryLoading ? (
<ActivityIndicator size='small' color='white' />
{loadingRetry ? (
<ActivityIndicator size={"small"} color={"white"} />
) : (
t("home.retry")
"Retry"
)}
</Button>
</View>
@@ -466,55 +453,44 @@ export const HomeIndex = () => {
scrollToOverflowEnabled={true}
ref={scrollViewRef}
nestedScrollEnabled
contentInsetAdjustmentBehavior='never'
contentInsetAdjustmentBehavior='automatic'
refreshControl={
<RefreshControl
refreshing={loading}
onRefresh={refetch}
tintColor='white' // For iOS
colors={["white"]} // For Android
progressViewOffset={200} // This offsets the refresh indicator to appear over the carousel
/>
<RefreshControl refreshing={loading} onRefresh={refetch} />
}
style={{ marginTop: Platform.isTV ? 0 : -100 }}
contentContainerStyle={{ paddingTop: Platform.isTV ? 0 : 100 }}
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 16,
}}
>
<AppleTVCarousel initialIndex={0} />
<View
style={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 16,
}}
>
<View className='flex flex-col space-y-4'>
{sections.map((section, index) => {
if (section.type === "ScrollingCollectionList") {
return (
<ScrollingCollectionList
key={index}
title={section.title}
queryKey={section.queryKey}
queryFn={section.queryFn}
orientation={section.orientation}
hideIfEmpty
/>
);
}
if (section.type === "MediaListSection") {
return (
<MediaListSection
key={index}
queryKey={section.queryKey}
queryFn={section.queryFn}
/>
);
}
return null;
})}
</View>
<View className='flex flex-col space-y-4'>
<LargeMovieCarousel />
{sections.map((section, index) => {
if (section.type === "ScrollingCollectionList") {
return (
<ScrollingCollectionList
key={index}
title={section.title}
queryKey={section.queryKey}
queryFn={section.queryFn}
orientation={section.orientation}
hideIfEmpty
/>
);
}
if (section.type === "MediaListSection") {
return (
<MediaListSection
key={index}
queryKey={section.queryKey}
queryFn={section.queryFn}
/>
);
}
return null;
})}
</View>
<View className='h-24' />
</ScrollView>
);
};

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