mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-16 08:08:18 +00:00
Compare commits
119 Commits
view-passw
...
build-perf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d02007c213 | ||
|
|
3e20050b64 | ||
|
|
a5552db377 | ||
|
|
59e9913c78 | ||
|
|
cf203a7c28 | ||
|
|
2b2797005a | ||
|
|
c53acb16fc | ||
|
|
d7958296a5 | ||
|
|
53570a5ee5 | ||
|
|
e3b7dd8241 | ||
|
|
786d082706 | ||
|
|
164de0af0d | ||
|
|
820b30b7e2 | ||
|
|
5bc4c4a856 | ||
|
|
f7e0667416 | ||
|
|
8c68283c56 | ||
|
|
bb0149406c | ||
|
|
b1d5630025 | ||
|
|
a5f5531bb9 | ||
|
|
c2a3817fa8 | ||
|
|
700bb2dc79 | ||
|
|
d741ca3ecc | ||
|
|
ae9f6b1ce4 | ||
|
|
3f3f95571c | ||
|
|
cd3f1a8cee | ||
|
|
be745dc136 | ||
|
|
7b6fe0a6c0 | ||
|
|
fc44283f09 | ||
|
|
b2f6edc54e | ||
|
|
b42d033b87 | ||
|
|
1fb166bcd1 | ||
|
|
de6133581b | ||
|
|
08c7382191 | ||
|
|
d7b4e01aa5 | ||
|
|
2f2099e243 | ||
|
|
79b4a0869a | ||
|
|
dececc477f | ||
|
|
8d7416ae1c | ||
|
|
e116d861f6 | ||
|
|
4a28352b53 | ||
|
|
9e26196fb3 | ||
|
|
b372c353c0 | ||
|
|
e6f69e0c7b | ||
|
|
48cb0b7013 | ||
|
|
1eba074ebd | ||
|
|
2d46907351 | ||
|
|
e877d038ba | ||
|
|
c6ad06b084 | ||
|
|
71a3c5e92b | ||
|
|
0a41962ddf | ||
|
|
312a59c5b0 | ||
|
|
fb0a70690e | ||
|
|
788f420ce5 | ||
|
|
2b761f15c8 | ||
|
|
44e489f40c | ||
|
|
e985adf062 | ||
|
|
5b0d2f3f7b | ||
|
|
4f6863f317 | ||
|
|
0d1aeaf8aa | ||
|
|
1ff09a2d34 | ||
|
|
92e40c7aa0 | ||
|
|
7fe3ca8484 | ||
|
|
f104e952ab | ||
|
|
0ec44add7d | ||
|
|
2273b7be0a | ||
|
|
1733db6c28 | ||
|
|
cc2e634137 | ||
|
|
af6b18546e | ||
|
|
26c99cfc3d | ||
|
|
1b3a5443ef | ||
|
|
8ca330d765 | ||
|
|
e8bf2b721e | ||
|
|
84d7ad72a6 | ||
|
|
edc9c8640d | ||
|
|
569b143eba | ||
|
|
49ece8d34e | ||
|
|
ead37aa806 | ||
|
|
d250295e36 | ||
|
|
28f6729ae2 | ||
|
|
2a195d9ba5 | ||
|
|
577774c355 | ||
|
|
98d571187e | ||
|
|
74c83c4f00 | ||
|
|
97d9309855 | ||
|
|
d1e55ca506 | ||
|
|
adec78832a | ||
|
|
19f604e986 | ||
|
|
e03cefce47 | ||
|
|
4398810b6c | ||
|
|
91c4df1585 | ||
|
|
0a8068e1b3 | ||
|
|
4b7986a125 | ||
|
|
3eaeaa3b4a | ||
|
|
9cd9861253 | ||
|
|
5e9755ea3c | ||
|
|
388342147e | ||
|
|
9b367fd8c2 | ||
|
|
2d4d3f5b1b | ||
|
|
388f65b443 | ||
|
|
621d164402 | ||
|
|
77895983b0 | ||
|
|
fa8af5bc11 | ||
|
|
1feb22854c | ||
|
|
09b3cc7835 | ||
|
|
9175c6a135 | ||
|
|
7436ad90eb | ||
|
|
fb2bdb0e2c | ||
|
|
0fd2d766a3 | ||
|
|
bdc0962d60 | ||
|
|
2a49b766e7 | ||
|
|
b02f49fbd2 | ||
|
|
0b0592a699 | ||
|
|
ceafdbf9ee | ||
|
|
2b77d0fefb | ||
|
|
32094fbc9f | ||
|
|
b5917821a0 | ||
|
|
42922cc92b | ||
|
|
0298fb00aa | ||
|
|
e47c863aa4 |
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"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": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
---
|
|
||||||
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 +1,15 @@
|
|||||||
EXPO_PUBLIC_WRITE_DEBUG=1
|
# Streamyfin-specific debug flag
|
||||||
|
EXPO_PUBLIC_WRITE_DEBUG=1
|
||||||
|
|
||||||
|
# Performance optimization (official Metro flag)
|
||||||
|
EXPO_USE_METRO_REQUIRE=1
|
||||||
|
|
||||||
|
# TV development support (used in metro.config.js)
|
||||||
|
# EXPO_TV=1
|
||||||
|
# Uncomment the above line ONLY when working on TV features. Leave commented for mobile-only development to avoid issues.
|
||||||
|
|
||||||
|
# Fast resolver optimization (2025 feature)
|
||||||
|
EXPO_USE_FAST_RESOLVER=1
|
||||||
|
|
||||||
|
# Bundle analysis for monitoring
|
||||||
|
EXPO_ATLAS=1
|
||||||
@@ -1 +1,26 @@
|
|||||||
EXPO_PUBLIC_WRITE_DEBUG=0
|
# Streamyfin Production Configuration
|
||||||
|
EXPO_PUBLIC_WRITE_DEBUG=0
|
||||||
|
|
||||||
|
# Production Performance Optimizations
|
||||||
|
NODE_ENV=production
|
||||||
|
EXPO_USE_METRO_REQUIRE=1
|
||||||
|
EXPO_USE_FAST_RESOLVER=1
|
||||||
|
|
||||||
|
# Production Build Optimizations
|
||||||
|
EXPO_OPTIMIZE_BUNDLE_SIZE=1
|
||||||
|
EXPO_NO_CLIENT_ENV_VARS=1
|
||||||
|
EXPO_LEGACY_BUNDLER=0
|
||||||
|
|
||||||
|
# Bundle Analysis (for monitoring)
|
||||||
|
EXPO_ATLAS=0
|
||||||
|
|
||||||
|
# Production Cache Optimizations
|
||||||
|
METRO_CACHE=1
|
||||||
|
|
||||||
|
# Security & Performance
|
||||||
|
EXPO_NO_DOTENV=1
|
||||||
|
FAST_REFRESH=0
|
||||||
|
|
||||||
|
# Production Bundle Features
|
||||||
|
EXPO_USE_HERMES=1
|
||||||
|
EXPO_MINIFY=1
|
||||||
96
.github/copilot-instructions.md
vendored
Normal file
96
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
# 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
Normal file
12
.github/crowdin.yml
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
478
.github/workflows/artifact-comment.yml
vendored
Normal file
478
.github/workflows/artifact-comment.yml
vendored
Normal file
@@ -0,0 +1,478 @@
|
|||||||
|
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
93
.github/workflows/build-android.yml
vendored
@@ -1,93 +0,0 @@
|
|||||||
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
Normal file
280
.github/workflows/build-apps.yml
vendored
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
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
95
.github/workflows/build-ios.yml
vendored
@@ -1,95 +0,0 @@
|
|||||||
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@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||||
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@2d92b76c45b91eb80fc44c74ce3fce0ee94e8f9d # v3.30.0
|
uses: github/codeql-action/init@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7
|
||||||
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@2d92b76c45b91eb80fc44c74ce3fce0ee94e8f9d # v3.30.0
|
uses: github/codeql-action/autobuild@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7
|
||||||
|
|
||||||
- name: 🧪 Perform CodeQL Analysis
|
- name: 🧪 Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@2d92b76c45b91eb80fc44c74ce3fce0ee94e8f9d # v3.30.0
|
uses: github/codeql-action/analyze@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7
|
||||||
|
|||||||
50
.github/workflows/crowdin.yml
vendored
Normal file
50
.github/workflows/crowdin.yml
vendored
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
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@595b5aeba73380359d98a5e087f648dbb0edce1b # v4.7.3
|
uses: actions/dependency-review-action@56339e523c0409420f6c2c9a2f4292bbb3c07dd3 # v4.8.0
|
||||||
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,6 +65,7 @@ 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
|
||||||
@@ -106,7 +107,7 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: "🟢 Setup Node.js"
|
- name: "🟢 Setup Node.js"
|
||||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||||
with:
|
with:
|
||||||
node-version: '24.x'
|
node-version: '24.x'
|
||||||
|
|
||||||
|
|||||||
25
.github/workflows/notification.yml
vendored
25
.github/workflows/notification.yml
vendored
@@ -1,13 +1,18 @@
|
|||||||
name: 🛎️ Discord Pull Request Notification
|
name: 🛎️ Discord 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
|
||||||
@@ -21,3 +26,21 @@ 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@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0
|
uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.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@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||||
with:
|
with:
|
||||||
node-version: '24.x'
|
node-version: '24.x'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|||||||
76
.gitignore
vendored
76
.gitignore
vendored
@@ -1,27 +1,16 @@
|
|||||||
|
# Dependencies and Package Managers
|
||||||
node_modules/
|
node_modules/
|
||||||
.expo/
|
bun.lock
|
||||||
dist/
|
bun.lockb
|
||||||
npm-debug.*
|
|
||||||
*.jks
|
|
||||||
*.p8
|
|
||||||
*.p12
|
|
||||||
*.key
|
|
||||||
*.mobileprovision
|
|
||||||
*.orig.*
|
|
||||||
web-build/
|
|
||||||
modules/vlc-player/android/build
|
|
||||||
|
|
||||||
# macOS
|
|
||||||
.DS_Store
|
|
||||||
|
|
||||||
expo-env.d.ts
|
|
||||||
|
|
||||||
Streamyfin.app
|
|
||||||
|
|
||||||
*.mp4
|
|
||||||
Streamyfin.app
|
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
|
||||||
|
# Expo and React Native Build Artifacts
|
||||||
|
.expo/
|
||||||
|
dist/
|
||||||
|
web-build/
|
||||||
|
.tsbuildinfo
|
||||||
|
|
||||||
|
# Platform-specific Build Directories
|
||||||
/ios
|
/ios
|
||||||
/android
|
/android
|
||||||
/iostv
|
/iostv
|
||||||
@@ -29,21 +18,50 @@ package-lock.json
|
|||||||
/androidmobile
|
/androidmobile
|
||||||
/androidtv
|
/androidtv
|
||||||
|
|
||||||
|
# Module-specific Builds
|
||||||
|
modules/vlc-player/android/build
|
||||||
modules/player/android
|
modules/player/android
|
||||||
|
modules/hls-downloader/android/build
|
||||||
|
|
||||||
pc-api-7079014811501811218-719-3b9f15aeccf8.json
|
# Generated Applications
|
||||||
credentials.json
|
Streamyfin.app
|
||||||
*.apk
|
*.apk
|
||||||
*.ipa
|
*.ipa
|
||||||
.continuerc.json
|
*.aab
|
||||||
|
|
||||||
|
# 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
|
||||||
modules/hls-downloader/android/build
|
.cursor/
|
||||||
streamyfin-4fec1-firebase-adminsdk.json
|
.claude/
|
||||||
|
|
||||||
|
# Environment and Configuration
|
||||||
|
expo-env.d.ts
|
||||||
|
.continuerc.json
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
*.aab
|
|
||||||
/version-backup-*
|
# Secrets and Credentials
|
||||||
bun.lockb
|
pc-api-7079014811501811218-719-3b9f15aeccf8.json
|
||||||
|
credentials.json
|
||||||
|
streamyfin-4fec1-firebase-adminsdk.json
|
||||||
|
|
||||||
|
# Version and Backup Files
|
||||||
|
/version-backup-*
|
||||||
24
.vscode/extensions.json
vendored
Normal file
24
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
// ==========================================
|
||||||
|
// 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,24 +1,178 @@
|
|||||||
{
|
{
|
||||||
|
// ==========================================
|
||||||
|
// 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,5 +1,9 @@
|
|||||||
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.35.1",
|
"version": "0.39.0",
|
||||||
"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": 67,
|
"versionCode": 71,
|
||||||
"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": "#2e2e2e",
|
"backgroundColor": "#010101",
|
||||||
"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: "prominent",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -11,12 +11,8 @@ 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"),
|
||||||
headerLargeStyle: {
|
headerBlurEffect: "none",
|
||||||
backgroundColor: "black",
|
|
||||||
},
|
|
||||||
headerBlurEffect: "prominent",
|
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -21,19 +21,16 @@ 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: "prominent",
|
headerBlurEffect: "none",
|
||||||
headerLargeStyle: {
|
|
||||||
backgroundColor: "black",
|
|
||||||
},
|
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<View className='flex flex-row items-center space-x-2'>
|
<View className='flex flex-row items-center px-2'>
|
||||||
{!Platform.isTV && (
|
{!Platform.isTV && (
|
||||||
<>
|
<>
|
||||||
<Chromecast.Chromecast />
|
<Chromecast.Chromecast background='transparent' />
|
||||||
|
|
||||||
{user?.Policy?.IsAdministrator && <SessionsButton />}
|
{user?.Policy?.IsAdministrator && <SessionsButton />}
|
||||||
<SettingsButton />
|
<SettingsButton />
|
||||||
</>
|
</>
|
||||||
@@ -138,14 +135,13 @@ const SessionsButton = () => {
|
|||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.push("/(auth)/sessions");
|
router.push("/(auth)/sessions");
|
||||||
}}
|
}}
|
||||||
|
className='mr-4'
|
||||||
>
|
>
|
||||||
<View className='mr-4'>
|
<Ionicons
|
||||||
<Ionicons
|
name='play-circle'
|
||||||
name='play-circle'
|
color={sessions.length === 0 ? "white" : "#9333ea"}
|
||||||
color={sessions.length === 0 ? "white" : "#9333ea"}
|
size={28}
|
||||||
size={25}
|
/>
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -90,6 +90,19 @@ 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: () => (
|
||||||
@@ -128,8 +141,30 @@ 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()]);
|
await Promise.all([deleteMovies(), deleteShows(), deleteOtherMedia()]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -238,6 +273,34 @@ 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'>
|
||||||
@@ -273,6 +336,11 @@ 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,6 +468,7 @@ 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",
|
||||||
@@ -501,7 +502,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='Video'
|
title={t("common.video")}
|
||||||
properties={{
|
properties={{
|
||||||
resolution: videoStreamTitle(),
|
resolution: videoStreamTitle(),
|
||||||
bitrate: videoStream?.BitRate,
|
bitrate: videoStream?.BitRate,
|
||||||
@@ -518,7 +519,7 @@ const TranscodingView = ({ session }: SessionCardProps) => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<TranscodingStreamView
|
<TranscodingStreamView
|
||||||
title='Audio'
|
title={t("common.audio")}
|
||||||
properties={{
|
properties={{
|
||||||
language: audioStream?.Language,
|
language: audioStream?.Language,
|
||||||
bitrate: audioStream?.BitRate,
|
bitrate: audioStream?.BitRate,
|
||||||
@@ -536,7 +537,7 @@ const TranscodingView = ({ session }: SessionCardProps) => {
|
|||||||
|
|
||||||
{subtitleStream && (
|
{subtitleStream && (
|
||||||
<TranscodingStreamView
|
<TranscodingStreamView
|
||||||
title='Subtitle'
|
title={t("common.subtitle")}
|
||||||
isTranscoding={false}
|
isTranscoding={false}
|
||||||
properties={{
|
properties={{
|
||||||
language: subtitleStream?.Language,
|
language: subtitleStream?.Language,
|
||||||
|
|||||||
@@ -139,7 +139,15 @@ 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(
|
||||||
() =>
|
() =>
|
||||||
@@ -277,12 +285,16 @@ 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={() => {
|
||||||
const url =
|
router.push({
|
||||||
mediaType === MediaType.MOVIE
|
pathname:
|
||||||
? `/(auth)/(tabs)/(search)/items/page?id=${details?.mediaInfo.jellyfinMediaId}`
|
mediaType === MediaType.MOVIE
|
||||||
: `/(auth)/(tabs)/(search)/series/${details?.mediaInfo.jellyfinMediaId}`;
|
? "/(auth)/(tabs)/(search)/items/page"
|
||||||
// @ts-expect-error
|
: "/(auth)/(tabs)/(search)/series/[id]",
|
||||||
router.push(url);
|
params:
|
||||||
|
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' />
|
||||||
@@ -292,7 +304,7 @@ const Page: React.FC = () => {
|
|||||||
borderStyle: "solid",
|
borderStyle: "solid",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text className='text-sm'>Play</Text>
|
<Text className='text-sm'>{t("common.play")}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -77,7 +77,13 @@ 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[]) => {
|
||||||
@@ -87,7 +93,7 @@ const Page = () => {
|
|||||||
}
|
}
|
||||||
_setSortBy(sortBy);
|
_setSortBy(sortBy);
|
||||||
},
|
},
|
||||||
[libraryId, sortByPreference],
|
[libraryId, sortByPreference, setSortByPreference, _setSortBy],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setSortOrder = useCallback(
|
const setSortOrder = useCallback(
|
||||||
@@ -101,7 +107,7 @@ const Page = () => {
|
|||||||
}
|
}
|
||||||
_setSortOrder(sortOrder);
|
_setSortOrder(sortOrder);
|
||||||
},
|
},
|
||||||
[libraryId, sortOrderPreference],
|
[libraryId, sortOrderPreference, setOderByPreference, _setSortOrder],
|
||||||
);
|
);
|
||||||
|
|
||||||
const nrOfCols = useMemo(() => {
|
const nrOfCols = useMemo(() => {
|
||||||
|
|||||||
@@ -1,224 +1,85 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
import { Platform } from "react-native";
|
import { useState } from "react";
|
||||||
|
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.Screen
|
<Stack>
|
||||||
name='index'
|
<Stack.Screen
|
||||||
options={{
|
name='index'
|
||||||
headerShown: !Platform.isTV,
|
options={{
|
||||||
headerLargeTitle: true,
|
headerShown: !Platform.isTV,
|
||||||
headerTitle: t("tabs.library"),
|
headerTitle: t("tabs.library"),
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "none",
|
||||||
headerLargeStyle: {
|
headerTransparent: Platform.OS === "ios",
|
||||||
backgroundColor: "black",
|
headerShadowVisible: false,
|
||||||
},
|
headerRight: () =>
|
||||||
headerTransparent: Platform.OS === "ios",
|
!pluginSettings?.libraryOptions?.locked &&
|
||||||
headerShadowVisible: false,
|
!Platform.isTV && (
|
||||||
headerRight: () =>
|
<TouchableOpacity
|
||||||
!pluginSettings?.libraryOptions?.locked &&
|
onPress={() => setOptionsSheetOpen(true)}
|
||||||
!Platform.isTV && (
|
className='flex flex-row items-center justify-center w-9 h-9'
|
||||||
<DropdownMenu.Root>
|
>
|
||||||
<DropdownMenu.Trigger>
|
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name='ellipsis-horizontal-outline'
|
name='ellipsis-horizontal-outline'
|
||||||
size={24}
|
size={24}
|
||||||
color='white'
|
color='white'
|
||||||
/>
|
/>
|
||||||
</DropdownMenu.Trigger>
|
</TouchableOpacity>
|
||||||
<DropdownMenu.Content
|
),
|
||||||
align={"end"}
|
}}
|
||||||
alignOffset={-10}
|
/>
|
||||||
avoidCollisions={false}
|
<Stack.Screen
|
||||||
collisionPadding={0}
|
name='[libraryId]'
|
||||||
loop={false}
|
options={{
|
||||||
side={"bottom"}
|
title: "",
|
||||||
sideOffset={10}
|
headerShown: !Platform.isTV,
|
||||||
>
|
headerBlurEffect: "none",
|
||||||
<DropdownMenu.Label>
|
headerTransparent: Platform.OS === "ios",
|
||||||
{t("library.options.display")}
|
headerShadowVisible: false,
|
||||||
</DropdownMenu.Label>
|
}}
|
||||||
<DropdownMenu.Group key='display-group'>
|
/>
|
||||||
<DropdownMenu.Sub>
|
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||||
<DropdownMenu.SubTrigger key='image-style-trigger'>
|
<Stack.Screen key={name} name={name} options={options} />
|
||||||
{t("library.options.display")}
|
))}
|
||||||
</DropdownMenu.SubTrigger>
|
<Stack.Screen
|
||||||
<DropdownMenu.SubContent
|
name='collections/[collectionId]'
|
||||||
alignOffset={-10}
|
options={{
|
||||||
avoidCollisions={true}
|
title: "",
|
||||||
collisionPadding={0}
|
headerShown: !Platform.isTV,
|
||||||
loop={true}
|
headerBlurEffect: "none",
|
||||||
sideOffset={10}
|
headerTransparent: Platform.OS === "ios",
|
||||||
>
|
headerShadowVisible: false,
|
||||||
<DropdownMenu.CheckboxItem
|
}}
|
||||||
key='display-option-1'
|
/>
|
||||||
value={settings.libraryOptions.display === "row"}
|
</Stack>
|
||||||
onValueChange={() =>
|
<LibraryOptionsSheet
|
||||||
updateSettings({
|
open={optionsSheetOpen}
|
||||||
libraryOptions: {
|
setOpen={setOptionsSheetOpen}
|
||||||
...settings.libraryOptions,
|
settings={settings.libraryOptions}
|
||||||
display: "row",
|
updateSettings={(options) =>
|
||||||
},
|
updateSettings({
|
||||||
})
|
libraryOptions: {
|
||||||
}
|
...settings.libraryOptions,
|
||||||
>
|
...options,
|
||||||
<DropdownMenu.ItemIndicator />
|
},
|
||||||
<DropdownMenu.ItemTitle key='display-title-1'>
|
})
|
||||||
{t("library.options.row")}
|
}
|
||||||
</DropdownMenu.ItemTitle>
|
disabled={pluginSettings?.libraryOptions?.locked}
|
||||||
</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,12 +14,8 @@ 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"),
|
||||||
headerLargeStyle: {
|
headerBlurEffect: "none",
|
||||||
backgroundColor: "black",
|
|
||||||
},
|
|
||||||
headerBlurEffect: "prominent",
|
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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";
|
||||||
@@ -21,6 +22,12 @@ 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";
|
||||||
@@ -97,7 +104,7 @@ export default function page() {
|
|||||||
/** Playback position in ticks. */
|
/** Playback position in ticks. */
|
||||||
playbackPosition?: string;
|
playbackPosition?: string;
|
||||||
}>();
|
}>();
|
||||||
useSettings();
|
const { settings } = useSettings();
|
||||||
|
|
||||||
const offline = offlineStr === "true";
|
const offline = offlineStr === "true";
|
||||||
const playbackManager = usePlaybackManager();
|
const playbackManager = usePlaybackManager();
|
||||||
@@ -264,12 +271,7 @@ export default function page() {
|
|||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
await videoRef.current?.pause();
|
await videoRef.current?.pause();
|
||||||
playbackManager.reportPlaybackProgress(
|
playbackManager.reportPlaybackProgress(
|
||||||
item?.Id!,
|
currentPlayStateInfo() as PlaybackProgressInfo,
|
||||||
msToTicks(progress.get()),
|
|
||||||
{
|
|
||||||
AudioStreamIndex: audioIndex ?? -1,
|
|
||||||
SubtitleStreamIndex: subtitleIndex ?? -1,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
videoRef.current?.play();
|
videoRef.current?.play();
|
||||||
@@ -387,12 +389,7 @@ export default function page() {
|
|||||||
if (!item?.Id) return;
|
if (!item?.Id) return;
|
||||||
|
|
||||||
playbackManager.reportPlaybackProgress(
|
playbackManager.reportPlaybackProgress(
|
||||||
item.Id,
|
currentPlayStateInfo() as PlaybackProgressInfo,
|
||||||
msToTicks(progress.get()),
|
|
||||||
{
|
|
||||||
AudioStreamIndex: audioIndex ?? -1,
|
|
||||||
SubtitleStreamIndex: subtitleIndex ?? -1,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
@@ -499,12 +496,7 @@ export default function page() {
|
|||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
if (item?.Id) {
|
if (item?.Id) {
|
||||||
playbackManager.reportPlaybackProgress(
|
playbackManager.reportPlaybackProgress(
|
||||||
item.Id,
|
currentPlayStateInfo() as PlaybackProgressInfo,
|
||||||
msToTicks(progress.get()),
|
|
||||||
{
|
|
||||||
AudioStreamIndex: audioIndex ?? -1,
|
|
||||||
SubtitleStreamIndex: subtitleIndex ?? -1,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!Platform.isTV) await activateKeepAwakeAsync();
|
if (!Platform.isTV) await activateKeepAwakeAsync();
|
||||||
@@ -515,12 +507,7 @@ export default function page() {
|
|||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
if (item?.Id) {
|
if (item?.Id) {
|
||||||
playbackManager.reportPlaybackProgress(
|
playbackManager.reportPlaybackProgress(
|
||||||
item.Id,
|
currentPlayStateInfo() as PlaybackProgressInfo,
|
||||||
msToTicks(progress.get()),
|
|
||||||
{
|
|
||||||
AudioStreamIndex: audioIndex ?? -1,
|
|
||||||
SubtitleStreamIndex: subtitleIndex ?? -1,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!Platform.isTV) await deactivateKeepAwake();
|
if (!Platform.isTV) await deactivateKeepAwake();
|
||||||
@@ -576,8 +563,34 @@ 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,15 +106,8 @@ function useNotificationObserver() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const subscription = Notifications.addNotificationResponseReceivedListener(
|
|
||||||
(response: { notification: any }) => {
|
|
||||||
redirect(response.notification);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
isMounted = false;
|
isMounted = false;
|
||||||
subscription.remove();
|
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
}
|
}
|
||||||
@@ -317,38 +310,42 @@ 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;
|
||||||
writeDebugLog(
|
writeInfoLog(`Notification ${title} opened`, data);
|
||||||
`Notification ${title} opened`,
|
|
||||||
response.notification.request.content,
|
|
||||||
);
|
|
||||||
if (data && Object.keys(data).length > 0) {
|
|
||||||
const type = (data?.type ?? "").toString().toLowerCase();
|
|
||||||
const itemId = data?.id;
|
|
||||||
|
|
||||||
switch (type) {
|
let url: any;
|
||||||
case "movie":
|
const type = (data?.type ?? "").toString().toLowerCase();
|
||||||
router.push(`/(auth)/(tabs)/home/items/page?id=${itemId}`);
|
const itemId = data?.id;
|
||||||
break;
|
|
||||||
case "episode":
|
switch (type) {
|
||||||
// We just clicked a notification for an individual episode.
|
case "movie":
|
||||||
if (itemId) {
|
url = `/(auth)/(tabs)/home/items/page?id=${itemId}`;
|
||||||
router.push(`/(auth)/(tabs)/home/items/page?id=${itemId}`);
|
break;
|
||||||
// summarized season notification for multiple episodes. Bring them to series season
|
case "episode":
|
||||||
|
// `/(auth)/(tabs)/${from}/items/page?id=${item.Id}`;
|
||||||
|
// We just clicked a notification for an individual episode.
|
||||||
|
if (itemId) {
|
||||||
|
url = `/(auth)/(tabs)/home/items/page?id=${itemId}`;
|
||||||
|
// summarized season notification for multiple episodes. Bring them to series season
|
||||||
|
} else {
|
||||||
|
const seriesId = data.seriesId;
|
||||||
|
const seasonIndex = data.seasonIndex;
|
||||||
|
if (seasonIndex) {
|
||||||
|
url = `/(auth)/(tabs)/home/series/${seriesId}?seasonIndex=${seasonIndex}`;
|
||||||
} else {
|
} else {
|
||||||
const seriesId = data.seriesId;
|
url = `/(auth)/(tabs)/home/series/${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);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -398,12 +395,17 @@ function Layout() {
|
|||||||
appState.current.match(/inactive|background/) &&
|
appState.current.match(/inactive|background/) &&
|
||||||
nextAppState === "active"
|
nextAppState === "active"
|
||||||
) {
|
) {
|
||||||
BackGroundDownloader.checkForExistingDownloads();
|
BackGroundDownloader.checkForExistingDownloads().catch(
|
||||||
|
(error: unknown) => {
|
||||||
|
writeErrorLog("Failed to resume background downloads", error);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
BackGroundDownloader.checkForExistingDownloads();
|
BackGroundDownloader.checkForExistingDownloads().catch((error: unknown) => {
|
||||||
|
writeErrorLog("Failed to resume background downloads", error);
|
||||||
|
});
|
||||||
return () => {
|
return () => {
|
||||||
subscription.remove();
|
subscription.remove();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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.2/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.2.5/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 { View, type ViewProps } from "react-native";
|
import { Platform, 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,6 +11,18 @@ 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
|
||||||
|
|||||||
776
components/AppleTVCarousel.tsx
Normal file
776
components/AppleTVCarousel.tsx
Normal file
@@ -0,0 +1,776 @@
|
|||||||
|
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 } from "react-native";
|
import { Platform, TouchableOpacity } from "react-native";
|
||||||
import GoogleCast, {
|
import GoogleCast, {
|
||||||
CastButton,
|
CastButton,
|
||||||
CastContext,
|
CastContext,
|
||||||
@@ -42,6 +42,22 @@ 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("Could not get download URL for {{itemName}}", {
|
t("home.downloads.toasts.could_not_get_download_url_for_item", {
|
||||||
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 { useImageColors } from "@/hooks/useImageColors";
|
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
|
||||||
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();
|
||||||
|
|
||||||
useImageColors({ item });
|
const itemColors = useImageColorsReturn({ item });
|
||||||
|
|
||||||
const [loadingLogo, setLoadingLogo] = useState(true);
|
const [loadingLogo, setLoadingLogo] = useState(true);
|
||||||
const [headerHeight, setHeaderHeight] = useState(350);
|
const [headerHeight, setHeaderHeight] = useState(350);
|
||||||
@@ -105,13 +105,27 @@ 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
|
<Chromecast.Chromecast width={22} height={22} />
|
||||||
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 && (
|
||||||
@@ -126,7 +140,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
),
|
)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [item, navigation, user]);
|
}, [item, navigation, user]);
|
||||||
@@ -253,6 +267,7 @@ 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.MediaStreams && item.MediaStreams.length <= 1)) return null;
|
if (isTv || (item.MediaSources && item.MediaSources.length <= 1)) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='flex shrink' style={{ minWidth: 75 }}>
|
<View className='flex shrink' style={{ minWidth: 75 }}>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ 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";
|
||||||
@@ -39,6 +40,7 @@ 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;
|
||||||
@@ -48,6 +50,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
item,
|
item,
|
||||||
selectedOptions,
|
selectedOptions,
|
||||||
isOffline,
|
isOffline,
|
||||||
|
colors,
|
||||||
...props
|
...props
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
@@ -55,16 +58,19 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
const mediaStatus = useMediaStatus();
|
const mediaStatus = useMediaStatus();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [colorAtom] = useAtom(itemThemeColorAtom);
|
const [globalColorAtom] = 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(colorAtom);
|
const endColor = useSharedValue(effectiveColors);
|
||||||
const startColor = useSharedValue(colorAtom);
|
const startColor = useSharedValue(effectiveColors);
|
||||||
const widthProgress = useSharedValue(0);
|
const widthProgress = useSharedValue(0);
|
||||||
const colorChangeProgress = useSharedValue(0);
|
const colorChangeProgress = useSharedValue(0);
|
||||||
const { settings, updateSettings } = useSettings();
|
const { settings, updateSettings } = useSettings();
|
||||||
@@ -297,7 +303,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
useAnimatedReaction(
|
useAnimatedReaction(
|
||||||
() => colorAtom,
|
() => effectiveColors,
|
||||||
(newColor) => {
|
(newColor) => {
|
||||||
endColor.value = newColor;
|
endColor.value = newColor;
|
||||||
colorChangeProgress.value = 0;
|
colorChangeProgress.value = 0;
|
||||||
@@ -306,19 +312,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),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[colorAtom],
|
[effectiveColors],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeout_2 = setTimeout(() => {
|
const timeout_2 = setTimeout(() => {
|
||||||
startColor.value = colorAtom;
|
startColor.value = effectiveColors;
|
||||||
startWidth.value = targetWidth.value;
|
startWidth.value = targetWidth.value;
|
||||||
}, ANIMATION_DURATION);
|
}, ANIMATION_DURATION);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(timeout_2);
|
clearTimeout(timeout_2);
|
||||||
};
|
};
|
||||||
}, [colorAtom, item]);
|
}, [effectiveColors, item]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ANIMATED STYLES
|
* ANIMATED STYLES
|
||||||
@@ -367,7 +373,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-xl z-10 overflow-hidden'>
|
<View className='absolute w-full h-full top-0 left-0 rounded-full z-10 overflow-hidden'>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[
|
style={[
|
||||||
animatedPrimaryStyle,
|
animatedPrimaryStyle,
|
||||||
@@ -381,15 +387,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-xl'
|
className='absolute w-full h-full top-0 left-0 rounded-full'
|
||||||
/>
|
/>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colorAtom.primary,
|
borderColor: effectiveColors.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-full 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,6 +15,7 @@ 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";
|
||||||
@@ -24,6 +25,7 @@ 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;
|
||||||
@@ -32,16 +34,20 @@ 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 [colorAtom] = useAtom(itemThemeColorAtom);
|
const [globalColorAtom] = 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(colorAtom);
|
const endColor = useSharedValue(effectiveColors);
|
||||||
const startColor = useSharedValue(colorAtom);
|
const startColor = useSharedValue(effectiveColors);
|
||||||
const widthProgress = useSharedValue(0);
|
const widthProgress = useSharedValue(0);
|
||||||
const colorChangeProgress = useSharedValue(0);
|
const colorChangeProgress = useSharedValue(0);
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
@@ -101,7 +107,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
useAnimatedReaction(
|
useAnimatedReaction(
|
||||||
() => colorAtom,
|
() => effectiveColors,
|
||||||
(newColor) => {
|
(newColor) => {
|
||||||
endColor.value = newColor;
|
endColor.value = newColor;
|
||||||
colorChangeProgress.value = 0;
|
colorChangeProgress.value = 0;
|
||||||
@@ -110,19 +116,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),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[colorAtom],
|
[effectiveColors],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeout_2 = setTimeout(() => {
|
const timeout_2 = setTimeout(() => {
|
||||||
startColor.value = colorAtom;
|
startColor.value = effectiveColors;
|
||||||
startWidth.value = targetWidth.value;
|
startWidth.value = targetWidth.value;
|
||||||
}, ANIMATION_DURATION);
|
}, ANIMATION_DURATION);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(timeout_2);
|
clearTimeout(timeout_2);
|
||||||
};
|
};
|
||||||
}, [colorAtom, item]);
|
}, [effectiveColors, item]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ANIMATED STYLES
|
* ANIMATED STYLES
|
||||||
@@ -189,7 +195,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colorAtom.primary,
|
borderColor: effectiveColors.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 { View, type ViewProps } from "react-native";
|
import { Platform, 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,6 +14,21 @@ 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
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
children,
|
children,
|
||||||
size = "default",
|
size = "default",
|
||||||
fillColor,
|
fillColor,
|
||||||
|
color = "white",
|
||||||
hapticFeedback = true,
|
hapticFeedback = true,
|
||||||
...viewProps
|
...viewProps
|
||||||
}) => {
|
}) => {
|
||||||
@@ -34,6 +36,25 @@ 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
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
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,6 +19,18 @@ 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
|
||||||
|
|||||||
@@ -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[2] || "(home)";
|
const from = (segments as string[])[2] || "(home)";
|
||||||
|
|
||||||
const autoApprove = useMemo(() => {
|
const autoApprove = useMemo(() => {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -43,6 +43,48 @@ 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,
|
||||||
@@ -55,7 +97,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[2] || "(home)";
|
const from = (segments as string[])[2] || "(home)";
|
||||||
|
|
||||||
const showActionSheet = useCallback(() => {
|
const showActionSheet = useCallback(() => {
|
||||||
if (
|
if (
|
||||||
@@ -101,12 +143,15 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onLongPress={showActionSheet}
|
onLongPress={showActionSheet}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
let url = itemRouter(item, from);
|
|
||||||
if (isOffline) {
|
if (isOffline) {
|
||||||
url += `&offline=true`;
|
// For offline mode, we still need to use query params
|
||||||
|
const url = `${itemRouter(item, from)}&offline=true`;
|
||||||
|
router.push(url as any);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
// @ts-expect-error
|
|
||||||
router.push(url);
|
const navigation = getItemNavigation(item, from);
|
||||||
|
router.push(navigation as any);
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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,
|
||||||
@@ -109,9 +110,9 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Action buttons in top right corner */}
|
{/* Action buttons in bottom right corner */}
|
||||||
<View className='absolute top-2 right-2 flex flex-row items-center space-x-2 z-10'>
|
<View className='absolute bottom-2 right-2 flex flex-row items-center space-x-2 z-10'>
|
||||||
{process.status === "downloading" && (
|
{process.status === "downloading" && Platform.OS !== "ios" && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => handlePause(process.id)}
|
onPress={() => handlePause(process.id)}
|
||||||
className='p-1'
|
className='p-1'
|
||||||
@@ -119,7 +120,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" && (
|
{process.status === "paused" && Platform.OS !== "ios" && (
|
||||||
<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, "Movie");
|
deleteFile(item.Id, item.Type);
|
||||||
}
|
}
|
||||||
}, [deleteFile, item.Id]);
|
}, [deleteFile, item.Id]);
|
||||||
|
|
||||||
|
|||||||
@@ -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 { itemRouter } from "../common/TouchableItemRouter";
|
import { getItemNavigation } from "../common/TouchableItemRouter";
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
@@ -88,22 +88,24 @@ export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
|
|||||||
if (!popularItems) return null;
|
if (!popularItems) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='flex flex-col items-center mt-2' {...props}>
|
<View className='flex flex-col items-center' {...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: 0.86,
|
parallaxScrollingScale: 1,
|
||||||
parallaxScrollingOffset: 100,
|
parallaxScrollingOffset: 0,
|
||||||
}}
|
}}
|
||||||
width={width}
|
width={width}
|
||||||
height={204}
|
height={500}
|
||||||
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}
|
||||||
@@ -146,20 +148,20 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
|||||||
}, [item]);
|
}, [item]);
|
||||||
|
|
||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
const from = segments[2] || "(home)";
|
const from = (segments as string[])[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();
|
||||||
// @ts-expect-error
|
const navigation = getItemNavigation(item, from);
|
||||||
if (url) router.push(url);
|
router.push(navigation as any);
|
||||||
}, [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 });
|
||||||
})
|
})
|
||||||
@@ -174,25 +176,19 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<GestureDetector gesture={tap}>
|
<GestureDetector gesture={tap}>
|
||||||
<Animated.View
|
<Animated.View style={{ opacity }}>
|
||||||
style={{
|
<View className='relative flex justify-center overflow-hidden border border-neutral-800'>
|
||||||
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: 200,
|
height: 500,
|
||||||
borderRadius: 16,
|
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<View className='absolute bottom-0 left-0 w-full h-24 p-4 flex items-center'>
|
<View className='absolute bottom-0 left-0 w-full flex items-center'>
|
||||||
<Image
|
<Image
|
||||||
source={{
|
source={{
|
||||||
uri: logoUri,
|
uri: logoUri,
|
||||||
|
|||||||
131
components/inputs/PinInput.tsx
Normal file
131
components/inputs/PinInput.tsx
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
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[2] || "(home)";
|
const from = (segments as string[])[2] || "(home)";
|
||||||
|
|
||||||
if (from === "(home)" || from === "(search)" || from === "(libraries)")
|
if (from === "(home)" || from === "(search)" || from === "(libraries)")
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -16,13 +16,12 @@ 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[2] || "(home)";
|
const from = (segments as string[])[2] || "(home)";
|
||||||
|
|
||||||
const navigate = useCallback(
|
const navigate = useCallback(
|
||||||
({ id, image, name }: Network | Studio) =>
|
({ id, image, name }: Network | Studio) =>
|
||||||
router.push({
|
router.push({
|
||||||
// @ts-expect-error - Dynamic pathname for jellyseerr routing
|
pathname: `/(auth)/(tabs)/${from}/jellyseerr/company/${id}` as any,
|
||||||
pathname: `/(auth)/(tabs)/${from}/jellyseerr/company/${id}`,
|
|
||||||
params: { id, image, name, type: slide.type },
|
params: { id, image, name, type: slide.type },
|
||||||
}),
|
}),
|
||||||
[slide],
|
[slide],
|
||||||
|
|||||||
@@ -13,13 +13,12 @@ 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[2] || "(home)";
|
const from = (segments as string[])[2] || "(home)";
|
||||||
|
|
||||||
const navigate = useCallback(
|
const navigate = useCallback(
|
||||||
(genre: GenreSliderItem) =>
|
(genre: GenreSliderItem) =>
|
||||||
router.push({
|
router.push({
|
||||||
// @ts-expect-error - Dynamic pathname for jellyseerr routing
|
pathname: `/(auth)/(tabs)/${from}/jellyseerr/genre/${genre.id}` as any,
|
||||||
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,7 +8,14 @@ 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";
|
||||||
|
|
||||||
const RequestCard: React.FC<{ request: MediaRequest }> = ({ request }) => {
|
type ExtendedMediaRequest = NonFunctionProperties<MediaRequest> & {
|
||||||
|
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({
|
||||||
@@ -67,9 +74,15 @@ const RecentRequestsSlide: React.FC<SlideProps & ViewProps> = ({
|
|||||||
<Slide
|
<Slide
|
||||||
{...props}
|
{...props}
|
||||||
slide={slide}
|
slide={slide}
|
||||||
data={requests.results}
|
data={
|
||||||
|
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: NonFunctionProperties<MediaRequest>) => (
|
renderItem={(item: ExtendedMediaRequest) => (
|
||||||
<RequestCard request={item} />
|
<RequestCard request={item} />
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ 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 {
|
||||||
@@ -24,7 +23,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[2];
|
const from = (segments as string[])[2];
|
||||||
|
|
||||||
const destinctPeople = useMemo(() => {
|
const destinctPeople = useMemo(() => {
|
||||||
const people: Record<string, BaseItemPerson> = {};
|
const people: Record<string, BaseItemPerson> = {};
|
||||||
@@ -56,15 +55,12 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
|
|||||||
renderItem={(i) => (
|
renderItem={(i) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
const url = itemRouter(
|
if (i.Id) {
|
||||||
{
|
router.push({
|
||||||
Id: i.Id,
|
pathname: "/persons/[personId]",
|
||||||
Type: "Person",
|
params: { personId: i.Id },
|
||||||
},
|
});
|
||||||
from,
|
}
|
||||||
);
|
|
||||||
// @ts-expect-error
|
|
||||||
router.push(url);
|
|
||||||
}}
|
}}
|
||||||
className='flex flex-col w-28'
|
className='flex flex-col w-28'
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
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, useQueryClient } from "@tanstack/react-query";
|
import { useQuery } 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,
|
||||||
@@ -57,6 +56,7 @@ 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,48 +70,6 @@ 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, useQueryClient } from "@tanstack/react-query";
|
import { useQuery } 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,7 +11,6 @@ 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";
|
||||||
@@ -87,7 +86,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: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
|
fields: ["Overview", "Trickplay"],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.data.TotalRecordCount === 0)
|
if (res.data.TotalRecordCount === 0)
|
||||||
@@ -101,25 +100,6 @@ 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,6 +1,7 @@
|
|||||||
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,
|
||||||
@@ -16,6 +17,7 @@ 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;
|
||||||
@@ -30,7 +32,7 @@ export const ItemActions = ({ item, ...props }: Props) => {
|
|||||||
|
|
||||||
const openTrailer = useCallback(async () => {
|
const openTrailer = useCallback(async () => {
|
||||||
if (!trailerLink) {
|
if (!trailerLink) {
|
||||||
Alert.alert("No trailer available");
|
Alert.alert(t("common.no_trailer_available"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,7 +41,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]);
|
}, [trailerLink, t]);
|
||||||
|
|
||||||
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],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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 {
|
||||||
@@ -27,7 +28,6 @@ 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,6 +38,7 @@ 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";
|
||||||
@@ -74,7 +75,12 @@ export const HomeIndex = () => {
|
|||||||
|
|
||||||
const { getDownloadedItems, cleanCacheDirectory } = useDownload();
|
const { getDownloadedItems, cleanCacheDirectory } = useDownload();
|
||||||
const prevIsConnected = useRef<boolean | null>(false);
|
const prevIsConnected = useRef<boolean | null>(false);
|
||||||
const { isConnected, loading: retryLoading, retryCheck } = useNetworkStatus();
|
const {
|
||||||
|
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
|
||||||
@@ -120,8 +126,11 @@ export const HomeIndex = () => {
|
|||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = eventBus.on("scrollToTop", () => {
|
const unsubscribe = eventBus.on("scrollToTop", () => {
|
||||||
if (segments[2] === "(home)")
|
if ((segments as string[])[2] === "(home)")
|
||||||
scrollViewRef.current?.scrollTo({ y: -152, animated: true });
|
scrollViewRef.current?.scrollTo({
|
||||||
|
y: Platform.isTV ? -152 : -100,
|
||||||
|
animated: true,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -187,9 +196,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"],
|
fields: ["PrimaryImageAspectRatio", "Path", "Genres"],
|
||||||
imageTypeLimit: 1,
|
imageTypeLimit: 1,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||||
includeItemTypes,
|
includeItemTypes,
|
||||||
parentId,
|
parentId,
|
||||||
})
|
})
|
||||||
@@ -231,8 +240,9 @@ export const HomeIndex = () => {
|
|||||||
(
|
(
|
||||||
await getItemsApi(api).getResumeItems({
|
await getItemsApi(api).getResumeItems({
|
||||||
userId: user.Id,
|
userId: user.Id,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||||
|
fields: ["Genres"],
|
||||||
})
|
})
|
||||||
).data.Items || [],
|
).data.Items || [],
|
||||||
type: "ScrollingCollectionList",
|
type: "ScrollingCollectionList",
|
||||||
@@ -245,9 +255,9 @@ export const HomeIndex = () => {
|
|||||||
(
|
(
|
||||||
await getTvShowsApi(api).getNextUp({
|
await getTvShowsApi(api).getNextUp({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
fields: ["MediaSourceCount"],
|
fields: ["MediaSourceCount", "Genres"],
|
||||||
limit: 20,
|
limit: 20,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||||
enableResumable: false,
|
enableResumable: false,
|
||||||
})
|
})
|
||||||
).data.Items || [],
|
).data.Items || [],
|
||||||
@@ -308,10 +318,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.items?.title || `section-${index}`;
|
const id = section.title || `section-${index}`;
|
||||||
ss.push({
|
ss.push({
|
||||||
title: t(`${id}`),
|
title: t(`${id}`),
|
||||||
queryKey: ["home", id],
|
queryKey: ["home", "custom", String(index), section.title ?? null],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (section.items) {
|
if (section.items) {
|
||||||
const response = await getItemsApi(api).getItems({
|
const response = await getItemsApi(api).getItems({
|
||||||
@@ -329,9 +339,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"],
|
fields: ["MediaSourceCount", "Genres"],
|
||||||
limit: section.nextUp?.limit || 25,
|
limit: section.nextUp?.limit || 25,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||||
enableResumable: section.nextUp?.enableResumable,
|
enableResumable: section.nextUp?.enableResumable,
|
||||||
enableRewatching: section.nextUp?.enableRewatching,
|
enableRewatching: section.nextUp?.enableRewatching,
|
||||||
});
|
});
|
||||||
@@ -347,6 +357,16 @@ 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",
|
||||||
@@ -358,13 +378,28 @@ export const HomeIndex = () => {
|
|||||||
|
|
||||||
const sections = settings?.home?.sections ? customSections : defaultSections;
|
const sections = settings?.home?.sections ? customSections : defaultSections;
|
||||||
|
|
||||||
if (isConnected === false) {
|
if (!isConnected || serverConnected !== true) {
|
||||||
|
let title = "";
|
||||||
|
let subtitle = "";
|
||||||
|
|
||||||
|
if (!isConnected) {
|
||||||
|
// No network connection
|
||||||
|
title = t("home.no_internet");
|
||||||
|
subtitle = t("home.no_internet_message");
|
||||||
|
} else if (serverConnected === null) {
|
||||||
|
// Network is up, but server is being checked
|
||||||
|
title = t("home.checking_server_connection");
|
||||||
|
subtitle = t("home.checking_server_connection_message");
|
||||||
|
} else if (!serverConnected) {
|
||||||
|
// Network is up, but server is unreachable
|
||||||
|
title = t("home.server_unreachable");
|
||||||
|
subtitle = t("home.server_unreachable_message");
|
||||||
|
}
|
||||||
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'>{t("home.no_internet")}</Text>
|
<Text className='text-3xl font-bold mb-2'>{title}</Text>
|
||||||
<Text className='text-center opacity-70'>
|
<Text className='text-center opacity-70'>{subtitle}</Text>
|
||||||
{t("home.no_internet_message")}
|
|
||||||
</Text>
|
|
||||||
<View className='mt-4'>
|
<View className='mt-4'>
|
||||||
{!Platform.isTV && (
|
{!Platform.isTV && (
|
||||||
<Button
|
<Button
|
||||||
@@ -378,6 +413,7 @@ 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}
|
||||||
@@ -390,9 +426,9 @@ export const HomeIndex = () => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{retryLoading ? (
|
{retryLoading ? (
|
||||||
<ActivityIndicator size={"small"} color={"white"} />
|
<ActivityIndicator size='small' color='white' />
|
||||||
) : (
|
) : (
|
||||||
"Retry"
|
t("home.retry")
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
@@ -422,44 +458,55 @@ export const HomeIndex = () => {
|
|||||||
scrollToOverflowEnabled={true}
|
scrollToOverflowEnabled={true}
|
||||||
ref={scrollViewRef}
|
ref={scrollViewRef}
|
||||||
nestedScrollEnabled
|
nestedScrollEnabled
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
contentInsetAdjustmentBehavior='never'
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl refreshing={loading} onRefresh={refetch} />
|
<RefreshControl
|
||||||
|
refreshing={loading}
|
||||||
|
onRefresh={refetch}
|
||||||
|
tintColor='white' // For iOS
|
||||||
|
colors={["white"]} // For Android
|
||||||
|
progressViewOffset={200} // This offsets the refresh indicator to appear over the carousel
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
contentContainerStyle={{
|
style={{ marginTop: Platform.isTV ? 0 : -100 }}
|
||||||
paddingLeft: insets.left,
|
contentContainerStyle={{ paddingTop: Platform.isTV ? 0 : 100 }}
|
||||||
paddingRight: insets.right,
|
|
||||||
paddingBottom: 16,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<View className='flex flex-col space-y-4'>
|
<AppleTVCarousel initialIndex={0} />
|
||||||
<LargeMovieCarousel />
|
<View
|
||||||
|
style={{
|
||||||
{sections.map((section, index) => {
|
paddingLeft: insets.left,
|
||||||
if (section.type === "ScrollingCollectionList") {
|
paddingRight: insets.right,
|
||||||
return (
|
paddingBottom: 16,
|
||||||
<ScrollingCollectionList
|
}}
|
||||||
key={index}
|
>
|
||||||
title={section.title}
|
<View className='flex flex-col space-y-4'>
|
||||||
queryKey={section.queryKey}
|
{sections.map((section, index) => {
|
||||||
queryFn={section.queryFn}
|
if (section.type === "ScrollingCollectionList") {
|
||||||
orientation={section.orientation}
|
return (
|
||||||
hideIfEmpty
|
<ScrollingCollectionList
|
||||||
/>
|
key={index}
|
||||||
);
|
title={section.title}
|
||||||
}
|
queryKey={section.queryKey}
|
||||||
if (section.type === "MediaListSection") {
|
queryFn={section.queryFn}
|
||||||
return (
|
orientation={section.orientation}
|
||||||
<MediaListSection
|
hideIfEmpty
|
||||||
key={index}
|
/>
|
||||||
queryKey={section.queryKey}
|
);
|
||||||
queryFn={section.queryFn}
|
}
|
||||||
/>
|
if (section.type === "MediaListSection") {
|
||||||
);
|
return (
|
||||||
}
|
<MediaListSection
|
||||||
return null;
|
key={index}
|
||||||
})}
|
queryKey={section.queryKey}
|
||||||
|
queryFn={section.queryFn}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
<View className='h-24' />
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
254
components/settings/LibraryOptionsSheet.tsx
Normal file
254
components/settings/LibraryOptionsSheet.tsx
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
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("Background downloads enabled");
|
toast.success(t("home.settings.toasts.background_downloads_enabled"));
|
||||||
} else if (settings?.autoDownload === false && registered) {
|
} else if (settings?.autoDownload === false && registered) {
|
||||||
unregisterBackgroundFetchAsync();
|
unregisterBackgroundFetchAsync();
|
||||||
toast.info("Background downloads disabled");
|
toast.info(t("home.settings.toasts.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,7 +2,6 @@ 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";
|
||||||
@@ -10,17 +9,19 @@ 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, View, type ViewProps } from "react-native";
|
import { Alert, Platform, 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>();
|
||||||
@@ -73,11 +74,17 @@ 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={() => bottomSheetModalRef?.current?.present()}
|
onPress={() => {
|
||||||
|
// 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'
|
||||||
/>
|
/>
|
||||||
@@ -93,6 +100,9 @@ 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'>
|
||||||
@@ -102,16 +112,17 @@ 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'>
|
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full space-y-4'>
|
||||||
<BottomSheetTextInput
|
<Text className='text-neutral-400 text-center'>
|
||||||
style={{ color: "white" }}
|
{t(
|
||||||
clearButtonMode='always'
|
|
||||||
placeholder={t(
|
|
||||||
"home.settings.quick_connect.enter_the_quick_connect_code",
|
"home.settings.quick_connect.enter_the_quick_connect_code",
|
||||||
)}
|
)}
|
||||||
placeholderTextColor='#9CA3AF'
|
</Text>
|
||||||
value={quickConnectCode}
|
<PinInput
|
||||||
|
value={quickConnectCode || ""}
|
||||||
onChangeText={setQuickConnectCode}
|
onChangeText={setQuickConnectCode}
|
||||||
|
style={{ paddingHorizontal: 16 }}
|
||||||
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ 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,6 +16,8 @@ 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;
|
||||||
|
|
||||||
@@ -25,6 +27,15 @@ 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;
|
||||||
|
|
||||||
@@ -147,6 +158,148 @@ 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,5 +1,6 @@
|
|||||||
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 =
|
||||||
@@ -12,8 +13,9 @@ type ICommonScreenOptions =
|
|||||||
export const commonScreenOptions: ICommonScreenOptions = {
|
export const commonScreenOptions: ICommonScreenOptions = {
|
||||||
title: "",
|
title: "",
|
||||||
headerShown: true,
|
headerShown: true,
|
||||||
headerTransparent: true,
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
|
headerBlurEffect: "none",
|
||||||
headerLeft: () => <HeaderBackButton />,
|
headerLeft: () => <HeaderBackButton />,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -379,8 +379,7 @@ export const Controls: FC<Props> = ({
|
|||||||
|
|
||||||
console.log("queryParams", queryParams);
|
console.log("queryParams", queryParams);
|
||||||
|
|
||||||
// @ts-expect-error
|
router.replace(`player/direct-player?${queryParams}` as any);
|
||||||
router.replace(`player/direct-player?${queryParams}`);
|
|
||||||
},
|
},
|
||||||
[settings, subtitleIndex, audioIndex, mediaSource, bitrateValue, router],
|
[settings, subtitleIndex, audioIndex, mediaSource, bitrateValue, router],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -95,8 +95,7 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
|
|||||||
playbackPosition: playbackPosition,
|
playbackPosition: playbackPosition,
|
||||||
}).toString();
|
}).toString();
|
||||||
|
|
||||||
//@ts-expect-error
|
router.replace(`player/direct-player?${queryParams}` as any);
|
||||||
router.replace(`player/direct-player?${queryParams}`);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const setTrackParams = (
|
const setTrackParams = (
|
||||||
|
|||||||
@@ -51,8 +51,7 @@ const DropdownView = () => {
|
|||||||
bitrateValue: bitrate.toString(),
|
bitrateValue: bitrate.toString(),
|
||||||
playbackPosition: playbackPosition,
|
playbackPosition: playbackPosition,
|
||||||
}).toString();
|
}).toString();
|
||||||
// @ts-expect-error
|
router.replace(`player/direct-player?${queryParams}` as any);
|
||||||
router.replace(`player/direct-player?${queryParams}`);
|
|
||||||
},
|
},
|
||||||
[item, mediaSource, subtitleIndex, audioIndex, playbackPosition],
|
[item, mediaSource, subtitleIndex, audioIndex, playbackPosition],
|
||||||
);
|
);
|
||||||
|
|||||||
45
constants/SubtitleConstants.ts
Normal file
45
constants/SubtitleConstants.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
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
Normal file
12
crowdin.yml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
"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,6 +4,17 @@
|
|||||||
},
|
},
|
||||||
"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",
|
||||||
@@ -15,6 +26,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"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",
|
||||||
@@ -27,6 +43,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"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",
|
||||||
@@ -38,29 +59,72 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"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.35.1",
|
"channel": "0.39.0",
|
||||||
"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.35.1",
|
"channel": "0.39.0",
|
||||||
"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.35.1",
|
"channel": "0.39.0",
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
|
|||||||
131
hooks/useImageColorsReturn.ts
Normal file
131
hooks/useImageColorsReturn.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
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.status === 403) {
|
if (error.response?.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?.region || "US",
|
() => jellyseerrUser?.settings?.discoverRegion || "US",
|
||||||
[jellyseerrUser],
|
[jellyseerrUser],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +1,58 @@
|
|||||||
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);
|
||||||
const state = await NetInfo.fetch();
|
await validateConnection();
|
||||||
setIsConnected(!!state.isConnected && !!state.isInternetReachable);
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, []);
|
}, [validateConnection]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = NetInfo.addEventListener((state) => {
|
const unsubscribe = NetInfo.addEventListener(async (state) => {
|
||||||
setIsConnected(!!state.isConnected && !!state.isInternetReachable);
|
setIsConnected(!!state.isConnected);
|
||||||
|
if (state.isConnected) {
|
||||||
|
await validateConnection();
|
||||||
|
} else {
|
||||||
|
setServerConnected(false);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initial state
|
// Initial check: wait for NetInfo first
|
||||||
NetInfo.fetch().then((state) => {
|
NetInfo.fetch().then((state) => {
|
||||||
setIsConnected(!!state.isConnected && !!state.isInternetReachable);
|
if (state.isConnected) {
|
||||||
|
validateConnection();
|
||||||
|
} else {
|
||||||
|
setServerConnected(false);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => unsubscribe();
|
return () => unsubscribe();
|
||||||
}, []);
|
}, [validateConnection]);
|
||||||
|
|
||||||
return { isConnected, loading, retryCheck };
|
return { isConnected, serverConnected, loading, retryCheck };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import type {
|
||||||
|
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";
|
||||||
@@ -141,13 +144,10 @@ 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 (
|
||||||
itemId: string,
|
playbackProgressInfo: PlaybackProgressInfo,
|
||||||
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,14 +192,7 @@ 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,6 +1,7 @@
|
|||||||
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";
|
||||||
@@ -9,6 +10,7 @@ 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";
|
||||||
@@ -29,6 +31,7 @@ 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" },
|
||||||
@@ -39,6 +42,7 @@ 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" },
|
||||||
@@ -59,12 +63,14 @@ 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 },
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
# login.yaml
|
|
||||||
|
|
||||||
appId: your.app.id
|
|
||||||
---
|
|
||||||
- launchApp
|
|
||||||
- tapOn: "Text on the screen"
|
|
||||||
237
metro.config.js
237
metro.config.js
@@ -1,28 +1,243 @@
|
|||||||
// 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); // eslint-disable-line no-undef
|
const config = getDefaultConfig(__dirname);
|
||||||
|
|
||||||
// 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;
|
||||||
|
|
||||||
// When enabled, the optional code below will allow Metro to resolve
|
// CPU optimization (your existing setting)
|
||||||
// and bundle source files with TV-specific extensions
|
const os = require("node:os");
|
||||||
// (e.g., *.ios.tv.tsx, *.android.tv.tsx, *.tv.tsx)
|
config.maxWorkers = Math.max(1, os.cpus().length - 1);
|
||||||
//
|
|
||||||
// Metro will still resolve source files with standard extensions
|
// JAVASCRIPT OPTIMIZATION (Safe & Stable)
|
||||||
// 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((e) => `tv.${e}`),
|
...originalSourceExts.map((ext) => `tv.${ext}`),
|
||||||
...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(", ")}...`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// config.resolver.unstable_enablePackageExports = false;
|
// DEVELOPMENT ENHANCEMENTS
|
||||||
|
// ========================
|
||||||
|
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 {
|
import type {
|
||||||
ChapterInfo,
|
ChapterInfo,
|
||||||
PlaybackStatePayload,
|
PlaybackStatePayload,
|
||||||
ProgressUpdatePayload,
|
ProgressUpdatePayload,
|
||||||
@@ -12,16 +12,20 @@ import {
|
|||||||
} from "./VlcPlayer.types";
|
} from "./VlcPlayer.types";
|
||||||
import VlcPlayerView from "./VlcPlayerView";
|
import VlcPlayerView from "./VlcPlayerView";
|
||||||
|
|
||||||
export {
|
// Component
|
||||||
VlcPlayerView,
|
export { VlcPlayerView };
|
||||||
VlcPlayerViewProps,
|
|
||||||
VlcPlayerViewRef,
|
// Component Types
|
||||||
|
export type { VlcPlayerViewProps, VlcPlayerViewRef };
|
||||||
|
|
||||||
|
// Media Types
|
||||||
|
export type { ChapterInfo, TrackInfo, VlcPlayerSource };
|
||||||
|
|
||||||
|
// Playback Events (alphabetically sorted)
|
||||||
|
export type {
|
||||||
PlaybackStatePayload,
|
PlaybackStatePayload,
|
||||||
ProgressUpdatePayload,
|
ProgressUpdatePayload,
|
||||||
VideoLoadStartPayload,
|
VideoLoadStartPayload,
|
||||||
VideoStateChangePayload,
|
|
||||||
VideoProgressPayload,
|
VideoProgressPayload,
|
||||||
VlcPlayerSource,
|
VideoStateChangePayload,
|
||||||
TrackInfo,
|
|
||||||
ChapterInfo,
|
|
||||||
};
|
};
|
||||||
|
|||||||
45
package.json
45
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": "tsc -p tsconfig.json --noEmit | grep -v \"utils/jellyseerr\"",
|
"typecheck": "node scripts/typecheck.js",
|
||||||
"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.9.2",
|
"@bottom-tabs/react-navigation": "^0.11.2",
|
||||||
"@expo/metro-runtime": "~5.0.4",
|
"@expo/metro-runtime": "~5.0.5",
|
||||||
"@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": "^14.1.0",
|
||||||
"@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.22",
|
"expo": "^53.0.23",
|
||||||
"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.5",
|
"expo-router": "~5.1.7",
|
||||||
"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.9.2",
|
"react-native-bottom-tabs": "^0.11.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.17.4",
|
"react-native-reanimated": "~3.19.1",
|
||||||
"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",
|
||||||
@@ -101,21 +101,21 @@
|
|||||||
"zod": "^4.1.3"
|
"zod": "^4.1.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.20.0",
|
"@babel/core": "7.28.4",
|
||||||
"@biomejs/biome": "^2.2.2",
|
"@biomejs/biome": "2.2.5",
|
||||||
"@react-native-community/cli": "^20.0.0",
|
"@react-native-community/cli": "20.0.2",
|
||||||
"@react-native-tvos/config-tv": "^0.1.1",
|
"@react-native-tvos/config-tv": "0.1.4",
|
||||||
"@types/jest": "^29.5.12",
|
"@types/jest": "30.0.0",
|
||||||
"@types/lodash": "^4.17.15",
|
"@types/lodash": "4.17.20",
|
||||||
"@types/react": "~19.0.10",
|
"@types/react": "~19.0.10",
|
||||||
"@types/react-test-renderer": "^19.0.0",
|
"@types/react-test-renderer": "19.1.0",
|
||||||
"expo-doctor": "^1.17.0",
|
"cross-env": "10.1.0",
|
||||||
"cross-env": "^10.0.0",
|
"expo-dev-client": "5.2.4",
|
||||||
"husky": "^9.1.7",
|
"expo-doctor": "1.17.9",
|
||||||
"lint-staged": "^16.1.5",
|
"husky": "9.1.7",
|
||||||
"postinstall-postinstall": "^2.1.0",
|
"lint-staged": "16.2.3",
|
||||||
"react-test-renderer": "19.1.1",
|
"react-test-renderer": "19.1.1",
|
||||||
"typescript": "~5.8.3"
|
"typescript": "5.8.3"
|
||||||
},
|
},
|
||||||
"expo": {
|
"expo": {
|
||||||
"install": {
|
"install": {
|
||||||
@@ -149,7 +149,6 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"trustedDependencies": [
|
"trustedDependencies": [
|
||||||
"postinstall-postinstall",
|
|
||||||
"unrs-resolver"
|
"unrs-resolver"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,9 +50,11 @@ 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",
|
||||||
"Streamyfin/Streamyfin-Bridging-Header.h",
|
bridgingHeaderPath,
|
||||||
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 { writeToLog } from "@/utils/log";
|
import { dumpDownloadDiagnostics, 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,37 +42,60 @@ 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 => {
|
||||||
let size = p.mediaSource.Size;
|
const size = p.mediaSource?.Size || 0;
|
||||||
const maxBitrate = p.maxBitrate.value;
|
const maxBitrate = p.maxBitrate?.value;
|
||||||
if (
|
const runTimeTicks = (p.item?.RunTimeTicks || 0) as number;
|
||||||
maxBitrate &&
|
|
||||||
size &&
|
|
||||||
p.mediaSource.Bitrate &&
|
|
||||||
maxBitrate < p.mediaSource.Bitrate
|
|
||||||
) {
|
|
||||||
size = (size / p.mediaSource.Bitrate) * maxBitrate;
|
|
||||||
}
|
|
||||||
// This function is for estimated size, so just return the adjusted size
|
|
||||||
return size ?? 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper to calculate download speed
|
if (!size && maxBitrate && runTimeTicks > 0) {
|
||||||
const calculateSpeed = (
|
// Jellyfin RunTimeTicks are in 10,000,000 ticks per second
|
||||||
process: JobStatus,
|
const seconds = runTimeTicks / 10000000;
|
||||||
newBytesDownloaded: number,
|
if (seconds > 0) {
|
||||||
): number | undefined => {
|
// maxBitrate is in bits per second; convert to bytes
|
||||||
const { bytesDownloaded: oldBytes = 0, lastProgressUpdateTime } = process;
|
return Math.round((maxBitrate / 8) * seconds);
|
||||||
const deltaBytes = newBytesDownloaded - oldBytes;
|
|
||||||
|
|
||||||
if (lastProgressUpdateTime && deltaBytes > 0) {
|
|
||||||
const deltaTimeInSeconds =
|
|
||||||
(Date.now() - new Date(lastProgressUpdateTime).getTime()) / 1000;
|
|
||||||
if (deltaTimeInSeconds > 0) {
|
|
||||||
return deltaBytes / deltaTimeInSeconds;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return undefined;
|
|
||||||
|
return size || 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate download speed in bytes/sec based on a job's last update time
|
||||||
|
// and previously recorded bytesDownloaded.
|
||||||
|
const calculateSpeed = (
|
||||||
|
p: JobStatus,
|
||||||
|
currentBytesDownloaded?: number,
|
||||||
|
): number | undefined => {
|
||||||
|
// Prefer session-only deltas when available: lastSessionBytes + lastSessionUpdateTime
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (p.lastSessionUpdateTime && p.lastSessionBytes !== undefined) {
|
||||||
|
const last = new Date(p.lastSessionUpdateTime).getTime();
|
||||||
|
const deltaTime = (now - last) / 1000;
|
||||||
|
if (deltaTime > 0) {
|
||||||
|
const current =
|
||||||
|
currentBytesDownloaded ?? p.bytesDownloaded ?? p.lastSessionBytes;
|
||||||
|
const deltaBytes = current - p.lastSessionBytes;
|
||||||
|
if (deltaBytes > 0) return deltaBytes / deltaTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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[]>([]);
|
||||||
@@ -170,27 +193,96 @@ function useDownloadProvider() {
|
|||||||
|
|
||||||
const currentProcesses = [...processes, ...missingProcesses];
|
const currentProcesses = [...processes, ...missingProcesses];
|
||||||
const updatedProcesses = currentProcesses.map((p) => {
|
const updatedProcesses = currentProcesses.map((p) => {
|
||||||
// fallback. Doesn't really work for transcodes as they may be a lot smaller.
|
// Enhanced filtering to prevent iOS zombie task interference
|
||||||
// We make an wild guess by comparing bitrates
|
// Only update progress for downloads that are actively downloading
|
||||||
|
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) {
|
|
||||||
progress = (100 / estimatedSize) * task.bytesDownloaded;
|
// If we have a pausedProgress snapshot then merge current session
|
||||||
|
// 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;
|
||||||
});
|
});
|
||||||
@@ -209,7 +301,7 @@ function useDownloadProvider() {
|
|||||||
return db.movies[id];
|
return db.movies[id];
|
||||||
}
|
}
|
||||||
|
|
||||||
// If not in movies, check episodes
|
// 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)) {
|
||||||
@@ -220,6 +312,11 @@ function useDownloadProvider() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check other media types
|
||||||
|
if (db.other[id]) {
|
||||||
|
return db.other[id];
|
||||||
|
}
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -256,7 +353,7 @@ function useDownloadProvider() {
|
|||||||
if (file) {
|
if (file) {
|
||||||
return JSON.parse(file) as DownloadsDatabase;
|
return JSON.parse(file) as DownloadsDatabase;
|
||||||
}
|
}
|
||||||
return { movies: {}, series: {} };
|
return { movies: {}, series: {}, other: {} }; // Initialize other media types storage
|
||||||
};
|
};
|
||||||
|
|
||||||
const getDownloadedItems = () => {
|
const getDownloadedItems = () => {
|
||||||
@@ -268,6 +365,7 @@ function useDownloadProvider() {
|
|||||||
Object.values(season.episodes),
|
Object.values(season.episodes),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
...Object.values(db.other), // Include other media types in results
|
||||||
];
|
];
|
||||||
return allItems;
|
return allItems;
|
||||||
};
|
};
|
||||||
@@ -372,10 +470,76 @@ 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: 0,
|
progress: process.progress || 0, // Preserve existing progress for resume
|
||||||
});
|
});
|
||||||
|
|
||||||
BackGroundDownloader?.setConfig({
|
BackGroundDownloader?.setConfig({
|
||||||
@@ -396,21 +560,42 @@ function useDownloadProvider() {
|
|||||||
.begin(() => {
|
.begin(() => {
|
||||||
updateProcess(process.id, {
|
updateProcess(process.id, {
|
||||||
status: "downloading",
|
status: "downloading",
|
||||||
progress: 0,
|
progress: process.progress || 0,
|
||||||
bytesDownloaded: 0,
|
bytesDownloaded: process.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) => {
|
||||||
const percent = (data.bytesDownloaded / data.bytesTotal) * 100;
|
// If this is a resumed download, add the paused bytes to current session bytes
|
||||||
|
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, data.bytesDownloaded),
|
speed: calculateSpeed(currentProcess, totalBytes),
|
||||||
status: "downloading",
|
status: "downloading",
|
||||||
progress: percent,
|
progress: Math.min(percent, MAX_PROGRESS_BEFORE_COMPLETION),
|
||||||
bytesDownloaded: data.bytesDownloaded,
|
bytesDownloaded: totalBytes,
|
||||||
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),
|
||||||
@@ -479,6 +664,9 @@ 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);
|
||||||
|
|
||||||
@@ -542,7 +730,17 @@ 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) {
|
||||||
startDownload(queuedDownload);
|
// Reserve the slot immediately to avoid race where startDownload's
|
||||||
|
// 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]);
|
||||||
@@ -551,8 +749,38 @@ 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);
|
||||||
task?.stop();
|
if (task) {
|
||||||
BackGroundDownloader.completeHandler(id);
|
// On iOS, suspended tasks need to be cancelled properly
|
||||||
|
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();
|
||||||
},
|
},
|
||||||
@@ -575,7 +803,7 @@ function useDownloadProvider() {
|
|||||||
intermediates: true,
|
intermediates: true,
|
||||||
});
|
});
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
toast.error(t("Failed to clean cache directory."));
|
toast.error(t("home.downloads.toasts.failed_to_clean_cache_directory"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -611,9 +839,13 @@ function useDownloadProvider() {
|
|||||||
status: "queued",
|
status: "queued",
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
};
|
};
|
||||||
setProcesses((prev) => [...prev, job]);
|
setProcesses((prev) => {
|
||||||
|
// 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_stated_for_item", {
|
t("home.downloads.toasts.download_started_for_item", {
|
||||||
item: item.Name,
|
item: item.Name,
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
@@ -633,16 +865,16 @@ function useDownloadProvider() {
|
|||||||
[authHeader, startDownload],
|
[authHeader, startDownload],
|
||||||
);
|
);
|
||||||
|
|
||||||
const deleteFile = async (id: string, type: "Movie" | "Episode") => {
|
const deleteFile = async (id: string, type: BaseItemDto["Type"]) => {
|
||||||
const db = getDownloadsDatabase();
|
const db = getDownloadsDatabase();
|
||||||
let downloadedItem: DownloadedItem | undefined;
|
let downloadedItem: DownloadedItem | undefined;
|
||||||
|
|
||||||
if (type === "Movie") {
|
if (type === "Movie" && Object.entries(db.movies).length !== 0) {
|
||||||
downloadedItem = db.movies[id];
|
downloadedItem = db.movies[id];
|
||||||
if (downloadedItem) {
|
if (downloadedItem) {
|
||||||
delete db.movies[id];
|
delete db.movies[id];
|
||||||
}
|
}
|
||||||
} else if (type === "Episode") {
|
} else if (type === "Episode" && Object.entries(db.series).length !== 0) {
|
||||||
const cleanUpEmptyParents = (
|
const cleanUpEmptyParents = (
|
||||||
series: any,
|
series: any,
|
||||||
seasonNumber: string,
|
seasonNumber: string,
|
||||||
@@ -672,6 +904,12 @@ 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) {
|
||||||
@@ -705,7 +943,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 && (item.Type === "Movie" || item.Type === "Episode")) {
|
if (item.Id) {
|
||||||
await deleteFile(item.Id, item.Type);
|
await deleteFile(item.Id, item.Type);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -747,6 +985,8 @@ 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)) {
|
||||||
@@ -791,12 +1031,99 @@ 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");
|
||||||
|
|
||||||
task.pause();
|
// Get current progress before stopping
|
||||||
updateProcess(id, { status: "paused" });
|
const currentProgress = process.progress;
|
||||||
|
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],
|
||||||
);
|
);
|
||||||
@@ -806,38 +1133,79 @@ 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");
|
||||||
|
|
||||||
const tasks = await BackGroundDownloader.checkForExistingDownloads();
|
// TODO: iOS resume functionality temporarily disabled due to background task issues
|
||||||
const task = tasks?.find((t: any) => t.id === id);
|
// Remove this check to re-enable iOS resume functionality in the future
|
||||||
if (!task) throw new Error("No task found");
|
if (Platform.OS === "ios") {
|
||||||
|
|
||||||
// Check if task state allows resuming
|
|
||||||
if (task.state === "FAILED") {
|
|
||||||
console.warn(
|
console.warn(
|
||||||
"Download task failed, cannot resume. Restarting download.",
|
`[RESUME] Resume functionality temporarily disabled on iOS for ${id}`,
|
||||||
);
|
);
|
||||||
// For failed tasks, we need to restart rather than resume
|
throw new Error("Resume functionality is currently disabled on iOS");
|
||||||
await startDownload(process);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
console.log(
|
||||||
task.resume();
|
`[RESUME] Attempting to resume ${id}. Paused bytes: ${process.pausedBytes}, Progress: ${process.pausedProgress}%`,
|
||||||
updateProcess(id, { status: "downloading" });
|
);
|
||||||
} catch (error: any) {
|
|
||||||
// Handle specific ERROR_CANNOT_RESUME error
|
/*
|
||||||
if (
|
// TODO: Uncomment this block to re-enable iOS resume functionality
|
||||||
error?.error === "ERROR_CANNOT_RESUME" ||
|
// Enhanced cleanup for iOS based on GitHub issue research
|
||||||
error?.errorCode === 1008
|
if (Platform.OS === "ios") {
|
||||||
) {
|
try {
|
||||||
console.warn("Cannot resume download, attempting to restart instead");
|
// Clean up any lingering zombie tasks first (critical for iOS)
|
||||||
await startDownload(process);
|
const allTasks =
|
||||||
return; // Return early to prevent error from bubbling up
|
await BackGroundDownloader.checkForExistingDownloads();
|
||||||
} else {
|
const existingTasks = allTasks?.filter((t: any) => t.id === id) || [];
|
||||||
// Only log error for non-handled cases
|
|
||||||
console.error("Error resuming download:", error);
|
if (existingTasks.length > 0) {
|
||||||
throw error; // Re-throw other errors
|
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);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[processes, updateProcess, startDownload],
|
[processes, updateProcess, startDownload],
|
||||||
);
|
);
|
||||||
@@ -861,6 +1229,21 @@ 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,6 +88,8 @@ 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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -129,4 +131,14 @@ 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.35.1" },
|
clientInfo: { name: "Streamyfin", version: "0.39.0" },
|
||||||
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.35.1"`,
|
}, DeviceId="${deviceId}", Version="0.39.0"`,
|
||||||
};
|
};
|
||||||
}, [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[0] === "(auth)";
|
const inAuthGroup = segments.length > 1 && segments[0] === "(auth)";
|
||||||
|
|
||||||
if (!user?.Id && inAuthGroup) {
|
if (!user?.Id && inAuthGroup) {
|
||||||
console.log("Redirected to login");
|
console.log("Redirected to login");
|
||||||
|
|||||||
256
scripts/typecheck.js
Normal file
256
scripts/typecheck.js
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
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}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
500
translations/ar.json
Normal file
500
translations/ar.json
Normal file
@@ -0,0 +1,500 @@
|
|||||||
|
{
|
||||||
|
"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": "المفضلة"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,44 +1,49 @@
|
|||||||
{
|
{
|
||||||
"login": {
|
"login": {
|
||||||
"username_required": "Username is required",
|
"username_required": "Username Is Required",
|
||||||
"error_title": "Error",
|
"error_title": "Error",
|
||||||
"login_title": "Log in",
|
"login_title": "Log In",
|
||||||
"login_to_title": "Log in to",
|
"login_to_title": "Log in to",
|
||||||
"username_placeholder": "Username",
|
"username_placeholder": "Username",
|
||||||
"password_placeholder": "Password",
|
"password_placeholder": "Password",
|
||||||
"login_button": "Log in",
|
"login_button": "Log In",
|
||||||
"quick_connect": "Quick Connect",
|
"quick_connect": "Quick Connect",
|
||||||
"enter_code_to_login": "Enter code {{code}} to login",
|
"enter_code_to_login": "Enter code {{code}} to login",
|
||||||
"failed_to_initiate_quick_connect": "Failed to initiate Quick Connect",
|
"failed_to_initiate_quick_connect": "Failed to initiate Quick Connect",
|
||||||
"got_it": "Got it",
|
"got_it": "Got It",
|
||||||
"connection_failed": "Connection failed",
|
"connection_failed": "Connection Failed",
|
||||||
"could_not_connect_to_server": "Could not connect to the server. Please check the URL and your network connection.",
|
"could_not_connect_to_server": "Could not connect to the server. Please check the URL and your network connection.",
|
||||||
"an_unexpected_error_occured": "An unexpected error occurred",
|
"an_unexpected_error_occured": "An Unexpected Error Occurred",
|
||||||
"change_server": "Change server",
|
"change_server": "Change Server",
|
||||||
"invalid_username_or_password": "Invalid username or password",
|
"invalid_username_or_password": "Invalid Username or Password",
|
||||||
"user_does_not_have_permission_to_log_in": "User does not have permission to log in",
|
"user_does_not_have_permission_to_log_in": "User does not have permission to log in",
|
||||||
"server_is_taking_too_long_to_respond_try_again_later": "Server is taking too long to respond, try again later",
|
"server_is_taking_too_long_to_respond_try_again_later": "Server is taking too long to respond, try again later",
|
||||||
"server_received_too_many_requests_try_again_later": "Server received too many requests, try again later.",
|
"server_received_too_many_requests_try_again_later": "Server received too many requests, try again later.",
|
||||||
"there_is_a_server_error": "There is a server error",
|
"there_is_a_server_error": "There is a server error",
|
||||||
"an_unexpected_error_occured_did_you_enter_the_correct_url": "An unexpected error occurred. Did you enter the server URL correctly?",
|
"an_unexpected_error_occured_did_you_enter_the_correct_url": "An unexpected error occurred. Did you enter the server URL correctly?",
|
||||||
"too_old_server_text": "Unsupported jellyfin server discovered",
|
"too_old_server_text": "Unsupported Jellyfin Server Discovered",
|
||||||
"too_old_server_description": "Please update jellyfin to the latest version"
|
"too_old_server_description": "Please update Jellyfin to the latest version"
|
||||||
},
|
},
|
||||||
"server": {
|
"server": {
|
||||||
"enter_url_to_jellyfin_server": "Enter the URL to your Jellyfin server",
|
"enter_url_to_jellyfin_server": "Enter the URL to your Jellyfin server",
|
||||||
"server_url_placeholder": "http(s)://your-server.com",
|
"server_url_placeholder": "http(s)://your-server.com",
|
||||||
"connect_button": "Connect",
|
"connect_button": "Connect",
|
||||||
"previous_servers": "previous servers",
|
"previous_servers": "Previous Servers",
|
||||||
"clear_button": "Clear",
|
"clear_button": "Clear",
|
||||||
"search_for_local_servers": "Search for local servers",
|
"search_for_local_servers": "Search for Local Servers",
|
||||||
"searching": "Searching...",
|
"searching": "Searching...",
|
||||||
"servers": "Servers"
|
"servers": "Servers"
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
|
"checking_server_connection": "Checking server connection...",
|
||||||
"no_internet": "No Internet",
|
"no_internet": "No Internet",
|
||||||
"no_items": "No items",
|
"no_items": "No Items",
|
||||||
"no_internet_message": "No worries, you can still watch\ndownloaded content.",
|
"no_internet_message": "No worries, you can still watch\ndownloaded content.",
|
||||||
"go_to_downloads": "Go to downloads",
|
"checking_server_connection_message": "Checking connection to server",
|
||||||
|
"go_to_downloads": "Go to Downloads",
|
||||||
|
"retry": "Retry",
|
||||||
|
"server_unreachable": "Server Unreachable",
|
||||||
|
"server_unreachable_message": "Could not reach the server.\nPlease check your network connection.",
|
||||||
"oops": "Oops!",
|
"oops": "Oops!",
|
||||||
"error_message": "Something went wrong.\nPlease log out and in again.",
|
"error_message": "Something went wrong.\nPlease log out and in again.",
|
||||||
"continue_watching": "Continue Watching",
|
"continue_watching": "Continue Watching",
|
||||||
@@ -48,22 +53,22 @@
|
|||||||
"suggested_episodes": "Suggested Episodes",
|
"suggested_episodes": "Suggested Episodes",
|
||||||
"intro": {
|
"intro": {
|
||||||
"welcome_to_streamyfin": "Welcome to Streamyfin",
|
"welcome_to_streamyfin": "Welcome to Streamyfin",
|
||||||
"a_free_and_open_source_client_for_jellyfin": "A free and open-source client for Jellyfin.",
|
"a_free_and_open_source_client_for_jellyfin": "A Free and Open-Source Client for Jellyfin.",
|
||||||
"features_title": "Features",
|
"features_title": "Features",
|
||||||
"features_description": "Streamyfin has a bunch of features and integrates with a wide array of software which you can find in the settings menu, these include:",
|
"features_description": "Streamyfin has a bunch of features and integrates with a wide array of software which you can find in the settings menu, these include:",
|
||||||
"jellyseerr_feature_description": "Connect to your Jellyseerr instance and request movies directly in the app.",
|
"jellyseerr_feature_description": "Connect to your Seerr instance and request movies directly in the app.",
|
||||||
"downloads_feature_title": "Downloads",
|
"downloads_feature_title": "Downloads",
|
||||||
"downloads_feature_description": "Download movies and tv-shows to view offline. Use either the default method or install the optimize server to download files in the background.",
|
"downloads_feature_description": "Download movies and tv-shows to view offline. Use either the default method or install the optimize server to download files in the background.",
|
||||||
"chromecast_feature_description": "Cast movies and tv-shows to your Chromecast devices.",
|
"chromecast_feature_description": "Cast movies and tv-shows to your Chromecast devices.",
|
||||||
"centralised_settings_plugin_title": "Centralised Settings Plugin",
|
"centralised_settings_plugin_title": "Centralised Settings Plugin",
|
||||||
"centralised_settings_plugin_description": "Configure settings from a centralised location on your Jellyfin server. All client settings for all users will be synced automatically.",
|
"centralised_settings_plugin_description": "Configure settings from a centralised location on your Jellyfin server. All client settings for all users will be synced automatically.",
|
||||||
"done_button": "Done",
|
"done_button": "Done",
|
||||||
"go_to_settings_button": "Go to settings",
|
"go_to_settings_button": "Go to Settings",
|
||||||
"read_more": "Read more"
|
"read_more": "Read More"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"settings_title": "Settings",
|
"settings_title": "Settings",
|
||||||
"log_out_button": "Log out",
|
"log_out_button": "Log Out",
|
||||||
"user_info": {
|
"user_info": {
|
||||||
"user_info_title": "User Info",
|
"user_info_title": "User Info",
|
||||||
"user": "User",
|
"user": "User",
|
||||||
@@ -76,41 +81,41 @@
|
|||||||
"authorize_button": "Authorize Quick Connect",
|
"authorize_button": "Authorize Quick Connect",
|
||||||
"enter_the_quick_connect_code": "Enter the quick connect code...",
|
"enter_the_quick_connect_code": "Enter the quick connect code...",
|
||||||
"success": "Success",
|
"success": "Success",
|
||||||
"quick_connect_autorized": "Quick Connect authorized",
|
"quick_connect_autorized": "Quick Connect Authorized",
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
"invalid_code": "Invalid code",
|
"invalid_code": "Invalid Code",
|
||||||
"authorize": "Authorize"
|
"authorize": "Authorize"
|
||||||
},
|
},
|
||||||
"media_controls": {
|
"media_controls": {
|
||||||
"media_controls_title": "Media Controls",
|
"media_controls_title": "Media Controls",
|
||||||
"forward_skip_length": "Forward skip length",
|
"forward_skip_length": "Forward Skip Length",
|
||||||
"rewind_length": "Rewind length",
|
"rewind_length": "Rewind Length",
|
||||||
"seconds_unit": "s"
|
"seconds_unit": "s"
|
||||||
},
|
},
|
||||||
"gesture_controls": {
|
"gesture_controls": {
|
||||||
"gesture_controls_title": "Gesture Controls",
|
"gesture_controls_title": "Gesture Controls",
|
||||||
"horizontal_swipe_skip": "Horizontal swipe to skip",
|
"horizontal_swipe_skip": "Horizontal Swipe to Skip",
|
||||||
"horizontal_swipe_skip_description": "Swipe left/right when controls are hidden to skip",
|
"horizontal_swipe_skip_description": "Swipe left/right when controls are hidden to skip",
|
||||||
"left_side_brightness": "Left side brightness control",
|
"left_side_brightness": "Left Side Brightness Control",
|
||||||
"left_side_brightness_description": "Swipe up/down on left side to adjust brightness",
|
"left_side_brightness_description": "Swipe up/down on left side to adjust brightness",
|
||||||
"right_side_volume": "Right side volume control",
|
"right_side_volume": "Right Side Volume Control",
|
||||||
"right_side_volume_description": "Swipe up/down on right side to adjust volume"
|
"right_side_volume_description": "Swipe up/down on right side to adjust volume"
|
||||||
},
|
},
|
||||||
"audio": {
|
"audio": {
|
||||||
"audio_title": "Audio",
|
"audio_title": "Audio",
|
||||||
"set_audio_track": "Set Audio Track From Previous Item",
|
"set_audio_track": "Set Audio Track From Previous Item",
|
||||||
"audio_language": "Audio language",
|
"audio_language": "Audio Language",
|
||||||
"audio_hint": "Choose a default audio language.",
|
"audio_hint": "Choose a default audio language.",
|
||||||
"none": "None",
|
"none": "None",
|
||||||
"language": "Language"
|
"language": "Language"
|
||||||
},
|
},
|
||||||
"subtitles": {
|
"subtitles": {
|
||||||
"subtitle_title": "Subtitles",
|
"subtitle_title": "Subtitles",
|
||||||
|
"subtitle_hint": "Configure how subtitles look and behave.",
|
||||||
"subtitle_language": "Subtitle language",
|
"subtitle_language": "Subtitle language",
|
||||||
"subtitle_mode": "Subtitle Mode",
|
"subtitle_mode": "Subtitle Mode",
|
||||||
"set_subtitle_track": "Set Subtitle Track From Previous Item",
|
"set_subtitle_track": "Set Subtitle Track From Previous Item",
|
||||||
"subtitle_size": "Subtitle Size",
|
"subtitle_size": "Subtitle Size",
|
||||||
"subtitle_hint": "Configure subtitle preference.",
|
|
||||||
"none": "None",
|
"none": "None",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
"loading": "Loading",
|
"loading": "Loading",
|
||||||
@@ -120,12 +125,43 @@
|
|||||||
"Always": "Always",
|
"Always": "Always",
|
||||||
"None": "None",
|
"None": "None",
|
||||||
"OnlyForced": "OnlyForced"
|
"OnlyForced": "OnlyForced"
|
||||||
|
},
|
||||||
|
"text_color": "Text Color",
|
||||||
|
"background_color": "Background Color",
|
||||||
|
"outline_color": "Outline Color",
|
||||||
|
"outline_thickness": "Outline Thickness",
|
||||||
|
"background_opacity": "Background Opacity",
|
||||||
|
"outline_opacity": "Outline Opacity",
|
||||||
|
"bold_text": "Bold Text",
|
||||||
|
"colors": {
|
||||||
|
"Black": "Black",
|
||||||
|
"Gray": "Gray",
|
||||||
|
"Silver": "Silver",
|
||||||
|
"White": "White",
|
||||||
|
"Maroon": "Maroon",
|
||||||
|
"Red": "Red",
|
||||||
|
"Fuchsia": "Fuchsia",
|
||||||
|
"Yellow": "Yellow",
|
||||||
|
"Olive": "Olive",
|
||||||
|
"Green": "Green",
|
||||||
|
"Teal": "Teal",
|
||||||
|
"Lime": "Lime",
|
||||||
|
"Purple": "Purple",
|
||||||
|
"Navy": "Navy",
|
||||||
|
"Blue": "Blue",
|
||||||
|
"Aqua": "Aqua"
|
||||||
|
},
|
||||||
|
"thickness": {
|
||||||
|
"None": "None",
|
||||||
|
"Thin": "Thin",
|
||||||
|
"Normal": "Normal",
|
||||||
|
"Thick": "Thick"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"other": {
|
"other": {
|
||||||
"other_title": "Other",
|
"other_title": "Other",
|
||||||
"follow_device_orientation": "Auto rotate",
|
"follow_device_orientation": "Auto Rotate",
|
||||||
"video_orientation": "Video orientation",
|
"video_orientation": "Video Orientation",
|
||||||
"orientation": "Orientation",
|
"orientation": "Orientation",
|
||||||
"orientations": {
|
"orientations": {
|
||||||
"DEFAULT": "Default",
|
"DEFAULT": "Default",
|
||||||
@@ -139,8 +175,8 @@
|
|||||||
"OTHER": "Other",
|
"OTHER": "Other",
|
||||||
"UNKNOWN": "Unknown"
|
"UNKNOWN": "Unknown"
|
||||||
},
|
},
|
||||||
"safe_area_in_controls": "Safe area in controls",
|
"safe_area_in_controls": "Safe Area in Controls",
|
||||||
"video_player": "Video player",
|
"video_player": "Video Player",
|
||||||
"video_players": {
|
"video_players": {
|
||||||
"VLC_3": "VLC 3",
|
"VLC_3": "VLC 3",
|
||||||
"VLC_4": "VLC 4 (Experimental + PiP)"
|
"VLC_4": "VLC 4 (Experimental + PiP)"
|
||||||
@@ -149,24 +185,13 @@
|
|||||||
"hide_libraries": "Hide Libraries",
|
"hide_libraries": "Hide Libraries",
|
||||||
"select_liraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.",
|
"select_liraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.",
|
||||||
"disable_haptic_feedback": "Disable Haptic Feedback",
|
"disable_haptic_feedback": "Disable Haptic Feedback",
|
||||||
"default_quality": "Default quality",
|
"default_quality": "Default Quality",
|
||||||
"max_auto_play_episode_count": "Max auto play episode count",
|
"max_auto_play_episode_count": "Max Auto Play Episode Count",
|
||||||
"disabled": "Disabled"
|
"disabled": "Disabled"
|
||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "Downloads",
|
"downloads_title": "Downloads",
|
||||||
"download_method": "Download method",
|
"remux_max_download": "Remux Max Download"
|
||||||
"remux_max_download": "Remux max download",
|
|
||||||
"auto_download": "Auto download",
|
|
||||||
"optimized_versions_server": "Optimized versions server",
|
|
||||||
"save_button": "Save",
|
|
||||||
"optimized_server": "Optimized Server",
|
|
||||||
"optimized": "Optimized",
|
|
||||||
"default": "Default",
|
|
||||||
"optimized_version_hint": "Enter the URL for the optimize server. The URL should include http or https and optionally the port.",
|
|
||||||
"read_more_about_optimized_server": "Read more about the optimize server.",
|
|
||||||
"url": "URL",
|
|
||||||
"server_url_placeholder": "http(s)://domain.org:port"
|
|
||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"plugins_title": "Plugins",
|
"plugins_title": "Plugins",
|
||||||
@@ -174,20 +199,18 @@
|
|||||||
"jellyseerr_warning": "This integration is in its early stages. Expect things to change.",
|
"jellyseerr_warning": "This integration is in its early stages. Expect things to change.",
|
||||||
"server_url": "Server URL",
|
"server_url": "Server URL",
|
||||||
"server_url_hint": "Example: http(s)://your-host.url\n(add port if required)",
|
"server_url_hint": "Example: http(s)://your-host.url\n(add port if required)",
|
||||||
"server_url_placeholder": "Jellyseerr URL...",
|
"server_url_placeholder": "Seerr URL",
|
||||||
"password": "Password",
|
"password": "Password",
|
||||||
"password_placeholder": "Enter password for Jellyfin user {{username}}",
|
"password_placeholder": "Enter password for Jellyfin user {{username}}",
|
||||||
"save_button": "Save",
|
|
||||||
"clear_button": "Clear",
|
|
||||||
"login_button": "Login",
|
"login_button": "Login",
|
||||||
"total_media_requests": "Total media requests",
|
"total_media_requests": "Total Media Requests",
|
||||||
"movie_quota_limit": "Movie quota limit",
|
"movie_quota_limit": "Movie Quota Limit",
|
||||||
"movie_quota_days": "Movie quota days",
|
"movie_quota_days": "Movie Quota Days",
|
||||||
"tv_quota_limit": "TV quota limit",
|
"tv_quota_limit": "TV Quota Limit",
|
||||||
"tv_quota_days": "TV quota days",
|
"tv_quota_days": "TV Quota Days",
|
||||||
"reset_jellyseerr_config_button": "Reset Jellyseerr config",
|
"reset_jellyseerr_config_button": "Reset Seerr Config",
|
||||||
"unlimited": "Unlimited",
|
"unlimited": "Unlimited",
|
||||||
"plus_n_more": "+{{n}} more",
|
"plus_n_more": "+{{n}} More",
|
||||||
"order_by": {
|
"order_by": {
|
||||||
"DEFAULT": "Default",
|
"DEFAULT": "Default",
|
||||||
"VOTE_COUNT_AND_AVERAGE": "Vote count and average",
|
"VOTE_COUNT_AND_AVERAGE": "Vote count and average",
|
||||||
@@ -199,7 +222,7 @@
|
|||||||
"url": "URL",
|
"url": "URL",
|
||||||
"server_url_placeholder": "http(s)://domain.org:port",
|
"server_url_placeholder": "http(s)://domain.org:port",
|
||||||
"marlin_search_hint": "Enter the URL for the Marlin server. The URL should include http or https and optionally the port.",
|
"marlin_search_hint": "Enter the URL for the Marlin server. The URL should include http or https and optionally the port.",
|
||||||
"read_more_about_marlin": "Read more about Marlin.",
|
"read_more_about_marlin": "Read More About Marlin.",
|
||||||
"save_button": "Save",
|
"save_button": "Save",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"saved": "Saved"
|
"saved": "Saved"
|
||||||
@@ -210,100 +233,100 @@
|
|||||||
"storage_title": "Storage",
|
"storage_title": "Storage",
|
||||||
"app_usage": "App {{usedSpace}}%",
|
"app_usage": "App {{usedSpace}}%",
|
||||||
"device_usage": "Device {{availableSpace}}%",
|
"device_usage": "Device {{availableSpace}}%",
|
||||||
"size_used": "{{used}} of {{total}} used",
|
"size_used": "{{used}} of {{total}} Used",
|
||||||
"delete_all_downloaded_files": "Delete All Downloaded Files"
|
"delete_all_downloaded_files": "Delete All Downloaded Files"
|
||||||
},
|
},
|
||||||
"intro": {
|
"intro": {
|
||||||
"show_intro": "Show intro",
|
"show_intro": "Show Intro",
|
||||||
"reset_intro": "Reset intro"
|
"reset_intro": "Reset Intro"
|
||||||
},
|
},
|
||||||
"logs": {
|
"logs": {
|
||||||
"logs_title": "Logs",
|
"logs_title": "Logs",
|
||||||
"export_logs": "Export logs",
|
"export_logs": "Export Logs",
|
||||||
"click_for_more_info": "Click for more info",
|
"click_for_more_info": "Click for More Info",
|
||||||
"level": "Level",
|
"level": "Level",
|
||||||
"no_logs_available": "No logs available",
|
"no_logs_available": "No Logs Available",
|
||||||
"delete_all_logs": "Delete all logs"
|
"delete_all_logs": "Delete All Logs"
|
||||||
},
|
},
|
||||||
"languages": {
|
"languages": {
|
||||||
"title": "Languages",
|
"title": "Languages",
|
||||||
"app_language": "App language",
|
"app_language": "App Language",
|
||||||
"app_language_description": "Select the language for the app.",
|
|
||||||
"system": "System"
|
"system": "System"
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"error_deleting_files": "Error deleting files",
|
"error_deleting_files": "Error Deleting Files",
|
||||||
"background_downloads_enabled": "Background downloads enabled",
|
"background_downloads_enabled": "Background downloads enabled",
|
||||||
"background_downloads_disabled": "Background downloads disabled",
|
"background_downloads_disabled": "Background downloads disabled"
|
||||||
"connected": "Connected",
|
|
||||||
"could_not_connect": "Could not connect",
|
|
||||||
"invalid_url": "Invalid URL"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sessions": {
|
"sessions": {
|
||||||
"title": "Sessions",
|
"title": "Sessions",
|
||||||
"no_active_sessions": "No active sessions"
|
"no_active_sessions": "No Active Sessions"
|
||||||
},
|
},
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"downloads_title": "Downloads",
|
"downloads_title": "Downloads",
|
||||||
"tvseries": "TV-Series",
|
"tvseries": "TV-Series",
|
||||||
"movies": "Movies",
|
"movies": "Movies",
|
||||||
"queue": "Queue",
|
"queue": "Queue",
|
||||||
|
"other_media": "Other media",
|
||||||
"queue_hint": "Queue and downloads will be lost on app restart",
|
"queue_hint": "Queue and downloads will be lost on app restart",
|
||||||
"no_items_in_queue": "No items in queue",
|
"no_items_in_queue": "No Items in Queue",
|
||||||
"no_downloaded_items": "No downloaded items",
|
"no_downloaded_items": "No Downloaded Items",
|
||||||
"delete_all_movies_button": "Delete all Movies",
|
"delete_all_movies_button": "Delete All Movies",
|
||||||
"delete_all_tvseries_button": "Delete all TV-Series",
|
"delete_all_tvseries_button": "Delete All TV-Series",
|
||||||
"delete_all_button": "Delete all",
|
"delete_all_button": "Delete All",
|
||||||
"active_download": "Active download",
|
"delete_all_other_media_button": "Delete other media",
|
||||||
"no_active_downloads": "No active downloads",
|
"active_download": "Active Download",
|
||||||
"active_downloads": "Active downloads",
|
"no_active_downloads": "No Active Downloads",
|
||||||
|
"active_downloads": "Active Downloads",
|
||||||
"new_app_version_requires_re_download": "New app version requires re-download",
|
"new_app_version_requires_re_download": "New app version requires re-download",
|
||||||
"new_app_version_requires_re_download_description": "The new update requires content to be downloaded again. Please remove all downloaded content and try again.",
|
"new_app_version_requires_re_download_description": "The new update requires content to be downloaded again. Please remove all downloaded content and try again.",
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"something_went_wrong": "Something went wrong",
|
"something_went_wrong": "Something Went Wrong",
|
||||||
"could_not_get_stream_url_from_jellyfin": "Could not get the stream URL from Jellyfin",
|
"could_not_get_stream_url_from_jellyfin": "Could not get the stream URL from Jellyfin",
|
||||||
"eta": "ETA {{eta}}",
|
"eta": "ETA {{eta}}",
|
||||||
"methods": "Methods",
|
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"you_are_not_allowed_to_download_files": "You are not allowed to download files.",
|
"you_are_not_allowed_to_download_files": "You are not allowed to download files.",
|
||||||
"deleted_all_movies_successfully": "Deleted all movies successfully!",
|
"deleted_all_movies_successfully": "Deleted All Movies Successfully!",
|
||||||
"failed_to_delete_all_movies": "Failed to delete all movies",
|
"failed_to_delete_all_movies": "Failed to Delete All Movies",
|
||||||
"deleted_all_tvseries_successfully": "Deleted all TV-Series successfully!",
|
"deleted_all_tvseries_successfully": "Deleted All TV-Series Successfully!",
|
||||||
"failed_to_delete_all_tvseries": "Failed to delete all TV-Series",
|
"failed_to_delete_all_tvseries": "Failed to Delete All TV-Series",
|
||||||
"download_deleted": "Download deleted",
|
"deleted_media_successfully": "Deleted other media Successfully!",
|
||||||
"could_not_delete_download": "Could not delete download",
|
"failed_to_delete_media": "Failed to Delete other media",
|
||||||
"download_paused": "Download paused",
|
"download_deleted": "Download Deleted",
|
||||||
"could_not_pause_download": "Could not pause download",
|
"could_not_delete_download": "Could Not Delete Download",
|
||||||
"download_resumed": "Download resumed",
|
"download_paused": "Download Paused",
|
||||||
"could_not_resume_download": "Could not resume download",
|
"could_not_pause_download": "Could Not Pause Download",
|
||||||
"download_completed": "Download completed",
|
"download_resumed": "Download Resumed",
|
||||||
"download_started_for": "Download started for {{item}}",
|
"could_not_resume_download": "Could Not Resume Download",
|
||||||
"item_is_ready_to_be_downloaded": "{{item}} is ready to be downloaded",
|
"download_completed": "Download Completed",
|
||||||
"download_stated_for_item": "Download started for {{item}}",
|
|
||||||
"download_failed_for_item": "Download failed for {{item}} - {{error}}",
|
"download_failed_for_item": "Download failed for {{item}} - {{error}}",
|
||||||
"download_completed_for_item": "Download completed for {{item}}",
|
"download_completed_for_item": "Download Completed for {{item}}",
|
||||||
"queued_item_for_optimization": "Queued {{item}} for optimization",
|
"download_started_for_item": "Download Started for {{item}}",
|
||||||
"failed_to_start_download_for_item": "Failed to start downloading for {{item}}: {{message}}",
|
"failed_to_start_download": "Failed to start download",
|
||||||
"server_responded_with_status_code": "Server responded with status {{statusCode}}",
|
|
||||||
"no_response_received_from_server": "No response received from the server",
|
|
||||||
"error_setting_up_the_request": "Error setting up the request",
|
|
||||||
"failed_to_start_download_for_item_unexpected_error": "Failed to start downloading for {{item}}: Unexpected error",
|
|
||||||
"all_files_folders_and_jobs_deleted_successfully": "All files, folders, and jobs deleted successfully",
|
"all_files_folders_and_jobs_deleted_successfully": "All files, folders, and jobs deleted successfully",
|
||||||
"an_error_occured_while_deleting_files_and_jobs": "An error occurred while deleting files and jobs",
|
"failed_to_clean_cache_directory": "Failed to clean cache directory",
|
||||||
"go_to_downloads": "Go to downloads"
|
"could_not_get_download_url_for_item": "Could not get download URL for {{itemName}}",
|
||||||
|
"go_to_downloads": "Go to Downloads"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"common": {
|
||||||
|
"select": "Select",
|
||||||
|
"no_trailer_available": "No trailer available",
|
||||||
|
"video": "Video",
|
||||||
|
"audio": "Audio",
|
||||||
|
"subtitle": "Subtitle",
|
||||||
|
"play": "Play"
|
||||||
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"search_here": "Search here...",
|
|
||||||
"search": "Search...",
|
"search": "Search...",
|
||||||
"x_items": "{{count}} items",
|
"x_items": "{{count}} Items",
|
||||||
"library": "Library",
|
"library": "Library",
|
||||||
"discover": "Discover",
|
"discover": "Discover",
|
||||||
"no_results": "No results",
|
"no_results": "No Results",
|
||||||
"no_results_found_for": "No results found for",
|
"no_results_found_for": "No Results Found For",
|
||||||
"movies": "Movies",
|
"movies": "Movies",
|
||||||
"series": "Series",
|
"series": "Series",
|
||||||
"episodes": "Episodes",
|
"episodes": "Episodes",
|
||||||
@@ -334,32 +357,29 @@
|
|||||||
"tmdb_tv_streaming_services": "TMDB TV Streaming Services"
|
"tmdb_tv_streaming_services": "TMDB TV Streaming Services"
|
||||||
},
|
},
|
||||||
"library": {
|
"library": {
|
||||||
"no_items_found": "No items found",
|
"no_results": "No Results",
|
||||||
"no_results": "No results",
|
"no_libraries_found": "No Libraries Found",
|
||||||
"no_libraries_found": "No libraries found",
|
|
||||||
"item_types": {
|
"item_types": {
|
||||||
"movies": "movies",
|
"movies": "Movies",
|
||||||
"series": "series",
|
"series": "Series",
|
||||||
"boxsets": "box sets",
|
"boxsets": "Box Sets",
|
||||||
"items": "items"
|
"items": "Items"
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
"display": "Display",
|
"display": "Display",
|
||||||
"row": "Row",
|
"row": "Row",
|
||||||
"list": "List",
|
"list": "List",
|
||||||
"image_style": "Image style",
|
"image_style": "Image Style",
|
||||||
"poster": "Poster",
|
"poster": "Poster",
|
||||||
"cover": "Cover",
|
"cover": "Cover",
|
||||||
"show_titles": "Show titles",
|
"show_titles": "Show Titles",
|
||||||
"show_stats": "Show stats"
|
"show_stats": "Show Stats"
|
||||||
},
|
},
|
||||||
"filters": {
|
"filters": {
|
||||||
"genres": "Genres",
|
"genres": "Genres",
|
||||||
"years": "Years",
|
"years": "Years",
|
||||||
"sort_by": "Sort By",
|
"sort_by": "Sort By",
|
||||||
"sort_order": "Sort Order",
|
"sort_order": "Sort Order",
|
||||||
"asc": "Ascending",
|
|
||||||
"desc": "Descending",
|
|
||||||
"tags": "Tags"
|
"tags": "Tags"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -368,36 +388,32 @@
|
|||||||
"movies": "Movies",
|
"movies": "Movies",
|
||||||
"episodes": "Episodes",
|
"episodes": "Episodes",
|
||||||
"videos": "Videos",
|
"videos": "Videos",
|
||||||
"boxsets": "Boxsets",
|
"boxsets": "Box Sets",
|
||||||
"playlists": "Playlists",
|
"playlists": "Playlists",
|
||||||
"noDataTitle": "No favorites yet",
|
"noDataTitle": "No Favorites Yet",
|
||||||
"noData": "Mark items as favorites to see them appear here for quick access."
|
"noData": "Mark items as favorites to see them appear here for quick access."
|
||||||
},
|
},
|
||||||
"custom_links": {
|
"custom_links": {
|
||||||
"no_links": "No links"
|
"no_links": "No Links"
|
||||||
},
|
},
|
||||||
"player": {
|
"player": {
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
"failed_to_get_stream_url": "Failed to get the stream URL",
|
"failed_to_get_stream_url": "Failed to get the stream URL",
|
||||||
"an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
|
"an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
|
||||||
"client_error": "Client error",
|
"client_error": "Client Error",
|
||||||
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
|
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
|
||||||
"message_from_server": "Message from server: {{message}}",
|
"message_from_server": "Message from Server: {{message}}",
|
||||||
"video_has_finished_playing": "Video has finished playing!",
|
|
||||||
"no_video_source": "No video source...",
|
|
||||||
"next_episode": "Next Episode",
|
"next_episode": "Next Episode",
|
||||||
"refresh_tracks": "Refresh Tracks",
|
"refresh_tracks": "Refresh Tracks",
|
||||||
"subtitle_tracks": "Subtitle Tracks:",
|
|
||||||
"audio_tracks": "Audio Tracks:",
|
"audio_tracks": "Audio Tracks:",
|
||||||
"playback_state": "Playback State:",
|
"playback_state": "Playback State:",
|
||||||
"no_data_available": "No data available",
|
|
||||||
"index": "Index:",
|
"index": "Index:",
|
||||||
"continue_watching": "Continue Watching",
|
"continue_watching": "Continue Watching",
|
||||||
"go_back": "Go back"
|
"go_back": "Go Back"
|
||||||
},
|
},
|
||||||
"item_card": {
|
"item_card": {
|
||||||
"next_up": "Next up",
|
"next_up": "Next Up",
|
||||||
"no_items_to_display": "No items to display",
|
"no_items_to_display": "No Items to Display",
|
||||||
"cast_and_crew": "Cast & Crew",
|
"cast_and_crew": "Cast & Crew",
|
||||||
"series": "Series",
|
"series": "Series",
|
||||||
"seasons": "Seasons",
|
"seasons": "Seasons",
|
||||||
@@ -405,36 +421,33 @@
|
|||||||
"no_episodes_for_this_season": "No episodes for this season",
|
"no_episodes_for_this_season": "No episodes for this season",
|
||||||
"overview": "Overview",
|
"overview": "Overview",
|
||||||
"more_with": "More with {{name}}",
|
"more_with": "More with {{name}}",
|
||||||
"similar_items": "Similar items",
|
"similar_items": "Similar Items",
|
||||||
"no_similar_items_found": "No similar items found",
|
"no_similar_items_found": "No Similar Items Found",
|
||||||
"video": "Video",
|
"video": "Video",
|
||||||
"more_details": "More details",
|
"more_details": "More Details",
|
||||||
"quality": "Quality",
|
"quality": "Quality",
|
||||||
"audio": "Audio",
|
"audio": "Audio",
|
||||||
"subtitles": "Subtitle",
|
"subtitles": "Subtitle",
|
||||||
"show_more": "Show more",
|
"show_more": "Show More",
|
||||||
"show_less": "Show less",
|
"show_less": "Show Less",
|
||||||
"appeared_in": "Appeared in",
|
"appeared_in": "Appeared In",
|
||||||
"could_not_load_item": "Could not load item",
|
"could_not_load_item": "Could Not Load Item",
|
||||||
"none": "None",
|
"none": "None",
|
||||||
"download": {
|
"download": {
|
||||||
"download_season": "Download Season",
|
"download_season": "Download Season",
|
||||||
"download_series": "Download Series",
|
"download_series": "Download Series",
|
||||||
"download_episode": "Download Episode",
|
"download_episode": "Download Episode",
|
||||||
"download_movie": "Download Movie",
|
"download_movie": "Download Movie",
|
||||||
"download_x_item": "Download {{item_count}} items",
|
"download_x_item": "Download {{item_count}} Items",
|
||||||
"download_unwatched_only": "Unwatched Only",
|
"download_unwatched_only": "Unwatched Only",
|
||||||
"download_button": "Download",
|
"download_button": "Download"
|
||||||
"using_optimized_server": "Using optimized server",
|
|
||||||
"using_default_method": "Using default method"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"live_tv": {
|
"live_tv": {
|
||||||
"next": "Next",
|
"next": "Next",
|
||||||
"previous": "Previous",
|
"previous": "Previous",
|
||||||
"live_tv": "Live TV",
|
"coming_soon": "Coming Soon",
|
||||||
"coming_soon": "Coming soon",
|
"on_now": "On Now",
|
||||||
"on_now": "On now",
|
|
||||||
"shows": "Shows",
|
"shows": "Shows",
|
||||||
"movies": "Movies",
|
"movies": "Movies",
|
||||||
"sports": "Sports",
|
"sports": "Sports",
|
||||||
@@ -445,16 +458,16 @@
|
|||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"yes": "Yes",
|
"yes": "Yes",
|
||||||
"whats_wrong": "What's wrong?",
|
"whats_wrong": "What's Wrong?",
|
||||||
"issue_type": "Issue type",
|
"issue_type": "Issue Type",
|
||||||
"select_an_issue": "Select an issue",
|
"select_an_issue": "Select an Issue",
|
||||||
"types": "Types",
|
"types": "Types",
|
||||||
"describe_the_issue": "(optional) Describe the issue...",
|
"describe_the_issue": "(Optional) Describe the Issue...",
|
||||||
"submit_button": "Submit",
|
"submit_button": "Submit",
|
||||||
"report_issue_button": "Report issue",
|
"report_issue_button": "Report Issue",
|
||||||
"request_button": "Request",
|
"request_button": "Request",
|
||||||
"are_you_sure_you_want_to_request_all_seasons": "Are you sure you want to request all seasons?",
|
"are_you_sure_you_want_to_request_all_seasons": "Are you sure you want to request all seasons?",
|
||||||
"failed_to_login": "Failed to login",
|
"failed_to_login": "Failed to Login",
|
||||||
"cast": "Cast",
|
"cast": "Cast",
|
||||||
"details": "Details",
|
"details": "Details",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
@@ -469,22 +482,22 @@
|
|||||||
"production_country": "Production Country",
|
"production_country": "Production Country",
|
||||||
"studios": "Studios",
|
"studios": "Studios",
|
||||||
"network": "Network",
|
"network": "Network",
|
||||||
"currently_streaming_on": "Currently Streaming on",
|
"currently_streaming_on": "Currently Streaming On",
|
||||||
"advanced": "Advanced",
|
"advanced": "Advanced",
|
||||||
"request_as": "Request As",
|
"request_as": "Request As",
|
||||||
"tags": "Tags",
|
"tags": "Tags",
|
||||||
"quality_profile": "Quality Profile",
|
"quality_profile": "Quality Profile",
|
||||||
"root_folder": "Root Folder",
|
"root_folder": "Root Folder",
|
||||||
"season_all": "Season (all)",
|
"season_all": "Season (All)",
|
||||||
"season_number": "Season {{season_number}}",
|
"season_number": "Season {{season_number}}",
|
||||||
"number_episodes": "{{episode_number}} Episodes",
|
"number_episodes": "{{episode_number}} Episodes",
|
||||||
"born": "Born",
|
"born": "Born",
|
||||||
"appearances": "Appearances",
|
"appearances": "Appearances",
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"jellyseer_does_not_meet_requirements": "Jellyseerr server does not meet minimum version requirements! Please update to at least 2.0.0",
|
"jellyseer_does_not_meet_requirements": "Seerr server does not meet minimum version requirements! Please update to at least 2.0.0",
|
||||||
"jellyseerr_test_failed": "Jellyseerr test failed. Please try again.",
|
"jellyseerr_test_failed": "Seerr test failed. Please try again.",
|
||||||
"failed_to_test_jellyseerr_server_url": "Failed to test jellyseerr server url",
|
"failed_to_test_jellyseerr_server_url": "Failed to test Seerr server url",
|
||||||
"issue_submitted": "Issue submitted!",
|
"issue_submitted": "Issue Submitted!",
|
||||||
"requested_item": "Requested {{item}}!",
|
"requested_item": "Requested {{item}}!",
|
||||||
"you_dont_have_permission_to_request": "You don't have permission to request!",
|
"you_dont_have_permission_to_request": "You don't have permission to request!",
|
||||||
"something_went_wrong_requesting_media": "Something went wrong requesting media!"
|
"something_went_wrong_requesting_media": "Something went wrong requesting media!"
|
||||||
|
|||||||
464
translations/hu.json
Normal file
464
translations/hu.json
Normal file
@@ -0,0 +1,464 @@
|
|||||||
|
{
|
||||||
|
"login": {
|
||||||
|
"username_required": "A felhasználónév megadása kötelező",
|
||||||
|
"error_title": "Hiba",
|
||||||
|
"login_title": "Bejelentkezés",
|
||||||
|
"login_to_title": "Bejelentkezés ide",
|
||||||
|
"username_placeholder": "Felhasználónév",
|
||||||
|
"password_placeholder": "Jelszó",
|
||||||
|
"login_button": "Bejelentkezés",
|
||||||
|
"quick_connect": "Gyorscsatlakozás",
|
||||||
|
"enter_code_to_login": "Írd be a {{code}} kódot a bejelentkezéshez",
|
||||||
|
"failed_to_initiate_quick_connect": "A Gyorscsatlakozás kezdeményezése sikertelen.",
|
||||||
|
"got_it": "Értettem",
|
||||||
|
"connection_failed": "Kapcsolódás Sikertelen",
|
||||||
|
"could_not_connect_to_server": "Nem sikerült csatlakozni a szerverhez. Kérjük, ellenőrizd az URL-t és a hálózati kapcsolatot.",
|
||||||
|
"an_unexpected_error_occured": "Váratlan Hiba Történt",
|
||||||
|
"change_server": "Szerverváltás",
|
||||||
|
"invalid_username_or_password": "Érvénytelen Felhasználónév vagy Jelszó",
|
||||||
|
"user_does_not_have_permission_to_log_in": "A felhasználónak nincs jogosultsága a bejelentkezéshez",
|
||||||
|
"server_is_taking_too_long_to_respond_try_again_later": "A szerver túl sokáig válaszol, próbáld újra később",
|
||||||
|
"server_received_too_many_requests_try_again_later": "A szerver túl sok kérést kapott, próbáld újra később.",
|
||||||
|
"there_is_a_server_error": "Szerverhiba történt",
|
||||||
|
"an_unexpected_error_occured_did_you_enter_the_correct_url": "Váratlan hiba történt. Helyesen adtad meg a szerver URL-jét?",
|
||||||
|
"too_old_server_text": "Nem Támogatott Jellyfin-szerver",
|
||||||
|
"too_old_server_description": "Frissítsd a Jellyfint a legújabb verzióra"
|
||||||
|
},
|
||||||
|
"server": {
|
||||||
|
"enter_url_to_jellyfin_server": "Add meg a Jellyfin szerver URL-jét",
|
||||||
|
"server_url_placeholder": "http(s)://a-te-szervered.hu",
|
||||||
|
"connect_button": "Csatlakozás",
|
||||||
|
"previous_servers": "Előző Szerverek",
|
||||||
|
"clear_button": "Törlés",
|
||||||
|
"search_for_local_servers": "Helyi Szerverek Keresése",
|
||||||
|
"searching": "Keresés...",
|
||||||
|
"servers": "Szerverek"
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"checking_server_connection": "Szerverkapcsolat ellenőrzése...",
|
||||||
|
"no_internet": "Nincs Internet",
|
||||||
|
"no_items": "Nincsenek elemek",
|
||||||
|
"no_internet_message": "Semmi gond, továbbra is nézheted\na letöltött tartalmakat.",
|
||||||
|
"checking_server_connection_message": "Kapcsolat ellenőrzése a szerverrel",
|
||||||
|
"go_to_downloads": "Ugrás a Letöltésekhez",
|
||||||
|
"retry": "Újra",
|
||||||
|
"server_unreachable": "Szerver Elérhetetlen",
|
||||||
|
"server_unreachable_message": "Nem sikerült elérni a szervert.\nKérjük, ellenőrizd a hálózati kapcsolatot.",
|
||||||
|
"oops": "Hoppá!",
|
||||||
|
"error_message": "Valami nem stimmel.\nKérjük, jelentkezz ki, majd újra be.",
|
||||||
|
"continue_watching": "Nézd Tovább",
|
||||||
|
"next_up": "Következő",
|
||||||
|
"recently_added_in": "Új a(z) {{libraryName}} könyvtárban",
|
||||||
|
"suggested_movies": "Javasolt Filmek",
|
||||||
|
"suggested_episodes": "Javasolt Epizódok",
|
||||||
|
"intro": {
|
||||||
|
"welcome_to_streamyfin": "Üdvözöljük a Streamyfinben",
|
||||||
|
"a_free_and_open_source_client_for_jellyfin": "Egy Ingyenes és Nyílt Forráskódú Jellyfin Kliens.",
|
||||||
|
"features_title": "Funkciók",
|
||||||
|
"features_description": "A Streamyfin számos funkcióval rendelkezik és sokféle szoftverrel integrálható, melyeket a beállítások menüben találhatsz:",
|
||||||
|
"jellyseerr_feature_description": "Csatlakozz a Jellyseerrhez és kérj filmeket közvetlenül az alkalmazásból.",
|
||||||
|
"downloads_feature_title": "Letöltések",
|
||||||
|
"downloads_feature_description": "Töltsd le a filmeket és sorozatokat az offline megtekintéshez. Használhatod az alapértelmezett módszert, vagy telepítheted az 'optimise server'-t a háttérben történő letöltéshez.",
|
||||||
|
"chromecast_feature_description": "Játszd le a filmeket és sorozatokat a Chromecast eszközeiden.",
|
||||||
|
"centralised_settings_plugin_title": "Központosított Beállítások Bővítmény",
|
||||||
|
"centralised_settings_plugin_description": "Konfiguráld a beállításaidat központilag a Jellyfin szerveren. Minden felhasználói kliensbeállítás automatikusan szinkronizálódik.",
|
||||||
|
"done_button": "Kész",
|
||||||
|
"go_to_settings_button": "Ugrás a Beállításokhoz",
|
||||||
|
"read_more": "Bővebben"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"settings_title": "Beállítások",
|
||||||
|
"log_out_button": "Kijelentkezés",
|
||||||
|
"user_info": {
|
||||||
|
"user_info_title": "Felhasználói Információk",
|
||||||
|
"user": "Felhasználó",
|
||||||
|
"server": "Szerver",
|
||||||
|
"token": "Token",
|
||||||
|
"app_version": "Alkalmazásverzió"
|
||||||
|
},
|
||||||
|
"quick_connect": {
|
||||||
|
"quick_connect_title": "Gyorscsatlakozás",
|
||||||
|
"authorize_button": "Gyorscsatlakozás Engedélyezése",
|
||||||
|
"enter_the_quick_connect_code": "Add meg a gyors csatlakozási kódot...",
|
||||||
|
"success": "Siker",
|
||||||
|
"quick_connect_autorized": "Gyorscsatlakozás Engedélyezve",
|
||||||
|
"error": "Hiba",
|
||||||
|
"invalid_code": "Érvénytelen Kód",
|
||||||
|
"authorize": "Engedélyezés"
|
||||||
|
},
|
||||||
|
"media_controls": {
|
||||||
|
"media_controls_title": "Médiavezérlés",
|
||||||
|
"forward_skip_length": "Előre Ugrás Hossza",
|
||||||
|
"rewind_length": "Visszatekerés Hossza",
|
||||||
|
"seconds_unit": "mp"
|
||||||
|
},
|
||||||
|
"gesture_controls": {
|
||||||
|
"gesture_controls_title": "Gesztusvezérlés",
|
||||||
|
"horizontal_swipe_skip": "Vízszintes Húzás Ugráshoz",
|
||||||
|
"horizontal_swipe_skip_description": "Ha a vezérlők el vannak rejtve, húzd balra vagy jobbra az ugráshoz.",
|
||||||
|
"left_side_brightness": "Fényerő a Bal Oldalon",
|
||||||
|
"left_side_brightness_description": "Húzd felfelé vagy lefelé a bal oldalon a fényerő állításához",
|
||||||
|
"right_side_volume": "Fényerő a Jobb Oldalon",
|
||||||
|
"right_side_volume_description": "Húzd felfelé vagy lefelé a jobb oldalon a hangerő állításához"
|
||||||
|
},
|
||||||
|
"audio": {
|
||||||
|
"audio_title": "Hang",
|
||||||
|
"set_audio_track": "Hangsáv Beállítása az Előző Elemből",
|
||||||
|
"audio_language": "Hangsáv Nyelve",
|
||||||
|
"audio_hint": "Válassz Alapértelmezett Hangsávnyelvet.",
|
||||||
|
"none": "Nincs",
|
||||||
|
"language": "Nyelv"
|
||||||
|
},
|
||||||
|
"subtitles": {
|
||||||
|
"subtitle_title": "Feliratok",
|
||||||
|
"subtitle_language": "Felirat Nyelve",
|
||||||
|
"subtitle_mode": "Felirat Módja",
|
||||||
|
"set_subtitle_track": "Feliratsáv Beállítása az Előző Elemből",
|
||||||
|
"subtitle_size": "Felirat Mérete",
|
||||||
|
"subtitle_hint": "Feliratbeállítások Megadása",
|
||||||
|
"none": "Nincs",
|
||||||
|
"language": "Nyelv",
|
||||||
|
"loading": "Betöltés",
|
||||||
|
"modes": {
|
||||||
|
"Default": "Alapértelmezett",
|
||||||
|
"Smart": "Intelligens",
|
||||||
|
"Always": "Mindig",
|
||||||
|
"None": "Nincs",
|
||||||
|
"OnlyForced": "Csak Kényszerített"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"other": {
|
||||||
|
"other_title": "Egyéb",
|
||||||
|
"follow_device_orientation": "Automatikus Forgatás",
|
||||||
|
"video_orientation": "Videó Tájolás",
|
||||||
|
"orientation": "Tájolás",
|
||||||
|
"orientations": {
|
||||||
|
"DEFAULT": "Alapértelmezett",
|
||||||
|
"ALL": "Összes",
|
||||||
|
"PORTRAIT": "Álló",
|
||||||
|
"PORTRAIT_UP": "Álló Felfelé",
|
||||||
|
"PORTRAIT_DOWN": "Álló Lefelé",
|
||||||
|
"LANDSCAPE": "Fekvő",
|
||||||
|
"LANDSCAPE_LEFT": "Fekvő Balra",
|
||||||
|
"LANDSCAPE_RIGHT": "Fekvő Jobbra",
|
||||||
|
"OTHER": "Egyéb",
|
||||||
|
"UNKNOWN": "Ismeretlen"
|
||||||
|
},
|
||||||
|
"safe_area_in_controls": "Biztonsági Sáv a Vezérlőkben",
|
||||||
|
"video_player": "Videólejátszó",
|
||||||
|
"video_players": {
|
||||||
|
"VLC_3": "VLC 3",
|
||||||
|
"VLC_4": "VLC 4 (Kísérleti + PiP)"
|
||||||
|
},
|
||||||
|
"show_custom_menu_links": "Egyéni Menülinkek Megjelenítése",
|
||||||
|
"hide_libraries": "Könyvtárak Elrejtése",
|
||||||
|
"select_liraries_you_want_to_hide": "Válaszd ki azokat a könyvtárakat, amelyeket el szeretnél rejteni a Könyvtár fülön és a kezdőlapon.",
|
||||||
|
"disable_haptic_feedback": "Haptikus Visszajelzés Letiltása",
|
||||||
|
"default_quality": "Alapértelmezett Minőség",
|
||||||
|
"max_auto_play_episode_count": "Max. Auto. Epizódlejátszás",
|
||||||
|
"disabled": "Letiltva"
|
||||||
|
},
|
||||||
|
"downloads": {
|
||||||
|
"downloads_title": "Letöltések",
|
||||||
|
"remux_max_download": "Remux Maximális Letöltés"
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"plugins_title": "Bővítmények",
|
||||||
|
"jellyseerr": {
|
||||||
|
"jellyseerr_warning": "Ez az integráció még korai stádiumban van. Számíts a változásokra.",
|
||||||
|
"server_url": "Szerver URL",
|
||||||
|
"server_url_hint": "Példa: http(s)://a-te-szolgáltatód.url\n(adj meg portot, ha szükséges)",
|
||||||
|
"server_url_placeholder": "Jellyseerr URL...",
|
||||||
|
"password": "Jelszó",
|
||||||
|
"password_placeholder": "Add meg a {{username}} Jellyfin felhasználó jelszavát",
|
||||||
|
"login_button": "Bejelentkezés",
|
||||||
|
"total_media_requests": "Összes Média Kérés",
|
||||||
|
"movie_quota_limit": "Film Kvóta Limit",
|
||||||
|
"movie_quota_days": "Film Kvóta Napok",
|
||||||
|
"tv_quota_limit": "Sorozat Kvóta Limit",
|
||||||
|
"tv_quota_days": "Sorozat Kvóta Napok",
|
||||||
|
"reset_jellyseerr_config_button": "Jellyseerr Beállítások Visszaállítása",
|
||||||
|
"unlimited": "Korlátlan",
|
||||||
|
"plus_n_more": "+{{n}} További",
|
||||||
|
"order_by": {
|
||||||
|
"DEFAULT": "Alapértelmezett",
|
||||||
|
"VOTE_COUNT_AND_AVERAGE": "Szavazatok Száma és Átlag",
|
||||||
|
"POPULARITY": "Népszerűség"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"marlin_search": {
|
||||||
|
"enable_marlin_search": "Marlin Keresés Engedélyezése",
|
||||||
|
"url": "URL",
|
||||||
|
"server_url_placeholder": "http(s)://domain.org:port",
|
||||||
|
"marlin_search_hint": "Add meg a Marlin szerver URL-jét. Az URL-nek tartalmaznia kell a http vagy https-t, és opcionálisan a portot.",
|
||||||
|
"read_more_about_marlin": "Tudj Meg Többet a Marlinról",
|
||||||
|
"save_button": "Mentés",
|
||||||
|
"toasts": {
|
||||||
|
"saved": "Mentve"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"storage": {
|
||||||
|
"storage_title": "Tárhely",
|
||||||
|
"app_usage": "Alkalmazás {{usedSpace}}%",
|
||||||
|
"device_usage": "Eszköz {{availableSpace}}%",
|
||||||
|
"size_used": "{{used}} / {{total}} Használatban",
|
||||||
|
"delete_all_downloaded_files": "Minden Letöltött Fájl Törlése"
|
||||||
|
},
|
||||||
|
"intro": {
|
||||||
|
"show_intro": "Bemutató Megjelenítése",
|
||||||
|
"reset_intro": "Bemutató Visszaállítása"
|
||||||
|
},
|
||||||
|
"logs": {
|
||||||
|
"logs_title": "Naplók",
|
||||||
|
"export_logs": "Naplók Exportálása",
|
||||||
|
"click_for_more_info": "Kattints a Részletekért",
|
||||||
|
"level": "Szint",
|
||||||
|
"no_logs_available": "Nincsenek Naplók",
|
||||||
|
"delete_all_logs": "Összes Napló Törlése"
|
||||||
|
},
|
||||||
|
"languages": {
|
||||||
|
"title": "Nyelvek",
|
||||||
|
"app_language": "Alkalmazás Nyelve",
|
||||||
|
"system": "Rendszer"
|
||||||
|
},
|
||||||
|
"toasts": {
|
||||||
|
"error_deleting_files": "Hiba a Fájlok Törlésekor"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sessions": {
|
||||||
|
"title": "Munkamenetek",
|
||||||
|
"no_active_sessions": "Nincsenek Aktív Munkamenetek"
|
||||||
|
},
|
||||||
|
"downloads": {
|
||||||
|
"downloads_title": "Letöltések",
|
||||||
|
"tvseries": "Sorozatok",
|
||||||
|
"movies": "Filmek",
|
||||||
|
"queue": "Sor",
|
||||||
|
"queue_hint": "A sor és a letöltések az alkalmazás újraindításakor elvesznek",
|
||||||
|
"no_items_in_queue": "Nincs Elem a Sorban",
|
||||||
|
"no_downloaded_items": "Nincsenek Letöltött Elemek",
|
||||||
|
"delete_all_movies_button": "Összes Film Törlése",
|
||||||
|
"delete_all_tvseries_button": "Összes Sorozat Törlése",
|
||||||
|
"delete_all_button": "Összes Törlése",
|
||||||
|
"active_download": "Aktív Letöltés",
|
||||||
|
"no_active_downloads": "Nincs Aktív Letöltés",
|
||||||
|
"active_downloads": "Aktív Letöltések",
|
||||||
|
"new_app_version_requires_re_download": "Az Új Alkalmazásverzió Újra Letöltést Igényel",
|
||||||
|
"new_app_version_requires_re_download_description": "Az új frissítéshez az összes tartalmat újra le kell tölteni. Kérjük, töröld az összes letöltött tartalmat, majd próbáld újra.",
|
||||||
|
"back": "Vissza",
|
||||||
|
"delete": "Törlés",
|
||||||
|
"something_went_wrong": "Hiba Történt",
|
||||||
|
"could_not_get_stream_url_from_jellyfin": "Nem sikerült lekérni a stream URL-t a Jellyfinből",
|
||||||
|
"eta": "Várható Idő: {{eta}}",
|
||||||
|
"toasts": {
|
||||||
|
"you_are_not_allowed_to_download_files": "Nem engedélyezett a fájlok letöltése.",
|
||||||
|
"deleted_all_movies_successfully": "Az Összes Film Sikeresen Törölve!",
|
||||||
|
"failed_to_delete_all_movies": "Nem Sikerült Törölni Az Összes Filmet",
|
||||||
|
"deleted_all_tvseries_successfully": "Az Összes Sorozat Sikeresen Törölve!",
|
||||||
|
"failed_to_delete_all_tvseries": "Nem Sikerült Törölni Az Összes Sorozatot",
|
||||||
|
"download_deleted": "Letöltés Törölve",
|
||||||
|
"could_not_delete_download": "Nem Sikerült Törölni a Letöltést",
|
||||||
|
"download_paused": "Letöltés Szüneteltetve",
|
||||||
|
"could_not_pause_download": "Nem Sikerült Szüneteltetni a Letöltést",
|
||||||
|
"download_resumed": "Letöltés Folytatva",
|
||||||
|
"could_not_resume_download": "Nem Sikerült Folytatni a Letöltést",
|
||||||
|
"download_completed": "Letöltés Befejezve",
|
||||||
|
"download_failed_for_item": "A(z) {{item}} letöltése sikertelen - {{error}}",
|
||||||
|
"download_completed_for_item": "A(z) {{item}} letöltése befejezve",
|
||||||
|
"all_files_folders_and_jobs_deleted_successfully": "Minden fájl, mappa és feladat sikeresen törölve",
|
||||||
|
"go_to_downloads": "Ugrás a Letöltésekhez"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"search": {
|
||||||
|
"search": "Keresés...",
|
||||||
|
"x_items": "{{count}} Elem",
|
||||||
|
"library": "Könyvtár",
|
||||||
|
"discover": "Felfedezés",
|
||||||
|
"no_results": "Nincs Eredmény",
|
||||||
|
"no_results_found_for": "Nincs Eredmény a Kereséshez",
|
||||||
|
"movies": "Filmek",
|
||||||
|
"series": "Sorozatok",
|
||||||
|
"episodes": "Epizódok",
|
||||||
|
"collections": "Gyűjtemények",
|
||||||
|
"actors": "Színészek",
|
||||||
|
"request_movies": "Filmek Kérése",
|
||||||
|
"request_series": "Sorozatok Kérése",
|
||||||
|
"recently_added": "Legutóbb Hozzáadva",
|
||||||
|
"recent_requests": "Legutóbbi Kérések",
|
||||||
|
"plex_watchlist": "Plex Watchlist",
|
||||||
|
"trending": "Népszerű",
|
||||||
|
"popular_movies": "Népszerű Filmek",
|
||||||
|
"movie_genres": "Film Műfajok",
|
||||||
|
"upcoming_movies": "Hamarosan Megjelenő Filmek",
|
||||||
|
"studios": "Stúdiók",
|
||||||
|
"popular_tv": "Népszerű Sorozatok",
|
||||||
|
"tv_genres": "Sorozat Műfajok",
|
||||||
|
"upcoming_tv": "Hamarosan Megjelenő Sorozatok",
|
||||||
|
"networks": "Csatornák",
|
||||||
|
"tmdb_movie_keyword": "TMDB Film Kulcsszó",
|
||||||
|
"tmdb_movie_genre": "TMDB Film Műfaj",
|
||||||
|
"tmdb_tv_keyword": "TMDB Sorozat Kulcsszó",
|
||||||
|
"tmdb_tv_genre": "TMDB Sorozat Műfaj",
|
||||||
|
"tmdb_search": "TMDB Keresés",
|
||||||
|
"tmdb_studio": "TMDB Stúdió",
|
||||||
|
"tmdb_network": "TMDB Csatorna",
|
||||||
|
"tmdb_movie_streaming_services": "TMDB Film Streaming Szolgáltatások",
|
||||||
|
"tmdb_tv_streaming_services": "TMDB Sorozat Streaming Szolgáltatások"
|
||||||
|
},
|
||||||
|
"library": {
|
||||||
|
"no_results": "Nincs Eredmény",
|
||||||
|
"no_libraries_found": "Nem Található Könyvtár",
|
||||||
|
"item_types": {
|
||||||
|
"movies": "Filmek",
|
||||||
|
"series": "Sorozatok",
|
||||||
|
"boxsets": "Gyűjtemények",
|
||||||
|
"items": "Elemek"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"display": "Megjelenítés",
|
||||||
|
"row": "Sor",
|
||||||
|
"list": "Lista",
|
||||||
|
"image_style": "Kép Stílusa",
|
||||||
|
"poster": "Poszter",
|
||||||
|
"cover": "Borító",
|
||||||
|
"show_titles": "Címek Megjelenítése",
|
||||||
|
"show_stats": "Statisztikák Megjelenítése"
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"genres": "Műfajok",
|
||||||
|
"years": "Évek",
|
||||||
|
"sort_by": "Rendezés",
|
||||||
|
"sort_order": "Rendezés Iránya",
|
||||||
|
"tags": "Címkék"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"favorites": {
|
||||||
|
"series": "Sorozatok",
|
||||||
|
"movies": "Filmek",
|
||||||
|
"episodes": "Epizódok",
|
||||||
|
"videos": "Videók",
|
||||||
|
"boxsets": "Gyűjtemények",
|
||||||
|
"playlists": "Lejátszási Listák",
|
||||||
|
"noDataTitle": "Még Nincsenek Kedvencek",
|
||||||
|
"noData": "Jelölj meg elemeket kedvencként, hogy itt gyorsan elérd őket."
|
||||||
|
},
|
||||||
|
"custom_links": {
|
||||||
|
"no_links": "Nincsenek Linkek"
|
||||||
|
},
|
||||||
|
"player": {
|
||||||
|
"error": "Hiba",
|
||||||
|
"failed_to_get_stream_url": "Nem sikerült lekérni a stream URL-t",
|
||||||
|
"an_error_occured_while_playing_the_video": "Hiba történt a videó lejátszása közben. Ellenőrizd a naplókat a beállításokban.",
|
||||||
|
"client_error": "Kliens Hiba",
|
||||||
|
"could_not_create_stream_for_chromecast": "A Chromecast stream létrehozása sikertelen volt",
|
||||||
|
"message_from_server": "Üzenet a szervertől: {{message}}",
|
||||||
|
"next_episode": "Következő Epizód",
|
||||||
|
"refresh_tracks": "Sávok Frissítése",
|
||||||
|
"audio_tracks": "Hangsávok:",
|
||||||
|
"playback_state": "Lejátszás Állapota:",
|
||||||
|
"index": "Index:",
|
||||||
|
"continue_watching": "Folytatás",
|
||||||
|
"go_back": "Vissza"
|
||||||
|
},
|
||||||
|
"item_card": {
|
||||||
|
"next_up": "Következő",
|
||||||
|
"no_items_to_display": "Nincs Megjeleníthető Elem",
|
||||||
|
"cast_and_crew": "Szereplők & Stáb",
|
||||||
|
"series": "Sorozat",
|
||||||
|
"seasons": "Évadok",
|
||||||
|
"season": "Évad",
|
||||||
|
"no_episodes_for_this_season": "Ehhez az évadhoz nincs epizód",
|
||||||
|
"overview": "Áttekintés",
|
||||||
|
"more_with": "További {{name}} Alkotások",
|
||||||
|
"similar_items": "Hasonló Elemek",
|
||||||
|
"no_similar_items_found": "Nincs Hasonló Elem",
|
||||||
|
"video": "Videó",
|
||||||
|
"more_details": "További Részletek",
|
||||||
|
"quality": "Minőség",
|
||||||
|
"audio": "Hang",
|
||||||
|
"subtitles": "Felirat",
|
||||||
|
"show_more": "Több Megjelenítése",
|
||||||
|
"show_less": "Kevesebb Megjelenítése",
|
||||||
|
"appeared_in": "Megjelent:",
|
||||||
|
"could_not_load_item": "Nem Sikerült Betölteni az Elemet",
|
||||||
|
"none": "Nincs",
|
||||||
|
"download": {
|
||||||
|
"download_season": "Évad Letöltése",
|
||||||
|
"download_series": "Sorozat Letöltése",
|
||||||
|
"download_episode": "Epizód Letöltése",
|
||||||
|
"download_movie": "Film Letöltése",
|
||||||
|
"download_x_item": "{{item_count}} Elem Letöltése",
|
||||||
|
"download_unwatched_only": "Csak Nem Megtekintett",
|
||||||
|
"download_button": "Letöltés"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"live_tv": {
|
||||||
|
"next": "Következő",
|
||||||
|
"previous": "Előző",
|
||||||
|
"coming_soon": "Hamarosan",
|
||||||
|
"on_now": "Most Műsoron",
|
||||||
|
"shows": "Sorozatok",
|
||||||
|
"movies": "Filmek",
|
||||||
|
"sports": "Sport",
|
||||||
|
"for_kids": "Gyerekeknek",
|
||||||
|
"news": "Hírek"
|
||||||
|
},
|
||||||
|
"jellyseerr": {
|
||||||
|
"confirm": "Megerősítés",
|
||||||
|
"cancel": "Mégse",
|
||||||
|
"yes": "Igen",
|
||||||
|
"whats_wrong": "Mi a Probléma?",
|
||||||
|
"issue_type": "Probléma Típusa",
|
||||||
|
"select_an_issue": "Válassz Problémát",
|
||||||
|
"types": "Típusok",
|
||||||
|
"describe_the_issue": "(Opcionális) Fejtsd ki a problémát...",
|
||||||
|
"submit_button": "Beküldés",
|
||||||
|
"report_issue_button": "Probléma Jelentése",
|
||||||
|
"request_button": "Kérés",
|
||||||
|
"are_you_sure_you_want_to_request_all_seasons": "Biztosan az összes évadot kéred?",
|
||||||
|
"failed_to_login": "Sikertelen Bejelentkezés",
|
||||||
|
"cast": "Szereplők",
|
||||||
|
"details": "Részletek",
|
||||||
|
"status": "Állapot",
|
||||||
|
"original_title": "Eredeti Cím",
|
||||||
|
"series_type": "Sorozat Típusa",
|
||||||
|
"release_dates": "Megjelenési Dátumok",
|
||||||
|
"first_air_date": "Első Vetítés Dátuma",
|
||||||
|
"next_air_date": "Következő Adás Dátuma",
|
||||||
|
"revenue": "Bevétel",
|
||||||
|
"budget": "Költségvetés",
|
||||||
|
"original_language": "Eredeti Nyelv",
|
||||||
|
"production_country": "Gyártási Ország",
|
||||||
|
"studios": "Stúdiók",
|
||||||
|
"network": "Csatorna",
|
||||||
|
"currently_streaming_on": "Jelenleg Elérhető:",
|
||||||
|
"advanced": "Haladó",
|
||||||
|
"request_as": "Kérés Más Felhasználóként",
|
||||||
|
"tags": "Címkék",
|
||||||
|
"quality_profile": "Minőségi Profil",
|
||||||
|
"root_folder": "Gyökérmappa",
|
||||||
|
"season_all": "Évad (Összes)",
|
||||||
|
"season_number": "Évad {{season_number}}",
|
||||||
|
"number_episodes": "{{episode_number}} Epizód",
|
||||||
|
"born": "Született",
|
||||||
|
"appearances": "Megjelenések",
|
||||||
|
"toasts": {
|
||||||
|
"jellyseer_does_not_meet_requirements": "A Jellyseerr szerver nem felel meg a minimum verziókövetelményeknek! Kérlek frissítsd legalább 2.0.0-ra.",
|
||||||
|
"jellyseerr_test_failed": "A Jellyseerr teszt sikertelen. Próbáld újra.",
|
||||||
|
"failed_to_test_jellyseerr_server_url": "Nem sikerült tesztelni a Jellyseerr szerver URL-jét",
|
||||||
|
"issue_submitted": "Probléma Beküldve!",
|
||||||
|
"requested_item": "{{item}} Kérése Sikeres!",
|
||||||
|
"you_dont_have_permission_to_request": "Nincs jogosultságod a kéréshez!",
|
||||||
|
"something_went_wrong_requesting_media": "Hiba történt a média kérés közben!"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tabs": {
|
||||||
|
"home": "Kezdőlap",
|
||||||
|
"search": "Keresés",
|
||||||
|
"library": "Könyvtár",
|
||||||
|
"custom_links": "Egyéni Linkek",
|
||||||
|
"favorites": "Kedvencek"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,17 @@
|
|||||||
"jsxImportSource": "react",
|
"jsxImportSource": "react",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./*"]
|
"@/*": ["./*"]
|
||||||
}
|
},
|
||||||
|
"incremental": true,
|
||||||
|
"tsBuildInfoFile": ".tsbuildinfo",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"app/**/*",
|
"app/**/*",
|
||||||
|
|||||||
@@ -88,10 +88,12 @@ export type Home = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type HomeSection = {
|
export type HomeSection = {
|
||||||
|
title?: string;
|
||||||
orientation?: "horizontal" | "vertical";
|
orientation?: "horizontal" | "vertical";
|
||||||
items?: HomeSectionItemResolver;
|
items?: HomeSectionItemResolver;
|
||||||
nextUp?: HomeSectionNextUpResolver;
|
nextUp?: HomeSectionNextUpResolver;
|
||||||
latest?: HomeSectionLatestResolver;
|
latest?: HomeSectionLatestResolver;
|
||||||
|
custom?: HomeSectionCustomEndpointResolver;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type HomeSectionItemResolver = {
|
export type HomeSectionItemResolver = {
|
||||||
@@ -105,6 +107,13 @@ export type HomeSectionItemResolver = {
|
|||||||
filters?: Array<ItemFilter>;
|
filters?: Array<ItemFilter>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type HomeSectionCustomEndpointResolver = {
|
||||||
|
title?: string;
|
||||||
|
endpoint: string;
|
||||||
|
headers?: any;
|
||||||
|
query?: any;
|
||||||
|
};
|
||||||
|
|
||||||
export type HomeSectionNextUpResolver = {
|
export type HomeSectionNextUpResolver = {
|
||||||
parentId?: string;
|
parentId?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
@@ -167,6 +176,13 @@ export type Settings = {
|
|||||||
defaultPlayer: VideoPlayer;
|
defaultPlayer: VideoPlayer;
|
||||||
maxAutoPlayEpisodeCount: MaxAutoPlayEpisodeCount;
|
maxAutoPlayEpisodeCount: MaxAutoPlayEpisodeCount;
|
||||||
autoPlayEpisodeCount: number;
|
autoPlayEpisodeCount: number;
|
||||||
|
vlcTextColor?: string;
|
||||||
|
vlcBackgroundColor?: string;
|
||||||
|
vlcOutlineColor?: string;
|
||||||
|
vlcOutlineThickness?: string;
|
||||||
|
vlcBackgroundOpacity?: number;
|
||||||
|
vlcOutlineOpacity?: number;
|
||||||
|
vlcIsBold?: boolean;
|
||||||
// Gesture controls
|
// Gesture controls
|
||||||
enableHorizontalSwipeSkip: boolean;
|
enableHorizontalSwipeSkip: boolean;
|
||||||
enableLeftSideBrightnessSwipe: boolean;
|
enableLeftSideBrightnessSwipe: boolean;
|
||||||
@@ -228,6 +244,13 @@ export const defaultValues: Settings = {
|
|||||||
defaultPlayer: VideoPlayer.VLC_3, // ios-only setting. does not matter what this is for android
|
defaultPlayer: VideoPlayer.VLC_3, // ios-only setting. does not matter what this is for android
|
||||||
maxAutoPlayEpisodeCount: { key: "3", value: 3 },
|
maxAutoPlayEpisodeCount: { key: "3", value: 3 },
|
||||||
autoPlayEpisodeCount: 0,
|
autoPlayEpisodeCount: 0,
|
||||||
|
vlcTextColor: undefined,
|
||||||
|
vlcBackgroundColor: undefined,
|
||||||
|
vlcOutlineColor: undefined,
|
||||||
|
vlcOutlineThickness: undefined,
|
||||||
|
vlcBackgroundOpacity: undefined,
|
||||||
|
vlcOutlineOpacity: undefined,
|
||||||
|
vlcIsBold: undefined,
|
||||||
// Gesture controls
|
// Gesture controls
|
||||||
enableHorizontalSwipeSkip: true,
|
enableHorizontalSwipeSkip: true,
|
||||||
enableLeftSideBrightnessSwipe: true,
|
enableLeftSideBrightnessSwipe: true,
|
||||||
|
|||||||
Submodule utils/jellyseerr updated: 4401b16414...fc6a9e952c
@@ -77,6 +77,17 @@ export const clearLogs = () => {
|
|||||||
storage.delete("logs");
|
storage.delete("logs");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const dumpDownloadDiagnostics = (extra: any = {}) => {
|
||||||
|
const diagnostics = {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
processes: extra?.processes || [],
|
||||||
|
nativeTasks: extra?.nativeTasks || [],
|
||||||
|
focusedProcess: extra?.focusedProcess || null,
|
||||||
|
};
|
||||||
|
writeDebugLog("Download diagnostics", diagnostics);
|
||||||
|
return diagnostics;
|
||||||
|
};
|
||||||
|
|
||||||
export function useLog() {
|
export function useLog() {
|
||||||
const context = useContext(LogContext);
|
const context = useContext(LogContext);
|
||||||
if (context === null) {
|
if (context === null) {
|
||||||
|
|||||||
Reference in New Issue
Block a user