mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-18 17:18:11 +00:00
Compare commits
20 Commits
build-perf
...
view-passw
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e91eba5c4 | ||
|
|
d563577c8f | ||
|
|
5676359138 | ||
|
|
6ea93ef8f0 | ||
|
|
d5aa812e04 | ||
|
|
79a785a615 | ||
|
|
4b2fcee460 | ||
|
|
a93b935df3 | ||
|
|
29d3360a10 | ||
|
|
be884ce6e6 | ||
|
|
a88e13b14f | ||
|
|
ff6b1112b6 | ||
|
|
1156942e33 | ||
|
|
e9effb46f6 | ||
|
|
4dce87dfd3 | ||
|
|
2f2e5a2730 | ||
|
|
614736ad4a | ||
|
|
3919bb346f | ||
|
|
87eff6f80c | ||
|
|
15d0de806b |
14
.claude/settings.local.json
Normal file
14
.claude/settings.local.json
Normal 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": []
|
||||||
|
}
|
||||||
|
}
|
||||||
7
.cursor/rules/no-custom-ios-folder-logic.mdc
Normal file
7
.cursor/rules/no-custom-ios-folder-logic.mdc
Normal 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.
|
||||||
@@ -1,15 +1 @@
|
|||||||
# Streamyfin-specific debug flag
|
EXPO_PUBLIC_WRITE_DEBUG=1
|
||||||
EXPO_PUBLIC_WRITE_DEBUG=1
|
|
||||||
|
|
||||||
# Performance optimization (official Metro flag)
|
|
||||||
EXPO_USE_METRO_REQUIRE=1
|
|
||||||
|
|
||||||
# TV development support (used in metro.config.js)
|
|
||||||
# EXPO_TV=1
|
|
||||||
# Uncomment the above line ONLY when working on TV features. Leave commented for mobile-only development to avoid issues.
|
|
||||||
|
|
||||||
# Fast resolver optimization (2025 feature)
|
|
||||||
EXPO_USE_FAST_RESOLVER=1
|
|
||||||
|
|
||||||
# Bundle analysis for monitoring
|
|
||||||
EXPO_ATLAS=1
|
|
||||||
@@ -1,26 +1 @@
|
|||||||
# Streamyfin Production Configuration
|
EXPO_PUBLIC_WRITE_DEBUG=0
|
||||||
EXPO_PUBLIC_WRITE_DEBUG=0
|
|
||||||
|
|
||||||
# Production Performance Optimizations
|
|
||||||
NODE_ENV=production
|
|
||||||
EXPO_USE_METRO_REQUIRE=1
|
|
||||||
EXPO_USE_FAST_RESOLVER=1
|
|
||||||
|
|
||||||
# Production Build Optimizations
|
|
||||||
EXPO_OPTIMIZE_BUNDLE_SIZE=1
|
|
||||||
EXPO_NO_CLIENT_ENV_VARS=1
|
|
||||||
EXPO_LEGACY_BUNDLER=0
|
|
||||||
|
|
||||||
# Bundle Analysis (for monitoring)
|
|
||||||
EXPO_ATLAS=0
|
|
||||||
|
|
||||||
# Production Cache Optimizations
|
|
||||||
METRO_CACHE=1
|
|
||||||
|
|
||||||
# Security & Performance
|
|
||||||
EXPO_NO_DOTENV=1
|
|
||||||
FAST_REFRESH=0
|
|
||||||
|
|
||||||
# Production Bundle Features
|
|
||||||
EXPO_USE_HERMES=1
|
|
||||||
EXPO_MINIFY=1
|
|
||||||
96
.github/copilot-instructions.md
vendored
96
.github/copilot-instructions.md
vendored
@@ -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
12
.github/crowdin.yml
vendored
@@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
13
.github/pull_request_template.md
vendored
13
.github/pull_request_template.md
vendored
@@ -5,8 +5,6 @@
|
|||||||
and to ensure all necessary checks are completed before merging.
|
and to ensure all necessary checks are completed before merging.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# 📦 Pull Request
|
|
||||||
|
|
||||||
## 🔖 Summary
|
## 🔖 Summary
|
||||||
<!--
|
<!--
|
||||||
A concise description of the changes introduced by this PR.
|
A concise description of the changes introduced by this PR.
|
||||||
@@ -36,12 +34,6 @@ Spec: https://www.conventionalcommits.org/ -->
|
|||||||
- Short summary: what changed and why (1–2 lines)
|
- Short summary: what changed and why (1–2 lines)
|
||||||
-->
|
-->
|
||||||
|
|
||||||
## 📋 Details
|
|
||||||
<!--
|
|
||||||
Provide more context or background. Explain any non-obvious decisions.
|
|
||||||
Include screenshots or GIFs for UI changes if applicable.
|
|
||||||
-->
|
|
||||||
|
|
||||||
### ⚠️ Breaking Changes
|
### ⚠️ Breaking Changes
|
||||||
<!-- List any breaking API/contract changes and migration guidance. If none, write “None”. -->
|
<!-- List any breaking API/contract changes and migration guidance. If none, write “None”. -->
|
||||||
|
|
||||||
@@ -52,7 +44,10 @@ Include screenshots or GIFs for UI changes if applicable.
|
|||||||
<!-- Hot paths, memory/CPU/latency implications, benchmarks if available. -->
|
<!-- Hot paths, memory/CPU/latency implications, benchmarks if available. -->
|
||||||
|
|
||||||
### 🖼️ Screenshots / GIFs (if UI)
|
### 🖼️ Screenshots / GIFs (if UI)
|
||||||
<!-- Before/After, dark mode, responsive states. -->
|
<!--
|
||||||
|
Provide more context or background. Explain any non-obvious decisions.
|
||||||
|
Before/After, dark mode, responsive states.
|
||||||
|
-->
|
||||||
|
|
||||||
## ✅ Checklist
|
## ✅ Checklist
|
||||||
<!--
|
<!--
|
||||||
|
|||||||
478
.github/workflows/artifact-comment.yml
vendored
478
.github/workflows/artifact-comment.yml
vendored
@@ -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
93
.github/workflows/build-android.yml
vendored
Normal 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
|
||||||
280
.github/workflows/build-apps.yml
vendored
280
.github/workflows/build-apps.yml
vendored
@@ -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
95
.github/workflows/build-ios.yml
vendored
Normal 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
|
||||||
2
.github/workflows/check-lockfile.yml
vendored
2
.github/workflows/check-lockfile.yml
vendored
@@ -32,7 +32,7 @@ jobs:
|
|||||||
bun-version: latest
|
bun-version: latest
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.bun/install/cache
|
~/.bun/install/cache
|
||||||
|
|||||||
6
.github/workflows/ci-codeql.yml
vendored
6
.github/workflows/ci-codeql.yml
vendored
@@ -31,13 +31,13 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: 🏁 Initialize CodeQL
|
- name: 🏁 Initialize CodeQL
|
||||||
uses: github/codeql-action/init@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7
|
uses: github/codeql-action/init@2d92b76c45b91eb80fc44c74ce3fce0ee94e8f9d # v3.30.0
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
queries: +security-extended,security-and-quality
|
queries: +security-extended,security-and-quality
|
||||||
|
|
||||||
- name: 🛠️ Autobuild
|
- name: 🛠️ Autobuild
|
||||||
uses: github/codeql-action/autobuild@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7
|
uses: github/codeql-action/autobuild@2d92b76c45b91eb80fc44c74ce3fce0ee94e8f9d # v3.30.0
|
||||||
|
|
||||||
- name: 🧪 Perform CodeQL Analysis
|
- name: 🧪 Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7
|
uses: github/codeql-action/analyze@2d92b76c45b91eb80fc44c74ce3fce0ee94e8f9d # v3.30.0
|
||||||
|
|||||||
50
.github/workflows/crowdin.yml
vendored
50
.github/workflows/crowdin.yml
vendored
@@ -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 }}
|
|
||||||
5
.github/workflows/linting.yml
vendored
5
.github/workflows/linting.yml
vendored
@@ -57,7 +57,7 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Dependency Review
|
- name: Dependency Review
|
||||||
uses: actions/dependency-review-action@56339e523c0409420f6c2c9a2f4292bbb3c07dd3 # v4.8.0
|
uses: actions/dependency-review-action@595b5aeba73380359d98a5e087f648dbb0edce1b # v4.7.3
|
||||||
with:
|
with:
|
||||||
fail-on-severity: high
|
fail-on-severity: high
|
||||||
base-ref: ${{ github.event.pull_request.base.sha || 'develop' }}
|
base-ref: ${{ github.event.pull_request.base.sha || 'develop' }}
|
||||||
@@ -65,7 +65,6 @@ jobs:
|
|||||||
|
|
||||||
expo-doctor:
|
expo-doctor:
|
||||||
name: 🚑 Expo Doctor Check
|
name: 🚑 Expo Doctor Check
|
||||||
if: false
|
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- name: 🛒 Checkout repository
|
- name: 🛒 Checkout repository
|
||||||
@@ -107,7 +106,7 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: "🟢 Setup Node.js"
|
- name: "🟢 Setup Node.js"
|
||||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
with:
|
with:
|
||||||
node-version: '24.x'
|
node-version: '24.x'
|
||||||
|
|
||||||
|
|||||||
25
.github/workflows/notification.yml
vendored
25
.github/workflows/notification.yml
vendored
@@ -1,18 +1,13 @@
|
|||||||
name: 🛎️ Discord Notification
|
name: 🛎️ Discord Pull Request Notification
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
types: [opened, reopened]
|
types: [opened, reopened]
|
||||||
branches: [develop]
|
branches: [develop]
|
||||||
workflow_run:
|
|
||||||
workflows: ["*"]
|
|
||||||
types: [completed]
|
|
||||||
branches: [develop]
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
notify:
|
notify:
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
if: github.event_name == 'pull_request'
|
|
||||||
steps:
|
steps:
|
||||||
- name: 🛎️ Notify Discord
|
- name: 🛎️ Notify Discord
|
||||||
uses: Ilshidur/action-discord@d2594079a10f1d6739ee50a2471f0ca57418b554 # 0.4.0
|
uses: Ilshidur/action-discord@d2594079a10f1d6739ee50a2471f0ca57418b554 # 0.4.0
|
||||||
@@ -26,21 +21,3 @@ jobs:
|
|||||||
**By:** ${{ github.event.pull_request.user.login }}
|
**By:** ${{ github.event.pull_request.user.login }}
|
||||||
**Branch:** ${{ github.event.pull_request.head.ref }}
|
**Branch:** ${{ github.event.pull_request.head.ref }}
|
||||||
🔗 ${{ github.event.pull_request.html_url }}
|
🔗 ${{ github.event.pull_request.html_url }}
|
||||||
|
|
||||||
notify-on-failure:
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
if: github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'failure'
|
|
||||||
steps:
|
|
||||||
- name: 🚨 Notify Discord on Failure
|
|
||||||
uses: Ilshidur/action-discord@d2594079a10f1d6739ee50a2471f0ca57418b554 # 0.4.0
|
|
||||||
env:
|
|
||||||
DISCORD_WEBHOOK: ${{ secrets.WEBHOOK_FAILED_JOB_URL }}
|
|
||||||
DISCORD_AVATAR: https://avatars.githubusercontent.com/u/193271640
|
|
||||||
with:
|
|
||||||
args: |
|
|
||||||
🚨 **Workflow Failed** in **${{ github.repository }}**
|
|
||||||
**Workflow:** ${{ github.event.workflow_run.name }}
|
|
||||||
**Branch:** ${{ github.event.workflow_run.head_branch }}
|
|
||||||
**Triggered by:** ${{ github.event.workflow_run.triggering_actor.login }}
|
|
||||||
**Commit:** ${{ github.event.workflow_run.head_commit.message }}
|
|
||||||
🔗 ${{ github.event.workflow_run.html_url }}
|
|
||||||
|
|||||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 🔄 Mark/Close Stale Issues
|
- name: 🔄 Mark/Close Stale Issues
|
||||||
uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
|
uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0
|
||||||
with:
|
with:
|
||||||
# Global settings
|
# Global settings
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
2
.github/workflows/update-issue-form.yml
vendored
2
.github/workflows/update-issue-form.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
|||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
|
|
||||||
- name: "🟢 Setup Node.js"
|
- name: "🟢 Setup Node.js"
|
||||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
with:
|
with:
|
||||||
node-version: '24.x'
|
node-version: '24.x'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|||||||
72
.gitignore
vendored
72
.gitignore
vendored
@@ -1,16 +1,27 @@
|
|||||||
# Dependencies and Package Managers
|
|
||||||
node_modules/
|
node_modules/
|
||||||
bun.lock
|
|
||||||
bun.lockb
|
|
||||||
package-lock.json
|
|
||||||
|
|
||||||
# Expo and React Native Build Artifacts
|
|
||||||
.expo/
|
.expo/
|
||||||
dist/
|
dist/
|
||||||
|
npm-debug.*
|
||||||
|
*.jks
|
||||||
|
*.p8
|
||||||
|
*.p12
|
||||||
|
*.key
|
||||||
|
*.mobileprovision
|
||||||
|
*.orig.*
|
||||||
web-build/
|
web-build/
|
||||||
.tsbuildinfo
|
modules/vlc-player/android/build
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
expo-env.d.ts
|
||||||
|
|
||||||
|
Streamyfin.app
|
||||||
|
|
||||||
|
*.mp4
|
||||||
|
Streamyfin.app
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
# Platform-specific Build Directories
|
|
||||||
/ios
|
/ios
|
||||||
/android
|
/android
|
||||||
/iostv
|
/iostv
|
||||||
@@ -18,50 +29,21 @@ web-build/
|
|||||||
/androidmobile
|
/androidmobile
|
||||||
/androidtv
|
/androidtv
|
||||||
|
|
||||||
# Module-specific Builds
|
|
||||||
modules/vlc-player/android/build
|
|
||||||
modules/player/android
|
modules/player/android
|
||||||
modules/hls-downloader/android/build
|
|
||||||
|
|
||||||
# Generated Applications
|
pc-api-7079014811501811218-719-3b9f15aeccf8.json
|
||||||
Streamyfin.app
|
credentials.json
|
||||||
*.apk
|
*.apk
|
||||||
*.ipa
|
*.ipa
|
||||||
*.aab
|
.continuerc.json
|
||||||
|
|
||||||
# Certificates and Keys
|
|
||||||
*.jks
|
|
||||||
*.p8
|
|
||||||
*.p12
|
|
||||||
*.key
|
|
||||||
*.mobileprovision
|
|
||||||
|
|
||||||
# Debug and Temporary Files
|
|
||||||
npm-debug.*
|
|
||||||
*.orig.*
|
|
||||||
*.mp4
|
|
||||||
|
|
||||||
# OS-specific Files
|
|
||||||
# macOS
|
|
||||||
.DS_Store
|
|
||||||
|
|
||||||
# IDE and Editor Files
|
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
.ruby-lsp
|
.ruby-lsp
|
||||||
.cursor/
|
modules/hls-downloader/android/build
|
||||||
.claude/
|
streamyfin-4fec1-firebase-adminsdk.json
|
||||||
|
|
||||||
# Environment and Configuration
|
|
||||||
expo-env.d.ts
|
|
||||||
.continuerc.json
|
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
*.aab
|
||||||
# Secrets and Credentials
|
/version-backup-*
|
||||||
pc-api-7079014811501811218-719-3b9f15aeccf8.json
|
bun.lockb
|
||||||
credentials.json
|
|
||||||
streamyfin-4fec1-firebase-adminsdk.json
|
|
||||||
|
|
||||||
# Version and Backup Files
|
|
||||||
/version-backup-*
|
|
||||||
24
.vscode/extensions.json
vendored
24
.vscode/extensions.json
vendored
@@ -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
176
.vscode/settings.json
vendored
@@ -1,178 +1,24 @@
|
|||||||
{
|
{
|
||||||
// ==========================================
|
|
||||||
// FORMATTING & LINTING
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
// Biome as default formatter
|
|
||||||
"editor.defaultFormatter": "biomejs.biome",
|
|
||||||
"editor.formatOnSave": true,
|
|
||||||
"editor.formatOnPaste": true,
|
|
||||||
"editor.formatOnType": false,
|
|
||||||
|
|
||||||
// Language-specific formatters
|
|
||||||
"[javascript]": {
|
"[javascript]": {
|
||||||
"editor.defaultFormatter": "biomejs.biome",
|
"editor.defaultFormatter": "biomejs.biome",
|
||||||
"editor.formatOnSave": true
|
"editor.formatOnSave": true
|
||||||
},
|
},
|
||||||
"[typescript]": {
|
|
||||||
"editor.defaultFormatter": "biomejs.biome",
|
|
||||||
"editor.formatOnSave": true
|
|
||||||
},
|
|
||||||
"[typescriptreact]": {
|
"[typescriptreact]": {
|
||||||
"editor.defaultFormatter": "biomejs.biome",
|
"editor.defaultFormatter": "biomejs.biome",
|
||||||
"editor.formatOnSave": true
|
"editor.formatOnSave": true
|
||||||
},
|
},
|
||||||
|
"prettier.printWidth": 120,
|
||||||
|
"[swift]": {
|
||||||
|
"editor.defaultFormatter": "sswg.swift-lang"
|
||||||
|
},
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.defaultFormatter": "biomejs.biome",
|
||||||
|
"[typescript]": {
|
||||||
|
"editor.defaultFormatter": "biomejs.biome",
|
||||||
|
"editor.formatOnSave": true
|
||||||
|
},
|
||||||
"[javascriptreact]": {
|
"[javascriptreact]": {
|
||||||
"editor.defaultFormatter": "biomejs.biome",
|
"editor.defaultFormatter": "biomejs.biome",
|
||||||
"editor.formatOnSave": true
|
"editor.formatOnSave": true
|
||||||
},
|
}
|
||||||
"[json]": {
|
|
||||||
"editor.defaultFormatter": "biomejs.biome",
|
|
||||||
"editor.formatOnSave": true
|
|
||||||
},
|
|
||||||
"[jsonc]": {
|
|
||||||
"editor.defaultFormatter": "biomejs.biome",
|
|
||||||
"editor.formatOnSave": true
|
|
||||||
},
|
|
||||||
"[swift]": {
|
|
||||||
"editor.insertSpaces": true,
|
|
||||||
"editor.tabSize": 2
|
|
||||||
},
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// TYPESCRIPT & JAVASCRIPT
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
// TypeScript performance optimizations
|
|
||||||
"typescript.preferences.includePackageJsonAutoImports": "auto",
|
|
||||||
"typescript.suggest.autoImports": true,
|
|
||||||
"typescript.updateImportsOnFileMove.enabled": "always",
|
|
||||||
"typescript.preferences.preferTypeOnlyAutoImports": true,
|
|
||||||
"typescript.preferences.importModuleSpecifier": "relative",
|
|
||||||
"typescript.preferences.includeCompletionsForImportStatements": true,
|
|
||||||
"typescript.preferences.includeCompletionsWithSnippetText": true,
|
|
||||||
|
|
||||||
// JavaScript settings
|
|
||||||
"javascript.preferences.importModuleSpecifier": "relative",
|
|
||||||
"javascript.suggest.autoImports": true,
|
|
||||||
"javascript.updateImportsOnFileMove.enabled": "always",
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// REACT NATIVE & EXPO
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
// File associations for React Native
|
|
||||||
"files.associations": {
|
|
||||||
"*.expo.ts": "typescript",
|
|
||||||
"*.expo.tsx": "typescriptreact",
|
|
||||||
"*.expo.js": "javascript",
|
|
||||||
"*.expo.jsx": "javascriptreact",
|
|
||||||
"metro.config.js": "javascript",
|
|
||||||
"babel.config.js": "javascript",
|
|
||||||
"app.config.js": "javascript",
|
|
||||||
"eas.json": "jsonc"
|
|
||||||
},
|
|
||||||
|
|
||||||
// React Native specific settings
|
|
||||||
"emmet.includeLanguages": {
|
|
||||||
"typescriptreact": "html",
|
|
||||||
"javascriptreact": "html"
|
|
||||||
},
|
|
||||||
"emmet.triggerExpansionOnTab": true,
|
|
||||||
|
|
||||||
// Exclude build directories from search
|
|
||||||
"search.exclude": {
|
|
||||||
"**/node_modules": true
|
|
||||||
},
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// EDITOR PERFORMANCE & UX
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
// Performance optimizations
|
|
||||||
"editor.largeFileOptimizations": true,
|
|
||||||
"files.watcherExclude": {
|
|
||||||
"**/.git/objects/**": true,
|
|
||||||
"**/.git/subtree-cache/**": true,
|
|
||||||
"**/node_modules/**": true,
|
|
||||||
"**/.expo/**": true,
|
|
||||||
"**/ios/**": true,
|
|
||||||
"**/android/**": true,
|
|
||||||
"**/build/**": true,
|
|
||||||
"**/dist/**": true
|
|
||||||
},
|
|
||||||
|
|
||||||
// Better editor behavior
|
|
||||||
"editor.suggestSelection": "first",
|
|
||||||
"editor.quickSuggestions": {
|
|
||||||
"strings": true,
|
|
||||||
"comments": true,
|
|
||||||
"other": true
|
|
||||||
},
|
|
||||||
"editor.snippetSuggestions": "top",
|
|
||||||
"editor.tabCompletion": "on",
|
|
||||||
"editor.wordBasedSuggestions": "off",
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// TERMINAL & DEVELOPMENT
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
// Terminal settings for Bun (Windows-specific)
|
|
||||||
"terminal.integrated.profiles.windows": {
|
|
||||||
"Command Prompt": {
|
|
||||||
"path": "C:\\Windows\\System32\\cmd.exe",
|
|
||||||
"env": {
|
|
||||||
"PATH": "${env:PATH};./node_modules/.bin"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// WORKSPACE & NAVIGATION
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
// Better workspace navigation
|
|
||||||
"explorer.fileNesting.enabled": true,
|
|
||||||
"explorer.fileNesting.expand": false,
|
|
||||||
"explorer.fileNesting.patterns": {
|
|
||||||
"*.ts": "${capture}.js",
|
|
||||||
"*.tsx": "${capture}.js",
|
|
||||||
"*.js": "${capture}.js,${capture}.js.map,${capture}.min.js,${capture}.d.ts",
|
|
||||||
"*.jsx": "${capture}.js",
|
|
||||||
"package.json": "package-lock.json,yarn.lock,bun.lock,bun.lockb,.yarnrc,.yarnrc.yml",
|
|
||||||
"tsconfig.json": "tsconfig.*.json",
|
|
||||||
".env": ".env.*",
|
|
||||||
"app.json": "app.config.js,eas.json,expo-env.d.ts",
|
|
||||||
"README.md": "LICENSE.txt,SECURITY.md,CODE_OF_CONDUCT.md,CONTRIBUTING.md"
|
|
||||||
},
|
|
||||||
|
|
||||||
// Better breadcrumbs and navigation
|
|
||||||
"breadcrumbs.enabled": true,
|
|
||||||
"outline.showVariables": true,
|
|
||||||
"outline.showConstants": true,
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// GIT & VERSION CONTROL
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
// Git integration
|
|
||||||
"git.autofetch": true,
|
|
||||||
"git.enableSmartCommit": true,
|
|
||||||
"git.confirmSync": false,
|
|
||||||
"git.ignoreLimitWarning": true,
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// CODE QUALITY & ERRORS
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
// Better error detection
|
|
||||||
"typescript.validate.enable": true,
|
|
||||||
"javascript.validate.enable": true,
|
|
||||||
"editor.codeActionsOnSave": {
|
|
||||||
"source.fixAll": "explicit",
|
|
||||||
"source.organizeImports": "explicit"
|
|
||||||
},
|
|
||||||
|
|
||||||
// Problem matcher for better error display
|
|
||||||
"typescript.tsc.autoDetect": "on"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
module.exports = ({ config }) => {
|
module.exports = ({ config }) => {
|
||||||
if (process.env.EXPO_TV === "1") {
|
if (process.env.EXPO_TV !== "1") {
|
||||||
// Add TV-specific plugin for TV builds
|
|
||||||
config.plugins.push("@react-native-tvos/config-tv");
|
|
||||||
} else {
|
|
||||||
// Add non-TV specific plugins for phone builds
|
|
||||||
config.plugins.push("expo-background-task");
|
config.plugins.push("expo-background-task");
|
||||||
|
|
||||||
config.plugins.push([
|
config.plugins.push([
|
||||||
|
|||||||
8
app.json
8
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.39.0",
|
"version": "0.35.1",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "streamyfin",
|
"scheme": "streamyfin",
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"jsEngine": "hermes",
|
"jsEngine": "hermes",
|
||||||
"versionCode": 71,
|
"versionCode": 67,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/images/icon-android-plain.png",
|
"foregroundImage": "./assets/images/icon-android-plain.png",
|
||||||
"monochromeImage": "./assets/images/icon-android-themed.png",
|
"monochromeImage": "./assets/images/icon-android-themed.png",
|
||||||
@@ -49,10 +49,10 @@
|
|||||||
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
|
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
|
||||||
"android.permission.WRITE_SETTINGS"
|
"android.permission.WRITE_SETTINGS"
|
||||||
],
|
],
|
||||||
"blockedPermissions": ["android.permission.ACTIVITY_RECOGNITION"],
|
|
||||||
"googleServicesFile": "./google-services.json"
|
"googleServicesFile": "./google-services.json"
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
|
"@react-native-tvos/config-tv",
|
||||||
"expo-router",
|
"expo-router",
|
||||||
"expo-font",
|
"expo-font",
|
||||||
[
|
[
|
||||||
@@ -121,7 +121,7 @@
|
|||||||
[
|
[
|
||||||
"expo-splash-screen",
|
"expo-splash-screen",
|
||||||
{
|
{
|
||||||
"backgroundColor": "#010101",
|
"backgroundColor": "#2e2e2e",
|
||||||
"image": "./assets/images/icon-ios-plain.png",
|
"image": "./assets/images/icon-ios-plain.png",
|
||||||
"imageWidth": 100
|
"imageWidth": 100
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export default function CustomMenuLayout() {
|
|||||||
headerShown: true,
|
headerShown: true,
|
||||||
headerLargeTitle: true,
|
headerLargeTitle: true,
|
||||||
headerTitle: t("tabs.custom_links"),
|
headerTitle: t("tabs.custom_links"),
|
||||||
headerBlurEffect: "none",
|
headerBlurEffect: "prominent",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -11,8 +11,12 @@ export default function SearchLayout() {
|
|||||||
name='index'
|
name='index'
|
||||||
options={{
|
options={{
|
||||||
headerShown: !Platform.isTV,
|
headerShown: !Platform.isTV,
|
||||||
|
headerLargeTitle: true,
|
||||||
headerTitle: t("tabs.favorites"),
|
headerTitle: t("tabs.favorites"),
|
||||||
headerBlurEffect: "none",
|
headerLargeStyle: {
|
||||||
|
backgroundColor: "black",
|
||||||
|
},
|
||||||
|
headerBlurEffect: "prominent",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -21,16 +21,19 @@ export default function IndexLayout() {
|
|||||||
name='index'
|
name='index'
|
||||||
options={{
|
options={{
|
||||||
headerShown: !Platform.isTV,
|
headerShown: !Platform.isTV,
|
||||||
|
headerLargeTitle: true,
|
||||||
headerTitle: t("tabs.home"),
|
headerTitle: t("tabs.home"),
|
||||||
headerBlurEffect: "none",
|
headerBlurEffect: "prominent",
|
||||||
|
headerLargeStyle: {
|
||||||
|
backgroundColor: "black",
|
||||||
|
},
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<View className='flex flex-row items-center px-2'>
|
<View className='flex flex-row items-center space-x-2'>
|
||||||
{!Platform.isTV && (
|
{!Platform.isTV && (
|
||||||
<>
|
<>
|
||||||
<Chromecast.Chromecast background='transparent' />
|
<Chromecast.Chromecast />
|
||||||
|
|
||||||
{user?.Policy?.IsAdministrator && <SessionsButton />}
|
{user?.Policy?.IsAdministrator && <SessionsButton />}
|
||||||
<SettingsButton />
|
<SettingsButton />
|
||||||
</>
|
</>
|
||||||
@@ -135,13 +138,14 @@ const SessionsButton = () => {
|
|||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.push("/(auth)/sessions");
|
router.push("/(auth)/sessions");
|
||||||
}}
|
}}
|
||||||
className='mr-4'
|
|
||||||
>
|
>
|
||||||
<Ionicons
|
<View className='mr-4'>
|
||||||
name='play-circle'
|
<Ionicons
|
||||||
color={sessions.length === 0 ? "white" : "#9333ea"}
|
name='play-circle'
|
||||||
size={28}
|
color={sessions.length === 0 ? "white" : "#9333ea"}
|
||||||
/>
|
size={25}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -90,19 +90,6 @@ export default function page() {
|
|||||||
}
|
}
|
||||||
}, [downloadedFiles]);
|
}, [downloadedFiles]);
|
||||||
|
|
||||||
const otherMedia = useMemo(() => {
|
|
||||||
try {
|
|
||||||
return (
|
|
||||||
downloadedFiles?.filter(
|
|
||||||
(f) => f.item.Type !== "Movie" && f.item.Type !== "Episode",
|
|
||||||
) || []
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
setShowMigration(true);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}, [downloadedFiles]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
@@ -141,30 +128,8 @@ export default function page() {
|
|||||||
writeToLog("ERROR", reason);
|
writeToLog("ERROR", reason);
|
||||||
toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
|
toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
|
||||||
});
|
});
|
||||||
const deleteOtherMedia = () =>
|
|
||||||
Promise.all(
|
|
||||||
otherMedia.map((item) =>
|
|
||||||
deleteFileByType(item.item.Type)
|
|
||||||
.then(() =>
|
|
||||||
toast.success(
|
|
||||||
t("home.downloads.toasts.deleted_media_successfully", {
|
|
||||||
type: item.item.Type,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.catch((reason) => {
|
|
||||||
writeToLog("ERROR", reason);
|
|
||||||
toast.error(
|
|
||||||
t("home.downloads.toasts.failed_to_delete_media", {
|
|
||||||
type: item.item.Type,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const deleteAllMedia = async () =>
|
const deleteAllMedia = async () =>
|
||||||
await Promise.all([deleteMovies(), deleteShows(), deleteOtherMedia()]);
|
await Promise.all([deleteMovies(), deleteShows()]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -273,34 +238,6 @@ export default function page() {
|
|||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{otherMedia.length > 0 && (
|
|
||||||
<View className='mb-4'>
|
|
||||||
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
|
||||||
<Text className='text-lg font-bold'>
|
|
||||||
{t("home.downloads.other_media")}
|
|
||||||
</Text>
|
|
||||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
|
||||||
<Text className='text-xs font-bold'>
|
|
||||||
{otherMedia?.length}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
|
||||||
<View className='px-4 flex flex-row'>
|
|
||||||
{otherMedia?.map((item) => (
|
|
||||||
<TouchableItemRouter
|
|
||||||
item={item.item}
|
|
||||||
isOffline
|
|
||||||
key={item.item.Id}
|
|
||||||
>
|
|
||||||
<MovieCard item={item.item} />
|
|
||||||
</TouchableItemRouter>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{downloadedFiles?.length === 0 && (
|
{downloadedFiles?.length === 0 && (
|
||||||
<View className='flex px-4'>
|
<View className='flex px-4'>
|
||||||
<Text className='opacity-50'>
|
<Text className='opacity-50'>
|
||||||
@@ -336,11 +273,6 @@ export default function page() {
|
|||||||
<Button color='purple' onPress={deleteShows}>
|
<Button color='purple' onPress={deleteShows}>
|
||||||
{t("home.downloads.delete_all_tvseries_button")}
|
{t("home.downloads.delete_all_tvseries_button")}
|
||||||
</Button>
|
</Button>
|
||||||
{otherMedia.length > 0 && (
|
|
||||||
<Button color='purple' onPress={deleteOtherMedia}>
|
|
||||||
{t("home.downloads.delete_all_other_media_button")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button color='red' onPress={deleteAllMedia}>
|
<Button color='red' onPress={deleteAllMedia}>
|
||||||
{t("home.downloads.delete_all_button")}
|
{t("home.downloads.delete_all_button")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -468,7 +468,6 @@ const TranscodingStreamView = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const TranscodingView = ({ session }: SessionCardProps) => {
|
const TranscodingView = ({ session }: SessionCardProps) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
const videoStream = useMemo(() => {
|
const videoStream = useMemo(() => {
|
||||||
return session.NowPlayingItem?.MediaStreams?.filter(
|
return session.NowPlayingItem?.MediaStreams?.filter(
|
||||||
(s) => s.Type === "Video",
|
(s) => s.Type === "Video",
|
||||||
@@ -502,7 +501,7 @@ const TranscodingView = ({ session }: SessionCardProps) => {
|
|||||||
return (
|
return (
|
||||||
<View className='flex flex-col bg-neutral-800 rounded-b-2xl p-4 pt-2'>
|
<View className='flex flex-col bg-neutral-800 rounded-b-2xl p-4 pt-2'>
|
||||||
<TranscodingStreamView
|
<TranscodingStreamView
|
||||||
title={t("common.video")}
|
title='Video'
|
||||||
properties={{
|
properties={{
|
||||||
resolution: videoStreamTitle(),
|
resolution: videoStreamTitle(),
|
||||||
bitrate: videoStream?.BitRate,
|
bitrate: videoStream?.BitRate,
|
||||||
@@ -519,7 +518,7 @@ const TranscodingView = ({ session }: SessionCardProps) => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<TranscodingStreamView
|
<TranscodingStreamView
|
||||||
title={t("common.audio")}
|
title='Audio'
|
||||||
properties={{
|
properties={{
|
||||||
language: audioStream?.Language,
|
language: audioStream?.Language,
|
||||||
bitrate: audioStream?.BitRate,
|
bitrate: audioStream?.BitRate,
|
||||||
@@ -537,7 +536,7 @@ const TranscodingView = ({ session }: SessionCardProps) => {
|
|||||||
|
|
||||||
{subtitleStream && (
|
{subtitleStream && (
|
||||||
<TranscodingStreamView
|
<TranscodingStreamView
|
||||||
title={t("common.subtitle")}
|
title='Subtitle'
|
||||||
isTranscoding={false}
|
isTranscoding={false}
|
||||||
properties={{
|
properties={{
|
||||||
language: subtitleStream?.Language,
|
language: subtitleStream?.Language,
|
||||||
|
|||||||
@@ -139,15 +139,7 @@ const Page: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
requestMedia(mediaTitle, body, refetch);
|
requestMedia(mediaTitle, body, refetch);
|
||||||
}, [
|
}, [details, result, requestMedia, hasAdvancedRequestPermission]);
|
||||||
details,
|
|
||||||
result,
|
|
||||||
requestMedia,
|
|
||||||
hasAdvancedRequestPermission,
|
|
||||||
mediaTitle,
|
|
||||||
refetch,
|
|
||||||
mediaType,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const isAnime = useMemo(
|
const isAnime = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -285,16 +277,12 @@ const Page: React.FC = () => {
|
|||||||
<Button
|
<Button
|
||||||
className='flex-1 bg-purple-600/50 border-purple-400 ring-purple-400 text-purple-100'
|
className='flex-1 bg-purple-600/50 border-purple-400 ring-purple-400 text-purple-100'
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.push({
|
const url =
|
||||||
pathname:
|
mediaType === MediaType.MOVIE
|
||||||
mediaType === MediaType.MOVIE
|
? `/(auth)/(tabs)/(search)/items/page?id=${details?.mediaInfo.jellyfinMediaId}`
|
||||||
? "/(auth)/(tabs)/(search)/items/page"
|
: `/(auth)/(tabs)/(search)/series/${details?.mediaInfo.jellyfinMediaId}`;
|
||||||
: "/(auth)/(tabs)/(search)/series/[id]",
|
// @ts-expect-error
|
||||||
params:
|
router.push(url);
|
||||||
mediaType === MediaType.MOVIE
|
|
||||||
? { id: details?.mediaInfo.jellyfinMediaId }
|
|
||||||
: { id: details?.mediaInfo.jellyfinMediaId },
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
iconLeft={
|
iconLeft={
|
||||||
<Ionicons name='play-outline' size={20} color='white' />
|
<Ionicons name='play-outline' size={20} color='white' />
|
||||||
@@ -304,7 +292,7 @@ const Page: React.FC = () => {
|
|||||||
borderStyle: "solid",
|
borderStyle: "solid",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text className='text-sm'>{t("common.play")}</Text>
|
<Text className='text-sm'>Play</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -77,13 +77,7 @@ const Page = () => {
|
|||||||
} else {
|
} else {
|
||||||
_setSortBy([SortByOption.SortName]);
|
_setSortBy([SortByOption.SortName]);
|
||||||
}
|
}
|
||||||
}, [
|
}, []);
|
||||||
libraryId,
|
|
||||||
sortOrderPreference,
|
|
||||||
sortByPreference,
|
|
||||||
_setSortOrder,
|
|
||||||
_setSortBy,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const setSortBy = useCallback(
|
const setSortBy = useCallback(
|
||||||
(sortBy: SortByOption[]) => {
|
(sortBy: SortByOption[]) => {
|
||||||
@@ -93,7 +87,7 @@ const Page = () => {
|
|||||||
}
|
}
|
||||||
_setSortBy(sortBy);
|
_setSortBy(sortBy);
|
||||||
},
|
},
|
||||||
[libraryId, sortByPreference, setSortByPreference, _setSortBy],
|
[libraryId, sortByPreference],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setSortOrder = useCallback(
|
const setSortOrder = useCallback(
|
||||||
@@ -107,7 +101,7 @@ const Page = () => {
|
|||||||
}
|
}
|
||||||
_setSortOrder(sortOrder);
|
_setSortOrder(sortOrder);
|
||||||
},
|
},
|
||||||
[libraryId, sortOrderPreference, setOderByPreference, _setSortOrder],
|
[libraryId, sortOrderPreference],
|
||||||
);
|
);
|
||||||
|
|
||||||
const nrOfCols = useMemo(() => {
|
const nrOfCols = useMemo(() => {
|
||||||
|
|||||||
@@ -1,85 +1,224 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
import { useState } from "react";
|
import { Platform } from "react-native";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform, TouchableOpacity } from "react-native";
|
|
||||||
import { LibraryOptionsSheet } from "@/components/settings/LibraryOptionsSheet";
|
|
||||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
|
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function IndexLayout() {
|
export default function IndexLayout() {
|
||||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
const [optionsSheetOpen, setOptionsSheetOpen] = useState(false);
|
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (!settings?.libraryOptions) return null;
|
if (!settings?.libraryOptions) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Stack>
|
||||||
<Stack>
|
<Stack.Screen
|
||||||
<Stack.Screen
|
name='index'
|
||||||
name='index'
|
options={{
|
||||||
options={{
|
headerShown: !Platform.isTV,
|
||||||
headerShown: !Platform.isTV,
|
headerLargeTitle: true,
|
||||||
headerTitle: t("tabs.library"),
|
headerTitle: t("tabs.library"),
|
||||||
headerBlurEffect: "none",
|
headerBlurEffect: "prominent",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerLargeStyle: {
|
||||||
headerShadowVisible: false,
|
backgroundColor: "black",
|
||||||
headerRight: () =>
|
},
|
||||||
!pluginSettings?.libraryOptions?.locked &&
|
headerTransparent: Platform.OS === "ios",
|
||||||
!Platform.isTV && (
|
headerShadowVisible: false,
|
||||||
<TouchableOpacity
|
headerRight: () =>
|
||||||
onPress={() => setOptionsSheetOpen(true)}
|
!pluginSettings?.libraryOptions?.locked &&
|
||||||
className='flex flex-row items-center justify-center w-9 h-9'
|
!Platform.isTV && (
|
||||||
>
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name='ellipsis-horizontal-outline'
|
name='ellipsis-horizontal-outline'
|
||||||
size={24}
|
size={24}
|
||||||
color='white'
|
color='white'
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</DropdownMenu.Trigger>
|
||||||
),
|
<DropdownMenu.Content
|
||||||
}}
|
align={"end"}
|
||||||
/>
|
alignOffset={-10}
|
||||||
<Stack.Screen
|
avoidCollisions={false}
|
||||||
name='[libraryId]'
|
collisionPadding={0}
|
||||||
options={{
|
loop={false}
|
||||||
title: "",
|
side={"bottom"}
|
||||||
headerShown: !Platform.isTV,
|
sideOffset={10}
|
||||||
headerBlurEffect: "none",
|
>
|
||||||
headerTransparent: Platform.OS === "ios",
|
<DropdownMenu.Label>
|
||||||
headerShadowVisible: false,
|
{t("library.options.display")}
|
||||||
}}
|
</DropdownMenu.Label>
|
||||||
/>
|
<DropdownMenu.Group key='display-group'>
|
||||||
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
<DropdownMenu.Sub>
|
||||||
<Stack.Screen key={name} name={name} options={options} />
|
<DropdownMenu.SubTrigger key='image-style-trigger'>
|
||||||
))}
|
{t("library.options.display")}
|
||||||
<Stack.Screen
|
</DropdownMenu.SubTrigger>
|
||||||
name='collections/[collectionId]'
|
<DropdownMenu.SubContent
|
||||||
options={{
|
alignOffset={-10}
|
||||||
title: "",
|
avoidCollisions={true}
|
||||||
headerShown: !Platform.isTV,
|
collisionPadding={0}
|
||||||
headerBlurEffect: "none",
|
loop={true}
|
||||||
headerTransparent: Platform.OS === "ios",
|
sideOffset={10}
|
||||||
headerShadowVisible: false,
|
>
|
||||||
}}
|
<DropdownMenu.CheckboxItem
|
||||||
/>
|
key='display-option-1'
|
||||||
</Stack>
|
value={settings.libraryOptions.display === "row"}
|
||||||
<LibraryOptionsSheet
|
onValueChange={() =>
|
||||||
open={optionsSheetOpen}
|
updateSettings({
|
||||||
setOpen={setOptionsSheetOpen}
|
libraryOptions: {
|
||||||
settings={settings.libraryOptions}
|
...settings.libraryOptions,
|
||||||
updateSettings={(options) =>
|
display: "row",
|
||||||
updateSettings({
|
},
|
||||||
libraryOptions: {
|
})
|
||||||
...settings.libraryOptions,
|
}
|
||||||
...options,
|
>
|
||||||
},
|
<DropdownMenu.ItemIndicator />
|
||||||
})
|
<DropdownMenu.ItemTitle key='display-title-1'>
|
||||||
}
|
{t("library.options.row")}
|
||||||
disabled={pluginSettings?.libraryOptions?.locked}
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.CheckboxItem>
|
||||||
|
<DropdownMenu.CheckboxItem
|
||||||
|
key='display-option-2'
|
||||||
|
value={settings.libraryOptions.display === "list"}
|
||||||
|
onValueChange={() =>
|
||||||
|
updateSettings({
|
||||||
|
libraryOptions: {
|
||||||
|
...settings.libraryOptions,
|
||||||
|
display: "list",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemIndicator />
|
||||||
|
<DropdownMenu.ItemTitle key='display-title-2'>
|
||||||
|
{t("library.options.list")}
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.CheckboxItem>
|
||||||
|
</DropdownMenu.SubContent>
|
||||||
|
</DropdownMenu.Sub>
|
||||||
|
<DropdownMenu.Sub>
|
||||||
|
<DropdownMenu.SubTrigger key='image-style-trigger'>
|
||||||
|
{t("library.options.image_style")}
|
||||||
|
</DropdownMenu.SubTrigger>
|
||||||
|
<DropdownMenu.SubContent
|
||||||
|
alignOffset={-10}
|
||||||
|
avoidCollisions={true}
|
||||||
|
collisionPadding={0}
|
||||||
|
loop={true}
|
||||||
|
sideOffset={10}
|
||||||
|
>
|
||||||
|
<DropdownMenu.CheckboxItem
|
||||||
|
key='poster-option'
|
||||||
|
value={
|
||||||
|
settings.libraryOptions.imageStyle === "poster"
|
||||||
|
}
|
||||||
|
onValueChange={() =>
|
||||||
|
updateSettings({
|
||||||
|
libraryOptions: {
|
||||||
|
...settings.libraryOptions,
|
||||||
|
imageStyle: "poster",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemIndicator />
|
||||||
|
<DropdownMenu.ItemTitle key='poster-title'>
|
||||||
|
{t("library.options.poster")}
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.CheckboxItem>
|
||||||
|
<DropdownMenu.CheckboxItem
|
||||||
|
key='cover-option'
|
||||||
|
value={settings.libraryOptions.imageStyle === "cover"}
|
||||||
|
onValueChange={() =>
|
||||||
|
updateSettings({
|
||||||
|
libraryOptions: {
|
||||||
|
...settings.libraryOptions,
|
||||||
|
imageStyle: "cover",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemIndicator />
|
||||||
|
<DropdownMenu.ItemTitle key='cover-title'>
|
||||||
|
{t("library.options.cover")}
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.CheckboxItem>
|
||||||
|
</DropdownMenu.SubContent>
|
||||||
|
</DropdownMenu.Sub>
|
||||||
|
</DropdownMenu.Group>
|
||||||
|
<DropdownMenu.Group key='show-titles-group'>
|
||||||
|
<DropdownMenu.CheckboxItem
|
||||||
|
disabled={settings.libraryOptions.imageStyle === "poster"}
|
||||||
|
key='show-titles-option'
|
||||||
|
value={settings.libraryOptions.showTitles}
|
||||||
|
onValueChange={(newValue: string) => {
|
||||||
|
if (settings.libraryOptions.imageStyle === "poster")
|
||||||
|
return;
|
||||||
|
updateSettings({
|
||||||
|
libraryOptions: {
|
||||||
|
...settings.libraryOptions,
|
||||||
|
showTitles: newValue === "on",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemIndicator />
|
||||||
|
<DropdownMenu.ItemTitle key='show-titles-title'>
|
||||||
|
{t("library.options.show_titles")}
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.CheckboxItem>
|
||||||
|
<DropdownMenu.CheckboxItem
|
||||||
|
key='show-stats-option'
|
||||||
|
value={settings.libraryOptions.showStats}
|
||||||
|
onValueChange={(newValue: string) => {
|
||||||
|
updateSettings({
|
||||||
|
libraryOptions: {
|
||||||
|
...settings.libraryOptions,
|
||||||
|
showStats: newValue === "on",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemIndicator />
|
||||||
|
<DropdownMenu.ItemTitle key='show-stats-title'>
|
||||||
|
{t("library.options.show_stats")}
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.CheckboxItem>
|
||||||
|
</DropdownMenu.Group>
|
||||||
|
|
||||||
|
<DropdownMenu.Separator />
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
<Stack.Screen
|
||||||
|
name='[libraryId]'
|
||||||
|
options={{
|
||||||
|
title: "",
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
|
headerBlurEffect: "prominent",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
headerShadowVisible: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||||
|
<Stack.Screen key={name} name={name} options={options} />
|
||||||
|
))}
|
||||||
|
<Stack.Screen
|
||||||
|
name='collections/[collectionId]'
|
||||||
|
options={{
|
||||||
|
title: "",
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
|
headerBlurEffect: "prominent",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
headerShadowVisible: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,12 @@ export default function SearchLayout() {
|
|||||||
name='index'
|
name='index'
|
||||||
options={{
|
options={{
|
||||||
headerShown: !Platform.isTV,
|
headerShown: !Platform.isTV,
|
||||||
|
headerLargeTitle: true,
|
||||||
headerTitle: t("tabs.search"),
|
headerTitle: t("tabs.search"),
|
||||||
headerBlurEffect: "none",
|
headerLargeStyle: {
|
||||||
|
backgroundColor: "black",
|
||||||
|
},
|
||||||
|
headerBlurEffect: "prominent",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import {
|
|||||||
type BaseItemDto,
|
type BaseItemDto,
|
||||||
type MediaSourceInfo,
|
type MediaSourceInfo,
|
||||||
PlaybackOrder,
|
PlaybackOrder,
|
||||||
PlaybackProgressInfo,
|
|
||||||
PlaybackStartInfo,
|
PlaybackStartInfo,
|
||||||
RepeatMode,
|
RepeatMode,
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
@@ -22,12 +21,6 @@ import { BITRATES } from "@/components/BitrateSelector";
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { Controls } from "@/components/video-player/controls/Controls";
|
import { Controls } from "@/components/video-player/controls/Controls";
|
||||||
import {
|
|
||||||
OUTLINE_THICKNESS,
|
|
||||||
OutlineThickness,
|
|
||||||
VLC_COLORS,
|
|
||||||
VLCColor,
|
|
||||||
} from "@/constants/SubtitleConstants";
|
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
@@ -104,7 +97,7 @@ export default function page() {
|
|||||||
/** Playback position in ticks. */
|
/** Playback position in ticks. */
|
||||||
playbackPosition?: string;
|
playbackPosition?: string;
|
||||||
}>();
|
}>();
|
||||||
const { settings } = useSettings();
|
useSettings();
|
||||||
|
|
||||||
const offline = offlineStr === "true";
|
const offline = offlineStr === "true";
|
||||||
const playbackManager = usePlaybackManager();
|
const playbackManager = usePlaybackManager();
|
||||||
@@ -271,7 +264,12 @@ export default function page() {
|
|||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
await videoRef.current?.pause();
|
await videoRef.current?.pause();
|
||||||
playbackManager.reportPlaybackProgress(
|
playbackManager.reportPlaybackProgress(
|
||||||
currentPlayStateInfo() as PlaybackProgressInfo,
|
item?.Id!,
|
||||||
|
msToTicks(progress.get()),
|
||||||
|
{
|
||||||
|
AudioStreamIndex: audioIndex ?? -1,
|
||||||
|
SubtitleStreamIndex: subtitleIndex ?? -1,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
videoRef.current?.play();
|
videoRef.current?.play();
|
||||||
@@ -389,7 +387,12 @@ export default function page() {
|
|||||||
if (!item?.Id) return;
|
if (!item?.Id) return;
|
||||||
|
|
||||||
playbackManager.reportPlaybackProgress(
|
playbackManager.reportPlaybackProgress(
|
||||||
currentPlayStateInfo() as PlaybackProgressInfo,
|
item.Id,
|
||||||
|
msToTicks(progress.get()),
|
||||||
|
{
|
||||||
|
AudioStreamIndex: audioIndex ?? -1,
|
||||||
|
SubtitleStreamIndex: subtitleIndex ?? -1,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
@@ -496,7 +499,12 @@ export default function page() {
|
|||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
if (item?.Id) {
|
if (item?.Id) {
|
||||||
playbackManager.reportPlaybackProgress(
|
playbackManager.reportPlaybackProgress(
|
||||||
currentPlayStateInfo() as PlaybackProgressInfo,
|
item.Id,
|
||||||
|
msToTicks(progress.get()),
|
||||||
|
{
|
||||||
|
AudioStreamIndex: audioIndex ?? -1,
|
||||||
|
SubtitleStreamIndex: subtitleIndex ?? -1,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!Platform.isTV) await activateKeepAwakeAsync();
|
if (!Platform.isTV) await activateKeepAwakeAsync();
|
||||||
@@ -507,7 +515,12 @@ export default function page() {
|
|||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
if (item?.Id) {
|
if (item?.Id) {
|
||||||
playbackManager.reportPlaybackProgress(
|
playbackManager.reportPlaybackProgress(
|
||||||
currentPlayStateInfo() as PlaybackProgressInfo,
|
item.Id,
|
||||||
|
msToTicks(progress.get()),
|
||||||
|
{
|
||||||
|
AudioStreamIndex: audioIndex ?? -1,
|
||||||
|
SubtitleStreamIndex: subtitleIndex ?? -1,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!Platform.isTV) await deactivateKeepAwake();
|
if (!Platform.isTV) await deactivateKeepAwake();
|
||||||
@@ -563,34 +576,8 @@ export default function page() {
|
|||||||
? allSubs.indexOf(chosenSubtitleTrack)
|
? allSubs.indexOf(chosenSubtitleTrack)
|
||||||
: [...textSubs].reverse().indexOf(chosenSubtitleTrack);
|
: [...textSubs].reverse().indexOf(chosenSubtitleTrack);
|
||||||
initOptions.push(`--sub-track=${finalIndex}`);
|
initOptions.push(`--sub-track=${finalIndex}`);
|
||||||
|
|
||||||
// Add VLC subtitle styling options from settings
|
|
||||||
const textColor = (settings.vlcTextColor ?? "White") as VLCColor;
|
|
||||||
const backgroundColor = (settings.vlcBackgroundColor ??
|
|
||||||
"Black") as VLCColor;
|
|
||||||
const outlineColor = (settings.vlcOutlineColor ?? "Black") as VLCColor;
|
|
||||||
const outlineThickness = (settings.vlcOutlineThickness ??
|
|
||||||
"Normal") as OutlineThickness;
|
|
||||||
const backgroundOpacity = settings.vlcBackgroundOpacity ?? 128;
|
|
||||||
const outlineOpacity = settings.vlcOutlineOpacity ?? 255;
|
|
||||||
const isBold = settings.vlcIsBold ?? false;
|
|
||||||
// Add subtitle styling options
|
|
||||||
initOptions.push(`--freetype-color=${VLC_COLORS[textColor]}`);
|
|
||||||
initOptions.push(`--freetype-background-opacity=${backgroundOpacity}`);
|
|
||||||
initOptions.push(
|
|
||||||
`--freetype-background-color=${VLC_COLORS[backgroundColor]}`,
|
|
||||||
);
|
|
||||||
initOptions.push(`--freetype-outline-opacity=${outlineOpacity}`);
|
|
||||||
initOptions.push(`--freetype-outline-color=${VLC_COLORS[outlineColor]}`);
|
|
||||||
initOptions.push(
|
|
||||||
`--freetype-outline-thickness=${OUTLINE_THICKNESS[outlineThickness]}`,
|
|
||||||
);
|
|
||||||
initOptions.push(`--sub-text-scale=${settings.subtitleSize}`);
|
|
||||||
initOptions.push("--sub-margin=40");
|
|
||||||
if (isBold) {
|
|
||||||
initOptions.push("--freetype-bold");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (notTranscoding && chosenAudioTrack) {
|
if (notTranscoding && chosenAudioTrack) {
|
||||||
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
|
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ import {
|
|||||||
} from "@/utils/background-tasks";
|
} from "@/utils/background-tasks";
|
||||||
import {
|
import {
|
||||||
LogProvider,
|
LogProvider,
|
||||||
|
writeDebugLog,
|
||||||
writeErrorLog,
|
writeErrorLog,
|
||||||
writeInfoLog,
|
|
||||||
writeToLog,
|
writeToLog,
|
||||||
} from "@/utils/log";
|
} from "@/utils/log";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
@@ -84,19 +84,19 @@ SplashScreen.setOptions({
|
|||||||
fade: true,
|
fade: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
function redirect(notification: typeof Notifications.Notification) {
|
|
||||||
const url = notification.request.content.data?.url;
|
|
||||||
if (url) {
|
|
||||||
router.push(url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function useNotificationObserver() {
|
function useNotificationObserver() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.isTV) return;
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
|
|
||||||
|
function redirect(notification: typeof Notifications.Notification) {
|
||||||
|
const url = notification.request.content.data?.url;
|
||||||
|
if (url) {
|
||||||
|
router.push(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Notifications.getLastNotificationResponseAsync().then(
|
Notifications.getLastNotificationResponseAsync().then(
|
||||||
(response: { notification: any }) => {
|
(response: { notification: any }) => {
|
||||||
if (!isMounted || !response?.notification) {
|
if (!isMounted || !response?.notification) {
|
||||||
@@ -106,8 +106,15 @@ function useNotificationObserver() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const subscription = Notifications.addNotificationResponseReceivedListener(
|
||||||
|
(response: { notification: any }) => {
|
||||||
|
redirect(response.notification);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
isMounted = false;
|
isMounted = false;
|
||||||
|
subscription.remove();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
}
|
}
|
||||||
@@ -310,42 +317,38 @@ function Layout() {
|
|||||||
responseListener.current =
|
responseListener.current =
|
||||||
Notifications?.addNotificationResponseReceivedListener(
|
Notifications?.addNotificationResponseReceivedListener(
|
||||||
(response: NotificationResponse) => {
|
(response: NotificationResponse) => {
|
||||||
// redirect if internal notification
|
|
||||||
redirect(response?.notification);
|
|
||||||
|
|
||||||
// Currently the notifications supported by the plugin will send data for deep links.
|
// Currently the notifications supported by the plugin will send data for deep links.
|
||||||
const { title, data } = response.notification.request.content;
|
const { title, data } = response.notification.request.content;
|
||||||
writeInfoLog(`Notification ${title} opened`, data);
|
writeDebugLog(
|
||||||
|
`Notification ${title} opened`,
|
||||||
|
response.notification.request.content,
|
||||||
|
);
|
||||||
|
if (data && Object.keys(data).length > 0) {
|
||||||
|
const type = (data?.type ?? "").toString().toLowerCase();
|
||||||
|
const itemId = data?.id;
|
||||||
|
|
||||||
let url: any;
|
switch (type) {
|
||||||
const type = (data?.type ?? "").toString().toLowerCase();
|
case "movie":
|
||||||
const itemId = data?.id;
|
router.push(`/(auth)/(tabs)/home/items/page?id=${itemId}`);
|
||||||
|
break;
|
||||||
switch (type) {
|
case "episode":
|
||||||
case "movie":
|
// We just clicked a notification for an individual episode.
|
||||||
url = `/(auth)/(tabs)/home/items/page?id=${itemId}`;
|
if (itemId) {
|
||||||
break;
|
router.push(`/(auth)/(tabs)/home/items/page?id=${itemId}`);
|
||||||
case "episode":
|
// summarized season notification for multiple episodes. Bring them to series season
|
||||||
// `/(auth)/(tabs)/${from}/items/page?id=${item.Id}`;
|
|
||||||
// We just clicked a notification for an individual episode.
|
|
||||||
if (itemId) {
|
|
||||||
url = `/(auth)/(tabs)/home/items/page?id=${itemId}`;
|
|
||||||
// summarized season notification for multiple episodes. Bring them to series season
|
|
||||||
} else {
|
|
||||||
const seriesId = data.seriesId;
|
|
||||||
const seasonIndex = data.seasonIndex;
|
|
||||||
if (seasonIndex) {
|
|
||||||
url = `/(auth)/(tabs)/home/series/${seriesId}?seasonIndex=${seasonIndex}`;
|
|
||||||
} else {
|
} else {
|
||||||
url = `/(auth)/(tabs)/home/series/${seriesId}`;
|
const seriesId = data.seriesId;
|
||||||
|
const seasonIndex = data.seasonIndex;
|
||||||
|
if (seasonIndex) {
|
||||||
|
router.push(
|
||||||
|
`/(auth)/(tabs)/home/series/${seriesId}?seasonIndex=${seasonIndex}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
router.push(`/(auth)/(tabs)/home/series/${seriesId}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
break;
|
||||||
break;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
writeInfoLog(`Notification attempting to redirect to ${url}`);
|
|
||||||
if (url) {
|
|
||||||
router.push(url);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -395,17 +398,12 @@ function Layout() {
|
|||||||
appState.current.match(/inactive|background/) &&
|
appState.current.match(/inactive|background/) &&
|
||||||
nextAppState === "active"
|
nextAppState === "active"
|
||||||
) {
|
) {
|
||||||
BackGroundDownloader.checkForExistingDownloads().catch(
|
BackGroundDownloader.checkForExistingDownloads();
|
||||||
(error: unknown) => {
|
|
||||||
writeErrorLog("Failed to resume background downloads", error);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
BackGroundDownloader.checkForExistingDownloads().catch((error: unknown) => {
|
BackGroundDownloader.checkForExistingDownloads();
|
||||||
writeErrorLog("Failed to resume background downloads", error);
|
|
||||||
});
|
|
||||||
return () => {
|
return () => {
|
||||||
subscription.remove();
|
subscription.remove();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { Button } from "@/components/Button";
|
|||||||
import { Input } from "@/components/common/Input";
|
import { Input } from "@/components/common/Input";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
|
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
|
||||||
|
import { PasswordInput } from "@/components/PasswordInput";
|
||||||
import { PreviousServersList } from "@/components/PreviousServersList";
|
import { PreviousServersList } from "@/components/PreviousServersList";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
||||||
@@ -52,6 +53,7 @@ const Login: React.FC = () => {
|
|||||||
username: _username,
|
username: _username,
|
||||||
password: _password,
|
password: _password,
|
||||||
});
|
});
|
||||||
|
const [showPassword, setShowPassword] = useState<boolean>(false);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A way to auto login based on a link
|
* A way to auto login based on a link
|
||||||
@@ -271,30 +273,27 @@ const Login: React.FC = () => {
|
|||||||
textContentType='oneTimeCode'
|
textContentType='oneTimeCode'
|
||||||
clearButtonMode='while-editing'
|
clearButtonMode='while-editing'
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
extraClassName='mb-4'
|
extraClassName='mb-2'
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Password */}
|
{/* Password */}
|
||||||
<Input
|
<View className='relative mb-2'>
|
||||||
placeholder={t("login.password_placeholder")}
|
<PasswordInput
|
||||||
onChangeText={(text: string) =>
|
value={credentials.password}
|
||||||
setCredentials({ ...credentials, password: text })
|
onChangeText={(text: string) =>
|
||||||
}
|
setCredentials({ ...credentials, password: text })
|
||||||
value={credentials.password}
|
}
|
||||||
secureTextEntry
|
placeholder={t("login.password_placeholder")}
|
||||||
keyboardType='default'
|
showPassword={showPassword}
|
||||||
returnKeyType='done'
|
onShowPasswordChange={setShowPassword}
|
||||||
autoCapitalize='none'
|
topOffset={16}
|
||||||
textContentType='password'
|
layout='tv'
|
||||||
clearButtonMode='while-editing'
|
/>
|
||||||
maxLength={500}
|
|
||||||
extraClassName='mb-4'
|
|
||||||
/>
|
|
||||||
|
|
||||||
<View className='mt-4'>
|
|
||||||
<Button onPress={handleLogin}>{t("login.login_button")}</Button>
|
|
||||||
</View>
|
</View>
|
||||||
<View className='mt-3'>
|
<View className='mt-3'>
|
||||||
|
<Button onPress={handleLogin}>{t("login.login_button")}</Button>
|
||||||
|
</View>
|
||||||
|
<View className='mt-2'>
|
||||||
<Button
|
<Button
|
||||||
onPress={handleQuickConnect}
|
onPress={handleQuickConnect}
|
||||||
className='bg-neutral-800 border border-neutral-700'
|
className='bg-neutral-800 border border-neutral-700'
|
||||||
@@ -348,7 +347,7 @@ const Login: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Lists stay full width but inside max width container */}
|
{/* Lists stay full width but inside max width container */}
|
||||||
<View className='mt-2'>
|
<View className='mt-4'>
|
||||||
<JellyfinServerDiscovery
|
<JellyfinServerDiscovery
|
||||||
onServerSelect={async (server: any) => {
|
onServerSelect={async (server: any) => {
|
||||||
setServerURL(server.address);
|
setServerURL(server.address);
|
||||||
@@ -402,22 +401,22 @@ const Login: React.FC = () => {
|
|||||||
textContentType='oneTimeCode'
|
textContentType='oneTimeCode'
|
||||||
clearButtonMode='while-editing'
|
clearButtonMode='while-editing'
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
|
extraClassName=''
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Input
|
<View className='relative'>
|
||||||
placeholder={t("login.password_placeholder")}
|
<PasswordInput
|
||||||
onChangeText={(text) =>
|
value={credentials.password}
|
||||||
setCredentials({ ...credentials, password: text })
|
onChangeText={(text) =>
|
||||||
}
|
setCredentials({ ...credentials, password: text })
|
||||||
value={credentials.password}
|
}
|
||||||
secureTextEntry
|
placeholder={t("login.password_placeholder")}
|
||||||
keyboardType='default'
|
showPassword={showPassword}
|
||||||
returnKeyType='done'
|
onShowPasswordChange={setShowPassword}
|
||||||
autoCapitalize='none'
|
topOffset={12}
|
||||||
textContentType='password'
|
layout='mobile'
|
||||||
clearButtonMode='while-editing'
|
/>
|
||||||
maxLength={500}
|
</View>
|
||||||
/>
|
|
||||||
<View className='flex flex-row items-center justify-between'>
|
<View className='flex flex-row items-center justify-between'>
|
||||||
<Button
|
<Button
|
||||||
onPress={handleLogin}
|
onPress={handleLogin}
|
||||||
@@ -428,7 +427,7 @@ const Login: React.FC = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={handleQuickConnect}
|
onPress={handleQuickConnect}
|
||||||
className='p-2 bg-neutral-900 rounded-xl h-12 w-12 flex items-center justify-center'
|
className='p-3 bg-neutral-900 rounded-xl h-13 w-13 flex items-center justify-center'
|
||||||
>
|
>
|
||||||
<MaterialCommunityIcons
|
<MaterialCommunityIcons
|
||||||
name='cellphone-lock'
|
name='cellphone-lock'
|
||||||
|
|||||||
@@ -115,4 +115,4 @@
|
|||||||
<path id="path259-2-6-4-6-7-0-1-0-5-9-4-7-1-5-7-6-2" class="cls-11" d="M46.97,39.46c5.94,0,10.75,4.81,10.75,10.75s-4.81,10.75-10.75,10.75-10.75-4.81-10.75-10.75c0-1.1.16-2.16.47-3.17.84,1.87,2.72,3.17,4.9,3.17,2.97,0,5.37-2.41,5.37-5.37,0-2.18-1.3-4.06-3.17-4.9,1-.31,2.06-.47,3.17-.47h.01Z"/>
|
<path id="path259-2-6-4-6-7-0-1-0-5-9-4-7-1-5-7-6-2" class="cls-11" d="M46.97,39.46c5.94,0,10.75,4.81,10.75,10.75s-4.81,10.75-10.75,10.75-10.75-4.81-10.75-10.75c0-1.1.16-2.16.47-3.17.84,1.87,2.72,3.17,4.9,3.17,2.97,0,5.37-2.41,5.37-5.37,0-2.18-1.3-4.06-3.17-4.9,1-.31,2.06-.47,3.17-.47h.01Z"/>
|
||||||
</g>
|
</g>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.2.5/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.2.2/schema.json",
|
||||||
"files": {
|
"files": {
|
||||||
"includes": [
|
"includes": [
|
||||||
"**/*",
|
"**/*",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { Platform, View, type ViewProps } from "react-native";
|
import { View, type ViewProps } from "react-native";
|
||||||
import { RoundButton } from "@/components/RoundButton";
|
import { RoundButton } from "@/components/RoundButton";
|
||||||
import { useFavorite } from "@/hooks/useFavorite";
|
import { useFavorite } from "@/hooks/useFavorite";
|
||||||
|
|
||||||
@@ -11,18 +11,6 @@ interface Props extends ViewProps {
|
|||||||
export const AddToFavorites: FC<Props> = ({ item, ...props }) => {
|
export const AddToFavorites: FC<Props> = ({ item, ...props }) => {
|
||||||
const { isFavorite, toggleFavorite } = useFavorite(item);
|
const { isFavorite, toggleFavorite } = useFavorite(item);
|
||||||
|
|
||||||
if (Platform.OS === "ios") {
|
|
||||||
return (
|
|
||||||
<View {...props}>
|
|
||||||
<RoundButton
|
|
||||||
size='large'
|
|
||||||
icon={isFavorite ? "heart" : "heart-outline"}
|
|
||||||
onPress={toggleFavorite}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<RoundButton
|
<RoundButton
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Feather } from "@expo/vector-icons";
|
import { Feather } from "@expo/vector-icons";
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { Platform, TouchableOpacity } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import GoogleCast, {
|
import GoogleCast, {
|
||||||
CastButton,
|
CastButton,
|
||||||
CastContext,
|
CastContext,
|
||||||
@@ -42,22 +42,6 @@ export function Chromecast({
|
|||||||
[Platform.OS],
|
[Platform.OS],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (Platform.OS === "ios") {
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
className='mr-4'
|
|
||||||
onPress={() => {
|
|
||||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
|
||||||
else CastContext.showCastDialog();
|
|
||||||
}}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<AndroidCastButton />
|
|
||||||
<Feather name='cast' size={22} color={"white"} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (background === "transparent")
|
if (background === "transparent")
|
||||||
return (
|
return (
|
||||||
<RoundButton
|
<RoundButton
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
if (!mediaSource) {
|
if (!mediaSource) {
|
||||||
console.error(`Could not get download URL for ${item.Name}`);
|
console.error(`Could not get download URL for ${item.Name}`);
|
||||||
toast.error(
|
toast.error(
|
||||||
t("home.downloads.toasts.could_not_get_download_url_for_item", {
|
t("Could not get download URL for {{itemName}}", {
|
||||||
itemName: item.Name,
|
itemName: item.Name,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import { CastAndCrew } from "@/components/series/CastAndCrew";
|
|||||||
import { CurrentSeries } from "@/components/series/CurrentSeries";
|
import { CurrentSeries } from "@/components/series/CurrentSeries";
|
||||||
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
|
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
|
||||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||||
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
|
import { useImageColors } from "@/hooks/useImageColors";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
@@ -61,7 +61,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const itemColors = useImageColorsReturn({ item });
|
useImageColors({ item });
|
||||||
|
|
||||||
const [loadingLogo, setLoadingLogo] = useState(true);
|
const [loadingLogo, setLoadingLogo] = useState(true);
|
||||||
const [headerHeight, setHeaderHeight] = useState(350);
|
const [headerHeight, setHeaderHeight] = useState(350);
|
||||||
@@ -105,27 +105,13 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
if (!Platform.isTV) {
|
if (!Platform.isTV) {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () =>
|
headerRight: () =>
|
||||||
item &&
|
item && (
|
||||||
(Platform.OS === "ios" ? (
|
|
||||||
<View className='flex flex-row items-center pl-2'>
|
|
||||||
<Chromecast.Chromecast width={22} height={22} />
|
|
||||||
{item.Type !== "Program" && (
|
|
||||||
<View className='flex flex-row items-center'>
|
|
||||||
{!Platform.isTV && (
|
|
||||||
<DownloadSingleItem item={item} size='large' />
|
|
||||||
)}
|
|
||||||
{user?.Policy?.IsAdministrator && (
|
|
||||||
<PlayInRemoteSessionButton item={item} size='large' />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<PlayedStatus items={[item]} size='large' />
|
|
||||||
<AddToFavorites item={item} />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<View className='flex flex-row items-center space-x-2'>
|
<View className='flex flex-row items-center space-x-2'>
|
||||||
<Chromecast.Chromecast width={22} height={22} />
|
<Chromecast.Chromecast
|
||||||
|
background='blur'
|
||||||
|
width={22}
|
||||||
|
height={22}
|
||||||
|
/>
|
||||||
{item.Type !== "Program" && (
|
{item.Type !== "Program" && (
|
||||||
<View className='flex flex-row items-center space-x-2'>
|
<View className='flex flex-row items-center space-x-2'>
|
||||||
{!Platform.isTV && (
|
{!Platform.isTV && (
|
||||||
@@ -140,7 +126,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
)),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [item, navigation, user]);
|
}, [item, navigation, user]);
|
||||||
@@ -267,7 +253,6 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
selectedOptions={selectedOptions}
|
selectedOptions={selectedOptions}
|
||||||
item={item}
|
item={item}
|
||||||
isOffline={isOffline}
|
isOffline={isOffline}
|
||||||
colors={itemColors}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export const MediaSourceSheet: React.FC<Props> = ({
|
|||||||
return getDisplayName(selected);
|
return getDisplayName(selected);
|
||||||
}, [selected, getDisplayName]);
|
}, [selected, getDisplayName]);
|
||||||
|
|
||||||
if (isTv || (item.MediaSources && item.MediaSources.length <= 1)) return null;
|
if (isTv || (item.MediaStreams && item.MediaStreams.length <= 1)) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='flex shrink' style={{ minWidth: 75 }}>
|
<View className='flex shrink' style={{ minWidth: 75 }}>
|
||||||
|
|||||||
111
components/PasswordInput.tsx
Normal file
111
components/PasswordInput.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type React from "react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { TouchableOpacity } from "react-native";
|
||||||
|
import { Input } from "./common/Input";
|
||||||
|
|
||||||
|
// Discriminated union for password visibility control
|
||||||
|
type PasswordVisibilityControlled = {
|
||||||
|
value?: string;
|
||||||
|
onChangeText: (text: string) => void;
|
||||||
|
placeholder: string;
|
||||||
|
showPassword: boolean;
|
||||||
|
onShowPasswordChange: (show: boolean) => void;
|
||||||
|
topOffset?: number;
|
||||||
|
layout?: "tv" | "mobile";
|
||||||
|
};
|
||||||
|
|
||||||
|
type PasswordVisibilityUncontrolled = {
|
||||||
|
value?: string;
|
||||||
|
onChangeText: (text: string) => void;
|
||||||
|
placeholder: string;
|
||||||
|
showPassword?: never;
|
||||||
|
onShowPasswordChange?: never;
|
||||||
|
topOffset?: number;
|
||||||
|
layout?: "tv" | "mobile";
|
||||||
|
defaultShowPassword?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PasswordInputProps =
|
||||||
|
| PasswordVisibilityControlled
|
||||||
|
| PasswordVisibilityUncontrolled;
|
||||||
|
|
||||||
|
export const PasswordInput: React.FC<PasswordInputProps> = (props) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const {
|
||||||
|
value = "",
|
||||||
|
onChangeText,
|
||||||
|
placeholder,
|
||||||
|
topOffset = 14, // Default 14px for mobile
|
||||||
|
layout = "mobile",
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
// Type guard to check if we're in controlled mode
|
||||||
|
const isControlled =
|
||||||
|
"showPassword" in props && "onShowPasswordChange" in props;
|
||||||
|
|
||||||
|
// Internal state for uncontrolled mode
|
||||||
|
const [internalShowPassword, setInternalShowPassword] = useState(() =>
|
||||||
|
!isControlled && "defaultShowPassword" in props
|
||||||
|
? ((props as PasswordVisibilityUncontrolled).defaultShowPassword ?? false)
|
||||||
|
: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use controlled value if available, otherwise use internal state
|
||||||
|
const showPassword = isControlled
|
||||||
|
? (props as PasswordVisibilityControlled).showPassword
|
||||||
|
: internalShowPassword;
|
||||||
|
|
||||||
|
const handleTogglePassword = () => {
|
||||||
|
if (isControlled) {
|
||||||
|
(props as PasswordVisibilityControlled).onShowPasswordChange(
|
||||||
|
!showPassword,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// For uncontrolled mode, toggle internal state
|
||||||
|
setInternalShowPassword(!showPassword);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate top position style with validation
|
||||||
|
const getTopStyle = () => {
|
||||||
|
if (typeof topOffset !== "number" || Number.isNaN(topOffset)) {
|
||||||
|
console.warn(`Invalid topOffset value: ${topOffset}`);
|
||||||
|
return { top: 14 }; // Default fallback (14px for mobile)
|
||||||
|
}
|
||||||
|
return { top: topOffset };
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
placeholder={placeholder}
|
||||||
|
onChangeText={onChangeText}
|
||||||
|
value={value}
|
||||||
|
secureTextEntry={!showPassword}
|
||||||
|
extraClassName='pr-4'
|
||||||
|
/>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleTogglePassword}
|
||||||
|
className={`absolute right-3 p-1 ${
|
||||||
|
layout === "tv" ? "h-10 justify-center" : ""
|
||||||
|
}`}
|
||||||
|
style={getTopStyle()}
|
||||||
|
accessible={true}
|
||||||
|
accessibilityRole='button'
|
||||||
|
accessibilityLabel={
|
||||||
|
showPassword ? t("login.hide_password") : t("login.show_password")
|
||||||
|
}
|
||||||
|
accessibilityHint={t("login.toggle_password_visibility")}
|
||||||
|
accessibilityState={{ checked: showPassword }}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={showPassword ? "eye-off" : "eye"}
|
||||||
|
size={24}
|
||||||
|
color='white'
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -23,7 +23,6 @@ import Animated, {
|
|||||||
withTiming,
|
withTiming,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
@@ -40,7 +39,6 @@ interface Props extends React.ComponentProps<typeof Button> {
|
|||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
selectedOptions: SelectedOptions;
|
selectedOptions: SelectedOptions;
|
||||||
isOffline?: boolean;
|
isOffline?: boolean;
|
||||||
colors?: ThemeColors;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ANIMATION_DURATION = 500;
|
const ANIMATION_DURATION = 500;
|
||||||
@@ -50,7 +48,6 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
item,
|
item,
|
||||||
selectedOptions,
|
selectedOptions,
|
||||||
isOffline,
|
isOffline,
|
||||||
colors,
|
|
||||||
...props
|
...props
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
@@ -58,19 +55,16 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
const mediaStatus = useMediaStatus();
|
const mediaStatus = useMediaStatus();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [globalColorAtom] = useAtom(itemThemeColorAtom);
|
const [colorAtom] = useAtom(itemThemeColorAtom);
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const user = useAtomValue(userAtom);
|
const user = useAtomValue(userAtom);
|
||||||
|
|
||||||
// Use colors prop if provided, otherwise fallback to global atom
|
|
||||||
const effectiveColors = colors || globalColorAtom;
|
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const startWidth = useSharedValue(0);
|
const startWidth = useSharedValue(0);
|
||||||
const targetWidth = useSharedValue(0);
|
const targetWidth = useSharedValue(0);
|
||||||
const endColor = useSharedValue(effectiveColors);
|
const endColor = useSharedValue(colorAtom);
|
||||||
const startColor = useSharedValue(effectiveColors);
|
const startColor = useSharedValue(colorAtom);
|
||||||
const widthProgress = useSharedValue(0);
|
const widthProgress = useSharedValue(0);
|
||||||
const colorChangeProgress = useSharedValue(0);
|
const colorChangeProgress = useSharedValue(0);
|
||||||
const { settings, updateSettings } = useSettings();
|
const { settings, updateSettings } = useSettings();
|
||||||
@@ -303,7 +297,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
useAnimatedReaction(
|
useAnimatedReaction(
|
||||||
() => effectiveColors,
|
() => colorAtom,
|
||||||
(newColor) => {
|
(newColor) => {
|
||||||
endColor.value = newColor;
|
endColor.value = newColor;
|
||||||
colorChangeProgress.value = 0;
|
colorChangeProgress.value = 0;
|
||||||
@@ -312,19 +306,19 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
easing: Easing.bezier(0.9, 0, 0.31, 0.99),
|
easing: Easing.bezier(0.9, 0, 0.31, 0.99),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[effectiveColors],
|
[colorAtom],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeout_2 = setTimeout(() => {
|
const timeout_2 = setTimeout(() => {
|
||||||
startColor.value = effectiveColors;
|
startColor.value = colorAtom;
|
||||||
startWidth.value = targetWidth.value;
|
startWidth.value = targetWidth.value;
|
||||||
}, ANIMATION_DURATION);
|
}, ANIMATION_DURATION);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(timeout_2);
|
clearTimeout(timeout_2);
|
||||||
};
|
};
|
||||||
}, [effectiveColors, item]);
|
}, [colorAtom, item]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ANIMATED STYLES
|
* ANIMATED STYLES
|
||||||
@@ -373,7 +367,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
className={"relative"}
|
className={"relative"}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<View className='absolute w-full h-full top-0 left-0 rounded-full z-10 overflow-hidden'>
|
<View className='absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden'>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[
|
style={[
|
||||||
animatedPrimaryStyle,
|
animatedPrimaryStyle,
|
||||||
@@ -387,15 +381,15 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
|
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[animatedAverageStyle, { opacity: 0.5 }]}
|
style={[animatedAverageStyle, { opacity: 0.5 }]}
|
||||||
className='absolute w-full h-full top-0 left-0 rounded-full'
|
className='absolute w-full h-full top-0 left-0 rounded-xl'
|
||||||
/>
|
/>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: effectiveColors.primary,
|
borderColor: colorAtom.primary,
|
||||||
borderStyle: "solid",
|
borderStyle: "solid",
|
||||||
}}
|
}}
|
||||||
className='flex flex-row items-center justify-center bg-transparent rounded-full z-20 h-12 w-full '
|
className='flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full '
|
||||||
>
|
>
|
||||||
<View className='flex flex-row items-center space-x-2'>
|
<View className='flex flex-row items-center space-x-2'>
|
||||||
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
|
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import Animated, {
|
|||||||
withTiming,
|
withTiming,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
|
||||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
@@ -25,7 +24,6 @@ import type { SelectedOptions } from "./ItemContent";
|
|||||||
interface Props extends React.ComponentProps<typeof Button> {
|
interface Props extends React.ComponentProps<typeof Button> {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
selectedOptions: SelectedOptions;
|
selectedOptions: SelectedOptions;
|
||||||
colors?: ThemeColors;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ANIMATION_DURATION = 500;
|
const ANIMATION_DURATION = 500;
|
||||||
@@ -34,20 +32,16 @@ const MIN_PLAYBACK_WIDTH = 15;
|
|||||||
export const PlayButton: React.FC<Props> = ({
|
export const PlayButton: React.FC<Props> = ({
|
||||||
item,
|
item,
|
||||||
selectedOptions,
|
selectedOptions,
|
||||||
colors,
|
|
||||||
...props
|
...props
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const [globalColorAtom] = useAtom(itemThemeColorAtom);
|
const [colorAtom] = useAtom(itemThemeColorAtom);
|
||||||
|
|
||||||
// Use colors prop if provided, otherwise fallback to global atom
|
|
||||||
const effectiveColors = colors || globalColorAtom;
|
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const startWidth = useSharedValue(0);
|
const startWidth = useSharedValue(0);
|
||||||
const targetWidth = useSharedValue(0);
|
const targetWidth = useSharedValue(0);
|
||||||
const endColor = useSharedValue(effectiveColors);
|
const endColor = useSharedValue(colorAtom);
|
||||||
const startColor = useSharedValue(effectiveColors);
|
const startColor = useSharedValue(colorAtom);
|
||||||
const widthProgress = useSharedValue(0);
|
const widthProgress = useSharedValue(0);
|
||||||
const colorChangeProgress = useSharedValue(0);
|
const colorChangeProgress = useSharedValue(0);
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
@@ -107,7 +101,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
useAnimatedReaction(
|
useAnimatedReaction(
|
||||||
() => effectiveColors,
|
() => colorAtom,
|
||||||
(newColor) => {
|
(newColor) => {
|
||||||
endColor.value = newColor;
|
endColor.value = newColor;
|
||||||
colorChangeProgress.value = 0;
|
colorChangeProgress.value = 0;
|
||||||
@@ -116,19 +110,19 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
easing: Easing.bezier(0.9, 0, 0.31, 0.99),
|
easing: Easing.bezier(0.9, 0, 0.31, 0.99),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[effectiveColors],
|
[colorAtom],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeout_2 = setTimeout(() => {
|
const timeout_2 = setTimeout(() => {
|
||||||
startColor.value = effectiveColors;
|
startColor.value = colorAtom;
|
||||||
startWidth.value = targetWidth.value;
|
startWidth.value = targetWidth.value;
|
||||||
}, ANIMATION_DURATION);
|
}, ANIMATION_DURATION);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(timeout_2);
|
clearTimeout(timeout_2);
|
||||||
};
|
};
|
||||||
}, [effectiveColors, item]);
|
}, [colorAtom, item]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ANIMATED STYLES
|
* ANIMATED STYLES
|
||||||
@@ -195,7 +189,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: effectiveColors.primary,
|
borderColor: colorAtom.primary,
|
||||||
borderStyle: "solid",
|
borderStyle: "solid",
|
||||||
}}
|
}}
|
||||||
className='flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full '
|
className='flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full '
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { Platform, View, type ViewProps } from "react-native";
|
import { View, type ViewProps } from "react-native";
|
||||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||||
import { RoundButton } from "./RoundButton";
|
import { RoundButton } from "./RoundButton";
|
||||||
|
|
||||||
@@ -14,21 +14,6 @@ export const PlayedStatus: React.FC<Props> = ({ items, ...props }) => {
|
|||||||
const allPlayed = items.every((item) => item.UserData?.Played);
|
const allPlayed = items.every((item) => item.UserData?.Played);
|
||||||
const toggle = useMarkAsPlayed(items);
|
const toggle = useMarkAsPlayed(items);
|
||||||
|
|
||||||
if (Platform.OS === "ios") {
|
|
||||||
return (
|
|
||||||
<View {...props}>
|
|
||||||
<RoundButton
|
|
||||||
color={allPlayed ? "purple" : "white"}
|
|
||||||
icon={allPlayed ? "checkmark" : "checkmark"}
|
|
||||||
onPress={async () => {
|
|
||||||
await toggle(!allPlayed);
|
|
||||||
}}
|
|
||||||
size={props.size}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<RoundButton
|
<RoundButton
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useMemo } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { useMMKVString } from "react-native-mmkv";
|
import { useMMKVString } from "react-native-mmkv";
|
||||||
|
import { storage } from "@/utils/mmkv";
|
||||||
import { ListGroup } from "./list/ListGroup";
|
import { ListGroup } from "./list/ListGroup";
|
||||||
import { ListItem } from "./list/ListItem";
|
import { ListItem } from "./list/ListItem";
|
||||||
|
|
||||||
@@ -17,8 +18,7 @@ interface PreviousServersListProps {
|
|||||||
export const PreviousServersList: React.FC<PreviousServersListProps> = ({
|
export const PreviousServersList: React.FC<PreviousServersListProps> = ({
|
||||||
onServerSelect,
|
onServerSelect,
|
||||||
}) => {
|
}) => {
|
||||||
const [_previousServers, setPreviousServers] =
|
const [_previousServers] = useMMKVString("previousServers");
|
||||||
useMMKVString("previousServers");
|
|
||||||
|
|
||||||
const previousServers = useMemo(() => {
|
const previousServers = useMemo(() => {
|
||||||
return JSON.parse(_previousServers || "[]") as Server[];
|
return JSON.parse(_previousServers || "[]") as Server[];
|
||||||
@@ -37,14 +37,16 @@ export const PreviousServersList: React.FC<PreviousServersListProps> = ({
|
|||||||
onPress={() => onServerSelect(s)}
|
onPress={() => onServerSelect(s)}
|
||||||
title={s.address}
|
title={s.address}
|
||||||
showArrow
|
showArrow
|
||||||
|
className='min-h-[48px] py-2'
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<ListItem
|
<ListItem
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
setPreviousServers("[]");
|
storage.delete("previousServers");
|
||||||
}}
|
}}
|
||||||
title={t("server.clear_button")}
|
title={t("server.clear_button")}
|
||||||
textColor='red'
|
textColor='red'
|
||||||
|
className='min-h-[48px] py-2'
|
||||||
/>
|
/>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ interface Props extends ViewProps {
|
|||||||
background?: boolean;
|
background?: boolean;
|
||||||
size?: "default" | "large";
|
size?: "default" | "large";
|
||||||
fillColor?: "primary";
|
fillColor?: "primary";
|
||||||
color?: "white" | "purple";
|
|
||||||
hapticFeedback?: boolean;
|
hapticFeedback?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,7 +20,6 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
children,
|
children,
|
||||||
size = "default",
|
size = "default",
|
||||||
fillColor,
|
fillColor,
|
||||||
color = "white",
|
|
||||||
hapticFeedback = true,
|
hapticFeedback = true,
|
||||||
...viewProps
|
...viewProps
|
||||||
}) => {
|
}) => {
|
||||||
@@ -36,25 +34,6 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
onPress?.();
|
onPress?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
if (Platform.OS === "ios") {
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handlePress}
|
|
||||||
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`}
|
|
||||||
{...(viewProps as any)}
|
|
||||||
>
|
|
||||||
{icon ? (
|
|
||||||
<Ionicons
|
|
||||||
name={icon}
|
|
||||||
size={size === "large" ? 22 : 18}
|
|
||||||
color={color === "white" ? "white" : "#9334E9"}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{children ? children : null}
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fillColor)
|
if (fillColor)
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
|
|||||||
12
components/_template.tsx
Normal file
12
components/_template.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -19,18 +19,6 @@ export const HeaderBackButton: React.FC<Props> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
if (Platform.OS === "ios") {
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => router.back()}
|
|
||||||
className='flex items-center justify-center w-9 h-9'
|
|
||||||
{...touchableOpacityProps}
|
|
||||||
>
|
|
||||||
<Ionicons name='arrow-back' size={24} color='white' />
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (background === "transparent" && Platform.OS !== "android")
|
if (background === "transparent" && Platform.OS !== "android")
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ export function Input(props: InputProps) {
|
|||||||
<TextInput
|
<TextInput
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
className={`
|
className={`
|
||||||
w-full text-lg px-5 py-4 rounded-2xl
|
w-full text-lg px-5 py-5 rounded-2xl
|
||||||
${isFocused ? "bg-neutral-700 border-2 border-white" : "bg-neutral-900 border-2 border-transparent"}
|
${isFocused ? "bg-neutral-700 border-2 border-white" : "bg-neutral-900 border-2 border-neutral-800"}
|
||||||
text-white ${extraClassName}
|
text-white ${extraClassName}
|
||||||
`}
|
`}
|
||||||
allowFontScaling={false}
|
allowFontScaling={false}
|
||||||
@@ -41,11 +41,15 @@ export function Input(props: InputProps) {
|
|||||||
) : (
|
) : (
|
||||||
<TextInput
|
<TextInput
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
className='p-4 rounded-xl bg-neutral-900'
|
className={`p-3 rounded-xl bg-neutral-900 ${
|
||||||
|
isFocused ? "border-2 border-white" : "border-2 border-neutral-800"
|
||||||
|
} ${extraClassName}`}
|
||||||
allowFontScaling={false}
|
allowFontScaling={false}
|
||||||
style={[{ color: "white" }, style]}
|
style={[{ color: "white" }, style]}
|
||||||
placeholderTextColor={"#9CA3AF"}
|
placeholderTextColor={"#9CA3AF"}
|
||||||
clearButtonMode='while-editing'
|
clearButtonMode='while-editing'
|
||||||
|
onFocus={() => setIsFocused(true)}
|
||||||
|
onBlur={() => setIsFocused(false)}
|
||||||
{...otherProps}
|
{...otherProps}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
|
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
|
||||||
|
|
||||||
const from = (segments as string[])[2] || "(home)";
|
const from = segments[2] || "(home)";
|
||||||
|
|
||||||
const autoApprove = useMemo(() => {
|
const autoApprove = useMemo(() => {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -43,48 +43,6 @@ export const itemRouter = (item: BaseItemDto, from: string) => {
|
|||||||
return `/(auth)/(tabs)/${from}/items/page?id=${item.Id}`;
|
return `/(auth)/(tabs)/${from}/items/page?id=${item.Id}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getItemNavigation = (item: BaseItemDto, _from: string) => {
|
|
||||||
if ("CollectionType" in item && item.CollectionType === "livetv") {
|
|
||||||
return {
|
|
||||||
pathname: "/livetv" as const,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.Type === "Series") {
|
|
||||||
return {
|
|
||||||
pathname: "/series/[id]" as const,
|
|
||||||
params: { id: item.Id! },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.Type === "Person") {
|
|
||||||
return {
|
|
||||||
pathname: "/persons/[personId]" as const,
|
|
||||||
params: { personId: item.Id! },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.Type === "BoxSet" || item.Type === "UserView") {
|
|
||||||
return {
|
|
||||||
pathname: "/collections/[collectionId]" as const,
|
|
||||||
params: { collectionId: item.Id! },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.Type === "CollectionFolder" || item.Type === "Playlist") {
|
|
||||||
return {
|
|
||||||
pathname: "/[libraryId]" as const,
|
|
||||||
params: { libraryId: item.Id! },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default case - items page
|
|
||||||
return {
|
|
||||||
pathname: "/items/page" as const,
|
|
||||||
params: { id: item.Id! },
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||||
item,
|
item,
|
||||||
isOffline = false,
|
isOffline = false,
|
||||||
@@ -97,7 +55,7 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
const markAsPlayedStatus = useMarkAsPlayed([item]);
|
const markAsPlayedStatus = useMarkAsPlayed([item]);
|
||||||
const { isFavorite, toggleFavorite } = useFavorite(item);
|
const { isFavorite, toggleFavorite } = useFavorite(item);
|
||||||
|
|
||||||
const from = (segments as string[])[2] || "(home)";
|
const from = segments[2] || "(home)";
|
||||||
|
|
||||||
const showActionSheet = useCallback(() => {
|
const showActionSheet = useCallback(() => {
|
||||||
if (
|
if (
|
||||||
@@ -143,15 +101,12 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onLongPress={showActionSheet}
|
onLongPress={showActionSheet}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
|
let url = itemRouter(item, from);
|
||||||
if (isOffline) {
|
if (isOffline) {
|
||||||
// For offline mode, we still need to use query params
|
url += `&offline=true`;
|
||||||
const url = `${itemRouter(item, from)}&offline=true`;
|
|
||||||
router.push(url as any);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
// @ts-expect-error
|
||||||
const navigation = getItemNavigation(item, from);
|
router.push(url);
|
||||||
router.push(navigation as any);
|
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { t } from "i18next";
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Platform,
|
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
type TouchableOpacityProps,
|
type TouchableOpacityProps,
|
||||||
View,
|
View,
|
||||||
@@ -110,9 +109,9 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Action buttons in bottom right corner */}
|
{/* Action buttons in top right corner */}
|
||||||
<View className='absolute bottom-2 right-2 flex flex-row items-center space-x-2 z-10'>
|
<View className='absolute top-2 right-2 flex flex-row items-center space-x-2 z-10'>
|
||||||
{process.status === "downloading" && Platform.OS !== "ios" && (
|
{process.status === "downloading" && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => handlePause(process.id)}
|
onPress={() => handlePause(process.id)}
|
||||||
className='p-1'
|
className='p-1'
|
||||||
@@ -120,7 +119,7 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|||||||
<Ionicons name='pause' size={20} color='white' />
|
<Ionicons name='pause' size={20} color='white' />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
{process.status === "paused" && Platform.OS !== "ios" && (
|
{process.status === "paused" && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => handleResume(process.id)}
|
onPress={() => handleResume(process.id)}
|
||||||
className='p-1'
|
className='p-1'
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
|||||||
*/
|
*/
|
||||||
const handleDeleteFile = useCallback(() => {
|
const handleDeleteFile = useCallback(() => {
|
||||||
if (item.Id) {
|
if (item.Id) {
|
||||||
deleteFile(item.Id, item.Type);
|
deleteFile(item.Id, "Movie");
|
||||||
}
|
}
|
||||||
}, [deleteFile, item.Id]);
|
}, [deleteFile, item.Id]);
|
||||||
|
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ export const FilterSheet = <T,>({
|
|||||||
{showSearch && (
|
{showSearch && (
|
||||||
<Input
|
<Input
|
||||||
placeholder={t("search.search")}
|
placeholder={t("search.search")}
|
||||||
className='my-2 border-neutral-800 border'
|
extraClassName='my-2 border-neutral-800 border'
|
||||||
value={search}
|
value={search}
|
||||||
onChangeText={(text) => {
|
onChangeText={(text) => {
|
||||||
setSearch(text);
|
setSearch(text);
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
import { getItemNavigation } from "../common/TouchableItemRouter";
|
import { itemRouter } from "../common/TouchableItemRouter";
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
@@ -88,24 +88,22 @@ export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
|
|||||||
if (!popularItems) return null;
|
if (!popularItems) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='flex flex-col items-center' {...props}>
|
<View className='flex flex-col items-center mt-2' {...props}>
|
||||||
<Carousel
|
<Carousel
|
||||||
ref={ref}
|
ref={ref}
|
||||||
autoPlay={false}
|
autoPlay={false}
|
||||||
loop={true}
|
loop={true}
|
||||||
snapEnabled={true}
|
snapEnabled={true}
|
||||||
vertical={false}
|
|
||||||
mode='parallax'
|
mode='parallax'
|
||||||
modeConfig={{
|
modeConfig={{
|
||||||
parallaxScrollingScale: 1,
|
parallaxScrollingScale: 0.86,
|
||||||
parallaxScrollingOffset: 0,
|
parallaxScrollingOffset: 100,
|
||||||
}}
|
}}
|
||||||
width={width}
|
width={width}
|
||||||
height={500}
|
height={204}
|
||||||
data={popularItems}
|
data={popularItems}
|
||||||
onProgressChange={progress}
|
onProgressChange={progress}
|
||||||
renderItem={({ item, index }) => <RenderItem key={index} item={item} />}
|
renderItem={({ item, index }) => <RenderItem key={index} item={item} />}
|
||||||
scrollAnimationDuration={1000}
|
|
||||||
/>
|
/>
|
||||||
<Pagination.Basic
|
<Pagination.Basic
|
||||||
progress={progress}
|
progress={progress}
|
||||||
@@ -148,20 +146,20 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
|||||||
}, [item]);
|
}, [item]);
|
||||||
|
|
||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
const from = (segments as string[])[2] || "(home)";
|
const from = segments[2] || "(home)";
|
||||||
|
|
||||||
const opacity = useSharedValue(1);
|
const opacity = useSharedValue(1);
|
||||||
|
|
||||||
const handleRoute = useCallback(() => {
|
const handleRoute = useCallback(() => {
|
||||||
if (!from) return;
|
if (!from) return;
|
||||||
|
const url = itemRouter(item, from);
|
||||||
lightHapticFeedback();
|
lightHapticFeedback();
|
||||||
const navigation = getItemNavigation(item, from);
|
// @ts-expect-error
|
||||||
router.push(navigation as any);
|
if (url) router.push(url);
|
||||||
}, [item, from]);
|
}, [item, from]);
|
||||||
|
|
||||||
const tap = Gesture.Tap()
|
const tap = Gesture.Tap()
|
||||||
.maxDuration(2000)
|
.maxDuration(2000)
|
||||||
.shouldCancelWhenOutside(true)
|
|
||||||
.onBegin(() => {
|
.onBegin(() => {
|
||||||
opacity.value = withTiming(0.8, { duration: 100 });
|
opacity.value = withTiming(0.8, { duration: 100 });
|
||||||
})
|
})
|
||||||
@@ -176,19 +174,25 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<GestureDetector gesture={tap}>
|
<GestureDetector gesture={tap}>
|
||||||
<Animated.View style={{ opacity }}>
|
<Animated.View
|
||||||
<View className='relative flex justify-center overflow-hidden border border-neutral-800'>
|
style={{
|
||||||
|
opacity: opacity,
|
||||||
|
}}
|
||||||
|
className='px-4'
|
||||||
|
>
|
||||||
|
<View className='relative flex justify-center rounded-2xl overflow-hidden border border-neutral-800'>
|
||||||
<Image
|
<Image
|
||||||
source={{
|
source={{
|
||||||
uri,
|
uri,
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: 500,
|
height: 200,
|
||||||
|
borderRadius: 16,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<View className='absolute bottom-0 left-0 w-full flex items-center'>
|
<View className='absolute bottom-0 left-0 w-full h-24 p-4 flex items-center'>
|
||||||
<Image
|
<Image
|
||||||
source={{
|
source={{
|
||||||
uri: logoUri,
|
uri: logoUri,
|
||||||
|
|||||||
@@ -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",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -22,7 +22,7 @@ const PersonPoster: React.FC<Props & ViewProps> = ({
|
|||||||
const { jellyseerrApi } = useJellyseerr();
|
const { jellyseerrApi } = useJellyseerr();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
const from = (segments as string[])[2] || "(home)";
|
const from = segments[2] || "(home)";
|
||||||
|
|
||||||
if (from === "(home)" || from === "(search)" || from === "(libraries)")
|
if (from === "(home)" || from === "(search)" || from === "(libraries)")
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -16,12 +16,13 @@ const CompanySlide: React.FC<
|
|||||||
> = ({ slide, data, ...props }) => {
|
> = ({ slide, data, ...props }) => {
|
||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
const { jellyseerrApi } = useJellyseerr();
|
const { jellyseerrApi } = useJellyseerr();
|
||||||
const from = (segments as string[])[2] || "(home)";
|
const from = segments[2] || "(home)";
|
||||||
|
|
||||||
const navigate = useCallback(
|
const navigate = useCallback(
|
||||||
({ id, image, name }: Network | Studio) =>
|
({ id, image, name }: Network | Studio) =>
|
||||||
router.push({
|
router.push({
|
||||||
pathname: `/(auth)/(tabs)/${from}/jellyseerr/company/${id}` as any,
|
// @ts-expect-error - Dynamic pathname for jellyseerr routing
|
||||||
|
pathname: `/(auth)/(tabs)/${from}/jellyseerr/company/${id}`,
|
||||||
params: { id, image, name, type: slide.type },
|
params: { id, image, name, type: slide.type },
|
||||||
}),
|
}),
|
||||||
[slide],
|
[slide],
|
||||||
|
|||||||
@@ -13,12 +13,13 @@ import { genreColorMap } from "@/utils/jellyseerr/src/components/Discover/consta
|
|||||||
const GenreSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
|
const GenreSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
|
||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
const { jellyseerrApi } = useJellyseerr();
|
const { jellyseerrApi } = useJellyseerr();
|
||||||
const from = (segments as string[])[2] || "(home)";
|
const from = segments[2] || "(home)";
|
||||||
|
|
||||||
const navigate = useCallback(
|
const navigate = useCallback(
|
||||||
(genre: GenreSliderItem) =>
|
(genre: GenreSliderItem) =>
|
||||||
router.push({
|
router.push({
|
||||||
pathname: `/(auth)/(tabs)/${from}/jellyseerr/genre/${genre.id}` as any,
|
// @ts-expect-error - Dynamic pathname for jellyseerr routing
|
||||||
|
pathname: `/(auth)/(tabs)/${from}/jellyseerr/genre/${genre.id}`,
|
||||||
params: { type: slide.type, name: genre.name },
|
params: { type: slide.type, name: genre.name },
|
||||||
}),
|
}),
|
||||||
[slide],
|
[slide],
|
||||||
|
|||||||
@@ -8,14 +8,7 @@ import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
|||||||
import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
|
import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
|
||||||
import type { NonFunctionProperties } from "@/utils/jellyseerr/server/interfaces/api/common";
|
import type { NonFunctionProperties } from "@/utils/jellyseerr/server/interfaces/api/common";
|
||||||
|
|
||||||
type ExtendedMediaRequest = NonFunctionProperties<MediaRequest> & {
|
const RequestCard: React.FC<{ request: MediaRequest }> = ({ request }) => {
|
||||||
profileName: string;
|
|
||||||
canRemove: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const RequestCard: React.FC<{ request: ExtendedMediaRequest }> = ({
|
|
||||||
request,
|
|
||||||
}) => {
|
|
||||||
const { jellyseerrApi } = useJellyseerr();
|
const { jellyseerrApi } = useJellyseerr();
|
||||||
|
|
||||||
const { data: details } = useQuery({
|
const { data: details } = useQuery({
|
||||||
@@ -74,15 +67,9 @@ const RecentRequestsSlide: React.FC<SlideProps & ViewProps> = ({
|
|||||||
<Slide
|
<Slide
|
||||||
{...props}
|
{...props}
|
||||||
slide={slide}
|
slide={slide}
|
||||||
data={
|
data={requests.results}
|
||||||
requests.results.map((item) => ({
|
|
||||||
...item,
|
|
||||||
profileName: item.profileName ?? "Unknown",
|
|
||||||
canRemove: Boolean(item.canRemove),
|
|
||||||
})) as ExtendedMediaRequest[]
|
|
||||||
}
|
|
||||||
keyExtractor={(item) => item.id.toString()}
|
keyExtractor={(item) => item.id.toString()}
|
||||||
renderItem={(item: ExtendedMediaRequest) => (
|
renderItem={(item: NonFunctionProperties<MediaRequest>) => (
|
||||||
<RequestCard request={item} />
|
<RequestCard request={item} />
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import type { PropsWithChildren, ReactNode } from "react";
|
import type { PropsWithChildren, ReactNode } from "react";
|
||||||
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
import { Platform, TouchableOpacity, View, type ViewProps } from "react-native";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { apiAtom } from "@/providers/JellyfinProvider";
|
|||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
import { HorizontalScroll } from "../common/HorizontalScroll";
|
import { HorizontalScroll } from "../common/HorizontalScroll";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
|
import { itemRouter } from "../common/TouchableItemRouter";
|
||||||
import Poster from "../posters/Poster";
|
import Poster from "../posters/Poster";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
@@ -23,7 +24,7 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
|
|||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const from = (segments as string[])[2];
|
const from = segments[2];
|
||||||
|
|
||||||
const destinctPeople = useMemo(() => {
|
const destinctPeople = useMemo(() => {
|
||||||
const people: Record<string, BaseItemPerson> = {};
|
const people: Record<string, BaseItemPerson> = {};
|
||||||
@@ -55,12 +56,15 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
|
|||||||
renderItem={(i) => (
|
renderItem={(i) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (i.Id) {
|
const url = itemRouter(
|
||||||
router.push({
|
{
|
||||||
pathname: "/persons/[personId]",
|
Id: i.Id,
|
||||||
params: { personId: i.Id },
|
Type: "Person",
|
||||||
});
|
},
|
||||||
}
|
from,
|
||||||
|
);
|
||||||
|
// @ts-expect-error
|
||||||
|
router.push(url);
|
||||||
}}
|
}}
|
||||||
className='flex flex-col w-28'
|
className='flex flex-col w-28'
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useEffect, useMemo, useRef } from "react";
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
import { TouchableOpacity, type ViewProps } from "react-native";
|
import { TouchableOpacity, type ViewProps } from "react-native";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||||
import {
|
import {
|
||||||
HorizontalScroll,
|
HorizontalScroll,
|
||||||
@@ -56,7 +57,6 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
|||||||
userId: user.Id,
|
userId: user.Id,
|
||||||
seasonId: seasonId || undefined,
|
seasonId: seasonId || undefined,
|
||||||
seriesId: item.SeriesId,
|
seriesId: item.SeriesId,
|
||||||
enableUserData: true,
|
|
||||||
fields: [
|
fields: [
|
||||||
"ItemCounts",
|
"ItemCounts",
|
||||||
"PrimaryImageAspectRatio",
|
"PrimaryImageAspectRatio",
|
||||||
@@ -70,6 +70,48 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
|||||||
enabled: !!api && !!user?.Id && !!seasonId,
|
enabled: !!api && !!user?.Id && !!seasonId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefetch previous and next episode
|
||||||
|
*/
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
useEffect(() => {
|
||||||
|
if (!item?.Id || !item.IndexNumber || !episodes || episodes.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousId = episodes?.find(
|
||||||
|
(ep) => ep.IndexNumber === item.IndexNumber! - 1,
|
||||||
|
)?.Id;
|
||||||
|
if (previousId) {
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: ["item", previousId],
|
||||||
|
queryFn: async () =>
|
||||||
|
await getUserItemData({
|
||||||
|
api,
|
||||||
|
userId: user?.Id,
|
||||||
|
itemId: previousId,
|
||||||
|
}),
|
||||||
|
staleTime: 60 * 1000 * 5,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextId = episodes?.find(
|
||||||
|
(ep) => ep.IndexNumber === item.IndexNumber! + 1,
|
||||||
|
)?.Id;
|
||||||
|
if (nextId) {
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: ["item", nextId],
|
||||||
|
queryFn: async () =>
|
||||||
|
await getUserItemData({
|
||||||
|
api,
|
||||||
|
userId: user?.Id,
|
||||||
|
itemId: nextId,
|
||||||
|
}),
|
||||||
|
staleTime: 60 * 1000 * 5,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [episodes, api, user?.Id, item]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (item?.Type === "Episode" && item.Id) {
|
if (item?.Type === "Episode" && item.Id) {
|
||||||
const index = episodes?.findIndex((ep) => ep.Id === item.Id);
|
const index = episodes?.findIndex((ep) => ep.Id === item.Id);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom } from "jotai";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
type SeasonIndexState,
|
type SeasonIndexState,
|
||||||
} from "@/components/series/SeasonDropdown";
|
} from "@/components/series/SeasonDropdown";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
import { runtimeTicksToSeconds } from "@/utils/time";
|
import { runtimeTicksToSeconds } from "@/utils/time";
|
||||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
@@ -86,7 +87,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
|||||||
seasonId: selectedSeasonId,
|
seasonId: selectedSeasonId,
|
||||||
enableUserData: true,
|
enableUserData: true,
|
||||||
// Note: Including trick play is necessary to enable trick play downloads
|
// Note: Including trick play is necessary to enable trick play downloads
|
||||||
fields: ["Overview", "Trickplay"],
|
fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.data.TotalRecordCount === 0)
|
if (res.data.TotalRecordCount === 0)
|
||||||
@@ -100,6 +101,25 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
|||||||
enabled: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
|
enabled: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
useEffect(() => {
|
||||||
|
for (const e of episodes || []) {
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: ["item", e.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!e.Id) return;
|
||||||
|
const res = await getUserItemData({
|
||||||
|
api,
|
||||||
|
userId: user?.Id,
|
||||||
|
itemId: e.Id,
|
||||||
|
});
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
staleTime: 60 * 5 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [episodes]);
|
||||||
|
|
||||||
// Used for height calculation
|
// Used for height calculation
|
||||||
const [nrOfEpisodes, setNrOfEpisodes] = useState(0);
|
const [nrOfEpisodes, setNrOfEpisodes] = useState(0);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Linking,
|
Linking,
|
||||||
@@ -17,7 +16,6 @@ interface Props extends ViewProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ItemActions = ({ item, ...props }: Props) => {
|
export const ItemActions = ({ item, ...props }: Props) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
const trailerLink = useMemo(() => {
|
const trailerLink = useMemo(() => {
|
||||||
if ("RemoteTrailers" in item && item.RemoteTrailers?.[0]?.Url) {
|
if ("RemoteTrailers" in item && item.RemoteTrailers?.[0]?.Url) {
|
||||||
return item.RemoteTrailers[0].Url;
|
return item.RemoteTrailers[0].Url;
|
||||||
@@ -32,7 +30,7 @@ export const ItemActions = ({ item, ...props }: Props) => {
|
|||||||
|
|
||||||
const openTrailer = useCallback(async () => {
|
const openTrailer = useCallback(async () => {
|
||||||
if (!trailerLink) {
|
if (!trailerLink) {
|
||||||
Alert.alert(t("common.no_trailer_available"));
|
Alert.alert("No trailer available");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +39,7 @@ export const ItemActions = ({ item, ...props }: Props) => {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to open trailer link:", err);
|
console.error("Failed to open trailer link:", err);
|
||||||
}
|
}
|
||||||
}, [trailerLink, t]);
|
}, [trailerLink]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='' {...props}>
|
<View className='' {...props}>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export default function DownloadSettings({ ...props }) {
|
|||||||
const allDisabled = useMemo(
|
const allDisabled = useMemo(
|
||||||
() =>
|
() =>
|
||||||
pluginSettings?.remuxConcurrentLimit?.locked === true &&
|
pluginSettings?.remuxConcurrentLimit?.locked === true &&
|
||||||
pluginSettings?.autoDownload?.locked === true,
|
pluginSettings?.autoDownload.locked === true,
|
||||||
[pluginSettings],
|
[pluginSettings],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
|
|||||||
"home.settings.gesture_controls.horizontal_swipe_skip_description",
|
"home.settings.gesture_controls.horizontal_swipe_skip_description",
|
||||||
)}
|
)}
|
||||||
disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked}
|
disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked}
|
||||||
|
style={{ minHeight: 72, paddingTop: 12, paddingBottom: 12 }}
|
||||||
>
|
>
|
||||||
<Switch
|
<Switch
|
||||||
value={settings.enableHorizontalSwipeSkip}
|
value={settings.enableHorizontalSwipeSkip}
|
||||||
@@ -52,6 +53,7 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
|
|||||||
"home.settings.gesture_controls.left_side_brightness_description",
|
"home.settings.gesture_controls.left_side_brightness_description",
|
||||||
)}
|
)}
|
||||||
disabled={pluginSettings?.enableLeftSideBrightnessSwipe?.locked}
|
disabled={pluginSettings?.enableLeftSideBrightnessSwipe?.locked}
|
||||||
|
style={{ minHeight: 72, paddingTop: 12, paddingBottom: 12 }}
|
||||||
>
|
>
|
||||||
<Switch
|
<Switch
|
||||||
value={settings.enableLeftSideBrightnessSwipe}
|
value={settings.enableLeftSideBrightnessSwipe}
|
||||||
@@ -68,6 +70,7 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
|
|||||||
"home.settings.gesture_controls.right_side_volume_description",
|
"home.settings.gesture_controls.right_side_volume_description",
|
||||||
)}
|
)}
|
||||||
disabled={pluginSettings?.enableRightSideVolumeSwipe?.locked}
|
disabled={pluginSettings?.enableRightSideVolumeSwipe?.locked}
|
||||||
|
style={{ minHeight: 72, paddingTop: 12, paddingBottom: 12 }}
|
||||||
>
|
>
|
||||||
<Switch
|
<Switch
|
||||||
value={settings.enableRightSideVolumeSwipe}
|
value={settings.enableRightSideVolumeSwipe}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Feather, Ionicons } from "@expo/vector-icons";
|
|||||||
import type { Api } from "@jellyfin/sdk";
|
import type { Api } from "@jellyfin/sdk";
|
||||||
import type {
|
import type {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
BaseItemDtoQueryResult,
|
|
||||||
BaseItemKind,
|
BaseItemKind,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import {
|
import {
|
||||||
@@ -28,6 +27,7 @@ import {
|
|||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel";
|
||||||
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
||||||
@@ -38,7 +38,6 @@ import { useDownload } from "@/providers/DownloadProvider";
|
|||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { eventBus } from "@/utils/eventBus";
|
import { eventBus } from "@/utils/eventBus";
|
||||||
import { AppleTVCarousel } from "../AppleTVCarousel";
|
|
||||||
|
|
||||||
type ScrollingCollectionListSection = {
|
type ScrollingCollectionListSection = {
|
||||||
type: "ScrollingCollectionList";
|
type: "ScrollingCollectionList";
|
||||||
@@ -75,12 +74,7 @@ export const HomeIndex = () => {
|
|||||||
|
|
||||||
const { getDownloadedItems, cleanCacheDirectory } = useDownload();
|
const { getDownloadedItems, cleanCacheDirectory } = useDownload();
|
||||||
const prevIsConnected = useRef<boolean | null>(false);
|
const prevIsConnected = useRef<boolean | null>(false);
|
||||||
const {
|
const { isConnected, loading: retryLoading, retryCheck } = useNetworkStatus();
|
||||||
isConnected,
|
|
||||||
serverConnected,
|
|
||||||
loading: retryLoading,
|
|
||||||
retryCheck,
|
|
||||||
} = useNetworkStatus();
|
|
||||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only invalidate cache when transitioning from offline to online
|
// Only invalidate cache when transitioning from offline to online
|
||||||
@@ -126,11 +120,8 @@ export const HomeIndex = () => {
|
|||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = eventBus.on("scrollToTop", () => {
|
const unsubscribe = eventBus.on("scrollToTop", () => {
|
||||||
if ((segments as string[])[2] === "(home)")
|
if (segments[2] === "(home)")
|
||||||
scrollViewRef.current?.scrollTo({
|
scrollViewRef.current?.scrollTo({ y: -152, animated: true });
|
||||||
y: Platform.isTV ? -152 : -100,
|
|
||||||
animated: true,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -196,9 +187,9 @@ export const HomeIndex = () => {
|
|||||||
await getUserLibraryApi(api).getLatestMedia({
|
await getUserLibraryApi(api).getLatestMedia({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
limit: 20,
|
limit: 20,
|
||||||
fields: ["PrimaryImageAspectRatio", "Path", "Genres"],
|
fields: ["PrimaryImageAspectRatio", "Path"],
|
||||||
imageTypeLimit: 1,
|
imageTypeLimit: 1,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
includeItemTypes,
|
includeItemTypes,
|
||||||
parentId,
|
parentId,
|
||||||
})
|
})
|
||||||
@@ -240,9 +231,8 @@ export const HomeIndex = () => {
|
|||||||
(
|
(
|
||||||
await getItemsApi(api).getResumeItems({
|
await getItemsApi(api).getResumeItems({
|
||||||
userId: user.Id,
|
userId: user.Id,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||||
fields: ["Genres"],
|
|
||||||
})
|
})
|
||||||
).data.Items || [],
|
).data.Items || [],
|
||||||
type: "ScrollingCollectionList",
|
type: "ScrollingCollectionList",
|
||||||
@@ -255,9 +245,9 @@ export const HomeIndex = () => {
|
|||||||
(
|
(
|
||||||
await getTvShowsApi(api).getNextUp({
|
await getTvShowsApi(api).getNextUp({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
fields: ["MediaSourceCount", "Genres"],
|
fields: ["MediaSourceCount"],
|
||||||
limit: 20,
|
limit: 20,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
enableResumable: false,
|
enableResumable: false,
|
||||||
})
|
})
|
||||||
).data.Items || [],
|
).data.Items || [],
|
||||||
@@ -318,10 +308,10 @@ export const HomeIndex = () => {
|
|||||||
if (!api || !user?.Id || !settings?.home?.sections) return [];
|
if (!api || !user?.Id || !settings?.home?.sections) return [];
|
||||||
const ss: Section[] = [];
|
const ss: Section[] = [];
|
||||||
for (const [index, section] of settings.home.sections.entries()) {
|
for (const [index, section] of settings.home.sections.entries()) {
|
||||||
const id = section.title || `section-${index}`;
|
const id = section.items?.title || `section-${index}`;
|
||||||
ss.push({
|
ss.push({
|
||||||
title: t(`${id}`),
|
title: t(`${id}`),
|
||||||
queryKey: ["home", "custom", String(index), section.title ?? null],
|
queryKey: ["home", id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (section.items) {
|
if (section.items) {
|
||||||
const response = await getItemsApi(api).getItems({
|
const response = await getItemsApi(api).getItems({
|
||||||
@@ -339,9 +329,9 @@ export const HomeIndex = () => {
|
|||||||
if (section.nextUp) {
|
if (section.nextUp) {
|
||||||
const response = await getTvShowsApi(api).getNextUp({
|
const response = await getTvShowsApi(api).getNextUp({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
fields: ["MediaSourceCount", "Genres"],
|
fields: ["MediaSourceCount"],
|
||||||
limit: section.nextUp?.limit || 25,
|
limit: section.nextUp?.limit || 25,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
enableResumable: section.nextUp?.enableResumable,
|
enableResumable: section.nextUp?.enableResumable,
|
||||||
enableRewatching: section.nextUp?.enableRewatching,
|
enableRewatching: section.nextUp?.enableRewatching,
|
||||||
});
|
});
|
||||||
@@ -357,16 +347,6 @@ export const HomeIndex = () => {
|
|||||||
});
|
});
|
||||||
return response.data || [];
|
return response.data || [];
|
||||||
}
|
}
|
||||||
if (section.custom) {
|
|
||||||
const response = await api.get<BaseItemDtoQueryResult>(
|
|
||||||
section.custom.endpoint,
|
|
||||||
{
|
|
||||||
params: { ...(section.custom.query || {}), userId: user?.Id },
|
|
||||||
headers: section.custom.headers || {},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return response.data.Items || [];
|
|
||||||
}
|
|
||||||
return [];
|
return [];
|
||||||
},
|
},
|
||||||
type: "ScrollingCollectionList",
|
type: "ScrollingCollectionList",
|
||||||
@@ -378,28 +358,13 @@ export const HomeIndex = () => {
|
|||||||
|
|
||||||
const sections = settings?.home?.sections ? customSections : defaultSections;
|
const sections = settings?.home?.sections ? customSections : defaultSections;
|
||||||
|
|
||||||
if (!isConnected || serverConnected !== true) {
|
if (isConnected === false) {
|
||||||
let title = "";
|
|
||||||
let subtitle = "";
|
|
||||||
|
|
||||||
if (!isConnected) {
|
|
||||||
// No network connection
|
|
||||||
title = t("home.no_internet");
|
|
||||||
subtitle = t("home.no_internet_message");
|
|
||||||
} else if (serverConnected === null) {
|
|
||||||
// Network is up, but server is being checked
|
|
||||||
title = t("home.checking_server_connection");
|
|
||||||
subtitle = t("home.checking_server_connection_message");
|
|
||||||
} else if (!serverConnected) {
|
|
||||||
// Network is up, but server is unreachable
|
|
||||||
title = t("home.server_unreachable");
|
|
||||||
subtitle = t("home.server_unreachable_message");
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<View className='flex flex-col items-center justify-center h-full -mt-6 px-8'>
|
<View className='flex flex-col items-center justify-center h-full -mt-6 px-8'>
|
||||||
<Text className='text-3xl font-bold mb-2'>{title}</Text>
|
<Text className='text-3xl font-bold mb-2'>{t("home.no_internet")}</Text>
|
||||||
<Text className='text-center opacity-70'>{subtitle}</Text>
|
<Text className='text-center opacity-70'>
|
||||||
|
{t("home.no_internet_message")}
|
||||||
|
</Text>
|
||||||
<View className='mt-4'>
|
<View className='mt-4'>
|
||||||
{!Platform.isTV && (
|
{!Platform.isTV && (
|
||||||
<Button
|
<Button
|
||||||
@@ -413,7 +378,6 @@ export const HomeIndex = () => {
|
|||||||
{t("home.go_to_downloads")}
|
{t("home.go_to_downloads")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
color='black'
|
color='black'
|
||||||
onPress={retryCheck}
|
onPress={retryCheck}
|
||||||
@@ -426,9 +390,9 @@ export const HomeIndex = () => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{retryLoading ? (
|
{retryLoading ? (
|
||||||
<ActivityIndicator size='small' color='white' />
|
<ActivityIndicator size={"small"} color={"white"} />
|
||||||
) : (
|
) : (
|
||||||
t("home.retry")
|
"Retry"
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
@@ -458,55 +422,44 @@ export const HomeIndex = () => {
|
|||||||
scrollToOverflowEnabled={true}
|
scrollToOverflowEnabled={true}
|
||||||
ref={scrollViewRef}
|
ref={scrollViewRef}
|
||||||
nestedScrollEnabled
|
nestedScrollEnabled
|
||||||
contentInsetAdjustmentBehavior='never'
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl
|
<RefreshControl refreshing={loading} onRefresh={refetch} />
|
||||||
refreshing={loading}
|
|
||||||
onRefresh={refetch}
|
|
||||||
tintColor='white' // For iOS
|
|
||||||
colors={["white"]} // For Android
|
|
||||||
progressViewOffset={200} // This offsets the refresh indicator to appear over the carousel
|
|
||||||
/>
|
|
||||||
}
|
}
|
||||||
style={{ marginTop: Platform.isTV ? 0 : -100 }}
|
contentContainerStyle={{
|
||||||
contentContainerStyle={{ paddingTop: Platform.isTV ? 0 : 100 }}
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
paddingBottom: 16,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<AppleTVCarousel initialIndex={0} />
|
<View className='flex flex-col space-y-4'>
|
||||||
<View
|
<LargeMovieCarousel />
|
||||||
style={{
|
|
||||||
paddingLeft: insets.left,
|
{sections.map((section, index) => {
|
||||||
paddingRight: insets.right,
|
if (section.type === "ScrollingCollectionList") {
|
||||||
paddingBottom: 16,
|
return (
|
||||||
}}
|
<ScrollingCollectionList
|
||||||
>
|
key={index}
|
||||||
<View className='flex flex-col space-y-4'>
|
title={section.title}
|
||||||
{sections.map((section, index) => {
|
queryKey={section.queryKey}
|
||||||
if (section.type === "ScrollingCollectionList") {
|
queryFn={section.queryFn}
|
||||||
return (
|
orientation={section.orientation}
|
||||||
<ScrollingCollectionList
|
hideIfEmpty
|
||||||
key={index}
|
/>
|
||||||
title={section.title}
|
);
|
||||||
queryKey={section.queryKey}
|
}
|
||||||
queryFn={section.queryFn}
|
if (section.type === "MediaListSection") {
|
||||||
orientation={section.orientation}
|
return (
|
||||||
hideIfEmpty
|
<MediaListSection
|
||||||
/>
|
key={index}
|
||||||
);
|
queryKey={section.queryKey}
|
||||||
}
|
queryFn={section.queryFn}
|
||||||
if (section.type === "MediaListSection") {
|
/>
|
||||||
return (
|
);
|
||||||
<MediaListSection
|
}
|
||||||
key={index}
|
return null;
|
||||||
queryKey={section.queryKey}
|
})}
|
||||||
queryFn={section.queryFn}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
<View className='h-24' />
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
|
import { PasswordInput } from "@/components/PasswordInput";
|
||||||
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
|
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import { userAtom } from "@/providers/JellyfinProvider";
|
import { userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
@@ -30,6 +31,9 @@ export const JellyseerrSettings = () => {
|
|||||||
string | undefined
|
string | undefined
|
||||||
>(settings?.jellyseerrServerUrl || undefined);
|
>(settings?.jellyseerrServerUrl || undefined);
|
||||||
|
|
||||||
|
const [showJellyseerrPassword, setShowJellyseerrPassword] =
|
||||||
|
useState<boolean>(false);
|
||||||
|
|
||||||
const loginToJellyseerrMutation = useMutation({
|
const loginToJellyseerrMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
if (!jellyseerrServerUrl && !settings?.jellyseerrServerUrl)
|
if (!jellyseerrServerUrl && !settings?.jellyseerrServerUrl)
|
||||||
@@ -127,7 +131,7 @@ export const JellyseerrSettings = () => {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Input
|
<Input
|
||||||
className='border border-neutral-800 mb-2'
|
extraClassName='border border-neutral-800 mb-2'
|
||||||
placeholder={t(
|
placeholder={t(
|
||||||
"home.settings.plugins.jellyseerr.server_url_placeholder",
|
"home.settings.plugins.jellyseerr.server_url_placeholder",
|
||||||
)}
|
)}
|
||||||
@@ -146,23 +150,20 @@ export const JellyseerrSettings = () => {
|
|||||||
<Text className='font-bold mb-2'>
|
<Text className='font-bold mb-2'>
|
||||||
{t("home.settings.plugins.jellyseerr.password")}
|
{t("home.settings.plugins.jellyseerr.password")}
|
||||||
</Text>
|
</Text>
|
||||||
<Input
|
<View className='relative'>
|
||||||
className='border border-neutral-800'
|
<PasswordInput
|
||||||
autoFocus={true}
|
value={jellyseerrPassword}
|
||||||
focusable={true}
|
onChangeText={setJellyseerrPassword}
|
||||||
placeholder={t(
|
placeholder={t(
|
||||||
"home.settings.plugins.jellyseerr.password_placeholder",
|
"home.settings.plugins.jellyseerr.password_placeholder",
|
||||||
{ username: user?.Name },
|
{ username: user?.Name },
|
||||||
)}
|
)}
|
||||||
value={jellyseerrPassword}
|
showPassword={showJellyseerrPassword}
|
||||||
keyboardType='default'
|
onShowPasswordChange={setShowJellyseerrPassword}
|
||||||
secureTextEntry={true}
|
layout='mobile'
|
||||||
returnKeyType='done'
|
topOffset={11}
|
||||||
autoCapitalize='none'
|
/>
|
||||||
textContentType='password'
|
</View>
|
||||||
onChangeText={setJellyseerrPassword}
|
|
||||||
editable={!loginToJellyseerrMutation.isPending}
|
|
||||||
/>
|
|
||||||
<Button
|
<Button
|
||||||
loading={loginToJellyseerrMutation.isPending}
|
loading={loginToJellyseerrMutation.isPending}
|
||||||
disabled={loginToJellyseerrMutation.isPending}
|
disabled={loginToJellyseerrMutation.isPending}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -41,10 +41,10 @@ export const OtherSettings: React.FC = () => {
|
|||||||
|
|
||||||
if (settings?.autoDownload === true && !registered) {
|
if (settings?.autoDownload === true && !registered) {
|
||||||
registerBackgroundFetchAsync();
|
registerBackgroundFetchAsync();
|
||||||
toast.success(t("home.settings.toasts.background_downloads_enabled"));
|
toast.success("Background downloads enabled");
|
||||||
} else if (settings?.autoDownload === false && registered) {
|
} else if (settings?.autoDownload === false && registered) {
|
||||||
unregisterBackgroundFetchAsync();
|
unregisterBackgroundFetchAsync();
|
||||||
toast.info(t("home.settings.toasts.background_downloads_disabled"));
|
toast.info("Background downloads disabled");
|
||||||
} else if (settings?.autoDownload === true && registered) {
|
} else if (settings?.autoDownload === true && registered) {
|
||||||
// Don't to anything
|
// Don't to anything
|
||||||
} else if (settings?.autoDownload === false && !registered) {
|
} else if (settings?.autoDownload === false && !registered) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
BottomSheetBackdrop,
|
BottomSheetBackdrop,
|
||||||
type BottomSheetBackdropProps,
|
type BottomSheetBackdropProps,
|
||||||
BottomSheetModal,
|
BottomSheetModal,
|
||||||
|
BottomSheetTextInput,
|
||||||
BottomSheetView,
|
BottomSheetView,
|
||||||
} from "@gorhom/bottom-sheet";
|
} from "@gorhom/bottom-sheet";
|
||||||
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
@@ -9,19 +10,17 @@ import { useAtom } from "jotai";
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useCallback, useRef, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Alert, Platform, View, type ViewProps } from "react-native";
|
import { Alert, View, type ViewProps } from "react-native";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { Button } from "../Button";
|
import { Button } from "../Button";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { PinInput } from "../inputs/PinInput";
|
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
import { ListItem } from "../list/ListItem";
|
import { ListItem } from "../list/ListItem";
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
||||||
const isTv = Platform.isTV;
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const [quickConnectCode, setQuickConnectCode] = useState<string>();
|
const [quickConnectCode, setQuickConnectCode] = useState<string>();
|
||||||
@@ -74,17 +73,11 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
|||||||
}
|
}
|
||||||
}, [api, user, quickConnectCode]);
|
}, [api, user, quickConnectCode]);
|
||||||
|
|
||||||
if (isTv) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<ListGroup title={t("home.settings.quick_connect.quick_connect_title")}>
|
<ListGroup title={t("home.settings.quick_connect.quick_connect_title")}>
|
||||||
<ListItem
|
<ListItem
|
||||||
onPress={() => {
|
onPress={() => bottomSheetModalRef?.current?.present()}
|
||||||
// Reset the code when opening the sheet
|
|
||||||
setQuickConnectCode("");
|
|
||||||
bottomSheetModalRef?.current?.present();
|
|
||||||
}}
|
|
||||||
title={t("home.settings.quick_connect.authorize_button")}
|
title={t("home.settings.quick_connect.authorize_button")}
|
||||||
textColor='blue'
|
textColor='blue'
|
||||||
/>
|
/>
|
||||||
@@ -100,9 +93,6 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
|||||||
backgroundColor: "#171717",
|
backgroundColor: "#171717",
|
||||||
}}
|
}}
|
||||||
backdropComponent={renderBackdrop}
|
backdropComponent={renderBackdrop}
|
||||||
keyboardBehavior='interactive'
|
|
||||||
keyboardBlurBehavior='restore'
|
|
||||||
android_keyboardInputMode='adjustResize'
|
|
||||||
>
|
>
|
||||||
<BottomSheetView>
|
<BottomSheetView>
|
||||||
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
||||||
@@ -112,17 +102,16 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className='flex flex-col space-y-2'>
|
<View className='flex flex-col space-y-2'>
|
||||||
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full space-y-4'>
|
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full'>
|
||||||
<Text className='text-neutral-400 text-center'>
|
<BottomSheetTextInput
|
||||||
{t(
|
style={{ color: "white" }}
|
||||||
|
clearButtonMode='always'
|
||||||
|
placeholder={t(
|
||||||
"home.settings.quick_connect.enter_the_quick_connect_code",
|
"home.settings.quick_connect.enter_the_quick_connect_code",
|
||||||
)}
|
)}
|
||||||
</Text>
|
placeholderTextColor='#9CA3AF'
|
||||||
<PinInput
|
value={quickConnectCode}
|
||||||
value={quickConnectCode || ""}
|
|
||||||
onChangeText={setQuickConnectCode}
|
onChangeText={setQuickConnectCode}
|
||||||
style={{ paddingHorizontal: 16 }}
|
|
||||||
autoFocus
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export const StorageSettings = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const calculatePercentage = (value: number, total: number) => {
|
const calculatePercentage = (value: number, total: number) => {
|
||||||
|
console.log("usage", value, total);
|
||||||
return ((value / total) * 100).toFixed(2);
|
return ((value / total) * 100).toFixed(2);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ import { useMedia } from "./MediaContext";
|
|||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
import { OUTLINE_THICKNESS, VLC_COLORS } from "@/constants/SubtitleConstants";
|
|
||||||
|
|
||||||
export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
||||||
const isTv = Platform.isTV;
|
const isTv = Platform.isTV;
|
||||||
|
|
||||||
@@ -27,15 +25,6 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
const cultures = media.cultures;
|
const cultures = media.cultures;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// Get VLC subtitle settings from the settings system
|
|
||||||
const textColor = settings?.vlcTextColor ?? "White";
|
|
||||||
const backgroundColor = settings?.vlcBackgroundColor ?? "Black";
|
|
||||||
const outlineColor = settings?.vlcOutlineColor ?? "Black";
|
|
||||||
const outlineThickness = settings?.vlcOutlineThickness ?? "Normal";
|
|
||||||
const backgroundOpacity = settings?.vlcBackgroundOpacity ?? 128;
|
|
||||||
const outlineOpacity = settings?.vlcOutlineOpacity ?? 255;
|
|
||||||
const isBold = settings?.vlcIsBold ?? false;
|
|
||||||
|
|
||||||
if (isTv) return null;
|
if (isTv) return null;
|
||||||
if (!settings) return null;
|
if (!settings) return null;
|
||||||
|
|
||||||
@@ -158,148 +147,6 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
onUpdate={(subtitleSize) => updateSettings({ subtitleSize })}
|
onUpdate={(subtitleSize) => updateSettings({ subtitleSize })}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem title={t("home.settings.subtitles.text_color")}>
|
|
||||||
<Dropdown
|
|
||||||
data={Object.keys(VLC_COLORS)}
|
|
||||||
keyExtractor={(item) => item}
|
|
||||||
titleExtractor={(item) =>
|
|
||||||
t(`home.settings.subtitles.colors.${item}`)
|
|
||||||
}
|
|
||||||
title={
|
|
||||||
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
|
|
||||||
<Text className='mr-1 text-[#8E8D91]'>
|
|
||||||
{t(`home.settings.subtitles.colors.${textColor}`)}
|
|
||||||
</Text>
|
|
||||||
<Ionicons
|
|
||||||
name='chevron-expand-sharp'
|
|
||||||
size={18}
|
|
||||||
color='#5A5960'
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
}
|
|
||||||
label={t("home.settings.subtitles.text_color")}
|
|
||||||
onSelected={(value) => updateSettings({ vlcTextColor: value })}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
<ListItem title={t("home.settings.subtitles.background_color")}>
|
|
||||||
<Dropdown
|
|
||||||
data={Object.keys(VLC_COLORS)}
|
|
||||||
keyExtractor={(item) => item}
|
|
||||||
titleExtractor={(item) =>
|
|
||||||
t(`home.settings.subtitles.colors.${item}`)
|
|
||||||
}
|
|
||||||
title={
|
|
||||||
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
|
|
||||||
<Text className='mr-1 text-[#8E8D91]'>
|
|
||||||
{t(`home.settings.subtitles.colors.${backgroundColor}`)}
|
|
||||||
</Text>
|
|
||||||
<Ionicons
|
|
||||||
name='chevron-expand-sharp'
|
|
||||||
size={18}
|
|
||||||
color='#5A5960'
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
}
|
|
||||||
label={t("home.settings.subtitles.background_color")}
|
|
||||||
onSelected={(value) =>
|
|
||||||
updateSettings({ vlcBackgroundColor: value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
<ListItem title={t("home.settings.subtitles.outline_color")}>
|
|
||||||
<Dropdown
|
|
||||||
data={Object.keys(VLC_COLORS)}
|
|
||||||
keyExtractor={(item) => item}
|
|
||||||
titleExtractor={(item) =>
|
|
||||||
t(`home.settings.subtitles.colors.${item}`)
|
|
||||||
}
|
|
||||||
title={
|
|
||||||
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
|
|
||||||
<Text className='mr-1 text-[#8E8D91]'>
|
|
||||||
{t(`home.settings.subtitles.colors.${outlineColor}`)}
|
|
||||||
</Text>
|
|
||||||
<Ionicons
|
|
||||||
name='chevron-expand-sharp'
|
|
||||||
size={18}
|
|
||||||
color='#5A5960'
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
}
|
|
||||||
label={t("home.settings.subtitles.outline_color")}
|
|
||||||
onSelected={(value) => updateSettings({ vlcOutlineColor: value })}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
<ListItem title={t("home.settings.subtitles.outline_thickness")}>
|
|
||||||
<Dropdown
|
|
||||||
data={Object.keys(OUTLINE_THICKNESS)}
|
|
||||||
keyExtractor={(item) => item}
|
|
||||||
titleExtractor={(item) =>
|
|
||||||
t(`home.settings.subtitles.thickness.${item}`)
|
|
||||||
}
|
|
||||||
title={
|
|
||||||
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
|
|
||||||
<Text className='mr-1 text-[#8E8D91]'>
|
|
||||||
{t(`home.settings.subtitles.thickness.${outlineThickness}`)}
|
|
||||||
</Text>
|
|
||||||
<Ionicons
|
|
||||||
name='chevron-expand-sharp'
|
|
||||||
size={18}
|
|
||||||
color='#5A5960'
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
}
|
|
||||||
label={t("home.settings.subtitles.outline_thickness")}
|
|
||||||
onSelected={(value) =>
|
|
||||||
updateSettings({ vlcOutlineThickness: value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
<ListItem title={t("home.settings.subtitles.background_opacity")}>
|
|
||||||
<Dropdown
|
|
||||||
data={[0, 32, 64, 96, 128, 160, 192, 224, 255]}
|
|
||||||
keyExtractor={String}
|
|
||||||
titleExtractor={(item) => `${Math.round((item / 255) * 100)}%`}
|
|
||||||
title={
|
|
||||||
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
|
|
||||||
<Text className='mr-1 text-[#8E8D91]'>{`${Math.round((backgroundOpacity / 255) * 100)}%`}</Text>
|
|
||||||
<Ionicons
|
|
||||||
name='chevron-expand-sharp'
|
|
||||||
size={18}
|
|
||||||
color='#5A5960'
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
}
|
|
||||||
label={t("home.settings.subtitles.background_opacity")}
|
|
||||||
onSelected={(value) =>
|
|
||||||
updateSettings({ vlcBackgroundOpacity: value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
<ListItem title={t("home.settings.subtitles.outline_opacity")}>
|
|
||||||
<Dropdown
|
|
||||||
data={[0, 32, 64, 96, 128, 160, 192, 224, 255]}
|
|
||||||
keyExtractor={String}
|
|
||||||
titleExtractor={(item) => `${Math.round((item / 255) * 100)}%`}
|
|
||||||
title={
|
|
||||||
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
|
|
||||||
<Text className='mr-1 text-[#8E8D91]'>{`${Math.round((outlineOpacity / 255) * 100)}%`}</Text>
|
|
||||||
<Ionicons
|
|
||||||
name='chevron-expand-sharp'
|
|
||||||
size={18}
|
|
||||||
color='#5A5960'
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
}
|
|
||||||
label={t("home.settings.subtitles.outline_opacity")}
|
|
||||||
onSelected={(value) => updateSettings({ vlcOutlineOpacity: value })}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
<ListItem title={t("home.settings.subtitles.bold_text")}>
|
|
||||||
<Switch
|
|
||||||
value={isBold}
|
|
||||||
onValueChange={(value) => updateSettings({ vlcIsBold: value })}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { ParamListBase, RouteProp } from "@react-navigation/native";
|
import type { ParamListBase, RouteProp } from "@react-navigation/native";
|
||||||
import type { NativeStackNavigationOptions } from "@react-navigation/native-stack";
|
import type { NativeStackNavigationOptions } from "@react-navigation/native-stack";
|
||||||
import { Platform } from "react-native";
|
|
||||||
import { HeaderBackButton } from "../common/HeaderBackButton";
|
import { HeaderBackButton } from "../common/HeaderBackButton";
|
||||||
|
|
||||||
type ICommonScreenOptions =
|
type ICommonScreenOptions =
|
||||||
@@ -13,9 +12,8 @@ type ICommonScreenOptions =
|
|||||||
export const commonScreenOptions: ICommonScreenOptions = {
|
export const commonScreenOptions: ICommonScreenOptions = {
|
||||||
title: "",
|
title: "",
|
||||||
headerShown: true,
|
headerShown: true,
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: true,
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerBlurEffect: "none",
|
|
||||||
headerLeft: () => <HeaderBackButton />,
|
headerLeft: () => <HeaderBackButton />,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -379,7 +379,8 @@ export const Controls: FC<Props> = ({
|
|||||||
|
|
||||||
console.log("queryParams", queryParams);
|
console.log("queryParams", queryParams);
|
||||||
|
|
||||||
router.replace(`player/direct-player?${queryParams}` as any);
|
// @ts-expect-error
|
||||||
|
router.replace(`player/direct-player?${queryParams}`);
|
||||||
},
|
},
|
||||||
[settings, subtitleIndex, audioIndex, mediaSource, bitrateValue, router],
|
[settings, subtitleIndex, audioIndex, mediaSource, bitrateValue, router],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -95,7 +95,8 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
|
|||||||
playbackPosition: playbackPosition,
|
playbackPosition: playbackPosition,
|
||||||
}).toString();
|
}).toString();
|
||||||
|
|
||||||
router.replace(`player/direct-player?${queryParams}` as any);
|
//@ts-expect-error
|
||||||
|
router.replace(`player/direct-player?${queryParams}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const setTrackParams = (
|
const setTrackParams = (
|
||||||
|
|||||||
@@ -51,7 +51,8 @@ const DropdownView = () => {
|
|||||||
bitrateValue: bitrate.toString(),
|
bitrateValue: bitrate.toString(),
|
||||||
playbackPosition: playbackPosition,
|
playbackPosition: playbackPosition,
|
||||||
}).toString();
|
}).toString();
|
||||||
router.replace(`player/direct-player?${queryParams}` as any);
|
// @ts-expect-error
|
||||||
|
router.replace(`player/direct-player?${queryParams}`);
|
||||||
},
|
},
|
||||||
[item, mediaSource, subtitleIndex, audioIndex, playbackPosition],
|
[item, mediaSource, subtitleIndex, audioIndex, playbackPosition],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
export type VLCColor =
|
|
||||||
| "Black"
|
|
||||||
| "Gray"
|
|
||||||
| "Silver"
|
|
||||||
| "White"
|
|
||||||
| "Maroon"
|
|
||||||
| "Red"
|
|
||||||
| "Fuchsia"
|
|
||||||
| "Yellow"
|
|
||||||
| "Olive"
|
|
||||||
| "Green"
|
|
||||||
| "Teal"
|
|
||||||
| "Lime"
|
|
||||||
| "Purple"
|
|
||||||
| "Navy"
|
|
||||||
| "Blue"
|
|
||||||
| "Aqua";
|
|
||||||
|
|
||||||
export type OutlineThickness = "None" | "Thin" | "Normal" | "Thick";
|
|
||||||
|
|
||||||
export const VLC_COLORS: Record<VLCColor, number> = {
|
|
||||||
Black: 0,
|
|
||||||
Gray: 8421504,
|
|
||||||
Silver: 12632256,
|
|
||||||
White: 16777215,
|
|
||||||
Maroon: 8388608,
|
|
||||||
Red: 16711680,
|
|
||||||
Fuchsia: 16711935,
|
|
||||||
Yellow: 16776960,
|
|
||||||
Olive: 8421376,
|
|
||||||
Green: 32768,
|
|
||||||
Teal: 32896,
|
|
||||||
Lime: 65280,
|
|
||||||
Purple: 8388736,
|
|
||||||
Navy: 128,
|
|
||||||
Blue: 255,
|
|
||||||
Aqua: 65535,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const OUTLINE_THICKNESS: Record<OutlineThickness, number> = {
|
|
||||||
None: 0,
|
|
||||||
Thin: 2,
|
|
||||||
Normal: 4,
|
|
||||||
Thick: 6,
|
|
||||||
};
|
|
||||||
12
crowdin.yml
12
crowdin.yml
@@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
70
eas.json
70
eas.json
@@ -4,17 +4,6 @@
|
|||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"development": {
|
"development": {
|
||||||
"resourceClass": "medium",
|
|
||||||
"cache": {
|
|
||||||
"key": "dev-{{ checksum \"bun.lock\" \"app.config.js\" }}",
|
|
||||||
"paths": [
|
|
||||||
"~/.bun/install/cache",
|
|
||||||
"node_modules",
|
|
||||||
".expo",
|
|
||||||
"android/.gradle",
|
|
||||||
"ios/Pods"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"environment": "development",
|
"environment": "development",
|
||||||
"developmentClient": true,
|
"developmentClient": true,
|
||||||
"distribution": "internal",
|
"distribution": "internal",
|
||||||
@@ -26,11 +15,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"development_tv": {
|
"development_tv": {
|
||||||
"resourceClass": "medium",
|
|
||||||
"cache": {
|
|
||||||
"key": "development-tv-{{ checksum \"bun.lock\" \"package.json\" }}",
|
|
||||||
"paths": ["~/.bun/install/cache", "node_modules", ".expo"]
|
|
||||||
},
|
|
||||||
"environment": "development",
|
"environment": "development",
|
||||||
"developmentClient": true,
|
"developmentClient": true,
|
||||||
"distribution": "internal",
|
"distribution": "internal",
|
||||||
@@ -43,11 +27,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"development-simulator": {
|
"development-simulator": {
|
||||||
"resourceClass": "medium",
|
|
||||||
"cache": {
|
|
||||||
"key": "development-simulator-{{ checksum \"bun.lock\" \"package.json\" }}",
|
|
||||||
"paths": ["~/.bun/install/cache", "node_modules", ".expo"]
|
|
||||||
},
|
|
||||||
"environment": "development",
|
"environment": "development",
|
||||||
"developmentClient": true,
|
"developmentClient": true,
|
||||||
"distribution": "internal",
|
"distribution": "internal",
|
||||||
@@ -59,72 +38,29 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"preview": {
|
"preview": {
|
||||||
"resourceClass": "large",
|
|
||||||
"cache": {
|
|
||||||
"key": "preview-{{ checksum \"bun.lock\" \"app.config.js\" }}",
|
|
||||||
"paths": [
|
|
||||||
"~/.bun/install/cache",
|
|
||||||
"node_modules",
|
|
||||||
".expo",
|
|
||||||
"android/.gradle",
|
|
||||||
"ios/Pods",
|
|
||||||
".next"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"distribution": "internal",
|
"distribution": "internal",
|
||||||
"env": {
|
"env": {
|
||||||
"EXPO_OPTIMIZE_BUNDLE_SIZE": "1",
|
|
||||||
"NODE_ENV": "production",
|
|
||||||
"EXPO_PUBLIC_WRITE_DEBUG": "1"
|
"EXPO_PUBLIC_WRITE_DEBUG": "1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"resourceClass": "large",
|
|
||||||
"cache": {
|
|
||||||
"key": "production-{{ checksum \"bun.lock\" \"app.config.js\" }}",
|
|
||||||
"paths": [
|
|
||||||
"~/.bun/install/cache",
|
|
||||||
"node_modules",
|
|
||||||
".expo",
|
|
||||||
"android/.gradle",
|
|
||||||
"ios/Pods"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"channel": "0.39.0",
|
"channel": "0.35.1",
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "app-bundle",
|
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
},
|
|
||||||
"ios": {
|
|
||||||
"image": "latest"
|
|
||||||
},
|
|
||||||
"env": {
|
|
||||||
"EXPO_OPTIMIZE_BUNDLE_SIZE": "1",
|
|
||||||
"NODE_ENV": "production"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production-apk": {
|
"production-apk": {
|
||||||
"resourceClass": "large",
|
|
||||||
"cache": {
|
|
||||||
"key": "production-apk-{{ checksum \"bun.lock\" \"package.json\" }}",
|
|
||||||
"paths": ["~/.bun/install/cache", "node_modules", ".expo"]
|
|
||||||
},
|
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"channel": "0.39.0",
|
"channel": "0.35.1",
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production-apk-tv": {
|
"production-apk-tv": {
|
||||||
"resourceClass": "large",
|
|
||||||
"cache": {
|
|
||||||
"key": "production-apk-tv-{{ checksum \"bun.lock\" \"package.json\" }}",
|
|
||||||
"paths": ["~/.bun/install/cache", "node_modules", ".expo"]
|
|
||||||
},
|
|
||||||
"environment": "production",
|
"environment": "production",
|
||||||
"channel": "0.39.0",
|
"channel": "0.35.1",
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
|
|||||||
@@ -1,131 +0,0 @@
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import { Platform } from "react-native";
|
|
||||||
import { getColors, ImageColorsResult } from "react-native-image-colors";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import {
|
|
||||||
adjustToNearBlack,
|
|
||||||
calculateTextColor,
|
|
||||||
isCloseToBlack,
|
|
||||||
} from "@/utils/atoms/primaryColor";
|
|
||||||
import { getItemImage } from "@/utils/getItemImage";
|
|
||||||
import { storage } from "@/utils/mmkv";
|
|
||||||
|
|
||||||
export interface ThemeColors {
|
|
||||||
primary: string;
|
|
||||||
text: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_COLORS: ThemeColors = {
|
|
||||||
primary: "#FFFFFF",
|
|
||||||
text: "#000000",
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom hook to extract and return image colors for a given item.
|
|
||||||
* Returns colors as state instead of updating global atom.
|
|
||||||
*
|
|
||||||
* @param item - The BaseItemDto object representing the item.
|
|
||||||
* @param disabled - A boolean flag to disable color extraction.
|
|
||||||
* @returns ThemeColors object with primary and text colors
|
|
||||||
*/
|
|
||||||
export const useImageColorsReturn = ({
|
|
||||||
item,
|
|
||||||
url,
|
|
||||||
disabled,
|
|
||||||
}: {
|
|
||||||
item?: BaseItemDto | null;
|
|
||||||
url?: string | null;
|
|
||||||
disabled?: boolean;
|
|
||||||
}): ThemeColors => {
|
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const [colors, setColors] = useState<ThemeColors>(DEFAULT_COLORS);
|
|
||||||
|
|
||||||
const isTv = Platform.isTV;
|
|
||||||
|
|
||||||
const source = useMemo(() => {
|
|
||||||
if (!api) return;
|
|
||||||
if (url) return { uri: url };
|
|
||||||
if (item)
|
|
||||||
return getItemImage({
|
|
||||||
item,
|
|
||||||
api,
|
|
||||||
variant: "Primary",
|
|
||||||
quality: 80,
|
|
||||||
width: 300,
|
|
||||||
});
|
|
||||||
return null;
|
|
||||||
}, [api, item, url]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Reset to default colors when item changes
|
|
||||||
if (!item && !url) {
|
|
||||||
setColors(DEFAULT_COLORS);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isTv) return;
|
|
||||||
if (disabled) return;
|
|
||||||
if (source?.uri) {
|
|
||||||
const _primary = storage.getString(`${source.uri}-primary`);
|
|
||||||
const _text = storage.getString(`${source.uri}-text`);
|
|
||||||
|
|
||||||
if (_primary && _text) {
|
|
||||||
setColors({
|
|
||||||
primary: _primary,
|
|
||||||
text: _text,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract colors from the image
|
|
||||||
getColors(source.uri, {
|
|
||||||
fallback: "#fff",
|
|
||||||
cache: false,
|
|
||||||
})
|
|
||||||
.then((colors: ImageColorsResult) => {
|
|
||||||
let primary = "#fff";
|
|
||||||
let text = "#000";
|
|
||||||
let backup = "#fff";
|
|
||||||
|
|
||||||
// Select the appropriate color based on the platform
|
|
||||||
if (colors.platform === "android") {
|
|
||||||
primary = colors.dominant;
|
|
||||||
backup = colors.vibrant;
|
|
||||||
} else if (colors.platform === "ios") {
|
|
||||||
primary = colors.detail;
|
|
||||||
backup = colors.primary;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Adjust the primary color if it's too close to black
|
|
||||||
if (primary && isCloseToBlack(primary)) {
|
|
||||||
if (backup && !isCloseToBlack(backup)) primary = backup;
|
|
||||||
primary = adjustToNearBlack(primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate the text color based on the primary color
|
|
||||||
if (primary) text = calculateTextColor(primary);
|
|
||||||
|
|
||||||
const newColors = {
|
|
||||||
primary,
|
|
||||||
text,
|
|
||||||
};
|
|
||||||
|
|
||||||
setColors(newColors);
|
|
||||||
|
|
||||||
// Cache the colors in storage
|
|
||||||
if (source.uri && primary) {
|
|
||||||
storage.set(`${source.uri}-primary`, primary);
|
|
||||||
storage.set(`${source.uri}-text`, text);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error: any) => {
|
|
||||||
console.error("Error getting colors", error);
|
|
||||||
setColors(DEFAULT_COLORS);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [isTv, source?.uri, disabled, item, url]);
|
|
||||||
|
|
||||||
return colors;
|
|
||||||
};
|
|
||||||
@@ -387,7 +387,7 @@ export class JellyseerrApi {
|
|||||||
`Jellyseerr response error\nerror: ${error.toString()}\nurl: ${error?.config?.url}`,
|
`Jellyseerr response error\nerror: ${error.toString()}\nurl: ${error?.config?.url}`,
|
||||||
error.response?.data,
|
error.response?.data,
|
||||||
);
|
);
|
||||||
if (error.response?.status === 403) {
|
if (error.status === 403) {
|
||||||
clearJellyseerrStorageData();
|
clearJellyseerrStorageData();
|
||||||
}
|
}
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
@@ -512,7 +512,7 @@ export const useJellyseerr = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const jellyseerrRegion = useMemo(
|
const jellyseerrRegion = useMemo(
|
||||||
() => jellyseerrUser?.settings?.discoverRegion || "US",
|
() => jellyseerrUser?.settings?.region || "US",
|
||||||
[jellyseerrUser],
|
[jellyseerrUser],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,58 +1,30 @@
|
|||||||
import NetInfo from "@react-native-community/netinfo";
|
import NetInfo from "@react-native-community/netinfo";
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
|
|
||||||
async function checkApiReachable(basePath?: string): Promise<boolean> {
|
|
||||||
if (!basePath) return false;
|
|
||||||
try {
|
|
||||||
const response = await fetch(basePath, { method: "HEAD" });
|
|
||||||
return response.ok;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useNetworkStatus() {
|
export function useNetworkStatus() {
|
||||||
const [isConnected, setIsConnected] = useState(false);
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
const [serverConnected, setServerConnected] = useState<boolean | null>(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
|
|
||||||
const validateConnection = useCallback(async () => {
|
|
||||||
if (!api?.basePath) return false;
|
|
||||||
const reachable = await checkApiReachable(api.basePath);
|
|
||||||
setServerConnected(reachable);
|
|
||||||
return reachable;
|
|
||||||
}, [api?.basePath]);
|
|
||||||
|
|
||||||
|
// Manual check (optional)
|
||||||
const retryCheck = useCallback(async () => {
|
const retryCheck = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
await validateConnection();
|
const state = await NetInfo.fetch();
|
||||||
|
setIsConnected(!!state.isConnected && !!state.isInternetReachable);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, [validateConnection]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = NetInfo.addEventListener(async (state) => {
|
const unsubscribe = NetInfo.addEventListener((state) => {
|
||||||
setIsConnected(!!state.isConnected);
|
setIsConnected(!!state.isConnected && !!state.isInternetReachable);
|
||||||
if (state.isConnected) {
|
|
||||||
await validateConnection();
|
|
||||||
} else {
|
|
||||||
setServerConnected(false);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initial check: wait for NetInfo first
|
// Initial state
|
||||||
NetInfo.fetch().then((state) => {
|
NetInfo.fetch().then((state) => {
|
||||||
if (state.isConnected) {
|
setIsConnected(!!state.isConnected && !!state.isInternetReachable);
|
||||||
validateConnection();
|
|
||||||
} else {
|
|
||||||
setServerConnected(false);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => unsubscribe();
|
return () => unsubscribe();
|
||||||
}, [validateConnection]);
|
}, []);
|
||||||
|
|
||||||
return { isConnected, serverConnected, loading, retryCheck };
|
return { isConnected, loading, retryCheck };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
import type {
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
BaseItemDto,
|
|
||||||
PlaybackProgressInfo,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import { getPlaystateApi, getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getPlaystateApi, getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
@@ -144,10 +141,13 @@ export const usePlaybackManager = ({
|
|||||||
* @param positionTicks The current playback position in ticks.
|
* @param positionTicks The current playback position in ticks.
|
||||||
*/
|
*/
|
||||||
const reportPlaybackProgress = async (
|
const reportPlaybackProgress = async (
|
||||||
playbackProgressInfo: PlaybackProgressInfo,
|
itemId: string,
|
||||||
|
positionTicks: number,
|
||||||
|
metadata?: {
|
||||||
|
AudioStreamIndex: number;
|
||||||
|
SubtitleStreamIndex: number;
|
||||||
|
},
|
||||||
) => {
|
) => {
|
||||||
const positionTicks = playbackProgressInfo.PositionTicks || 0;
|
|
||||||
const itemId = playbackProgressInfo.ItemId!;
|
|
||||||
const localItem = getDownloadedItemById(itemId);
|
const localItem = getDownloadedItemById(itemId);
|
||||||
|
|
||||||
// Handle local state update for downloaded items
|
// Handle local state update for downloaded items
|
||||||
@@ -192,7 +192,14 @@ export const usePlaybackManager = ({
|
|||||||
if (isOnline && api) {
|
if (isOnline && api) {
|
||||||
try {
|
try {
|
||||||
await getPlaystateApi(api).reportPlaybackProgress({
|
await getPlaystateApi(api).reportPlaybackProgress({
|
||||||
playbackProgressInfo,
|
playbackProgressInfo: {
|
||||||
|
ItemId: itemId,
|
||||||
|
PositionTicks: Math.floor(positionTicks),
|
||||||
|
...(metadata && { AudioStreamIndex: metadata.AudioStreamIndex }),
|
||||||
|
...(metadata && {
|
||||||
|
SubtitleStreamIndex: metadata.SubtitleStreamIndex,
|
||||||
|
}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to report playback progress", error);
|
console.error("Failed to report playback progress", error);
|
||||||
|
|||||||
6
i18n.ts
6
i18n.ts
@@ -1,7 +1,6 @@
|
|||||||
import { getLocales } from "expo-localization";
|
import { getLocales } from "expo-localization";
|
||||||
import i18n from "i18next";
|
import i18n from "i18next";
|
||||||
import { initReactI18next } from "react-i18next";
|
import { initReactI18next } from "react-i18next";
|
||||||
import ar from "./translations/ar.json";
|
|
||||||
import ca from "./translations/ca.json";
|
import ca from "./translations/ca.json";
|
||||||
import da from "./translations/da.json";
|
import da from "./translations/da.json";
|
||||||
import de from "./translations/de.json";
|
import de from "./translations/de.json";
|
||||||
@@ -10,7 +9,6 @@ import eo from "./translations/eo.json";
|
|||||||
import es from "./translations/es.json";
|
import es from "./translations/es.json";
|
||||||
import fi from "./translations/fi.json";
|
import fi from "./translations/fi.json";
|
||||||
import fr from "./translations/fr.json";
|
import fr from "./translations/fr.json";
|
||||||
import hu from "./translations/hu.json";
|
|
||||||
import it from "./translations/it.json";
|
import it from "./translations/it.json";
|
||||||
import ja from "./translations/ja.json";
|
import ja from "./translations/ja.json";
|
||||||
import nb from "./translations/nb.json";
|
import nb from "./translations/nb.json";
|
||||||
@@ -31,7 +29,6 @@ import zhTW from "./translations/zh-TW.json";
|
|||||||
|
|
||||||
export const APP_LANGUAGES = [
|
export const APP_LANGUAGES = [
|
||||||
{ label: "Catalan", value: "ca" },
|
{ label: "Catalan", value: "ca" },
|
||||||
{ label: "العربية", value: "ar" },
|
|
||||||
{ label: "Dansk", value: "da" },
|
{ label: "Dansk", value: "da" },
|
||||||
{ label: "Deutsch", value: "de" },
|
{ label: "Deutsch", value: "de" },
|
||||||
{ label: "English", value: "en" },
|
{ label: "English", value: "en" },
|
||||||
@@ -42,7 +39,6 @@ export const APP_LANGUAGES = [
|
|||||||
{ label: "日本語", value: "ja" },
|
{ label: "日本語", value: "ja" },
|
||||||
{ label: "Klingon", value: "tlh" },
|
{ label: "Klingon", value: "tlh" },
|
||||||
{ label: "Türkçe", value: "tr" },
|
{ label: "Türkçe", value: "tr" },
|
||||||
{ label: "Magyar", value: "hu" },
|
|
||||||
{ label: "Nederlands", value: "nl" },
|
{ label: "Nederlands", value: "nl" },
|
||||||
{ label: "Polski", value: "pl" },
|
{ label: "Polski", value: "pl" },
|
||||||
{ label: "Português (Brasil)", value: "pt-BR" },
|
{ label: "Português (Brasil)", value: "pt-BR" },
|
||||||
@@ -63,14 +59,12 @@ i18n.use(initReactI18next).init({
|
|||||||
compatibilityJSON: "v4",
|
compatibilityJSON: "v4",
|
||||||
resources: {
|
resources: {
|
||||||
ca: { translation: ca },
|
ca: { translation: ca },
|
||||||
ar: { translation: ar },
|
|
||||||
da: { translation: da },
|
da: { translation: da },
|
||||||
de: { translation: de },
|
de: { translation: de },
|
||||||
en: { translation: en },
|
en: { translation: en },
|
||||||
es: { translation: es },
|
es: { translation: es },
|
||||||
eo: { translation: eo },
|
eo: { translation: eo },
|
||||||
fr: { translation: fr },
|
fr: { translation: fr },
|
||||||
hu: { translation: hu },
|
|
||||||
it: { translation: it },
|
it: { translation: it },
|
||||||
ja: { translation: ja },
|
ja: { translation: ja },
|
||||||
nl: { translation: nl },
|
nl: { translation: nl },
|
||||||
|
|||||||
6
login.yaml
Normal file
6
login.yaml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# login.yaml
|
||||||
|
|
||||||
|
appId: your.app.id
|
||||||
|
---
|
||||||
|
- launchApp
|
||||||
|
- tapOn: "Text on the screen"
|
||||||
237
metro.config.js
237
metro.config.js
@@ -1,243 +1,28 @@
|
|||||||
// Learn more https://docs.expo.io/guides/customizing-metro
|
// Learn more https://docs.expo.io/guides/customizing-metro
|
||||||
const { getDefaultConfig } = require("expo/metro-config");
|
const { getDefaultConfig } = require("expo/metro-config");
|
||||||
const path = require("node:path");
|
|
||||||
|
|
||||||
/** @type {import('expo/metro-config').MetroConfig} */
|
/** @type {import('expo/metro-config').MetroConfig} */
|
||||||
const config = getDefaultConfig(__dirname);
|
const config = getDefaultConfig(__dirname); // eslint-disable-line no-undef
|
||||||
|
|
||||||
// =======================================================
|
// Add Hermes parser
|
||||||
// STREAMYFIN METRO CONFIG - PERFORMANCE OPTIMIZED 🚀
|
|
||||||
// =======================================================
|
|
||||||
// Advanced configuration for multi-platform Jellyfin client
|
|
||||||
// Optimized for media streaming, TV support, and Bun
|
|
||||||
// =======================================================
|
|
||||||
|
|
||||||
// HERMES + ADVANCED PERFORMANCE
|
|
||||||
// ==============================
|
|
||||||
config.transformer.hermesParser = true;
|
config.transformer.hermesParser = true;
|
||||||
|
|
||||||
// CPU optimization (your existing setting)
|
// When enabled, the optional code below will allow Metro to resolve
|
||||||
const os = require("node:os");
|
// and bundle source files with TV-specific extensions
|
||||||
config.maxWorkers = Math.max(1, os.cpus().length - 1);
|
// (e.g., *.ios.tv.tsx, *.android.tv.tsx, *.tv.tsx)
|
||||||
|
//
|
||||||
// JAVASCRIPT OPTIMIZATION (Safe & Stable)
|
// Metro will still resolve source files with standard extensions
|
||||||
// ========================================
|
// as usual if TV-specific files are not found for a module.
|
||||||
config.transformer = {
|
//
|
||||||
...config.transformer,
|
|
||||||
hermesParser: true,
|
|
||||||
|
|
||||||
// NEW: Inline requires for 15-30% startup improvement
|
|
||||||
inlineRequires: true,
|
|
||||||
|
|
||||||
// ADVANCED: Hermes-optimized minification for streaming apps
|
|
||||||
minifierConfig: {
|
|
||||||
mangle: {
|
|
||||||
keep_fnames: process.env.NODE_ENV === "development",
|
|
||||||
},
|
|
||||||
output: {
|
|
||||||
ascii_only: true,
|
|
||||||
beautify: false,
|
|
||||||
semicolons: false,
|
|
||||||
},
|
|
||||||
compress: {
|
|
||||||
// Production-only optimizations
|
|
||||||
drop_console: process.env.NODE_ENV === "production",
|
|
||||||
dead_code: true,
|
|
||||||
drop_debugger: true,
|
|
||||||
conditionals: true,
|
|
||||||
evaluate: true,
|
|
||||||
unused: true,
|
|
||||||
reduce_vars: true,
|
|
||||||
// Keep class names for error reporting
|
|
||||||
keep_classnames: true,
|
|
||||||
// Preserve function names for performance profiling in dev
|
|
||||||
keep_fnames: process.env.NODE_ENV === "development",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// RESOLVER OPTIMIZATIONS ENHANCED
|
|
||||||
// ===============================
|
|
||||||
config.resolver = {
|
|
||||||
...config.resolver,
|
|
||||||
|
|
||||||
// NEW: Package exports (stable in recent Metro)
|
|
||||||
unstable_enablePackageExports: true,
|
|
||||||
|
|
||||||
// ENHANCED: Extensions optimized for Streamyfin
|
|
||||||
assetExts: [
|
|
||||||
...config.resolver.assetExts,
|
|
||||||
// Video formats (enhanced for Jellyfin)
|
|
||||||
"mkv",
|
|
||||||
"mp4",
|
|
||||||
"avi",
|
|
||||||
"mov",
|
|
||||||
"wmv",
|
|
||||||
"flv",
|
|
||||||
"webm",
|
|
||||||
"m4v",
|
|
||||||
"mpg",
|
|
||||||
"mpeg",
|
|
||||||
// Audio formats
|
|
||||||
"mp3",
|
|
||||||
"wav",
|
|
||||||
"flac",
|
|
||||||
"aac",
|
|
||||||
"m4a",
|
|
||||||
"ogg",
|
|
||||||
"wma",
|
|
||||||
"opus",
|
|
||||||
// Subtitle files (complete for media)
|
|
||||||
"srt",
|
|
||||||
"vtt",
|
|
||||||
"ass",
|
|
||||||
"ssa",
|
|
||||||
"sub",
|
|
||||||
"idx",
|
|
||||||
"sbv",
|
|
||||||
"ttml",
|
|
||||||
// Database/Cache
|
|
||||||
"db",
|
|
||||||
"sqlite",
|
|
||||||
"realm",
|
|
||||||
"json5",
|
|
||||||
// Fonts (TV optimized)
|
|
||||||
"woff2",
|
|
||||||
"woff",
|
|
||||||
"eot",
|
|
||||||
"otf",
|
|
||||||
// Images (enhanced for thumbnails)
|
|
||||||
"avif",
|
|
||||||
"heic",
|
|
||||||
"heif", // Modern formats
|
|
||||||
],
|
|
||||||
|
|
||||||
sourceExts: [
|
|
||||||
...config.resolver.sourceExts,
|
|
||||||
"mjs",
|
|
||||||
"cjs", // Modern JS support
|
|
||||||
],
|
|
||||||
|
|
||||||
// NEW: Platform prioritization for performance
|
|
||||||
platforms: ["ios", "android", "native", "web", "tv"],
|
|
||||||
};
|
|
||||||
|
|
||||||
// SERIALIZER OPTIMIZATIONS (Production)
|
|
||||||
// ====================================
|
|
||||||
if (process.env.NODE_ENV === "production") {
|
|
||||||
config.serializer = {
|
|
||||||
...config.serializer,
|
|
||||||
|
|
||||||
// NEW: Module IDs optimized for caching
|
|
||||||
createModuleIdFactory: () => {
|
|
||||||
return (path) => {
|
|
||||||
// Shorter module IDs for smaller bundles
|
|
||||||
return require("node:crypto")
|
|
||||||
.createHash("sha1")
|
|
||||||
.update(path)
|
|
||||||
.digest("hex")
|
|
||||||
.substring(0, 8);
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
// NEW: Bundle pre-loading for streaming apps
|
|
||||||
getModulesRunBeforeMainModule: (_entryFilePath) => [
|
|
||||||
// Pre-load critical modules for faster TTI
|
|
||||||
require.resolve("react-native/Libraries/Core/InitializeCore"),
|
|
||||||
],
|
|
||||||
|
|
||||||
// Web bundle splitting (if applicable)
|
|
||||||
...(process.env.EXPO_PLATFORM === "web" && {
|
|
||||||
// Experimental code splitting for web
|
|
||||||
experimentalSerializerHook: () => {},
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// TV PLATFORM ENHANCEMENTS
|
|
||||||
// ========================
|
|
||||||
if (process.env?.EXPO_TV === "1") {
|
if (process.env?.EXPO_TV === "1") {
|
||||||
const originalSourceExts = config.resolver.sourceExts;
|
const originalSourceExts = config.resolver.sourceExts;
|
||||||
const tvSourceExts = [
|
const tvSourceExts = [
|
||||||
...originalSourceExts.map((ext) => `tv.${ext}`),
|
...originalSourceExts.map((e) => `tv.${e}`),
|
||||||
...originalSourceExts,
|
...originalSourceExts,
|
||||||
];
|
];
|
||||||
config.resolver.sourceExts = tvSourceExts;
|
config.resolver.sourceExts = tvSourceExts;
|
||||||
|
|
||||||
// NEW: TV-specific optimizations
|
|
||||||
config.transformer = {
|
|
||||||
...config.transformer,
|
|
||||||
// Optimize transforms for TV hardware
|
|
||||||
experimentalImportSupport: false, // Reduce complexity on TV
|
|
||||||
// Use legacy transformer for better TV compatibility
|
|
||||||
allowOptionalDependencies: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log("📺 TV platform optimized for Streamyfin");
|
|
||||||
console.log(`📁 TV extensions: ${tvSourceExts.slice(0, 6).join(", ")}...`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DEVELOPMENT ENHANCEMENTS
|
// config.resolver.unstable_enablePackageExports = false;
|
||||||
// ========================
|
|
||||||
if (process.env.NODE_ENV === "development") {
|
|
||||||
// NEW: Enhanced error reporting
|
|
||||||
config.reporter = {
|
|
||||||
update: (event) => {
|
|
||||||
if (event.type === "bundle_build_failed") {
|
|
||||||
console.error(
|
|
||||||
"🔴 Streamyfin Bundle Build Failed:",
|
|
||||||
event.error.message,
|
|
||||||
);
|
|
||||||
} else if (event.type === "bundle_build_done") {
|
|
||||||
console.log(
|
|
||||||
"✅ Streamyfin Bundle Ready:",
|
|
||||||
event.bundleDetails?.bundleSize || "Unknown size",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log("🚀 Streamyfin Metro Config - OPTIMIZED VERSION");
|
|
||||||
console.log(`📦 Workers: ${config.maxWorkers}`);
|
|
||||||
console.log(`🎯 Hermes: ${config.transformer.hermesParser}`);
|
|
||||||
console.log(`⚡ Inline requires: ${config.transformer.inlineRequires}`);
|
|
||||||
console.log(
|
|
||||||
`📺 TV support: ${process.env.EXPO_TV === "1" ? "ENABLED" : "DISABLED"}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// STREAMING APP SPECIFIC OPTIMIZATIONS
|
|
||||||
// ===================================
|
|
||||||
|
|
||||||
// NEW: Cache hints for better performance
|
|
||||||
if (typeof config.cacheStores === "undefined") {
|
|
||||||
// Only add if not causing issues
|
|
||||||
try {
|
|
||||||
const MetroCache = require("metro-cache");
|
|
||||||
config.cacheStores = [
|
|
||||||
new MetroCache.FileStore({
|
|
||||||
root: path.join(os.tmpdir(), "streamyfin-metro-cache"),
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
} catch (_e) {
|
|
||||||
// Fallback: use default cache
|
|
||||||
console.log("ℹ️ Using default Metro cache (custom cache not available)");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NEW: Network request optimizations for streaming
|
|
||||||
config.server = {
|
|
||||||
...config.server,
|
|
||||||
// Enhanced request handling for media assets
|
|
||||||
enhanceMiddleware: (middleware, _server) => {
|
|
||||||
// Add caching headers for static assets
|
|
||||||
return (req, res, next) => {
|
|
||||||
if (req.url?.match(/\.(mp4|mkv|jpg|jpeg|png|webp)$/)) {
|
|
||||||
res.setHeader("Cache-Control", "public, max-age=31536000");
|
|
||||||
}
|
|
||||||
return middleware(req, res, next);
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = config;
|
module.exports = config;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type {
|
import {
|
||||||
ChapterInfo,
|
ChapterInfo,
|
||||||
PlaybackStatePayload,
|
PlaybackStatePayload,
|
||||||
ProgressUpdatePayload,
|
ProgressUpdatePayload,
|
||||||
@@ -12,20 +12,16 @@ import type {
|
|||||||
} from "./VlcPlayer.types";
|
} from "./VlcPlayer.types";
|
||||||
import VlcPlayerView from "./VlcPlayerView";
|
import VlcPlayerView from "./VlcPlayerView";
|
||||||
|
|
||||||
// Component
|
export {
|
||||||
export { VlcPlayerView };
|
VlcPlayerView,
|
||||||
|
VlcPlayerViewProps,
|
||||||
// Component Types
|
VlcPlayerViewRef,
|
||||||
export type { VlcPlayerViewProps, VlcPlayerViewRef };
|
|
||||||
|
|
||||||
// Media Types
|
|
||||||
export type { ChapterInfo, TrackInfo, VlcPlayerSource };
|
|
||||||
|
|
||||||
// Playback Events (alphabetically sorted)
|
|
||||||
export type {
|
|
||||||
PlaybackStatePayload,
|
PlaybackStatePayload,
|
||||||
ProgressUpdatePayload,
|
ProgressUpdatePayload,
|
||||||
VideoLoadStartPayload,
|
VideoLoadStartPayload,
|
||||||
VideoProgressPayload,
|
|
||||||
VideoStateChangePayload,
|
VideoStateChangePayload,
|
||||||
|
VideoProgressPayload,
|
||||||
|
VlcPlayerSource,
|
||||||
|
TrackInfo,
|
||||||
|
ChapterInfo,
|
||||||
};
|
};
|
||||||
|
|||||||
44
package.json
44
package.json
@@ -14,7 +14,7 @@
|
|||||||
"android:tv": "cross-env EXPO_TV=1 expo run:android",
|
"android:tv": "cross-env EXPO_TV=1 expo run:android",
|
||||||
"build:android:local": "cd android && cross-env NODE_ENV=production ./gradlew assembleRelease",
|
"build:android:local": "cd android && cross-env NODE_ENV=production ./gradlew assembleRelease",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"typecheck": "node scripts/typecheck.js",
|
"typecheck": "tsc -p tsconfig.json --noEmit | grep -v \"utils/jellyseerr\"",
|
||||||
"check": "biome check . --max-diagnostics 1000",
|
"check": "biome check . --max-diagnostics 1000",
|
||||||
"lint": "biome check --write --unsafe --max-diagnostics 1000",
|
"lint": "biome check --write --unsafe --max-diagnostics 1000",
|
||||||
"format": "biome format --write .",
|
"format": "biome format --write .",
|
||||||
@@ -22,29 +22,29 @@
|
|||||||
"test": "bun run typecheck && bun run lint && bun run format && bun run doctor"
|
"test": "bun run typecheck && bun run lint && bun run format && bun run doctor"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bottom-tabs/react-navigation": "^0.11.2",
|
"@bottom-tabs/react-navigation": "^0.9.2",
|
||||||
"@expo/metro-runtime": "~5.0.5",
|
"@expo/metro-runtime": "~5.0.4",
|
||||||
"@expo/react-native-action-sheet": "^4.1.1",
|
"@expo/react-native-action-sheet": "^4.1.1",
|
||||||
"@expo/vector-icons": "^14.1.0",
|
"@expo/vector-icons": "^15.0.2",
|
||||||
"@gorhom/bottom-sheet": "^5.1.0",
|
"@gorhom/bottom-sheet": "^5.1.0",
|
||||||
"@jellyfin/sdk": "^0.11.0",
|
"@jellyfin/sdk": "^0.11.0",
|
||||||
"@kesha-antonov/react-native-background-downloader": "^3.2.6",
|
"@kesha-antonov/react-native-background-downloader": "^3.2.6",
|
||||||
"@react-native-community/netinfo": "^11.4.1",
|
"@react-native-community/netinfo": "^11.4.1",
|
||||||
"@react-native-menu/menu": "1.2.3",
|
"@react-native-menu/menu": "^1.2.3",
|
||||||
"@react-navigation/material-top-tabs": "^7.2.14",
|
"@react-navigation/material-top-tabs": "^7.2.14",
|
||||||
"@react-navigation/native": "^7.0.14",
|
"@react-navigation/native": "^7.0.14",
|
||||||
"@shopify/flash-list": "^1.8.3",
|
"@shopify/flash-list": "^1.8.3",
|
||||||
"@tanstack/react-query": "^5.66.0",
|
"@tanstack/react-query": "^5.66.0",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"expo": "^53.0.23",
|
"expo": "^53.0.22",
|
||||||
"expo-application": "~6.1.4",
|
"expo-application": "~6.1.4",
|
||||||
"expo-asset": "~11.1.7",
|
"expo-asset": "~11.1.7",
|
||||||
"expo-atlas": "^0.4.0",
|
|
||||||
"expo-background-task": "~0.2.8",
|
"expo-background-task": "~0.2.8",
|
||||||
"expo-blur": "~14.1.4",
|
"expo-blur": "~14.1.4",
|
||||||
"expo-brightness": "~13.1.4",
|
"expo-brightness": "~13.1.4",
|
||||||
"expo-build-properties": "~0.14.6",
|
"expo-build-properties": "~0.14.6",
|
||||||
"expo-constants": "~17.1.5",
|
"expo-constants": "~17.1.5",
|
||||||
|
"expo-dev-client": "^5.2.0",
|
||||||
"expo-device": "~7.1.4",
|
"expo-device": "~7.1.4",
|
||||||
"expo-font": "~13.3.1",
|
"expo-font": "~13.3.1",
|
||||||
"expo-haptics": "~14.1.4",
|
"expo-haptics": "~14.1.4",
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
"expo-linking": "~7.1.4",
|
"expo-linking": "~7.1.4",
|
||||||
"expo-localization": "~16.1.5",
|
"expo-localization": "~16.1.5",
|
||||||
"expo-notifications": "~0.31.2",
|
"expo-notifications": "~0.31.2",
|
||||||
"expo-router": "~5.1.7",
|
"expo-router": "~5.1.5",
|
||||||
"expo-screen-orientation": "~8.1.6",
|
"expo-screen-orientation": "~8.1.6",
|
||||||
"expo-sensors": "~14.1.4",
|
"expo-sensors": "~14.1.4",
|
||||||
"expo-sharing": "~13.1.5",
|
"expo-sharing": "~13.1.5",
|
||||||
@@ -71,7 +71,7 @@
|
|||||||
"react-i18next": "^15.4.0",
|
"react-i18next": "^15.4.0",
|
||||||
"react-native": "npm:react-native-tvos@0.79.5-0",
|
"react-native": "npm:react-native-tvos@0.79.5-0",
|
||||||
"react-native-awesome-slider": "^2.9.0",
|
"react-native-awesome-slider": "^2.9.0",
|
||||||
"react-native-bottom-tabs": "^0.11.2",
|
"react-native-bottom-tabs": "^0.9.2",
|
||||||
"react-native-circular-progress": "^1.4.1",
|
"react-native-circular-progress": "^1.4.1",
|
||||||
"react-native-collapsible": "^1.6.2",
|
"react-native-collapsible": "^1.6.2",
|
||||||
"react-native-country-flag": "^2.0.2",
|
"react-native-country-flag": "^2.0.2",
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
"react-native-ios-utilities": "5.1.8",
|
"react-native-ios-utilities": "5.1.8",
|
||||||
"react-native-mmkv": "2.12.2",
|
"react-native-mmkv": "2.12.2",
|
||||||
"react-native-pager-view": "^6.9.1",
|
"react-native-pager-view": "^6.9.1",
|
||||||
"react-native-reanimated": "~3.19.1",
|
"react-native-reanimated": "~3.17.4",
|
||||||
"react-native-reanimated-carousel": "4.0.2",
|
"react-native-reanimated-carousel": "4.0.2",
|
||||||
"react-native-safe-area-context": "5.4.0",
|
"react-native-safe-area-context": "5.4.0",
|
||||||
"react-native-screens": "~4.11.1",
|
"react-native-screens": "~4.11.1",
|
||||||
@@ -94,26 +94,26 @@
|
|||||||
"react-native-video": "6.14.1",
|
"react-native-video": "6.14.1",
|
||||||
"react-native-volume-manager": "^2.0.8",
|
"react-native-volume-manager": "^2.0.8",
|
||||||
"react-native-web": "^0.20.0",
|
"react-native-web": "^0.20.0",
|
||||||
"sonner-native": "^0.21.0",
|
"sonner-native": "^0.21.1",
|
||||||
"tailwindcss": "3.3.2",
|
"tailwindcss": "3.3.2",
|
||||||
"use-debounce": "^10.0.4",
|
"use-debounce": "^10.0.4",
|
||||||
"zeego": "^3.0.6",
|
"zeego": "^3.0.6",
|
||||||
"zod": "^4.1.3"
|
"zod": "^4.1.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "7.28.4",
|
"@babel/core": "7.28.3",
|
||||||
"@biomejs/biome": "2.2.5",
|
"@biomejs/biome": "2.2.2",
|
||||||
"@react-native-community/cli": "20.0.2",
|
"@react-native-community/cli": "20.0.1",
|
||||||
"@react-native-tvos/config-tv": "0.1.4",
|
"@react-native-tvos/config-tv": "0.1.3",
|
||||||
"@types/jest": "30.0.0",
|
"@types/jest": "29.5.14",
|
||||||
"@types/lodash": "4.17.20",
|
"@types/lodash": "4.17.20",
|
||||||
"@types/react": "~19.0.10",
|
"@types/react": "~19.0.10",
|
||||||
"@types/react-test-renderer": "19.1.0",
|
"@types/react-test-renderer": "19.1.0",
|
||||||
"cross-env": "10.1.0",
|
"expo-doctor": "1.17.2",
|
||||||
"expo-dev-client": "5.2.4",
|
"cross-env": "10.0.0",
|
||||||
"expo-doctor": "1.17.9",
|
|
||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"lint-staged": "16.2.3",
|
"lint-staged": "16.1.6",
|
||||||
|
"postinstall": "0.11.2",
|
||||||
"react-test-renderer": "19.1.1",
|
"react-test-renderer": "19.1.1",
|
||||||
"typescript": "5.8.3"
|
"typescript": "5.8.3"
|
||||||
},
|
},
|
||||||
@@ -123,7 +123,8 @@
|
|||||||
"react-native",
|
"react-native",
|
||||||
"@shopify/flash-list",
|
"@shopify/flash-list",
|
||||||
"react-native-reanimated",
|
"react-native-reanimated",
|
||||||
"react-native-pager-view"
|
"react-native-pager-view",
|
||||||
|
"@expo/vector-icons"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"doctor": {
|
"doctor": {
|
||||||
@@ -149,6 +150,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"trustedDependencies": [
|
"trustedDependencies": [
|
||||||
|
"postinstall",
|
||||||
"unrs-resolver"
|
"unrs-resolver"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,11 +50,9 @@ function withRNBackgroundDownloader(config) {
|
|||||||
// Expo 53's xcode‑js doesn't expose pbxTargets().
|
// Expo 53's xcode‑js doesn't expose pbxTargets().
|
||||||
// Setting the property once at the project level is sufficient.
|
// Setting the property once at the project level is sufficient.
|
||||||
["Debug", "Release"].forEach((cfg) => {
|
["Debug", "Release"].forEach((cfg) => {
|
||||||
// Use the detected projectName to set the bridging header path instead of a hardcoded value
|
|
||||||
const bridgingHeaderPath = `${projectName}/${projectName}-Bridging-Header.h`;
|
|
||||||
project.updateBuildProperty(
|
project.updateBuildProperty(
|
||||||
"SWIFT_OBJC_BRIDGING_HEADER",
|
"SWIFT_OBJC_BRIDGING_HEADER",
|
||||||
bridgingHeaderPath,
|
"Streamyfin/Streamyfin-Bridging-Header.h",
|
||||||
cfg,
|
cfg,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import { useSettings } from "@/utils/atoms/settings";
|
|||||||
import { getOrSetDeviceId } from "@/utils/device";
|
import { getOrSetDeviceId } from "@/utils/device";
|
||||||
import useDownloadHelper from "@/utils/download";
|
import useDownloadHelper from "@/utils/download";
|
||||||
import { getItemImage } from "@/utils/getItemImage";
|
import { getItemImage } from "@/utils/getItemImage";
|
||||||
import { dumpDownloadDiagnostics, writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
import { fetchAndParseSegments } from "@/utils/segments";
|
import { fetchAndParseSegments } from "@/utils/segments";
|
||||||
import { generateTrickplayUrl, getTrickplayInfo } from "@/utils/trickplay";
|
import { generateTrickplayUrl, getTrickplayInfo } from "@/utils/trickplay";
|
||||||
@@ -42,60 +42,37 @@ const BackGroundDownloader = !Platform.isTV
|
|||||||
? require("@kesha-antonov/react-native-background-downloader")
|
? require("@kesha-antonov/react-native-background-downloader")
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Cap progress at 99% to avoid showing 100% before the download is actually complete
|
|
||||||
const MAX_PROGRESS_BEFORE_COMPLETION = 99;
|
|
||||||
|
|
||||||
// Estimate the total download size in bytes for a job. If the media source
|
|
||||||
// provides a Size, use that. Otherwise, if we have a bitrate and run time
|
|
||||||
// (RunTimeTicks), approximate size = (bitrate bits/sec * seconds) / 8.
|
|
||||||
const calculateEstimatedSize = (p: JobStatus): number => {
|
const calculateEstimatedSize = (p: JobStatus): number => {
|
||||||
const size = p.mediaSource?.Size || 0;
|
let size = p.mediaSource.Size;
|
||||||
const maxBitrate = p.maxBitrate?.value;
|
const maxBitrate = p.maxBitrate.value;
|
||||||
const runTimeTicks = (p.item?.RunTimeTicks || 0) as number;
|
if (
|
||||||
|
maxBitrate &&
|
||||||
if (!size && maxBitrate && runTimeTicks > 0) {
|
size &&
|
||||||
// Jellyfin RunTimeTicks are in 10,000,000 ticks per second
|
p.mediaSource.Bitrate &&
|
||||||
const seconds = runTimeTicks / 10000000;
|
maxBitrate < p.mediaSource.Bitrate
|
||||||
if (seconds > 0) {
|
) {
|
||||||
// maxBitrate is in bits per second; convert to bytes
|
size = (size / p.mediaSource.Bitrate) * maxBitrate;
|
||||||
return Math.round((maxBitrate / 8) * seconds);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
// This function is for estimated size, so just return the adjusted size
|
||||||
return size || 0;
|
return size ?? 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Calculate download speed in bytes/sec based on a job's last update time
|
// Helper to calculate download speed
|
||||||
// and previously recorded bytesDownloaded.
|
|
||||||
const calculateSpeed = (
|
const calculateSpeed = (
|
||||||
p: JobStatus,
|
process: JobStatus,
|
||||||
currentBytesDownloaded?: number,
|
newBytesDownloaded: number,
|
||||||
): number | undefined => {
|
): number | undefined => {
|
||||||
// Prefer session-only deltas when available: lastSessionBytes + lastSessionUpdateTime
|
const { bytesDownloaded: oldBytes = 0, lastProgressUpdateTime } = process;
|
||||||
const now = Date.now();
|
const deltaBytes = newBytesDownloaded - oldBytes;
|
||||||
|
|
||||||
if (p.lastSessionUpdateTime && p.lastSessionBytes !== undefined) {
|
if (lastProgressUpdateTime && deltaBytes > 0) {
|
||||||
const last = new Date(p.lastSessionUpdateTime).getTime();
|
const deltaTimeInSeconds =
|
||||||
const deltaTime = (now - last) / 1000;
|
(Date.now() - new Date(lastProgressUpdateTime).getTime()) / 1000;
|
||||||
if (deltaTime > 0) {
|
if (deltaTimeInSeconds > 0) {
|
||||||
const current =
|
return deltaBytes / deltaTimeInSeconds;
|
||||||
currentBytesDownloaded ?? p.bytesDownloaded ?? p.lastSessionBytes;
|
|
||||||
const deltaBytes = current - p.lastSessionBytes;
|
|
||||||
if (deltaBytes > 0) return deltaBytes / deltaTime;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return undefined;
|
||||||
// Fallback to total-based deltas for compatibility
|
|
||||||
if (!p.lastProgressUpdateTime || p.bytesDownloaded === undefined)
|
|
||||||
return undefined;
|
|
||||||
const last = new Date(p.lastProgressUpdateTime).getTime();
|
|
||||||
const deltaTime = (now - last) / 1000;
|
|
||||||
if (deltaTime <= 0) return undefined;
|
|
||||||
const prev = p.bytesDownloaded || 0;
|
|
||||||
const current = currentBytesDownloaded ?? prev;
|
|
||||||
const deltaBytes = current - prev;
|
|
||||||
if (deltaBytes <= 0) return undefined;
|
|
||||||
return deltaBytes / deltaTime;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const processesAtom = atom<JobStatus[]>([]);
|
export const processesAtom = atom<JobStatus[]>([]);
|
||||||
@@ -193,96 +170,27 @@ function useDownloadProvider() {
|
|||||||
|
|
||||||
const currentProcesses = [...processes, ...missingProcesses];
|
const currentProcesses = [...processes, ...missingProcesses];
|
||||||
const updatedProcesses = currentProcesses.map((p) => {
|
const updatedProcesses = currentProcesses.map((p) => {
|
||||||
// Enhanced filtering to prevent iOS zombie task interference
|
// fallback. Doesn't really work for transcodes as they may be a lot smaller.
|
||||||
// Only update progress for downloads that are actively downloading
|
// We make an wild guess by comparing bitrates
|
||||||
if (p.status !== "downloading") {
|
|
||||||
return p;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find task for this process
|
|
||||||
const task = tasks.find((s: any) => s.id === p.id);
|
const task = tasks.find((s: any) => s.id === p.id);
|
||||||
if (!task) {
|
|
||||||
return p; // No task found, keep current state
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
// TODO: Uncomment this block to re-enable iOS zombie task detection
|
|
||||||
// iOS: Extra validation to prevent zombie task interference
|
|
||||||
if (Platform.OS === "ios") {
|
|
||||||
// Check if we have multiple tasks for same ID (zombie detection)
|
|
||||||
const tasksForId = tasks.filter((t: any) => t.id === p.id);
|
|
||||||
if (tasksForId.length > 1) {
|
|
||||||
console.warn(
|
|
||||||
`[UPDATE] Detected ${tasksForId.length} zombie tasks for ${p.id}, ignoring progress update`,
|
|
||||||
);
|
|
||||||
return p; // Don't update progress from potentially conflicting tasks
|
|
||||||
}
|
|
||||||
|
|
||||||
// If task state looks suspicious (e.g., iOS task stuck in background), be conservative
|
|
||||||
if (
|
|
||||||
task.state &&
|
|
||||||
["SUSPENDED", "PAUSED"].includes(task.state) &&
|
|
||||||
p.status === "downloading"
|
|
||||||
) {
|
|
||||||
console.warn(
|
|
||||||
`[UPDATE] Task ${p.id} has suspicious state ${task.state}, ignoring progress update`,
|
|
||||||
);
|
|
||||||
return p;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
||||||
if (task && p.status === "downloading") {
|
if (task && p.status === "downloading") {
|
||||||
const estimatedSize = calculateEstimatedSize(p);
|
const estimatedSize = calculateEstimatedSize(p);
|
||||||
let progress = p.progress;
|
let progress = p.progress;
|
||||||
|
if (estimatedSize > 0) {
|
||||||
// If we have a pausedProgress snapshot then merge current session
|
progress = (100 / estimatedSize) * task.bytesDownloaded;
|
||||||
// progress into it. We accept pausedProgress === 0 as valid because
|
|
||||||
// users can pause immediately after starting.
|
|
||||||
if (p.pausedProgress !== undefined) {
|
|
||||||
const totalBytesDownloaded =
|
|
||||||
(p.pausedBytes ?? 0) + task.bytesDownloaded;
|
|
||||||
|
|
||||||
// Calculate progress based on total bytes downloaded vs estimated size
|
|
||||||
progress =
|
|
||||||
estimatedSize > 0
|
|
||||||
? (totalBytesDownloaded / estimatedSize) * 100
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
// Use the total accounted bytes when computing speed so the
|
|
||||||
// displayed speed and progress remain consistent after resume.
|
|
||||||
const speed = calculateSpeed(p, totalBytesDownloaded);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...p,
|
|
||||||
progress: Math.min(progress, MAX_PROGRESS_BEFORE_COMPLETION),
|
|
||||||
speed,
|
|
||||||
bytesDownloaded: totalBytesDownloaded,
|
|
||||||
lastProgressUpdateTime: new Date(),
|
|
||||||
estimatedTotalSizeBytes: estimatedSize,
|
|
||||||
// Set session bytes to total bytes downloaded
|
|
||||||
lastSessionBytes: totalBytesDownloaded,
|
|
||||||
lastSessionUpdateTime: new Date(),
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
if (estimatedSize > 0) {
|
|
||||||
progress = (100 / estimatedSize) * task.bytesDownloaded;
|
|
||||||
}
|
|
||||||
if (progress >= 100) {
|
|
||||||
progress = MAX_PROGRESS_BEFORE_COMPLETION;
|
|
||||||
}
|
|
||||||
const speed = calculateSpeed(p, task.bytesDownloaded);
|
|
||||||
return {
|
|
||||||
...p,
|
|
||||||
progress,
|
|
||||||
speed,
|
|
||||||
bytesDownloaded: task.bytesDownloaded,
|
|
||||||
lastProgressUpdateTime: new Date(),
|
|
||||||
estimatedTotalSizeBytes: estimatedSize,
|
|
||||||
lastSessionBytes: task.bytesDownloaded,
|
|
||||||
lastSessionUpdateTime: new Date(),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
if (progress >= 100) {
|
||||||
|
progress = 99;
|
||||||
|
}
|
||||||
|
const speed = calculateSpeed(p, task.bytesDownloaded);
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
progress,
|
||||||
|
speed,
|
||||||
|
bytesDownloaded: task.bytesDownloaded,
|
||||||
|
lastProgressUpdateTime: new Date(),
|
||||||
|
estimatedTotalSizeBytes: estimatedSize,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return p;
|
return p;
|
||||||
});
|
});
|
||||||
@@ -301,7 +209,7 @@ function useDownloadProvider() {
|
|||||||
return db.movies[id];
|
return db.movies[id];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check episodes
|
// If not in movies, check episodes
|
||||||
for (const series of Object.values(db.series)) {
|
for (const series of Object.values(db.series)) {
|
||||||
for (const season of Object.values(series.seasons)) {
|
for (const season of Object.values(series.seasons)) {
|
||||||
for (const episode of Object.values(season.episodes)) {
|
for (const episode of Object.values(season.episodes)) {
|
||||||
@@ -312,11 +220,6 @@ function useDownloadProvider() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check other media types
|
|
||||||
if (db.other[id]) {
|
|
||||||
return db.other[id];
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -353,7 +256,7 @@ function useDownloadProvider() {
|
|||||||
if (file) {
|
if (file) {
|
||||||
return JSON.parse(file) as DownloadsDatabase;
|
return JSON.parse(file) as DownloadsDatabase;
|
||||||
}
|
}
|
||||||
return { movies: {}, series: {}, other: {} }; // Initialize other media types storage
|
return { movies: {}, series: {} };
|
||||||
};
|
};
|
||||||
|
|
||||||
const getDownloadedItems = () => {
|
const getDownloadedItems = () => {
|
||||||
@@ -365,7 +268,6 @@ function useDownloadProvider() {
|
|||||||
Object.values(season.episodes),
|
Object.values(season.episodes),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
...Object.values(db.other), // Include other media types in results
|
|
||||||
];
|
];
|
||||||
return allItems;
|
return allItems;
|
||||||
};
|
};
|
||||||
@@ -470,76 +372,10 @@ function useDownloadProvider() {
|
|||||||
async (process: JobStatus) => {
|
async (process: JobStatus) => {
|
||||||
if (!process?.item.Id || !authHeader) throw new Error("No item id");
|
if (!process?.item.Id || !authHeader) throw new Error("No item id");
|
||||||
|
|
||||||
// Enhanced cleanup for existing tasks to prevent duplicates
|
|
||||||
try {
|
|
||||||
const allTasks = await BackGroundDownloader.checkForExistingDownloads();
|
|
||||||
const existingTasks = allTasks?.filter((t: any) => t.id === process.id);
|
|
||||||
|
|
||||||
if (existingTasks && existingTasks.length > 0) {
|
|
||||||
console.log(
|
|
||||||
`[START] Found ${existingTasks.length} existing task(s) for ${process.id}, cleaning up...`,
|
|
||||||
);
|
|
||||||
|
|
||||||
for (let i = 0; i < existingTasks.length; i++) {
|
|
||||||
const existingTask = existingTasks[i];
|
|
||||||
console.log(
|
|
||||||
`[START] Cleaning up task ${i + 1}/${existingTasks.length} for ${process.id}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
/*
|
|
||||||
// TODO: Uncomment this block to re-enable iOS-specific cleanup
|
|
||||||
// iOS: More aggressive cleanup sequence
|
|
||||||
if (Platform.OS === "ios") {
|
|
||||||
try {
|
|
||||||
await existingTask.pause();
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
||||||
} catch (_pauseErr) {
|
|
||||||
// Ignore pause errors
|
|
||||||
}
|
|
||||||
|
|
||||||
await existingTask.stop();
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
||||||
|
|
||||||
// Multiple complete handler calls to ensure cleanup
|
|
||||||
BackGroundDownloader.completeHandler(process.id);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
||||||
} else {
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Simple cleanup for all platforms (currently Android only)
|
|
||||||
await existingTask.stop();
|
|
||||||
BackGroundDownloader.completeHandler(process.id);
|
|
||||||
|
|
||||||
/* } // End of iOS block - uncomment when re-enabling iOS functionality */
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`[START] Successfully cleaned up task ${i + 1} for ${process.id}`,
|
|
||||||
);
|
|
||||||
} catch (taskError) {
|
|
||||||
console.warn(
|
|
||||||
`[START] Failed to cleanup task ${i + 1} for ${process.id}:`,
|
|
||||||
taskError,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cleanup delay (simplified for Android)
|
|
||||||
const cleanupDelay = 200; // Platform.OS === "ios" ? 500 : 200;
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, cleanupDelay));
|
|
||||||
console.log(`[START] Cleanup completed for ${process.id}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(
|
|
||||||
`[START] Failed to check/cleanup existing tasks for ${process.id}:`,
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateProcess(process.id, {
|
updateProcess(process.id, {
|
||||||
speed: undefined,
|
speed: undefined,
|
||||||
status: "downloading",
|
status: "downloading",
|
||||||
progress: process.progress || 0, // Preserve existing progress for resume
|
progress: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
BackGroundDownloader?.setConfig({
|
BackGroundDownloader?.setConfig({
|
||||||
@@ -560,42 +396,21 @@ function useDownloadProvider() {
|
|||||||
.begin(() => {
|
.begin(() => {
|
||||||
updateProcess(process.id, {
|
updateProcess(process.id, {
|
||||||
status: "downloading",
|
status: "downloading",
|
||||||
progress: process.progress || 0,
|
progress: 0,
|
||||||
bytesDownloaded: process.bytesDownloaded || 0,
|
bytesDownloaded: 0,
|
||||||
lastProgressUpdateTime: new Date(),
|
lastProgressUpdateTime: new Date(),
|
||||||
lastSessionBytes: process.lastSessionBytes || 0,
|
|
||||||
lastSessionUpdateTime: new Date(),
|
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.progress(
|
.progress(
|
||||||
throttle((data) => {
|
throttle((data) => {
|
||||||
updateProcess(process.id, (currentProcess) => {
|
updateProcess(process.id, (currentProcess) => {
|
||||||
// If this is a resumed download, add the paused bytes to current session bytes
|
const percent = (data.bytesDownloaded / data.bytesTotal) * 100;
|
||||||
const resumedBytes = currentProcess.pausedBytes || 0;
|
|
||||||
const totalBytes = data.bytesDownloaded + resumedBytes;
|
|
||||||
|
|
||||||
// Calculate progress based on total bytes if we have resumed bytes
|
|
||||||
let percent: number;
|
|
||||||
if (resumedBytes > 0 && data.bytesTotal > 0) {
|
|
||||||
// For resumed downloads, calculate based on estimated total size
|
|
||||||
const estimatedTotal =
|
|
||||||
currentProcess.estimatedTotalSizeBytes ||
|
|
||||||
data.bytesTotal + resumedBytes;
|
|
||||||
percent = (totalBytes / estimatedTotal) * 100;
|
|
||||||
} else {
|
|
||||||
// For fresh downloads, use normal calculation
|
|
||||||
percent = (data.bytesDownloaded / data.bytesTotal) * 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
speed: calculateSpeed(currentProcess, totalBytes),
|
speed: calculateSpeed(currentProcess, data.bytesDownloaded),
|
||||||
status: "downloading",
|
status: "downloading",
|
||||||
progress: Math.min(percent, MAX_PROGRESS_BEFORE_COMPLETION),
|
progress: percent,
|
||||||
bytesDownloaded: totalBytes,
|
bytesDownloaded: data.bytesDownloaded,
|
||||||
lastProgressUpdateTime: new Date(),
|
lastProgressUpdateTime: new Date(),
|
||||||
// update session-only counters - use current session bytes only for speed calc
|
|
||||||
lastSessionBytes: data.bytesDownloaded,
|
|
||||||
lastSessionUpdateTime: new Date(),
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, 500),
|
}, 500),
|
||||||
@@ -664,9 +479,6 @@ function useDownloadProvider() {
|
|||||||
db.series[item.SeriesId].seasons[seasonNumber].episodes[
|
db.series[item.SeriesId].seasons[seasonNumber].episodes[
|
||||||
episodeNumber
|
episodeNumber
|
||||||
] = downloadedItem;
|
] = downloadedItem;
|
||||||
} else if (item.Id) {
|
|
||||||
// Handle other media types
|
|
||||||
db.other[item.Id] = downloadedItem;
|
|
||||||
}
|
}
|
||||||
await saveDownloadsDatabase(db);
|
await saveDownloadsDatabase(db);
|
||||||
|
|
||||||
@@ -730,17 +542,7 @@ function useDownloadProvider() {
|
|||||||
if (activeDownloads < concurrentLimit) {
|
if (activeDownloads < concurrentLimit) {
|
||||||
const queuedDownload = processes.find((p) => p.status === "queued");
|
const queuedDownload = processes.find((p) => p.status === "queued");
|
||||||
if (queuedDownload) {
|
if (queuedDownload) {
|
||||||
// Reserve the slot immediately to avoid race where startDownload's
|
startDownload(queuedDownload);
|
||||||
// asynchronous begin callback hasn't executed yet and multiple
|
|
||||||
// downloads are started, bypassing the concurrent limit.
|
|
||||||
updateProcess(queuedDownload.id, { status: "downloading" });
|
|
||||||
startDownload(queuedDownload).catch((error) => {
|
|
||||||
console.error("Failed to start download:", error);
|
|
||||||
updateProcess(queuedDownload.id, { status: "error" });
|
|
||||||
toast.error(t("home.downloads.toasts.failed_to_start_download"), {
|
|
||||||
description: error.message || "Unknown error",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [processes, settings?.remuxConcurrentLimit, startDownload]);
|
}, [processes, settings?.remuxConcurrentLimit, startDownload]);
|
||||||
@@ -749,38 +551,8 @@ function useDownloadProvider() {
|
|||||||
async (id: string) => {
|
async (id: string) => {
|
||||||
const tasks = await BackGroundDownloader.checkForExistingDownloads();
|
const tasks = await BackGroundDownloader.checkForExistingDownloads();
|
||||||
const task = tasks?.find((t: any) => t.id === id);
|
const task = tasks?.find((t: any) => t.id === id);
|
||||||
if (task) {
|
task?.stop();
|
||||||
// On iOS, suspended tasks need to be cancelled properly
|
BackGroundDownloader.completeHandler(id);
|
||||||
if (Platform.OS === "ios") {
|
|
||||||
const state = task.state || task.state?.();
|
|
||||||
if (
|
|
||||||
state === "PAUSED" ||
|
|
||||||
state === "paused" ||
|
|
||||||
state === "SUSPENDED" ||
|
|
||||||
state === "suspended"
|
|
||||||
) {
|
|
||||||
// For suspended tasks, we need to resume first, then stop
|
|
||||||
try {
|
|
||||||
await task.resume();
|
|
||||||
// Small delay to allow resume to take effect
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
||||||
} catch (_resumeError) {
|
|
||||||
// Resume might fail, continue with stop
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
task.stop();
|
|
||||||
} catch (_err) {
|
|
||||||
// ignore stop errors
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
BackGroundDownloader.completeHandler(id);
|
|
||||||
} catch (_err) {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setProcesses((prev) => prev.filter((process) => process.id !== id));
|
setProcesses((prev) => prev.filter((process) => process.id !== id));
|
||||||
manageDownloadQueue();
|
manageDownloadQueue();
|
||||||
},
|
},
|
||||||
@@ -803,7 +575,7 @@ function useDownloadProvider() {
|
|||||||
intermediates: true,
|
intermediates: true,
|
||||||
});
|
});
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
toast.error(t("home.downloads.toasts.failed_to_clean_cache_directory"));
|
toast.error(t("Failed to clean cache directory."));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -839,13 +611,9 @@ function useDownloadProvider() {
|
|||||||
status: "queued",
|
status: "queued",
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
};
|
};
|
||||||
setProcesses((prev) => {
|
setProcesses((prev) => [...prev, job]);
|
||||||
// Remove any existing processes for this item to prevent duplicates
|
|
||||||
const filtered = prev.filter((p) => p.id !== item.Id);
|
|
||||||
return [...filtered, job];
|
|
||||||
});
|
|
||||||
toast.success(
|
toast.success(
|
||||||
t("home.downloads.toasts.download_started_for_item", {
|
t("home.downloads.toasts.download_stated_for_item", {
|
||||||
item: item.Name,
|
item: item.Name,
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
@@ -865,16 +633,16 @@ function useDownloadProvider() {
|
|||||||
[authHeader, startDownload],
|
[authHeader, startDownload],
|
||||||
);
|
);
|
||||||
|
|
||||||
const deleteFile = async (id: string, type: BaseItemDto["Type"]) => {
|
const deleteFile = async (id: string, type: "Movie" | "Episode") => {
|
||||||
const db = getDownloadsDatabase();
|
const db = getDownloadsDatabase();
|
||||||
let downloadedItem: DownloadedItem | undefined;
|
let downloadedItem: DownloadedItem | undefined;
|
||||||
|
|
||||||
if (type === "Movie" && Object.entries(db.movies).length !== 0) {
|
if (type === "Movie") {
|
||||||
downloadedItem = db.movies[id];
|
downloadedItem = db.movies[id];
|
||||||
if (downloadedItem) {
|
if (downloadedItem) {
|
||||||
delete db.movies[id];
|
delete db.movies[id];
|
||||||
}
|
}
|
||||||
} else if (type === "Episode" && Object.entries(db.series).length !== 0) {
|
} else if (type === "Episode") {
|
||||||
const cleanUpEmptyParents = (
|
const cleanUpEmptyParents = (
|
||||||
series: any,
|
series: any,
|
||||||
seasonNumber: string,
|
seasonNumber: string,
|
||||||
@@ -904,12 +672,6 @@ function useDownloadProvider() {
|
|||||||
}
|
}
|
||||||
if (downloadedItem) break;
|
if (downloadedItem) break;
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// Handle other media types
|
|
||||||
downloadedItem = db.other[id];
|
|
||||||
if (downloadedItem) {
|
|
||||||
delete db.other[id];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (downloadedItem?.videoFilePath) {
|
if (downloadedItem?.videoFilePath) {
|
||||||
@@ -943,7 +705,7 @@ function useDownloadProvider() {
|
|||||||
|
|
||||||
const deleteItems = async (items: BaseItemDto[]) => {
|
const deleteItems = async (items: BaseItemDto[]) => {
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (item.Id) {
|
if (item.Id && (item.Type === "Movie" || item.Type === "Episode")) {
|
||||||
await deleteFile(item.Id, item.Type);
|
await deleteFile(item.Id, item.Type);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -985,8 +747,6 @@ function useDownloadProvider() {
|
|||||||
const db = getDownloadsDatabase();
|
const db = getDownloadsDatabase();
|
||||||
if (db.movies[itemId]) {
|
if (db.movies[itemId]) {
|
||||||
db.movies[itemId] = updatedItem;
|
db.movies[itemId] = updatedItem;
|
||||||
} else if (db.other[itemId]) {
|
|
||||||
db.other[itemId] = updatedItem;
|
|
||||||
} else {
|
} else {
|
||||||
for (const series of Object.values(db.series)) {
|
for (const series of Object.values(db.series)) {
|
||||||
for (const season of Object.values(series.seasons)) {
|
for (const season of Object.values(series.seasons)) {
|
||||||
@@ -1031,99 +791,12 @@ function useDownloadProvider() {
|
|||||||
const process = processes.find((p) => p.id === id);
|
const process = processes.find((p) => p.id === id);
|
||||||
if (!process) throw new Error("No active download");
|
if (!process) throw new Error("No active download");
|
||||||
|
|
||||||
// TODO: iOS pause functionality temporarily disabled due to background task issues
|
|
||||||
// Remove this check to re-enable iOS pause functionality in the future
|
|
||||||
if (Platform.OS === "ios") {
|
|
||||||
console.warn(
|
|
||||||
`[PAUSE] Pause functionality temporarily disabled on iOS for ${id}`,
|
|
||||||
);
|
|
||||||
throw new Error("Pause functionality is currently disabled on iOS");
|
|
||||||
}
|
|
||||||
|
|
||||||
const tasks = await BackGroundDownloader.checkForExistingDownloads();
|
const tasks = await BackGroundDownloader.checkForExistingDownloads();
|
||||||
const task = tasks?.find((t: any) => t.id === id);
|
const task = tasks?.find((t: any) => t.id === id);
|
||||||
if (!task) throw new Error("No task found");
|
if (!task) throw new Error("No task found");
|
||||||
|
|
||||||
// Get current progress before stopping
|
task.pause();
|
||||||
const currentProgress = process.progress;
|
updateProcess(id, { status: "paused" });
|
||||||
const currentBytes = process.bytesDownloaded || task.bytesDownloaded || 0;
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`[PAUSE] Starting pause for ${id}. Current bytes: ${currentBytes}, Progress: ${currentProgress}%`,
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
/*
|
|
||||||
// TODO: Uncomment this block to re-enable iOS pause functionality
|
|
||||||
// iOS-specific aggressive cleanup approach based on GitHub issue #26
|
|
||||||
if (Platform.OS === "ios") {
|
|
||||||
// Get ALL tasks for this ID - there might be multiple zombie tasks
|
|
||||||
const allTasks =
|
|
||||||
await BackGroundDownloader.checkForExistingDownloads();
|
|
||||||
const tasksForId = allTasks?.filter((t: any) => t.id === id) || [];
|
|
||||||
|
|
||||||
console.log(`[PAUSE] Found ${tasksForId.length} task(s) for ${id}`);
|
|
||||||
|
|
||||||
// Stop ALL tasks for this ID to prevent zombie processes
|
|
||||||
for (let i = 0; i < tasksForId.length; i++) {
|
|
||||||
const taskToStop = tasksForId[i];
|
|
||||||
console.log(
|
|
||||||
`[PAUSE] Stopping task ${i + 1}/${tasksForId.length} for ${id}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// iOS: pause → stop sequence with delays (based on issue research)
|
|
||||||
await taskToStop.pause();
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
||||||
|
|
||||||
await taskToStop.stop();
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`[PAUSE] Successfully stopped task ${i + 1} for ${id}`,
|
|
||||||
);
|
|
||||||
} catch (taskError) {
|
|
||||||
console.warn(
|
|
||||||
`[PAUSE] Failed to stop task ${i + 1} for ${id}:`,
|
|
||||||
taskError,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extra cleanup delay for iOS NSURLSession to fully stop
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
||||||
} else {
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Android: simpler approach (currently the only active platform)
|
|
||||||
await task.stop();
|
|
||||||
|
|
||||||
/* } // End of iOS block - uncomment when re-enabling iOS functionality */
|
|
||||||
|
|
||||||
// Clean up the native task handler
|
|
||||||
try {
|
|
||||||
BackGroundDownloader.completeHandler(id);
|
|
||||||
} catch (_err) {
|
|
||||||
console.warn(`[PAUSE] Handler cleanup warning for ${id}:`, _err);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update process state to paused
|
|
||||||
updateProcess(id, {
|
|
||||||
status: "paused",
|
|
||||||
progress: currentProgress,
|
|
||||||
bytesDownloaded: currentBytes,
|
|
||||||
pausedAt: new Date(),
|
|
||||||
pausedProgress: currentProgress,
|
|
||||||
pausedBytes: currentBytes,
|
|
||||||
lastSessionBytes: process.lastSessionBytes ?? currentBytes,
|
|
||||||
lastSessionUpdateTime: process.lastSessionUpdateTime ?? new Date(),
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`Download paused successfully: ${id}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error pausing task:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[processes, updateProcess],
|
[processes, updateProcess],
|
||||||
);
|
);
|
||||||
@@ -1133,78 +806,37 @@ function useDownloadProvider() {
|
|||||||
const process = processes.find((p) => p.id === id);
|
const process = processes.find((p) => p.id === id);
|
||||||
if (!process) throw new Error("No active download");
|
if (!process) throw new Error("No active download");
|
||||||
|
|
||||||
// TODO: iOS resume functionality temporarily disabled due to background task issues
|
const tasks = await BackGroundDownloader.checkForExistingDownloads();
|
||||||
// Remove this check to re-enable iOS resume functionality in the future
|
const task = tasks?.find((t: any) => t.id === id);
|
||||||
if (Platform.OS === "ios") {
|
if (!task) throw new Error("No task found");
|
||||||
|
|
||||||
|
// Check if task state allows resuming
|
||||||
|
if (task.state === "FAILED") {
|
||||||
console.warn(
|
console.warn(
|
||||||
`[RESUME] Resume functionality temporarily disabled on iOS for ${id}`,
|
"Download task failed, cannot resume. Restarting download.",
|
||||||
);
|
);
|
||||||
throw new Error("Resume functionality is currently disabled on iOS");
|
// For failed tasks, we need to restart rather than resume
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`[RESUME] Attempting to resume ${id}. Paused bytes: ${process.pausedBytes}, Progress: ${process.pausedProgress}%`,
|
|
||||||
);
|
|
||||||
|
|
||||||
/*
|
|
||||||
// TODO: Uncomment this block to re-enable iOS resume functionality
|
|
||||||
// Enhanced cleanup for iOS based on GitHub issue research
|
|
||||||
if (Platform.OS === "ios") {
|
|
||||||
try {
|
|
||||||
// Clean up any lingering zombie tasks first (critical for iOS)
|
|
||||||
const allTasks =
|
|
||||||
await BackGroundDownloader.checkForExistingDownloads();
|
|
||||||
const existingTasks = allTasks?.filter((t: any) => t.id === id) || [];
|
|
||||||
|
|
||||||
if (existingTasks.length > 0) {
|
|
||||||
console.log(
|
|
||||||
`[RESUME] Found ${existingTasks.length} lingering task(s), cleaning up...`,
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const task of existingTasks) {
|
|
||||||
try {
|
|
||||||
await task.stop();
|
|
||||||
BackGroundDownloader.completeHandler(id);
|
|
||||||
} catch (cleanupError) {
|
|
||||||
console.warn(`[RESUME] Cleanup error:`, cleanupError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for iOS cleanup to complete
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`[RESUME] Pre-resume cleanup failed:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Simple approach: always restart the download from where we left off
|
|
||||||
// This works consistently across all platforms (currently Android only)
|
|
||||||
if (
|
|
||||||
process.pausedProgress !== undefined &&
|
|
||||||
process.pausedBytes !== undefined
|
|
||||||
) {
|
|
||||||
// We have saved pause state - restore it and restart
|
|
||||||
updateProcess(id, {
|
|
||||||
progress: process.pausedProgress,
|
|
||||||
bytesDownloaded: process.pausedBytes,
|
|
||||||
status: "downloading",
|
|
||||||
// Reset session counters for proper speed calculation
|
|
||||||
lastSessionBytes: process.pausedBytes,
|
|
||||||
lastSessionUpdateTime: new Date(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Small delay to ensure any cleanup in startDownload completes
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
||||||
|
|
||||||
const updatedProcess = processes.find((p) => p.id === id);
|
|
||||||
await startDownload(updatedProcess || process);
|
|
||||||
|
|
||||||
console.log(`Download resumed successfully: ${id}`);
|
|
||||||
} else {
|
|
||||||
// No pause state - start from beginning
|
|
||||||
await startDownload(process);
|
await startDownload(process);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
task.resume();
|
||||||
|
updateProcess(id, { status: "downloading" });
|
||||||
|
} catch (error: any) {
|
||||||
|
// Handle specific ERROR_CANNOT_RESUME error
|
||||||
|
if (
|
||||||
|
error?.error === "ERROR_CANNOT_RESUME" ||
|
||||||
|
error?.errorCode === 1008
|
||||||
|
) {
|
||||||
|
console.warn("Cannot resume download, attempting to restart instead");
|
||||||
|
await startDownload(process);
|
||||||
|
return; // Return early to prevent error from bubbling up
|
||||||
|
} else {
|
||||||
|
// Only log error for non-handled cases
|
||||||
|
console.error("Error resuming download:", error);
|
||||||
|
throw error; // Re-throw other errors
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[processes, updateProcess, startDownload],
|
[processes, updateProcess, startDownload],
|
||||||
@@ -1229,21 +861,6 @@ function useDownloadProvider() {
|
|||||||
cleanCacheDirectory,
|
cleanCacheDirectory,
|
||||||
updateDownloadedItem,
|
updateDownloadedItem,
|
||||||
appSizeUsage,
|
appSizeUsage,
|
||||||
dumpDownloadDiagnostics: async (id?: string) => {
|
|
||||||
// Collect JS-side processes and native task info (best-effort)
|
|
||||||
const tasks = BackGroundDownloader
|
|
||||||
? await BackGroundDownloader.checkForExistingDownloads()
|
|
||||||
: [];
|
|
||||||
const extra: any = {
|
|
||||||
processes,
|
|
||||||
nativeTasks: tasks || [],
|
|
||||||
};
|
|
||||||
if (id) {
|
|
||||||
const p = processes.find((x) => x.id === id);
|
|
||||||
extra.focusedProcess = p || null;
|
|
||||||
}
|
|
||||||
return dumpDownloadDiagnostics(extra);
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -88,8 +88,6 @@ export interface DownloadsDatabase {
|
|||||||
movies: Record<string, DownloadedItem>;
|
movies: Record<string, DownloadedItem>;
|
||||||
/** A map of series IDs to their downloaded series data. */
|
/** A map of series IDs to their downloaded series data. */
|
||||||
series: Record<string, DownloadedSeries>;
|
series: Record<string, DownloadedSeries>;
|
||||||
/** A map of IDs to downloaded items that are neither movies nor episodes */
|
|
||||||
other: Record<string, DownloadedItem>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -131,14 +129,4 @@ export type JobStatus = {
|
|||||||
/** Estimated total size of the download in bytes (optional) this is used when we
|
/** Estimated total size of the download in bytes (optional) this is used when we
|
||||||
* download transcoded content because we don't know the size of the file until it's downloaded */
|
* download transcoded content because we don't know the size of the file until it's downloaded */
|
||||||
estimatedTotalSizeBytes?: number;
|
estimatedTotalSizeBytes?: number;
|
||||||
/** Timestamp when the download was paused (optional) */
|
|
||||||
pausedAt?: Date;
|
|
||||||
/** Progress percentage when download was paused (optional) */
|
|
||||||
pausedProgress?: number;
|
|
||||||
/** Bytes downloaded when download was paused (optional) */
|
|
||||||
pausedBytes?: number;
|
|
||||||
/** Bytes downloaded in the current session (since last resume). Used for session-only speed calculation. */
|
|
||||||
lastSessionBytes?: number;
|
|
||||||
/** Timestamp when the session-only bytes were last updated. */
|
|
||||||
lastSessionUpdateTime?: Date;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
setJellyfin(
|
setJellyfin(
|
||||||
() =>
|
() =>
|
||||||
new Jellyfin({
|
new Jellyfin({
|
||||||
clientInfo: { name: "Streamyfin", version: "0.39.0" },
|
clientInfo: { name: "Streamyfin", version: "0.35.1" },
|
||||||
deviceInfo: {
|
deviceInfo: {
|
||||||
name: deviceName,
|
name: deviceName,
|
||||||
id,
|
id,
|
||||||
@@ -87,7 +87,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
return {
|
return {
|
||||||
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
||||||
Platform.OS === "android" ? "Android" : "iOS"
|
Platform.OS === "android" ? "Android" : "iOS"
|
||||||
}, DeviceId="${deviceId}", Version="0.39.0"`,
|
}, DeviceId="${deviceId}", Version="0.35.1"`,
|
||||||
};
|
};
|
||||||
}, [deviceId]);
|
}, [deviceId]);
|
||||||
|
|
||||||
@@ -374,7 +374,7 @@ function useProtectedRoute(user: UserDto | null, loaded = false) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loaded === false) return;
|
if (loaded === false) return;
|
||||||
|
|
||||||
const inAuthGroup = segments.length > 1 && segments[0] === "(auth)";
|
const inAuthGroup = segments[0] === "(auth)";
|
||||||
|
|
||||||
if (!user?.Id && inAuthGroup) {
|
if (!user?.Id && inAuthGroup) {
|
||||||
console.log("Redirected to login");
|
console.log("Redirected to login");
|
||||||
|
|||||||
@@ -1,256 +0,0 @@
|
|||||||
const { execFileSync } = require("node:child_process");
|
|
||||||
const process = require("node:process");
|
|
||||||
|
|
||||||
// Enhanced ANSI color codes and styles
|
|
||||||
const colors = {
|
|
||||||
red: "\x1b[31m",
|
|
||||||
green: "\x1b[32m",
|
|
||||||
yellow: "\x1b[33m",
|
|
||||||
blue: "\x1b[34m",
|
|
||||||
magenta: "\x1b[35m",
|
|
||||||
cyan: "\x1b[36m",
|
|
||||||
white: "\x1b[37m",
|
|
||||||
gray: "\x1b[90m",
|
|
||||||
reset: "\x1b[0m",
|
|
||||||
bold: "\x1b[1m",
|
|
||||||
dim: "\x1b[2m",
|
|
||||||
underline: "\x1b[4m",
|
|
||||||
bg: {
|
|
||||||
red: "\x1b[41m",
|
|
||||||
green: "\x1b[42m",
|
|
||||||
yellow: "\x1b[43m",
|
|
||||||
blue: "\x1b[44m",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const border = "━".repeat(80);
|
|
||||||
|
|
||||||
// Center the title within the border
|
|
||||||
const title = "🔥 STREAMYFIN TYPESCRIPT CHECK";
|
|
||||||
const titlePadding = Math.floor((80 - title.length) / 2);
|
|
||||||
const centeredTitle = " ".repeat(titlePadding) + title;
|
|
||||||
|
|
||||||
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
||||||
|
|
||||||
function log(message, color = "") {
|
|
||||||
if (useColor && color) {
|
|
||||||
console.log(`${color}${message}${colors.reset}`);
|
|
||||||
} else {
|
|
||||||
console.log(String(message));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatError(errorLine) {
|
|
||||||
if (!useColor) return errorLine;
|
|
||||||
|
|
||||||
// Color file paths in cyan
|
|
||||||
let formatted = errorLine.replace(
|
|
||||||
/^([^(]+\([^)]+\):)/,
|
|
||||||
`${colors.cyan}$1${colors.reset}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Color error codes in red bold
|
|
||||||
formatted = formatted.replace(
|
|
||||||
/(error TS\d+:)/g,
|
|
||||||
`${colors.red}${colors.bold}$1${colors.reset}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Color type names in yellow
|
|
||||||
formatted = formatted.replace(
|
|
||||||
/(Type '[^']*')/g,
|
|
||||||
`${colors.yellow}$1${colors.reset}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Color property names in magenta
|
|
||||||
formatted = formatted.replace(
|
|
||||||
/(Property '[^']*')/g,
|
|
||||||
`${colors.magenta}$1${colors.reset}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return formatted;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseErrorsAndCreateSummary(errorOutput) {
|
|
||||||
const lines = errorOutput.split("\n").filter((line) => line.trim());
|
|
||||||
const errorsByFile = new Map();
|
|
||||||
const formattedErrors = [];
|
|
||||||
|
|
||||||
let currentError = [];
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
const trimmedLine = line.trim();
|
|
||||||
if (!trimmedLine) continue;
|
|
||||||
|
|
||||||
// Check if this is the start of a new error (has file path and error code)
|
|
||||||
const errorMatch = line.match(/^([^(]+\([^)]+\):)\s*(error TS\d+:)/);
|
|
||||||
|
|
||||||
if (errorMatch) {
|
|
||||||
// If we have a previous error, add it to the list
|
|
||||||
if (currentError.length > 0) {
|
|
||||||
formattedErrors.push(currentError.join("\n"));
|
|
||||||
currentError = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract file info for summary
|
|
||||||
const filePath = errorMatch[1].split("(")[0];
|
|
||||||
if (!errorsByFile.has(filePath)) {
|
|
||||||
errorsByFile.set(filePath, 0);
|
|
||||||
}
|
|
||||||
errorsByFile.set(filePath, errorsByFile.get(filePath) + 1);
|
|
||||||
|
|
||||||
// Start new error
|
|
||||||
currentError.push(formatError(line));
|
|
||||||
} else if (currentError.length > 0) {
|
|
||||||
// This is a continuation of the current error
|
|
||||||
currentError.push(` ${colors.gray}${line}${colors.reset}`);
|
|
||||||
} else if (line.match(/Found \d+ errors? in \d+ files?/)) {
|
|
||||||
// Skip the summary line; no action needed for this line
|
|
||||||
} else {
|
|
||||||
// Standalone line
|
|
||||||
formattedErrors.push(formatError(line));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add the last error if exists
|
|
||||||
if (currentError.length > 0) {
|
|
||||||
formattedErrors.push(currentError.join("\n"));
|
|
||||||
}
|
|
||||||
|
|
||||||
return { formattedErrors, errorsByFile };
|
|
||||||
}
|
|
||||||
|
|
||||||
function createErrorSummaryTable(errorsByFile) {
|
|
||||||
if (errorsByFile.size === 0) return "";
|
|
||||||
|
|
||||||
const sortedFiles = Array.from(errorsByFile.entries()).sort(
|
|
||||||
(a, b) => b[1] - a[1],
|
|
||||||
); // Sort by error count descending
|
|
||||||
|
|
||||||
let table = `\n${colors.gray}${colors.bold}Errors Files${colors.reset}\n`;
|
|
||||||
|
|
||||||
for (const [file, count] of sortedFiles) {
|
|
||||||
const paddedCount = String(count).padStart(6);
|
|
||||||
table += `${colors.red}${paddedCount}${colors.reset} ${colors.cyan}${file}${colors.reset}\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return table;
|
|
||||||
}
|
|
||||||
|
|
||||||
function runTypeCheck() {
|
|
||||||
const extraArgs = process.argv.slice(2);
|
|
||||||
|
|
||||||
// Prefer local TypeScript binary when available
|
|
||||||
const runnerArgs = ["-p", "tsconfig.json", "--noEmit", ...extraArgs];
|
|
||||||
let execArgs = null;
|
|
||||||
try {
|
|
||||||
const tscBin = require.resolve("typescript/bin/tsc");
|
|
||||||
execArgs = { cmd: process.execPath, args: [tscBin, ...runnerArgs] };
|
|
||||||
} catch {
|
|
||||||
// fallback to PATH tsc
|
|
||||||
execArgs = {
|
|
||||||
cmd: "tsc",
|
|
||||||
args: ["-p", "tsconfig.json", "--noEmit", ...extraArgs],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
log(
|
|
||||||
`🔍 ${colors.bold}Running TypeScript type check...${colors.reset} ${colors.gray}${extraArgs.join(" ")}${colors.reset}`.trim(),
|
|
||||||
colors.blue,
|
|
||||||
);
|
|
||||||
|
|
||||||
const MAX_BUFFER_SIZE = 64 * 1024 * 1024; // 64MB
|
|
||||||
|
|
||||||
execFileSync(execArgs.cmd, execArgs.args, {
|
|
||||||
encoding: "utf8",
|
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
|
||||||
maxBuffer: MAX_BUFFER_SIZE,
|
|
||||||
env: { ...process.env, FORCE_COLOR: "0" },
|
|
||||||
});
|
|
||||||
|
|
||||||
log(
|
|
||||||
`✅ ${colors.bold}TypeScript check passed${colors.reset} - no errors found!`,
|
|
||||||
colors.green,
|
|
||||||
);
|
|
||||||
return { ok: true };
|
|
||||||
} catch (error) {
|
|
||||||
const errorOutput = (error && (error.stderr || error.stdout)) || "";
|
|
||||||
|
|
||||||
// Filter out jellyseerr utils errors - this is a third-party git submodule
|
|
||||||
// that generates a large volume of known type errors
|
|
||||||
const filteredLines = errorOutput.split("\n").filter((line) => {
|
|
||||||
const trimmedLine = line.trim();
|
|
||||||
return trimmedLine && !trimmedLine.includes("utils/jellyseerr");
|
|
||||||
});
|
|
||||||
|
|
||||||
if (filteredLines.length > 0) {
|
|
||||||
// Count TypeScript error occurrences (TS####)
|
|
||||||
const remainingMatches = (
|
|
||||||
filteredLines.join("\n").match(/\berror\s+TS\d+:/gi) || []
|
|
||||||
).length;
|
|
||||||
|
|
||||||
// Parse errors and create formatted output with summary
|
|
||||||
const { formattedErrors, errorsByFile } = parseErrorsAndCreateSummary(
|
|
||||||
filteredLines.join("\n"),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Enhanced error header
|
|
||||||
log(
|
|
||||||
`\n${colors.bg.red} ERROR ${colors.reset} ${colors.red}${colors.bold}TypeScript errors found:${colors.reset}`,
|
|
||||||
);
|
|
||||||
console.log();
|
|
||||||
|
|
||||||
// Display errors with spacing between each error
|
|
||||||
for (let i = 0; i < formattedErrors.length; i++) {
|
|
||||||
console.log(formattedErrors[i]);
|
|
||||||
|
|
||||||
// Add spacing between errors (but not after the last one)
|
|
||||||
if (i < formattedErrors.length - 1) {
|
|
||||||
console.log(); // Empty line between errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create and display summary table
|
|
||||||
const summaryTable = createErrorSummaryTable(errorsByFile);
|
|
||||||
if (summaryTable) {
|
|
||||||
console.log(summaryTable);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean summary - just the error count
|
|
||||||
const errorIcon = "🚨";
|
|
||||||
log(
|
|
||||||
`${errorIcon} ${colors.red}${colors.bold}${remainingMatches} TypeScript error${remainingMatches !== 1 ? "s" : ""}${colors.reset}`,
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
|
|
||||||
return { ok: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
log(
|
|
||||||
`✅ ${colors.bold}TypeScript check passed${colors.reset} ${colors.gray}(jellyseerr utils errors ignored)${colors.reset}`,
|
|
||||||
colors.green,
|
|
||||||
);
|
|
||||||
return { ok: true };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enhanced header
|
|
||||||
console.log(`${colors.blue}${colors.bold}${border}${colors.reset}`);
|
|
||||||
console.log(`${colors.blue}${colors.bold}${centeredTitle}${colors.reset}`);
|
|
||||||
console.log(`${colors.blue}${colors.bold}${border}${colors.reset}`);
|
|
||||||
console.log();
|
|
||||||
|
|
||||||
// Main execution
|
|
||||||
const result = runTypeCheck();
|
|
||||||
|
|
||||||
console.log();
|
|
||||||
if (!result.ok) {
|
|
||||||
log(
|
|
||||||
`${colors.red}${colors.bold}🔥 Typecheck failed - please fix the errors above${colors.reset}`,
|
|
||||||
);
|
|
||||||
process.exitCode = 1;
|
|
||||||
} else {
|
|
||||||
log(
|
|
||||||
`${colors.green}${colors.bold}🎉 All checks passed! Ready to ship 🚀${colors.reset}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,500 +0,0 @@
|
|||||||
{
|
|
||||||
"login": {
|
|
||||||
"username_required": "اسم المستخدم مطلوب",
|
|
||||||
"error_title": "خطأ",
|
|
||||||
"login_title": "تسجيل الدخول",
|
|
||||||
"login_to_title": "تسجيل الدخول إلى",
|
|
||||||
"username_placeholder": "اسم المستخدم",
|
|
||||||
"password_placeholder": "كلمة المرور",
|
|
||||||
"login_button": "تسجيل الدخول",
|
|
||||||
"quick_connect": "اتصال سريع",
|
|
||||||
"enter_code_to_login": "أدخل الرمز {{code}} لتسجيل الدخول",
|
|
||||||
"failed_to_initiate_quick_connect": "فشل في بدء الاتصال السريع",
|
|
||||||
"got_it": "حسنًا",
|
|
||||||
"connection_failed": "فشل الاتصال",
|
|
||||||
"could_not_connect_to_server": "تعذر الاتصال بالخادم. يرجى التحقق من الرابط واتصال الشبكة.",
|
|
||||||
"an_unexpected_error_occured": "حدث خطأ غير متوقع",
|
|
||||||
"change_server": "تغيير الخادم",
|
|
||||||
"invalid_username_or_password": "اسم المستخدم أو كلمة المرور غير صالحة",
|
|
||||||
"user_does_not_have_permission_to_log_in": "ليس لدى المستخدم صلاحية تسجيل الدخول",
|
|
||||||
"server_is_taking_too_long_to_respond_try_again_later": "يستغرق الخادم وقتًا طويلاً للرد، يرجى المحاولة مرة أخرى لاحقًا",
|
|
||||||
"server_received_too_many_requests_try_again_later": "تلقى الخادم عددًا كبيرًا جدًا من الطلبات، يرجى المحاولة مرة أخرى لاحقًا.",
|
|
||||||
"there_is_a_server_error": "هناك خطأ في الخادم",
|
|
||||||
"an_unexpected_error_occured_did_you_enter_the_correct_url": "حدث خطأ غير متوقع. هل أدخلت رابط الخادم بشكل صحيح؟",
|
|
||||||
"too_old_server_text": "تم اكتشاف خادم jellyfin غير مدعوم",
|
|
||||||
"too_old_server_description": "يرجى تحديث jellyfin إلى أحدث إصدار"
|
|
||||||
},
|
|
||||||
"server": {
|
|
||||||
"enter_url_to_jellyfin_server": "أدخل رابط خادم Jellyfin الخاص بك",
|
|
||||||
"server_url_placeholder": "http(s)://your-server.com",
|
|
||||||
"connect_button": "اتصال",
|
|
||||||
"previous_servers": "الخوادم السابقة",
|
|
||||||
"clear_button": "مسح",
|
|
||||||
"search_for_local_servers": "البحث عن الخوادم المحلية",
|
|
||||||
"searching": "يبحث...",
|
|
||||||
"servers": "الخوادم"
|
|
||||||
},
|
|
||||||
"home": {
|
|
||||||
"no_internet": "لا يوجد اتصال بالإنترنت",
|
|
||||||
"no_items": "لا توجد عناصر",
|
|
||||||
"no_internet_message": "لا تقلق، لا يزال بإمكانك مشاهدة المحتوى الذي تم تنزيله.",
|
|
||||||
"go_to_downloads": "الذهاب إلى التنزيلات",
|
|
||||||
"oops": "عفوًا!",
|
|
||||||
"error_message": "حدث خطأ ما.\nيرجى تسجيل الخروج ثم الدخول مرة أخرى.",
|
|
||||||
"continue_watching": "متابعة المشاهدة",
|
|
||||||
"next_up": "التالي",
|
|
||||||
"recently_added_in": "أضيف مؤخراً في {{libraryName}}",
|
|
||||||
"suggested_movies": "أفلام مقترحة",
|
|
||||||
"suggested_episodes": "حلقات مقترحة",
|
|
||||||
"intro": {
|
|
||||||
"welcome_to_streamyfin": "مرحبًا بك في Streamyfin",
|
|
||||||
"a_free_and_open_source_client_for_jellyfin": "عميل مجاني ومفتوح المصدر لـ Jellyfin.",
|
|
||||||
"features_title": "الميزات",
|
|
||||||
"features_description": "يحتوي Streamyfin على مجموعة من الميزات ويتكامل مع مجموعة واسعة من البرامج التي يمكنك العثور عليها في قائمة الإعدادات، وتشمل:",
|
|
||||||
"jellyseerr_feature_description": "اتصل بمثيل Jellyseerr الخاص بك واطلب الأفلام مباشرة في التطبيق.",
|
|
||||||
"downloads_feature_title": "التنزيلات",
|
|
||||||
"downloads_feature_description": "قم بتنزيل الأفلام والمسلسلات التلفزيونية لمشاهدتها في وضع عدم الاتصال. استخدم إما الطريقة الافتراضية أو قم بتثبيت الخادم المحسن لتنزيل الملفات في الخلفية.",
|
|
||||||
"chromecast_feature_description": "قم ببث الأفلام والبرامج التلفزيونية على أجهزة Chromecast الخاصة بك.",
|
|
||||||
"centralised_settings_plugin_title": "إضافة الإعدادات المركزية",
|
|
||||||
"centralised_settings_plugin_description": "قم بتكوين الإعدادات من موقع مركزي على خادم Jellyfin الخاص بك. ستتم مزامنة جميع إعدادات العميل لجميع المستخدمين تلقائيًا.",
|
|
||||||
"done_button": "تم",
|
|
||||||
"go_to_settings_button": "الذهاب إلى الإعدادات",
|
|
||||||
"read_more": "اقرأ المزيد"
|
|
||||||
},
|
|
||||||
"settings": {
|
|
||||||
"settings_title": "الإعدادات",
|
|
||||||
"log_out_button": "تسجيل الخروج",
|
|
||||||
"user_info": {
|
|
||||||
"user_info_title": "معلومات المستخدم",
|
|
||||||
"user": "المستخدم",
|
|
||||||
"server": "الخادم",
|
|
||||||
"token": "الرمز",
|
|
||||||
"app_version": "إصدار التطبيق"
|
|
||||||
},
|
|
||||||
"quick_connect": {
|
|
||||||
"quick_connect_title": "اتصال سريع",
|
|
||||||
"authorize_button": "تفويض الاتصال السريع",
|
|
||||||
"enter_the_quick_connect_code": "أدخل رمز الاتصال السريع...",
|
|
||||||
"success": "نجاح",
|
|
||||||
"quick_connect_autorized": "تم تفويض الاتصال السريع",
|
|
||||||
"error": "خطأ",
|
|
||||||
"invalid_code": "رمز غير صالح",
|
|
||||||
"authorize": "تفويض"
|
|
||||||
},
|
|
||||||
"media_controls": {
|
|
||||||
"media_controls_title": "عناصر التحكم بالوسائط",
|
|
||||||
"forward_skip_length": "مدة التقديم السريع",
|
|
||||||
"rewind_length": "مدة الترجيع",
|
|
||||||
"seconds_unit": "ث"
|
|
||||||
},
|
|
||||||
"gesture_controls": {
|
|
||||||
"gesture_controls_title": "التحكم بالإيماءات",
|
|
||||||
"horizontal_swipe_skip": "السحب الأفقي للتخطي",
|
|
||||||
"horizontal_swipe_skip_description": "اسحب لليسار/لليمين عندما تكون عناصر التحكم مخفية للتخطي",
|
|
||||||
"left_side_brightness": "التحكم في السطوع من الجانب الأيسر",
|
|
||||||
"left_side_brightness_description": "اسحب لأعلى/لأسفل على الجانب الأيسر لضبط السطوع",
|
|
||||||
"right_side_volume": "التحكم في مستوى الصوت من الجانب الأيمن",
|
|
||||||
"right_side_volume_description": "اسحب لأعلى/لأسفل على الجانب الأيمن لضبط مستوى الصوت"
|
|
||||||
},
|
|
||||||
"audio": {
|
|
||||||
"audio_title": "الصوت",
|
|
||||||
"set_audio_track": "تعيين مسار الصوت من العنصر السابق",
|
|
||||||
"audio_language": "لغة الصوت",
|
|
||||||
"audio_hint": "اختر لغة صوت افتراضية.",
|
|
||||||
"none": "لا شيء",
|
|
||||||
"language": "اللغة"
|
|
||||||
},
|
|
||||||
"subtitles": {
|
|
||||||
"subtitle_title": "الترجمة",
|
|
||||||
"subtitle_language": "لغة الترجمة",
|
|
||||||
"subtitle_mode": "وضع الترجمة",
|
|
||||||
"set_subtitle_track": "تعيين مسار الترجمة من العنصر السابق",
|
|
||||||
"subtitle_size": "حجم الترجمة",
|
|
||||||
"subtitle_hint": "تكوين تفضيلات الترجمة.",
|
|
||||||
"none": "لا شيء",
|
|
||||||
"language": "اللغة",
|
|
||||||
"loading": "جار التحميل",
|
|
||||||
"modes": {
|
|
||||||
"Default": "افتراضي",
|
|
||||||
"Smart": "ذكي",
|
|
||||||
"Always": "دائماً",
|
|
||||||
"None": "لا شيء",
|
|
||||||
"OnlyForced": "فقط الإجبارية"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"other": {
|
|
||||||
"other_title": "أخرى",
|
|
||||||
"follow_device_orientation": "تدوير تلقائي",
|
|
||||||
"video_orientation": "اتجاه الفيديو",
|
|
||||||
"orientation": "الاتجاه",
|
|
||||||
"orientations": {
|
|
||||||
"DEFAULT": "افتراضي",
|
|
||||||
"ALL": "الكل",
|
|
||||||
"PORTRAIT": "عمودي",
|
|
||||||
"PORTRAIT_UP": "عمودي لأعلى",
|
|
||||||
"PORTRAIT_DOWN": "عمودي لأسفل",
|
|
||||||
"LANDSCAPE": "أفقي",
|
|
||||||
"LANDSCAPE_LEFT": "أفقي لليسار",
|
|
||||||
"LANDSCAPE_RIGHT": "أفقي لليمين",
|
|
||||||
"OTHER": "أخرى",
|
|
||||||
"UNKNOWN": "غير معروف"
|
|
||||||
},
|
|
||||||
"safe_area_in_controls": "مساحة آمنة في عناصر التحكم",
|
|
||||||
"video_player": "مشغل الفيديو",
|
|
||||||
"video_players": {
|
|
||||||
"VLC_3": "VLC 3",
|
|
||||||
"VLC_4": "VLC 4 (تجريبي + صورة داخل صورة)"
|
|
||||||
},
|
|
||||||
"show_custom_menu_links": "إظهار روابط القائمة المخصصة",
|
|
||||||
"hide_libraries": "إخفاء المكتبات",
|
|
||||||
"select_liraries_you_want_to_hide": "حدد المكتبات التي تريد إخفاءها من علامة تبويب المكتبة وأقسام الصفحة الرئيسية.",
|
|
||||||
"disable_haptic_feedback": "تعطيل ردود الفعل اللمسية",
|
|
||||||
"default_quality": "الجودة الافتراضية",
|
|
||||||
"max_auto_play_episode_count": "الحد الأقصى لعدد الحلقات التي يتم تشغيلها تلقائيًا",
|
|
||||||
"disabled": "معطل"
|
|
||||||
},
|
|
||||||
"downloads": {
|
|
||||||
"downloads_title": "التنزيلات",
|
|
||||||
"download_method": "طريقة التنزيل",
|
|
||||||
"remux_max_download": "الحد الأقصى لتنزيل الريمكس",
|
|
||||||
"auto_download": "تنزيل تلقائي",
|
|
||||||
"optimized_versions_server": "خادم الإصدارات المحسّنة",
|
|
||||||
"save_button": "حفظ",
|
|
||||||
"optimized_server": "الخادم المحسن",
|
|
||||||
"optimized": "محسن",
|
|
||||||
"default": "افتراضي",
|
|
||||||
"optimized_version_hint": "أدخل رابط الخادم المحسن. يجب أن يتضمن الرابط http أو https ويمكن أن يتضمن المنفذ اختياريًا.",
|
|
||||||
"read_more_about_optimized_server": "اقرأ المزيد عن الخادم المحسن.",
|
|
||||||
"url": "الرابط",
|
|
||||||
"server_url_placeholder": "http(s)://domain.org:port"
|
|
||||||
},
|
|
||||||
"plugins": {
|
|
||||||
"plugins_title": "الإضافات",
|
|
||||||
"jellyseerr": {
|
|
||||||
"jellyseerr_warning": "هذا التكامل في مراحله الأولى. توقع أن تتغير الأمور.",
|
|
||||||
"server_url": "رابط الخادم",
|
|
||||||
"server_url_hint": "مثال: http(s)://your-host.url\n(أضف المنفذ إذا لزم الأمر)",
|
|
||||||
"server_url_placeholder": "رابط Jellyseerr...",
|
|
||||||
"password": "كلمة المرور",
|
|
||||||
"password_placeholder": "أدخل كلمة المرور لمستخدم Jellyfin {{username}}",
|
|
||||||
"save_button": "حفظ",
|
|
||||||
"clear_button": "مسح",
|
|
||||||
"login_button": "تسجيل الدخول",
|
|
||||||
"total_media_requests": "إجمالي طلبات الوسائط",
|
|
||||||
"movie_quota_limit": "حد حصة الأفلام",
|
|
||||||
"movie_quota_days": "أيام حصة الأفلام",
|
|
||||||
"tv_quota_limit": "حد حصة المسلسلات",
|
|
||||||
"tv_quota_days": "أيام حصة المسلسلات",
|
|
||||||
"reset_jellyseerr_config_button": "إعادة تعيين تكوين Jellyseerr",
|
|
||||||
"unlimited": "غير محدود",
|
|
||||||
"plus_n_more": "+{{n}} المزيد",
|
|
||||||
"order_by": {
|
|
||||||
"DEFAULT": "افتراضي",
|
|
||||||
"VOTE_COUNT_AND_AVERAGE": "عدد الأصوات والمعدل",
|
|
||||||
"POPULARITY": "الشعبية"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"marlin_search": {
|
|
||||||
"enable_marlin_search": "تمكين بحث مارلن",
|
|
||||||
"url": "الرابط",
|
|
||||||
"server_url_placeholder": "http(s)://domain.org:port",
|
|
||||||
"marlin_search_hint": "أدخل رابط خادم مارلن. يجب أن يتضمن الرابط http أو https ويمكن أن يتضمن المنفذ اختياريًا.",
|
|
||||||
"read_more_about_marlin": "اقرأ المزيد عن مارلن.",
|
|
||||||
"save_button": "حفظ",
|
|
||||||
"toasts": {
|
|
||||||
"saved": "تم الحفظ"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"storage": {
|
|
||||||
"storage_title": "التخزين",
|
|
||||||
"app_usage": "التطبيق {{usedSpace}}%",
|
|
||||||
"device_usage": "الجهاز {{availableSpace}}%",
|
|
||||||
"size_used": "تم استخدام {{used}} من {{total}}",
|
|
||||||
"delete_all_downloaded_files": "حذف جميع الملفات التي تم تنزيلها"
|
|
||||||
},
|
|
||||||
"intro": {
|
|
||||||
"show_intro": "إظهار المقدمة",
|
|
||||||
"reset_intro": "إعادة تعيين المقدمة"
|
|
||||||
},
|
|
||||||
"logs": {
|
|
||||||
"logs_title": "السجلات",
|
|
||||||
"export_logs": "تصدير السجلات",
|
|
||||||
"click_for_more_info": "انقر لمزيد من المعلومات",
|
|
||||||
"level": "المستوى",
|
|
||||||
"no_logs_available": "لا توجد سجلات متاحة",
|
|
||||||
"delete_all_logs": "حذف جميع السجلات"
|
|
||||||
},
|
|
||||||
"languages": {
|
|
||||||
"title": "اللغات",
|
|
||||||
"app_language": "لغة التطبيق",
|
|
||||||
"app_language_description": "حدد لغة التطبيق.",
|
|
||||||
"system": "النظام"
|
|
||||||
},
|
|
||||||
"toasts": {
|
|
||||||
"error_deleting_files": "خطأ في حذف الملفات",
|
|
||||||
"background_downloads_enabled": "تمكين التنزيلات في الخلفية",
|
|
||||||
"background_downloads_disabled": "تعطيل التنزيلات في الخلفية",
|
|
||||||
"connected": "متصل",
|
|
||||||
"could_not_connect": "تعذر الاتصال",
|
|
||||||
"invalid_url": "رابط غير صالح"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"sessions": {
|
|
||||||
"title": "الجلسات",
|
|
||||||
"no_active_sessions": "لا توجد جلسات نشطة"
|
|
||||||
},
|
|
||||||
"downloads": {
|
|
||||||
"downloads_title": "التنزيلات",
|
|
||||||
"tvseries": "مسلسلات",
|
|
||||||
"movies": "أفلام",
|
|
||||||
"queue": "قائمة الانتظار",
|
|
||||||
"queue_hint": "ستفقد قائمة الانتظار والتنزيلات عند إعادة تشغيل التطبيق",
|
|
||||||
"no_items_in_queue": "لا توجد عناصر في قائمة الانتظار",
|
|
||||||
"no_downloaded_items": "لا توجد عناصر تم تنزيلها",
|
|
||||||
"delete_all_movies_button": "حذف جميع الأفلام",
|
|
||||||
"delete_all_tvseries_button": "حذف جميع المسلسلات",
|
|
||||||
"delete_all_button": "حذف الكل",
|
|
||||||
"active_download": "تنزيل نشط",
|
|
||||||
"no_active_downloads": "لا توجد تنزيلات نشطة",
|
|
||||||
"active_downloads": "تنزيلات نشطة",
|
|
||||||
"new_app_version_requires_re_download": "يتطلب إصدار التطبيق الجديد إعادة التنزيل",
|
|
||||||
"new_app_version_requires_re_download_description": "يتطلب التحديث الجديد تنزيل المحتوى مرة أخرى. يرجى إزالة كل المحتوى الذي تم تنزيله والمحاولة مرة أخرى.",
|
|
||||||
"back": "رجوع",
|
|
||||||
"delete": "حذف",
|
|
||||||
"something_went_wrong": "حدث خطأ ما",
|
|
||||||
"could_not_get_stream_url_from_jellyfin": "تعذر الحصول على رابط البث من Jellyfin",
|
|
||||||
"eta": "الوقت المتبقي {{eta}}",
|
|
||||||
"methods": "الطرق",
|
|
||||||
"toasts": {
|
|
||||||
"you_are_not_allowed_to_download_files": "غير مسموح لك بتنزيل الملفات.",
|
|
||||||
"deleted_all_movies_successfully": "تم حذف جميع الأفلام بنجاح!",
|
|
||||||
"failed_to_delete_all_movies": "فشل حذف جميع الأفلام",
|
|
||||||
"deleted_all_tvseries_successfully": "تم حذف جميع المسلسلات بنجاح!",
|
|
||||||
"failed_to_delete_all_tvseries": "فشل حذف جميع المسلسلات",
|
|
||||||
"download_deleted": "تم حذف التنزيل",
|
|
||||||
"could_not_delete_download": "تعذر حذف التنزيل",
|
|
||||||
"download_paused": "تم إيقاف التنزيل مؤقتًا",
|
|
||||||
"could_not_pause_download": "تعذر إيقاف التنزيل مؤقتًا",
|
|
||||||
"download_resumed": "تم استئناف التنزيل",
|
|
||||||
"could_not_resume_download": "تعذر استئناف التنزيل",
|
|
||||||
"download_completed": "اكتمل التنزيل",
|
|
||||||
"download_started_for": "بدأ تنزيل {{item}}",
|
|
||||||
"item_is_ready_to_be_downloaded": "{{item}} جاهز للتنزيل",
|
|
||||||
"download_stated_for_item": "بدأ تنزيل {{item}}",
|
|
||||||
"download_failed_for_item": "فشل تنزيل {{item}} - {{error}}",
|
|
||||||
"download_completed_for_item": "اكتمل تنزيل {{item}}",
|
|
||||||
"queued_item_for_optimization": "تمت إضافة {{item}} إلى قائمة الانتظار للتحسين",
|
|
||||||
"failed_to_start_download_for_item": "فشل بدء تنزيل {{item}}: {{message}}",
|
|
||||||
"server_responded_with_status_code": "استجاب الخادم بالحالة {{statusCode}}",
|
|
||||||
"no_response_received_from_server": "لم يتم تلقي أي رد من الخادم",
|
|
||||||
"error_setting_up_the_request": "خطأ في إعداد الطلب",
|
|
||||||
"failed_to_start_download_for_item_unexpected_error": "فشل بدء تنزيل {{item}}: خطأ غير متوقع",
|
|
||||||
"all_files_folders_and_jobs_deleted_successfully": "تم حذف جميع الملفات والمجلدات والمهام بنجاح",
|
|
||||||
"an_error_occured_while_deleting_files_and_jobs": "حدث خطأ أثناء حذف الملفات والمهام",
|
|
||||||
"go_to_downloads": "الذهاب إلى التنزيلات"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"search": {
|
|
||||||
"search_here": "ابحث هنا...",
|
|
||||||
"search": "بحث...",
|
|
||||||
"x_items": "{{count}} عناصر",
|
|
||||||
"library": "المكتبة",
|
|
||||||
"discover": "اكتشف",
|
|
||||||
"no_results": "لا توجد نتائج",
|
|
||||||
"no_results_found_for": "لم يتم العثور على نتائج لـ",
|
|
||||||
"movies": "أفلام",
|
|
||||||
"series": "مسلسلات",
|
|
||||||
"episodes": "حلقات",
|
|
||||||
"collections": "مجموعات",
|
|
||||||
"actors": "ممثلون",
|
|
||||||
"request_movies": "طلب أفلام",
|
|
||||||
"request_series": "طلب مسلسلات",
|
|
||||||
"recently_added": "أضيف مؤخراً",
|
|
||||||
"recent_requests": "الطلبات الأخيرة",
|
|
||||||
"plex_watchlist": "قائمة مشاهدة Plex",
|
|
||||||
"trending": "شائع",
|
|
||||||
"popular_movies": "أفلام شائعة",
|
|
||||||
"movie_genres": "أنواع الأفلام",
|
|
||||||
"upcoming_movies": "أفلام قادمة",
|
|
||||||
"studios": "استوديوهات",
|
|
||||||
"popular_tv": "مسلسلات شائعة",
|
|
||||||
"tv_genres": "أنواع المسلسلات",
|
|
||||||
"upcoming_tv": "مسلسلات قادمة",
|
|
||||||
"networks": "شبكات",
|
|
||||||
"tmdb_movie_keyword": "كلمة مفتاحية لفيلم TMDB",
|
|
||||||
"tmdb_movie_genre": "نوع فيلم TMDB",
|
|
||||||
"tmdb_tv_keyword": "كلمة مفتاحية لمسلسل TMDB",
|
|
||||||
"tmdb_tv_genre": "نوع مسلسل TMDB",
|
|
||||||
"tmdb_search": "بحث TMDB",
|
|
||||||
"tmdb_studio": "استوديو TMDB",
|
|
||||||
"tmdb_network": "شبكة TMDB",
|
|
||||||
"tmdb_movie_streaming_services": "خدمات بث الأفلام TMDB",
|
|
||||||
"tmdb_tv_streaming_services": "خدمات بث المسلسلات TMDB"
|
|
||||||
},
|
|
||||||
"library": {
|
|
||||||
"no_items_found": "لم يتم العثور على عناصر",
|
|
||||||
"no_results": "لا توجد نتائج",
|
|
||||||
"no_libraries_found": "لم يتم العثور على مكتبات",
|
|
||||||
"item_types": {
|
|
||||||
"movies": "أفلام",
|
|
||||||
"series": "مسلسلات",
|
|
||||||
"boxsets": "مجموعات",
|
|
||||||
"items": "عناصر"
|
|
||||||
},
|
|
||||||
"options": {
|
|
||||||
"display": "عرض",
|
|
||||||
"row": "صف",
|
|
||||||
"list": "قائمة",
|
|
||||||
"image_style": "نمط الصورة",
|
|
||||||
"poster": "ملصق",
|
|
||||||
"cover": "غلاف",
|
|
||||||
"show_titles": "إظهار العناوين",
|
|
||||||
"show_stats": "إظهار الإحصائيات"
|
|
||||||
},
|
|
||||||
"filters": {
|
|
||||||
"genres": "الأنواع",
|
|
||||||
"years": "السنوات",
|
|
||||||
"sort_by": "ترتيب حسب",
|
|
||||||
"sort_order": "ترتيب",
|
|
||||||
"asc": "تصاعدي",
|
|
||||||
"desc": "تنازلي",
|
|
||||||
"tags": "الوسوم"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"favorites": {
|
|
||||||
"series": "مسلسلات",
|
|
||||||
"movies": "أفلام",
|
|
||||||
"episodes": "حلقات",
|
|
||||||
"videos": "فيديوهات",
|
|
||||||
"boxsets": "مجموعات",
|
|
||||||
"playlists": "قوائم التشغيل",
|
|
||||||
"noDataTitle": "لا توجد مفضلات بعد",
|
|
||||||
"noData": "ضع علامة على العناصر كمفضلة لتظهر هنا للوصول السريع."
|
|
||||||
},
|
|
||||||
"custom_links": {
|
|
||||||
"no_links": "لا توجد روابط"
|
|
||||||
},
|
|
||||||
"player": {
|
|
||||||
"error": "خطأ",
|
|
||||||
"failed_to_get_stream_url": "فشل في الحصول على رابط البث",
|
|
||||||
"an_error_occured_while_playing_the_video": "حدث خطأ أثناء تشغيل الفيديو. تحقق من السجلات في الإعدادات.",
|
|
||||||
"client_error": "خطأ في العميل",
|
|
||||||
"could_not_create_stream_for_chromecast": "تعذر إنشاء بث لـ Chromecast",
|
|
||||||
"message_from_server": "رسالة من الخادم: {{message}}",
|
|
||||||
"video_has_finished_playing": "انتهى تشغيل الفيديو!",
|
|
||||||
"no_video_source": "لا يوجد مصدر فيديو...",
|
|
||||||
"next_episode": "الحلقة التالية",
|
|
||||||
"refresh_tracks": "تحديث المسارات",
|
|
||||||
"subtitle_tracks": "مسارات الترجمة:",
|
|
||||||
"audio_tracks": "مسارات الصوت:",
|
|
||||||
"playback_state": "حالة التشغيل:",
|
|
||||||
"no_data_available": "لا توجد بيانات متاحة",
|
|
||||||
"index": "الفهرس:",
|
|
||||||
"continue_watching": "متابعة المشاهدة",
|
|
||||||
"go_back": "رجوع"
|
|
||||||
},
|
|
||||||
"item_card": {
|
|
||||||
"next_up": "التالي",
|
|
||||||
"no_items_to_display": "لا توجد عناصر لعرضها",
|
|
||||||
"cast_and_crew": "طاقم العمل",
|
|
||||||
"series": "مسلسلات",
|
|
||||||
"seasons": "مواسم",
|
|
||||||
"season": "موسم",
|
|
||||||
"no_episodes_for_this_season": "لا توجد حلقات لهذا الموسم",
|
|
||||||
"overview": "نظرة عامة",
|
|
||||||
"more_with": "المزيد مع {{name}}",
|
|
||||||
"similar_items": "عناصر مشابهة",
|
|
||||||
"no_similar_items_found": "لم يتم العثور على عناصر مشابهة",
|
|
||||||
"video": "فيديو",
|
|
||||||
"more_details": "المزيد من التفاصيل",
|
|
||||||
"quality": "الجودة",
|
|
||||||
"audio": "الصوت",
|
|
||||||
"subtitles": "الترجمة",
|
|
||||||
"show_more": "عرض المزيد",
|
|
||||||
"show_less": "عرض أقل",
|
|
||||||
"appeared_in": "ظهر في",
|
|
||||||
"could_not_load_item": "تعذر تحميل العنصر",
|
|
||||||
"none": "لا شيء",
|
|
||||||
"download": {
|
|
||||||
"download_season": "تنزيل الموسم",
|
|
||||||
"download_series": "تنزيل المسلسل",
|
|
||||||
"download_episode": "تنزيل الحلقة",
|
|
||||||
"download_movie": "تنزيل الفيلم",
|
|
||||||
"download_x_item": "تنزيل {{item_count}} عناصر",
|
|
||||||
"download_unwatched_only": "فقط غير المشاهدة",
|
|
||||||
"download_button": "تنزيل",
|
|
||||||
"using_optimized_server": "استخدام الخادم المحسن",
|
|
||||||
"using_default_method": "استخدام الطريقة الافتراضية"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"live_tv": {
|
|
||||||
"next": "التالي",
|
|
||||||
"previous": "السابق",
|
|
||||||
"live_tv": "بث مباشر",
|
|
||||||
"coming_soon": "قريباً",
|
|
||||||
"on_now": "يعرض الآن",
|
|
||||||
"shows": "برامج",
|
|
||||||
"movies": "أفلام",
|
|
||||||
"sports": "رياضة",
|
|
||||||
"for_kids": "للأطفال",
|
|
||||||
"news": "أخبار"
|
|
||||||
},
|
|
||||||
"jellyseerr": {
|
|
||||||
"confirm": "تأكيد",
|
|
||||||
"cancel": "إلغاء",
|
|
||||||
"yes": "نعم",
|
|
||||||
"whats_wrong": "ما المشكلة؟",
|
|
||||||
"issue_type": "نوع المشكلة",
|
|
||||||
"select_an_issue": "حدد مشكلة",
|
|
||||||
"types": "الأنواع",
|
|
||||||
"describe_the_issue": "(اختياري) صف المشكلة...",
|
|
||||||
"submit_button": "إرسال",
|
|
||||||
"report_issue_button": "الإبلاغ عن مشكلة",
|
|
||||||
"request_button": "طلب",
|
|
||||||
"are_you_sure_you_want_to_request_all_seasons": "هل أنت متأكد أنك تريد طلب جميع المواسم؟",
|
|
||||||
"failed_to_login": "فشل تسجيل الدخول",
|
|
||||||
"cast": "طاقم العمل",
|
|
||||||
"details": "التفاصيل",
|
|
||||||
"status": "الحالة",
|
|
||||||
"original_title": "العنوان الأصلي",
|
|
||||||
"series_type": "نوع المسلسل",
|
|
||||||
"release_dates": "تواريخ الإصدار",
|
|
||||||
"first_air_date": "تاريخ أول عرض",
|
|
||||||
"next_air_date": "تاريخ العرض التالي",
|
|
||||||
"revenue": "الإيرادات",
|
|
||||||
"budget": "الميزانية",
|
|
||||||
"original_language": "اللغة الأصلية",
|
|
||||||
"production_country": "بلد الإنتاج",
|
|
||||||
"studios": "استوديوهات",
|
|
||||||
"network": "شبكة",
|
|
||||||
"currently_streaming_on": "يتم بثه حاليًا على",
|
|
||||||
"advanced": "متقدم",
|
|
||||||
"request_as": "طلب باسم",
|
|
||||||
"tags": "الوسوم",
|
|
||||||
"quality_profile": "ملف تعريف الجودة",
|
|
||||||
"root_folder": "المجلد الجذر",
|
|
||||||
"season_all": "الموسم (الكل)",
|
|
||||||
"season_number": "الموسم {{season_number}}",
|
|
||||||
"number_episodes": "{{episode_number}} حلقات",
|
|
||||||
"born": "مواليد",
|
|
||||||
"appearances": "المشاركات",
|
|
||||||
"toasts": {
|
|
||||||
"jellyseer_does_not_meet_requirements": "خادم Jellyseerr لا يفي بالحد الأدنى من متطلبات الإصدار! يرجى التحديث إلى 2.0.0 على الأقل",
|
|
||||||
"jellyseerr_test_failed": "فشل اختبار Jellyseerr. يرجى المحاولة مرة أخرى.",
|
|
||||||
"failed_to_test_jellyseerr_server_url": "فشل اختبار رابط خادم jellyseerr",
|
|
||||||
"issue_submitted": "تم إرسال المشكلة!",
|
|
||||||
"requested_item": "تم طلب {{item}}!",
|
|
||||||
"you_dont_have_permission_to_request": "ليس لديك إذن للطلب!",
|
|
||||||
"something_went_wrong_requesting_media": "حدث خطأ ما أثناء طلب الوسائط!"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"tabs": {
|
|
||||||
"home": "الرئيسية",
|
|
||||||
"search": "بحث",
|
|
||||||
"library": "المكتبة",
|
|
||||||
"custom_links": "روابط مخصصة",
|
|
||||||
"favorites": "المفضلة"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user