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
140 changed files with 1735 additions and 6676 deletions

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

@@ -32,7 +32,7 @@ jobs:
bun-version: latest
- 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@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7
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@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7
uses: github/codeql-action/autobuild@2d92b76c45b91eb80fc44c74ce3fce0ee94e8f9d # v3.30.0
- name: 🧪 Perform CodeQL Analysis
uses: github/codeql-action/analyze@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7
uses: github/codeql-action/analyze@2d92b76c45b91eb80fc44c74ce3fce0ee94e8f9d # v3.30.0

View File

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

View File

@@ -57,7 +57,7 @@ jobs:
fetch-depth: 0
- name: Dependency Review
uses: actions/dependency-review-action@56339e523c0409420f6c2c9a2f4292bbb3c07dd3 # v4.8.0
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@a0853c24544627f65ddf259abe73b1d18a591444 # v5.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@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: '24.x'
cache: 'npm'

72
.gitignore vendored
View File

@@ -1,16 +1,27 @@
# Dependencies and Package Managers
node_modules/
bun.lock
bun.lockb
package-lock.json
# Expo and React Native Build Artifacts
.expo/
dist/
npm-debug.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
*.orig.*
web-build/
.tsbuildinfo
modules/vlc-player/android/build
# macOS
.DS_Store
expo-env.d.ts
Streamyfin.app
*.mp4
Streamyfin.app
package-lock.json
# Platform-specific Build Directories
/ios
/android
/iostv
@@ -18,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
]
}

176
.vscode/settings.json vendored
View File

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

View File

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

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,10 +49,10 @@
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
"android.permission.WRITE_SETTINGS"
],
"blockedPermissions": ["android.permission.ACTIVITY_RECOGNITION"],
"googleServicesFile": "./google-services.json"
},
"plugins": [
"@react-native-tvos/config-tv",
"expo-router",
"expo-font",
[
@@ -121,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

@@ -90,19 +90,6 @@ export default function page() {
}
}, [downloadedFiles]);
const otherMedia = useMemo(() => {
try {
return (
downloadedFiles?.filter(
(f) => f.item.Type !== "Movie" && f.item.Type !== "Episode",
) || []
);
} catch {
setShowMigration(true);
return [];
}
}, [downloadedFiles]);
useEffect(() => {
navigation.setOptions({
headerRight: () => (
@@ -141,30 +128,8 @@ export default function page() {
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 (
<>
@@ -273,34 +238,6 @@ export default function page() {
</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'>
@@ -336,11 +273,6 @@ export default function page() {
<Button color='purple' onPress={deleteShows}>
{t("home.downloads.delete_all_tvseries_button")}
</Button>
{otherMedia.length > 0 && (
<Button color='purple' onPress={deleteOtherMedia}>
{t("home.downloads.delete_all_other_media_button")}
</Button>
)}
<Button color='red' onPress={deleteAllMedia}>
{t("home.downloads.delete_all_button")}
</Button>

View 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

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

@@ -139,15 +139,7 @@ const Page: React.FC = () => {
}
requestMedia(mediaTitle, body, refetch);
}, [
details,
result,
requestMedia,
hasAdvancedRequestPermission,
mediaTitle,
refetch,
mediaType,
]);
}, [details, result, requestMedia, hasAdvancedRequestPermission]);
const isAnime = useMemo(
() =>
@@ -285,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' />
@@ -304,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>
)

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

@@ -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";
@@ -104,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();
@@ -271,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();
@@ -389,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,
},
);
},
[
@@ -496,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();
@@ -507,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();
@@ -563,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)}`);
}
@@ -761,6 +748,7 @@ export default function page() {
setAspectRatio={setAspectRatio}
setScaleFactor={setScaleFactor}
isVlc
api={api}
downloadedFiles={downloadedFiles}
/>
)}

View File

@@ -20,8 +20,8 @@ import {
} from "@/utils/background-tasks";
import {
LogProvider,
writeDebugLog,
writeErrorLog,
writeInfoLog,
writeToLog,
} from "@/utils/log";
import { storage } from "@/utils/mmkv";
@@ -84,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) {
@@ -106,8 +106,15 @@ function useNotificationObserver() {
},
);
const subscription = Notifications.addNotificationResponseReceivedListener(
(response: { notification: any }) => {
redirect(response.notification);
},
);
return () => {
isMounted = false;
subscription.remove();
};
}, []);
}
@@ -223,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);
@@ -262,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();
@@ -310,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 = (data?.type ?? "").toString().toLowerCase();
const itemId = data?.id;
switch (type) {
case "movie":
url = `/(auth)/(tabs)/home/items/page?id=${itemId}`;
break;
case "episode":
// `/(auth)/(tabs)/${from}/items/page?id=${item.Id}`;
// We just clicked a notification for an individual episode.
if (itemId) {
url = `/(auth)/(tabs)/home/items/page?id=${itemId}`;
// summarized season notification for multiple episodes. Bring them to series season
} else {
const seriesId = data.seriesId;
const seasonIndex = data.seasonIndex;
if (seasonIndex) {
url = `/(auth)/(tabs)/home/series/${seriesId}?seasonIndex=${seasonIndex}`;
switch (type) {
case "movie":
router.push(`/(auth)/(tabs)/home/items/page?id=${itemId}`);
break;
case "episode":
// We just clicked a notification for an individual episode.
if (itemId) {
router.push(`/(auth)/(tabs)/home/items/page?id=${itemId}`);
// summarized season notification for multiple episodes. Bring them to series season
} else {
url = `/(auth)/(tabs)/home/series/${seriesId}`;
const seriesId = data.seriesId;
const seasonIndex = data.seasonIndex;
if (seasonIndex) {
router.push(
`/(auth)/(tabs)/home/series/${seriesId}?seasonIndex=${seasonIndex}`,
);
} else {
router.push(`/(auth)/(tabs)/home/series/${seriesId}`);
}
}
}
break;
}
writeInfoLog(`Notification attempting to redirect to ${url}`);
if (url) {
router.push(url);
break;
}
}
},
);
@@ -395,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();
};

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.5/schema.json",
"$schema": "https://biomejs.dev/schemas/2.2.2/schema.json",
"files": {
"includes": [
"**/*",

615
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 any);
},
[router],
);
const panGesture = Gesture.Pan()
.activeOffsetX([-PAN_ACTIVE_OFFSET, PAN_ACTIVE_OFFSET])
.onUpdate((event) => {
translateX.value = -currentIndex * screenWidth + event.translationX;
})
.onEnd((event) => {
const velocity = event.velocityX;
const translation = event.translationX;
let newIndex = currentIndex;
// Improved thresholds for more responsive navigation
if (
Math.abs(translation) > screenWidth * TRANSLATION_THRESHOLD ||
Math.abs(velocity) > VELOCITY_THRESHOLD
) {
if (translation > 0 && currentIndex > 0) {
newIndex = currentIndex - 1;
} else if (
translation < 0 &&
items &&
currentIndex < items.length - 1
) {
newIndex = currentIndex + 1;
}
}
runOnJS(goToIndex)(newIndex);
});
const containerAnimatedStyle = useAnimatedStyle(() => {
return {
transform: [{ translateX: translateX.value }],
};
});
const renderDots = () => {
if (!hasItems || items.length <= 1) return null;
return (
<View
style={{
position: "absolute",
bottom: DOTS_BOTTOM_POSITION,
left: 0,
right: 0,
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
gap: DOT_GAP,
}}
>
{items.map((_, index) => (
<DotIndicator
key={index}
index={index}
currentIndex={currentIndex}
onPress={goToIndex}
/>
))}
</View>
);
};
const renderSkeletonLoader = () => {
return (
<View
style={{
width: screenWidth,
height: CAROUSEL_HEIGHT,
backgroundColor: "#000",
}}
>
{/* Background Skeleton */}
<View
style={{
width: "100%",
height: "100%",
backgroundColor: SKELETON_BACKGROUND_COLOR,
position: "absolute",
}}
/>
{/* Dark Overlay Skeleton */}
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: `rgba(0, 0, 0, ${OVERLAY_OPACITY})`,
}}
/>
{/* Gradient Fade to Black Top Skeleton */}
<LinearGradient
colors={["rgba(0,0,0,1)", "rgba(0,0,0,0.8)", "transparent"]}
style={{
position: "absolute",
left: 0,
right: 0,
top: 0,
height: GRADIENT_HEIGHT_TOP,
}}
/>
{/* Gradient Fade to Black Bottom Skeleton */}
<LinearGradient
colors={["transparent", "rgba(0,0,0,0.8)", "rgba(0,0,0,1)"]}
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
height: GRADIENT_HEIGHT_BOTTOM,
}}
/>
{/* Logo Skeleton */}
<View
style={{
position: "absolute",
bottom: LOGO_BOTTOM_POSITION,
left: 0,
right: 0,
paddingHorizontal: HORIZONTAL_PADDING,
alignItems: "center",
}}
>
<View
style={{
height: LOGO_HEIGHT,
width: LOGO_WIDTH_PERCENTAGE,
backgroundColor: SKELETON_ELEMENT_COLOR,
borderRadius: LOGO_SKELETON_BORDER_RADIUS,
}}
/>
</View>
{/* Type and Genres Skeleton */}
<View
style={{
position: "absolute",
bottom: GENRES_BOTTOM_POSITION,
left: 0,
right: 0,
paddingHorizontal: HORIZONTAL_PADDING,
alignItems: "center",
}}
>
<View
style={{
height: TEXT_SKELETON_HEIGHT,
width: TEXT_SKELETON_WIDTH,
backgroundColor: SKELETON_ELEMENT_COLOR,
borderRadius: TEXT_SKELETON_BORDER_RADIUS,
}}
/>
</View>
{/* Controls Skeleton */}
<View
style={{
position: "absolute",
bottom: CONTROLS_BOTTOM_POSITION,
left: 0,
right: 0,
paddingHorizontal: HORIZONTAL_PADDING,
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
gap: CONTROLS_GAP,
}}
>
{/* Play Button Skeleton */}
<View
style={{
height: PLAY_BUTTON_SKELETON_HEIGHT,
flex: 1,
maxWidth: MAX_BUTTON_WIDTH,
backgroundColor: SKELETON_ELEMENT_COLOR,
borderRadius: PLAY_BUTTON_BORDER_RADIUS,
}}
/>
{/* Played Status Skeleton */}
<View
style={{
width: PLAYED_STATUS_SKELETON_SIZE,
height: PLAYED_STATUS_SKELETON_SIZE,
backgroundColor: SKELETON_ELEMENT_COLOR,
borderRadius: PLAYED_STATUS_BORDER_RADIUS,
}}
/>
</View>
{/* Dots Skeleton */}
<View
style={{
position: "absolute",
bottom: DOTS_BOTTOM_POSITION,
left: 0,
right: 0,
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
gap: DOT_GAP,
}}
>
{[1, 2, 3].map((_, index) => (
<View
key={index}
style={{
width: index === 0 ? DOT_ACTIVE_WIDTH : DOT_INACTIVE_WIDTH,
height: DOT_HEIGHT,
backgroundColor:
index === 0
? SKELETON_ACTIVE_DOT_COLOR
: SKELETON_ELEMENT_COLOR,
borderRadius: DOT_BORDER_RADIUS,
}}
/>
))}
</View>
</View>
);
};
const renderItem = (item: BaseItemDto, _index: number) => {
const itemLogoUrl = api ? getLogoImageUrlById({ api, item }) : null;
return (
<View
key={item.Id}
style={{
width: screenWidth,
height: CAROUSEL_HEIGHT,
position: "relative",
}}
>
{/* Background Backdrop */}
<ItemImage
item={item}
variant='Backdrop'
style={{
width: "100%",
height: "100%",
position: "absolute",
}}
/>
{/* Dark Overlay */}
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: `rgba(0, 0, 0, ${OVERLAY_OPACITY})`,
}}
/>
{/* Gradient Fade to Black at Top */}
<LinearGradient
colors={["rgba(0,0,0,1)", "rgba(0,0,0,0.2)", "transparent"]}
style={{
position: "absolute",
left: 0,
right: 0,
top: 0,
height: GRADIENT_HEIGHT_TOP,
}}
/>
{/* Gradient Fade to Black at Bottom */}
<LinearGradient
colors={["transparent", "rgba(0,0,0,0.8)", "rgba(0,0,0,1)"]}
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
height: GRADIENT_HEIGHT_BOTTOM,
}}
/>
{/* Logo Section */}
{itemLogoUrl && (
<TouchableOpacity
onPress={() => navigateToItem(item)}
style={{
position: "absolute",
bottom: LOGO_BOTTOM_POSITION,
left: 0,
right: 0,
paddingHorizontal: HORIZONTAL_PADDING,
alignItems: "center",
}}
>
<Image
source={{
uri: itemLogoUrl,
}}
style={{
height: LOGO_HEIGHT,
width: LOGO_WIDTH_PERCENTAGE,
}}
contentFit='contain'
/>
</TouchableOpacity>
)}
{/* Type and Genres Section */}
<View
style={{
position: "absolute",
bottom: GENRES_BOTTOM_POSITION,
left: 0,
right: 0,
paddingHorizontal: HORIZONTAL_PADDING,
alignItems: "center",
}}
>
<TouchableOpacity onPress={() => navigateToItem(item)}>
<Animated.Text
style={{
color: `rgba(255, 255, 255, ${TEXT_OPACITY})`,
fontSize: GENRES_FONT_SIZE,
fontWeight: "500",
textAlign: "center",
textShadowColor: TEXT_SHADOW_COLOR,
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: TEXT_SHADOW_RADIUS,
}}
>
{(() => {
let typeLabel = "";
if (item.Type === "Episode") {
// For episodes, show season and episode number
const season = item.ParentIndexNumber;
const episode = item.IndexNumber;
if (season && episode) {
typeLabel = `S${season} • E${episode}`;
} else {
typeLabel = "Episode";
}
} else {
typeLabel =
item.Type === "Series"
? "TV Show"
: item.Type === "Movie"
? "Movie"
: item.Type || "";
}
const genres =
item.Genres && item.Genres.length > 0
? item.Genres.slice(0, MAX_GENRES_COUNT).join(" • ")
: "";
if (typeLabel && genres) {
return `${typeLabel}${genres}`;
} else if (typeLabel) {
return typeLabel;
} else if (genres) {
return genres;
} else {
return "";
}
})()}
</Animated.Text>
</TouchableOpacity>
</View>
{/* Controls Section */}
<View
style={{
position: "absolute",
bottom: CONTROLS_BOTTOM_POSITION,
left: 0,
right: 0,
paddingHorizontal: HORIZONTAL_PADDING,
}}
>
<View
style={{
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
gap: CONTROLS_GAP,
}}
>
{/* Play Button */}
<View style={{ flex: 1, maxWidth: MAX_BUTTON_WIDTH }}>
{selectedOptions && (
<PlayButton
item={item}
selectedOptions={selectedOptions}
colors={currentItemColors}
/>
)}
</View>
{/* Mark as Played */}
<PlayedStatus items={[item]} size='large' />
</View>
</View>
</View>
);
};
// Handle loading state
if (isLoading) {
return (
<View
style={{
height: CAROUSEL_HEIGHT,
backgroundColor: "#000",
overflow: "hidden",
}}
>
{renderSkeletonLoader()}
</View>
);
}
// Handle empty items
if (!hasItems) {
return null;
}
return (
<View
style={{
height: CAROUSEL_HEIGHT, // Fixed height instead of flex: 1
backgroundColor: "#000",
overflow: "hidden",
}}
>
<GestureDetector gesture={panGesture}>
<Animated.View
style={[
{
height: CAROUSEL_HEIGHT, // Fixed height instead of flex: 1
flexDirection: "row",
width: screenWidth * items.length,
},
containerAnimatedStyle,
]}
>
{items.map((item, index) => renderItem(item, index))}
</Animated.View>
</GestureDetector>
{/* Animated Dots Indicator */}
{renderDots()}
</View>
);
};

View File

@@ -1,6 +1,6 @@
import { Feather } from "@expo/vector-icons";
import { 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

@@ -23,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";
@@ -40,7 +39,6 @@ interface Props extends React.ComponentProps<typeof Button> {
item: BaseItemDto;
selectedOptions: SelectedOptions;
isOffline?: boolean;
colors?: ThemeColors;
}
const ANIMATION_DURATION = 500;
@@ -50,7 +48,6 @@ export const PlayButton: React.FC<Props> = ({
item,
selectedOptions,
isOffline,
colors,
...props
}: Props) => {
const { showActionSheetWithOptions } = useActionSheet();
@@ -58,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(
@@ -303,7 +297,7 @@ export const PlayButton: React.FC<Props> = ({
);
useAnimatedReaction(
() => effectiveColors,
() => colorAtom,
(newColor) => {
endColor.value = newColor;
colorChangeProgress.value = 0;
@@ -312,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
@@ -373,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,
@@ -387,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(
@@ -107,7 +101,7 @@ export const PlayButton: React.FC<Props> = ({
);
useAnimatedReaction(
() => effectiveColors,
() => colorAtom,
(newColor) => {
endColor.value = newColor;
colorChangeProgress.value = 0;
@@ -116,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
@@ -195,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

@@ -10,7 +10,6 @@ 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,
@@ -18,7 +17,7 @@ import type {
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
interface Props extends TouchableOpacityProps {
result?: MovieResult | TvResult | MovieDetails | TvDetails | PersonCreditCast;
result?: MovieResult | TvResult | MovieDetails | TvDetails;
mediaTitle: string;
releaseYear: number;
canRequest: boolean;
@@ -40,7 +39,7 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
const segments = useSegments();
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
const from = (segments as string[])[2] || "(home)";
const from = segments[2] || "(home)";
const autoApprove = useMemo(() => {
return (

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,8 @@
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";
@@ -11,7 +14,10 @@ interface Props extends TouchableOpacityProps {
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`;
}
@@ -20,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") {
@@ -43,48 +49,6 @@ export const itemRouter = (item: BaseItemDto, from: string) => {
return `/(auth)/(tabs)/${from}/items/page?id=${item.Id}`;
};
export const getItemNavigation = (item: BaseItemDto, _from: string) => {
if ("CollectionType" in item && item.CollectionType === "livetv") {
return {
pathname: "/livetv" as const,
};
}
if (item.Type === "Series") {
return {
pathname: "/series/[id]" as const,
params: { id: item.Id! },
};
}
if (item.Type === "Person") {
return {
pathname: "/persons/[personId]" as const,
params: { personId: item.Id! },
};
}
if (item.Type === "BoxSet" || item.Type === "UserView") {
return {
pathname: "/collections/[collectionId]" as const,
params: { collectionId: item.Id! },
};
}
if (item.Type === "CollectionFolder" || item.Type === "Playlist") {
return {
pathname: "/[libraryId]" as const,
params: { libraryId: item.Id! },
};
}
// Default case - items page
return {
pathname: "/items/page" as const,
params: { id: item.Id! },
};
};
export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
item,
isOffline = false,
@@ -97,7 +61,7 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
const markAsPlayedStatus = useMarkAsPlayed([item]);
const { isFavorite, toggleFavorite } = useFavorite(item);
const from = (segments as string[])[2] || "(home)";
const from = segments[2] || "(home)";
const showActionSheet = useCallback(() => {
if (
@@ -143,15 +107,12 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
<TouchableOpacity
onLongPress={showActionSheet}
onPress={() => {
let url = itemRouter(item, from);
if (isOffline) {
// For offline mode, we still need to use query params
const url = `${itemRouter(item, from)}&offline=true`;
router.push(url as any);
return;
url += `&offline=true`;
}
const navigation = getItemNavigation(item, from);
router.push(navigation as any);
// @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,12 +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 { getItemNavigation } from "../common/TouchableItemRouter";
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);
@@ -88,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,20 +146,20 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
}, [item]);
const segments = useSegments();
const from = (segments as string[])[2] || "(home)";
const from = segments[2] || "(home)";
const opacity = useSharedValue(1);
const handleRoute = useCallback(() => {
if (!from) return;
const url = itemRouter(item, from);
lightHapticFeedback();
const navigation = getItemNavigation(item, from);
router.push(navigation 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 });
})
@@ -176,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,131 +0,0 @@
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
import React, { useCallback, useImperativeHandle, useRef } from "react";
import {
type StyleProp,
StyleSheet,
Text,
type TextInputProps,
View,
type ViewStyle,
} from "react-native";
interface PinInputProps
extends Omit<TextInputProps, "value" | "onChangeText" | "style"> {
value: string;
onChangeText: (text: string) => void;
length?: number;
autoFocus?: boolean;
style?: StyleProp<ViewStyle>;
}
export interface PinInputRef {
focus: () => void;
}
const PinInputComponent = React.forwardRef<PinInputRef, PinInputProps>(
(props, ref) => {
const {
value,
onChangeText,
length = 6,
style,
autoFocus,
...rest
} = props;
const inputRef = useRef<any>(null);
const activeIndex = value.length;
const handlePress = useCallback(() => {
inputRef.current?.focus();
}, []);
useImperativeHandle(
ref,
() => ({
focus: () => inputRef.current?.focus(),
}),
[],
);
return (
<View style={[styles.container, style]}>
<BottomSheetTextInput
ref={inputRef}
value={value}
onChangeText={onChangeText}
keyboardType='number-pad'
maxLength={length}
style={styles.hiddenInput}
autoFocus={autoFocus}
{...rest}
/>
<View style={styles.cells} onTouchStart={handlePress}>
{Array(length)
.fill(0)
.map((_, i) => (
<View
key={i}
style={[
styles.cell,
i === activeIndex && styles.activeCell,
i === activeIndex - 1 && styles.filledCell,
]}
>
<Text style={styles.digit}>{value[i]}</Text>
{i === activeIndex && <View style={styles.cursor} />}
</View>
))}
</View>
</View>
);
},
);
PinInputComponent.displayName = "PinInput";
export const PinInput = PinInputComponent;
const styles = StyleSheet.create({
container: {
width: "100%",
},
hiddenInput: {
position: "absolute",
width: 1,
height: 1,
opacity: 0,
},
cells: {
flexDirection: "row",
justifyContent: "space-between",
width: "100%",
},
cell: {
width: 40,
height: 48,
borderWidth: 1,
borderColor: "#374151",
borderRadius: 8,
alignItems: "center",
justifyContent: "center",
backgroundColor: "#1F2937",
},
activeCell: {
borderColor: "#6366F1",
},
filledCell: {
borderColor: "#4B5563",
},
digit: {
fontSize: 24,
color: "white",
fontWeight: "500",
},
cursor: {
position: "absolute",
width: 2,
height: 24,
backgroundColor: "#6366F1",
},
});

View File

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

View File

@@ -16,12 +16,13 @@ const CompanySlide: React.FC<
> = ({ slide, data, ...props }) => {
const segments = useSegments();
const { jellyseerrApi } = useJellyseerr();
const from = (segments as string[])[2] || "(home)";
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

@@ -13,12 +13,13 @@ import { genreColorMap } from "@/utils/jellyseerr/src/components/Discover/consta
const GenreSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
const segments = useSegments();
const { jellyseerrApi } = useJellyseerr();
const from = (segments as string[])[2] || "(home)";
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

@@ -8,14 +8,7 @@ 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";
type ExtendedMediaRequest = NonFunctionProperties<MediaRequest> & {
profileName: string;
canRemove: boolean;
};
const RequestCard: React.FC<{ request: ExtendedMediaRequest }> = ({
request,
}) => {
const RequestCard: React.FC<{ request: MediaRequest }> = ({ request }) => {
const { jellyseerrApi } = useJellyseerr();
const { data: details } = useQuery({
@@ -74,15 +67,9 @@ const RecentRequestsSlide: React.FC<SlideProps & ViewProps> = ({
<Slide
{...props}
slide={slide}
data={
requests.results.map((item) => ({
...item,
profileName: item.profileName ?? "Unknown",
canRemove: Boolean(item.canRemove),
})) as ExtendedMediaRequest[]
}
data={requests.results}
keyExtractor={(item) => item.id.toString()}
renderItem={(item: ExtendedMediaRequest) => (
renderItem={(item: NonFunctionProperties<MediaRequest>) => (
<RequestCard request={item} />
)}
/>

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

@@ -20,7 +20,6 @@ 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,
@@ -28,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;

View File

@@ -10,8 +10,9 @@ 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 { HorizontalScroll } from "../common/HorizontalScroll";
import { HorizontalScroll } from "../common/HorrizontalScroll";
import { Text } from "../common/Text";
import { itemRouter } from "../common/TouchableItemRouter";
import Poster from "../posters/Poster";
interface Props extends ViewProps {
@@ -23,21 +24,19 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
const [api] = useAtom(apiAtom);
const segments = useSegments();
const { t } = useTranslation();
const from = (segments as string[])[2];
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;
@@ -55,12 +54,9 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
renderItem={(i) => (
<TouchableOpacity
onPress={() => {
if (i.Id) {
router.push({
pathname: "/persons/[personId]",
params: { personId: i.Id },
});
}
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,7 +1,7 @@
import { Ionicons } from "@expo/vector-icons";
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 { atom, useAtom } from "jotai";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -11,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";
@@ -73,7 +74,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
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) {
@@ -86,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)
@@ -97,9 +98,32 @@ 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();
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);
useEffect(() => {
@@ -145,7 +169,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
) : 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

@@ -2,7 +2,6 @@ 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 {
@@ -12,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";
@@ -28,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";
@@ -65,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();
@@ -75,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
@@ -126,11 +128,8 @@ export const HomeIndex = () => {
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 () => {
@@ -138,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,
@@ -196,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,
})
@@ -240,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",
@@ -255,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 || [],
@@ -318,10 +339,10 @@ 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.title || `section-${index}`;
const id = section.items?.title || `section-${index}`;
ss.push({
title: t(`${id}`),
queryKey: ["home", "custom", String(index), section.title ?? null],
title: id,
queryKey: ["home", id],
queryFn: async () => {
if (section.items) {
const response = await getItemsApi(api).getItems({
@@ -339,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,
});
@@ -357,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",
@@ -378,57 +389,41 @@ export const HomeIndex = () => {
const sections = settings?.home?.sections ? customSections : defaultSections;
if (!isConnected || serverConnected !== true) {
let title = "";
let subtitle = "";
if (!isConnected) {
// No network connection
title = t("home.no_internet");
subtitle = t("home.no_internet_message");
} else if (serverConnected === null) {
// Network is up, but server is being checked
title = t("home.checking_server_connection");
subtitle = t("home.checking_server_connection_message");
} else if (!serverConnected) {
// Network is up, but server is unreachable
title = t("home.server_unreachable");
subtitle = t("home.server_unreachable_message");
}
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>
@@ -458,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>
);
};

View File

@@ -20,7 +20,7 @@ export const JellyseerrSettings = () => {
const { t } = useTranslation();
const [user] = useAtom(userAtom);
const { settings, updateSettings } = useSettings();
const [settings, updateSettings, _pluginSettings] = useSettings(null);
const [jellyseerrPassword, setJellyseerrPassword] = useState<
string | undefined

View File

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

View File

@@ -28,7 +28,7 @@ export const useMedia = () => {
};
export const MediaProvider = ({ children }: { children: ReactNode }) => {
const { settings, updateSettings } = useSettings();
const [settings, updateSettings] = useSettings(null);
const api = useAtomValue(apiAtom);
const queryClient = useQueryClient();

View File

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

View File

@@ -23,7 +23,7 @@ import { ListItem } from "../list/ListItem";
export const OtherSettings: React.FC = () => {
const router = useRouter();
const { settings, updateSettings, pluginSettings } = useSettings();
const [settings, updateSettings, pluginSettings] = useSettings(null);
const { t } = useTranslation();
@@ -41,10 +41,10 @@ export const OtherSettings: React.FC = () => {
if (settings?.autoDownload === true && !registered) {
registerBackgroundFetchAsync();
toast.success(t("home.settings.toasts.background_downloads_enabled"));
toast.success("Background downloads enabled");
} else if (settings?.autoDownload === false && registered) {
unregisterBackgroundFetchAsync();
toast.info(t("home.settings.toasts.background_downloads_disabled"));
toast.info("Background downloads disabled");
} else if (settings?.autoDownload === true && registered) {
// Don't to anything
} else if (settings?.autoDownload === false && !registered) {

View File

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

View File

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

View File

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

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