mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-16 16:18:09 +00:00
Compare commits
136 Commits
copilot/fi
...
codeql-fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c0ed076d5 | ||
|
|
118c24ee05 | ||
|
|
61d60c2e74 | ||
|
|
6c722e1fd9 | ||
|
|
e117e98c8e | ||
|
|
f5c791cce4 | ||
|
|
eaebb79f4c | ||
|
|
7e82d9c7b6 | ||
|
|
c62c8ca077 | ||
|
|
25cfcf0735 | ||
|
|
f19b33eff6 | ||
|
|
1c4f54584f | ||
|
|
87d02078c8 | ||
|
|
1e9fba6f85 | ||
|
|
dbcf5759da | ||
|
|
37364b235f | ||
|
|
fcb8e83845 | ||
|
|
16046a4a15 | ||
|
|
13b29bdee0 | ||
|
|
bf4a1f9462 | ||
|
|
264439858d | ||
|
|
6576a58884 | ||
|
|
3eae2f3fde | ||
|
|
deae6ab21a | ||
|
|
5478179367 | ||
|
|
3e20050b64 | ||
|
|
59e9913c78 | ||
|
|
2b2797005a | ||
|
|
c53acb16fc | ||
|
|
53570a5ee5 | ||
|
|
e3b7dd8241 | ||
|
|
786d082706 | ||
|
|
164de0af0d | ||
|
|
820b30b7e2 | ||
|
|
5bc4c4a856 | ||
|
|
f7e0667416 | ||
|
|
8c68283c56 | ||
|
|
bb0149406c | ||
|
|
b1d5630025 | ||
|
|
a5f5531bb9 | ||
|
|
c2a3817fa8 | ||
|
|
700bb2dc79 | ||
|
|
d741ca3ecc | ||
|
|
ae9f6b1ce4 | ||
|
|
cd3f1a8cee | ||
|
|
be745dc136 | ||
|
|
7b6fe0a6c0 | ||
|
|
fc44283f09 | ||
|
|
b2f6edc54e | ||
|
|
b42d033b87 | ||
|
|
1fb166bcd1 | ||
|
|
de6133581b | ||
|
|
08c7382191 | ||
|
|
d7b4e01aa5 | ||
|
|
2f2099e243 | ||
|
|
79b4a0869a | ||
|
|
dececc477f | ||
|
|
8d7416ae1c | ||
|
|
e116d861f6 | ||
|
|
4a28352b53 | ||
|
|
b372c353c0 | ||
|
|
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 | ||
|
|
569b143eba | ||
|
|
ead37aa806 | ||
|
|
d250295e36 | ||
|
|
28f6729ae2 | ||
|
|
2a195d9ba5 | ||
|
|
577774c355 | ||
|
|
74c83c4f00 | ||
|
|
97d9309855 | ||
|
|
e03cefce47 | ||
|
|
91c4df1585 | ||
|
|
388342147e | ||
|
|
9b367fd8c2 | ||
|
|
2d4d3f5b1b | ||
|
|
388f65b443 | ||
|
|
621d164402 | ||
|
|
77895983b0 | ||
|
|
fa8af5bc11 | ||
|
|
1feb22854c | ||
|
|
09b3cc7835 | ||
|
|
9175c6a135 | ||
|
|
7436ad90eb | ||
|
|
fb2bdb0e2c | ||
|
|
0fd2d766a3 | ||
|
|
bdc0962d60 | ||
|
|
2a49b766e7 | ||
|
|
b02f49fbd2 | ||
|
|
0b0592a699 | ||
|
|
ceafdbf9ee | ||
|
|
2b77d0fefb | ||
|
|
32094fbc9f | ||
|
|
b5917821a0 | ||
|
|
42922cc92b | ||
|
|
0298fb00aa | ||
|
|
e47c863aa4 | ||
|
|
0b9bbb63eb | ||
|
|
b4014c922e | ||
|
|
957e60714a | ||
|
|
93a63f6b48 | ||
|
|
c778956a52 | ||
|
|
6308375438 | ||
|
|
2d3344f013 | ||
|
|
ae720d6bb4 | ||
|
|
62b25d7bf7 | ||
|
|
68e3b74e49 | ||
|
|
e8c9bb1730 | ||
|
|
ae09a59569 | ||
|
|
8a6c6dbd69 |
@@ -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.
|
|
||||||
33
.github/CONTRIBUTING.md
vendored
33
.github/CONTRIBUTING.md
vendored
@@ -1,11 +1,13 @@
|
|||||||
# Contributing to Streamyfin
|
# Contributing to Streamyfin
|
||||||
|
|
||||||
Thank you for your interest in contributing to the Streamyfin mobile app project! This document provides guidelines to smoothly collaborate on the Streamyfin codebase and help improve the app for all users.
|
Thank you for your interest in contributing to the Streamyfin project. This document outlines the guidelines for effective collaboration across the Streamyfin codebase and aims to ensure a smooth, productive experience for all contributors.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
|
|
||||||
|
- [AI Assistance Disclosure](#ai-assistance-disclosure)
|
||||||
- [Reporting Issues](#reporting-issues)
|
- [Reporting Issues](#reporting-issues)
|
||||||
- [Reporting Security Vulnerabilities](#reporting-security-vulnerabilities)
|
- [Reporting Security Vulnerabilities](#reporting-security-vulnerabilities)
|
||||||
- [Requesting Features & Enhancements](#requesting-features--enhancements)
|
- [Requesting Features & Enhancements](#requesting-features--enhancements)
|
||||||
@@ -19,6 +21,25 @@ Thank you for your interest in contributing to the Streamyfin mobile app project
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## AI Assistance Disclosure
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> If any AI tool was used while contributing to Streamyfin, it must be disclosed in the pull request.
|
||||||
|
|
||||||
|
State in your PR whether AI assistance was used and to what extent (for example, *docs only* or *code generation*).
|
||||||
|
If AI-generated text was used in PR discussions or responses, disclose that as well.
|
||||||
|
Minor autocomplete or keyword suggestions do not require disclosure.
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
> This PR was written primarily by Claude Code.
|
||||||
|
> I used Cursor to explore parts of the codebase, but the implementation is fully manual.
|
||||||
|
|
||||||
|
Failing to disclose AI usage wastes maintainers’ time and complicates review efforts.
|
||||||
|
AI-assisted contributions are welcome, but contributors remain fully responsible for the code they submit.
|
||||||
|
|
||||||
|
Always disclose AI involvement to maintain transparency and respect for maintainers’ time.
|
||||||
|
|
||||||
## Reporting Issues
|
## Reporting Issues
|
||||||
|
|
||||||
Streamyfin uses GitHub issues to track bugs and improvements. Before opening a new issue:
|
Streamyfin uses GitHub issues to track bugs and improvements. Before opening a new issue:
|
||||||
@@ -46,11 +67,11 @@ When creating a new feature request:
|
|||||||
|
|
||||||
- Check if the idea or similar request exists.
|
- Check if the idea or similar request exists.
|
||||||
- Use reactions like 👍 to support existing requests.
|
- Use reactions like 👍 to support existing requests.
|
||||||
- Provide a clear explanation of the use case and benefits.
|
- Clearly describe the use case and potential benefits.
|
||||||
|
- Include screenshots when relevant.
|
||||||
---
|
---
|
||||||
|
|
||||||
## Developing the Mobile App
|
## Developing Streamyfin
|
||||||
|
|
||||||
### Codebase Overview
|
### Codebase Overview
|
||||||
|
|
||||||
@@ -136,6 +157,8 @@ When opening a PR:
|
|||||||
- Provide a detailed description in the PR body, explaining what, why, and any impacts.
|
- Provide a detailed description in the PR body, explaining what, why, and any impacts.
|
||||||
- Include screenshots or recordings if UI changes are involved.
|
- Include screenshots or recordings if UI changes are involved.
|
||||||
- Ensure CI checks are green (lint, type-check, build).
|
- Ensure CI checks are green (lint, type-check, build).
|
||||||
|
- Confirm that the branch is **up to date with `main`** before submission.
|
||||||
|
- Mention if AI-generated code or content was used (see [AI Assistance Disclosure](#ai-assistance-disclosure)).
|
||||||
- Do not include secrets, tokens, or production credentials. Redact sensitive data in logs and screenshots.
|
- Do not include secrets, tokens, or production credentials. Redact sensitive data in logs and screenshots.
|
||||||
- Keep PRs focused; avoid bundling unrelated changes together.
|
- Keep PRs focused; avoid bundling unrelated changes together.
|
||||||
|
|
||||||
@@ -159,4 +182,4 @@ PRs require review and approval by maintainers before merging.---
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Thank you for helping make Streamyfin a better app for everyone!
|
Thank you for contributing to make Streamyfin better for everyone!
|
||||||
|
|||||||
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@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||||
|
with:
|
||||||
|
name: streamyfin-android-phone-apk-${{ env.DATE_TAG }}
|
||||||
|
path: |
|
||||||
|
android/app/build/outputs/apk/release/*.apk
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
build-android-tv:
|
||||||
|
if: (!contains(github.event.head_commit.message, '[skip ci]'))
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
name: 🤖 Build Android APK (TV)
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: 📥 Checkout code
|
||||||
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
|
fetch-depth: 0
|
||||||
|
submodules: recursive
|
||||||
|
show-progress: false
|
||||||
|
|
||||||
|
- name: 🍞 Setup Bun
|
||||||
|
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||||
|
with:
|
||||||
|
bun-version: latest
|
||||||
|
|
||||||
|
- name: 💾 Cache Bun dependencies
|
||||||
|
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||||
|
with:
|
||||||
|
path: ~/.bun/install/cache
|
||||||
|
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-${{ runner.arch }}-bun-develop
|
||||||
|
${{ runner.os }}-bun-develop
|
||||||
|
|
||||||
|
- name: 📦 Install dependencies and reload submodules
|
||||||
|
run: |
|
||||||
|
bun install --frozen-lockfile
|
||||||
|
bun run submodule-reload
|
||||||
|
|
||||||
|
- name: 💾 Cache Gradle global
|
||||||
|
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.gradle/caches
|
||||||
|
~/.gradle/wrapper
|
||||||
|
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-gradle-
|
||||||
|
|
||||||
|
- name: 🛠️ Generate project files
|
||||||
|
run: bun run prebuild:tv
|
||||||
|
|
||||||
|
- name: 💾 Cache project Gradle (.gradle)
|
||||||
|
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||||
|
with:
|
||||||
|
path: android/.gradle
|
||||||
|
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||||
|
restore-keys: ${{ runner.os }}-android-gradle-develop
|
||||||
|
|
||||||
|
- name: 🚀 Build APK
|
||||||
|
env:
|
||||||
|
EXPO_TV: 1
|
||||||
|
run: bun run build:android:local
|
||||||
|
|
||||||
|
- name: 📅 Set date tag
|
||||||
|
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: 📤 Upload APK artifact
|
||||||
|
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||||
|
with:
|
||||||
|
name: streamyfin-android-tv-apk-${{ env.DATE_TAG }}
|
||||||
|
path: |
|
||||||
|
android/app/build/outputs/apk/release/*.apk
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
build-ios-phone:
|
||||||
|
if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
|
||||||
|
runs-on: macos-15
|
||||||
|
name: 🍎 Build iOS IPA (Phone)
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: 📥 Checkout code
|
||||||
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
|
fetch-depth: 0
|
||||||
|
submodules: recursive
|
||||||
|
show-progress: false
|
||||||
|
|
||||||
|
- name: 🍞 Setup Bun
|
||||||
|
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||||
|
with:
|
||||||
|
bun-version: latest
|
||||||
|
|
||||||
|
- name: 💾 Cache Bun dependencies
|
||||||
|
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||||
|
with:
|
||||||
|
path: ~/.bun/install/cache
|
||||||
|
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-bun-cache
|
||||||
|
|
||||||
|
- name: 📦 Install dependencies and reload submodules
|
||||||
|
run: |
|
||||||
|
bun install --frozen-lockfile
|
||||||
|
bun run submodule-reload
|
||||||
|
|
||||||
|
- name: 🛠️ Generate project files
|
||||||
|
run: bun run prebuild
|
||||||
|
|
||||||
|
- name: 🏗️ Setup EAS
|
||||||
|
uses: expo/expo-github-action@main
|
||||||
|
with:
|
||||||
|
eas-version: latest
|
||||||
|
token: ${{ secrets.EXPO_TOKEN }}
|
||||||
|
eas-cache: true
|
||||||
|
|
||||||
|
- name: ⚙️ Ensure iOS SDKs installed
|
||||||
|
run: xcodebuild -downloadPlatform iOS
|
||||||
|
|
||||||
|
- name: 🚀 Build iOS app
|
||||||
|
env:
|
||||||
|
EXPO_TV: 0
|
||||||
|
run: eas build -p ios --local --non-interactive
|
||||||
|
|
||||||
|
- name: 📅 Set date tag
|
||||||
|
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: 📤 Upload IPA artifact
|
||||||
|
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||||
|
with:
|
||||||
|
name: streamyfin-ios-phone-ipa-${{ env.DATE_TAG }}
|
||||||
|
path: build-*.ipa
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
# Disabled for now - uncomment when ready to build iOS TV
|
||||||
|
# build-ios-tv:
|
||||||
|
# if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
|
||||||
|
# runs-on: macos-15
|
||||||
|
# name: 🍎 Build iOS IPA (TV)
|
||||||
|
# permissions:
|
||||||
|
# contents: read
|
||||||
|
#
|
||||||
|
# steps:
|
||||||
|
# - name: 📥 Checkout code
|
||||||
|
# uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
|
# with:
|
||||||
|
# ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
|
# fetch-depth: 0
|
||||||
|
# submodules: recursive
|
||||||
|
# show-progress: false
|
||||||
|
#
|
||||||
|
# - name: 🍞 Setup Bun
|
||||||
|
# uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||||
|
# with:
|
||||||
|
# bun-version: latest
|
||||||
|
#
|
||||||
|
# - name: 💾 Cache Bun dependencies
|
||||||
|
# uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||||
|
# with:
|
||||||
|
# path: ~/.bun/install/cache
|
||||||
|
# key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||||
|
# restore-keys: |
|
||||||
|
# ${{ runner.os }}-bun-cache
|
||||||
|
#
|
||||||
|
# - name: 📦 Install dependencies and reload submodules
|
||||||
|
# run: |
|
||||||
|
# bun install --frozen-lockfile
|
||||||
|
# bun run submodule-reload
|
||||||
|
#
|
||||||
|
# - name: 🛠️ Generate project files
|
||||||
|
# run: bun run prebuild:tv
|
||||||
|
#
|
||||||
|
# - name: 🏗️ Setup EAS
|
||||||
|
# uses: expo/expo-github-action@main
|
||||||
|
# with:
|
||||||
|
# eas-version: latest
|
||||||
|
# token: ${{ secrets.EXPO_TOKEN }}
|
||||||
|
# eas-cache: true
|
||||||
|
#
|
||||||
|
# - name: ⚙️ Ensure tvOS SDKs installed
|
||||||
|
# run: xcodebuild -downloadPlatform tvOS
|
||||||
|
#
|
||||||
|
# - name: 🚀 Build iOS app
|
||||||
|
# env:
|
||||||
|
# EXPO_TV: 1
|
||||||
|
# run: eas build -p ios --local --non-interactive
|
||||||
|
#
|
||||||
|
# - name: 📅 Set date tag
|
||||||
|
# run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||||
|
#
|
||||||
|
# - name: 📤 Upload IPA artifact
|
||||||
|
# uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||||
|
# with:
|
||||||
|
# name: streamyfin-ios-tv-ipa-${{ env.DATE_TAG }}
|
||||||
|
# path: build-*.ipa
|
||||||
|
# retention-days: 7
|
||||||
95
.github/workflows/build-ios.yml
vendored
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@3c3833e0f8c1c83d449a7478aa59c036a9165498 # v3.29.11
|
uses: github/codeql-action/init@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
queries: +security-extended,security-and-quality
|
queries: +security-extended,security-and-quality
|
||||||
|
|
||||||
- name: 🛠️ Autobuild
|
- name: 🛠️ Autobuild
|
||||||
uses: github/codeql-action/autobuild@3c3833e0f8c1c83d449a7478aa59c036a9165498 # v3.29.11
|
uses: github/codeql-action/autobuild@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0
|
||||||
|
|
||||||
- name: 🧪 Perform CodeQL Analysis
|
- name: 🧪 Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@3c3833e0f8c1c83d449a7478aa59c036a9165498 # v3.29.11
|
uses: github/codeql-action/analyze@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0
|
||||||
|
|||||||
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@08713f00a50548bfe39b37e8f44afb53e7a802d4 # v2.12.0
|
||||||
|
with:
|
||||||
|
upload_sources: true
|
||||||
|
upload_translations: true
|
||||||
|
download_translations: true
|
||||||
|
localization_branch_name: I10n_crowdin_translations
|
||||||
|
create_pull_request: true
|
||||||
|
pull_request_title: "feat: New Crowdin Translations"
|
||||||
|
pull_request_body: "New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)"
|
||||||
|
pull_request_base_branch_name: "develop"
|
||||||
|
pull_request_labels: "🌐 translation"
|
||||||
|
# Quality control options
|
||||||
|
skip_untranslated_strings: true
|
||||||
|
export_only_approved: false
|
||||||
|
# Commit customization
|
||||||
|
commit_message: "feat(i18n): update translations from Crowdin"
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.CROWDIN_GITHUB_TOKEN }}
|
||||||
|
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||||
|
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||||
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@40c09b7dc99638e5ddb0bfd91c1673effc064d8a # v4.8.1
|
||||||
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@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||||
with:
|
with:
|
||||||
node-version: '24.x'
|
node-version: '24.x'
|
||||||
|
|
||||||
|
|||||||
26
.github/workflows/notification.yml
vendored
26
.github/workflows/notification.yml
vendored
@@ -1,13 +1,19 @@
|
|||||||
name: 🛎️ Discord Pull Request Notification
|
name: 🛎️ Discord Notification
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
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 +27,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@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||||
with:
|
with:
|
||||||
node-version: '24.x'
|
node-version: '24.x'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|||||||
74
.gitignore
vendored
74
.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
|
|
||||||
|
# Secrets and Credentials
|
||||||
|
pc-api-7079014811501811218-719-3b9f15aeccf8.json
|
||||||
|
credentials.json
|
||||||
|
streamyfin-4fec1-firebase-adminsdk.json
|
||||||
|
|
||||||
|
# Version and Backup Files
|
||||||
/version-backup-*
|
/version-backup-*
|
||||||
bun.lockb
|
|
||||||
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"
|
||||||
}
|
}
|
||||||
|
|||||||
157
README.md
157
README.md
@@ -5,146 +5,135 @@
|
|||||||
<img src="https://raw.githubusercontent.com/streamyfin/.github/refs/heads/main/streamyfin-github-banner.png" alt="Streamyfin" width="100%">
|
<img src="https://raw.githubusercontent.com/streamyfin/.github/refs/heads/main/streamyfin-github-banner.png" alt="Streamyfin" width="100%">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
**Streamyfin is a simple, user-friendly Jellyfin video streaming client built with Expo. Designed as an alternative to other Jellyfin clients, it aims to offer a smooth and reliable streaming experience. We hope you'll find it a valuable addition to your media streaming toolbox.**
|
**Streamyfin is a user-friendly Jellyfin video streaming client built with Expo. Designed as an alternative to other Jellyfin clients, it aims to offer a smooth and reliable streaming experience. We hope you'll find it a valuable addition to your media streaming toolbox.**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="./assets/images/screenshots/screenshot1.png" width="22%">
|
<img src="./assets/images/screenshots/screenshot1.png" width="20%">
|
||||||
|
|
||||||
<img src="./assets/images/screenshots/screenshot3.png" width="22%">
|
<img src="./assets/images/screenshots/screenshot3.png" width="20%">
|
||||||
|
|
||||||
<img src="./assets/images/screenshots/screenshot2.png" width="22%">
|
<img src="./assets/images/screenshots/screenshot2.png" width="20%">
|
||||||
|
|
||||||
<img src="./assets/images/jellyseerr.PNG" width="23%">
|
<img src="./assets/images/jellyseerr.PNG" width="21%">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
||||||
## 🌟 Features
|
## 🌟 Features
|
||||||
|
|
||||||
- 🚀 **Skip Intro / Credits Support**
|
- 🚀 **Skip Intro / Credits Support**: Lets you quickly skip intros and credits during playback
|
||||||
- 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking.
|
- 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking
|
||||||
- 📥 **Download media** (Experimental): Save your media locally and watch it offline.
|
- 📥 **Download media**: Save your media locally and watch it offline
|
||||||
- 📡 **Chromecast** (Experimental): Cast your media to any Chromecast-enabled device.
|
- ⚙️ **Settings management**: Manage app configurations for all users through our plugin
|
||||||
- 📡 **Settings management** (Experimental): Manage app settings for all your users with a JF plugin.
|
- 🤖 **Seerr (formerly Jellyseerr) integration**: Request media directly in the app
|
||||||
- 🤖 **Jellyseerr integration**: Request media directly in the app.
|
- 👁️ **Sessions view:** View all active sessions currently streaming on your server
|
||||||
- 👁️ **Sessions View:** View all active sessions currently streaming on your server.
|
- 📡 **Chromecast**: Cast your media to any Chromecast-enabled device
|
||||||
|
|
||||||
## 🧪 Experimental Features
|
## 🧪 Experimental Features
|
||||||
|
|
||||||
Streamyfin includes some exciting experimental features like media downloading and Chromecast support. These features are still in development, and your patience and feedback are much appreciated as we work to improve them.
|
Streamyfin offers exciting experimental features such as media downloading and Chromecast support. These features are under active development, and your feedback and patience help us make them even better.
|
||||||
|
|
||||||
### 📥 Downloading
|
### 📥 Downloading
|
||||||
|
|
||||||
Downloading works by using ffmpeg to convert an HLS stream into a video file on the device. This means that you can download and view any file you can stream! The file is converted by Jellyfin on the server in real time as it is downloaded. This means a **bit longer download times** but supports any file that your server can transcode.
|
Downloading works by using FFmpeg to convert an HLS stream into a video file on your device. This lets you download and watch any content that you can stream. The conversion is handled in real time by Jellyfin on the server during the download. While this may take a bit longer, it ensures compatibility with any file your server can transcode.
|
||||||
|
|
||||||
### 🎥 Chromecast
|
|
||||||
|
|
||||||
Chromecast support is still in development, and we're working on improving it. Currently, it supports casting videos, but we're working on adding support for subtitles and other features.
|
|
||||||
|
|
||||||
### 🧩 Streamyfin Plugin
|
### 🧩 Streamyfin Plugin
|
||||||
|
|
||||||
The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that holds all settings for the client Streamyfin. This allows you to synchronize settings across all your users, like for example:
|
The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that holds all settings for the client Streamyfin. This allows you to synchronize settings across all your users, like for example:
|
||||||
|
|
||||||
- Auto log in to Jellyseerr without the user having to do anything
|
- Automatic Seerr login with no user input required
|
||||||
- Choose the default languages
|
- Set your preferred default languages
|
||||||
- Set download method and search provider
|
- Configure download method and search provider
|
||||||
- Customize home screen
|
- Personalize your home screen
|
||||||
- And much more...
|
- And much more
|
||||||
|
|
||||||
[Streamyfin Plugin](https://github.com/streamyfin/jellyfin-plugin-streamyfin)
|
[Streamyfin Plugin](https://github.com/streamyfin/jellyfin-plugin-streamyfin)
|
||||||
|
|
||||||
|
### 📡 Chromecast
|
||||||
|
|
||||||
|
Chromecast support is currently under development. Video casting is already available, and we're actively working on adding subtitle support and additional features.
|
||||||
|
|
||||||
### 🔍 Jellysearch
|
### 🔍 Jellysearch
|
||||||
|
|
||||||
[Jellysearch](https://gitlab.com/DomiStyle/jellysearch) now works with Streamyfin!
|
[Jellysearch](https://gitlab.com/DomiStyle/jellysearch) works with Streamyfin
|
||||||
|
|
||||||
> A fast full-text search proxy for Jellyfin. Integrates seamlessly with most Jellyfin clients.
|
> A fast full-text search proxy for Jellyfin. Integrates seamlessly with most Jellyfin clients.
|
||||||
|
|
||||||
## 🛣️ Roadmap for V1
|
## 🛣️ Roadmap
|
||||||
|
|
||||||
Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) To see what we're working on next, we are always open to feedback and suggestions. Please let us know if you have any ideas or feature requests.
|
Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) To see what we're working on next, we are always open to feedback and suggestions. Please let us know if you have any ideas or feature requests.
|
||||||
|
|
||||||
## 📥 Get it now
|
## 📥 Download Streamyfin
|
||||||
|
|
||||||
<div style="display: flex; gap: 5px;">
|
<div style="display: flex; gap: 5px;">
|
||||||
<a href="https://apps.apple.com/app/streamyfin/id6593660679?l=en-GB"><img height=50 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/></a>
|
<a href="https://apps.apple.com/app/streamyfin/id6593660679?l=en-GB"><img height=50 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/></a>
|
||||||
<a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin"><img height=50 alt="Get the beta on Google Play" src="./assets/Google_Play_Store_badge_EN.svg"/></a>
|
<a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin"><img height=50 alt="Get Streamyfin on Google Play Store" src="./assets/Google_Play_Store_badge_EN.svg"/></a>
|
||||||
|
<a href="https://github.com/streamyfin/streamyfin/releases/latest"><img height=50 alt="Get Streamyfin on Github" src="./assets/Download_on_Github_.png"/></a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
Or download the APKs [here on GitHub](https://github.com/streamyfin/streamyfin/releases) for Android.
|
### 🧪 Beta Testing
|
||||||
|
|
||||||
### 🧪 Beta testing
|
To access the Streamyfin beta, you need to subscribe to the Member tier (or higher) on [Patreon](https://www.patreon.com/streamyfin). This grants you immediate access to the 🧪-beta-releases channel on Discord and lets me know you’ve subscribed. This is where I share APKs and IPAs. It does not provide automatic TestFlight access, so please send me a DM (Cagemaster) with the email you use for Apple so we can add you manually.
|
||||||
|
|
||||||
To access the Streamyfin beta, you need to subscribe to the Member tier (or higher) on [Patreon](https://www.patreon.com/streamyfin). This will give you immediate access to the 🧪-public-beta channel on Discord and I'll know that you have subscribed. This is where I post APKs and IPAs. This won't give automatic access to the TestFlight, however, so you need to send me a DM with the email you use for Apple so that I can manually add you.
|
**Note**: Anyone actively contributing to Streamyfin’s source code will receive automatic access to beta releases.
|
||||||
|
|
||||||
**Note**: Everyone who is actively contributing to the source code of Streamyfin will have automatic access to the betas.
|
|
||||||
|
|
||||||
## 🚀 Getting Started
|
## 🚀 Getting Started
|
||||||
|
|
||||||
### Prerequisites
|
### ⚙️ Prerequisites
|
||||||
|
|
||||||
- Ensure you have an active Jellyfin server.
|
- Your device is on the same network as the Jellyfin server (for local connections)
|
||||||
- Make sure your device is connected to the same network as your Jellyfin server.
|
- Your Jellyfin server is up and running with remote access enabled if you plan to connect from outside your local network
|
||||||
|
- Your server version is up to date (older versions may cause compatibility issues)
|
||||||
|
- You have a valid Jellyfin user account with access to the media libraries you want to view
|
||||||
|
- If using features such as **downloads** or **Seerr integration**, confirm the required plugins are installed and configured on your Jellyfin server
|
||||||
|
|
||||||
## 🙌 Contributing
|
## 🙌 Contributing
|
||||||
|
|
||||||
We welcome any help to make Streamyfin better. If you'd like to contribute, please fork the repository and submit a pull request. For major changes, it's best to open an issue first to discuss your ideas.
|
We welcome contributions that improve Streamyfin. Start by forking the repository and submitting a pull request. For major changes or new features, please open an issue first to discuss your ideas and ensure alignment with the project.
|
||||||
|
|
||||||
### 👨💻 Development info
|
## 🌍 Translations
|
||||||
|
|
||||||
|
[](https://crowdin.com/project/streamyfin)
|
||||||
|
|
||||||
|
Streamyfin is available in multiple languages, and we’re always looking for contributors to help make the app accessible worldwide.
|
||||||
|
You can contribute translations directly on our [Crowdin project page](https://crowdin.com/project/streamyfin).
|
||||||
|
|
||||||
|
### 👨💻 Development Info
|
||||||
|
|
||||||
1. Use node `>20`
|
1. Use node `>20`
|
||||||
2. Install dependencies `bun i && bun run submodule-reload`
|
2. Install dependencies `bun i && bun run submodule-reload`
|
||||||
3. Make sure you have xcode and/or android studio installed. (follow the guides for expo: https://docs.expo.dev/workflow/android-studio-emulator/)
|
3. Make sure you have xcode and/or android studio installed. (follow the guides for expo: https://docs.expo.dev/workflow/android-studio-emulator/)
|
||||||
4. Install BiomeJS extension in VSCode/Your IDE (https://biomejs.dev/)
|
4. Install BiomeJS extension in VSCode/Your IDE (https://biomejs.dev/)
|
||||||
4. run `npm run prebuild`
|
4. run `npm run prebuild`
|
||||||
5. Create an expo dev build by running `npm run ios` or `npm run android`. This will open a simulator on your computer and run the app.
|
5. Create an expo dev build by running `npm run ios` or `npm run android`. This will open a simulator on your computer and run the app
|
||||||
|
|
||||||
For the TV version suffix the npm commands with `:tv`.
|
For the TV version suffix the npm commands with `:tv`.
|
||||||
|
|
||||||
`npm run prebuild:tv`
|
`npm run prebuild:tv`
|
||||||
`npm run ios:tv or npm run android:tv`
|
`npm run ios:tv or npm run android:tv`
|
||||||
|
|
||||||
## 📄 License
|
## 👋 Get in Touch with Us
|
||||||
|
|
||||||
Streamyfin is licensed under the Mozilla Public License 2.0 (MPL-2.0).
|
Need assistance or have any questions?
|
||||||
This means you are free to use, modify, and distribute this software. The MPL-2.0 is a copyleft license that allows for more flexibility in combining the software with proprietary code.
|
|
||||||
Key points of the MPL-2.0:
|
|
||||||
|
|
||||||
- You can use the software for any purpose
|
- **Discord:** [Join our server](https://discord.gg/BuGG9ZNhaE)
|
||||||
- You can modify the software and distribute modified versions
|
- **GitHub Issues:** [Report bugs or request features](https://github.com/streamyfin/streamyfin/issues)
|
||||||
- You must include the original copyright and license notices
|
- **Email:** [developer@streamyfin.app](mailto:developer@streamyfin.app)
|
||||||
- You must disclose your source code for any modifications to the covered files
|
|
||||||
- Larger works may combine MPL code with code under other licenses
|
|
||||||
- MPL-licensed components must remain under the MPL, but the larger work can be under a different license
|
|
||||||
- For the full text of the license, please see the LICENSE file in this repository
|
|
||||||
|
|
||||||
## 🌐 Connect with Us
|
|
||||||
|
|
||||||
Join our Discord: [](https://discord.gg/BuGG9ZNhaE)
|
|
||||||
|
|
||||||
Need support or have questions:
|
|
||||||
|
|
||||||
- GitHub Issues: Report bugs or request features here.
|
|
||||||
- Email: [developer@streamyfin.app](mailto:developer@streamyfin.app)
|
|
||||||
|
|
||||||
## ❓ FAQ
|
## ❓ FAQ
|
||||||
|
|
||||||
1. Q: Why can't I see my libraries in Streamyfin?
|
1. Q: Why can't I see my libraries in Streamyfin?
|
||||||
A: Make sure your server is running one of the latest versions and that you have at least one library that isn't audio only.
|
A: Make sure your server is running one of the latest versions and that you have at least one library that isn't audio only
|
||||||
2. Q: Why can't I see my music library?
|
2. Q: Why can't I see my music library?
|
||||||
A: We don't currently support music and are unlikely to support music in the near future.
|
A: We don't currently support music and are unlikely to support music in the near future
|
||||||
|
|
||||||
## 📝 Credits
|
## 📝 Credits
|
||||||
|
|
||||||
Streamyfin is developed by [Fredrik Burmester](https://github.com/fredrikburmester) and is not affiliated with Jellyfin. The app is built with Expo, React Native, and other open-source libraries.
|
Streamyfin is developed by [Fredrik Burmester](https://github.com/fredrikburmester) and is not affiliated with Jellyfin. The app is built using Expo, React Native, and other open-source libraries.
|
||||||
|
|
||||||
## ✨ Acknowledgements
|
## 🎖️ Core Developers
|
||||||
|
|
||||||
We would like to thank the Jellyfin team for their great software and awesome support on discord.
|
|
||||||
|
|
||||||
Special shoutout to the JF official clients for being an inspiration to ours.
|
|
||||||
|
|
||||||
### 🏆 Core Developers
|
|
||||||
|
|
||||||
Thanks to the following contributors for their significant contributions:
|
Thanks to the following contributors for their significant contributions:
|
||||||
|
|
||||||
@@ -229,21 +218,41 @@ Thanks to the following contributors for their significant contributions:
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
And all other developers who have contributed to Streamyfin, thank you for your contributions.
|
## ✨ Acknowledgements
|
||||||
|
|
||||||
I'd also like to thank the following people and projects for their contributions to Streamyfin:
|
We would like to thank the Jellyfin team for their excellent software and support on Discord.
|
||||||
|
|
||||||
|
Special thanks to the official Jellyfin clients, which have served as an inspiration for Streamyfin.
|
||||||
|
|
||||||
|
We also thank all other developers who have contributed to Streamyfin, your efforts are greatly appreciated.
|
||||||
|
|
||||||
|
A special mention to the following people and projects for their contributions:
|
||||||
|
|
||||||
|
- [Reiverr](https://github.com/aleksilassila/reiverr) for invaluable help with understanding the Jellyfin API
|
||||||
|
- [Jellyfin TS SDK](https://github.com/jellyfin/jellyfin-sdk-typescript) for providing the TypeScript SDK
|
||||||
|
- [Seerr](https://github.com/seerr-team/seerr) for enabling API integration with their project
|
||||||
|
|
||||||
- [Reiverr](https://github.com/aleksilassila/reiverr) for great help with understanding the Jellyfin API.
|
|
||||||
- [Jellyfin TS SDK](https://github.com/jellyfin/jellyfin-sdk-typescript) for the TypeScript SDK.
|
|
||||||
- [Jellyseerr](https://github.com/Fallenbagel/jellyseerr) for enabling API integration with their project.
|
|
||||||
- The Jellyfin devs for always being helpful in the Discord.
|
|
||||||
|
|
||||||
## ⭐ Star History
|
## ⭐ Star History
|
||||||
|
|
||||||
[](https://star-history.com/#streamyfin/streamyfin&Date)
|
[](https://star-history.com/#streamyfin/streamyfin&Date)
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
Streamyfin is licensed under the Mozilla Public License 2.0 (MPL-2.0).
|
||||||
|
This means you are free to use, modify, and distribute this software. The MPL-2.0 is a copyleft license that allows for more flexibility in combining the software with proprietary code.
|
||||||
|
Key points of the MPL-2.0:
|
||||||
|
|
||||||
|
- You can use the software for any purpose
|
||||||
|
- You can modify the software and distribute modified versions
|
||||||
|
- You must include the original copyright and license notices
|
||||||
|
- You must disclose your source code for any modifications to the covered files
|
||||||
|
- Larger works may combine MPL code with code under other licenses
|
||||||
|
- MPL-licensed components must remain under the MPL, but the larger work can be under a different license
|
||||||
|
- For the full text of the license, please see the LICENSE file in this repository
|
||||||
|
|
||||||
## ⚠️ Disclaimer
|
## ⚠️ Disclaimer
|
||||||
Streamyfin does not promote, support, or condone piracy in any form. The app is intended solely for streaming media that you personally own and control. It does not provide or include any media content. Any discussions or support requests related to piracy are strictly prohibited across all our channels.
|
Streamyfin does not promote, support, or condone piracy in any form. The app is intended solely for streaming media that you personally own and control. It does not provide or include any media content. Any discussions, support requests, or references to piracy, as well as any tools, software, or websites related to piracy, are strictly prohibited across all our channels.
|
||||||
|
|
||||||
## 🤝 Sponsorship
|
## 🤝 Sponsorship
|
||||||
VPS hosting generously provided by [Hexabyte](https://hexabyte.se/en/vps/?currency=eur) and [SweHosting](https://swehosting.se/en/#tj%C3%A4nster)
|
VPS hosting generously provided by [Hexabyte](https://hexabyte.se/en/vps/?currency=eur) and [SweHosting](https://swehosting.se/en/#tj%C3%A4nster)
|
||||||
|
|||||||
7
app.json
7
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.35.0",
|
"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": 66,
|
"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,6 +49,7 @@
|
|||||||
"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": [
|
||||||
@@ -121,7 +122,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,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const [settings, updateSettings, pluginSettings] = useSettings(null);
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
const user = useAtomValue(userAtom);
|
const user = useAtomValue(userAtom);
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
|
|||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const [_settings, _updateSettings, pluginSettings] = useSettings(null);
|
const { pluginSettings } = useSettings();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DisabledSetting
|
<DisabledSetting
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export default function page() {
|
|||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [settings, updateSettings, pluginSettings] = useSettings(null);
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
|
const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { COMPANY_LOGO_IMAGE_FILTER } from "@/utils/jellyseerr/src/components/Dis
|
|||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const local = useLocalSearchParams();
|
const local = useLocalSearchParams();
|
||||||
const { jellyseerrApi } = useJellyseerr();
|
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
|
||||||
|
|
||||||
const { companyId, image, type } = local as unknown as {
|
const { companyId, image, type } = local as unknown as {
|
||||||
companyId: string;
|
companyId: string;
|
||||||
@@ -53,7 +53,10 @@ export default function page() {
|
|||||||
uniqBy(
|
uniqBy(
|
||||||
data?.pages
|
data?.pages
|
||||||
?.filter((p) => p?.results.length)
|
?.filter((p) => p?.results.length)
|
||||||
.flatMap((p) => p?.results ?? []),
|
.flatMap(
|
||||||
|
(p) =>
|
||||||
|
p?.results.filter((r) => isJellyseerrMovieOrTvResult(r)) ?? [],
|
||||||
|
),
|
||||||
"id",
|
"id",
|
||||||
) ?? [],
|
) ?? [],
|
||||||
[data],
|
[data],
|
||||||
@@ -98,9 +101,7 @@ export default function page() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
renderItem={(item, _index) => (
|
renderItem={(item, _index) => <JellyseerrPoster item={item} />}
|
||||||
<JellyseerrPoster item={item as MovieResult | TvResult} />
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,14 +8,10 @@ import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
|
|||||||
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
||||||
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
|
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||||
import {
|
|
||||||
type MovieResult,
|
|
||||||
type TvResult,
|
|
||||||
} from "@/utils/jellyseerr/server/models/Search";
|
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const local = useLocalSearchParams();
|
const local = useLocalSearchParams();
|
||||||
const { jellyseerrApi } = useJellyseerr();
|
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
|
||||||
|
|
||||||
const { genreId, name, type } = local as unknown as {
|
const { genreId, name, type } = local as unknown as {
|
||||||
genreId: string;
|
genreId: string;
|
||||||
@@ -51,7 +47,10 @@ export default function page() {
|
|||||||
uniqBy(
|
uniqBy(
|
||||||
data?.pages
|
data?.pages
|
||||||
?.filter((p) => p?.results.length)
|
?.filter((p) => p?.results.length)
|
||||||
.flatMap((p) => p?.results ?? []),
|
.flatMap(
|
||||||
|
(p) =>
|
||||||
|
p?.results.filter((r) => isJellyseerrMovieOrTvResult(r)) ?? [],
|
||||||
|
),
|
||||||
"id",
|
"id",
|
||||||
) ?? [],
|
) ?? [],
|
||||||
[data],
|
[data],
|
||||||
@@ -62,7 +61,7 @@ export default function page() {
|
|||||||
jellyseerrApi
|
jellyseerrApi
|
||||||
? flatData.map((r) =>
|
? flatData.map((r) =>
|
||||||
jellyseerrApi.imageProxy(
|
jellyseerrApi.imageProxy(
|
||||||
(r as TvResult | MovieResult).backdropPath,
|
r.backdropPath,
|
||||||
"w1920_and_h800_multi_faces",
|
"w1920_and_h800_multi_faces",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -92,9 +91,7 @@ export default function page() {
|
|||||||
{name}
|
{name}
|
||||||
</Text>
|
</Text>
|
||||||
}
|
}
|
||||||
renderItem={(item, _index) => (
|
renderItem={(item, _index) => <JellyseerrPoster item={item} />}
|
||||||
<JellyseerrPoster item={item as MovieResult | TvResult} />
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,10 +10,6 @@ import { OverviewText } from "@/components/OverviewText";
|
|||||||
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import type { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
|
import type { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
|
||||||
import type {
|
|
||||||
MovieResult,
|
|
||||||
TvResult,
|
|
||||||
} from "@/utils/jellyseerr/server/models/Search";
|
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const local = useLocalSearchParams();
|
const local = useLocalSearchParams();
|
||||||
@@ -106,9 +102,7 @@ export default function page() {
|
|||||||
MainContent={() => (
|
MainContent={() => (
|
||||||
<OverviewText text={data?.details?.biography} className='mt-4' />
|
<OverviewText text={data?.details?.biography} className='mt-4' />
|
||||||
)}
|
)}
|
||||||
renderItem={(item, _index) => (
|
renderItem={(item, _index) => <JellyseerrPoster item={item} />}
|
||||||
<JellyseerrPoster item={item as MovieResult | TvResult} />
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useAtom } from "jotai";
|
|||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { InfiniteHorizontalScroll } from "@/components/common/InfiniteHorrizontalScroll";
|
import { InfiniteHorizontalScroll } from "@/components/common/InfiniteHorizontalScroll";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
@@ -22,21 +22,21 @@ import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
|||||||
|
|
||||||
const page: React.FC = () => {
|
const page: React.FC = () => {
|
||||||
const local = useLocalSearchParams();
|
const local = useLocalSearchParams();
|
||||||
const { actorId } = local as { actorId: string };
|
const { personId } = local as { personId: string };
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
const { data: item, isLoading: l1 } = useQuery({
|
const { data: item, isLoading: l1 } = useQuery({
|
||||||
queryKey: ["item", actorId],
|
queryKey: ["item", personId],
|
||||||
queryFn: async () =>
|
queryFn: async () =>
|
||||||
await getUserItemData({
|
await getUserItemData({
|
||||||
api,
|
api,
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
itemId: actorId,
|
itemId: personId,
|
||||||
}),
|
}),
|
||||||
enabled: !!actorId && !!api,
|
enabled: !!personId && !!api,
|
||||||
staleTime: 60,
|
staleTime: 60,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ const page: React.FC = () => {
|
|||||||
|
|
||||||
const response = await getItemsApi(api).getItems({
|
const response = await getItemsApi(api).getItems({
|
||||||
userId: user.Id,
|
userId: user.Id,
|
||||||
personIds: [actorId],
|
personIds: [personId],
|
||||||
startIndex: pageParam,
|
startIndex: pageParam,
|
||||||
limit: 16,
|
limit: 16,
|
||||||
sortOrder: ["Descending", "Descending", "Ascending"],
|
sortOrder: ["Descending", "Descending", "Ascending"],
|
||||||
@@ -68,7 +68,7 @@ const page: React.FC = () => {
|
|||||||
|
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
[api, user?.Id, actorId],
|
[api, user?.Id, personId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const backdropUrl = useMemo(
|
const backdropUrl = useMemo(
|
||||||
@@ -131,7 +131,7 @@ const page: React.FC = () => {
|
|||||||
</TouchableItemRouter>
|
</TouchableItemRouter>
|
||||||
)}
|
)}
|
||||||
queryFn={fetchItems}
|
queryFn={fetchItems}
|
||||||
queryKey={["actor", "movies", actorId]}
|
queryKey={["actor", "movies", personId]}
|
||||||
/>
|
/>
|
||||||
<View className='h-12' />
|
<View className='h-12' />
|
||||||
</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(null);
|
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export default function index() {
|
|||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [settings] = useSettings(null);
|
const { settings } = useSettings();
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export default function search() {
|
|||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
const [settings] = useSettings(null);
|
const { settings } = useSettings();
|
||||||
const { jellyseerrApi } = useJellyseerr();
|
const { jellyseerrApi } = useJellyseerr();
|
||||||
const [jellyseerrOrderBy, setJellyseerrOrderBy] =
|
const [jellyseerrOrderBy, setJellyseerrOrderBy] =
|
||||||
useState<JellyseerrSearchSort>(
|
useState<JellyseerrSearchSort>(
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export const NativeTabs = withLayoutContext<
|
|||||||
>(Navigator);
|
>(Navigator);
|
||||||
|
|
||||||
export default function TabLayout() {
|
export default function TabLayout() {
|
||||||
const [settings] = useSettings(null);
|
const { settings } = useSettings();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}>();
|
}>();
|
||||||
const [_settings] = useSettings(null);
|
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)}`);
|
||||||
}
|
}
|
||||||
@@ -748,7 +761,6 @@ export default function page() {
|
|||||||
setAspectRatio={setAspectRatio}
|
setAspectRatio={setAspectRatio}
|
||||||
setScaleFactor={setScaleFactor}
|
setScaleFactor={setScaleFactor}
|
||||||
isVlc
|
isVlc
|
||||||
api={api}
|
|
||||||
downloadedFiles={downloadedFiles}
|
downloadedFiles={downloadedFiles}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
103
app/_layout.tsx
103
app/_layout.tsx
@@ -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();
|
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
}
|
}
|
||||||
@@ -230,7 +223,7 @@ const queryClient = new QueryClient({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function Layout() {
|
function Layout() {
|
||||||
const [settings] = useSettings(null);
|
const { settings } = useSettings();
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const appState = useRef(AppState.currentState);
|
const appState = useRef(AppState.currentState);
|
||||||
@@ -269,6 +262,15 @@ function Layout() {
|
|||||||
await Notifications?.setNotificationChannelAsync("default", {
|
await Notifications?.setNotificationChannelAsync("default", {
|
||||||
name: "default",
|
name: "default",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Create dedicated channel for download notifications
|
||||||
|
console.log("Setting android notification channel 'downloads'");
|
||||||
|
await Notifications?.setNotificationChannelAsync("downloads", {
|
||||||
|
name: "Downloads",
|
||||||
|
importance: Notifications.AndroidImportance.DEFAULT,
|
||||||
|
vibrationPattern: [0, 250, 250, 250],
|
||||||
|
lightColor: "#FF231F7C",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const granted = await checkAndRequestPermissions();
|
const granted = await checkAndRequestPermissions();
|
||||||
@@ -308,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);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -389,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();
|
||||||
};
|
};
|
||||||
|
|||||||
BIN
assets/Download_on_Github_.png
Normal file
BIN
assets/Download_on_Github_.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.5 KiB |
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.2.2/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.2.7/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
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const [queue, _setQueue] = useAtom(queueAtom);
|
const [queue, _setQueue] = useAtom(queueAtom);
|
||||||
const [settings] = useSettings(null);
|
const { settings } = useSettings();
|
||||||
const [downloadUnwatchedOnly, setDownloadUnwatchedOnly] = useState(false);
|
const [downloadUnwatchedOnly, setDownloadUnwatchedOnly] = useState(false);
|
||||||
|
|
||||||
const { processes, startBackgroundDownload, getDownloadedItems } =
|
const { processes, startBackgroundDownload, getDownloadedItems } =
|
||||||
@@ -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";
|
||||||
@@ -54,14 +54,14 @@ interface ItemContentProps {
|
|||||||
export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||||
({ item, isOffline }) => {
|
({ item, isOffline }) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [settings] = useSettings(null);
|
const { settings } = useSettings();
|
||||||
const { orientation } = useOrientation();
|
const { orientation } = useOrientation();
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
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 }}>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useAtom } from "jotai";
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { View, type ViewProps } from "react-native";
|
import { View, type ViewProps } from "react-native";
|
||||||
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
|
import { HorizontalScroll } from "@/components/common/HorizontalScroll";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
|
|||||||
@@ -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,19 +58,22 @@ 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(null);
|
const { settings, updateSettings } = useSettings();
|
||||||
const lightHapticFeedback = useHaptic("light");
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
const goToPlayer = useCallback(
|
const goToPlayer = useCallback(
|
||||||
@@ -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,19 +34,23 @@ 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(null);
|
const { settings } = useSettings();
|
||||||
const lightHapticFeedback = useHaptic("light");
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
const goToPlayer = useCallback(
|
const goToPlayer = useCallback(
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { View, type ViewProps } from "react-native";
|
import { View, type ViewProps } from "react-native";
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { HorizontalScroll } from "./common/HorrizontalScroll";
|
import { HorizontalScroll } from "./common/HorizontalScroll";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
import { TouchableItemRouter } from "./common/TouchableItemRouter";
|
import { TouchableItemRouter } from "./common/TouchableItemRouter";
|
||||||
import { ItemCardText } from "./ItemCardText";
|
import { ItemCardText } from "./ItemCardText";
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Permission,
|
Permission,
|
||||||
} from "@/utils/jellyseerr/server/lib/permissions";
|
} from "@/utils/jellyseerr/server/lib/permissions";
|
||||||
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||||
|
import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
|
||||||
import type {
|
import type {
|
||||||
MovieResult,
|
MovieResult,
|
||||||
TvResult,
|
TvResult,
|
||||||
@@ -17,7 +18,7 @@ import type {
|
|||||||
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||||
|
|
||||||
interface Props extends TouchableOpacityProps {
|
interface Props extends TouchableOpacityProps {
|
||||||
result?: MovieResult | TvResult | MovieDetails | TvDetails;
|
result?: MovieResult | TvResult | MovieDetails | TvDetails | PersonCreditCast;
|
||||||
mediaTitle: string;
|
mediaTitle: string;
|
||||||
releaseYear: number;
|
releaseYear: number;
|
||||||
canRequest: boolean;
|
canRequest: boolean;
|
||||||
@@ -39,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 (
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||||
import type {
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
BaseItemDto,
|
|
||||||
BaseItemPerson,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { useRouter, useSegments } from "expo-router";
|
import { useRouter, useSegments } from "expo-router";
|
||||||
import { type PropsWithChildren, useCallback } from "react";
|
import { type PropsWithChildren, useCallback } from "react";
|
||||||
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
|
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
|
||||||
@@ -14,10 +11,7 @@ interface Props extends TouchableOpacityProps {
|
|||||||
isOffline?: boolean;
|
isOffline?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const itemRouter = (
|
export const itemRouter = (item: BaseItemDto, from: string) => {
|
||||||
item: BaseItemDto | BaseItemPerson,
|
|
||||||
from: string,
|
|
||||||
) => {
|
|
||||||
if ("CollectionType" in item && item.CollectionType === "livetv") {
|
if ("CollectionType" in item && item.CollectionType === "livetv") {
|
||||||
return `/(auth)/(tabs)/${from}/livetv`;
|
return `/(auth)/(tabs)/${from}/livetv`;
|
||||||
}
|
}
|
||||||
@@ -26,8 +20,8 @@ export const itemRouter = (
|
|||||||
return `/(auth)/(tabs)/${from}/series/${item.Id}`;
|
return `/(auth)/(tabs)/${from}/series/${item.Id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.Type === "Person" || item.Type === "Actor") {
|
if (item.Type === "Person") {
|
||||||
return `/(auth)/(tabs)/${from}/actors/${item.Id}`;
|
return `/(auth)/(tabs)/${from}/persons/${item.Id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.Type === "BoxSet") {
|
if (item.Type === "BoxSet") {
|
||||||
@@ -49,6 +43,48 @@ export const itemRouter = (
|
|||||||
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,
|
||||||
@@ -61,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 (
|
||||||
@@ -107,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}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,27 +1,9 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { useRouter } from "expo-router";
|
|
||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
import { useMemo } from "react";
|
import { View, type ViewProps } from "react-native";
|
||||||
import {
|
|
||||||
ActivityIndicator,
|
|
||||||
TouchableOpacity,
|
|
||||||
type TouchableOpacityProps,
|
|
||||||
View,
|
|
||||||
type ViewProps,
|
|
||||||
} from "react-native";
|
|
||||||
import { toast } from "sonner-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { JobStatus } from "@/providers/Downloads/types";
|
import { JobStatus } from "@/providers/Downloads/types";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { DownloadCard } from "./DownloadCard";
|
||||||
import { formatTimeString } from "@/utils/time";
|
|
||||||
import { Button } from "../Button";
|
|
||||||
|
|
||||||
const bytesToMB = (bytes: number) => {
|
|
||||||
return bytes / 1024 / 1024;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ActiveDownloadsProps extends ViewProps {}
|
interface ActiveDownloadsProps extends ViewProps {}
|
||||||
|
|
||||||
@@ -52,163 +34,3 @@ export default function ActiveDownloads({ ...props }: ActiveDownloadsProps) {
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DownloadCardProps extends TouchableOpacityProps {
|
|
||||||
process: JobStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|
||||||
const { startDownload, pauseDownload, resumeDownload, removeProcess } =
|
|
||||||
useDownload();
|
|
||||||
const router = useRouter();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const handlePause = async (id: string) => {
|
|
||||||
try {
|
|
||||||
await pauseDownload(id);
|
|
||||||
toast.success(t("home.downloads.toasts.download_paused"));
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error pausing download:", error);
|
|
||||||
toast.error(t("home.downloads.toasts.could_not_pause_download"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleResume = async (id: string) => {
|
|
||||||
try {
|
|
||||||
await resumeDownload(id);
|
|
||||||
toast.success(t("home.downloads.toasts.download_resumed"));
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error resuming download:", error);
|
|
||||||
toast.error(t("home.downloads.toasts.could_not_resume_download"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
|
||||||
try {
|
|
||||||
await removeProcess(id);
|
|
||||||
toast.success(t("home.downloads.toasts.download_deleted"));
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["downloads"] });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error deleting download:", error);
|
|
||||||
toast.error(t("home.downloads.toasts.could_not_delete_download"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const eta = (p: JobStatus) => {
|
|
||||||
if (!p.speed || p.speed <= 0 || !p.estimatedTotalSizeBytes) return null;
|
|
||||||
|
|
||||||
const bytesRemaining = p.estimatedTotalSizeBytes - (p.bytesDownloaded || 0);
|
|
||||||
if (bytesRemaining <= 0) return null;
|
|
||||||
|
|
||||||
const secondsRemaining = bytesRemaining / p.speed;
|
|
||||||
|
|
||||||
return formatTimeString(secondsRemaining, "s");
|
|
||||||
};
|
|
||||||
|
|
||||||
const base64Image = useMemo(() => {
|
|
||||||
return storage.getString(process.item.Id!);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => router.push(`/(auth)/items/page?id=${process.item.Id}`)}
|
|
||||||
className='relative bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden'
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{process.status === "downloading" && (
|
|
||||||
<View
|
|
||||||
className={`
|
|
||||||
bg-purple-600 h-1 absolute bottom-0 left-0
|
|
||||||
`}
|
|
||||||
style={{
|
|
||||||
width: process.progress
|
|
||||||
? `${Math.max(5, process.progress)}%`
|
|
||||||
: "5%",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<View className='px-3 py-1.5 flex flex-col w-full'>
|
|
||||||
<View className='flex flex-row items-center w-full'>
|
|
||||||
{base64Image && (
|
|
||||||
<View className='w-14 aspect-[10/15] rounded-lg overflow-hidden mr-4'>
|
|
||||||
<Image
|
|
||||||
source={{
|
|
||||||
uri: `data:image/jpeg;base64,${base64Image}`,
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
}}
|
|
||||||
contentFit='cover'
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
<View className='shrink mb-1'>
|
|
||||||
<Text className='text-xs opacity-50'>{process.item.Type}</Text>
|
|
||||||
<Text className='font-semibold shrink'>{process.item.Name}</Text>
|
|
||||||
<Text className='text-xs opacity-50'>
|
|
||||||
{process.item.ProductionYear}
|
|
||||||
</Text>
|
|
||||||
<View className='flex flex-row items-center space-x-2 mt-1 text-purple-600'>
|
|
||||||
{process.progress === 0 ? (
|
|
||||||
<ActivityIndicator size={"small"} color={"white"} />
|
|
||||||
) : (
|
|
||||||
<Text className='text-xs'>{process.progress.toFixed(0)}%</Text>
|
|
||||||
)}
|
|
||||||
{process.speed && process.speed > 0 && (
|
|
||||||
<Text className='text-xs'>
|
|
||||||
{bytesToMB(process.speed).toFixed(2)} MB/s
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{eta(process) && (
|
|
||||||
<Text className='text-xs'>
|
|
||||||
{t("home.downloads.eta", { eta: eta(process) })}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className='flex flex-row items-center space-x-2 mt-1 text-purple-600'>
|
|
||||||
<Text className='text-xs capitalize'>{process.status}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<View className='ml-auto flex flex-row items-center space-x-2'>
|
|
||||||
{process.status === "downloading" && (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => handlePause(process.id)}
|
|
||||||
className='p-2 rounded-full bg-yellow-600'
|
|
||||||
>
|
|
||||||
<Ionicons name='pause' size={20} color='white' />
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
{process.status === "paused" && (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => handleResume(process.id)}
|
|
||||||
className='p-2 rounded-full bg-green-600'
|
|
||||||
>
|
|
||||||
<Ionicons name='play' size={20} color='white' />
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => handleDelete(process.id)}
|
|
||||||
className='p-2 rounded-full bg-red-600'
|
|
||||||
>
|
|
||||||
<Ionicons name='close' size={20} color='white' />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
{process.status === "completed" && (
|
|
||||||
<View className='flex flex-row mt-4 space-x-4'>
|
|
||||||
<Button
|
|
||||||
onPress={() => {
|
|
||||||
startDownload(process);
|
|
||||||
}}
|
|
||||||
className='w-full'
|
|
||||||
>
|
|
||||||
Download now
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
199
components/downloads/DownloadCard.tsx
Normal file
199
components/downloads/DownloadCard.tsx
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { t } from "i18next";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Platform,
|
||||||
|
TouchableOpacity,
|
||||||
|
type TouchableOpacityProps,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
|
import { JobStatus } from "@/providers/Downloads/types";
|
||||||
|
import { storage } from "@/utils/mmkv";
|
||||||
|
import { formatTimeString } from "@/utils/time";
|
||||||
|
import { Button } from "../Button";
|
||||||
|
|
||||||
|
const bytesToMB = (bytes: number) => {
|
||||||
|
return bytes / 1024 / 1024;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DownloadCardProps extends TouchableOpacityProps {
|
||||||
|
process: JobStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||||
|
const { startDownload, pauseDownload, resumeDownload, removeProcess } =
|
||||||
|
useDownload();
|
||||||
|
const router = useRouter();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const handlePause = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await pauseDownload(id);
|
||||||
|
toast.success(t("home.downloads.toasts.download_paused"));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error pausing download:", error);
|
||||||
|
toast.error(t("home.downloads.toasts.could_not_pause_download"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResume = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await resumeDownload(id);
|
||||||
|
toast.success(t("home.downloads.toasts.download_resumed"));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error resuming download:", error);
|
||||||
|
toast.error(t("home.downloads.toasts.could_not_resume_download"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await removeProcess(id);
|
||||||
|
toast.success(t("home.downloads.toasts.download_deleted"));
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["downloads"] });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting download:", error);
|
||||||
|
toast.error(t("home.downloads.toasts.could_not_delete_download"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const eta = (p: JobStatus) => {
|
||||||
|
if (!p.speed || p.speed <= 0 || !p.estimatedTotalSizeBytes) return null;
|
||||||
|
|
||||||
|
const bytesRemaining = p.estimatedTotalSizeBytes - (p.bytesDownloaded || 0);
|
||||||
|
if (bytesRemaining <= 0) return null;
|
||||||
|
|
||||||
|
const secondsRemaining = bytesRemaining / p.speed;
|
||||||
|
|
||||||
|
return formatTimeString(secondsRemaining, "s");
|
||||||
|
};
|
||||||
|
|
||||||
|
const base64Image = useMemo(() => {
|
||||||
|
return storage.getString(process.item.Id!);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Sanitize progress to ensure it's within valid bounds
|
||||||
|
const sanitizedProgress = useMemo(() => {
|
||||||
|
if (
|
||||||
|
typeof process.progress !== "number" ||
|
||||||
|
Number.isNaN(process.progress)
|
||||||
|
) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return Math.max(0, Math.min(100, process.progress));
|
||||||
|
}, [process.progress]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => router.push(`/(auth)/items/page?id=${process.item.Id}`)}
|
||||||
|
className='relative bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden'
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{process.status === "downloading" && (
|
||||||
|
<View
|
||||||
|
className={`
|
||||||
|
bg-purple-600 h-1 absolute bottom-0 left-0
|
||||||
|
`}
|
||||||
|
style={{
|
||||||
|
width:
|
||||||
|
sanitizedProgress > 0
|
||||||
|
? `${Math.max(5, sanitizedProgress)}%`
|
||||||
|
: "5%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action buttons in bottom right corner */}
|
||||||
|
<View className='absolute bottom-2 right-2 flex flex-row items-center space-x-2 z-10'>
|
||||||
|
{process.status === "downloading" && Platform.OS !== "ios" && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => handlePause(process.id)}
|
||||||
|
className='p-1'
|
||||||
|
>
|
||||||
|
<Ionicons name='pause' size={20} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
{process.status === "paused" && Platform.OS !== "ios" && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => handleResume(process.id)}
|
||||||
|
className='p-1'
|
||||||
|
>
|
||||||
|
<Ionicons name='play' size={20} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => handleDelete(process.id)}
|
||||||
|
className='p-1'
|
||||||
|
>
|
||||||
|
<Ionicons name='close' size={20} color='red' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='px-3 py-1.5 flex flex-col w-full'>
|
||||||
|
<View className='flex flex-row items-center w-full'>
|
||||||
|
{base64Image && (
|
||||||
|
<View className='w-14 aspect-[10/15] rounded-lg overflow-hidden mr-4'>
|
||||||
|
<Image
|
||||||
|
source={{
|
||||||
|
uri: `data:image/jpeg;base64,${base64Image}`,
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
contentFit='cover'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View className='shrink mb-1 flex-1'>
|
||||||
|
<Text className='text-xs opacity-50'>{process.item.Type}</Text>
|
||||||
|
<Text className='font-semibold shrink'>{process.item.Name}</Text>
|
||||||
|
<Text className='text-xs opacity-50'>
|
||||||
|
{process.item.ProductionYear}
|
||||||
|
</Text>
|
||||||
|
<View className='flex flex-row items-center space-x-2 mt-1 text-purple-600'>
|
||||||
|
{sanitizedProgress === 0 ? (
|
||||||
|
<ActivityIndicator size={"small"} color={"white"} />
|
||||||
|
) : (
|
||||||
|
<Text className='text-xs'>{sanitizedProgress.toFixed(0)}%</Text>
|
||||||
|
)}
|
||||||
|
{process.speed && process.speed > 0 && (
|
||||||
|
<Text className='text-xs'>
|
||||||
|
{bytesToMB(process.speed).toFixed(2)} MB/s
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{eta(process) && (
|
||||||
|
<Text className='text-xs'>
|
||||||
|
{t("home.downloads.eta", { eta: eta(process) })}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='flex flex-row items-center space-x-2 mt-1 text-purple-600'>
|
||||||
|
<Text className='text-xs capitalize'>{process.status}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{process.status === "completed" && (
|
||||||
|
<View className='flex flex-row mt-4 space-x-4'>
|
||||||
|
<Button
|
||||||
|
onPress={() => {
|
||||||
|
startDownload(process);
|
||||||
|
}}
|
||||||
|
className='w-full'
|
||||||
|
>
|
||||||
|
Download now
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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,12 +21,12 @@ 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 {}
|
||||||
|
|
||||||
export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
|
export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
|
||||||
const [settings] = useSettings(null);
|
const { settings } = useSettings();
|
||||||
|
|
||||||
const ref = React.useRef<ICarouselInstance>(null);
|
const ref = React.useRef<ICarouselInstance>(null);
|
||||||
const progress = useSharedValue<number>(0);
|
const progress = useSharedValue<number>(0);
|
||||||
@@ -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],
|
||||||
|
|||||||
@@ -11,16 +11,12 @@ import {
|
|||||||
useJellyseerr,
|
useJellyseerr,
|
||||||
} from "@/hooks/useJellyseerr";
|
} from "@/hooks/useJellyseerr";
|
||||||
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||||
import type {
|
|
||||||
MovieResult,
|
|
||||||
TvResult,
|
|
||||||
} from "@/utils/jellyseerr/server/models/Search";
|
|
||||||
|
|
||||||
const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({
|
const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({
|
||||||
slide,
|
slide,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const { jellyseerrApi } = useJellyseerr();
|
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
|
||||||
|
|
||||||
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||||
queryKey: ["jellyseerr", "discover", slide.id],
|
queryKey: ["jellyseerr", "discover", slide.id],
|
||||||
@@ -69,7 +65,9 @@ const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({
|
|||||||
uniqBy(
|
uniqBy(
|
||||||
data?.pages
|
data?.pages
|
||||||
?.filter((p) => p?.results.length)
|
?.filter((p) => p?.results.length)
|
||||||
.flatMap((p) => p?.results),
|
.flatMap((p) =>
|
||||||
|
p?.results.filter((r) => isJellyseerrMovieOrTvResult(r)),
|
||||||
|
),
|
||||||
"id",
|
"id",
|
||||||
),
|
),
|
||||||
[data],
|
[data],
|
||||||
@@ -86,12 +84,7 @@ const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({
|
|||||||
onEndReached={() => {
|
onEndReached={() => {
|
||||||
if (hasNextPage) fetchNextPage();
|
if (hasNextPage) fetchNextPage();
|
||||||
}}
|
}}
|
||||||
renderItem={(item) => (
|
renderItem={(item) => <JellyseerrPoster item={item} key={item?.id} />}
|
||||||
<JellyseerrPoster
|
|
||||||
item={item as MovieResult | TvResult}
|
|
||||||
key={item?.id}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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} />
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ const icons: Record<CollectionType, IconName> = {
|
|||||||
export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const [settings] = useSettings(null);
|
const { settings } = useSettings();
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
className={`flex flex-row items-center justify-between bg-neutral-900 h-11 pr-4 pl-4 ${
|
className={`flex flex-row items-center justify-between bg-neutral-900 min-h-[42px] py-2 pr-4 pl-4 ${
|
||||||
disabled ? "opacity-50" : ""
|
disabled ? "opacity-50" : ""
|
||||||
}`}
|
}`}
|
||||||
{...(viewProps as any)}
|
{...(viewProps as any)}
|
||||||
@@ -54,7 +54,7 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className={`flex flex-row items-center justify-between bg-neutral-900 h-11 pr-4 pl-4 ${
|
className={`flex flex-row items-center justify-between bg-neutral-900 min-h-[42px] py-2 pr-4 pl-4 ${
|
||||||
disabled ? "opacity-50" : ""
|
disabled ? "opacity-50" : ""
|
||||||
}`}
|
}`}
|
||||||
{...viewProps}
|
{...viewProps}
|
||||||
@@ -106,7 +106,10 @@ const ListItemContent = ({
|
|||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
{subtitle && (
|
{subtitle && (
|
||||||
<Text className='text-[#9899A1] text-sm mt-0.5' numberOfLines={2}>
|
<Text
|
||||||
|
className='text-[#9899A1] text-[12px] mt-0.5'
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
{subtitle}
|
{subtitle}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { useAtom } from "jotai";
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { View, type ViewProps } from "react-native";
|
import { View, type ViewProps } from "react-native";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { InfiniteHorizontalScroll } from "../common/InfiniteHorrizontalScroll";
|
import { InfiniteHorizontalScroll } from "../common/InfiniteHorizontalScroll";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||||
import { ItemCardText } from "../ItemCardText";
|
import { ItemCardText } from "../ItemCardText";
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { MediaStatus } 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 { DownloadingItem } from "@/utils/jellyseerr/server/lib/downloadtracker";
|
import type { DownloadingItem } from "@/utils/jellyseerr/server/lib/downloadtracker";
|
||||||
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||||
|
import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
|
||||||
import type {
|
import type {
|
||||||
MovieResult,
|
MovieResult,
|
||||||
TvResult,
|
TvResult,
|
||||||
@@ -27,7 +28,7 @@ import type {
|
|||||||
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
item?: MovieResult | TvResult | MovieDetails | TvDetails;
|
item?: MovieResult | TvResult | MovieDetails | TvDetails | PersonCreditCast;
|
||||||
horizontal?: boolean;
|
horizontal?: boolean;
|
||||||
showDownloadInfo?: boolean;
|
showDownloadInfo?: boolean;
|
||||||
mediaRequest?: MediaRequest;
|
mediaRequest?: MediaRequest;
|
||||||
|
|||||||
@@ -10,9 +10,8 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
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,19 +23,21 @@ 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: BaseItemPerson[] = [];
|
const people: Record<string, BaseItemPerson> = {};
|
||||||
item?.People?.forEach((person) => {
|
item?.People?.forEach((person) => {
|
||||||
const existingPerson = people.find((p) => p.Id === person.Id);
|
if (!person.Id) return;
|
||||||
|
|
||||||
|
const existingPerson = people[person.Id];
|
||||||
if (existingPerson) {
|
if (existingPerson) {
|
||||||
existingPerson.Role = `${existingPerson.Role}, ${person.Role}`;
|
existingPerson.Role = `${existingPerson.Role}, ${person.Role}`;
|
||||||
} else {
|
} else {
|
||||||
people.push(person);
|
people[person.Id] = person;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return people;
|
return Object.values(people);
|
||||||
}, [item?.People]);
|
}, [item?.People]);
|
||||||
|
|
||||||
if (!from) return null;
|
if (!from) return null;
|
||||||
@@ -54,9 +55,12 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
|
|||||||
renderItem={(i) => (
|
renderItem={(i) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
const url = itemRouter(i, from);
|
if (i.Id) {
|
||||||
// @ts-expect-error
|
router.push({
|
||||||
router.push(url);
|
pathname: "/persons/[personId]",
|
||||||
|
params: { personId: i.Id },
|
||||||
|
});
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
className='flex flex-col w-28'
|
className='flex flex-col w-28'
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
|
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
|
||||||
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
import { HorizontalScroll } from "../common/HorizontalScroll";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import Poster from "../posters/Poster";
|
import Poster from "../posters/Poster";
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { orderBy } from "lodash";
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { Alert, TouchableOpacity, View } from "react-native";
|
import { Alert, TouchableOpacity, View } from "react-native";
|
||||||
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
|
import { HorizontalScroll } from "@/components/common/HorizontalScroll";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Tags } from "@/components/GenreTags";
|
import { Tags } from "@/components/GenreTags";
|
||||||
import { dateOpts } from "@/components/jellyseerr/DetailFacts";
|
import { dateOpts } from "@/components/jellyseerr/DetailFacts";
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
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,
|
||||||
type HorizontalScrollRef,
|
type HorizontalScrollRef,
|
||||||
} from "../common/HorrizontalScroll";
|
} from "../common/HorizontalScroll";
|
||||||
import { ItemCardText } from "../ItemCardText";
|
import { ItemCardText } from "../ItemCardText";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
@@ -42,11 +41,7 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
|||||||
return item?.SeasonId;
|
return item?.SeasonId;
|
||||||
}, [item]);
|
}, [item]);
|
||||||
|
|
||||||
const {
|
const { data: episodes, isPending } = useQuery({
|
||||||
data: episodes,
|
|
||||||
isLoading,
|
|
||||||
isFetching,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["episodes", seasonId, isOffline],
|
queryKey: ["episodes", seasonId, isOffline],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (isOffline) {
|
if (isOffline) {
|
||||||
@@ -61,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",
|
||||||
@@ -74,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);
|
||||||
@@ -132,7 +86,7 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
|||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
data={episodes}
|
data={episodes}
|
||||||
extraData={item}
|
extraData={item}
|
||||||
loading={loading || isLoading || isFetching}
|
loading={loading || isPending}
|
||||||
renderItem={(_item, _idx) => (
|
renderItem={(_item, _idx) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={_item.Id}
|
key={_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";
|
||||||
@@ -74,7 +73,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
|||||||
return season.Id!;
|
return season.Id!;
|
||||||
}, [seasons, seasonIndex]);
|
}, [seasons, seasonIndex]);
|
||||||
|
|
||||||
const { data: episodes, isFetching } = useQuery({
|
const { data: episodes, isPending } = useQuery({
|
||||||
queryKey: ["episodes", item.Id, selectedSeasonId],
|
queryKey: ["episodes", item.Id, selectedSeasonId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!api || !user?.Id || !item.Id || !selectedSeasonId) {
|
if (!api || !user?.Id || !item.Id || !selectedSeasonId) {
|
||||||
@@ -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)
|
||||||
@@ -98,32 +97,9 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
|||||||
|
|
||||||
return res.data.Items;
|
return res.data.Items;
|
||||||
},
|
},
|
||||||
select: (data) =>
|
|
||||||
[...(data || [])].sort(
|
|
||||||
(a, b) => (a.IndexNumber ?? 0) - (b.IndexNumber ?? 0),
|
|
||||||
),
|
|
||||||
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(() => {
|
||||||
@@ -169,7 +145,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
|||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
<View className='px-4 flex flex-col mt-4'>
|
<View className='px-4 flex flex-col mt-4'>
|
||||||
{isFetching ? (
|
{isPending ? (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
minHeight: 144 * nrOfEpisodes,
|
minHeight: 144 * nrOfEpisodes,
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ interface Props extends ViewProps {}
|
|||||||
|
|
||||||
export const AppLanguageSelector: React.FC<Props> = () => {
|
export const AppLanguageSelector: React.FC<Props> = () => {
|
||||||
const isTv = Platform.isTV;
|
const isTv = Platform.isTV;
|
||||||
const [settings, updateSettings] = useSettings(null);
|
const { settings, updateSettings } = useSettings();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (isTv) return null;
|
if (isTv) return null;
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
const isTv = Platform.isTV;
|
const isTv = Platform.isTV;
|
||||||
|
|
||||||
const media = useMedia();
|
const media = useMedia();
|
||||||
const [_, __, pluginSettings] = useSettings(null);
|
const { pluginSettings } = useSettings();
|
||||||
const { settings, updateSettings } = media;
|
const { settings, updateSettings } = media;
|
||||||
const cultures = media.cultures;
|
const cultures = media.cultures;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { ListGroup } from "../list/ListGroup";
|
|||||||
import { ListItem } from "../list/ListItem";
|
import { ListItem } from "../list/ListItem";
|
||||||
|
|
||||||
export const ChromecastSettings: React.FC = ({ ...props }) => {
|
export const ChromecastSettings: React.FC = ({ ...props }) => {
|
||||||
const [settings, updateSettings] = useSettings(null);
|
const { settings, updateSettings } = useSettings();
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<ListGroup title={"Chromecast"}>
|
<ListGroup title={"Chromecast"}>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { ListGroup } from "../list/ListGroup";
|
|||||||
import { ListItem } from "../list/ListItem";
|
import { ListItem } from "../list/ListItem";
|
||||||
|
|
||||||
export const Dashboard = () => {
|
export const Dashboard = () => {
|
||||||
const [settings, _updateSettings] = useSettings(null);
|
const { settings } = useSettings();
|
||||||
const { sessions = [] } = useSessions({} as useSessionsProps);
|
const { sessions = [] } = useSessions({} as useSessionsProps);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ import { ListGroup } from "../list/ListGroup";
|
|||||||
import { ListItem } from "../list/ListItem";
|
import { ListItem } from "../list/ListItem";
|
||||||
|
|
||||||
export default function DownloadSettings({ ...props }) {
|
export default function DownloadSettings({ ...props }) {
|
||||||
const [settings, updateSettings, pluginSettings] = useSettings(null);
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const allDisabled = useMemo(
|
const allDisabled = useMemo(
|
||||||
() =>
|
() =>
|
||||||
pluginSettings?.remuxConcurrentLimit?.locked === true &&
|
pluginSettings?.remuxConcurrentLimit?.locked === true &&
|
||||||
pluginSettings?.autoDownload.locked === true,
|
pluginSettings?.autoDownload?.locked === true,
|
||||||
[pluginSettings],
|
[pluginSettings],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ interface Props extends ViewProps {}
|
|||||||
export const GestureControls: React.FC<Props> = ({ ...props }) => {
|
export const GestureControls: React.FC<Props> = ({ ...props }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [settings, updateSettings, pluginSettings] = useSettings(null);
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
|
|
||||||
const disabled = useMemo(
|
const disabled = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -11,7 +12,6 @@ import {
|
|||||||
getUserLibraryApi,
|
getUserLibraryApi,
|
||||||
getUserViewsApi,
|
getUserViewsApi,
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
import NetInfo from "@react-native-community/netinfo";
|
|
||||||
import { type QueryFunction, useQuery } from "@tanstack/react-query";
|
import { type QueryFunction, useQuery } from "@tanstack/react-query";
|
||||||
import { useNavigation, useRouter, useSegments } from "expo-router";
|
import { useNavigation, useRouter, useSegments } from "expo-router";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
@@ -28,16 +28,17 @@ 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";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
|
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
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";
|
||||||
@@ -64,16 +65,7 @@ export const HomeIndex = () => {
|
|||||||
const user = useAtomValue(userAtom);
|
const user = useAtomValue(userAtom);
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [
|
const { settings, refreshStreamyfinPluginSettings } = useSettings();
|
||||||
settings,
|
|
||||||
_updateSettings,
|
|
||||||
_pluginSettings,
|
|
||||||
_setPluginSettings,
|
|
||||||
refreshStreamyfinPluginSettings,
|
|
||||||
] = useSettings(null);
|
|
||||||
|
|
||||||
const [isConnected, setIsConnected] = useState<boolean | null>(null);
|
|
||||||
const [loadingRetry, setLoadingRetry] = useState(false);
|
|
||||||
|
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
|
||||||
@@ -83,6 +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,
|
||||||
|
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
|
||||||
@@ -128,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 () => {
|
||||||
@@ -137,29 +138,6 @@ export const HomeIndex = () => {
|
|||||||
};
|
};
|
||||||
}, [segments]);
|
}, [segments]);
|
||||||
|
|
||||||
const checkConnection = useCallback(async () => {
|
|
||||||
setLoadingRetry(true);
|
|
||||||
const state = await NetInfo.fetch();
|
|
||||||
setIsConnected(state.isConnected);
|
|
||||||
setLoadingRetry(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const unsubscribe = NetInfo.addEventListener((state) => {
|
|
||||||
if (state.isConnected === false || state.isInternetReachable === false)
|
|
||||||
setIsConnected(false);
|
|
||||||
else setIsConnected(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
NetInfo.fetch().then((state) => {
|
|
||||||
setIsConnected(state.isConnected);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
unsubscribe();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
isError: e1,
|
isError: e1,
|
||||||
@@ -218,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,
|
||||||
})
|
})
|
||||||
@@ -262,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",
|
||||||
@@ -276,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 || [],
|
||||||
@@ -339,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: 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({
|
||||||
@@ -360,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,
|
||||||
});
|
});
|
||||||
@@ -378,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",
|
||||||
@@ -389,41 +378,57 @@ 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'>
|
||||||
<Button
|
{!Platform.isTV && (
|
||||||
color='purple'
|
<Button
|
||||||
onPress={() => router.push("/(auth)/downloads")}
|
color='purple'
|
||||||
justify='center'
|
onPress={() => router.push("/(auth)/downloads")}
|
||||||
iconRight={
|
justify='center'
|
||||||
<Ionicons name='arrow-forward' size={20} color='white' />
|
iconRight={
|
||||||
}
|
<Ionicons name='arrow-forward' size={20} color='white' />
|
||||||
>
|
}
|
||||||
{t("home.go_to_downloads")}
|
>
|
||||||
</Button>
|
{t("home.go_to_downloads")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
color='black'
|
color='black'
|
||||||
onPress={() => {
|
onPress={retryCheck}
|
||||||
checkConnection();
|
|
||||||
}}
|
|
||||||
justify='center'
|
justify='center'
|
||||||
className='mt-2'
|
className='mt-2'
|
||||||
iconRight={
|
iconRight={
|
||||||
loadingRetry ? null : (
|
retryLoading ? null : (
|
||||||
<Ionicons name='refresh' size={20} color='white' />
|
<Ionicons name='refresh' size={20} color='white' />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{loadingRetry ? (
|
{retryLoading ? (
|
||||||
<ActivityIndicator size={"small"} color={"white"} />
|
<ActivityIndicator size='small' color='white' />
|
||||||
) : (
|
) : (
|
||||||
"Retry"
|
t("home.retry")
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
@@ -453,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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export const JellyseerrSettings = () => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const [settings, updateSettings, _pluginSettings] = useSettings(null);
|
const { settings, updateSettings } = useSettings();
|
||||||
|
|
||||||
const [jellyseerrPassword, setJellyseerrPassword] = useState<
|
const [jellyseerrPassword, setJellyseerrPassword] = useState<
|
||||||
string | undefined
|
string | undefined
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -28,7 +28,7 @@ export const useMedia = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const MediaProvider = ({ children }: { children: ReactNode }) => {
|
export const MediaProvider = ({ children }: { children: ReactNode }) => {
|
||||||
const [settings, updateSettings] = useSettings(null);
|
const { settings, updateSettings } = useSettings();
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ interface Props extends ViewProps {}
|
|||||||
export const MediaToggles: React.FC<Props> = ({ ...props }) => {
|
export const MediaToggles: React.FC<Props> = ({ ...props }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [settings, updateSettings, pluginSettings] = useSettings(null);
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
|
|
||||||
const disabled = useMemo(
|
const disabled = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { ListItem } from "../list/ListItem";
|
|||||||
|
|
||||||
export const OtherSettings: React.FC = () => {
|
export const OtherSettings: React.FC = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [settings, updateSettings, pluginSettings] = useSettings(null);
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { ListGroup } from "../list/ListGroup";
|
|||||||
import { ListItem } from "../list/ListItem";
|
import { ListItem } from "../list/ListItem";
|
||||||
|
|
||||||
export const PluginSettings = () => {
|
export const PluginSettings = () => {
|
||||||
const [settings, _updateSettings] = useSettings(null);
|
const { settings } = useSettings();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
|||||||
@@ -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,15 +16,26 @@ 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;
|
||||||
|
|
||||||
const media = useMedia();
|
const media = useMedia();
|
||||||
const [_, __, pluginSettings] = useSettings(null);
|
const { pluginSettings } = useSettings();
|
||||||
const { settings, updateSettings } = media;
|
const { settings, updateSettings } = media;
|
||||||
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,12 +13,13 @@ 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 />,
|
||||||
};
|
};
|
||||||
|
|
||||||
const routes = ["actors/[actorId]", "items/page", "series/[id]"];
|
const routes = ["persons/[personId]", "items/page", "series/[id]"];
|
||||||
|
|
||||||
export const nestedTabPageScreenOptions: Record<string, ICommonScreenOptions> =
|
export const nestedTabPageScreenOptions: Record<string, ICommonScreenOptions> =
|
||||||
Object.fromEntries(routes.map((route) => [route, commonScreenOptions]));
|
Object.fromEntries(routes.map((route) => [route, commonScreenOptions]));
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user