Compare commits
3 Commits
build-perf
...
no-tv
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83a264d5a1 | ||
|
|
2d434a0125 | ||
|
|
0d7edca1ad |
22
.claude/settings.local.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(rm:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(for file in /Users/fredrikburmester/Documents/GitHub/streamyfin/translations/*.json)",
|
||||
"Bash(do)",
|
||||
"Bash(if grep -q \"live_tv\" \"$file\")",
|
||||
"Bash(then)",
|
||||
"Bash(echo \"Processing $file\")",
|
||||
"Bash(fi)",
|
||||
"Bash(done)",
|
||||
"Bash(bun run:*)",
|
||||
"Bash(pod install:*)",
|
||||
"Bash(bun install:*)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(cat:*)"
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1 @@
|
||||
# Streamyfin-specific debug flag
|
||||
EXPO_PUBLIC_WRITE_DEBUG=1
|
||||
|
||||
# Performance optimization (official Metro flag)
|
||||
EXPO_USE_METRO_REQUIRE=1
|
||||
|
||||
# TV development support (used in metro.config.js)
|
||||
# EXPO_TV=1
|
||||
# Uncomment the above line ONLY when working on TV features. Leave commented for mobile-only development to avoid issues.
|
||||
|
||||
# Fast resolver optimization (2025 feature)
|
||||
EXPO_USE_FAST_RESOLVER=1
|
||||
|
||||
# Bundle analysis for monitoring
|
||||
EXPO_ATLAS=1
|
||||
EXPO_PUBLIC_WRITE_DEBUG=1
|
||||
@@ -1,26 +1 @@
|
||||
# Streamyfin Production Configuration
|
||||
EXPO_PUBLIC_WRITE_DEBUG=0
|
||||
|
||||
# Production Performance Optimizations
|
||||
NODE_ENV=production
|
||||
EXPO_USE_METRO_REQUIRE=1
|
||||
EXPO_USE_FAST_RESOLVER=1
|
||||
|
||||
# Production Build Optimizations
|
||||
EXPO_OPTIMIZE_BUNDLE_SIZE=1
|
||||
EXPO_NO_CLIENT_ENV_VARS=1
|
||||
EXPO_LEGACY_BUNDLER=0
|
||||
|
||||
# Bundle Analysis (for monitoring)
|
||||
EXPO_ATLAS=0
|
||||
|
||||
# Production Cache Optimizations
|
||||
METRO_CACHE=1
|
||||
|
||||
# Security & Performance
|
||||
EXPO_NO_DOTENV=1
|
||||
FAST_REFRESH=0
|
||||
|
||||
# Production Bundle Features
|
||||
EXPO_USE_HERMES=1
|
||||
EXPO_MINIFY=1
|
||||
EXPO_PUBLIC_WRITE_DEBUG=0
|
||||
3
.eslintrc.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals"]
|
||||
}
|
||||
162
.github/CONTRIBUTING.md
vendored
@@ -1,162 +0,0 @@
|
||||
# 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.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Reporting Issues](#reporting-issues)
|
||||
- [Reporting Security Vulnerabilities](#reporting-security-vulnerabilities)
|
||||
- [Requesting Features & Enhancements](#requesting-features--enhancements)
|
||||
- [Developing the Mobile App](#developing-the-mobile-app)
|
||||
- [Codebase Overview](#codebase-overview)
|
||||
- [Setting Up Your Development Environment](#setting-up-your-development-environment)
|
||||
- [Making Changes](#making-changes)
|
||||
- [Pull Request Guidelines](#pull-request-guidelines)
|
||||
- [Release Process](#release-process)
|
||||
- [Getting Help and Community](#getting-help-and-community)
|
||||
|
||||
---
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
Streamyfin uses GitHub issues to track bugs and improvements. Before opening a new issue:
|
||||
|
||||
- Search existing issues for duplicates.
|
||||
- Provide clear, reproducible steps to demonstrate bugs.
|
||||
- Include device info, OS version, Streamyfin version, and any relevant logs.
|
||||
- Apply the `bug` label to the issue for easier triage; no title prefix needed.
|
||||
|
||||
If you're unsure about how to report an issue or need help, reach out to the community via our chat links.
|
||||
|
||||
### Reporting Security Vulnerabilities
|
||||
|
||||
Please do not file public GitHub issues for security vulnerabilities.
|
||||
|
||||
Report security concerns via GitHub Security Advisories (Repository → Security → Report a vulnerability). Provide steps to reproduce, affected versions, and mitigation ideas if available. We’ll acknowledge receipt and coordinate a fix before public disclosure.
|
||||
|
||||
If Security Advisories are unavailable for you, contact the maintainers via the email listed in SECURITY.md.---
|
||||
|
||||
## Requesting Features & Enhancements
|
||||
|
||||
Please submit feature and enhancement requests as GitHub issues labeled `enhancement`.
|
||||
|
||||
When creating a new feature request:
|
||||
|
||||
- Check if the idea or similar request exists.
|
||||
- Use reactions like 👍 to support existing requests.
|
||||
- Provide a clear explanation of the use case and benefits.
|
||||
|
||||
---
|
||||
|
||||
## Developing the Mobile App
|
||||
|
||||
### Codebase Overview
|
||||
|
||||
Streamyfin is built primarily using Expo and React Native to support both iOS and Android platforms within a single repository. The app communicates directly with Jellyfin backend servers for media streaming.
|
||||
|
||||
### Setting Up Your Development Environment
|
||||
|
||||
1. Fork the Streamyfin repository on GitHub. If prompted with “Copy the main branch only,” uncheck it so all branches are copied.
|
||||
2. Clone your fork:
|
||||
|
||||
```
|
||||
|
||||
git clone git@github.com:yourusername/streamyfin.git
|
||||
# or
|
||||
git clone https://github.com/yourusername/streamyfin.git
|
||||
cd streamyfin
|
||||
|
||||
```
|
||||
|
||||
3. Initialize submodules and install dependencies:
|
||||
|
||||
```
|
||||
bun run submodule-reload
|
||||
bun install
|
||||
```
|
||||
|
||||
4. Start the development server locally (with Expo):
|
||||
|
||||
```
|
||||
bun ios / bun android
|
||||
```
|
||||
|
||||
> Optionally, to run directly on a device or emulator:
|
||||
>
|
||||
> ```
|
||||
> # For iOS (requires macOS and Xcode):
|
||||
> bun run ios
|
||||
> # For Android (requires Android Studio or Android Debug Bridge (ADB) tool, plus an emulator or physical device):
|
||||
> bun run android
|
||||
> ```
|
||||
|
||||
5. Use the Expo app on your mobile device or emulator to run and debug Streamyfin.
|
||||
|
||||
### Making Changes
|
||||
|
||||
1. Stay up to date by syncing with upstream:
|
||||
|
||||
```bash
|
||||
# Add the upstream remote only once (skip if already added)
|
||||
git remote add upstream https://github.com/streamyfin/streamyfin.git
|
||||
# Fetch latest changes from upstream
|
||||
git fetch upstream
|
||||
# Rebase your current branch onto the upstream default branch (replace 'develop' if you are working from another upstream branch)
|
||||
git rebase upstream/develop
|
||||
```
|
||||
|
||||
2. Create a descriptive feature or bugfix branch:
|
||||
|
||||
```
|
||||
|
||||
git checkout -b feat/feature-name
|
||||
|
||||
```
|
||||
|
||||
3. Commit changes with clear, concise messages using imperative mood.
|
||||
4. Push changes to your fork:
|
||||
|
||||
```
|
||||
|
||||
git push --set-upstream origin feat/feature-name
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pull Request Guidelines
|
||||
|
||||
When opening a PR:
|
||||
|
||||
- Title should clearly summarize the change.
|
||||
- Reference any related issue(s) using keywords like `closes #123`.
|
||||
- Follow our [Conventional Commits](https://www.conventionalcommits.org/) style, e.g., `feat: add new playback controls`.
|
||||
- Provide a detailed description in the PR body, explaining what, why, and any impacts.
|
||||
- Include screenshots or recordings if UI changes are involved.
|
||||
- Ensure CI checks are green (lint, type-check, build).
|
||||
- Do not include secrets, tokens, or production credentials. Redact sensitive data in logs and screenshots.
|
||||
- Keep PRs focused; avoid bundling unrelated changes together.
|
||||
|
||||
PRs require review and approval by maintainers before merging.---
|
||||
|
||||
## Release Process
|
||||
|
||||
- Streamyfin follows semantic versioning (`MAJOR.MINOR.PATCH`).
|
||||
- Releases are made periodically after testing and QA cycles.
|
||||
- Tag each release and publish a GitHub Release with a changelog.
|
||||
- Consider automating versioning and changelogs (e.g., Changesets or semantic-release).
|
||||
- Release announcements are posted on our repository and community channels.
|
||||
- Contributions accepted through PRs will be included in upcoming releases according to readiness.
|
||||
|
||||
---
|
||||
|
||||
## Getting Help and Community
|
||||
|
||||
- Join our community chat channels on [Discord](https://discord.streamyfin.app) for questions and support.
|
||||
- Use GitHub discussions or open issues to get assistance or report problems.
|
||||
|
||||
---
|
||||
|
||||
Thank you for helping make Streamyfin a better app for everyone!
|
||||
64
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
name: Bug report
|
||||
description: Create a report to help us improve
|
||||
title: "[Bug]: "
|
||||
labels:
|
||||
- ["❌ bug"]
|
||||
projects:
|
||||
- ["streamyfin/3"]
|
||||
|
||||
body:
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: What happened?
|
||||
description: Also tell us, what did you expect to happen?
|
||||
placeholder: A clear and concise description of what the bug is.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: repro
|
||||
attributes:
|
||||
label: Reproduction steps
|
||||
description: "How do you trigger this bug? Please walk us through it step by step."
|
||||
placeholder: |
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: device
|
||||
attributes:
|
||||
label: Which device and operating system are you using?
|
||||
description: e.g. iPhone 15, iOS 18.1.1
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: version
|
||||
attributes:
|
||||
label: Version
|
||||
description: What version of Streamyfin are you running?
|
||||
options:
|
||||
- 0.29.0
|
||||
- 0.28.0
|
||||
- 0.27.0
|
||||
- 0.26.1
|
||||
- 0.26.0
|
||||
- 0.25.0
|
||||
- 0.24.0
|
||||
- 0.23.0
|
||||
- 0.22.0
|
||||
- 0.21.0
|
||||
- older
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: If applicable, please add screenshots to help explain your problem.
|
||||
You can drag and drop images here or paste them directly into the comment box.
|
||||
8
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,8 +0,0 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 🤔 Questions and Help
|
||||
url: https://discord.streamyfin.app
|
||||
about: Support questions? Please use our Streamyfin Discord community for help.
|
||||
- name: 🛡️ Security vulnerability report
|
||||
url: https://github.com/streamyfin/streamyfin/security/policy
|
||||
about: Please report security vulnerabilities privately via our Security Policy for responsible disclosure.
|
||||
15
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: '✨ enhancement'
|
||||
assignees: ''
|
||||
projects:
|
||||
- streamyfin/3
|
||||
---
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
87
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -1,87 +0,0 @@
|
||||
name: "🚀 Feature Request"
|
||||
description: Suggest an idea for this project
|
||||
title: "[REQUEST]: "
|
||||
labels: ["✨ enhancement"]
|
||||
projects:
|
||||
- "streamyfin/3"
|
||||
body:
|
||||
- type: markdown
|
||||
id: introduction
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to fill out this feature request!
|
||||
Please keep in mind that Streamyfin is a [free and open-source](https://github.com/streamyfin/streamyfin) project, made up entirely and exclusively of **volunteers** who donate their free time to the project.
|
||||
- type: checkboxes
|
||||
id: before-posting
|
||||
attributes:
|
||||
label: "This feature request respects the following points:"
|
||||
description: All conditions are **required**. Failure to comply with any of these conditions may cause your feature request to be closed without comment.
|
||||
options:
|
||||
- label: This is a **feature request**, not a question or a configuration issue; Please visit our community channels first to troubleshoot with volunteers, before creating a report. The links can be found in our [Discord](https://discord.streamyfin.app).
|
||||
required: true
|
||||
- label: This issue is **not** already reported on [GitHub](https://github.com/streamyfin/streamyfin/issues?q=is%3Aissue+is%3Aopen+label%3A"✨%20enhancement") _(I've searched it)_.
|
||||
required: true
|
||||
- label: I'm using an up-to-date version of Streamyfin. We generally do not support older versions. If possible, please update to the latest version before opening an issue.
|
||||
required: true
|
||||
- label: I agree to follow Streamyfin's [Contribution Guidelines](https://github.com/streamyfin/streamyfin/blob/develop/.github/CONTRIBUTING.md).
|
||||
required: true
|
||||
- label: This report addresses only a single feature request; If you have multiple feature requests, kindly create separate reports for each one.
|
||||
required: true
|
||||
- type: markdown
|
||||
id: preliminary-information
|
||||
attributes:
|
||||
value: |
|
||||
### General preliminary information
|
||||
|
||||
|
||||
Please keep the following in mind when creating this issue:
|
||||
|
||||
|
||||
1. Fill in as much of the template as possible.
|
||||
2. Provide as much detail as possible. Do not assume other people to know what is going on.
|
||||
3. Keep everything readable and structured. Nobody enjoys reading poorly written reports that are difficult to understand.
|
||||
4. Keep an eye on your report as long as it is open, your involvement might be requested at a later moment.
|
||||
5. Keep the title short and descriptive. The title is not the place to write down a full description of the issue.
|
||||
6. When choosing to omit information in a field, write 'n/a' to explicitly indicate the deliberate absence of data. Avoid leaving the field blank or empty.
|
||||
- type: textarea
|
||||
id: feature-description
|
||||
attributes:
|
||||
label: Description of the feature request
|
||||
description: Please provide a detailed description of the feature request, in a readable and comprehensible way.
|
||||
placeholder: |
|
||||
I would like to see a new feature that allows users to [...]
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: related-problems
|
||||
attributes:
|
||||
label: Is your feature request related to a problem?
|
||||
description: A clear and concise description of what the problem is.
|
||||
placeholder: |
|
||||
I'm always frustrated when [...]
|
||||
- type: textarea
|
||||
id: solution-description
|
||||
attributes:
|
||||
label: Describe the solution you'd like
|
||||
description: A clear and concise description of what you want to happen.
|
||||
placeholder: |
|
||||
I would like to see [...]
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: alternative-description
|
||||
attributes:
|
||||
label: Describe alternatives you've considered
|
||||
description: A clear and concise description of any alternative solutions or features you've considered.
|
||||
placeholder: |
|
||||
I've considered [...]
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: Relevant screenshots or videos
|
||||
description: Attach relevant screenshots or videos related to this report (drag-and-drop or paste into the editor).
|
||||
- type: textarea
|
||||
id: additional-information
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: Any additional information that might be useful to this feature request.
|
||||
119
.github/ISSUE_TEMPLATE/issue_report.yml
vendored
@@ -1,119 +0,0 @@
|
||||
name: "🐛 Bug Report"
|
||||
description: Create a report to help us improve
|
||||
title: "[Bug]: "
|
||||
labels:
|
||||
- "🐛 bug"
|
||||
projects:
|
||||
- "streamyfin/3"
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
id: introduction
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to fill out this bug report!
|
||||
Please keep in mind that Streamyfin is a [free and open-source](https://github.com/streamyfin/streamyfin) project maintained entirely by **volunteers** who donate their free time.
|
||||
|
||||
- type: checkboxes
|
||||
id: before-posting
|
||||
attributes:
|
||||
label: "This issue respects the following points:"
|
||||
description: All conditions are **required**. Failure to comply with any of these conditions may cause your issue to be closed without comment.
|
||||
options:
|
||||
- label: This is a **bug**, not a question or configuration issue; please consult our community channels before filing a report. [Discord](https://discord.streamyfin.app).
|
||||
required: true
|
||||
- label: This issue is **not** already reported on [GitHub](https://github.com/streamyfin/streamyfin/issues?q=is%3Aissue+is%3Aopen+label%3A"🐛%20bug") *(I've searched it)*.
|
||||
required: true
|
||||
- label: I'm using an up-to-date version of Streamyfin. We generally do not support older versions. If possible, please update to the latest version before opening an issue.
|
||||
required: true
|
||||
- label: I agree to follow Streamyfin's [Contribution rules](https://github.com/streamyfin/streamyfin/blob/develop/.github/CONTRIBUTING.md).
|
||||
required: true
|
||||
- label: This report addresses only a single issue; If you encounter multiple issues, please create separate reports for each one.
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: What happened?
|
||||
description: A clear and concise description of what the bug is.
|
||||
placeholder: Describe what happened in detail.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: what-expected
|
||||
attributes:
|
||||
label: What did you expect to happen?
|
||||
description: Tell us what you expected to happen instead.
|
||||
placeholder: Describe the expected behavior clearly.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: repro
|
||||
attributes:
|
||||
label: Reproduction steps
|
||||
description: "How do you trigger this bug? Please walk us through it step by step."
|
||||
placeholder: |
|
||||
1. Open Streamyfin app
|
||||
2. Navigate to [specific section]
|
||||
3. Tap on [specific item]
|
||||
4. See error
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: device
|
||||
attributes:
|
||||
label: Which device and operating system are you using?
|
||||
description: Please provide your device model and OS version
|
||||
placeholder: e.g. iPhone 15 Pro, iOS 18.1.1 or Samsung Galaxy S24, Android 14
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: version
|
||||
attributes:
|
||||
label: Streamyfin Version
|
||||
description: What version of Streamyfin are you running?
|
||||
options:
|
||||
- 0.30.2
|
||||
- 0.29.0
|
||||
- 0.28.0
|
||||
- 0.27.0
|
||||
- 0.26.1
|
||||
- 0.26.0
|
||||
- 0.25.0
|
||||
- older
|
||||
- TestFlight/Development build
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: jellyfin-info
|
||||
attributes:
|
||||
label: Jellyfin Server Information
|
||||
description: Please provide details about your Jellyfin server
|
||||
placeholder: |
|
||||
- Jellyfin Server Version: e.g. 10.10.7
|
||||
- Server OS: e.g. Ubuntu 22.04, Windows 11, Docker
|
||||
- Connection: e.g. Local network, Remote via domain, VPN
|
||||
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: Screenshots or Videos
|
||||
description: If applicable, please add screenshots or videos to help explain your problem. You can drag and drop images here or paste them directly into the comment box.
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Relevant logs (if available)
|
||||
description: If you have access to app logs or crash reports, please include them here. **Remember to remove any personal information like server URLs or usernames.**
|
||||
render: shell
|
||||
|
||||
- type: textarea
|
||||
id: additional-info
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: Any additional context that might help us understand and reproduce the issue.
|
||||
96
.github/copilot-instructions.md
vendored
@@ -1,96 +0,0 @@
|
||||
# Copilot Instructions for Streamyfin
|
||||
|
||||
## Project Overview
|
||||
|
||||
Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native).
|
||||
It supports mobile (iOS/Android) and TV platforms, integrates with Jellyfin and Jellyseerr APIs,
|
||||
and provides seamless media streaming with offline capabilities and Chromecast support.
|
||||
|
||||
## Main Technologies
|
||||
|
||||
- **Runtime**: Bun (JavaScript/TypeScript execution)
|
||||
- **Framework**: React Native (Expo)
|
||||
- **Language**: TypeScript (strict mode)
|
||||
- **State Management**: Jotai (global state) + React Query (server state)
|
||||
- **API SDK**: Jellyfin SDK (TypeScript)
|
||||
- **Navigation**: Expo Router (file-based routing)
|
||||
- **Code Quality**: BiomeJS (formatting/linting)
|
||||
- **Build Platform**: EAS (Expo Application Services)
|
||||
- **CI/CD**: GitHub Actions with Bun
|
||||
|
||||
## Package Management
|
||||
|
||||
**CRITICAL: ALWAYS use `bun` for all package management operations**
|
||||
|
||||
- **NEVER use `npm`, `yarn` or `npx` commands**
|
||||
- Use `bun install` instead of `npm install` or `yarn install`
|
||||
- Use `bun add <package>` instead of `npm install <package>`
|
||||
- Use `bun remove <package>` instead of `npm uninstall <package>`
|
||||
- Use `bun run <script>` instead of `npm run <script>`
|
||||
- Use `bunx <command>` instead of `npx <command>`
|
||||
- For Expo: use `bunx create-expo-app` or `bunx @expo/cli`
|
||||
|
||||
## Code Structure
|
||||
|
||||
- `app/` – Main application code (screens, navigation, etc.)
|
||||
- `components/` – Reusable UI components
|
||||
- `providers/` – Context and API providers (e.g., JellyfinProvider.tsx)
|
||||
- `utils/` – Utility functions and Jotai atoms
|
||||
- `assets/` – Images and static assets
|
||||
- `scripts/` – Automation scripts (Node.js, Bash)
|
||||
- `plugins/` – Expo/Metro plugins
|
||||
|
||||
## Coding Standards
|
||||
|
||||
- Use TypeScript for ALL files (no .js files)
|
||||
- Use descriptive English names for variables, functions, and components
|
||||
- Prefer functional React components with hooks
|
||||
- Use Jotai atoms for global state management
|
||||
- Use React Query for server state and caching
|
||||
- Follow BiomeJS formatting and linting rules
|
||||
- Use `const` over `let`, avoid `var` entirely
|
||||
- Implement proper error boundaries
|
||||
- Use React.memo() for performance optimization
|
||||
- Handle both mobile and TV navigation patterns
|
||||
|
||||
## API Integration
|
||||
|
||||
- Use Jellyfin SDK for all server interactions
|
||||
- Access authenticated APIs via `apiAtom` and `userAtom` from JellyfinProvider
|
||||
- Implement proper loading states and error handling
|
||||
- Use React Query for caching and background updates
|
||||
- Handle offline scenarios gracefully
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
- Leverage Bun's superior runtime performance
|
||||
- Optimize FlatList components with proper props
|
||||
- Use lazy loading for non-critical components
|
||||
- Implement proper image caching strategies
|
||||
- Monitor bundle size and use tree-shaking effectively
|
||||
|
||||
## Testing
|
||||
|
||||
- Use Bun's built-in test runner when possible
|
||||
- Test files: `*.test.ts` or `*.test.tsx`
|
||||
- Run tests with: `bun test`
|
||||
- Mock external APIs in tests
|
||||
- Focus on testing business logic and custom hooks
|
||||
|
||||
## Commit Messages
|
||||
|
||||
Use [Conventional Commits](https://www.conventionalcommits.org/):
|
||||
Exemples:
|
||||
- `feat(player): add Chromecast support`
|
||||
- `fix(auth): handle expired JWT tokens`
|
||||
- `chore(deps): update Jellyfin SDK`
|
||||
|
||||
## Special Instructions
|
||||
|
||||
- Prioritize cross-platform compatibility (mobile + TV)
|
||||
- Ensure accessibility for TV remote navigation
|
||||
- Use existing atoms, hooks, and utilities before creating new ones
|
||||
- Maintain compatibility with Expo and EAS workflows
|
||||
- Always verify Bun compatibility when suggesting new dependencies
|
||||
|
||||
**Copilot: Please use these instructions to provide context-aware suggestions and code completions for this repository.**
|
||||
12
.github/crowdin.yml
vendored
@@ -1,12 +0,0 @@
|
||||
"project_id_env": "CROWDIN_PROJECT_ID"
|
||||
"api_token_env": "CROWDIN_PERSONAL_TOKEN"
|
||||
"base_path": "."
|
||||
|
||||
"preserve_hierarchy": true
|
||||
|
||||
"files": [
|
||||
{
|
||||
"source": "translations/en.json",
|
||||
"translation": "translations/%two_letters_code%.json"
|
||||
}
|
||||
]
|
||||
91
.github/pull_request_template.md
vendored
@@ -1,91 +0,0 @@
|
||||
<!--
|
||||
Pull Request Template for Streamyfin
|
||||
====================================
|
||||
Use this template to help reviewers understand the purpose of your PR
|
||||
and to ensure all necessary checks are completed before merging.
|
||||
-->
|
||||
|
||||
# 📦 Pull Request
|
||||
|
||||
## 🔖 Summary
|
||||
<!--
|
||||
A concise description of the changes introduced by this PR.
|
||||
Example:
|
||||
“Add real-time currency conversion widget to dashboard.”
|
||||
-->
|
||||
|
||||
## 🏷️ Ticket / Issue
|
||||
<!--
|
||||
Link to the related ticket, issue or user story.
|
||||
You can also indicate if this PR supersedes a previous one.
|
||||
Example:
|
||||
- Closes #123
|
||||
- Fixes STREAMYFIN-456
|
||||
- Resolves #789
|
||||
- Supersedes #120
|
||||
- Related: #130
|
||||
-->
|
||||
|
||||
## 🛠️ What’s Changed
|
||||
<!-- Use a Conventional Commit in the PR title, e.g., `feat(auth): add MFA`.
|
||||
If this PR introduces a breaking change, include a `BREAKING CHANGE:` block in the description.
|
||||
Spec: https://www.conventionalcommits.org/ -->
|
||||
|
||||
- Type: feat | fix | docs | style | refactor | perf | test | chore | build | ci | revert
|
||||
- Scope (optional): e.g., auth, billing, mobile
|
||||
- Short summary: what changed and why (1–2 lines)
|
||||
-->
|
||||
|
||||
## 📋 Details
|
||||
<!--
|
||||
Provide more context or background. Explain any non-obvious decisions.
|
||||
Include screenshots or GIFs for UI changes if applicable.
|
||||
-->
|
||||
|
||||
### ⚠️ Breaking Changes
|
||||
<!-- List any breaking API/contract changes and migration guidance. If none, write “None”. -->
|
||||
|
||||
### 🔐 Security & Privacy Impact
|
||||
<!-- Data touched, new permissions/scopes, PII, secrets, threat considerations. If none, write “None”. -->
|
||||
|
||||
### ⚡ Performance Impact
|
||||
<!-- Hot paths, memory/CPU/latency implications, benchmarks if available. -->
|
||||
|
||||
### 🖼️ Screenshots / GIFs (if UI)
|
||||
<!-- Before/After, dark mode, responsive states. -->
|
||||
|
||||
## ✅ Checklist
|
||||
<!--
|
||||
Review and check off items as you complete them.
|
||||
-->
|
||||
- [ ] I’ve read the [contribution guidelines](CONTRIBUTING.md)
|
||||
- [ ] Code follows project style and passes lint/format (`npm|pnpm|yarn|bun` scripts)
|
||||
- [ ] Type checks pass (tsc/biome/etc.)
|
||||
- [ ] Docs updated (README/ADR/usage/API)
|
||||
- [ ] No secrets/credentials included; env vars documented
|
||||
- [ ] Release notes/CHANGELOG entry added (if applicable)
|
||||
- [ ] Verified locally that changes behave as expected
|
||||
|
||||
## 🔍 Testing Instructions
|
||||
<!--
|
||||
Describe how reviewers can test your changes.
|
||||
Example:
|
||||
1. `git fetch origin pull/<PR_ID>/head:branchname && git checkout branchname`
|
||||
2. Install deps: `npm|pnpm|yarn|bun install`
|
||||
3. Start service/app: `npm|pnpm|yarn|bun run [target]` (e.g., `npm run ios` or `bun run android:tv`)
|
||||
4. Run tests: `npm|pnpm|yarn|bun test`
|
||||
5. Verification steps:
|
||||
- [ ] Expected UI/endpoint behavior
|
||||
- [ ] Logs show no errors
|
||||
- [ ] Edge cases covered (list)
|
||||
-->
|
||||
|
||||
## ⚙️ Deployment Notes
|
||||
<!--
|
||||
Describe any deployment considerations such as config, environment vars, or native builds.
|
||||
-->
|
||||
|
||||
## 📝 Additional Notes
|
||||
<!--
|
||||
Any other information or references related to this PR.
|
||||
-->
|
||||
46
.github/renovate.json
vendored
@@ -1,46 +0,0 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"description": "Renovate configuration for Streamyfin - Expo React Native Jellyfin client",
|
||||
"extends": [
|
||||
"config:best-practices",
|
||||
":dependencyDashboard",
|
||||
":enableVulnerabilityAlertsWithLabel(security)",
|
||||
":semanticCommits",
|
||||
":timezone(Etc/UTC)",
|
||||
"group:testNonMajor",
|
||||
"group:monorepos",
|
||||
"helpers:pinGitHubActionDigests",
|
||||
"customManagers:biomeVersions",
|
||||
":automergeBranch",
|
||||
":automergeRequireAllStatusChecks"
|
||||
],
|
||||
"addLabels": ["dependencies"],
|
||||
"rebaseWhen": "conflicted",
|
||||
"ignorePaths": ["**/node_modules/**"],
|
||||
"ignoreUnstable": true,
|
||||
"minimumReleaseAge": "3 days",
|
||||
"schedule": ["before 6am on Sunday"],
|
||||
"branchPrefix": "renovate/",
|
||||
"commitMessagePrefix": "chore(deps):",
|
||||
"osvVulnerabilityAlerts": true,
|
||||
"configMigration": true,
|
||||
"separateMinorPatch": true,
|
||||
"lockFileMaintenance": {
|
||||
"vulnerabilityAlerts": {
|
||||
"enabled": true,
|
||||
"addLabels": ["security", "vulnerability"],
|
||||
"assigneesFromCodeOwners": true,
|
||||
"commitMessageSuffix": " [SECURITY]"
|
||||
},
|
||||
"packageRules": [
|
||||
{
|
||||
"description": "Group minor and patch GitHub Action updates into a single PR",
|
||||
"matchManagers": ["github-actions"],
|
||||
"groupName": "CI dependencies",
|
||||
"groupSlug": "ci-deps",
|
||||
"matchUpdateTypes": ["minor", "patch", "digest", "pin"],
|
||||
"automerge": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
478
.github/workflows/artifact-comment.yml
vendored
@@ -1,478 +0,0 @@
|
||||
name: 📝 Artifact Comment on PR
|
||||
|
||||
concurrency:
|
||||
group: artifact-comment-${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
workflow_dispatch: # Allow manual testing
|
||||
pull_request: # Show in PR checks and provide status updates
|
||||
types: [opened, synchronize, reopened]
|
||||
workflow_run: # Triggered when build workflows complete
|
||||
workflows:
|
||||
- "🏗️ Build Apps"
|
||||
types:
|
||||
- completed
|
||||
|
||||
jobs:
|
||||
comment-artifacts:
|
||||
if: github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request' || (github.event_name == 'workflow_run' && github.event.workflow_run.event == 'pull_request')
|
||||
name: 📦 Post Build Artifacts
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
actions: read
|
||||
|
||||
steps:
|
||||
- name: 🔍 Get PR and Artifacts
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
with:
|
||||
script: |
|
||||
// Check if we're running from a fork (more precise detection)
|
||||
const targetRepo = context.repo.owner + '/' + context.repo.repo;
|
||||
const prHeadRepo = context.payload.pull_request?.head?.repo?.full_name;
|
||||
const workflowHeadRepo = context.payload.workflow_run?.head_repository?.full_name;
|
||||
|
||||
// For debugging
|
||||
console.log('🔍 Repository detection:');
|
||||
console.log('- Target repository:', targetRepo);
|
||||
console.log('- PR head repository:', prHeadRepo || 'N/A');
|
||||
console.log('- Workflow head repository:', workflowHeadRepo || 'N/A');
|
||||
console.log('- Event name:', context.eventName);
|
||||
|
||||
// Only skip if it's actually a different repository (fork)
|
||||
const isFromFork = prHeadRepo && prHeadRepo !== targetRepo;
|
||||
const workflowFromFork = workflowHeadRepo && workflowHeadRepo !== targetRepo;
|
||||
|
||||
if (isFromFork || workflowFromFork) {
|
||||
console.log('🚫 Workflow running from fork - skipping comment creation to avoid permission errors');
|
||||
console.log('Fork repository:', prHeadRepo || workflowHeadRepo);
|
||||
console.log('Target repository:', targetRepo);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('✅ Same repository - proceeding with comment creation'); // Handle repository_dispatch, pull_request, and manual dispatch events
|
||||
let pr;
|
||||
let targetCommitSha;
|
||||
|
||||
if (context.eventName === 'workflow_run') {
|
||||
// Find PR associated with this workflow run commit
|
||||
console.log('Workflow run event:', context.payload.workflow_run.name);
|
||||
|
||||
const { data: pullRequests } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
commit_sha: context.payload.workflow_run.head_sha
|
||||
});
|
||||
|
||||
if (pullRequests.length === 0) {
|
||||
console.log('No pull request found for commit:', context.payload.workflow_run.head_sha);
|
||||
return;
|
||||
}
|
||||
|
||||
pr = pullRequests[0];
|
||||
targetCommitSha = context.payload.workflow_run.head_sha;
|
||||
|
||||
} else if (context.eventName === 'pull_request') {
|
||||
// Direct PR event
|
||||
pr = context.payload.pull_request;
|
||||
targetCommitSha = pr.head.sha;
|
||||
|
||||
} else if (context.eventName === 'workflow_dispatch') {
|
||||
// For manual testing, try to find PR for current branch/commit
|
||||
console.log('Manual workflow dispatch triggered');
|
||||
|
||||
// First, try to find PRs associated with current commit
|
||||
try {
|
||||
const { data: pullRequests } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
commit_sha: context.sha
|
||||
});
|
||||
|
||||
if (pullRequests.length > 0) {
|
||||
pr = pullRequests[0];
|
||||
targetCommitSha = pr.head.sha;
|
||||
console.log(`Found PR #${pr.number} for commit ${context.sha.substring(0, 7)}`);
|
||||
} else {
|
||||
// Fallback: get latest open PR
|
||||
const { data: openPRs } = await github.rest.pulls.list({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'open',
|
||||
sort: 'updated',
|
||||
direction: 'desc',
|
||||
per_page: 1
|
||||
});
|
||||
|
||||
if (openPRs.length > 0) {
|
||||
pr = openPRs[0];
|
||||
targetCommitSha = pr.head.sha;
|
||||
console.log(`Using latest open PR #${pr.number} for manual testing`);
|
||||
} else {
|
||||
console.log('No open PRs found for manual testing');
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Error finding PR for manual testing:', error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
} else {
|
||||
console.log('Unsupported event type:', context.eventName);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Processing PR #${pr.number} for commit ${targetCommitSha.substring(0, 7)}`);
|
||||
|
||||
// Get all recent workflow runs for this PR to collect artifacts from multiple builds
|
||||
const { data: workflowRuns } = await github.rest.actions.listWorkflowRunsForRepo({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
head_sha: targetCommitSha,
|
||||
per_page: 30
|
||||
});
|
||||
|
||||
// Filter for build workflows only, include active runs even if marked as cancelled
|
||||
const buildRuns = workflowRuns.workflow_runs
|
||||
.filter(run =>
|
||||
(run.name.includes('Build Apps') ||
|
||||
run.name.includes('Android APK Build') ||
|
||||
run.name.includes('iOS IPA Build'))
|
||||
)
|
||||
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
||||
|
||||
console.log(`Found ${buildRuns.length} non-cancelled build workflow runs for this commit`);
|
||||
|
||||
// Log current status of each build for debugging
|
||||
buildRuns.forEach(run => {
|
||||
console.log(`- ${run.name}: ${run.status} (${run.conclusion || 'no conclusion yet'}) - Created: ${run.created_at}`);
|
||||
});
|
||||
|
||||
// Collect artifacts and statuses from builds - prioritize active runs over completed ones
|
||||
let allArtifacts = [];
|
||||
let buildStatuses = {};
|
||||
|
||||
// Get the most relevant run for each workflow type (prioritize active over cancelled)
|
||||
const findBestRun = (nameFilter) => {
|
||||
const matchingRuns = buildRuns.filter(run => run.name.includes(nameFilter));
|
||||
|
||||
// First try to find an in-progress run
|
||||
const inProgressRun = matchingRuns.find(run => run.status === 'in_progress');
|
||||
if (inProgressRun) return inProgressRun;
|
||||
|
||||
// Then try to find a queued run
|
||||
const queuedRun = matchingRuns.find(run => run.status === 'queued');
|
||||
if (queuedRun) return queuedRun;
|
||||
|
||||
// Check if the workflow is completed but has non-cancelled jobs
|
||||
const completedRuns = matchingRuns.filter(run => run.status === 'completed');
|
||||
for (const run of completedRuns) {
|
||||
// We'll check individual jobs later to see if they're actually running
|
||||
if (run.conclusion !== 'cancelled') {
|
||||
return run;
|
||||
}
|
||||
}
|
||||
|
||||
// Finally fall back to most recent run (even if cancelled at workflow level)
|
||||
return matchingRuns[0]; // Already sorted by most recent first
|
||||
};
|
||||
|
||||
const latestAppsRun = findBestRun('Build Apps');
|
||||
const latestAndroidRun = findBestRun('Android APK Build');
|
||||
const latestIOSRun = findBestRun('iOS IPA Build');
|
||||
|
||||
// For the consolidated workflow, get individual job statuses
|
||||
if (latestAppsRun) {
|
||||
console.log(`Getting individual job statuses for run ${latestAppsRun.id} (status: ${latestAppsRun.status}, conclusion: ${latestAppsRun.conclusion || 'none'})`);
|
||||
|
||||
try {
|
||||
// Get all jobs for this workflow run
|
||||
const { data: jobs } = await github.rest.actions.listJobsForWorkflowRun({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: latestAppsRun.id
|
||||
});
|
||||
|
||||
console.log(`Found ${jobs.jobs.length} jobs in workflow run`);
|
||||
jobs.jobs.forEach(job => {
|
||||
console.log(`- Job: ${job.name} | Status: ${job.status} | Conclusion: ${job.conclusion || 'none'}`);
|
||||
});
|
||||
|
||||
// Check if we have any actually running jobs (not cancelled)
|
||||
const activeJobs = jobs.jobs.filter(job =>
|
||||
job.status === 'in_progress' ||
|
||||
job.status === 'queued' ||
|
||||
(job.status === 'completed' && job.conclusion !== 'cancelled')
|
||||
);
|
||||
|
||||
console.log(`Found ${activeJobs.length} active (non-cancelled) jobs out of ${jobs.jobs.length} total jobs`);
|
||||
|
||||
// If no jobs are actually running, skip this workflow
|
||||
if (activeJobs.length === 0 && latestAppsRun.conclusion === 'cancelled') {
|
||||
console.log('All jobs are cancelled, skipping this workflow run');
|
||||
return; // Exit early
|
||||
}
|
||||
|
||||
// Map job names to our build targets
|
||||
const jobMappings = {
|
||||
'Android Phone': ['🤖 Build Android APK (Phone)', 'build-android-phone'],
|
||||
'Android TV': ['🤖 Build Android APK (TV)', 'build-android-tv'],
|
||||
'iOS Phone': ['🍎 Build iOS IPA (Phone)', 'build-ios-phone']
|
||||
};
|
||||
|
||||
// Create individual status for each job
|
||||
for (const [platform, jobNames] of Object.entries(jobMappings)) {
|
||||
const job = jobs.jobs.find(j =>
|
||||
jobNames.some(name => j.name.includes(name) || j.name === name)
|
||||
);
|
||||
|
||||
if (job) {
|
||||
buildStatuses[platform] = {
|
||||
name: job.name,
|
||||
status: job.status,
|
||||
conclusion: job.conclusion,
|
||||
url: job.html_url,
|
||||
runId: latestAppsRun.id,
|
||||
created_at: job.started_at || latestAppsRun.created_at
|
||||
};
|
||||
console.log(`Mapped ${platform} to job: ${job.name} (${job.status}/${job.conclusion || 'none'})`);
|
||||
} else {
|
||||
console.log(`No job found for ${platform}, using workflow status as fallback`);
|
||||
buildStatuses[platform] = {
|
||||
name: latestAppsRun.name,
|
||||
status: latestAppsRun.status,
|
||||
conclusion: latestAppsRun.conclusion,
|
||||
url: latestAppsRun.html_url,
|
||||
runId: latestAppsRun.id,
|
||||
created_at: latestAppsRun.created_at
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.log(`Failed to get jobs for run ${latestAppsRun.id}:`, error.message);
|
||||
// Fallback to workflow-level status
|
||||
buildStatuses['Android Phone'] = buildStatuses['Android TV'] = buildStatuses['iOS Phone'] = {
|
||||
name: latestAppsRun.name,
|
||||
status: latestAppsRun.status,
|
||||
conclusion: latestAppsRun.conclusion,
|
||||
url: latestAppsRun.html_url,
|
||||
runId: latestAppsRun.id,
|
||||
created_at: latestAppsRun.created_at
|
||||
};
|
||||
}
|
||||
|
||||
// Collect artifacts if any job has completed successfully
|
||||
if (latestAppsRun.status === 'completed' ||
|
||||
Object.values(buildStatuses).some(status => status.conclusion === 'success')) {
|
||||
try {
|
||||
const { data: artifacts } = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: latestAppsRun.id
|
||||
});
|
||||
allArtifacts.push(...artifacts.artifacts);
|
||||
} catch (error) {
|
||||
console.log(`Failed to get apps artifacts for run ${latestAppsRun.id}:`, error.message);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback to separate workflows (for backward compatibility)
|
||||
if (latestAndroidRun) {
|
||||
buildStatuses['Android'] = {
|
||||
name: latestAndroidRun.name,
|
||||
status: latestAndroidRun.status,
|
||||
conclusion: latestAndroidRun.conclusion,
|
||||
url: latestAndroidRun.html_url,
|
||||
runId: latestAndroidRun.id,
|
||||
created_at: latestAndroidRun.created_at
|
||||
};
|
||||
|
||||
if (latestAndroidRun.conclusion === 'success') {
|
||||
try {
|
||||
const { data: artifacts } = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: latestAndroidRun.id
|
||||
});
|
||||
allArtifacts.push(...artifacts.artifacts);
|
||||
} catch (error) {
|
||||
console.log(`Failed to get Android artifacts for run ${latestAndroidRun.id}:`, error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (latestIOSRun) {
|
||||
buildStatuses['iOS'] = {
|
||||
name: latestIOSRun.name,
|
||||
status: latestIOSRun.status,
|
||||
conclusion: latestIOSRun.conclusion,
|
||||
url: latestIOSRun.html_url,
|
||||
runId: latestIOSRun.id,
|
||||
created_at: latestIOSRun.created_at
|
||||
};
|
||||
|
||||
if (latestIOSRun.conclusion === 'success') {
|
||||
try {
|
||||
const { data: artifacts } = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: latestIOSRun.id
|
||||
});
|
||||
allArtifacts.push(...artifacts.artifacts);
|
||||
} catch (error) {
|
||||
console.log(`Failed to get iOS artifacts for run ${latestIOSRun.id}:`, error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Collected ${allArtifacts.length} total artifacts from all builds`);
|
||||
|
||||
// Debug: Show which workflow we're using and its status
|
||||
if (latestAppsRun) {
|
||||
console.log(`Using consolidated workflow: ${latestAppsRun.name} (${latestAppsRun.status}/${latestAppsRun.conclusion})`);
|
||||
} else {
|
||||
console.log(`Using separate workflows - Android: ${latestAndroidRun?.name || 'none'}, iOS: ${latestIOSRun?.name || 'none'}`);
|
||||
}
|
||||
|
||||
// Debug: List all artifacts found
|
||||
allArtifacts.forEach(artifact => {
|
||||
console.log(`- Artifact: ${artifact.name} (from run ${artifact.workflow_run.id})`);
|
||||
});
|
||||
|
||||
// Build comment body with progressive status for individual builds
|
||||
let commentBody = `## 🔧 Build Status for PR #${pr.number}\n\n`;
|
||||
commentBody += `🔗 **Commit**: [\`${targetCommitSha.substring(0, 7)}\`](https://github.com/${context.repo.owner}/${context.repo.repo}/commit/${targetCommitSha})\n\n`; // Progressive build status and downloads table
|
||||
commentBody += `### 📦 Build Artifacts\n\n`;
|
||||
commentBody += `| Platform | Device | Status | Download |\n`;
|
||||
commentBody += `|----------|--------|--------|---------|\n`;
|
||||
|
||||
// Process each expected build target individually
|
||||
const buildTargets = [
|
||||
{ name: 'Android Phone', platform: '🤖', device: '📱', statusKey: 'Android Phone', artifactPattern: /android.*phone/i },
|
||||
{ name: 'Android TV', platform: '🤖', device: '📺', statusKey: 'Android TV', artifactPattern: /android.*tv/i },
|
||||
{ name: 'iOS Phone', platform: '🍎', device: '📱', statusKey: 'iOS Phone', artifactPattern: /ios.*phone/i },
|
||||
{ name: 'iOS TV', platform: '🍎', device: '📺', statusKey: 'iOS TV', artifactPattern: /ios.*tv/i }
|
||||
];
|
||||
|
||||
for (const target of buildTargets) {
|
||||
// Find matching job status directly
|
||||
const matchingStatus = buildStatuses[target.statusKey];
|
||||
|
||||
// Find matching artifact
|
||||
const matchingArtifact = allArtifacts.find(artifact =>
|
||||
target.artifactPattern.test(artifact.name)
|
||||
);
|
||||
|
||||
let status = '⏳ Pending';
|
||||
let downloadLink = '*Waiting for build...*';
|
||||
|
||||
// Special case for iOS TV - show as disabled
|
||||
if (target.name === 'iOS TV') {
|
||||
status = '💤 Disabled';
|
||||
downloadLink = '*Disabled for now*';
|
||||
} else if (matchingStatus) {
|
||||
if (matchingStatus.conclusion === 'success' && matchingArtifact) {
|
||||
status = '✅ Complete';
|
||||
const directLink = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${matchingArtifact.workflow_run.id}/artifacts/${matchingArtifact.id}`;
|
||||
const fileType = target.name.includes('Android') ? 'APK' : 'IPA';
|
||||
downloadLink = `[📥 Download ${fileType}](${directLink})`;
|
||||
} else if (matchingStatus.conclusion === 'failure') {
|
||||
status = `❌ [Failed](${matchingStatus.url})`;
|
||||
downloadLink = '*Build failed*';
|
||||
} else if (matchingStatus.conclusion === 'cancelled') {
|
||||
status = `⚪ [Cancelled](${matchingStatus.url})`;
|
||||
downloadLink = '*Build cancelled*';
|
||||
} else if (matchingStatus.status === 'in_progress') {
|
||||
status = `🔄 [Building...](${matchingStatus.url})`;
|
||||
downloadLink = '*Build in progress...*';
|
||||
} else if (matchingStatus.status === 'queued') {
|
||||
status = `⏳ [Queued](${matchingStatus.url})`;
|
||||
downloadLink = '*Waiting to start...*';
|
||||
} else if (matchingStatus.status === 'completed' && !matchingStatus.conclusion) {
|
||||
// Workflow completed but conclusion not yet available (rare edge case)
|
||||
status = `🔄 [Finishing...](${matchingStatus.url})`;
|
||||
downloadLink = '*Finalizing build...*';
|
||||
} else if (matchingStatus.status === 'completed' && matchingStatus.conclusion === 'success' && !matchingArtifact) {
|
||||
// Build succeeded but artifacts not yet available
|
||||
status = `⏳ [Processing artifacts...](${matchingStatus.url})`;
|
||||
downloadLink = '*Preparing download...*';
|
||||
} else {
|
||||
// Fallback for any unexpected states
|
||||
status = `❓ [${matchingStatus.status}/${matchingStatus.conclusion || 'pending'}](${matchingStatus.url})`;
|
||||
downloadLink = `*Status: ${matchingStatus.status}, Conclusion: ${matchingStatus.conclusion || 'pending'}*`;
|
||||
}
|
||||
}
|
||||
|
||||
commentBody += `| ${target.platform} ${target.name.split(' ')[0]} | ${target.device} ${target.name.split(' ')[1]} | ${status} | ${downloadLink} |\n`;
|
||||
}
|
||||
|
||||
commentBody += `\n`;
|
||||
|
||||
// Show installation instructions if we have any artifacts
|
||||
if (allArtifacts.length > 0) {
|
||||
commentBody += `### 🔧 Installation Instructions\n\n`;
|
||||
commentBody += `- **Android APK**: Download and install directly on your device (enable "Install from unknown sources")\n`;
|
||||
commentBody += `- **iOS IPA**: Install using [AltStore](https://altstore.io/), [Sideloadly](https://sideloadly.io/), or Xcode\n\n`;
|
||||
commentBody += `> ⚠️ **Note**: Artifacts expire in 7 days from build date\n\n`;
|
||||
} else {
|
||||
commentBody += `⏳ **Builds are starting up...** This comment will update automatically as each build completes.\n\n`;
|
||||
}
|
||||
|
||||
commentBody += `<sub>*Auto-generated by [GitHub Actions](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId})*</sub>`;
|
||||
commentBody += `\n<!-- streamyfin-artifact-comment -->`;
|
||||
|
||||
// Try to find existing bot comment to update (with permission check)
|
||||
try {
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number
|
||||
});
|
||||
|
||||
const botComment = comments.find(comment =>
|
||||
comment.user.type === 'Bot' &&
|
||||
comment.body.includes('<!-- streamyfin-artifact-comment -->')
|
||||
);
|
||||
|
||||
if (botComment) {
|
||||
// Update existing comment
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: botComment.id,
|
||||
body: commentBody
|
||||
});
|
||||
console.log(`✅ Updated comment ${botComment.id} on PR #${pr.number}`);
|
||||
} else {
|
||||
// Create new comment
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
body: commentBody
|
||||
});
|
||||
console.log(`✅ Created new comment on PR #${pr.number}`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.status === 403) {
|
||||
console.log('🚫 Permission denied - likely running from a fork. Skipping comment creation.');
|
||||
console.log('Error details:', error.message);
|
||||
|
||||
// Log the build status instead of commenting
|
||||
console.log('📊 Build Status Summary:');
|
||||
for (const target of buildTargets) {
|
||||
const matchingStatus = buildStatuses[target.statusKey];
|
||||
if (matchingStatus) {
|
||||
console.log(`- ${target.name}: ${matchingStatus.status}/${matchingStatus.conclusion || 'none'}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Re-throw other errors
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
79
.github/workflows/build-android.yml
vendored
Normal file
@@ -0,0 +1,79 @@
|
||||
name: 🤖 Android APK Build
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
branches: [develop, master]
|
||||
push:
|
||||
branches: [develop, master]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-24.04
|
||||
name: 🏗️ Build Android APK
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
show-progress: false
|
||||
submodules: recursive
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||
with:
|
||||
bun-version: '1.2.17'
|
||||
|
||||
- name: ☕ Setup JDK
|
||||
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: '17'
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-cache-
|
||||
|
||||
- name: 📦 Install dependencies
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
bun run submodule-reload
|
||||
|
||||
- name: 💾 Cache Android dependencies
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
|
||||
with:
|
||||
path: |
|
||||
android/.gradle
|
||||
key: ${{ runner.os }}-android-deps-${{ hashFiles('android/**/build.gradle') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-android-deps-
|
||||
|
||||
- name: 🛠️ Generate project files
|
||||
run: bun run prebuild
|
||||
|
||||
- name: 🚀 Build APK via Bun
|
||||
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-apk-${{ env.DATE_TAG }}
|
||||
path: |
|
||||
android/app/build/outputs/apk/release/*.apk
|
||||
android/app/build/outputs/bundle/release/*.aab
|
||||
retention-days: 7
|
||||
280
.github/workflows/build-apps.yml
vendored
@@ -1,280 +0,0 @@
|
||||
name: 🏗️ Build Apps
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
branches: [develop, master]
|
||||
push:
|
||||
branches: [develop, master]
|
||||
|
||||
jobs:
|
||||
build-android-phone:
|
||||
if: (!contains(github.event.head_commit.message, '[skip ci]'))
|
||||
runs-on: ubuntu-24.04
|
||||
name: 🤖 Build Android APK (Phone)
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-bun-develop
|
||||
${{ runner.os }}-bun-develop
|
||||
|
||||
- name: 📦 Install dependencies and reload submodules
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
bun run submodule-reload
|
||||
|
||||
- name: 💾 Cache Gradle global
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-
|
||||
|
||||
- name: 🛠️ Generate project files
|
||||
run: bun run prebuild
|
||||
|
||||
- name: 💾 Cache project Gradle (.gradle)
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: android/.gradle
|
||||
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||
restore-keys: ${{ runner.os }}-android-gradle-develop
|
||||
|
||||
- name: 🚀 Build APK
|
||||
env:
|
||||
EXPO_TV: 0
|
||||
run: bun run build:android:local
|
||||
|
||||
- name: 📅 Set date tag
|
||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||
|
||||
- name: 📤 Upload APK artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: streamyfin-android-phone-apk-${{ env.DATE_TAG }}
|
||||
path: |
|
||||
android/app/build/outputs/apk/release/*.apk
|
||||
retention-days: 7
|
||||
|
||||
build-android-tv:
|
||||
if: (!contains(github.event.head_commit.message, '[skip ci]'))
|
||||
runs-on: ubuntu-24.04
|
||||
name: 🤖 Build Android APK (TV)
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-bun-develop
|
||||
${{ runner.os }}-bun-develop
|
||||
|
||||
- name: 📦 Install dependencies and reload submodules
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
bun run submodule-reload
|
||||
|
||||
- name: 💾 Cache Gradle global
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-
|
||||
|
||||
- name: 🛠️ Generate project files
|
||||
run: bun run prebuild:tv
|
||||
|
||||
- name: 💾 Cache project Gradle (.gradle)
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: android/.gradle
|
||||
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||
restore-keys: ${{ runner.os }}-android-gradle-develop
|
||||
|
||||
- name: 🚀 Build APK
|
||||
env:
|
||||
EXPO_TV: 1
|
||||
run: bun run build:android:local
|
||||
|
||||
- name: 📅 Set date tag
|
||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||
|
||||
- name: 📤 Upload APK artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: streamyfin-android-tv-apk-${{ env.DATE_TAG }}
|
||||
path: |
|
||||
android/app/build/outputs/apk/release/*.apk
|
||||
retention-days: 7
|
||||
|
||||
build-ios-phone:
|
||||
if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
|
||||
runs-on: macos-15
|
||||
name: 🍎 Build iOS IPA (Phone)
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-cache
|
||||
|
||||
- name: 📦 Install dependencies and reload submodules
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
bun run submodule-reload
|
||||
|
||||
- name: 🛠️ Generate project files
|
||||
run: bun run prebuild
|
||||
|
||||
- name: 🏗️ Setup EAS
|
||||
uses: expo/expo-github-action@main
|
||||
with:
|
||||
eas-version: latest
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
eas-cache: true
|
||||
|
||||
- name: ⚙️ Ensure iOS SDKs installed
|
||||
run: xcodebuild -downloadPlatform iOS
|
||||
|
||||
- name: 🚀 Build iOS app
|
||||
env:
|
||||
EXPO_TV: 0
|
||||
run: eas build -p ios --local --non-interactive
|
||||
|
||||
- name: 📅 Set date tag
|
||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||
|
||||
- name: 📤 Upload IPA artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: streamyfin-ios-phone-ipa-${{ env.DATE_TAG }}
|
||||
path: build-*.ipa
|
||||
retention-days: 7
|
||||
|
||||
# Disabled for now - uncomment when ready to build iOS TV
|
||||
# build-ios-tv:
|
||||
# if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
|
||||
# runs-on: macos-15
|
||||
# name: 🍎 Build iOS IPA (TV)
|
||||
# permissions:
|
||||
# contents: read
|
||||
#
|
||||
# steps:
|
||||
# - name: 📥 Checkout code
|
||||
# uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
# with:
|
||||
# ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
# fetch-depth: 0
|
||||
# submodules: recursive
|
||||
# show-progress: false
|
||||
#
|
||||
# - name: 🍞 Setup Bun
|
||||
# uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||
# with:
|
||||
# bun-version: latest
|
||||
#
|
||||
# - name: 💾 Cache Bun dependencies
|
||||
# uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
# with:
|
||||
# path: ~/.bun/install/cache
|
||||
# key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||
# restore-keys: |
|
||||
# ${{ runner.os }}-bun-cache
|
||||
#
|
||||
# - name: 📦 Install dependencies and reload submodules
|
||||
# run: |
|
||||
# bun install --frozen-lockfile
|
||||
# bun run submodule-reload
|
||||
#
|
||||
# - name: 🛠️ Generate project files
|
||||
# run: bun run prebuild:tv
|
||||
#
|
||||
# - name: 🏗️ Setup EAS
|
||||
# uses: expo/expo-github-action@main
|
||||
# with:
|
||||
# eas-version: latest
|
||||
# token: ${{ secrets.EXPO_TOKEN }}
|
||||
# eas-cache: true
|
||||
#
|
||||
# - name: ⚙️ Ensure tvOS SDKs installed
|
||||
# run: xcodebuild -downloadPlatform tvOS
|
||||
#
|
||||
# - name: 🚀 Build iOS app
|
||||
# env:
|
||||
# EXPO_TV: 1
|
||||
# run: eas build -p ios --local --non-interactive
|
||||
#
|
||||
# - name: 📅 Set date tag
|
||||
# run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||
#
|
||||
# - name: 📤 Upload IPA artifact
|
||||
# uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
# with:
|
||||
# name: streamyfin-ios-tv-ipa-${{ env.DATE_TAG }}
|
||||
# path: build-*.ipa
|
||||
# retention-days: 7
|
||||
70
.github/workflows/build-ios.yml
vendored
Normal file
@@ -0,0 +1,70 @@
|
||||
name: 🤖 iOS IPA Build
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
branches: [develop, master]
|
||||
push:
|
||||
branches: [develop, master]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: macos-15
|
||||
name: 🏗️ Build iOS IPA
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: 📥 Check out repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
show-progress: false
|
||||
submodules: recursive
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||
with:
|
||||
bun-version: '1.2.17'
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-cache-
|
||||
|
||||
- name: 📦 Install & Prepare
|
||||
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: 16.7.1
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- name: 🏗️ Build iOS app
|
||||
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-ipa-${{ env.DATE_TAG }}
|
||||
path: |
|
||||
build-*.ipa
|
||||
retention-days: 7
|
||||
6
.github/workflows/check-lockfile.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
show-progress: false
|
||||
@@ -29,10 +29,10 @@ jobs:
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||
with:
|
||||
bun-version: latest
|
||||
bun-version: '1.2.17'
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
with:
|
||||
path: |
|
||||
~/.bun/install/cache
|
||||
|
||||
10
.github/workflows/ci-codeql.yml
vendored
@@ -20,24 +20,24 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'javascript-typescript', 'actions' ]
|
||||
language: [ 'javascript-typescript' ]
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
show-progress: false
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 🏁 Initialize CodeQL
|
||||
uses: github/codeql-action/init@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7
|
||||
uses: github/codeql-action/init@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: +security-extended,security-and-quality
|
||||
|
||||
- name: 🛠️ Autobuild
|
||||
uses: github/codeql-action/autobuild@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7
|
||||
uses: github/codeql-action/autobuild@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
|
||||
|
||||
- name: 🧪 Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7
|
||||
uses: github/codeql-action/analyze@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
|
||||
|
||||
2
.github/workflows/conflict.yml
vendored
@@ -19,6 +19,6 @@ jobs:
|
||||
- name: 🚩 Apply merge conflict label
|
||||
uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3
|
||||
with:
|
||||
dirtyLabel: '⚔️ merge-conflict'
|
||||
dirtyLabel: 'merge-conflict'
|
||||
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'
|
||||
repoToken: '${{ secrets.GITHUB_TOKEN }}'
|
||||
|
||||
50
.github/workflows/crowdin.yml
vendored
@@ -1,50 +0,0 @@
|
||||
name: 🌐 Translation Sync
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [develop]
|
||||
paths:
|
||||
- "translations/**"
|
||||
- "crowdin.yml"
|
||||
- "i18n.ts"
|
||||
- ".github/workflows/crowdin.yml"
|
||||
# Run weekly to pull new translations
|
||||
schedule:
|
||||
- cron: "0 2 * * 1" # Every Monday at 2 AM UTC
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
sync-translations:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout Repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 🌐 Sync Translations with Crowdin
|
||||
uses: crowdin/github-action@0749939f635900a2521aa6aac7a3766642b2dc71 # v2.11.0
|
||||
with:
|
||||
upload_sources: true
|
||||
upload_translations: true
|
||||
download_translations: true
|
||||
localization_branch_name: I10n_crowdin_translations
|
||||
create_pull_request: true
|
||||
pull_request_title: "feat: New Crowdin Translations"
|
||||
pull_request_body: "New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)"
|
||||
pull_request_base_branch_name: "develop"
|
||||
pull_request_labels: "🌐 translation"
|
||||
# Quality control options
|
||||
skip_untranslated_strings: true
|
||||
export_only_approved: false
|
||||
# Commit customization
|
||||
commit_message: "feat(i18n): update translations from Crowdin"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.CROWDIN_GITHUB_TOKEN }}
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
56
.github/workflows/linting.yml
vendored
@@ -1,12 +1,10 @@
|
||||
name: 🚦 Security & Quality Gate
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
pull_request_target:
|
||||
types: [opened, edited, synchronize, reopened]
|
||||
branches: [develop, master]
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [develop]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -14,18 +12,17 @@ permissions:
|
||||
jobs:
|
||||
validate_pr_title:
|
||||
name: "📝 Validate PR Title"
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
|
||||
- uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017 # v5.5.3
|
||||
id: lint_pr_title
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4
|
||||
- uses: marocchino/sticky-pull-request-comment@d2ad0de260ae8b0235ce059e63f2949ba9e05943 # v2.9.3
|
||||
if: always() && (steps.lint_pr_title.outputs.error_message != null)
|
||||
with:
|
||||
header: pr-title-lint-error
|
||||
@@ -39,7 +36,7 @@ jobs:
|
||||
```
|
||||
|
||||
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
|
||||
uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4
|
||||
uses: marocchino/sticky-pull-request-comment@d2ad0de260ae8b0235ce059e63f2949ba9e05943 # v2.9.3
|
||||
with:
|
||||
header: pr-title-lint-error
|
||||
delete: true
|
||||
@@ -51,70 +48,45 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Dependency Review
|
||||
uses: actions/dependency-review-action@56339e523c0409420f6c2c9a2f4292bbb3c07dd3 # v4.8.0
|
||||
uses: actions/dependency-review-action@da24556b548a50705dd671f47852072ea4c105d9 # v4.7.1
|
||||
with:
|
||||
fail-on-severity: high
|
||||
deny-licenses: GPL-3.0, AGPL-3.0
|
||||
base-ref: ${{ github.event.pull_request.base.sha || 'develop' }}
|
||||
head-ref: ${{ github.event.pull_request.head.sha || github.ref }}
|
||||
|
||||
expo-doctor:
|
||||
name: 🚑 Expo Doctor Check
|
||||
if: false
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: 🛒 Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
submodules: recursive
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: 📦 Install dependencies (bun)
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: 🚑 Run Expo Doctor
|
||||
run: bun expo-doctor
|
||||
|
||||
code_quality:
|
||||
name: "🔍 Lint & Test (${{ matrix.command }})"
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
command:
|
||||
- "lint"
|
||||
- "check"
|
||||
- "format"
|
||||
- "typecheck"
|
||||
|
||||
steps:
|
||||
- name: "📥 Checkout PR code"
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
submodules: recursive
|
||||
fetch-depth: 0
|
||||
|
||||
- name: "🟢 Setup Node.js"
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: '24.x'
|
||||
node-version: '22.x'
|
||||
|
||||
- name: "🍞 Setup Bun"
|
||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||
with:
|
||||
bun-version: latest
|
||||
bun-version: '1.2.17'
|
||||
|
||||
- name: "📦 Install dependencies"
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
27
.github/workflows/notification.yml
vendored
@@ -1,21 +1,16 @@
|
||||
name: 🛎️ Discord Notification
|
||||
name: 🛎️ Discord Pull Request Notification
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, reopened]
|
||||
branches: [develop]
|
||||
workflow_run:
|
||||
workflows: ["*"]
|
||||
types: [completed]
|
||||
branches: [develop]
|
||||
|
||||
jobs:
|
||||
notify:
|
||||
runs-on: ubuntu-24.04
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: 🛎️ Notify Discord
|
||||
uses: Ilshidur/action-discord@d2594079a10f1d6739ee50a2471f0ca57418b554 # 0.4.0
|
||||
uses: Ilshidur/action-discord@0c4b27844ba47cb1c7bee539c8eead5284ce9fa9 # 0.3.2
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
DISCORD_AVATAR: https://avatars.githubusercontent.com/u/193271640
|
||||
@@ -26,21 +21,3 @@ jobs:
|
||||
**By:** ${{ github.event.pull_request.user.login }}
|
||||
**Branch:** ${{ github.event.pull_request.head.ref }}
|
||||
🔗 ${{ github.event.pull_request.html_url }}
|
||||
|
||||
notify-on-failure:
|
||||
runs-on: ubuntu-24.04
|
||||
if: github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'failure'
|
||||
steps:
|
||||
- name: 🚨 Notify Discord on Failure
|
||||
uses: Ilshidur/action-discord@d2594079a10f1d6739ee50a2471f0ca57418b554 # 0.4.0
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.WEBHOOK_FAILED_JOB_URL }}
|
||||
DISCORD_AVATAR: https://avatars.githubusercontent.com/u/193271640
|
||||
with:
|
||||
args: |
|
||||
🚨 **Workflow Failed** in **${{ github.repository }}**
|
||||
**Workflow:** ${{ github.event.workflow_run.name }}
|
||||
**Branch:** ${{ github.event.workflow_run.head_branch }}
|
||||
**Triggered by:** ${{ github.event.workflow_run.triggering_actor.login }}
|
||||
**Commit:** ${{ github.event.workflow_run.head_commit.message }}
|
||||
🔗 ${{ github.event.workflow_run.html_url }}
|
||||
|
||||
6
.github/workflows/stale.yml
vendored
@@ -15,17 +15,17 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: 🔄 Mark/Close Stale Issues
|
||||
uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
|
||||
uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0
|
||||
with:
|
||||
# Global settings
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
operations-per-run: 500 # Increase if you have >1000 issues
|
||||
enable-statistics: true
|
||||
log-level: debug
|
||||
|
||||
# Issue configuration
|
||||
days-before-issue-stale: 90
|
||||
days-before-issue-close: 7
|
||||
stale-issue-label: "🕰️ stale"
|
||||
stale-issue-label: "stale"
|
||||
exempt-issue-labels: "Roadmap v1,help needed,enhancement"
|
||||
|
||||
# Notifications messages
|
||||
|
||||
67
.github/workflows/update-issue-form.yml
vendored
@@ -1,67 +0,0 @@
|
||||
name: 🐛 Update Bug Report Template
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published] # Run on every published release on any branch
|
||||
|
||||
concurrency:
|
||||
group: update-issue-form-${{ github.event.release.tag_name || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
update-bug-report:
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: "🟢 Setup Node.js"
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
with:
|
||||
node-version: '24.x'
|
||||
cache: 'npm'
|
||||
|
||||
- name: 🔍 Extract minor version from app.json
|
||||
id: minor
|
||||
uses: actions/github-script@main
|
||||
with:
|
||||
result-encoding: string
|
||||
script: |
|
||||
const fs = require('fs-extra');
|
||||
const semver = require('semver');
|
||||
const content = fs.readJsonSync('./app.json');
|
||||
const version = content.expo.version;
|
||||
const minorVersion = semver.minor(version);
|
||||
return minorVersion.toString();
|
||||
|
||||
- name: 📝 Update bug report version
|
||||
uses: ShaMan123/gha-populate-form-version@be012141ca560dbb92156e3fe098c46035f6260d #v2.0.5
|
||||
with:
|
||||
semver: '^0.${{ steps.minor.outputs.result }}.0'
|
||||
dry_run: no-push
|
||||
|
||||
- name: ⚙️ Update bug report node version dropdown
|
||||
uses: ShaMan123/gha-populate-form-version@be012141ca560dbb92156e3fe098c46035f6260d #v2.0.5
|
||||
with:
|
||||
dropdown: _node_version
|
||||
package: node
|
||||
semver: '>=24.0.0'
|
||||
dry_run: no-push
|
||||
|
||||
- name: 📬 Commit and create pull request
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e #v7.0.8
|
||||
with:
|
||||
add-paths: .github/ISSUE_TEMPLATE/bug_report.yml
|
||||
branch: ci-update-bug-report
|
||||
base: develop
|
||||
delete-branch: true
|
||||
labels: ⚙️ ci, 🤖 github-actions
|
||||
title: 'chore(): Update bug report template to match release version'
|
||||
body: |
|
||||
Automated update to `.github/ISSUE_TEMPLATE/bug_report.yml`
|
||||
Triggered by workflow run [${{ github.run_id }}](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})
|
||||
71
.gitignore
vendored
@@ -1,16 +1,28 @@
|
||||
# Dependencies and Package Managers
|
||||
node_modules/
|
||||
bun.lock
|
||||
bun.lockb
|
||||
package-lock.json
|
||||
|
||||
# Expo and React Native Build Artifacts
|
||||
.expo/
|
||||
dist/
|
||||
npm-debug.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
*.orig.*
|
||||
web-build/
|
||||
.tsbuildinfo
|
||||
modules/vlc-player/android/build
|
||||
modules/vlc-player/android/.gradle
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
expo-env.d.ts
|
||||
|
||||
Streamyfin.app
|
||||
|
||||
*.mp4
|
||||
Streamyfin.app
|
||||
package-lock.json
|
||||
|
||||
# Platform-specific Build Directories
|
||||
/ios
|
||||
/android
|
||||
/iostv
|
||||
@@ -18,50 +30,19 @@ web-build/
|
||||
/androidmobile
|
||||
/androidtv
|
||||
|
||||
# Module-specific Builds
|
||||
modules/vlc-player/android/build
|
||||
modules/player/android
|
||||
modules/hls-downloader/android/build
|
||||
|
||||
# Generated Applications
|
||||
Streamyfin.app
|
||||
pc-api-7079014811501811218-719-3b9f15aeccf8.json
|
||||
credentials.json
|
||||
*.apk
|
||||
*.ipa
|
||||
*.aab
|
||||
.continuerc.json
|
||||
|
||||
# Certificates and Keys
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
|
||||
# Debug and Temporary Files
|
||||
npm-debug.*
|
||||
*.orig.*
|
||||
*.mp4
|
||||
|
||||
# OS-specific Files
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# IDE and Editor Files
|
||||
.vscode/
|
||||
.idea/
|
||||
.ruby-lsp
|
||||
.cursor/
|
||||
.claude/
|
||||
|
||||
# Environment and Configuration
|
||||
expo-env.d.ts
|
||||
.continuerc.json
|
||||
modules/hls-downloader/android/build
|
||||
streamyfin-4fec1-firebase-adminsdk.json
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Secrets and Credentials
|
||||
pc-api-7079014811501811218-719-3b9f15aeccf8.json
|
||||
credentials.json
|
||||
streamyfin-4fec1-firebase-adminsdk.json
|
||||
|
||||
# Version and Backup Files
|
||||
/version-backup-*
|
||||
*.aab
|
||||
|
||||
24
.vscode/extensions.json
vendored
@@ -1,24 +0,0 @@
|
||||
{
|
||||
// ==========================================
|
||||
// Streamyfin - Recommended VS Code Extensions
|
||||
// ==========================================
|
||||
// Essential extensions for working with Streamyfin
|
||||
// See .github/copilot-instructions.md for coding standards
|
||||
|
||||
"recommendations": [
|
||||
// Code Quality & Formatting
|
||||
"biomejs.biome", // Fast formatter and linter for JavaScript/TypeScript - replaces ESLint + Prettier
|
||||
|
||||
// React Native & Expo
|
||||
"expo.vscode-expo-tools", // Official Expo extension - provides commands, debugging, and config IntelliSense
|
||||
"msjsdiag.vscode-react-native", // React Native debugging and IntelliSense - essential for RN development
|
||||
|
||||
// Developer Experience
|
||||
"bradlc.vscode-tailwindcss", // Tailwind CSS IntelliSense - autocomplete for NativeWind classes
|
||||
"yoavbls.pretty-ts-errors", // Makes TypeScript error messages human-readable with formatting and highlights
|
||||
"usernamehw.errorlens", // Shows errors and warnings inline in the editor - faster debugging
|
||||
|
||||
// Bun Support
|
||||
"oven.bun-vscode" // Official Bun extension - provides debugging and language support for Bun runtime
|
||||
]
|
||||
}
|
||||
176
.vscode/settings.json
vendored
@@ -1,178 +1,24 @@
|
||||
{
|
||||
// ==========================================
|
||||
// FORMATTING & LINTING
|
||||
// ==========================================
|
||||
|
||||
// Biome as default formatter
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.formatOnPaste": true,
|
||||
"editor.formatOnType": false,
|
||||
|
||||
// Language-specific formatters
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"prettier.printWidth": 120,
|
||||
"[swift]": {
|
||||
"editor.defaultFormatter": "sswg.swift-lang"
|
||||
},
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[javascriptreact]": {
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[jsonc]": {
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[swift]": {
|
||||
"editor.insertSpaces": true,
|
||||
"editor.tabSize": 2
|
||||
},
|
||||
|
||||
// ==========================================
|
||||
// TYPESCRIPT & JAVASCRIPT
|
||||
// ==========================================
|
||||
|
||||
// TypeScript performance optimizations
|
||||
"typescript.preferences.includePackageJsonAutoImports": "auto",
|
||||
"typescript.suggest.autoImports": true,
|
||||
"typescript.updateImportsOnFileMove.enabled": "always",
|
||||
"typescript.preferences.preferTypeOnlyAutoImports": true,
|
||||
"typescript.preferences.importModuleSpecifier": "relative",
|
||||
"typescript.preferences.includeCompletionsForImportStatements": true,
|
||||
"typescript.preferences.includeCompletionsWithSnippetText": true,
|
||||
|
||||
// JavaScript settings
|
||||
"javascript.preferences.importModuleSpecifier": "relative",
|
||||
"javascript.suggest.autoImports": true,
|
||||
"javascript.updateImportsOnFileMove.enabled": "always",
|
||||
|
||||
// ==========================================
|
||||
// REACT NATIVE & EXPO
|
||||
// ==========================================
|
||||
|
||||
// File associations for React Native
|
||||
"files.associations": {
|
||||
"*.expo.ts": "typescript",
|
||||
"*.expo.tsx": "typescriptreact",
|
||||
"*.expo.js": "javascript",
|
||||
"*.expo.jsx": "javascriptreact",
|
||||
"metro.config.js": "javascript",
|
||||
"babel.config.js": "javascript",
|
||||
"app.config.js": "javascript",
|
||||
"eas.json": "jsonc"
|
||||
},
|
||||
|
||||
// React Native specific settings
|
||||
"emmet.includeLanguages": {
|
||||
"typescriptreact": "html",
|
||||
"javascriptreact": "html"
|
||||
},
|
||||
"emmet.triggerExpansionOnTab": true,
|
||||
|
||||
// Exclude build directories from search
|
||||
"search.exclude": {
|
||||
"**/node_modules": true
|
||||
},
|
||||
|
||||
// ==========================================
|
||||
// EDITOR PERFORMANCE & UX
|
||||
// ==========================================
|
||||
|
||||
// Performance optimizations
|
||||
"editor.largeFileOptimizations": true,
|
||||
"files.watcherExclude": {
|
||||
"**/.git/objects/**": true,
|
||||
"**/.git/subtree-cache/**": true,
|
||||
"**/node_modules/**": true,
|
||||
"**/.expo/**": true,
|
||||
"**/ios/**": true,
|
||||
"**/android/**": true,
|
||||
"**/build/**": true,
|
||||
"**/dist/**": true
|
||||
},
|
||||
|
||||
// Better editor behavior
|
||||
"editor.suggestSelection": "first",
|
||||
"editor.quickSuggestions": {
|
||||
"strings": true,
|
||||
"comments": true,
|
||||
"other": true
|
||||
},
|
||||
"editor.snippetSuggestions": "top",
|
||||
"editor.tabCompletion": "on",
|
||||
"editor.wordBasedSuggestions": "off",
|
||||
|
||||
// ==========================================
|
||||
// TERMINAL & DEVELOPMENT
|
||||
// ==========================================
|
||||
|
||||
// Terminal settings for Bun (Windows-specific)
|
||||
"terminal.integrated.profiles.windows": {
|
||||
"Command Prompt": {
|
||||
"path": "C:\\Windows\\System32\\cmd.exe",
|
||||
"env": {
|
||||
"PATH": "${env:PATH};./node_modules/.bin"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ==========================================
|
||||
// WORKSPACE & NAVIGATION
|
||||
// ==========================================
|
||||
|
||||
// Better workspace navigation
|
||||
"explorer.fileNesting.enabled": true,
|
||||
"explorer.fileNesting.expand": false,
|
||||
"explorer.fileNesting.patterns": {
|
||||
"*.ts": "${capture}.js",
|
||||
"*.tsx": "${capture}.js",
|
||||
"*.js": "${capture}.js,${capture}.js.map,${capture}.min.js,${capture}.d.ts",
|
||||
"*.jsx": "${capture}.js",
|
||||
"package.json": "package-lock.json,yarn.lock,bun.lock,bun.lockb,.yarnrc,.yarnrc.yml",
|
||||
"tsconfig.json": "tsconfig.*.json",
|
||||
".env": ".env.*",
|
||||
"app.json": "app.config.js,eas.json,expo-env.d.ts",
|
||||
"README.md": "LICENSE.txt,SECURITY.md,CODE_OF_CONDUCT.md,CONTRIBUTING.md"
|
||||
},
|
||||
|
||||
// Better breadcrumbs and navigation
|
||||
"breadcrumbs.enabled": true,
|
||||
"outline.showVariables": true,
|
||||
"outline.showConstants": true,
|
||||
|
||||
// ==========================================
|
||||
// GIT & VERSION CONTROL
|
||||
// ==========================================
|
||||
|
||||
// Git integration
|
||||
"git.autofetch": true,
|
||||
"git.enableSmartCommit": true,
|
||||
"git.confirmSync": false,
|
||||
"git.ignoreLimitWarning": true,
|
||||
|
||||
// ==========================================
|
||||
// CODE QUALITY & ERRORS
|
||||
// ==========================================
|
||||
|
||||
// Better error detection
|
||||
"typescript.validate.enable": true,
|
||||
"javascript.validate.enable": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": "explicit",
|
||||
"source.organizeImports": "explicit"
|
||||
},
|
||||
|
||||
// Problem matcher for better error display
|
||||
"typescript.tsc.autoDetect": "on"
|
||||
}
|
||||
}
|
||||
|
||||
84
README.md
@@ -1,24 +1,15 @@
|
||||
# 📺 Streamyfin
|
||||
|
||||
<a href="https://www.buymeacoffee.com/fredrikbur3" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a>
|
||||
|
||||
A simple and user-friendly Jellyfin video streaming client built with Expo. If you are looking for an alternative to other Jellyfin clients, we hope you find Streamyfin a useful addition to your media streaming toolbox.
|
||||
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/streamyfin/.github/refs/heads/main/streamyfin-github-banner.png" alt="Streamyfin" width="100%">
|
||||
</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.**
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<img src="./assets/images/screenshots/screenshot1.png" width="22%">
|
||||
|
||||
<img src="./assets/images/screenshots/screenshot3.png" width="22%">
|
||||
|
||||
<img src="./assets/images/screenshots/screenshot2.png" width="22%">
|
||||
|
||||
<img src="./assets/images/jellyseerr.PNG" width="23%">
|
||||
</p>
|
||||
|
||||
<div style="display: flex; flex-direction: row; gap: 8px">
|
||||
<img width=150 src="./assets/images/screenshots/screenshot1.png" />
|
||||
<img width=150 src="./assets/images/screenshots/screenshot3.png" />
|
||||
<img width=150 src="./assets/images/screenshots/screenshot2.png" />
|
||||
<img width=159 src="./assets/images/jellyseerr.PNG"/>
|
||||
</div>
|
||||
|
||||
## 🌟 Features
|
||||
|
||||
@@ -56,7 +47,7 @@ The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that ho
|
||||
|
||||
### 🔍 Jellysearch
|
||||
|
||||
[Jellysearch](https://gitlab.com/DomiStyle/jellysearch) now works with Streamyfin!
|
||||
[Jellysearch](https://gitlab.com/DomiStyle/jellysearch) now works with Streamyfin! 🚀
|
||||
|
||||
> A fast full-text search proxy for Jellyfin. Integrates seamlessly with most Jellyfin clients.
|
||||
|
||||
@@ -116,16 +107,16 @@ Key points of the MPL-2.0:
|
||||
- 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
|
||||
- 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)
|
||||
Join our Discord: [https://discord.gg/aJvAYeycyY](https://discord.gg/aJvAYeycyY)
|
||||
|
||||
Need support or have questions:
|
||||
If you have questions or need support, feel free to reach out:
|
||||
|
||||
- GitHub Issues: Report bugs or request features here.
|
||||
- Email: [developer@streamyfin.app](mailto:developer@streamyfin.app)
|
||||
- Email: [fredrik.burmester@gmail.com](mailto:fredrik.burmester@gmail.com)
|
||||
|
||||
## ❓ FAQ
|
||||
|
||||
@@ -148,86 +139,77 @@ Special shoutout to the JF official clients for being an inspiration to ours.
|
||||
|
||||
Thanks to the following contributors for their significant contributions:
|
||||
|
||||
<div align="left">
|
||||
<table>
|
||||
<tr>
|
||||
<tr
|
||||
style="
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
"
|
||||
>
|
||||
<td align="center">
|
||||
<a href="https://github.com/Alexk2309">
|
||||
<img src="https://github.com/Alexk2309.png?size=55" width="55" style="border-radius: 50%;" />
|
||||
<img src="https://github.com/Alexk2309.png?size=80" width="80" style="border-radius: 50%;" />
|
||||
<br /><sub><b>@Alexk2309</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/herrrta">
|
||||
<img src="https://github.com/herrrta.png?size=55" width="55" style="border-radius: 50%;" />
|
||||
<img src="https://github.com/herrrta.png?size=80" width="80" style="border-radius: 50%;" />
|
||||
<br /><sub><b>@herrrta</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/lostb1t">
|
||||
<img src="https://github.com/lostb1t.png?size=55" width="55" style="border-radius: 50%;" />
|
||||
<img src="https://github.com/lostb1t.png?size=80" width="80" style="border-radius: 50%;" />
|
||||
<br /><sub><b>@lostb1t</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/Simon-Eklundh">
|
||||
<img src="https://github.com/Simon-Eklundh.png?size=55" width="55" style="border-radius: 50%;" />
|
||||
<img src="https://github.com/Simon-Eklundh.png?size=80" width="80" style="border-radius: 50%;" />
|
||||
<br /><sub><b>@Simon-Eklundh</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/topiga">
|
||||
<img src="https://github.com/topiga.png?size=55" width="55" style="border-radius: 50%;" />
|
||||
<img src="https://github.com/topiga.png?size=80" width="80" style="border-radius: 50%;" />
|
||||
<br /><sub><b>@topiga</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/lancechant">
|
||||
<img src="https://github.com/lancechant.png?size=55" width="55" style="border-radius: 50%;" />
|
||||
<br /><sub><b>@lancechant</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="https://github.com/simoncaron">
|
||||
<img src="https://github.com/simoncaron.png?size=55" width="55" style="border-radius: 50%;" />
|
||||
<img src="https://github.com/simoncaron.png?size=80" width="80" style="border-radius: 50%;" />
|
||||
<br /><sub><b>@simoncaron</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/jakequade">
|
||||
<img src="https://github.com/jakequade.png?size=55" width="55" style="border-radius: 50%;" />
|
||||
<img src="https://github.com/jakequade.png?size=80" width="80" style="border-radius: 50%;" />
|
||||
<br /><sub><b>@jakequade</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/Ryan0204">
|
||||
<img src="https://github.com/Ryan0204.png?size=55" width="55" style="border-radius: 50%;" />
|
||||
<img src="https://github.com/Ryan0204.png?size=80" width="80" style="border-radius: 50%;" />
|
||||
<br /><sub><b>@Ryan0204</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/retardgerman">
|
||||
<img src="https://github.com/retardgerman.png?size=55" width="55" style="border-radius: 50%;" />
|
||||
<img src="https://github.com/retardgerman.png?size=80" width="80" style="border-radius: 50%;" />
|
||||
<br /><sub><b>@retardgerman</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/whoopsi-daisy">
|
||||
<img src="https://github.com/whoopsi-daisy.png?size=55" width="55" style="border-radius: 50%;" />
|
||||
<img src="https://github.com/whoopsi-daisy.png?size=80" width="80" style="border-radius: 50%;" />
|
||||
<br /><sub><b>@whoopsi-daisy</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/Gauvino">
|
||||
<img src="https://github.com/Gauvino.png?size=55" width="55" style="border-radius: 50%;" />
|
||||
<br /><sub><b>@Gauvino</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
And all other developers who have contributed to Streamyfin, thank you for your contributions.
|
||||
|
||||
@@ -246,4 +228,4 @@ I'd also like to thank the following people and projects for their contributions
|
||||
Streamyfin does not promote, support, or condone piracy in any form. The app is intended solely for streaming media that you personally own and control. It does not provide or include any media content. Any discussions or support requests related to piracy are strictly prohibited across all our channels.
|
||||
|
||||
## 🤝 Sponsorship
|
||||
VPS hosting generously provided by [Hexabyte](https://hexabyte.se/en/vps/?currency=eur) and [SweHosting](https://swehosting.se/en/#tj%C3%A4nster)
|
||||
VPS hosting generously provided by [Hexabyte](https://hexabyte.se/en/vps/?currency=eur)
|
||||
|
||||
40
SECURITY.md
@@ -1,40 +0,0 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions of Streamyfin Mobile App
|
||||
|
||||
Only the most recent stable release of the Streamyfin mobile app is guaranteed to include the latest security patches. **Running older app versions may leave you vulnerable to security risks**. Always update your app from the official App Store or Google Play Store as soon as updates are available. If you must run an older version, avoid using sensitive features (e.g., account management, payment methods) until you can upgrade.
|
||||
|
||||
This policy applies only to the current stable app release. Security flaws in previous app versions that are no longer present in the latest release **will not** be back-ported or fixed.
|
||||
|
||||
## Supported Versions of Other Streamyfin Components (Server, Plugins)
|
||||
|
||||
Most Streamyfin backend services and plugins are supported only in their latest release. Consult each project’s README or release notes for any exceptions.
|
||||
|
||||
## Vulnerability Triage
|
||||
|
||||
Before reporting an issue, please consider:
|
||||
|
||||
- Administrator-level risks: Certain administrative or configuration endpoints in the backend may inherently carry elevated privileges. Vulnerabilities that **require administrator or root access** are classified as low priority. Report those via normal GitHub Issues.
|
||||
- Known vulnerabilities: We maintain a public list of known issues on our Security Advisories page at https://github.com/Streamyfin/Streamyfin/security/advisories. If your issue is already listed there, please do not re-report it.
|
||||
- Local-only issues: Vulnerabilities exploitable only with physical device access, manual file modification, or local debugging (e.g., modifying app files, rooting/jailbreaking) are considered low- to medium-priority.
|
||||
- Infrastructure reports: To report issues in our website, servers, CI/CD, or other infrastructure, tag your report subject with `[Streamyfin Infrastructure]`. Our infrastructure team follows standard patch policies for public vulnerabilities, so avoid duplicating widely known issues.
|
||||
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
After confirming your issue is new and relevant, send an email to **developer@streamyfin.app** with the following:
|
||||
|
||||
1. Subject line: `[Streamyfin Security] <short summary>`
|
||||
2. Overview (public-safe): Describe what component is affected (mobile app, backend API, plugin) and the high-level impact. We may reuse this text for a GitHub Security Advisory.
|
||||
3. Details: Provide reproduction steps, code or API snippets, proof-of-concept, and any suggested remediation. Detail exactly how to trigger the issue.
|
||||
4. Your GitHub username: So we can invite you to the GitHub Security Advisory (GHSA) for coordination and credit.
|
||||
|
||||
Once received, we will review the report, file a GHSA if warranted, and include you and the relevant teams in the remediation process.
|
||||
|
||||
## Post-Disclosure Process
|
||||
|
||||
Streamyfin is a volunteer-driven project. **We appreciate patience and do not enforce strict disclosure deadlines**, especially for complex issues. You may send polite follow-ups if there’s no response after a reasonable interval.
|
||||
|
||||
- Patch releases: For critical vulnerabilities, we generally issue a point release promptly unless a major release is imminent; in that case, we defer the fix.
|
||||
- Advisory publication: After releasing a patched app version, we wait at least seven days (1 week) before publishing the GHSA to allow most users to upgrade. We request that any third-party disclosures (blog posts, advisories) occur **after** our GHSA publication.
|
||||
- CVE assignment: We will request CVEs via the GitHub Security interface and include them in the published advisory.
|
||||
@@ -1,19 +1,8 @@
|
||||
module.exports = ({ config }) => {
|
||||
if (process.env.EXPO_TV === "1") {
|
||||
// Add TV-specific plugin for TV builds
|
||||
config.plugins.push("@react-native-tvos/config-tv");
|
||||
} else {
|
||||
// Add non-TV specific plugins for phone builds
|
||||
config.plugins.push("expo-background-task");
|
||||
|
||||
config.plugins.push([
|
||||
"react-native-google-cast",
|
||||
{ useDefaultExpandedMediaControls: true },
|
||||
]);
|
||||
|
||||
// Add the background downloader plugin only for non-TV builds
|
||||
config.plugins.push("./plugins/withRNBackgroundDownloader.js");
|
||||
}
|
||||
config.plugins.push([
|
||||
"react-native-google-cast",
|
||||
{ useDefaultExpandedMediaControls: true },
|
||||
]);
|
||||
return {
|
||||
android: {
|
||||
googleServicesFile: process.env.GOOGLE_SERVICES_JSON,
|
||||
|
||||
31
app.json
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "Streamyfin",
|
||||
"slug": "streamyfin",
|
||||
"version": "0.39.0",
|
||||
"version": "0.29.1",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "streamyfin",
|
||||
@@ -29,19 +29,18 @@
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.fredrikburmester.streamyfin",
|
||||
"icon": {
|
||||
"dark": "./assets/images/icon-ios-plain.png",
|
||||
"dark": "./assets/images/icon-plain.png",
|
||||
"light": "./assets/images/icon-ios-light.png",
|
||||
"tinted": "./assets/images/icon-ios-tinted.png"
|
||||
},
|
||||
"appleTeamId": "MWD5K362T8"
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"jsEngine": "hermes",
|
||||
"versionCode": 71,
|
||||
"versionCode": 56,
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/icon-android-plain.png",
|
||||
"monochromeImage": "./assets/images/icon-android-themed.png",
|
||||
"backgroundColor": "#2E2E2E"
|
||||
"foregroundImage": "./assets/images/icon-plain.png",
|
||||
"monochromeImage": "./assets/images/icon-mono.png",
|
||||
"backgroundColor": "#464646"
|
||||
},
|
||||
"package": "com.fredrikburmester.streamyfin",
|
||||
"permissions": [
|
||||
@@ -49,7 +48,6 @@
|
||||
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
|
||||
"android.permission.WRITE_SETTINGS"
|
||||
],
|
||||
"blockedPermissions": ["android.permission.ACTIVITY_RECOGNITION"],
|
||||
"googleServicesFile": "./google-services.json"
|
||||
},
|
||||
"plugins": [
|
||||
@@ -114,15 +112,17 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
["react-native-bottom-tabs"],
|
||||
["./plugins/withChangeNativeAndroidTextToWhite.js"],
|
||||
["./plugins/withAndroidManifest.js"],
|
||||
["./plugins/withTrustLocalCerts.js"],
|
||||
["./plugins/withGradleProperties.js"],
|
||||
["./plugins/withRNBackgroundDownloader.js"],
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
"backgroundColor": "#010101",
|
||||
"image": "./assets/images/icon-ios-plain.png",
|
||||
"backgroundColor": "#2e2e2e",
|
||||
"image": "./assets/images/StreamyFinFinal.png",
|
||||
"imageWidth": 100
|
||||
}
|
||||
],
|
||||
@@ -133,8 +133,13 @@
|
||||
"color": "#9333EA"
|
||||
}
|
||||
],
|
||||
"./plugins/with-runtime-framework-headers.js",
|
||||
"react-native-bottom-tabs"
|
||||
[
|
||||
"react-native-google-cast",
|
||||
{
|
||||
"useDefaultExpandedMediaControls": true
|
||||
}
|
||||
],
|
||||
"expo-background-task"
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
|
||||
@@ -12,7 +12,7 @@ export default function CustomMenuLayout() {
|
||||
headerShown: true,
|
||||
headerLargeTitle: true,
|
||||
headerTitle: t("tabs.custom_links"),
|
||||
headerBlurEffect: "none",
|
||||
headerBlurEffect: "prominent",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
|
||||
@@ -10,9 +10,13 @@ export default function SearchLayout() {
|
||||
<Stack.Screen
|
||||
name='index'
|
||||
options={{
|
||||
headerShown: !Platform.isTV,
|
||||
headerShown: true,
|
||||
headerLargeTitle: true,
|
||||
headerTitle: t("tabs.favorites"),
|
||||
headerBlurEffect: "none",
|
||||
headerLargeStyle: {
|
||||
backgroundColor: "black",
|
||||
},
|
||||
headerBlurEffect: "prominent",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
|
||||
@@ -20,17 +20,20 @@ export default function IndexLayout() {
|
||||
<Stack.Screen
|
||||
name='index'
|
||||
options={{
|
||||
headerShown: !Platform.isTV,
|
||||
headerShown: true,
|
||||
headerLargeTitle: true,
|
||||
headerTitle: t("tabs.home"),
|
||||
headerBlurEffect: "none",
|
||||
headerBlurEffect: "prominent",
|
||||
headerLargeStyle: {
|
||||
backgroundColor: "black",
|
||||
},
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerRight: () => (
|
||||
<View className='flex flex-row items-center px-2'>
|
||||
<View className='flex flex-row items-center space-x-2'>
|
||||
{!Platform.isTV && (
|
||||
<>
|
||||
<Chromecast.Chromecast background='transparent' />
|
||||
|
||||
<Chromecast.Chromecast />
|
||||
{user?.Policy?.IsAdministrator && <SessionsButton />}
|
||||
<SettingsButton />
|
||||
</>
|
||||
@@ -63,6 +66,12 @@ export default function IndexLayout() {
|
||||
title: t("home.settings.settings_title"),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='settings/optimized-server/page'
|
||||
options={{
|
||||
title: "",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='settings/marlin-search/page'
|
||||
options={{
|
||||
@@ -135,13 +144,14 @@ const SessionsButton = () => {
|
||||
onPress={() => {
|
||||
router.push("/(auth)/sessions");
|
||||
}}
|
||||
className='mr-4'
|
||||
>
|
||||
<Ionicons
|
||||
name='play-circle'
|
||||
color={sessions.length === 0 ? "white" : "#9333ea"}
|
||||
size={28}
|
||||
/>
|
||||
<View className='mr-4'>
|
||||
<Ionicons
|
||||
name='play-circle'
|
||||
color={sessions.length === 0 ? "white" : "#9333ea"}
|
||||
size={25}
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -23,12 +23,12 @@ export default function page() {
|
||||
const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>(
|
||||
{},
|
||||
);
|
||||
const { getDownloadedItems, deleteItems } = useDownload();
|
||||
const { downloadedFiles, deleteItems } = useDownload();
|
||||
|
||||
const series = useMemo(() => {
|
||||
try {
|
||||
return (
|
||||
getDownloadedItems()
|
||||
downloadedFiles
|
||||
?.filter((f) => f.item.SeriesId === seriesId)
|
||||
?.sort(
|
||||
(a, b) => a?.item.ParentIndexNumber! - b.item.ParentIndexNumber!,
|
||||
@@ -37,37 +37,7 @@ export default function page() {
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}, [getDownloadedItems]);
|
||||
|
||||
// Group episodes by season in a single pass
|
||||
const seasonGroups = useMemo(() => {
|
||||
const groups: Record<number, BaseItemDto[]> = {};
|
||||
|
||||
series.forEach((episode) => {
|
||||
const seasonNumber = episode.item.ParentIndexNumber;
|
||||
if (seasonNumber !== undefined && seasonNumber !== null) {
|
||||
if (!groups[seasonNumber]) {
|
||||
groups[seasonNumber] = [];
|
||||
}
|
||||
groups[seasonNumber].push(episode.item);
|
||||
}
|
||||
});
|
||||
|
||||
// Sort episodes within each season
|
||||
Object.values(groups).forEach((episodes) => {
|
||||
episodes.sort((a, b) => (a.IndexNumber || 0) - (b.IndexNumber || 0));
|
||||
});
|
||||
|
||||
return groups;
|
||||
}, [series]);
|
||||
|
||||
// Get unique seasons (just the season numbers, sorted)
|
||||
const uniqueSeasons = useMemo(() => {
|
||||
const seasonNumbers = Object.keys(seasonGroups)
|
||||
.map(Number)
|
||||
.sort((a, b) => a - b);
|
||||
return seasonNumbers.map((seasonNum) => seasonGroups[seasonNum][0]); // First episode of each season
|
||||
}, [seasonGroups]);
|
||||
}, [downloadedFiles]);
|
||||
|
||||
const seasonIndex =
|
||||
seasonIndexState[series?.[0]?.item?.ParentId ?? ""] ||
|
||||
@@ -75,8 +45,20 @@ export default function page() {
|
||||
"";
|
||||
|
||||
const groupBySeason = useMemo<BaseItemDto[]>(() => {
|
||||
return seasonGroups[Number(seasonIndex)] ?? [];
|
||||
}, [seasonGroups, seasonIndex]);
|
||||
const seasons: Record<string, BaseItemDto[]> = {};
|
||||
|
||||
series?.forEach((episode) => {
|
||||
if (!seasons[episode.item.ParentIndexNumber!]) {
|
||||
seasons[episode.item.ParentIndexNumber!] = [];
|
||||
}
|
||||
|
||||
seasons[episode.item.ParentIndexNumber!].push(episode.item);
|
||||
});
|
||||
return (
|
||||
seasons[seasonIndex]?.sort((a, b) => a.IndexNumber! - b.IndexNumber!) ??
|
||||
[]
|
||||
);
|
||||
}, [series, seasonIndex]);
|
||||
|
||||
const initialSeasonIndex = useMemo(
|
||||
() =>
|
||||
@@ -120,7 +102,7 @@ export default function page() {
|
||||
<View className='flex flex-row items-center justify-start my-2 px-4'>
|
||||
<SeasonDropdown
|
||||
item={series[0].item}
|
||||
seasons={uniqueSeasons}
|
||||
seasons={series.map((s) => s.item)}
|
||||
state={seasonIndexState}
|
||||
initialSeasonIndex={initialSeasonIndex!}
|
||||
onSelect={(season) => {
|
||||
|
||||
@@ -6,69 +6,38 @@ import {
|
||||
BottomSheetView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { useNavigation, useRouter } from "expo-router";
|
||||
import { t } from "i18next";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { toast } from "sonner-native";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import ActiveDownloads from "@/components/downloads/ActiveDownloads";
|
||||
import { ActiveDownloads } from "@/components/downloads/ActiveDownloads";
|
||||
import { DownloadSize } from "@/components/downloads/DownloadSize";
|
||||
import { MovieCard } from "@/components/downloads/MovieCard";
|
||||
import { SeriesCard } from "@/components/downloads/SeriesCard";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { type DownloadedItem } from "@/providers/Downloads/types";
|
||||
import { type DownloadedItem, useDownload } from "@/providers/DownloadProvider";
|
||||
import { queueAtom } from "@/utils/atoms/queue";
|
||||
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
|
||||
export default function page() {
|
||||
const navigation = useNavigation();
|
||||
const { t } = useTranslation();
|
||||
const [queue, setQueue] = useAtom(queueAtom);
|
||||
const {
|
||||
removeProcess,
|
||||
getDownloadedItems,
|
||||
deleteFileByType,
|
||||
deleteAllFiles,
|
||||
} = useDownload();
|
||||
const { removeProcess, downloadedFiles, deleteFileByType } = useDownload();
|
||||
const router = useRouter();
|
||||
const [settings] = useSettings();
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
|
||||
const [showMigration, setShowMigration] = useState(false);
|
||||
|
||||
const migration_20241124 = () => {
|
||||
Alert.alert(
|
||||
t("home.downloads.new_app_version_requires_re_download"),
|
||||
t("home.downloads.new_app_version_requires_re_download_description"),
|
||||
[
|
||||
{
|
||||
text: t("home.downloads.back"),
|
||||
onPress: () => {
|
||||
setShowMigration(false);
|
||||
router.back();
|
||||
},
|
||||
},
|
||||
{
|
||||
text: t("home.downloads.delete"),
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
await deleteAllFiles();
|
||||
setShowMigration(false);
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
const downloadedFiles = getDownloadedItems();
|
||||
|
||||
const movies = useMemo(() => {
|
||||
try {
|
||||
return downloadedFiles?.filter((f) => f.item.Type === "Movie") || [];
|
||||
} catch {
|
||||
setShowMigration(true);
|
||||
migration_20241124();
|
||||
return [];
|
||||
}
|
||||
}, [downloadedFiles]);
|
||||
@@ -85,23 +54,12 @@ export default function page() {
|
||||
});
|
||||
return Object.values(series);
|
||||
} catch {
|
||||
setShowMigration(true);
|
||||
migration_20241124();
|
||||
return [];
|
||||
}
|
||||
}, [downloadedFiles]);
|
||||
|
||||
const otherMedia = useMemo(() => {
|
||||
try {
|
||||
return (
|
||||
downloadedFiles?.filter(
|
||||
(f) => f.item.Type !== "Movie" && f.item.Type !== "Episode",
|
||||
) || []
|
||||
);
|
||||
} catch {
|
||||
setShowMigration(true);
|
||||
return [];
|
||||
}
|
||||
}, [downloadedFiles]);
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
@@ -113,12 +71,6 @@ export default function page() {
|
||||
});
|
||||
}, [downloadedFiles]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showMigration) {
|
||||
migration_20241124();
|
||||
}
|
||||
}, [showMigration]);
|
||||
|
||||
const deleteMovies = () =>
|
||||
deleteFileByType("Movie")
|
||||
.then(() =>
|
||||
@@ -141,37 +93,21 @@ export default function page() {
|
||||
writeToLog("ERROR", reason);
|
||||
toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
|
||||
});
|
||||
const deleteOtherMedia = () =>
|
||||
Promise.all(
|
||||
otherMedia.map((item) =>
|
||||
deleteFileByType(item.item.Type)
|
||||
.then(() =>
|
||||
toast.success(
|
||||
t("home.downloads.toasts.deleted_media_successfully", {
|
||||
type: item.item.Type,
|
||||
}),
|
||||
),
|
||||
)
|
||||
.catch((reason) => {
|
||||
writeToLog("ERROR", reason);
|
||||
toast.error(
|
||||
t("home.downloads.toasts.failed_to_delete_media", {
|
||||
type: item.item.Type,
|
||||
}),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const deleteAllMedia = async () =>
|
||||
await Promise.all([deleteMovies(), deleteShows(), deleteOtherMedia()]);
|
||||
await Promise.all([deleteMovies(), deleteShows()]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<View style={{ flex: 1 }}>
|
||||
<ScrollView showsVerticalScrollIndicator={false} className='flex-1'>
|
||||
<View className='py-4'>
|
||||
<View className='mb-4 flex flex-col space-y-4 px-4'>
|
||||
<ScrollView
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: 100,
|
||||
}}
|
||||
>
|
||||
<View className='py-4'>
|
||||
<View className='mb-4 flex flex-col space-y-4 px-4'>
|
||||
{settings?.downloadMethod === DownloadMethod.Remux && (
|
||||
<View className='bg-neutral-900 p-4 rounded-2xl'>
|
||||
<Text className='text-lg font-bold'>
|
||||
{t("home.downloads.queue")}
|
||||
@@ -215,102 +151,70 @@ export default function page() {
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<ActiveDownloads />
|
||||
</View>
|
||||
|
||||
{movies.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.movies")}
|
||||
</Text>
|
||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
||||
<Text className='text-xs font-bold'>{movies?.length}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View className='px-4 flex flex-row'>
|
||||
{movies?.map((item) => (
|
||||
<TouchableItemRouter
|
||||
item={item.item}
|
||||
isOffline
|
||||
key={item.item.Id}
|
||||
>
|
||||
<MovieCard item={item.item} />
|
||||
</TouchableItemRouter>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
{groupedBySeries.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.tvseries")}
|
||||
</Text>
|
||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
||||
<Text className='text-xs font-bold'>
|
||||
{groupedBySeries?.length}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View className='px-4 flex flex-row'>
|
||||
{groupedBySeries?.map((items) => (
|
||||
<View
|
||||
className='mb-2 last:mb-0'
|
||||
key={items[0].item.SeriesId}
|
||||
>
|
||||
<SeriesCard
|
||||
items={items.map((i) => i.item)}
|
||||
key={items[0].item.SeriesId}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{otherMedia.length > 0 && (
|
||||
<View className='mb-4'>
|
||||
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
||||
<Text className='text-lg font-bold'>
|
||||
{t("home.downloads.other_media")}
|
||||
</Text>
|
||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
||||
<Text className='text-xs font-bold'>
|
||||
{otherMedia?.length}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View className='px-4 flex flex-row'>
|
||||
{otherMedia?.map((item) => (
|
||||
<TouchableItemRouter
|
||||
item={item.item}
|
||||
isOffline
|
||||
key={item.item.Id}
|
||||
>
|
||||
<MovieCard item={item.item} />
|
||||
</TouchableItemRouter>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
{downloadedFiles?.length === 0 && (
|
||||
<View className='flex px-4'>
|
||||
<Text className='opacity-50'>
|
||||
{t("home.downloads.no_downloaded_items")}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<ActiveDownloads />
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
{movies.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.movies")}
|
||||
</Text>
|
||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
||||
<Text className='text-xs font-bold'>{movies?.length}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View className='px-4 flex flex-row'>
|
||||
{movies?.map((item) => (
|
||||
<View className='mb-2 last:mb-0' key={item.item.Id}>
|
||||
<MovieCard item={item.item} />
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
{groupedBySeries.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.tvseries")}
|
||||
</Text>
|
||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
||||
<Text className='text-xs font-bold'>
|
||||
{groupedBySeries?.length}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View className='px-4 flex flex-row'>
|
||||
{groupedBySeries?.map((items) => (
|
||||
<View
|
||||
className='mb-2 last:mb-0'
|
||||
key={items[0].item.SeriesId}
|
||||
>
|
||||
<SeriesCard
|
||||
items={items.map((i) => i.item)}
|
||||
key={items[0].item.SeriesId}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
{downloadedFiles?.length === 0 && (
|
||||
<View className='flex px-4'>
|
||||
<Text className='opacity-50'>
|
||||
{t("home.downloads.no_downloaded_items")}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetModalRef}
|
||||
enableDynamicSizing
|
||||
@@ -336,11 +240,6 @@ export default function page() {
|
||||
<Button color='purple' onPress={deleteShows}>
|
||||
{t("home.downloads.delete_all_tvseries_button")}
|
||||
</Button>
|
||||
{otherMedia.length > 0 && (
|
||||
<Button color='purple' onPress={deleteOtherMedia}>
|
||||
{t("home.downloads.delete_all_other_media_button")}
|
||||
</Button>
|
||||
)}
|
||||
<Button color='red' onPress={deleteAllMedia}>
|
||||
{t("home.downloads.delete_all_button")}
|
||||
</Button>
|
||||
@@ -350,3 +249,23 @@ export default function page() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function migration_20241124() {
|
||||
const router = useRouter();
|
||||
const { deleteAllFiles } = useDownload();
|
||||
Alert.alert(
|
||||
t("home.downloads.new_app_version_requires_re_download"),
|
||||
t("home.downloads.new_app_version_requires_re_download_description"),
|
||||
[
|
||||
{
|
||||
text: t("home.downloads.back"),
|
||||
onPress: () => router.back(),
|
||||
},
|
||||
{
|
||||
text: t("home.downloads.delete"),
|
||||
style: "destructive",
|
||||
onPress: async () => await deleteAllFiles(),
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Image } from "expo-image";
|
||||
import { useFocusEffect, useRouter } from "expo-router";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Linking, Platform, TouchableOpacity, View } from "react-native";
|
||||
import { Linking, TouchableOpacity, View } from "react-native";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
@@ -19,9 +19,7 @@ export default function page() {
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
className={`bg-neutral-900 h-full ${Platform.isTV ? "py-5 space-y-4" : "py-16 space-y-8"} px-4`}
|
||||
>
|
||||
<View className='bg-neutral-900 h-full py-16 px-4 space-y-8'>
|
||||
<View>
|
||||
<Text className='text-3xl font-bold text-center mb-2'>
|
||||
{t("home.intro.welcome_to_streamyfin")}
|
||||
@@ -51,50 +49,42 @@ export default function page() {
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
{!Platform.isTV && (
|
||||
<>
|
||||
<View className='flex flex-row items-center mt-4'>
|
||||
<View
|
||||
style={{
|
||||
width: 50,
|
||||
height: 50,
|
||||
}}
|
||||
className='flex items-center justify-center'
|
||||
>
|
||||
<Ionicons
|
||||
name='cloud-download-outline'
|
||||
size={32}
|
||||
color='white'
|
||||
/>
|
||||
</View>
|
||||
<View className='shrink ml-2'>
|
||||
<Text className='font-bold mb-1'>
|
||||
{t("home.intro.downloads_feature_title")}
|
||||
</Text>
|
||||
<Text className='shrink text-xs'>
|
||||
{t("home.intro.downloads_feature_description")}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className='flex flex-row items-center mt-4'>
|
||||
<View
|
||||
style={{
|
||||
width: 50,
|
||||
height: 50,
|
||||
}}
|
||||
className='flex items-center justify-center'
|
||||
>
|
||||
<Feather name='cast' size={28} color={"white"} />
|
||||
</View>
|
||||
<View className='shrink ml-2'>
|
||||
<Text className='font-bold mb-1'>Chromecast</Text>
|
||||
<Text className='shrink text-xs'>
|
||||
{t("home.intro.chromecast_feature_description")}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
<View className='flex flex-row items-center mt-4'>
|
||||
<View
|
||||
style={{
|
||||
width: 50,
|
||||
height: 50,
|
||||
}}
|
||||
className='flex items-center justify-center'
|
||||
>
|
||||
<Ionicons name='cloud-download-outline' size={32} color='white' />
|
||||
</View>
|
||||
<View className='shrink ml-2'>
|
||||
<Text className='font-bold mb-1'>
|
||||
{t("home.intro.downloads_feature_title")}
|
||||
</Text>
|
||||
<Text className='shrink text-xs'>
|
||||
{t("home.intro.downloads_feature_description")}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className='flex flex-row items-center mt-4'>
|
||||
<View
|
||||
style={{
|
||||
width: 50,
|
||||
height: 50,
|
||||
}}
|
||||
className='flex items-center justify-center'
|
||||
>
|
||||
<Feather name='cast' size={28} color={"white"} />
|
||||
</View>
|
||||
<View className='shrink ml-2'>
|
||||
<Text className='font-bold mb-1'>Chromecast</Text>
|
||||
<Text className='shrink text-xs'>
|
||||
{t("home.intro.chromecast_feature_description")}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className='flex flex-row items-center mt-4'>
|
||||
<View
|
||||
style={{
|
||||
@@ -109,22 +99,19 @@ export default function page() {
|
||||
<Text className='font-bold mb-1'>
|
||||
{t("home.intro.centralised_settings_plugin_title")}
|
||||
</Text>
|
||||
<View className='flex-row flex-wrap items-baseline'>
|
||||
<Text className='shrink text-xs'>
|
||||
{t("home.intro.centralised_settings_plugin_description")}{" "}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
<Text className='shrink text-xs'>
|
||||
{t("home.intro.centralised_settings_plugin_description")}{" "}
|
||||
<Text
|
||||
className='text-purple-600'
|
||||
onPress={() => {
|
||||
Linking.openURL(
|
||||
"https://github.com/streamyfin/jellyfin-plugin-streamyfin",
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Text className='text-xs text-purple-600 underline'>
|
||||
{t("home.intro.read_more")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{t("home.intro.read_more")}
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -99,19 +99,15 @@ const SessionCard = ({ session }: SessionCardProps) => {
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
const { data: ipInfo } = useQuery<{
|
||||
cityName?: string;
|
||||
countryCode?: string;
|
||||
}>({
|
||||
const { data: ipInfo } = useQuery({
|
||||
queryKey: ["ipinfo", session.RemoteEndPoint],
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
cacheTime: Number.POSITIVE_INFINITY,
|
||||
queryFn: async () => {
|
||||
const resp = await api!.axiosInstance.get(
|
||||
const resp = await api.axiosInstance.get(
|
||||
`https://freeipapi.com/api/json/${session.RemoteEndPoint}`,
|
||||
);
|
||||
return resp.data;
|
||||
},
|
||||
enabled: !!api,
|
||||
});
|
||||
|
||||
// Handle session controls
|
||||
@@ -438,6 +434,8 @@ const TranscodingStreamView = ({
|
||||
isTranscoding,
|
||||
properties,
|
||||
transcodeProperties,
|
||||
value,
|
||||
transcodeValue,
|
||||
}: TranscodingStreamViewProps) => {
|
||||
return (
|
||||
<View className='flex flex-col pt-2 first:pt-0'>
|
||||
@@ -468,7 +466,6 @@ const TranscodingStreamView = ({
|
||||
};
|
||||
|
||||
const TranscodingView = ({ session }: SessionCardProps) => {
|
||||
const { t } = useTranslation();
|
||||
const videoStream = useMemo(() => {
|
||||
return session.NowPlayingItem?.MediaStreams?.filter(
|
||||
(s) => s.Type === "Video",
|
||||
@@ -502,7 +499,7 @@ const TranscodingView = ({ session }: SessionCardProps) => {
|
||||
return (
|
||||
<View className='flex flex-col bg-neutral-800 rounded-b-2xl p-4 pt-2'>
|
||||
<TranscodingStreamView
|
||||
title={t("common.video")}
|
||||
title='Video'
|
||||
properties={{
|
||||
resolution: videoStreamTitle(),
|
||||
bitrate: videoStream?.BitRate,
|
||||
@@ -519,7 +516,7 @@ const TranscodingView = ({ session }: SessionCardProps) => {
|
||||
/>
|
||||
|
||||
<TranscodingStreamView
|
||||
title={t("common.audio")}
|
||||
title='Audio'
|
||||
properties={{
|
||||
language: audioStream?.Language,
|
||||
bitrate: audioStream?.BitRate,
|
||||
@@ -537,7 +534,7 @@ const TranscodingView = ({ session }: SessionCardProps) => {
|
||||
|
||||
{subtitleStream && (
|
||||
<TranscodingStreamView
|
||||
title={t("common.subtitle")}
|
||||
title='Subtitle'
|
||||
isTranscoding={false}
|
||||
properties={{
|
||||
language: subtitleStream?.Language,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useNavigation, useRouter } from "expo-router";
|
||||
import { t } from "i18next";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect } from "react";
|
||||
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import { ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ListGroup } from "@/components/list/ListGroup";
|
||||
@@ -11,7 +11,6 @@ import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
|
||||
import { AudioToggles } from "@/components/settings/AudioToggles";
|
||||
import { ChromecastSettings } from "@/components/settings/ChromecastSettings";
|
||||
import DownloadSettings from "@/components/settings/DownloadSettings";
|
||||
import { GestureControls } from "@/components/settings/GestureControls";
|
||||
import { MediaProvider } from "@/components/settings/MediaContext";
|
||||
import { MediaToggles } from "@/components/settings/MediaToggles";
|
||||
import { OtherSettings } from "@/components/settings/OtherSettings";
|
||||
@@ -68,20 +67,19 @@ export default function settings() {
|
||||
|
||||
<MediaProvider>
|
||||
<MediaToggles className='mb-4' />
|
||||
<GestureControls className='mb-4' />
|
||||
<AudioToggles className='mb-4' />
|
||||
<SubtitleToggles className='mb-4' />
|
||||
</MediaProvider>
|
||||
|
||||
<OtherSettings />
|
||||
|
||||
{!Platform.isTV && <DownloadSettings />}
|
||||
<DownloadSettings />
|
||||
|
||||
<PluginSettings />
|
||||
|
||||
<AppLanguageSelector />
|
||||
|
||||
{!Platform.isTV && <ChromecastSettings />}
|
||||
<ChromecastSettings />
|
||||
|
||||
<ListGroup title={"Intro"}>
|
||||
<ListItem
|
||||
@@ -114,7 +112,7 @@ export default function settings() {
|
||||
</ListGroup>
|
||||
</View>
|
||||
|
||||
{!Platform.isTV && <StorageSettings />}
|
||||
<StorageSettings />
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
@@ -12,7 +12,7 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function page() {
|
||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||
const [settings, updateSettings, pluginSettings] = useSettings();
|
||||
const user = useAtomValue(userAtom);
|
||||
const api = useAtomValue(apiAtom);
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function page() {
|
||||
const { pluginSettings } = useSettings();
|
||||
const [_settings, _updateSettings, pluginSettings] = useSettings();
|
||||
|
||||
return (
|
||||
<DisabledSetting
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import { useNavigation } from "expo-router";
|
||||
import * as Sharing from "expo-sharing";
|
||||
import { useCallback, useEffect, useId, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import Collapsible from "react-native-collapsible";
|
||||
@@ -10,14 +10,11 @@ import { FilterButton } from "@/components/filters/FilterButton";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { LogLevel, useLog, writeErrorLog } from "@/utils/log";
|
||||
|
||||
export default function Page() {
|
||||
export default function page() {
|
||||
const navigation = useNavigation();
|
||||
const { logs } = useLog();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const orderFilterId = useId();
|
||||
const levelsFilterId = useId();
|
||||
|
||||
const defaultLevels: LogLevel[] = ["INFO", "ERROR", "DEBUG", "WARN"];
|
||||
const codeBlockStyle = {
|
||||
backgroundColor: "#000",
|
||||
@@ -28,12 +25,10 @@ export default function Page() {
|
||||
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [state, setState] = useState<Record<string, boolean>>({});
|
||||
|
||||
const [order, setOrder] = useState<"asc" | "desc">("desc");
|
||||
const [levels, setLevels] = useState<LogLevel[]>(defaultLevels);
|
||||
|
||||
const _orderId = useId();
|
||||
const _levelsId = useId();
|
||||
|
||||
const filteredLogs = useMemo(
|
||||
() =>
|
||||
logs
|
||||
@@ -78,24 +73,24 @@ export default function Page() {
|
||||
<>
|
||||
<View className='flex flex-row justify-end py-2 px-4 space-x-2'>
|
||||
<FilterButton
|
||||
id={orderFilterId}
|
||||
id='order'
|
||||
queryKey='log'
|
||||
queryFn={async () => ["asc", "desc"]}
|
||||
set={(values) => setOrder(values[0])}
|
||||
values={[order]}
|
||||
title={t("library.filters.sort_order")}
|
||||
renderItemLabel={(order) => t(`library.filters.${order}`)}
|
||||
disableSearch={true}
|
||||
showSearch={false}
|
||||
/>
|
||||
<FilterButton
|
||||
id={levelsFilterId}
|
||||
id='levels'
|
||||
queryKey='log'
|
||||
queryFn={async () => defaultLevels}
|
||||
set={setLevels}
|
||||
values={levels}
|
||||
title={t("home.settings.logs.level")}
|
||||
renderItemLabel={(level) => level}
|
||||
disableSearch={true}
|
||||
showSearch={false}
|
||||
multiple={true}
|
||||
/>
|
||||
</View>
|
||||
@@ -127,7 +122,7 @@ export default function Page() {
|
||||
{new Date(log.timestamp).toLocaleString()}
|
||||
</Text>
|
||||
</View>
|
||||
<Text selectable className='text-xs'>
|
||||
<Text uiTextView selectable className='text-xs'>
|
||||
{log.message}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -21,7 +21,7 @@ export default function page() {
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||
const [settings, updateSettings, pluginSettings] = useSettings();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
|
||||
|
||||
93
app/(auth)/(tabs)/(home)/settings/optimized-server/page.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActivityIndicator, TouchableOpacity } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import { OptimizedServerForm } from "@/components/settings/OptimizedServerForm";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getOrSetDeviceId } from "@/utils/device";
|
||||
import { getStatistics } from "@/utils/optimize-server";
|
||||
|
||||
export default function page() {
|
||||
const navigation = useNavigation();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [settings, updateSettings, pluginSettings] = useSettings();
|
||||
|
||||
const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] =
|
||||
useState<string>(settings?.optimizedVersionsServerUrl || "");
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (newVal: string) => {
|
||||
if (newVal.length === 0 || !newVal.startsWith("http")) {
|
||||
toast.error(t("home.settings.toasts.invalid_url"));
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedUrl = newVal.endsWith("/") ? newVal : `${newVal}/`;
|
||||
|
||||
updateSettings({
|
||||
optimizedVersionsServerUrl: updatedUrl,
|
||||
});
|
||||
|
||||
return await getStatistics({
|
||||
url: updatedUrl,
|
||||
authHeader: api?.accessToken,
|
||||
deviceId: getOrSetDeviceId(),
|
||||
});
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
if (data) {
|
||||
toast.success(t("home.settings.toasts.connected"));
|
||||
} else {
|
||||
toast.error(t("home.settings.toasts.could_not_connect"));
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("home.settings.toasts.could_not_connect"));
|
||||
},
|
||||
});
|
||||
|
||||
const onSave = (newVal: string) => {
|
||||
saveMutation.mutate(newVal);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!pluginSettings?.optimizedVersionsServerUrl?.locked) {
|
||||
navigation.setOptions({
|
||||
title: t("home.settings.downloads.optimized_server"),
|
||||
headerRight: () =>
|
||||
saveMutation.isPending ? (
|
||||
<ActivityIndicator size={"small"} color={"white"} />
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
onPress={() => onSave(optimizedVersionsServerUrl)}
|
||||
>
|
||||
<Text className='text-blue-500'>
|
||||
{t("home.settings.downloads.save_button")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
),
|
||||
});
|
||||
}
|
||||
}, [navigation, optimizedVersionsServerUrl, saveMutation.isPending]);
|
||||
|
||||
return (
|
||||
<DisabledSetting
|
||||
disabled={pluginSettings?.optimizedVersionsServerUrl?.locked === true}
|
||||
className='p-4'
|
||||
>
|
||||
<OptimizedServerForm
|
||||
value={optimizedVersionsServerUrl}
|
||||
onChangeValue={setOptimizedVersionsServerUrl}
|
||||
/>
|
||||
</DisabledSetting>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { useAtom } from "jotai";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { InfiniteHorizontalScroll } from "@/components/common/InfiniteHorizontalScroll";
|
||||
import { InfiniteHorizontalScroll } from "@/components/common/InfiniteHorrizontalScroll";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
@@ -22,21 +22,21 @@ import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
|
||||
const page: React.FC = () => {
|
||||
const local = useLocalSearchParams();
|
||||
const { personId } = local as { personId: string };
|
||||
const { actorId } = local as { actorId: string };
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const { data: item, isLoading: l1 } = useQuery({
|
||||
queryKey: ["item", personId],
|
||||
queryKey: ["item", actorId],
|
||||
queryFn: async () =>
|
||||
await getUserItemData({
|
||||
api,
|
||||
userId: user?.Id,
|
||||
itemId: personId,
|
||||
itemId: actorId,
|
||||
}),
|
||||
enabled: !!personId && !!api,
|
||||
enabled: !!actorId && !!api,
|
||||
staleTime: 60,
|
||||
});
|
||||
|
||||
@@ -50,7 +50,7 @@ const page: React.FC = () => {
|
||||
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user.Id,
|
||||
personIds: [personId],
|
||||
personIds: [actorId],
|
||||
startIndex: pageParam,
|
||||
limit: 16,
|
||||
sortOrder: ["Descending", "Descending", "Ascending"],
|
||||
@@ -68,7 +68,7 @@ const page: React.FC = () => {
|
||||
|
||||
return response.data;
|
||||
},
|
||||
[api, user?.Id, personId],
|
||||
[api, user?.Id, actorId],
|
||||
);
|
||||
|
||||
const backdropUrl = useMemo(
|
||||
@@ -131,7 +131,7 @@ const page: React.FC = () => {
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
queryFn={fetchItems}
|
||||
queryKey={["actor", "movies", personId]}
|
||||
queryKey={["actor", "movies", actorId]}
|
||||
/>
|
||||
<View className='h-12' />
|
||||
</View>
|
||||
@@ -112,7 +112,7 @@ const page: React.FC = () => {
|
||||
recursive: true,
|
||||
genres: selectedGenres,
|
||||
tags: selectedTags,
|
||||
years: selectedYears.map((year) => Number.parseInt(year, 10)),
|
||||
years: selectedYears.map((year) => Number.parseInt(year)),
|
||||
includeItemTypes: ["Movie", "Series"],
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import type React from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -11,16 +14,30 @@ import Animated, {
|
||||
} from "react-native-reanimated";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ItemContent } from "@/components/ItemContent";
|
||||
import { useItemQuery } from "@/hooks/useItemQuery";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
const Page: React.FC = () => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const { id } = useLocalSearchParams() as { id: string };
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { offline } = useLocalSearchParams() as { offline?: string };
|
||||
const isOffline = offline === "true";
|
||||
const { data: item, isError } = useQuery({
|
||||
queryKey: ["item", id],
|
||||
queryFn: async () => {
|
||||
if (!api || !user || !id) return;
|
||||
const res = await getUserLibraryApi(api).getItem({
|
||||
itemId: id,
|
||||
userId: user?.Id,
|
||||
});
|
||||
|
||||
const { data: item, isError } = useItemQuery(id, isOffline);
|
||||
return res.data;
|
||||
},
|
||||
staleTime: 0,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
});
|
||||
|
||||
const opacity = useSharedValue(1);
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
@@ -90,7 +107,7 @@ const Page: React.FC = () => {
|
||||
<View className='h-12 bg-neutral-900 rounded-lg w-full mb-2' />
|
||||
<View className='h-24 bg-neutral-900 rounded-lg mb-1 w-full' />
|
||||
</Animated.View>
|
||||
{item && <ItemContent item={item} isOffline={isOffline} />}
|
||||
{item && <ItemContent item={item} />}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -15,9 +15,9 @@ import { COMPANY_LOGO_IMAGE_FILTER } from "@/utils/jellyseerr/src/components/Dis
|
||||
|
||||
export default function page() {
|
||||
const local = useLocalSearchParams();
|
||||
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
|
||||
const { companyId, image, type } = local as unknown as {
|
||||
const { companyId, name, image, type } = local as unknown as {
|
||||
companyId: string;
|
||||
name: string;
|
||||
image: string;
|
||||
@@ -53,10 +53,7 @@ export default function page() {
|
||||
uniqBy(
|
||||
data?.pages
|
||||
?.filter((p) => p?.results.length)
|
||||
.flatMap(
|
||||
(p) =>
|
||||
p?.results.filter((r) => isJellyseerrMovieOrTvResult(r)) ?? [],
|
||||
),
|
||||
.flatMap((p) => p?.results ?? []),
|
||||
"id",
|
||||
) ?? [],
|
||||
[data],
|
||||
@@ -101,7 +98,9 @@ export default function page() {
|
||||
}}
|
||||
/>
|
||||
}
|
||||
renderItem={(item, _index) => <JellyseerrPoster item={item} />}
|
||||
renderItem={(item, _index) => (
|
||||
<JellyseerrPoster item={item as MovieResult | TvResult} />
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,10 +8,14 @@ import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
|
||||
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
||||
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||
import {
|
||||
type MovieResult,
|
||||
type TvResult,
|
||||
} from "@/utils/jellyseerr/server/models/Search";
|
||||
|
||||
export default function page() {
|
||||
const local = useLocalSearchParams();
|
||||
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
|
||||
const { genreId, name, type } = local as unknown as {
|
||||
genreId: string;
|
||||
@@ -47,10 +51,7 @@ export default function page() {
|
||||
uniqBy(
|
||||
data?.pages
|
||||
?.filter((p) => p?.results.length)
|
||||
.flatMap(
|
||||
(p) =>
|
||||
p?.results.filter((r) => isJellyseerrMovieOrTvResult(r)) ?? [],
|
||||
),
|
||||
.flatMap((p) => p?.results ?? []),
|
||||
"id",
|
||||
) ?? [],
|
||||
[data],
|
||||
@@ -61,7 +62,7 @@ export default function page() {
|
||||
jellyseerrApi
|
||||
? flatData.map((r) =>
|
||||
jellyseerrApi.imageProxy(
|
||||
r.backdropPath,
|
||||
(r as TvResult | MovieResult).backdropPath,
|
||||
"w1920_and_h800_multi_faces",
|
||||
),
|
||||
)
|
||||
@@ -91,7 +92,9 @@ export default function page() {
|
||||
{name}
|
||||
</Text>
|
||||
}
|
||||
renderItem={(item, _index) => <JellyseerrPoster item={item} />}
|
||||
renderItem={(item, _index) => (
|
||||
<JellyseerrPoster item={item as MovieResult | TvResult} />
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -139,15 +139,7 @@ const Page: React.FC = () => {
|
||||
}
|
||||
|
||||
requestMedia(mediaTitle, body, refetch);
|
||||
}, [
|
||||
details,
|
||||
result,
|
||||
requestMedia,
|
||||
hasAdvancedRequestPermission,
|
||||
mediaTitle,
|
||||
refetch,
|
||||
mediaType,
|
||||
]);
|
||||
}, [details, result, requestMedia, hasAdvancedRequestPermission]);
|
||||
|
||||
const isAnime = useMemo(
|
||||
() =>
|
||||
@@ -229,7 +221,11 @@ const Page: React.FC = () => {
|
||||
| TvDetails
|
||||
}
|
||||
/>
|
||||
<Text selectable className='font-bold text-2xl mb-1'>
|
||||
<Text
|
||||
uiTextView
|
||||
selectable
|
||||
className='font-bold text-2xl mb-1'
|
||||
>
|
||||
{mediaTitle}
|
||||
</Text>
|
||||
<Text className='opacity-50'>{releaseYear}</Text>
|
||||
@@ -260,41 +256,35 @@ const Page: React.FC = () => {
|
||||
) : (
|
||||
details?.mediaInfo?.jellyfinMediaId && (
|
||||
<View className='flex flex-row space-x-2 mt-4'>
|
||||
{!Platform.isTV && (
|
||||
<Button
|
||||
className='flex-1 bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100'
|
||||
color='transparent'
|
||||
onPress={() => bottomSheetModalRef?.current?.present()}
|
||||
iconLeft={
|
||||
<Ionicons
|
||||
name='warning-outline'
|
||||
size={20}
|
||||
color='white'
|
||||
/>
|
||||
}
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
}}
|
||||
>
|
||||
<Text className='text-sm'>
|
||||
{t("jellyseerr.report_issue_button")}
|
||||
</Text>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
className='flex-1 bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100'
|
||||
color='transparent'
|
||||
onPress={() => bottomSheetModalRef?.current?.present()}
|
||||
iconLeft={
|
||||
<Ionicons
|
||||
name='warning-outline'
|
||||
size={20}
|
||||
color='white'
|
||||
/>
|
||||
}
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
}}
|
||||
>
|
||||
<Text className='text-sm'>
|
||||
{t("jellyseerr.report_issue_button")}
|
||||
</Text>
|
||||
</Button>
|
||||
<Button
|
||||
className='flex-1 bg-purple-600/50 border-purple-400 ring-purple-400 text-purple-100'
|
||||
onPress={() => {
|
||||
router.push({
|
||||
pathname:
|
||||
mediaType === MediaType.MOVIE
|
||||
? "/(auth)/(tabs)/(search)/items/page"
|
||||
: "/(auth)/(tabs)/(search)/series/[id]",
|
||||
params:
|
||||
mediaType === MediaType.MOVIE
|
||||
? { id: details?.mediaInfo.jellyfinMediaId }
|
||||
: { id: details?.mediaInfo.jellyfinMediaId },
|
||||
});
|
||||
const url =
|
||||
mediaType === MediaType.MOVIE
|
||||
? `/(auth)/(tabs)/(search)/items/page?id=${details?.mediaInfo.jellyfinMediaId}`
|
||||
: `/(auth)/(tabs)/(search)/series/${details?.mediaInfo.jellyfinMediaId}`;
|
||||
// @ts-expect-error
|
||||
router.push(url);
|
||||
}}
|
||||
iconLeft={
|
||||
<Ionicons name='play-outline' size={20} color='white' />
|
||||
@@ -304,7 +294,7 @@ const Page: React.FC = () => {
|
||||
borderStyle: "solid",
|
||||
}}
|
||||
>
|
||||
<Text className='text-sm'>{t("common.play")}</Text>
|
||||
<Text className='text-sm'>Play</Text>
|
||||
</Button>
|
||||
</View>
|
||||
)
|
||||
@@ -343,95 +333,92 @@ const Page: React.FC = () => {
|
||||
}}
|
||||
onDismiss={() => _setRequestBody(undefined)}
|
||||
/>
|
||||
{!Platform.isTV && (
|
||||
// This is till it's fixed because the menu isn't selectable on TV
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetModalRef}
|
||||
enableDynamicSizing
|
||||
handleIndicatorStyle={{
|
||||
backgroundColor: "white",
|
||||
}}
|
||||
backgroundStyle={{
|
||||
backgroundColor: "#171717",
|
||||
}}
|
||||
backdropComponent={renderBackdrop}
|
||||
>
|
||||
<BottomSheetView>
|
||||
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
||||
<View>
|
||||
<Text className='font-bold text-2xl text-neutral-100'>
|
||||
{t("jellyseerr.whats_wrong")}
|
||||
</Text>
|
||||
</View>
|
||||
<View className='flex flex-col space-y-2 items-start'>
|
||||
<View className='flex flex-col'>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className='flex flex-col'>
|
||||
<Text className='opacity-50 mb-1 text-xs'>
|
||||
{t("jellyseerr.issue_type")}
|
||||
</Text>
|
||||
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||
<Text style={{}} className='' numberOfLines={1}>
|
||||
{issueType
|
||||
? IssueTypeName[issueType]
|
||||
: t("jellyseerr.select_an_issue")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={false}
|
||||
side='bottom'
|
||||
align='center'
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={0}
|
||||
sideOffset={0}
|
||||
>
|
||||
<DropdownMenu.Label>
|
||||
{t("jellyseerr.types")}
|
||||
</DropdownMenu.Label>
|
||||
{Object.entries(IssueTypeName)
|
||||
.reverse()
|
||||
.map(([key, value], _idx) => (
|
||||
<DropdownMenu.Item
|
||||
key={value}
|
||||
onSelect={() =>
|
||||
setIssueType(key as unknown as IssueType)
|
||||
}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{value}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
|
||||
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full'>
|
||||
<BottomSheetTextInput
|
||||
multiline
|
||||
maxLength={254}
|
||||
style={{ color: "white" }}
|
||||
clearButtonMode='always'
|
||||
placeholder={t("jellyseerr.describe_the_issue")}
|
||||
placeholderTextColor='#9CA3AF'
|
||||
// Issue with multiline + Textinput inside a portal
|
||||
// https://github.com/callstack/react-native-paper/issues/1668
|
||||
defaultValue={issueMessage}
|
||||
onChangeText={setIssueMessage}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<Button className='mt-auto' onPress={submitIssue} color='purple'>
|
||||
{t("jellyseerr.submit_button")}
|
||||
</Button>
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetModalRef}
|
||||
enableDynamicSizing
|
||||
handleIndicatorStyle={{
|
||||
backgroundColor: "white",
|
||||
}}
|
||||
backgroundStyle={{
|
||||
backgroundColor: "#171717",
|
||||
}}
|
||||
backdropComponent={renderBackdrop}
|
||||
>
|
||||
<BottomSheetView>
|
||||
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
||||
<View>
|
||||
<Text className='font-bold text-2xl text-neutral-100'>
|
||||
{t("jellyseerr.whats_wrong")}
|
||||
</Text>
|
||||
</View>
|
||||
</BottomSheetView>
|
||||
</BottomSheetModal>
|
||||
)}
|
||||
<View className='flex flex-col space-y-2 items-start'>
|
||||
<View className='flex flex-col'>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className='flex flex-col'>
|
||||
<Text className='opacity-50 mb-1 text-xs'>
|
||||
{t("jellyseerr.issue_type")}
|
||||
</Text>
|
||||
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||
<Text style={{}} className='' numberOfLines={1}>
|
||||
{issueType
|
||||
? IssueTypeName[issueType]
|
||||
: t("jellyseerr.select_an_issue")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={false}
|
||||
side='bottom'
|
||||
align='center'
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={0}
|
||||
sideOffset={0}
|
||||
>
|
||||
<DropdownMenu.Label>
|
||||
{t("jellyseerr.types")}
|
||||
</DropdownMenu.Label>
|
||||
{Object.entries(IssueTypeName)
|
||||
.reverse()
|
||||
.map(([key, value], _idx) => (
|
||||
<DropdownMenu.Item
|
||||
key={value}
|
||||
onSelect={() =>
|
||||
setIssueType(key as unknown as IssueType)
|
||||
}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{value}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
|
||||
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full'>
|
||||
<BottomSheetTextInput
|
||||
multiline
|
||||
maxLength={254}
|
||||
style={{ color: "white" }}
|
||||
clearButtonMode='always'
|
||||
placeholder={t("jellyseerr.describe_the_issue")}
|
||||
placeholderTextColor='#9CA3AF'
|
||||
// Issue with multiline + Textinput inside a portal
|
||||
// https://github.com/callstack/react-native-paper/issues/1668
|
||||
defaultValue={issueMessage}
|
||||
onChangeText={setIssueMessage}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<Button className='mt-auto' onPress={submitIssue} color='purple'>
|
||||
{t("jellyseerr.submit_button")}
|
||||
</Button>
|
||||
</View>
|
||||
</BottomSheetView>
|
||||
</BottomSheetModal>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,6 +10,10 @@ import { OverviewText } from "@/components/OverviewText";
|
||||
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import type { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
|
||||
import type {
|
||||
MovieResult,
|
||||
TvResult,
|
||||
} from "@/utils/jellyseerr/server/models/Search";
|
||||
|
||||
export default function page() {
|
||||
const local = useLocalSearchParams();
|
||||
@@ -17,13 +21,14 @@ export default function page() {
|
||||
|
||||
const {
|
||||
jellyseerrApi,
|
||||
jellyseerrUser,
|
||||
jellyseerrRegion: region,
|
||||
jellyseerrLocale: locale,
|
||||
} = useJellyseerr();
|
||||
|
||||
const { personId } = local as { personId: string };
|
||||
|
||||
const { data } = useQuery({
|
||||
const { data, isLoading, isFetching } = useQuery({
|
||||
queryKey: ["jellyseerr", "person", personId],
|
||||
queryFn: async () => ({
|
||||
details: await jellyseerrApi?.personDetails(personId),
|
||||
@@ -102,7 +107,9 @@ export default function page() {
|
||||
MainContent={() => (
|
||||
<OverviewText text={data?.details?.biography} className='mt-4' />
|
||||
)}
|
||||
renderItem={(item, _index) => <JellyseerrPoster item={item} />}
|
||||
renderItem={(item, _index) => (
|
||||
<JellyseerrPoster item={item as MovieResult | TvResult} />
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import {
|
||||
createMaterialTopTabNavigator,
|
||||
MaterialTopTabNavigationEventMap,
|
||||
MaterialTopTabNavigationOptions,
|
||||
} from "@react-navigation/material-top-tabs";
|
||||
import type {
|
||||
ParamListBase,
|
||||
TabNavigationState,
|
||||
} from "@react-navigation/native";
|
||||
import { Stack, withLayoutContext } from "expo-router";
|
||||
|
||||
const { Navigator } = createMaterialTopTabNavigator();
|
||||
|
||||
export const Tab = withLayoutContext<
|
||||
MaterialTopTabNavigationOptions,
|
||||
typeof Navigator,
|
||||
TabNavigationState<ParamListBase>,
|
||||
MaterialTopTabNavigationEventMap
|
||||
>(Navigator);
|
||||
|
||||
const Layout = () => {
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: "Live TV" }} />
|
||||
<Tab
|
||||
initialRouteName='programs'
|
||||
keyboardDismissMode='none'
|
||||
screenOptions={{
|
||||
tabBarBounces: true,
|
||||
tabBarLabelStyle: { fontSize: 10 },
|
||||
tabBarItemStyle: {
|
||||
width: 100,
|
||||
},
|
||||
tabBarStyle: { backgroundColor: "black" },
|
||||
animationEnabled: true,
|
||||
lazy: true,
|
||||
swipeEnabled: true,
|
||||
tabBarIndicatorStyle: { backgroundColor: "#9334E9" },
|
||||
tabBarScrollEnabled: true,
|
||||
}}
|
||||
>
|
||||
<Tab.Screen name='programs' />
|
||||
<Tab.Screen name='guide' />
|
||||
<Tab.Screen name='channels' />
|
||||
<Tab.Screen name='recordings' />
|
||||
</Tab>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
@@ -1,55 +0,0 @@
|
||||
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import { View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { ItemImage } from "@/components/common/ItemImage";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
export default function page() {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const _insets = useSafeAreaInsets();
|
||||
|
||||
const { data: channels } = useQuery({
|
||||
queryKey: ["livetv", "channels"],
|
||||
queryFn: async () => {
|
||||
const res = await getLiveTvApi(api!).getLiveTvChannels({
|
||||
startIndex: 0,
|
||||
limit: 500,
|
||||
enableFavoriteSorting: true,
|
||||
userId: user?.Id,
|
||||
addCurrentProgram: false,
|
||||
enableUserData: false,
|
||||
enableImageTypes: ["Primary"],
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<View className='flex flex-1'>
|
||||
<FlashList
|
||||
data={channels?.Items}
|
||||
estimatedItemSize={76}
|
||||
renderItem={({ item }) => (
|
||||
<View className='flex flex-row items-center px-4 mb-2'>
|
||||
<View className='w-22 mr-4 rounded-lg overflow-hidden'>
|
||||
<ItemImage
|
||||
style={{
|
||||
aspectRatio: "1/1",
|
||||
width: 60,
|
||||
borderRadius: 8,
|
||||
}}
|
||||
item={item}
|
||||
/>
|
||||
</View>
|
||||
<Text className='font-bold'>{item.Name}</Text>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Dimensions, ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { ItemImage } from "@/components/common/ItemImage";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { HourHeader } from "@/components/livetv/HourHeader";
|
||||
import { LiveTVGuideRow } from "@/components/livetv/LiveTVGuideRow";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
const HOUR_HEIGHT = 30;
|
||||
const ITEMS_PER_PAGE = 20;
|
||||
|
||||
const MemoizedLiveTVGuideRow = React.memo(LiveTVGuideRow);
|
||||
|
||||
export default function page() {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const insets = useSafeAreaInsets();
|
||||
const [date, _setDate] = useState<Date>(new Date());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
const { data: channels } = useQuery({
|
||||
queryKey: ["livetv", "channels", currentPage],
|
||||
queryFn: async () => {
|
||||
const res = await getLiveTvApi(api!).getLiveTvChannels({
|
||||
startIndex: (currentPage - 1) * ITEMS_PER_PAGE,
|
||||
limit: ITEMS_PER_PAGE,
|
||||
enableFavoriteSorting: true,
|
||||
userId: user?.Id,
|
||||
addCurrentProgram: false,
|
||||
enableUserData: false,
|
||||
enableImageTypes: ["Primary"],
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
});
|
||||
|
||||
const { data: programs } = useQuery({
|
||||
queryKey: ["livetv", "programs", date, currentPage],
|
||||
queryFn: async () => {
|
||||
const startOfDay = new Date(date);
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
const endOfDay = new Date(date);
|
||||
endOfDay.setHours(23, 59, 59, 999);
|
||||
|
||||
const now = new Date();
|
||||
const isToday = startOfDay.toDateString() === now.toDateString();
|
||||
|
||||
const res = await getLiveTvApi(api!).getPrograms({
|
||||
getProgramsDto: {
|
||||
MaxStartDate: endOfDay.toISOString(),
|
||||
MinEndDate: isToday ? now.toISOString() : startOfDay.toISOString(),
|
||||
ChannelIds: channels?.Items?.map((c) => c.Id).filter(
|
||||
Boolean,
|
||||
) as string[],
|
||||
ImageTypeLimit: 1,
|
||||
EnableImages: false,
|
||||
SortBy: ["StartDate"],
|
||||
EnableTotalRecordCount: false,
|
||||
EnableUserData: false,
|
||||
},
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
enabled: !!channels,
|
||||
});
|
||||
|
||||
const screenWidth = Dimensions.get("window").width;
|
||||
|
||||
const [scrollX, setScrollX] = useState(0);
|
||||
|
||||
const handleNextPage = useCallback(() => {
|
||||
setCurrentPage((prev) => prev + 1);
|
||||
}, []);
|
||||
|
||||
const handlePrevPage = useCallback(() => {
|
||||
setCurrentPage((prev) => Math.max(1, prev - 1));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
nestedScrollEnabled
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
key={"home"}
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: 16,
|
||||
}}
|
||||
>
|
||||
<PageButtons
|
||||
currentPage={currentPage}
|
||||
onPrevPage={handlePrevPage}
|
||||
onNextPage={handleNextPage}
|
||||
isNextDisabled={
|
||||
!channels || (channels?.Items?.length || 0) < ITEMS_PER_PAGE
|
||||
}
|
||||
/>
|
||||
|
||||
<View className='flex flex-row'>
|
||||
<View className='flex flex-col w-[64px]'>
|
||||
<View
|
||||
style={{
|
||||
height: HOUR_HEIGHT,
|
||||
}}
|
||||
className='bg-neutral-800'
|
||||
/>
|
||||
{channels?.Items?.map((c, i) => (
|
||||
<View className='h-16 w-16 mr-4 rounded-lg overflow-hidden' key={i}>
|
||||
<ItemImage
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
contentFit='contain'
|
||||
item={c}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
<ScrollView
|
||||
style={{
|
||||
width: screenWidth - 64,
|
||||
}}
|
||||
horizontal
|
||||
scrollEnabled
|
||||
onScroll={(e) => {
|
||||
setScrollX(e.nativeEvent.contentOffset.x);
|
||||
}}
|
||||
>
|
||||
<View className='flex flex-col'>
|
||||
<HourHeader height={HOUR_HEIGHT} />
|
||||
{channels?.Items?.map((c, _i) => (
|
||||
<MemoizedLiveTVGuideRow
|
||||
channel={c}
|
||||
programs={programs?.Items}
|
||||
key={c.Id}
|
||||
scrollX={scrollX}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
interface PageButtonsProps {
|
||||
currentPage: number;
|
||||
onPrevPage: () => void;
|
||||
onNextPage: () => void;
|
||||
isNextDisabled: boolean;
|
||||
}
|
||||
|
||||
const PageButtons: React.FC<PageButtonsProps> = ({
|
||||
currentPage,
|
||||
onPrevPage,
|
||||
onNextPage,
|
||||
isNextDisabled,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View className='flex flex-row justify-between items-center bg-neutral-800 w-full px-4 py-2'>
|
||||
<TouchableOpacity
|
||||
onPress={onPrevPage}
|
||||
disabled={currentPage === 1}
|
||||
className='flex flex-row items-center'
|
||||
>
|
||||
<Ionicons
|
||||
name='chevron-back'
|
||||
size={24}
|
||||
color={currentPage === 1 ? "gray" : "white"}
|
||||
/>
|
||||
<Text
|
||||
className={`ml-1 ${
|
||||
currentPage === 1 ? "text-gray-500" : "text-white"
|
||||
}`}
|
||||
>
|
||||
{t("live_tv.previous")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<Text className='text-white'>Page {currentPage}</Text>
|
||||
<TouchableOpacity
|
||||
onPress={onNextPage}
|
||||
disabled={isNextDisabled}
|
||||
className='flex flex-row items-center'
|
||||
>
|
||||
<Text
|
||||
className={`mr-1 ${isNextDisabled ? "text-gray-500" : "text-white"}`}
|
||||
>
|
||||
{t("live_tv.next")}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-forward'
|
||||
size={24}
|
||||
color={isNextDisabled ? "gray" : "white"}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -1,145 +0,0 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useAtom } from "jotai";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
export default function page() {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
nestedScrollEnabled
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
key={"home"}
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: 16,
|
||||
paddingTop: 8,
|
||||
}}
|
||||
>
|
||||
<View className='flex flex-col space-y-2'>
|
||||
<ScrollingCollectionList
|
||||
queryKey={["livetv", "recommended"]}
|
||||
title={t("live_tv.on_now")}
|
||||
queryFn={async () => {
|
||||
if (!api) return [] as BaseItemDto[];
|
||||
const res = await getLiveTvApi(api).getRecommendedPrograms({
|
||||
userId: user?.Id,
|
||||
isAiring: true,
|
||||
limit: 24,
|
||||
imageTypeLimit: 1,
|
||||
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
|
||||
enableTotalRecordCount: false,
|
||||
fields: ["ChannelInfo", "PrimaryImageAspectRatio"],
|
||||
});
|
||||
return res.data.Items || [];
|
||||
}}
|
||||
orientation='horizontal'
|
||||
/>
|
||||
<ScrollingCollectionList
|
||||
queryKey={["livetv", "shows"]}
|
||||
title={t("live_tv.shows")}
|
||||
queryFn={async () => {
|
||||
if (!api) return [] as BaseItemDto[];
|
||||
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
||||
userId: user?.Id,
|
||||
hasAired: false,
|
||||
limit: 9,
|
||||
isMovie: false,
|
||||
isSeries: true,
|
||||
isSports: false,
|
||||
isNews: false,
|
||||
isKids: false,
|
||||
enableTotalRecordCount: false,
|
||||
fields: ["ChannelInfo", "PrimaryImageAspectRatio"],
|
||||
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
|
||||
});
|
||||
return res.data.Items || [];
|
||||
}}
|
||||
orientation='horizontal'
|
||||
/>
|
||||
<ScrollingCollectionList
|
||||
queryKey={["livetv", "movies"]}
|
||||
title={t("live_tv.movies")}
|
||||
queryFn={async () => {
|
||||
if (!api) return [] as BaseItemDto[];
|
||||
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
||||
userId: user?.Id,
|
||||
hasAired: false,
|
||||
limit: 9,
|
||||
isMovie: true,
|
||||
enableTotalRecordCount: false,
|
||||
fields: ["ChannelInfo"],
|
||||
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
|
||||
});
|
||||
return res.data.Items || [];
|
||||
}}
|
||||
orientation='horizontal'
|
||||
/>
|
||||
<ScrollingCollectionList
|
||||
queryKey={["livetv", "sports"]}
|
||||
title={t("live_tv.sports")}
|
||||
queryFn={async () => {
|
||||
if (!api) return [] as BaseItemDto[];
|
||||
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
||||
userId: user?.Id,
|
||||
hasAired: false,
|
||||
limit: 9,
|
||||
isSports: true,
|
||||
enableTotalRecordCount: false,
|
||||
fields: ["ChannelInfo"],
|
||||
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
|
||||
});
|
||||
return res.data.Items || [];
|
||||
}}
|
||||
orientation='horizontal'
|
||||
/>
|
||||
<ScrollingCollectionList
|
||||
queryKey={["livetv", "kids"]}
|
||||
title={t("live_tv.for_kids")}
|
||||
queryFn={async () => {
|
||||
if (!api) return [] as BaseItemDto[];
|
||||
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
||||
userId: user?.Id,
|
||||
hasAired: false,
|
||||
limit: 9,
|
||||
isKids: true,
|
||||
enableTotalRecordCount: false,
|
||||
fields: ["ChannelInfo"],
|
||||
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
|
||||
});
|
||||
return res.data.Items || [];
|
||||
}}
|
||||
orientation='horizontal'
|
||||
/>
|
||||
<ScrollingCollectionList
|
||||
queryKey={["livetv", "news"]}
|
||||
title={t("live_tv.news")}
|
||||
queryFn={async () => {
|
||||
if (!api) return [] as BaseItemDto[];
|
||||
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
||||
userId: user?.Id,
|
||||
hasAired: false,
|
||||
limit: 9,
|
||||
isNews: true,
|
||||
enableTotalRecordCount: false,
|
||||
fields: ["ChannelInfo"],
|
||||
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
|
||||
});
|
||||
return res.data.Items || [];
|
||||
}}
|
||||
orientation='horizontal'
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
|
||||
export default function page() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View className='flex items-center justify-center h-full -mt-12'>
|
||||
<Text>{t("live_tv.coming_soon")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -69,18 +69,10 @@ const page: React.FC = () => {
|
||||
seriesId: item?.Id!,
|
||||
userId: user?.Id!,
|
||||
enableUserData: true,
|
||||
// Note: Including trick play is necessary to enable trick play downloads
|
||||
fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
|
||||
fields: ["MediaSources", "MediaStreams", "Overview"],
|
||||
});
|
||||
return res?.data.Items || [];
|
||||
},
|
||||
select: (data) =>
|
||||
// This needs to be sorted by parent index number and then index number, that way we can download the episodes in the correct order.
|
||||
[...(data || [])].sort(
|
||||
(a, b) =>
|
||||
(a.ParentIndexNumber ?? 0) - (b.ParentIndexNumber ?? 0) ||
|
||||
(a.IndexNumber ?? 0) - (b.IndexNumber ?? 0),
|
||||
),
|
||||
staleTime: 60,
|
||||
enabled: !!api && !!user?.Id && !!item?.Id,
|
||||
});
|
||||
@@ -141,10 +133,10 @@ const page: React.FC = () => {
|
||||
style={{
|
||||
height: 130,
|
||||
width: "100%",
|
||||
resizeMode: "contain",
|
||||
}}
|
||||
contentFit='contain'
|
||||
/>
|
||||
) : undefined
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<View className='flex flex-col pt-4'>
|
||||
|
||||
@@ -77,13 +77,7 @@ const Page = () => {
|
||||
} else {
|
||||
_setSortBy([SortByOption.SortName]);
|
||||
}
|
||||
}, [
|
||||
libraryId,
|
||||
sortOrderPreference,
|
||||
sortByPreference,
|
||||
_setSortOrder,
|
||||
_setSortBy,
|
||||
]);
|
||||
}, []);
|
||||
|
||||
const setSortBy = useCallback(
|
||||
(sortBy: SortByOption[]) => {
|
||||
@@ -93,7 +87,7 @@ const Page = () => {
|
||||
}
|
||||
_setSortBy(sortBy);
|
||||
},
|
||||
[libraryId, sortByPreference, setSortByPreference, _setSortBy],
|
||||
[libraryId, sortByPreference],
|
||||
);
|
||||
|
||||
const setSortOrder = useCallback(
|
||||
@@ -107,7 +101,7 @@ const Page = () => {
|
||||
}
|
||||
_setSortOrder(sortOrder);
|
||||
},
|
||||
[libraryId, sortOrderPreference, setOderByPreference, _setSortOrder],
|
||||
[libraryId, sortOrderPreference],
|
||||
);
|
||||
|
||||
const nrOfCols = useMemo(() => {
|
||||
@@ -174,7 +168,7 @@ const Page = () => {
|
||||
fields: ["PrimaryImageAspectRatio", "SortName"],
|
||||
genres: selectedGenres,
|
||||
tags: selectedTags,
|
||||
years: selectedYears.map((year) => Number.parseInt(year, 10)),
|
||||
years: selectedYears.map((year) => Number.parseInt(year)),
|
||||
includeItemTypes: itemType ? [itemType] : undefined,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,85 +1,224 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Stack } from "expo-router";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, TouchableOpacity } from "react-native";
|
||||
import { LibraryOptionsSheet } from "@/components/settings/LibraryOptionsSheet";
|
||||
import { Platform } from "react-native";
|
||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function IndexLayout() {
|
||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||
const [optionsSheetOpen, setOptionsSheetOpen] = useState(false);
|
||||
const [settings, updateSettings, pluginSettings] = useSettings();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!settings?.libraryOptions) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
name='index'
|
||||
options={{
|
||||
headerShown: !Platform.isTV,
|
||||
headerTitle: t("tabs.library"),
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerRight: () =>
|
||||
!pluginSettings?.libraryOptions?.locked &&
|
||||
!Platform.isTV && (
|
||||
<TouchableOpacity
|
||||
onPress={() => setOptionsSheetOpen(true)}
|
||||
className='flex flex-row items-center justify-center w-9 h-9'
|
||||
>
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
name='index'
|
||||
options={{
|
||||
headerShown: true,
|
||||
headerLargeTitle: true,
|
||||
headerTitle: t("tabs.library"),
|
||||
headerBlurEffect: "prominent",
|
||||
headerLargeStyle: {
|
||||
backgroundColor: "black",
|
||||
},
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerRight: () =>
|
||||
!pluginSettings?.libraryOptions?.locked &&
|
||||
!Platform.isTV && (
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<Ionicons
|
||||
name='ellipsis-horizontal-outline'
|
||||
size={24}
|
||||
color='white'
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='[libraryId]'
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: !Platform.isTV,
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||
<Stack.Screen key={name} name={name} options={options} />
|
||||
))}
|
||||
<Stack.Screen
|
||||
name='collections/[collectionId]'
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: !Platform.isTV,
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
<LibraryOptionsSheet
|
||||
open={optionsSheetOpen}
|
||||
setOpen={setOptionsSheetOpen}
|
||||
settings={settings.libraryOptions}
|
||||
updateSettings={(options) =>
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
...options,
|
||||
},
|
||||
})
|
||||
}
|
||||
disabled={pluginSettings?.libraryOptions?.locked}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
align={"end"}
|
||||
alignOffset={-10}
|
||||
avoidCollisions={false}
|
||||
collisionPadding={0}
|
||||
loop={false}
|
||||
side={"bottom"}
|
||||
sideOffset={10}
|
||||
>
|
||||
<DropdownMenu.Label>
|
||||
{t("library.options.display")}
|
||||
</DropdownMenu.Label>
|
||||
<DropdownMenu.Group key='display-group'>
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger key='image-style-trigger'>
|
||||
{t("library.options.display")}
|
||||
</DropdownMenu.SubTrigger>
|
||||
<DropdownMenu.SubContent
|
||||
alignOffset={-10}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={0}
|
||||
loop={true}
|
||||
sideOffset={10}
|
||||
>
|
||||
<DropdownMenu.CheckboxItem
|
||||
key='display-option-1'
|
||||
value={settings.libraryOptions.display === "row"}
|
||||
onValueChange={() =>
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
display: "row",
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<DropdownMenu.ItemIndicator />
|
||||
<DropdownMenu.ItemTitle key='display-title-1'>
|
||||
{t("library.options.row")}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
<DropdownMenu.CheckboxItem
|
||||
key='display-option-2'
|
||||
value={settings.libraryOptions.display === "list"}
|
||||
onValueChange={() =>
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
display: "list",
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<DropdownMenu.ItemIndicator />
|
||||
<DropdownMenu.ItemTitle key='display-title-2'>
|
||||
{t("library.options.list")}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger key='image-style-trigger'>
|
||||
{t("library.options.image_style")}
|
||||
</DropdownMenu.SubTrigger>
|
||||
<DropdownMenu.SubContent
|
||||
alignOffset={-10}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={0}
|
||||
loop={true}
|
||||
sideOffset={10}
|
||||
>
|
||||
<DropdownMenu.CheckboxItem
|
||||
key='poster-option'
|
||||
value={
|
||||
settings.libraryOptions.imageStyle === "poster"
|
||||
}
|
||||
onValueChange={() =>
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
imageStyle: "poster",
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<DropdownMenu.ItemIndicator />
|
||||
<DropdownMenu.ItemTitle key='poster-title'>
|
||||
{t("library.options.poster")}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
<DropdownMenu.CheckboxItem
|
||||
key='cover-option'
|
||||
value={settings.libraryOptions.imageStyle === "cover"}
|
||||
onValueChange={() =>
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
imageStyle: "cover",
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<DropdownMenu.ItemIndicator />
|
||||
<DropdownMenu.ItemTitle key='cover-title'>
|
||||
{t("library.options.cover")}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
</DropdownMenu.Group>
|
||||
<DropdownMenu.Group key='show-titles-group'>
|
||||
<DropdownMenu.CheckboxItem
|
||||
disabled={settings.libraryOptions.imageStyle === "poster"}
|
||||
key='show-titles-option'
|
||||
value={settings.libraryOptions.showTitles}
|
||||
onValueChange={(newValue: string) => {
|
||||
if (settings.libraryOptions.imageStyle === "poster")
|
||||
return;
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
showTitles: newValue === "on",
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemIndicator />
|
||||
<DropdownMenu.ItemTitle key='show-titles-title'>
|
||||
{t("library.options.show_titles")}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
<DropdownMenu.CheckboxItem
|
||||
key='show-stats-option'
|
||||
value={settings.libraryOptions.showStats}
|
||||
onValueChange={(newValue: string) => {
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
showStats: newValue === "on",
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemIndicator />
|
||||
<DropdownMenu.ItemTitle key='show-stats-title'>
|
||||
{t("library.options.show_stats")}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
</DropdownMenu.Group>
|
||||
|
||||
<DropdownMenu.Separator />
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
<Stack.Screen
|
||||
name='[libraryId]'
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: true,
|
||||
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: true,
|
||||
headerBlurEffect: "prominent",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function index() {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const queryClient = useQueryClient();
|
||||
const { settings } = useSettings();
|
||||
const [settings] = useSettings();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
||||
@@ -13,9 +13,13 @@ export default function SearchLayout() {
|
||||
<Stack.Screen
|
||||
name='index'
|
||||
options={{
|
||||
headerShown: !Platform.isTV,
|
||||
headerShown: true,
|
||||
headerLargeTitle: true,
|
||||
headerTitle: t("tabs.search"),
|
||||
headerBlurEffect: "none",
|
||||
headerLargeStyle: {
|
||||
backgroundColor: "black",
|
||||
},
|
||||
headerBlurEffect: "prominent",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
@@ -27,7 +31,7 @@ export default function SearchLayout() {
|
||||
name='collections/[collectionId]'
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: !Platform.isTV,
|
||||
headerShown: true,
|
||||
headerBlurEffect: "prominent",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
|
||||
@@ -10,7 +10,6 @@ import { useAtom } from "jotai";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
@@ -21,7 +20,6 @@ import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useDebounce } from "use-debounce";
|
||||
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||
import { Input } from "@/components/common/Input";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import { FilterButton } from "@/components/filters/FilterButton";
|
||||
@@ -59,9 +57,6 @@ export default function search() {
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const searchFilterId = useId();
|
||||
const orderFilterId = useId();
|
||||
|
||||
const { q } = params as { q: string };
|
||||
|
||||
const [searchType, setSearchType] = useState<SearchType>("Library");
|
||||
@@ -71,7 +66,7 @@ export default function search() {
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
|
||||
const { settings } = useSettings();
|
||||
const [settings] = useSettings();
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
const [jellyseerrOrderBy, setJellyseerrOrderBy] =
|
||||
useState<JellyseerrSearchSort>(
|
||||
@@ -254,223 +249,205 @@ export default function search() {
|
||||
}, [l1, l2, l3, l7, l8]);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
keyboardDismissMode='on-drag'
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
{/* <View
|
||||
className='flex flex-col'
|
||||
style={{
|
||||
marginTop: Platform.OS === "android" ? 16 : 0,
|
||||
}}
|
||||
> */}
|
||||
{Platform.isTV && (
|
||||
<Input
|
||||
placeholder={t("search.search")}
|
||||
onChangeText={(text) => {
|
||||
router.setParams({ q: "" });
|
||||
setSearch(text);
|
||||
}}
|
||||
keyboardType='default'
|
||||
returnKeyType='done'
|
||||
autoCapitalize='none'
|
||||
clearButtonMode='while-editing'
|
||||
maxLength={500}
|
||||
/>
|
||||
)}
|
||||
<View
|
||||
className='flex flex-col'
|
||||
style={{
|
||||
marginTop: Platform.OS === "android" ? 16 : 0,
|
||||
<>
|
||||
<ScrollView
|
||||
keyboardDismissMode='on-drag'
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
{jellyseerrApi && (
|
||||
<ScrollView
|
||||
horizontal
|
||||
className='flex flex-row flex-wrap space-x-2 px-4 mb-2'
|
||||
>
|
||||
<TouchableOpacity onPress={() => setSearchType("Library")}>
|
||||
<Tag
|
||||
text={t("search.library")}
|
||||
textClass='p-1'
|
||||
className={
|
||||
searchType === "Library" ? "bg-purple-600" : undefined
|
||||
}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => setSearchType("Discover")}>
|
||||
<Tag
|
||||
text={t("search.discover")}
|
||||
textClass='p-1'
|
||||
className={
|
||||
searchType === "Discover" ? "bg-purple-600" : undefined
|
||||
}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
{searchType === "Discover" &&
|
||||
!loading &&
|
||||
noResults &&
|
||||
debouncedSearch.length > 0 && (
|
||||
<View className='flex flex-row justify-end items-center space-x-1'>
|
||||
<FilterButton
|
||||
id={searchFilterId}
|
||||
queryKey='jellyseerr_search'
|
||||
queryFn={async () =>
|
||||
Object.keys(JellyseerrSearchSort).filter((v) =>
|
||||
Number.isNaN(Number(v)),
|
||||
)
|
||||
}
|
||||
set={(value) => setJellyseerrOrderBy(value[0])}
|
||||
values={[jellyseerrOrderBy]}
|
||||
title={t("library.filters.sort_by")}
|
||||
renderItemLabel={(item) =>
|
||||
t(`home.settings.plugins.jellyseerr.order_by.${item}`)
|
||||
}
|
||||
disableSearch={true}
|
||||
/>
|
||||
<FilterButton
|
||||
id={orderFilterId}
|
||||
queryKey='jellysearr_search'
|
||||
queryFn={async () => ["asc", "desc"]}
|
||||
set={(value) => setJellyseerrSortOrder(value[0])}
|
||||
values={[jellyseerrSortOrder]}
|
||||
title={t("library.filters.sort_order")}
|
||||
renderItemLabel={(item) => t(`library.filters.${item}`)}
|
||||
disableSearch={true}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
)}
|
||||
<View
|
||||
className='flex flex-col'
|
||||
style={{
|
||||
marginTop: Platform.OS === "android" ? 16 : 0,
|
||||
}}
|
||||
>
|
||||
{jellyseerrApi && (
|
||||
<ScrollView
|
||||
horizontal
|
||||
className='flex flex-row flex-wrap space-x-2 px-4 mb-2'
|
||||
>
|
||||
<TouchableOpacity onPress={() => setSearchType("Library")}>
|
||||
<Tag
|
||||
text={t("search.library")}
|
||||
textClass='p-1'
|
||||
className={
|
||||
searchType === "Library" ? "bg-purple-600" : undefined
|
||||
}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => setSearchType("Discover")}>
|
||||
<Tag
|
||||
text={t("search.discover")}
|
||||
textClass='p-1'
|
||||
className={
|
||||
searchType === "Discover" ? "bg-purple-600" : undefined
|
||||
}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
{searchType === "Discover" &&
|
||||
!loading &&
|
||||
noResults &&
|
||||
debouncedSearch.length > 0 && (
|
||||
<View className='flex flex-row justify-end items-center space-x-1'>
|
||||
<FilterButton
|
||||
id='search'
|
||||
queryKey='jellyseerr_search'
|
||||
queryFn={async () =>
|
||||
Object.keys(JellyseerrSearchSort).filter((v) =>
|
||||
Number.isNaN(Number(v)),
|
||||
)
|
||||
}
|
||||
set={(value) => setJellyseerrOrderBy(value[0])}
|
||||
values={[jellyseerrOrderBy]}
|
||||
title={t("library.filters.sort_by")}
|
||||
renderItemLabel={(item) =>
|
||||
t(`home.settings.plugins.jellyseerr.order_by.${item}`)
|
||||
}
|
||||
showSearch={false}
|
||||
/>
|
||||
<FilterButton
|
||||
id='order'
|
||||
queryKey='jellysearr_search'
|
||||
queryFn={async () => ["asc", "desc"]}
|
||||
set={(value) => setJellyseerrSortOrder(value[0])}
|
||||
values={[jellyseerrSortOrder]}
|
||||
title={t("library.filters.sort_order")}
|
||||
renderItemLabel={(item) => t(`library.filters.${item}`)}
|
||||
showSearch={false}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
<View className='mt-2'>
|
||||
<LoadingSkeleton isLoading={loading} />
|
||||
</View>
|
||||
|
||||
{searchType === "Library" ? (
|
||||
<View className={l1 || l2 ? "opacity-0" : "opacity-100"}>
|
||||
<SearchItemWrapper
|
||||
header={t("search.movies")}
|
||||
items={movies}
|
||||
renderItem={(item: BaseItemDto) => (
|
||||
<TouchableItemRouter
|
||||
key={item.Id}
|
||||
className='flex flex-col w-28 mr-2'
|
||||
item={item}
|
||||
>
|
||||
<MoviePoster item={item} key={item.Id} />
|
||||
<Text numberOfLines={2} className='mt-2'>
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text className='opacity-50 text-xs'>
|
||||
{item.ProductionYear}
|
||||
</Text>
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
items={series}
|
||||
header={t("search.series")}
|
||||
renderItem={(item: BaseItemDto) => (
|
||||
<TouchableItemRouter
|
||||
key={item.Id}
|
||||
item={item}
|
||||
className='flex flex-col w-28 mr-2'
|
||||
>
|
||||
<SeriesPoster item={item} key={item.Id} />
|
||||
<Text numberOfLines={2} className='mt-2'>
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text className='opacity-50 text-xs'>
|
||||
{item.ProductionYear}
|
||||
</Text>
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
items={episodes}
|
||||
header={t("search.episodes")}
|
||||
renderItem={(item: BaseItemDto) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className='flex flex-col w-44 mr-2'
|
||||
>
|
||||
<ContinueWatchingPoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
items={collections}
|
||||
header={t("search.collections")}
|
||||
renderItem={(item: BaseItemDto) => (
|
||||
<TouchableItemRouter
|
||||
key={item.Id}
|
||||
item={item}
|
||||
className='flex flex-col w-28 mr-2'
|
||||
>
|
||||
<MoviePoster item={item} key={item.Id} />
|
||||
<Text numberOfLines={2} className='mt-2'>
|
||||
{item.Name}
|
||||
</Text>
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
items={actors}
|
||||
header={t("search.actors")}
|
||||
renderItem={(item: BaseItemDto) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className='flex flex-col w-28 mr-2'
|
||||
>
|
||||
<MoviePoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
<View className='mt-2'>
|
||||
<LoadingSkeleton isLoading={loading} />
|
||||
</View>
|
||||
) : (
|
||||
<JellyserrIndexPage
|
||||
searchQuery={debouncedSearch}
|
||||
sortType={jellyseerrOrderBy}
|
||||
order={jellyseerrSortOrder}
|
||||
/>
|
||||
)}
|
||||
|
||||
{searchType === "Library" &&
|
||||
(!loading && noResults && debouncedSearch.length > 0 ? (
|
||||
<View>
|
||||
<Text className='text-center text-lg font-bold mt-4'>
|
||||
{t("search.no_results_found_for")}
|
||||
</Text>
|
||||
<Text className='text-xs text-purple-600 text-center'>
|
||||
"{debouncedSearch}"
|
||||
</Text>
|
||||
{searchType === "Library" ? (
|
||||
<View className={l1 || l2 ? "opacity-0" : "opacity-100"}>
|
||||
<SearchItemWrapper
|
||||
header={t("search.movies")}
|
||||
items={movies}
|
||||
renderItem={(item: BaseItemDto) => (
|
||||
<TouchableItemRouter
|
||||
key={item.Id}
|
||||
className='flex flex-col w-28 mr-2'
|
||||
item={item}
|
||||
>
|
||||
<MoviePoster item={item} key={item.Id} />
|
||||
<Text numberOfLines={2} className='mt-2'>
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text className='opacity-50 text-xs'>
|
||||
{item.ProductionYear}
|
||||
</Text>
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
items={series}
|
||||
header={t("search.series")}
|
||||
renderItem={(item: BaseItemDto) => (
|
||||
<TouchableItemRouter
|
||||
key={item.Id}
|
||||
item={item}
|
||||
className='flex flex-col w-28 mr-2'
|
||||
>
|
||||
<SeriesPoster item={item} key={item.Id} />
|
||||
<Text numberOfLines={2} className='mt-2'>
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text className='opacity-50 text-xs'>
|
||||
{item.ProductionYear}
|
||||
</Text>
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
items={episodes}
|
||||
header={t("search.episodes")}
|
||||
renderItem={(item: BaseItemDto) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className='flex flex-col w-44 mr-2'
|
||||
>
|
||||
<ContinueWatchingPoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
items={collections}
|
||||
header={t("search.collections")}
|
||||
renderItem={(item: BaseItemDto) => (
|
||||
<TouchableItemRouter
|
||||
key={item.Id}
|
||||
item={item}
|
||||
className='flex flex-col w-28 mr-2'
|
||||
>
|
||||
<MoviePoster item={item} key={item.Id} />
|
||||
<Text numberOfLines={2} className='mt-2'>
|
||||
{item.Name}
|
||||
</Text>
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
items={actors}
|
||||
header={t("search.actors")}
|
||||
renderItem={(item: BaseItemDto) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className='flex flex-col w-28 mr-2'
|
||||
>
|
||||
<MoviePoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
) : debouncedSearch.length === 0 ? (
|
||||
<View className='mt-4 flex flex-col items-center space-y-2'>
|
||||
{exampleSearches.map((e) => (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
setSearch(e);
|
||||
searchBarRef.current?.setText(e);
|
||||
}}
|
||||
key={e}
|
||||
className='mb-2'
|
||||
>
|
||||
<Text className='text-purple-600'>{e}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
) : null)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
) : (
|
||||
<JellyserrIndexPage
|
||||
searchQuery={debouncedSearch}
|
||||
sortType={jellyseerrOrderBy}
|
||||
order={jellyseerrSortOrder}
|
||||
/>
|
||||
)}
|
||||
|
||||
{searchType === "Library" &&
|
||||
(!loading && noResults && debouncedSearch.length > 0 ? (
|
||||
<View>
|
||||
<Text className='text-center text-lg font-bold mt-4'>
|
||||
{t("search.no_results_found_for")}
|
||||
</Text>
|
||||
<Text className='text-xs text-purple-600 text-center'>
|
||||
"{debouncedSearch}"
|
||||
</Text>
|
||||
</View>
|
||||
) : debouncedSearch.length === 0 ? (
|
||||
<View className='mt-4 flex flex-col items-center space-y-2'>
|
||||
{exampleSearches.map((e) => (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
setSearch(e);
|
||||
searchBarRef.current?.setText(e);
|
||||
}}
|
||||
key={e}
|
||||
className='mb-2'
|
||||
>
|
||||
<Text className='text-purple-600'>{e}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
) : null)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,33 +1,34 @@
|
||||
import {
|
||||
createNativeBottomTabNavigator,
|
||||
type NativeBottomTabNavigationEventMap,
|
||||
type NativeBottomTabNavigationOptions,
|
||||
} from "@bottom-tabs/react-navigation";
|
||||
import type {
|
||||
ParamListBase,
|
||||
TabNavigationState,
|
||||
} from "@react-navigation/native";
|
||||
import { useFocusEffect, useRouter, withLayoutContext } from "expo-router";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
const { Navigator } = createNativeBottomTabNavigator();
|
||||
|
||||
import type { BottomTabNavigationOptions } from "@react-navigation/bottom-tabs";
|
||||
import type {
|
||||
ParamListBase,
|
||||
TabNavigationState,
|
||||
} from "@react-navigation/native";
|
||||
import { SystemBars } from "react-native-edge-to-edge";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { eventBus } from "@/utils/eventBus";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
|
||||
const { Navigator } = createNativeBottomTabNavigator();
|
||||
|
||||
export const NativeTabs = withLayoutContext<
|
||||
NativeBottomTabNavigationOptions,
|
||||
BottomTabNavigationOptions,
|
||||
typeof Navigator,
|
||||
TabNavigationState<ParamListBase>,
|
||||
NativeBottomTabNavigationEventMap
|
||||
>(Navigator);
|
||||
|
||||
export default function TabLayout() {
|
||||
const { settings } = useSettings();
|
||||
const [settings] = useSettings();
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
@@ -51,6 +52,7 @@ export default function TabLayout() {
|
||||
<SystemBars hidden={false} style='light' />
|
||||
<NativeTabs
|
||||
sidebarAdaptable={false}
|
||||
ignoresTopSafeArea
|
||||
tabBarStyle={{
|
||||
backgroundColor: "#121212",
|
||||
}}
|
||||
@@ -59,7 +61,7 @@ export default function TabLayout() {
|
||||
>
|
||||
<NativeTabs.Screen redirect name='index' />
|
||||
<NativeTabs.Screen
|
||||
listeners={(_e) => ({
|
||||
listeners={({ navigation }) => ({
|
||||
tabPress: (_e) => {
|
||||
eventBus.emit("scrollToTop");
|
||||
},
|
||||
@@ -69,7 +71,8 @@ export default function TabLayout() {
|
||||
title: t("tabs.home"),
|
||||
tabBarIcon:
|
||||
Platform.OS === "android"
|
||||
? (_e) => require("@/assets/icons/house.fill.png")
|
||||
? ({ color, focused, size }) =>
|
||||
require("@/assets/icons/house.fill.png")
|
||||
: ({ focused }) =>
|
||||
focused
|
||||
? { sfSymbol: "house.fill" }
|
||||
@@ -77,7 +80,7 @@ export default function TabLayout() {
|
||||
}}
|
||||
/>
|
||||
<NativeTabs.Screen
|
||||
listeners={(_e) => ({
|
||||
listeners={({ navigation }) => ({
|
||||
tabPress: (_e) => {
|
||||
eventBus.emit("searchTabPressed");
|
||||
},
|
||||
@@ -87,7 +90,8 @@ export default function TabLayout() {
|
||||
title: t("tabs.search"),
|
||||
tabBarIcon:
|
||||
Platform.OS === "android"
|
||||
? (_e) => require("@/assets/icons/magnifyingglass.png")
|
||||
? ({ color, focused, size }) =>
|
||||
require("@/assets/icons/magnifyingglass.png")
|
||||
: ({ focused }) =>
|
||||
focused
|
||||
? { sfSymbol: "magnifyingglass" }
|
||||
@@ -100,7 +104,7 @@ export default function TabLayout() {
|
||||
title: t("tabs.favorites"),
|
||||
tabBarIcon:
|
||||
Platform.OS === "android"
|
||||
? ({ focused }) =>
|
||||
? ({ color, focused, size }) =>
|
||||
focused
|
||||
? require("@/assets/icons/heart.fill.png")
|
||||
: require("@/assets/icons/heart.png")
|
||||
@@ -116,7 +120,8 @@ export default function TabLayout() {
|
||||
title: t("tabs.library"),
|
||||
tabBarIcon:
|
||||
Platform.OS === "android"
|
||||
? (_e) => require("@/assets/icons/server.rack.png")
|
||||
? ({ color, focused, size }) =>
|
||||
require("@/assets/icons/server.rack.png")
|
||||
: ({ focused }) =>
|
||||
focused
|
||||
? { sfSymbol: "rectangle.stack.fill" }
|
||||
@@ -127,10 +132,11 @@ export default function TabLayout() {
|
||||
name='(custom-links)'
|
||||
options={{
|
||||
title: t("tabs.custom_links"),
|
||||
// @ts-expect-error
|
||||
tabBarItemHidden: !settings?.showCustomMenuLinks,
|
||||
tabBarIcon:
|
||||
Platform.OS === "android"
|
||||
? (_e) => require("@/assets/icons/list.png")
|
||||
? ({ focused }) => require("@/assets/icons/list.png")
|
||||
: ({ focused }) =>
|
||||
focused
|
||||
? { sfSymbol: "list.dash.fill" }
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
type BaseItemDto,
|
||||
type MediaSourceInfo,
|
||||
PlaybackOrder,
|
||||
PlaybackProgressInfo,
|
||||
type PlaybackProgressInfo,
|
||||
PlaybackStartInfo,
|
||||
RepeatMode,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
@@ -15,38 +15,36 @@ import { router, useGlobalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, Platform, View } from "react-native";
|
||||
import { useAnimatedReaction, useSharedValue } from "react-native-reanimated";
|
||||
|
||||
import { Alert, View } from "react-native";
|
||||
import { useSharedValue } from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { BITRATES } from "@/components/BitrateSelector";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { Controls } from "@/components/video-player/controls/Controls";
|
||||
import {
|
||||
OUTLINE_THICKNESS,
|
||||
OutlineThickness,
|
||||
VLC_COLORS,
|
||||
VLCColor,
|
||||
} from "@/constants/SubtitleConstants";
|
||||
import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||
import { VlcPlayerView } from "@/modules";
|
||||
import type {
|
||||
PipStartedPayload,
|
||||
PlaybackStatePayload,
|
||||
ProgressUpdatePayload,
|
||||
VlcPlayerViewRef,
|
||||
} from "@/modules/VlcPlayer.types";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { DownloadedItem } from "@/providers/Downloads/types";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import { generateDeviceProfile } from "@/utils/profiles/native";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import generateDeviceProfile from "@/utils/profiles/native";
|
||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||
|
||||
const downloadProvider = require("@/providers/DownloadProvider");
|
||||
|
||||
const IGNORE_SAFE_AREAS_KEY = "video_player_ignore_safe_areas";
|
||||
|
||||
export default function page() {
|
||||
const videoRef = useRef<VlcPlayerViewRef>(null);
|
||||
const user = useAtomValue(userAtom);
|
||||
@@ -56,26 +54,23 @@ export default function page() {
|
||||
|
||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
||||
const [showControls, _setShowControls] = useState(true);
|
||||
const [aspectRatio, setAspectRatio] = useState<
|
||||
"default" | "16:9" | "4:3" | "1:1" | "21:9"
|
||||
>("default");
|
||||
const [scaleFactor, setScaleFactor] = useState<
|
||||
1.0 | 1.1 | 1.2 | 1.3 | 1.4 | 1.5 | 1.6 | 1.7 | 1.8 | 1.9 | 2.0
|
||||
>(1.0);
|
||||
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(() => {
|
||||
// Load persisted state from storage
|
||||
const saved = storage.getBoolean(IGNORE_SAFE_AREAS_KEY);
|
||||
return saved ?? false;
|
||||
});
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [isBuffering, setIsBuffering] = useState(true);
|
||||
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
||||
const [isPipStarted, setIsPipStarted] = useState(false);
|
||||
|
||||
const progress = useSharedValue(0);
|
||||
const isSeeking = useSharedValue(false);
|
||||
const cacheProgress = useSharedValue(0);
|
||||
const VolumeManager = Platform.isTV
|
||||
? null
|
||||
: require("react-native-volume-manager");
|
||||
const VolumeManager = require("react-native-volume-manager");
|
||||
|
||||
const downloadUtils = useDownload();
|
||||
const downloadedFiles = downloadUtils.getDownloadedItems();
|
||||
const getDownloadedItem = downloadProvider.useDownload();
|
||||
|
||||
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
||||
|
||||
@@ -86,6 +81,11 @@ export default function page() {
|
||||
lightHapticFeedback();
|
||||
}, []);
|
||||
|
||||
// Persist ignoreSafeAreas state whenever it changes
|
||||
useEffect(() => {
|
||||
storage.set(IGNORE_SAFE_AREAS_KEY, ignoreSafeAreas);
|
||||
}, [ignoreSafeAreas]);
|
||||
|
||||
const {
|
||||
itemId,
|
||||
audioIndex: audioIndexStr,
|
||||
@@ -104,10 +104,9 @@ export default function page() {
|
||||
/** Playback position in ticks. */
|
||||
playbackPosition?: string;
|
||||
}>();
|
||||
const { settings } = useSettings();
|
||||
|
||||
const [settings] = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
const offline = offlineStr === "true";
|
||||
const playbackManager = usePlaybackManager();
|
||||
|
||||
const audioIndex = audioIndexStr
|
||||
? Number.parseInt(audioIndexStr, 10)
|
||||
@@ -120,33 +119,27 @@ export default function page() {
|
||||
: BITRATES[0].value;
|
||||
|
||||
const [item, setItem] = useState<BaseItemDto | null>(null);
|
||||
const [downloadedItem, setDownloadedItem] = useState<DownloadedItem | null>(
|
||||
null,
|
||||
);
|
||||
const [itemStatus, setItemStatus] = useState({
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
});
|
||||
|
||||
/** Gets the initial playback position from the URL. */
|
||||
/** Gets the initial playback position from the URL or the item's user data. */
|
||||
const getInitialPlaybackTicks = useCallback((): number => {
|
||||
if (playbackPositionFromUrl) {
|
||||
return Number.parseInt(playbackPositionFromUrl, 10);
|
||||
}
|
||||
return item?.UserData?.PlaybackPositionTicks ?? 0;
|
||||
}, [playbackPositionFromUrl]);
|
||||
}, [playbackPositionFromUrl, item]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchItemData = async () => {
|
||||
setItemStatus({ isLoading: true, isError: false });
|
||||
try {
|
||||
let fetchedItem: BaseItemDto | null = null;
|
||||
if (offline && !Platform.isTV) {
|
||||
const data = downloadUtils.getDownloadedItemById(itemId);
|
||||
if (data) {
|
||||
fetchedItem = data.item as BaseItemDto;
|
||||
setDownloadedItem(data);
|
||||
}
|
||||
if (offline) {
|
||||
const data = await getDownloadedItem.getDownloadedItem(itemId);
|
||||
if (data) fetchedItem = data.item as BaseItemDto;
|
||||
} else {
|
||||
const res = await getUserLibraryApi(api!).getItem({
|
||||
itemId,
|
||||
@@ -182,49 +175,27 @@ export default function page() {
|
||||
useEffect(() => {
|
||||
const fetchStreamData = async () => {
|
||||
setStreamStatus({ isLoading: true, isError: false });
|
||||
const native = await generateDeviceProfile();
|
||||
try {
|
||||
// Don't attempt to fetch stream data if item is not available
|
||||
if (!item?.Id) {
|
||||
console.log("Item not loaded yet, skipping stream data fetch");
|
||||
setStreamStatus({ isLoading: false, isError: false });
|
||||
return;
|
||||
}
|
||||
|
||||
let result: Stream | null = null;
|
||||
if (offline && downloadedItem && downloadedItem.mediaSource) {
|
||||
const url = downloadedItem.videoFilePath;
|
||||
if (offline) {
|
||||
const data = await getDownloadedItem.getDownloadedItem(itemId);
|
||||
if (!data?.mediaSource) return;
|
||||
const url = await getDownloadedFileUrl(data.item.Id!);
|
||||
if (item) {
|
||||
result = {
|
||||
mediaSource: downloadedItem.mediaSource,
|
||||
sessionId: "",
|
||||
url: url,
|
||||
};
|
||||
result = { mediaSource: data.mediaSource, sessionId: "", url };
|
||||
}
|
||||
} else {
|
||||
// Validate required parameters before calling getStreamUrl
|
||||
if (!api) {
|
||||
console.warn("API not available for streaming");
|
||||
setStreamStatus({ isLoading: false, isError: true });
|
||||
return;
|
||||
}
|
||||
if (!user?.Id) {
|
||||
console.warn("User not authenticated for streaming");
|
||||
setStreamStatus({ isLoading: false, isError: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const native = generateDeviceProfile();
|
||||
const transcoding = generateDeviceProfile({ transcode: true });
|
||||
const res = await getStreamUrl({
|
||||
api,
|
||||
item,
|
||||
startTimeTicks: getInitialPlaybackTicks(),
|
||||
userId: user.Id,
|
||||
userId: user?.Id,
|
||||
audioStreamIndex: audioIndex,
|
||||
maxStreamingBitrate: bitrateValue,
|
||||
mediaSourceId: mediaSourceId,
|
||||
subtitleStreamIndex: subtitleIndex,
|
||||
deviceProfile: bitrateValue ? transcoding : native,
|
||||
deviceProfile: native,
|
||||
});
|
||||
if (!res) return;
|
||||
const { mediaSource, sessionId, url } = res;
|
||||
@@ -245,34 +216,26 @@ export default function page() {
|
||||
}
|
||||
};
|
||||
fetchStreamData();
|
||||
}, [
|
||||
itemId,
|
||||
mediaSourceId,
|
||||
bitrateValue,
|
||||
api,
|
||||
item,
|
||||
user?.Id,
|
||||
downloadedItem,
|
||||
]);
|
||||
}, [itemId, mediaSourceId, bitrateValue, api, item, user?.Id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!stream || !api) return;
|
||||
if (!stream) return;
|
||||
|
||||
const reportPlaybackStart = async () => {
|
||||
await getPlaystateApi(api).reportPlaybackStart({
|
||||
await getPlaystateApi(api!).reportPlaybackStart({
|
||||
playbackStartInfo: currentPlayStateInfo() as PlaybackStartInfo,
|
||||
});
|
||||
};
|
||||
|
||||
reportPlaybackStart();
|
||||
}, [stream, api]);
|
||||
}, [stream]);
|
||||
|
||||
const togglePlay = async () => {
|
||||
lightHapticFeedback();
|
||||
setIsPlaying(!isPlaying);
|
||||
if (isPlaying) {
|
||||
await videoRef.current?.pause();
|
||||
playbackManager.reportPlaybackProgress(
|
||||
currentPlayStateInfo() as PlaybackProgressInfo,
|
||||
);
|
||||
reportPlaybackProgress();
|
||||
} else {
|
||||
videoRef.current?.play();
|
||||
await getPlaystateApi(api!).reportPlaybackStart({
|
||||
@@ -282,6 +245,7 @@ export default function page() {
|
||||
};
|
||||
|
||||
const reportPlaybackStopped = useCallback(async () => {
|
||||
if (offline) return;
|
||||
const currentTimeInTicks = msToTicks(progress.get());
|
||||
await getPlaystateApi(api!).onPlaybackStopped({
|
||||
itemId: item?.Id!,
|
||||
@@ -289,6 +253,8 @@ export default function page() {
|
||||
positionTicks: currentTimeInTicks,
|
||||
playSessionId: stream?.sessionId!,
|
||||
});
|
||||
|
||||
revalidateProgressCache();
|
||||
}, [
|
||||
api,
|
||||
item,
|
||||
@@ -300,15 +266,10 @@ export default function page() {
|
||||
]);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
// Update URL with final playback position before stopping
|
||||
router.setParams({
|
||||
playbackPosition: msToTicks(progress.get()).toString(),
|
||||
});
|
||||
reportPlaybackStopped();
|
||||
setIsPlaybackStopped(true);
|
||||
videoRef.current?.stop();
|
||||
revalidateProgressCache();
|
||||
}, [videoRef, reportPlaybackStopped, progress]);
|
||||
}, [videoRef, reportPlaybackStopped]);
|
||||
|
||||
useEffect(() => {
|
||||
const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
|
||||
@@ -317,7 +278,7 @@ export default function page() {
|
||||
};
|
||||
}, [navigation, stop]);
|
||||
|
||||
const currentPlayStateInfo = useCallback(() => {
|
||||
const currentPlayStateInfo = () => {
|
||||
if (!stream) return;
|
||||
return {
|
||||
itemId: item?.Id!,
|
||||
@@ -333,32 +294,7 @@ export default function page() {
|
||||
repeatMode: RepeatMode.RepeatNone,
|
||||
playbackOrder: PlaybackOrder.Default,
|
||||
};
|
||||
}, [
|
||||
stream,
|
||||
item?.Id,
|
||||
audioIndex,
|
||||
subtitleIndex,
|
||||
mediaSourceId,
|
||||
progress,
|
||||
isPlaying,
|
||||
isMuted,
|
||||
]);
|
||||
|
||||
const lastUrlUpdateTime = useSharedValue(0);
|
||||
const wasJustSeeking = useSharedValue(false);
|
||||
const URL_UPDATE_INTERVAL = 30000; // Update URL every 30 seconds instead of every second
|
||||
|
||||
// Track when seeking ends to update URL immediately
|
||||
useAnimatedReaction(
|
||||
() => isSeeking.get(),
|
||||
(currentSeeking, previousSeeking) => {
|
||||
if (previousSeeking && !currentSeeking) {
|
||||
// Seeking just ended
|
||||
wasJustSeeking.value = true;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
};
|
||||
|
||||
const onProgress = useCallback(
|
||||
async (data: ProgressUpdatePayload) => {
|
||||
@@ -371,26 +307,15 @@ export default function page() {
|
||||
|
||||
progress.set(currentTime);
|
||||
|
||||
// Update URL immediately after seeking, or every 30 seconds during normal playback
|
||||
const now = Date.now();
|
||||
const shouldUpdateUrl = wasJustSeeking.get();
|
||||
wasJustSeeking.value = false;
|
||||
// Update the playback position in the URL.
|
||||
router.setParams({
|
||||
playbackPosition: msToTicks(currentTime).toString(),
|
||||
});
|
||||
|
||||
if (
|
||||
shouldUpdateUrl ||
|
||||
now - lastUrlUpdateTime.get() > URL_UPDATE_INTERVAL
|
||||
) {
|
||||
router.setParams({
|
||||
playbackPosition: msToTicks(currentTime).toString(),
|
||||
});
|
||||
lastUrlUpdateTime.value = now;
|
||||
}
|
||||
if (offline) return;
|
||||
if (!item?.Id || !stream) return;
|
||||
|
||||
if (!item?.Id) return;
|
||||
|
||||
playbackManager.reportPlaybackProgress(
|
||||
currentPlayStateInfo() as PlaybackProgressInfo,
|
||||
);
|
||||
reportPlaybackProgress();
|
||||
},
|
||||
[
|
||||
item?.Id,
|
||||
@@ -405,14 +330,35 @@ export default function page() {
|
||||
],
|
||||
);
|
||||
|
||||
const onPipStarted = useCallback((e: PipStartedPayload) => {
|
||||
const { pipStarted } = e.nativeEvent;
|
||||
setIsPipStarted(pipStarted);
|
||||
}, []);
|
||||
|
||||
const reportPlaybackProgress = useCallback(async () => {
|
||||
if (!api || offline || !stream) return;
|
||||
await getPlaystateApi(api).reportPlaybackProgress({
|
||||
playbackProgressInfo: currentPlayStateInfo() as PlaybackProgressInfo,
|
||||
});
|
||||
}, [
|
||||
api,
|
||||
isPlaying,
|
||||
offline,
|
||||
stream,
|
||||
item?.Id,
|
||||
audioIndex,
|
||||
subtitleIndex,
|
||||
mediaSourceId,
|
||||
progress,
|
||||
]);
|
||||
|
||||
/** Gets the initial playback position in seconds. */
|
||||
const startPosition = useMemo(() => {
|
||||
if (offline) return 0;
|
||||
return ticksToSeconds(getInitialPlaybackTicks());
|
||||
}, [getInitialPlaybackTicks]);
|
||||
}, [offline, getInitialPlaybackTicks]);
|
||||
|
||||
const volumeUpCb = useCallback(async () => {
|
||||
if (Platform.isTV) return;
|
||||
|
||||
try {
|
||||
const { volume: currentVolume } = await VolumeManager.getVolume();
|
||||
const newVolume = Math.min(currentVolume + 0.1, 1.0);
|
||||
@@ -425,8 +371,6 @@ export default function page() {
|
||||
const [previousVolume, setPreviousVolume] = useState<number | null>(null);
|
||||
|
||||
const toggleMuteCb = useCallback(async () => {
|
||||
if (Platform.isTV) return;
|
||||
|
||||
try {
|
||||
const { volume: currentVolume } = await VolumeManager.getVolume();
|
||||
const currentVolumePercent = currentVolume * 100;
|
||||
@@ -447,10 +391,7 @@ export default function page() {
|
||||
console.error("Error toggling mute:", error);
|
||||
}
|
||||
}, [previousVolume]);
|
||||
|
||||
const volumeDownCb = useCallback(async () => {
|
||||
if (Platform.isTV) return;
|
||||
|
||||
try {
|
||||
const { volume: currentVolume } = await VolumeManager.getVolume();
|
||||
const newVolume = Math.max(currentVolume - 0.1, 0); // Decrease by 10%
|
||||
@@ -467,8 +408,6 @@ export default function page() {
|
||||
}, []);
|
||||
|
||||
const setVolumeCb = useCallback(async (newVolume: number) => {
|
||||
if (Platform.isTV) return;
|
||||
|
||||
try {
|
||||
const clampedVolume = Math.max(0, Math.min(newVolume, 100));
|
||||
console.log("Setting volume to", clampedVolume);
|
||||
@@ -494,23 +433,15 @@ export default function page() {
|
||||
const { state, isBuffering, isPlaying } = e.nativeEvent;
|
||||
if (state === "Playing") {
|
||||
setIsPlaying(true);
|
||||
if (item?.Id) {
|
||||
playbackManager.reportPlaybackProgress(
|
||||
currentPlayStateInfo() as PlaybackProgressInfo,
|
||||
);
|
||||
}
|
||||
if (!Platform.isTV) await activateKeepAwakeAsync();
|
||||
reportPlaybackProgress();
|
||||
await activateKeepAwakeAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
if (state === "Paused") {
|
||||
setIsPlaying(false);
|
||||
if (item?.Id) {
|
||||
playbackManager.reportPlaybackProgress(
|
||||
currentPlayStateInfo() as PlaybackProgressInfo,
|
||||
);
|
||||
}
|
||||
if (!Platform.isTV) await deactivateKeepAwake();
|
||||
reportPlaybackProgress();
|
||||
await deactivateKeepAwake();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -521,7 +452,7 @@ export default function page() {
|
||||
setIsBuffering(true);
|
||||
}
|
||||
},
|
||||
[playbackManager, item?.Id, progress],
|
||||
[reportPlaybackProgress],
|
||||
);
|
||||
|
||||
const allAudio =
|
||||
@@ -539,58 +470,28 @@ export default function page() {
|
||||
.filter((sub: any) => sub.DeliveryMethod === "External")
|
||||
.map((sub: any) => ({
|
||||
name: sub.DisplayTitle,
|
||||
DeliveryUrl: offline ? sub.DeliveryUrl : api?.basePath + sub.DeliveryUrl,
|
||||
DeliveryUrl: api?.basePath + sub.DeliveryUrl,
|
||||
}));
|
||||
/** The text based subtitle tracks */
|
||||
|
||||
const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream);
|
||||
/** The user chosen subtitle track from the server */
|
||||
|
||||
const chosenSubtitleTrack = allSubs.find(
|
||||
(sub) => sub.Index === subtitleIndex,
|
||||
);
|
||||
/** The user chosen audio track from the server */
|
||||
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
|
||||
/** Whether the stream we're playing is not transcoding*/
|
||||
|
||||
const notTranscoding = !stream?.mediaSource.TranscodingUrl;
|
||||
/** The initial options to pass to the VLC Player */
|
||||
const initOptions = [``];
|
||||
const initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
|
||||
if (
|
||||
chosenSubtitleTrack &&
|
||||
(notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
|
||||
) {
|
||||
// If not transcoding, we can the index as normal.
|
||||
// If transcoding, we need to reverse the text based subtitles, because VLC reverses the HLS subtitles.
|
||||
const finalIndex = notTranscoding
|
||||
? allSubs.indexOf(chosenSubtitleTrack)
|
||||
: [...textSubs].reverse().indexOf(chosenSubtitleTrack);
|
||||
: textSubs.indexOf(chosenSubtitleTrack);
|
||||
initOptions.push(`--sub-track=${finalIndex}`);
|
||||
|
||||
// Add VLC subtitle styling options from settings
|
||||
const textColor = (settings.vlcTextColor ?? "White") as VLCColor;
|
||||
const backgroundColor = (settings.vlcBackgroundColor ??
|
||||
"Black") as VLCColor;
|
||||
const outlineColor = (settings.vlcOutlineColor ?? "Black") as VLCColor;
|
||||
const outlineThickness = (settings.vlcOutlineThickness ??
|
||||
"Normal") as OutlineThickness;
|
||||
const backgroundOpacity = settings.vlcBackgroundOpacity ?? 128;
|
||||
const outlineOpacity = settings.vlcOutlineOpacity ?? 255;
|
||||
const isBold = settings.vlcIsBold ?? false;
|
||||
// Add subtitle styling options
|
||||
initOptions.push(`--freetype-color=${VLC_COLORS[textColor]}`);
|
||||
initOptions.push(`--freetype-background-opacity=${backgroundOpacity}`);
|
||||
initOptions.push(
|
||||
`--freetype-background-color=${VLC_COLORS[backgroundColor]}`,
|
||||
);
|
||||
initOptions.push(`--freetype-outline-opacity=${outlineOpacity}`);
|
||||
initOptions.push(`--freetype-outline-color=${VLC_COLORS[outlineColor]}`);
|
||||
initOptions.push(
|
||||
`--freetype-outline-thickness=${OUTLINE_THICKNESS[outlineThickness]}`,
|
||||
);
|
||||
initOptions.push(`--sub-text-scale=${settings.subtitleSize}`);
|
||||
initOptions.push("--sub-margin=40");
|
||||
if (isBold) {
|
||||
initOptions.push("--freetype-bold");
|
||||
}
|
||||
}
|
||||
|
||||
if (notTranscoding && chosenAudioTrack) {
|
||||
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
|
||||
}
|
||||
@@ -603,72 +504,7 @@ export default function page() {
|
||||
return () => setIsMounted(false);
|
||||
}, []);
|
||||
|
||||
// Memoize video ref functions to prevent unnecessary re-renders
|
||||
const startPictureInPicture = useCallback(async () => {
|
||||
return videoRef.current?.startPictureInPicture?.();
|
||||
}, []);
|
||||
const play = useCallback(() => {
|
||||
videoRef.current?.play?.();
|
||||
}, []);
|
||||
|
||||
const pause = useCallback(() => {
|
||||
videoRef.current?.pause?.();
|
||||
}, []);
|
||||
|
||||
const seek = useCallback((position: number) => {
|
||||
videoRef.current?.seekTo?.(position);
|
||||
}, []);
|
||||
const getAudioTracks = useCallback(async () => {
|
||||
return videoRef.current?.getAudioTracks?.() || null;
|
||||
}, []);
|
||||
|
||||
const getSubtitleTracks = useCallback(async () => {
|
||||
return videoRef.current?.getSubtitleTracks?.() || null;
|
||||
}, []);
|
||||
|
||||
const setSubtitleTrack = useCallback((index: number) => {
|
||||
videoRef.current?.setSubtitleTrack?.(index);
|
||||
}, []);
|
||||
|
||||
const setSubtitleURL = useCallback((url: string, _customName?: string) => {
|
||||
// Note: VlcPlayer type only expects url parameter
|
||||
videoRef.current?.setSubtitleURL?.(url);
|
||||
}, []);
|
||||
|
||||
const setAudioTrack = useCallback((index: number) => {
|
||||
videoRef.current?.setAudioTrack?.(index);
|
||||
}, []);
|
||||
|
||||
const setVideoAspectRatio = useCallback(
|
||||
async (aspectRatio: string | null) => {
|
||||
return (
|
||||
videoRef.current?.setVideoAspectRatio?.(aspectRatio) ||
|
||||
Promise.resolve()
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const setVideoScaleFactor = useCallback(async (scaleFactor: number) => {
|
||||
return (
|
||||
videoRef.current?.setVideoScaleFactor?.(scaleFactor) || Promise.resolve()
|
||||
);
|
||||
}, []);
|
||||
|
||||
console.log("Debug: component render"); // Uncomment to debug re-renders
|
||||
|
||||
// Show error UI first, before checking loading/missing‐data
|
||||
if (itemStatus.isError || streamStatus.isError) {
|
||||
return (
|
||||
<View className='w-screen h-screen flex flex-col items-center justify-center bg-black'>
|
||||
<Text className='text-white'>{t("player.error")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Then show loader while either side is still fetching or data isn’t present
|
||||
if (itemStatus.isLoading || streamStatus.isLoading || !item || !stream) {
|
||||
// …loader UI…
|
||||
if (itemStatus.isLoading || streamStatus.isLoading) {
|
||||
return (
|
||||
<View className='w-screen h-screen flex flex-col items-center justify-center bg-black'>
|
||||
<Loader />
|
||||
@@ -684,14 +520,7 @@ export default function page() {
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: "black",
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<View style={{ flex: 1, backgroundColor: "black" }}>
|
||||
<View
|
||||
style={{
|
||||
display: "flex",
|
||||
@@ -700,6 +529,8 @@ export default function page() {
|
||||
position: "relative",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
paddingLeft: ignoreSafeAreas ? 0 : insets.left,
|
||||
paddingRight: ignoreSafeAreas ? 0 : insets.right,
|
||||
}}
|
||||
>
|
||||
<VlcPlayerView
|
||||
@@ -707,7 +538,7 @@ export default function page() {
|
||||
source={{
|
||||
uri: stream?.url || "",
|
||||
autoplay: true,
|
||||
isNetwork: !offline,
|
||||
isNetwork: true,
|
||||
startPosition,
|
||||
externalSubtitles,
|
||||
initOptions,
|
||||
@@ -716,6 +547,7 @@ export default function page() {
|
||||
onVideoProgress={onProgress}
|
||||
progressUpdateInterval={1000}
|
||||
onVideoStateChange={onPlaybackStateChanged}
|
||||
onPipStarted={onPipStarted}
|
||||
onVideoLoadEnd={() => {
|
||||
setIsVideoLoaded(true);
|
||||
}}
|
||||
@@ -729,7 +561,7 @@ export default function page() {
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
{isMounted === true && item && (
|
||||
{videoRef.current && !isPipStarted && isMounted === true && item ? (
|
||||
<Controls
|
||||
mediaSource={stream?.mediaSource}
|
||||
item={item}
|
||||
@@ -742,28 +574,23 @@ export default function page() {
|
||||
isBuffering={isBuffering}
|
||||
showControls={showControls}
|
||||
setShowControls={setShowControls}
|
||||
setIgnoreSafeAreas={setIgnoreSafeAreas}
|
||||
ignoreSafeAreas={ignoreSafeAreas}
|
||||
isVideoLoaded={isVideoLoaded}
|
||||
startPictureInPicture={startPictureInPicture}
|
||||
play={play}
|
||||
pause={pause}
|
||||
seek={seek}
|
||||
startPictureInPicture={videoRef?.current?.startPictureInPicture}
|
||||
play={videoRef.current?.play}
|
||||
pause={videoRef.current?.pause}
|
||||
seek={videoRef.current?.seekTo}
|
||||
enableTrickplay={true}
|
||||
getAudioTracks={getAudioTracks}
|
||||
getSubtitleTracks={getSubtitleTracks}
|
||||
getAudioTracks={videoRef.current?.getAudioTracks}
|
||||
getSubtitleTracks={videoRef.current?.getSubtitleTracks}
|
||||
offline={offline}
|
||||
setSubtitleTrack={setSubtitleTrack}
|
||||
setSubtitleURL={setSubtitleURL}
|
||||
setAudioTrack={setAudioTrack}
|
||||
setVideoAspectRatio={setVideoAspectRatio}
|
||||
setVideoScaleFactor={setVideoScaleFactor}
|
||||
aspectRatio={aspectRatio}
|
||||
scaleFactor={scaleFactor}
|
||||
setAspectRatio={setAspectRatio}
|
||||
setScaleFactor={setScaleFactor}
|
||||
setSubtitleTrack={videoRef.current.setSubtitleTrack}
|
||||
setSubtitleURL={videoRef.current.setSubtitleURL}
|
||||
setAudioTrack={videoRef.current.setAudioTrack}
|
||||
isVlc
|
||||
downloadedFiles={downloadedFiles}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ScrollViewStyleReset } from "expo-router/html";
|
||||
import { type PropsWithChildren } from "react";
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
/**
|
||||
* This file is web-only and used to configure the root HTML for every web page during static rendering.
|
||||
|
||||
525
app/_layout.tsx
@@ -1,6 +1,7 @@
|
||||
import "@/augmentations";
|
||||
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { Platform } from "react-native";
|
||||
import i18n from "@/i18n";
|
||||
import { DownloadProvider } from "@/providers/DownloadProvider";
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
getTokenFromStorage,
|
||||
JellyfinProvider,
|
||||
} from "@/providers/JellyfinProvider";
|
||||
import { JobQueueProvider } from "@/providers/JobQueueProvider";
|
||||
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
||||
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
||||
import { type Settings, useSettings } from "@/utils/atoms/settings";
|
||||
@@ -20,11 +22,12 @@ import {
|
||||
} from "@/utils/background-tasks";
|
||||
import {
|
||||
LogProvider,
|
||||
writeDebugLog,
|
||||
writeErrorLog,
|
||||
writeInfoLog,
|
||||
writeToLog,
|
||||
} from "@/utils/log";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server";
|
||||
|
||||
const BackGroundDownloader = !Platform.isTV
|
||||
? require("@kesha-antonov/react-native-background-downloader")
|
||||
@@ -33,25 +36,24 @@ const BackGroundDownloader = !Platform.isTV
|
||||
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import * as BackgroundTask from "expo-background-task";
|
||||
|
||||
import * as Device from "expo-device";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
|
||||
const Notifications = !Platform.isTV ? require("expo-notifications") : null;
|
||||
|
||||
import { getLocales } from "expo-localization";
|
||||
import { router, Stack, useSegments } from "expo-router";
|
||||
import * as SplashScreen from "expo-splash-screen";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
|
||||
import * as TaskManager from "expo-task-manager";
|
||||
|
||||
import { getLocales } from "expo-localization";
|
||||
import { Provider as JotaiProvider } from "jotai";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import { Appearance, AppState } from "react-native";
|
||||
import { SystemBars } from "react-native-edge-to-edge";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import "react-native-reanimated";
|
||||
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
||||
import type { EventSubscription } from "expo-modules-core";
|
||||
@@ -84,19 +86,19 @@ SplashScreen.setOptions({
|
||||
fade: true,
|
||||
});
|
||||
|
||||
function redirect(notification: typeof Notifications.Notification) {
|
||||
const url = notification.request.content.data?.url;
|
||||
if (url) {
|
||||
router.push(url);
|
||||
}
|
||||
}
|
||||
|
||||
function useNotificationObserver() {
|
||||
useEffect(() => {
|
||||
if (Platform.isTV) return;
|
||||
if (Platform.isTV) return;
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
function redirect(notification: typeof Notifications.Notification) {
|
||||
const url = notification.request.content.data?.url;
|
||||
if (url) {
|
||||
router.push(url);
|
||||
}
|
||||
}
|
||||
|
||||
Notifications.getLastNotificationResponseAsync().then(
|
||||
(response: { notification: any }) => {
|
||||
if (!isMounted || !response?.notification) {
|
||||
@@ -106,8 +108,15 @@ function useNotificationObserver() {
|
||||
},
|
||||
);
|
||||
|
||||
const subscription = Notifications.addNotificationResponseReceivedListener(
|
||||
(response: { notification: any }) => {
|
||||
redirect(response.notification);
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
subscription.remove();
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -117,7 +126,9 @@ if (!Platform.isTV) {
|
||||
console.log("TaskManager ~ sessions trigger");
|
||||
|
||||
const api = store.get(apiAtom);
|
||||
if (api === null || api === undefined) return;
|
||||
if (api === null || api === undefined) {
|
||||
return { value: null };
|
||||
}
|
||||
|
||||
const response = await getSessionApi(api).getSessions({
|
||||
activeWithinSeconds: 360,
|
||||
@@ -126,30 +137,99 @@ if (!Platform.isTV) {
|
||||
const result = response.data.filter((s) => s.NowPlayingItem);
|
||||
Notifications.setBadgeCountAsync(result.length);
|
||||
|
||||
return BackgroundTask.BackgroundTaskResult.Success;
|
||||
return { value: "success" };
|
||||
});
|
||||
|
||||
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
|
||||
console.log("TaskManager ~ trigger");
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
const settingsData = storage.getString("settings");
|
||||
|
||||
if (!settingsData) return BackgroundTask.BackgroundTaskResult.Failed;
|
||||
if (!settingsData) return { value: null };
|
||||
|
||||
const settings: Partial<Settings> = JSON.parse(settingsData);
|
||||
const url = settings?.optimizedVersionsServerUrl;
|
||||
|
||||
if (!settings?.autoDownload)
|
||||
return BackgroundTask.BackgroundTaskResult.Failed;
|
||||
if (!settings?.autoDownload || !url) return { value: null };
|
||||
|
||||
const token = getTokenFromStorage();
|
||||
const deviceId = getOrSetDeviceId();
|
||||
const baseDirectory = FileSystem.documentDirectory;
|
||||
|
||||
if (!token || !deviceId || !baseDirectory)
|
||||
return BackgroundTask.BackgroundTaskResult.Failed;
|
||||
if (!token || !deviceId || !baseDirectory) return { value: null };
|
||||
|
||||
const jobs = await getAllJobsByDeviceId({
|
||||
deviceId,
|
||||
authHeader: token,
|
||||
url,
|
||||
});
|
||||
|
||||
console.log("TaskManager ~ Active jobs: ", jobs.length);
|
||||
|
||||
for (const job of jobs) {
|
||||
if (job.status === "completed") {
|
||||
const downloadUrl = `${url}download/${job.id}`;
|
||||
const tasks = await BackGroundDownloader.checkForExistingDownloads();
|
||||
|
||||
if (tasks.find((task: { id: string }) => task.id === job.id)) {
|
||||
console.log("TaskManager ~ Download already in progress: ", job.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
BackGroundDownloader.download({
|
||||
id: job.id,
|
||||
url: downloadUrl,
|
||||
destination: `${baseDirectory}${job.item.Id}.mp4`,
|
||||
headers: {
|
||||
Authorization: token,
|
||||
},
|
||||
})
|
||||
.begin(() => {
|
||||
console.log("TaskManager ~ Download started: ", job.id);
|
||||
})
|
||||
.done(() => {
|
||||
console.log("TaskManager ~ Download completed: ", job.id);
|
||||
_saveDownloadedItemInfo(job.item);
|
||||
BackGroundDownloader.completeHandler(job.id);
|
||||
cancelJobById({
|
||||
authHeader: token,
|
||||
id: job.id,
|
||||
url: url,
|
||||
});
|
||||
Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: job.item.Name,
|
||||
body: "Download completed",
|
||||
data: {
|
||||
url: "/downloads",
|
||||
},
|
||||
},
|
||||
trigger: null,
|
||||
});
|
||||
})
|
||||
.error((error: any) => {
|
||||
console.log("TaskManager ~ Download error: ", job.id, error);
|
||||
BackGroundDownloader.completeHandler(job.id);
|
||||
Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: job.item.Name,
|
||||
body: "Download failed",
|
||||
data: {
|
||||
url: "/downloads",
|
||||
},
|
||||
},
|
||||
trigger: null,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Auto download started: ${new Date(now).toISOString()}`);
|
||||
|
||||
// Be sure to return the successful result type!
|
||||
return BackgroundTask.BackgroundTaskResult.Success;
|
||||
return { value: "success" };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -158,31 +238,22 @@ const checkAndRequestPermissions = async () => {
|
||||
const hasAskedBefore = storage.getString(
|
||||
"hasAskedForNotificationPermission",
|
||||
);
|
||||
let granted = false;
|
||||
|
||||
if (hasAskedBefore !== "true") {
|
||||
const { status } = await Notifications.requestPermissionsAsync();
|
||||
granted = status === "granted";
|
||||
if (granted) {
|
||||
|
||||
if (status === "granted") {
|
||||
writeToLog("INFO", "Notification permissions granted.");
|
||||
console.log("Notification permissions granted.");
|
||||
} else {
|
||||
writeToLog("ERROR", "Notification permissions denied.");
|
||||
console.log("Notification permissions denied.");
|
||||
}
|
||||
|
||||
storage.set("hasAskedForNotificationPermission", "true");
|
||||
} else {
|
||||
// Already asked before, check current status
|
||||
const { status } = await Notifications.getPermissionsAsync();
|
||||
granted = status === "granted";
|
||||
if (!granted) {
|
||||
writeToLog(
|
||||
"ERROR",
|
||||
"Notification permissions denied (already asked before).",
|
||||
);
|
||||
console.log("Notification permissions denied (already asked before).");
|
||||
}
|
||||
console.log("Already asked for notification permissions before.");
|
||||
}
|
||||
return granted;
|
||||
} catch (error) {
|
||||
writeToLog(
|
||||
"ERROR",
|
||||
@@ -190,7 +261,6 @@ const checkAndRequestPermissions = async () => {
|
||||
error,
|
||||
);
|
||||
console.error("Error checking/requesting notification permissions:", error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -223,7 +293,7 @@ const queryClient = new QueryClient({
|
||||
});
|
||||
|
||||
function Layout() {
|
||||
const { settings } = useSettings();
|
||||
const [settings] = useSettings();
|
||||
const [user] = useAtom(userAtom);
|
||||
const [api] = useAtom(apiAtom);
|
||||
const appState = useRef(AppState.currentState);
|
||||
@@ -235,67 +305,52 @@ function Layout() {
|
||||
);
|
||||
}, [settings?.preferedLanguage, i18n]);
|
||||
|
||||
useNotificationObserver();
|
||||
if (!Platform.isTV) {
|
||||
useNotificationObserver();
|
||||
|
||||
const [expoPushToken, setExpoPushToken] = useState<ExpoPushToken>();
|
||||
const notificationListener = useRef<EventSubscription>(null);
|
||||
const responseListener = useRef<EventSubscription>(null);
|
||||
const [expoPushToken, setExpoPushToken] = useState<ExpoPushToken>();
|
||||
const notificationListener = useRef<EventSubscription>();
|
||||
const responseListener = useRef<EventSubscription>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!Platform.isTV && expoPushToken && api && user) {
|
||||
api
|
||||
?.post("/Streamyfin/device", {
|
||||
token: expoPushToken.data,
|
||||
deviceId: getOrSetDeviceId(),
|
||||
userId: user.Id,
|
||||
})
|
||||
.then((_) => console.log("Posted expo push token"))
|
||||
.catch((_) =>
|
||||
writeErrorLog("Failed to push expo push token to plugin"),
|
||||
);
|
||||
} else console.log("No token available");
|
||||
}, [api, expoPushToken, user]);
|
||||
useEffect(() => {
|
||||
if (expoPushToken && api && user) {
|
||||
api
|
||||
?.post("/Streamyfin/device", {
|
||||
token: expoPushToken.data,
|
||||
deviceId: getOrSetDeviceId(),
|
||||
userId: user.Id,
|
||||
})
|
||||
.then((_) => console.log("Posted expo push token"))
|
||||
.catch((_) =>
|
||||
writeErrorLog("Failed to push expo push token to plugin"),
|
||||
);
|
||||
} else console.log("No token available");
|
||||
}, [api, expoPushToken, user]);
|
||||
|
||||
async function registerNotifications() {
|
||||
if (Platform.OS === "android") {
|
||||
console.log("Setting android notification channel 'default'");
|
||||
await Notifications?.setNotificationChannelAsync("default", {
|
||||
name: "default",
|
||||
});
|
||||
async function registerNotifications() {
|
||||
if (Platform.OS === "android") {
|
||||
console.log("Setting android notification channel 'default'");
|
||||
await Notifications?.setNotificationChannelAsync("default", {
|
||||
name: "default",
|
||||
});
|
||||
}
|
||||
|
||||
// Create dedicated channel for download notifications
|
||||
console.log("Setting android notification channel 'downloads'");
|
||||
await Notifications?.setNotificationChannelAsync("downloads", {
|
||||
name: "Downloads",
|
||||
importance: Notifications.AndroidImportance.DEFAULT,
|
||||
vibrationPattern: [0, 250, 250, 250],
|
||||
lightColor: "#FF231F7C",
|
||||
});
|
||||
await checkAndRequestPermissions();
|
||||
|
||||
if (!Platform.isTV && user && user.Policy?.IsAdministrator) {
|
||||
await registerBackgroundFetchAsyncSessions();
|
||||
}
|
||||
|
||||
// only create push token for real devices (pointless for emulators)
|
||||
if (Device.isDevice) {
|
||||
Notifications?.getExpoPushTokenAsync()
|
||||
.then((token: ExpoPushToken) => token && setExpoPushToken(token))
|
||||
.catch((reason: any) => console.log("Failed to get token", reason));
|
||||
}
|
||||
}
|
||||
|
||||
const granted = await checkAndRequestPermissions();
|
||||
if (!granted) {
|
||||
console.log(
|
||||
"Notification permissions not granted, skipping background fetch and push token registration.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Platform.isTV && user && user.Policy?.IsAdministrator) {
|
||||
await registerBackgroundFetchAsyncSessions();
|
||||
}
|
||||
|
||||
// only create push token for real devices (pointless for emulators)
|
||||
if (Device.isDevice) {
|
||||
Notifications?.getExpoPushTokenAsync()
|
||||
.then((token: ExpoPushToken) => token && setExpoPushToken(token))
|
||||
.catch((reason: any) => console.log("Failed to get token", reason));
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!Platform.isTV) {
|
||||
void registerNotifications();
|
||||
useEffect(() => {
|
||||
registerNotifications();
|
||||
|
||||
notificationListener.current =
|
||||
Notifications?.addNotificationReceivedListener(
|
||||
@@ -310,102 +365,101 @@ function Layout() {
|
||||
responseListener.current =
|
||||
Notifications?.addNotificationResponseReceivedListener(
|
||||
(response: NotificationResponse) => {
|
||||
// redirect if internal notification
|
||||
redirect(response?.notification);
|
||||
|
||||
// Currently the notifications supported by the plugin will send data for deep links.
|
||||
const { title, data } = response.notification.request.content;
|
||||
writeInfoLog(`Notification ${title} opened`, data);
|
||||
|
||||
let url: any;
|
||||
const type = (data?.type ?? "").toString().toLowerCase();
|
||||
const itemId = data?.id;
|
||||
writeDebugLog(
|
||||
`Notification ${title} opened`,
|
||||
response.notification.request.content,
|
||||
);
|
||||
|
||||
switch (type) {
|
||||
case "movie":
|
||||
url = `/(auth)/(tabs)/home/items/page?id=${itemId}`;
|
||||
break;
|
||||
case "episode":
|
||||
// `/(auth)/(tabs)/${from}/items/page?id=${item.Id}`;
|
||||
// We just clicked a notification for an individual episode.
|
||||
if (itemId) {
|
||||
url = `/(auth)/(tabs)/home/items/page?id=${itemId}`;
|
||||
// summarized season notification for multiple episodes. Bring them to series season
|
||||
} else {
|
||||
const seriesId = data.seriesId;
|
||||
const seasonIndex = data.seasonIndex;
|
||||
if (seasonIndex) {
|
||||
url = `/(auth)/(tabs)/home/series/${seriesId}?seasonIndex=${seasonIndex}`;
|
||||
} else {
|
||||
url = `/(auth)/(tabs)/home/series/${seriesId}`;
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
const type = data?.type?.toLower?.();
|
||||
const itemId = data?.id;
|
||||
|
||||
switch (type) {
|
||||
case "movie":
|
||||
router.push(`/(auth)/(tabs)/home/items/page?id=${itemId}`);
|
||||
break;
|
||||
case "episode":
|
||||
// We just clicked a notification for an individual episode.
|
||||
if (itemId) {
|
||||
router.push(`/(auth)/(tabs)/home/items/page?id=${itemId}`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
// summarized season notification for multiple episodes. Bring them to series season
|
||||
else {
|
||||
const seriesId = data.seriesId;
|
||||
const seasonIndex = data.seasonIndex;
|
||||
|
||||
writeInfoLog(`Notification attempting to redirect to ${url}`);
|
||||
if (url) {
|
||||
router.push(url);
|
||||
if (seasonIndex) {
|
||||
router.push(
|
||||
`/(auth)/(tabs)/home/series/${seriesId}?seasonIndex=${seasonIndex}`,
|
||||
);
|
||||
} else {
|
||||
router.push(`/(auth)/(tabs)/home/series/${seriesId}`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
notificationListener.current?.remove();
|
||||
responseListener.current?.remove();
|
||||
notificationListener.current &&
|
||||
Notifications?.removeNotificationSubscription(
|
||||
notificationListener.current,
|
||||
);
|
||||
responseListener.current &&
|
||||
Notifications?.removeNotificationSubscription(
|
||||
responseListener.current,
|
||||
);
|
||||
};
|
||||
}
|
||||
}, [user, api]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.isTV) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (segments.includes("direct-player" as never)) {
|
||||
if (
|
||||
!settings.followDeviceOrientation &&
|
||||
settings.defaultVideoOrientation
|
||||
) {
|
||||
ScreenOrientation.lockAsync(settings.defaultVideoOrientation);
|
||||
useEffect(() => {
|
||||
if (Platform.isTV) {
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (settings.followDeviceOrientation === true) {
|
||||
ScreenOrientation.unlockAsync();
|
||||
} else {
|
||||
ScreenOrientation.lockAsync(
|
||||
ScreenOrientation.OrientationLock.PORTRAIT_UP,
|
||||
);
|
||||
}
|
||||
}, [
|
||||
settings.followDeviceOrientation,
|
||||
settings.defaultVideoOrientation,
|
||||
segments,
|
||||
]);
|
||||
if (segments.includes("direct-player" as never)) {
|
||||
if (
|
||||
!settings.followDeviceOrientation &&
|
||||
settings.defaultVideoOrientation
|
||||
) {
|
||||
ScreenOrientation.lockAsync(settings.defaultVideoOrientation);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.isTV) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subscription = AppState.addEventListener("change", (nextAppState) => {
|
||||
if (
|
||||
appState.current.match(/inactive|background/) &&
|
||||
nextAppState === "active"
|
||||
) {
|
||||
BackGroundDownloader.checkForExistingDownloads().catch(
|
||||
(error: unknown) => {
|
||||
writeErrorLog("Failed to resume background downloads", error);
|
||||
},
|
||||
if (settings.followDeviceOrientation === true) {
|
||||
ScreenOrientation.unlockAsync();
|
||||
} else {
|
||||
ScreenOrientation.lockAsync(
|
||||
ScreenOrientation.OrientationLock.PORTRAIT_UP,
|
||||
);
|
||||
}
|
||||
});
|
||||
}, [
|
||||
settings.followDeviceOrientation,
|
||||
settings.defaultVideoOrientation,
|
||||
segments,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = AppState.addEventListener(
|
||||
"change",
|
||||
(nextAppState) => {
|
||||
if (
|
||||
appState.current.match(/inactive|background/) &&
|
||||
nextAppState === "active"
|
||||
) {
|
||||
BackGroundDownloader.checkForExistingDownloads();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
BackGroundDownloader.checkForExistingDownloads();
|
||||
|
||||
BackGroundDownloader.checkForExistingDownloads().catch((error: unknown) => {
|
||||
writeErrorLog("Failed to resume background downloads", error);
|
||||
});
|
||||
return () => {
|
||||
subscription.remove();
|
||||
};
|
||||
@@ -413,62 +467,85 @@ function Layout() {
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<JellyfinProvider>
|
||||
<PlaySettingsProvider>
|
||||
<LogProvider>
|
||||
<WebSocketProvider>
|
||||
<DownloadProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<SystemBars style='light' hidden={false} />
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<Stack initialRouteName='(auth)/(tabs)'>
|
||||
<Stack.Screen
|
||||
name='(auth)/(tabs)'
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
header: () => null,
|
||||
<JobQueueProvider>
|
||||
<JellyfinProvider>
|
||||
<PlaySettingsProvider>
|
||||
<LogProvider>
|
||||
<WebSocketProvider>
|
||||
<DownloadProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<SystemBars style='light' hidden={false} />
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<Stack initialRouteName='(auth)/(tabs)'>
|
||||
<Stack.Screen
|
||||
name='(auth)/(tabs)'
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
header: () => null,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='(auth)/player'
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
header: () => null,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='login'
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: "",
|
||||
headerTransparent: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name='+not-found' />
|
||||
</Stack>
|
||||
<Toaster
|
||||
duration={4000}
|
||||
toastOptions={{
|
||||
style: {
|
||||
backgroundColor: "#262626",
|
||||
borderColor: "#363639",
|
||||
borderWidth: 1,
|
||||
},
|
||||
titleStyle: {
|
||||
color: "white",
|
||||
},
|
||||
}}
|
||||
closeButton
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='(auth)/player'
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
header: () => null,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='login'
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: "",
|
||||
headerTransparent: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name='+not-found' />
|
||||
</Stack>
|
||||
<Toaster
|
||||
duration={4000}
|
||||
toastOptions={{
|
||||
style: {
|
||||
backgroundColor: "#262626",
|
||||
borderColor: "#363639",
|
||||
borderWidth: 1,
|
||||
},
|
||||
titleStyle: {
|
||||
color: "white",
|
||||
},
|
||||
}}
|
||||
closeButton
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</DownloadProvider>
|
||||
</WebSocketProvider>
|
||||
</LogProvider>
|
||||
</PlaySettingsProvider>
|
||||
</JellyfinProvider>
|
||||
</ThemeProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</DownloadProvider>
|
||||
</WebSocketProvider>
|
||||
</LogProvider>
|
||||
</PlaySettingsProvider>
|
||||
</JellyfinProvider>
|
||||
</JobQueueProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function _saveDownloadedItemInfo(item: BaseItemDto) {
|
||||
try {
|
||||
const downloadedItems = storage.getString("downloadedItems");
|
||||
const items: BaseItemDto[] = downloadedItems
|
||||
? JSON.parse(downloadedItems)
|
||||
: [];
|
||||
|
||||
const existingItemIndex = items.findIndex((i) => i.Id === item.Id);
|
||||
if (existingItemIndex !== -1) {
|
||||
items[existingItemIndex] = item;
|
||||
} else {
|
||||
items.push(item);
|
||||
}
|
||||
|
||||
storage.set("downloadedItems", JSON.stringify(items));
|
||||
} catch (error) {
|
||||
writeToLog("ERROR", "Failed to save downloaded item information:", error);
|
||||
console.error("Failed to save downloaded item information:", error);
|
||||
}
|
||||
}
|
||||
|
||||
213
app/login.tsx
@@ -133,52 +133,27 @@ const Login: React.FC = () => {
|
||||
*/
|
||||
const checkUrl = useCallback(async (url: string) => {
|
||||
setLoadingServerCheck(true);
|
||||
const baseUrl = url.replace(/^https?:\/\//i, "");
|
||||
const protocols = ["https", "http"];
|
||||
|
||||
try {
|
||||
return checkHttp(baseUrl, protocols);
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message === "Server too old") {
|
||||
throw e;
|
||||
const response = await fetch(`${url}/System/Info/Public`, {
|
||||
mode: "cors",
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = (await response.json()) as PublicSystemInfo;
|
||||
|
||||
setServerName(data.ServerName || "");
|
||||
return url;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
} finally {
|
||||
setLoadingServerCheck(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
async function checkHttp(baseUrl: string, protocols: string[]) {
|
||||
for (const protocol of protocols) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${protocol}://${baseUrl}/System/Info/Public`,
|
||||
{
|
||||
mode: "cors",
|
||||
},
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = (await response.json()) as PublicSystemInfo;
|
||||
const serverVersion = data.Version?.split(".");
|
||||
if (serverVersion && +serverVersion[0] <= 10) {
|
||||
if (+serverVersion[1] < 10) {
|
||||
Alert.alert(
|
||||
t("login.too_old_server_text"),
|
||||
t("login.too_old_server_description"),
|
||||
);
|
||||
throw new Error("Server too old");
|
||||
}
|
||||
}
|
||||
setServerName(data.ServerName || "");
|
||||
return `${protocol}://${baseUrl}`;
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message === "Server too old") {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
/**
|
||||
* Handles the connection attempt to a Jellyfin server.
|
||||
*
|
||||
@@ -197,17 +172,17 @@ const Login: React.FC = () => {
|
||||
*/
|
||||
const handleConnect = useCallback(async (url: string) => {
|
||||
url = url.trim().replace(/\/$/, "");
|
||||
try {
|
||||
const result = await checkUrl(url);
|
||||
if (result === undefined) {
|
||||
Alert.alert(
|
||||
t("login.connection_failed"),
|
||||
t("login.could_not_connect_to_server"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await setServer({ address: result });
|
||||
} catch {}
|
||||
const result = await checkUrl(url);
|
||||
|
||||
if (result === undefined) {
|
||||
Alert.alert(
|
||||
t("login.connection_failed"),
|
||||
t("login.could_not_connect_to_server"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await setServer({ address: url });
|
||||
}, []);
|
||||
|
||||
const handleQuickConnect = async () => {
|
||||
@@ -232,143 +207,7 @@ const Login: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
return Platform.isTV ? (
|
||||
// TV layout
|
||||
<SafeAreaView className='flex-1 bg-black'>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
{api?.basePath ? (
|
||||
// ------------ Username/Password view ------------
|
||||
<View className='flex-1 items-center justify-center'>
|
||||
{/* Safe centered column with max width so TV doesn’t stretch too far */}
|
||||
<View className='w-[92%] max-w-[900px] px-2 -mt-12'>
|
||||
<Text className='text-3xl font-bold text-white mb-1'>
|
||||
{serverName ? (
|
||||
<>
|
||||
{`${t("login.login_to_title")} `}
|
||||
<Text className='text-purple-500'>{serverName}</Text>
|
||||
</>
|
||||
) : (
|
||||
t("login.login_title")
|
||||
)}
|
||||
</Text>
|
||||
<Text className='text-xs text-neutral-400 mb-6'>
|
||||
{api.basePath}
|
||||
</Text>
|
||||
|
||||
{/* Username */}
|
||||
<Input
|
||||
placeholder={t("login.username_placeholder")}
|
||||
onChangeText={(text: string) =>
|
||||
setCredentials({ ...credentials, username: text })
|
||||
}
|
||||
value={credentials.username}
|
||||
keyboardType='default'
|
||||
returnKeyType='done'
|
||||
autoCapitalize='none'
|
||||
textContentType='oneTimeCode'
|
||||
clearButtonMode='while-editing'
|
||||
maxLength={500}
|
||||
extraClassName='mb-4'
|
||||
/>
|
||||
|
||||
{/* Password */}
|
||||
<Input
|
||||
placeholder={t("login.password_placeholder")}
|
||||
onChangeText={(text: string) =>
|
||||
setCredentials({ ...credentials, password: text })
|
||||
}
|
||||
value={credentials.password}
|
||||
secureTextEntry
|
||||
keyboardType='default'
|
||||
returnKeyType='done'
|
||||
autoCapitalize='none'
|
||||
textContentType='password'
|
||||
clearButtonMode='while-editing'
|
||||
maxLength={500}
|
||||
extraClassName='mb-4'
|
||||
/>
|
||||
|
||||
<View className='mt-4'>
|
||||
<Button onPress={handleLogin}>{t("login.login_button")}</Button>
|
||||
</View>
|
||||
<View className='mt-3'>
|
||||
<Button
|
||||
onPress={handleQuickConnect}
|
||||
className='bg-neutral-800 border border-neutral-700'
|
||||
>
|
||||
{t("login.quick_connect")}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
// ------------ Server connect view ------------
|
||||
<View className='flex-1 items-center justify-center'>
|
||||
<View className='w-[92%] max-w-[900px] -mt-2'>
|
||||
<View className='items-center mb-1'>
|
||||
<Image
|
||||
source={require("@/assets/images/icon-ios-plain.png")}
|
||||
style={{ width: 110, height: 110 }}
|
||||
contentFit='contain'
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Text className='text-white text-4xl font-bold text-center'>
|
||||
Streamyfin
|
||||
</Text>
|
||||
<Text className='text-neutral-400 text-base text-left mt-2 mb-1'>
|
||||
{t("server.enter_url_to_jellyfin_server")}
|
||||
</Text>
|
||||
|
||||
{/* Full-width Input with clear focus ring */}
|
||||
<Input
|
||||
aria-label='Server URL'
|
||||
placeholder={t("server.server_url_placeholder")}
|
||||
onChangeText={setServerURL}
|
||||
value={serverURL}
|
||||
keyboardType='url'
|
||||
returnKeyType='done'
|
||||
autoCapitalize='none'
|
||||
textContentType='URL'
|
||||
maxLength={500}
|
||||
/>
|
||||
|
||||
{/* Full-width primary button */}
|
||||
<View className='mt-4'>
|
||||
<Button
|
||||
onPress={async () => {
|
||||
await handleConnect(serverURL);
|
||||
}}
|
||||
>
|
||||
{t("server.connect_button")}
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
{/* Lists stay full width but inside max width container */}
|
||||
<View className='mt-2'>
|
||||
<JellyfinServerDiscovery
|
||||
onServerSelect={async (server: any) => {
|
||||
setServerURL(server.address);
|
||||
if (server.serverName) setServerName(server.serverName);
|
||||
await handleConnect(server.address);
|
||||
}}
|
||||
/>
|
||||
<PreviousServersList
|
||||
onServerSelect={async (s: any) => {
|
||||
await handleConnect(s.address);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
) : (
|
||||
// Mobile layout
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, paddingBottom: 16 }}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
@@ -452,7 +291,7 @@ const Login: React.FC = () => {
|
||||
marginLeft: -23,
|
||||
marginBottom: -20,
|
||||
}}
|
||||
source={require("@/assets/images/icon-ios-plain.png")}
|
||||
source={require("@/assets/images/StreamyFinFinal.png")}
|
||||
/>
|
||||
<Text className='text-3xl font-bold'>Streamyfin</Text>
|
||||
<Text className='text-neutral-500'>
|
||||
|
||||
@@ -115,4 +115,4 @@
|
||||
<path id="path259-2-6-4-6-7-0-1-0-5-9-4-7-1-5-7-6-2" class="cls-11" d="M46.97,39.46c5.94,0,10.75,4.81,10.75,10.75s-4.81,10.75-10.75,10.75-10.75-4.81-10.75-10.75c0-1.1.16-2.16.47-3.17.84,1.87,2.72,3.17,4.9,3.17,2.97,0,5.37-2.41,5.37-5.37,0-2.18-1.3-4.06-3.17-4.9,1-.31,2.06-.47,3.17-.47h.01Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
BIN
assets/images/StreamyFinFinal.png
Normal file
|
After Width: | Height: | Size: 231 KiB |
BIN
assets/images/adaptive_icon.png
Normal file
|
After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 28 KiB |
BIN
assets/images/icon-mono.png
Normal file
|
After Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 326 KiB After Width: | Height: | Size: 326 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 49 KiB |
@@ -7,27 +7,15 @@ declare module "react-native-mmkv" {
|
||||
}
|
||||
}
|
||||
|
||||
// Add the augmentation methods directly to the MMKV prototype
|
||||
// This follows the recommended pattern while adding the helper methods your app uses
|
||||
MMKV.prototype.get = function <T>(key: string): T | undefined {
|
||||
try {
|
||||
const serializedItem = this.getString(key);
|
||||
if (!serializedItem) return undefined;
|
||||
return JSON.parse(serializedItem);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to parse MMKV value for key "${key}":`, error);
|
||||
return undefined;
|
||||
}
|
||||
const serializedItem = this.getString(key);
|
||||
return serializedItem ? JSON.parse(serializedItem) : undefined;
|
||||
};
|
||||
|
||||
MMKV.prototype.setAny = function (key: string, value: any | undefined): void {
|
||||
try {
|
||||
if (value === undefined) {
|
||||
this.delete(key);
|
||||
} else {
|
||||
this.set(key, JSON.stringify(value));
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to set MMKV value for key "${key}":`, error);
|
||||
if (value === undefined) {
|
||||
this.delete(key);
|
||||
} else {
|
||||
this.set(key, JSON.stringify(value));
|
||||
}
|
||||
};
|
||||
|
||||
18
biome.json
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.2.5/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.0.5/schema.json",
|
||||
"files": {
|
||||
"includes": [
|
||||
"**/*",
|
||||
"!node_modules",
|
||||
"!ios",
|
||||
"!android",
|
||||
"!Streamyfin.app",
|
||||
"!utils/jellyseerr",
|
||||
"!.expo"
|
||||
"!node_modules/**",
|
||||
"!ios/**",
|
||||
"!android/**",
|
||||
"!Streamyfin.app/**",
|
||||
"!utils/jellyseerr/**",
|
||||
"!.expo/**"
|
||||
]
|
||||
},
|
||||
"linter": {
|
||||
@@ -24,9 +24,7 @@
|
||||
"noForEach": "off"
|
||||
},
|
||||
"recommended": true,
|
||||
"correctness": {
|
||||
"useExhaustiveDependencies": "off"
|
||||
},
|
||||
"correctness": { "useExhaustiveDependencies": "off" },
|
||||
"suspicious": {
|
||||
"noExplicitAny": "off",
|
||||
"noArrayIndexKey": "off"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import type { FC } from "react";
|
||||
import { Platform, View, type ViewProps } from "react-native";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { RoundButton } from "@/components/RoundButton";
|
||||
import { useFavorite } from "@/hooks/useFavorite";
|
||||
|
||||
@@ -11,18 +11,6 @@ interface Props extends ViewProps {
|
||||
export const AddToFavorites: FC<Props> = ({ item, ...props }) => {
|
||||
const { isFavorite, toggleFavorite } = useFavorite(item);
|
||||
|
||||
if (Platform.OS === "ios") {
|
||||
return (
|
||||
<View {...props}>
|
||||
<RoundButton
|
||||
size='large'
|
||||
icon={isFavorite ? "heart" : "heart-outline"}
|
||||
onPress={toggleFavorite}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<RoundButton
|
||||
|
||||
@@ -1,776 +0,0 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import {
|
||||
getItemsApi,
|
||||
getTvShowsApi,
|
||||
getUserLibraryApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Dimensions, Pressable, TouchableOpacity, View } from "react-native";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import Animated, {
|
||||
Easing,
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
|
||||
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import { ItemImage } from "./common/ItemImage";
|
||||
import { getItemNavigation } from "./common/TouchableItemRouter";
|
||||
import type { SelectedOptions } from "./ItemContent";
|
||||
import { PlayButton } from "./PlayButton";
|
||||
import { PlayedStatus } from "./PlayedStatus";
|
||||
|
||||
interface AppleTVCarouselProps {
|
||||
initialIndex?: number;
|
||||
onItemChange?: (index: number) => void;
|
||||
}
|
||||
|
||||
const { width: screenWidth, height: screenHeight } = Dimensions.get("window");
|
||||
|
||||
// Layout Constants
|
||||
const CAROUSEL_HEIGHT = screenHeight / 1.45;
|
||||
const GRADIENT_HEIGHT_TOP = 150;
|
||||
const GRADIENT_HEIGHT_BOTTOM = 150;
|
||||
const LOGO_HEIGHT = 80;
|
||||
|
||||
// Position Constants
|
||||
const LOGO_BOTTOM_POSITION = 210;
|
||||
const GENRES_BOTTOM_POSITION = 170;
|
||||
const CONTROLS_BOTTOM_POSITION = 100;
|
||||
const DOTS_BOTTOM_POSITION = 60;
|
||||
|
||||
// Size Constants
|
||||
const DOT_HEIGHT = 6;
|
||||
const DOT_ACTIVE_WIDTH = 20;
|
||||
const DOT_INACTIVE_WIDTH = 12;
|
||||
const PLAY_BUTTON_SKELETON_HEIGHT = 50;
|
||||
const PLAYED_STATUS_SKELETON_SIZE = 40;
|
||||
const TEXT_SKELETON_HEIGHT = 20;
|
||||
const TEXT_SKELETON_WIDTH = 250;
|
||||
const _EMPTY_STATE_ICON_SIZE = 64;
|
||||
|
||||
// Spacing Constants
|
||||
const HORIZONTAL_PADDING = 40;
|
||||
const DOT_PADDING = 2;
|
||||
const DOT_GAP = 4;
|
||||
const CONTROLS_GAP = 20;
|
||||
const _TEXT_MARGIN_TOP = 16;
|
||||
|
||||
// Border Radius Constants
|
||||
const DOT_BORDER_RADIUS = 3;
|
||||
const LOGO_SKELETON_BORDER_RADIUS = 8;
|
||||
const TEXT_SKELETON_BORDER_RADIUS = 4;
|
||||
const PLAY_BUTTON_BORDER_RADIUS = 25;
|
||||
const PLAYED_STATUS_BORDER_RADIUS = 20;
|
||||
|
||||
// Animation Constants
|
||||
const DOT_ANIMATION_DURATION = 300;
|
||||
const CAROUSEL_TRANSITION_DURATION = 250;
|
||||
const PAN_ACTIVE_OFFSET = 10;
|
||||
const TRANSLATION_THRESHOLD = 0.2;
|
||||
const VELOCITY_THRESHOLD = 400;
|
||||
|
||||
// Text Constants
|
||||
const GENRES_FONT_SIZE = 16;
|
||||
const _EMPTY_STATE_FONT_SIZE = 18;
|
||||
const TEXT_SHADOW_RADIUS = 2;
|
||||
const MAX_GENRES_COUNT = 2;
|
||||
const MAX_BUTTON_WIDTH = 300;
|
||||
|
||||
// Opacity Constants
|
||||
const OVERLAY_OPACITY = 0.4;
|
||||
const DOT_INACTIVE_OPACITY = 0.6;
|
||||
const TEXT_OPACITY = 0.9;
|
||||
|
||||
// Color Constants
|
||||
const SKELETON_BACKGROUND_COLOR = "#1a1a1a";
|
||||
const SKELETON_ELEMENT_COLOR = "#333";
|
||||
const SKELETON_ACTIVE_DOT_COLOR = "#666";
|
||||
const _EMPTY_STATE_COLOR = "#666";
|
||||
const TEXT_SHADOW_COLOR = "rgba(0, 0, 0, 0.8)";
|
||||
const LOGO_WIDTH_PERCENTAGE = "80%";
|
||||
|
||||
const DotIndicator = ({
|
||||
index,
|
||||
currentIndex,
|
||||
onPress,
|
||||
}: {
|
||||
index: number;
|
||||
currentIndex: number;
|
||||
onPress: (index: number) => void;
|
||||
}) => {
|
||||
const isActive = index === currentIndex;
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
width: withTiming(isActive ? DOT_ACTIVE_WIDTH : DOT_INACTIVE_WIDTH, {
|
||||
duration: DOT_ANIMATION_DURATION,
|
||||
easing: Easing.out(Easing.quad),
|
||||
}),
|
||||
opacity: withTiming(isActive ? 1 : DOT_INACTIVE_OPACITY, {
|
||||
duration: DOT_ANIMATION_DURATION,
|
||||
easing: Easing.out(Easing.quad),
|
||||
}),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => onPress(index)}
|
||||
style={{
|
||||
padding: DOT_PADDING, // Increase touch area
|
||||
}}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
height: DOT_HEIGHT,
|
||||
backgroundColor: isActive ? "white" : "rgba(255, 255, 255, 0.4)",
|
||||
borderRadius: DOT_BORDER_RADIUS,
|
||||
},
|
||||
animatedStyle,
|
||||
]}
|
||||
/>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
||||
initialIndex = 0,
|
||||
onItemChange,
|
||||
}) => {
|
||||
const { settings } = useSettings();
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const { isConnected, serverConnected } = useNetworkStatus();
|
||||
const router = useRouter();
|
||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||
const translateX = useSharedValue(-currentIndex * screenWidth);
|
||||
|
||||
const isQueryEnabled =
|
||||
!!api && !!user?.Id && isConnected && serverConnected === true;
|
||||
|
||||
const { data: continueWatchingData, isLoading: continueWatchingLoading } =
|
||||
useQuery({
|
||||
queryKey: ["appleTVCarousel", "continueWatching", user?.Id],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id) return [];
|
||||
const response = await getItemsApi(api).getResumeItems({
|
||||
userId: user.Id,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||
fields: ["Genres"],
|
||||
limit: 2,
|
||||
});
|
||||
return response.data.Items || [];
|
||||
},
|
||||
enabled: isQueryEnabled,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
const { data: nextUpData, isLoading: nextUpLoading } = useQuery({
|
||||
queryKey: ["appleTVCarousel", "nextUp", user?.Id],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id) return [];
|
||||
const response = await getTvShowsApi(api).getNextUp({
|
||||
userId: user.Id,
|
||||
fields: ["MediaSourceCount", "Genres"],
|
||||
limit: 2,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||
enableResumable: false,
|
||||
});
|
||||
return response.data.Items || [];
|
||||
},
|
||||
enabled: isQueryEnabled,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
const { data: recentlyAddedData, isLoading: recentlyAddedLoading } = useQuery(
|
||||
{
|
||||
queryKey: ["appleTVCarousel", "recentlyAdded", user?.Id],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id) return [];
|
||||
const response = await getUserLibraryApi(api).getLatestMedia({
|
||||
userId: user.Id,
|
||||
limit: 2,
|
||||
fields: ["PrimaryImageAspectRatio", "Path", "Genres"],
|
||||
imageTypeLimit: 1,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||
});
|
||||
return response.data || [];
|
||||
},
|
||||
enabled: isQueryEnabled,
|
||||
staleTime: 60 * 1000,
|
||||
},
|
||||
);
|
||||
|
||||
const items = useMemo(() => {
|
||||
const continueItems = continueWatchingData ?? [];
|
||||
const nextItems = nextUpData ?? [];
|
||||
const recentItems = recentlyAddedData ?? [];
|
||||
|
||||
return [
|
||||
...continueItems.slice(0, 2),
|
||||
...nextItems.slice(0, 2),
|
||||
...recentItems.slice(0, 2),
|
||||
];
|
||||
}, [continueWatchingData, nextUpData, recentlyAddedData]);
|
||||
|
||||
const isLoading =
|
||||
continueWatchingLoading || nextUpLoading || recentlyAddedLoading;
|
||||
const hasItems = items.length > 0;
|
||||
|
||||
// Only get play settings if we have valid items
|
||||
const currentItem = hasItems ? items[currentIndex] : null;
|
||||
|
||||
// Extract colors for the current item only (for performance)
|
||||
const currentItemColors = useImageColorsReturn({ item: currentItem });
|
||||
|
||||
// Create a fallback empty item for useDefaultPlaySettings when no item is available
|
||||
const itemForPlaySettings = currentItem || { MediaSources: [] };
|
||||
const {
|
||||
defaultAudioIndex,
|
||||
defaultBitrate,
|
||||
defaultMediaSource,
|
||||
defaultSubtitleIndex,
|
||||
} = useDefaultPlaySettings(itemForPlaySettings as BaseItemDto, settings);
|
||||
|
||||
const [selectedOptions, setSelectedOptions] = useState<
|
||||
SelectedOptions | undefined
|
||||
>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
// Only set options if we have valid current item
|
||||
if (currentItem) {
|
||||
setSelectedOptions({
|
||||
bitrate: defaultBitrate,
|
||||
mediaSource: defaultMediaSource,
|
||||
subtitleIndex: defaultSubtitleIndex ?? -1,
|
||||
audioIndex: defaultAudioIndex,
|
||||
});
|
||||
} else {
|
||||
setSelectedOptions(undefined);
|
||||
}
|
||||
}, [
|
||||
defaultAudioIndex,
|
||||
defaultBitrate,
|
||||
defaultSubtitleIndex,
|
||||
defaultMediaSource,
|
||||
currentIndex,
|
||||
currentItem,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasItems) {
|
||||
setCurrentIndex(initialIndex);
|
||||
translateX.value = -initialIndex * screenWidth;
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentIndex((prev) => {
|
||||
const newIndex = Math.min(prev, items.length - 1);
|
||||
translateX.value = -newIndex * screenWidth;
|
||||
return newIndex;
|
||||
});
|
||||
}, [hasItems, items, initialIndex, translateX]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasItems) {
|
||||
onItemChange?.(currentIndex);
|
||||
}
|
||||
}, [hasItems, currentIndex, onItemChange]);
|
||||
|
||||
const goToIndex = useCallback(
|
||||
(index: number) => {
|
||||
if (!hasItems || index < 0 || index >= items.length) return;
|
||||
|
||||
translateX.value = withTiming(-index * screenWidth, {
|
||||
duration: CAROUSEL_TRANSITION_DURATION, // Slightly longer for smoother feel
|
||||
easing: Easing.bezier(0.25, 0.46, 0.45, 0.94), // iOS-like smooth deceleration curve
|
||||
});
|
||||
|
||||
setCurrentIndex(index);
|
||||
onItemChange?.(index);
|
||||
},
|
||||
[hasItems, items, onItemChange, translateX],
|
||||
);
|
||||
|
||||
const navigateToItem = useCallback(
|
||||
(item: BaseItemDto) => {
|
||||
const navigation = getItemNavigation(item, "(home)");
|
||||
router.push(navigation as any);
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
const panGesture = Gesture.Pan()
|
||||
.activeOffsetX([-PAN_ACTIVE_OFFSET, PAN_ACTIVE_OFFSET])
|
||||
.onUpdate((event) => {
|
||||
translateX.value = -currentIndex * screenWidth + event.translationX;
|
||||
})
|
||||
.onEnd((event) => {
|
||||
const velocity = event.velocityX;
|
||||
const translation = event.translationX;
|
||||
|
||||
let newIndex = currentIndex;
|
||||
|
||||
// Improved thresholds for more responsive navigation
|
||||
if (
|
||||
Math.abs(translation) > screenWidth * TRANSLATION_THRESHOLD ||
|
||||
Math.abs(velocity) > VELOCITY_THRESHOLD
|
||||
) {
|
||||
if (translation > 0 && currentIndex > 0) {
|
||||
newIndex = currentIndex - 1;
|
||||
} else if (
|
||||
translation < 0 &&
|
||||
items &&
|
||||
currentIndex < items.length - 1
|
||||
) {
|
||||
newIndex = currentIndex + 1;
|
||||
}
|
||||
}
|
||||
|
||||
runOnJS(goToIndex)(newIndex);
|
||||
});
|
||||
|
||||
const containerAnimatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [{ translateX: translateX.value }],
|
||||
};
|
||||
});
|
||||
|
||||
const renderDots = () => {
|
||||
if (!hasItems || items.length <= 1) return null;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: DOTS_BOTTOM_POSITION,
|
||||
left: 0,
|
||||
right: 0,
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: DOT_GAP,
|
||||
}}
|
||||
>
|
||||
{items.map((_, index) => (
|
||||
<DotIndicator
|
||||
key={index}
|
||||
index={index}
|
||||
currentIndex={currentIndex}
|
||||
onPress={goToIndex}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSkeletonLoader = () => {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
width: screenWidth,
|
||||
height: CAROUSEL_HEIGHT,
|
||||
backgroundColor: "#000",
|
||||
}}
|
||||
>
|
||||
{/* Background Skeleton */}
|
||||
<View
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
backgroundColor: SKELETON_BACKGROUND_COLOR,
|
||||
position: "absolute",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Dark Overlay Skeleton */}
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: `rgba(0, 0, 0, ${OVERLAY_OPACITY})`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Gradient Fade to Black Top Skeleton */}
|
||||
<LinearGradient
|
||||
colors={["rgba(0,0,0,1)", "rgba(0,0,0,0.8)", "transparent"]}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
height: GRADIENT_HEIGHT_TOP,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Gradient Fade to Black Bottom Skeleton */}
|
||||
<LinearGradient
|
||||
colors={["transparent", "rgba(0,0,0,0.8)", "rgba(0,0,0,1)"]}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: GRADIENT_HEIGHT_BOTTOM,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Logo Skeleton */}
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: LOGO_BOTTOM_POSITION,
|
||||
left: 0,
|
||||
right: 0,
|
||||
paddingHorizontal: HORIZONTAL_PADDING,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
height: LOGO_HEIGHT,
|
||||
width: LOGO_WIDTH_PERCENTAGE,
|
||||
backgroundColor: SKELETON_ELEMENT_COLOR,
|
||||
borderRadius: LOGO_SKELETON_BORDER_RADIUS,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Type and Genres Skeleton */}
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: GENRES_BOTTOM_POSITION,
|
||||
left: 0,
|
||||
right: 0,
|
||||
paddingHorizontal: HORIZONTAL_PADDING,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
height: TEXT_SKELETON_HEIGHT,
|
||||
width: TEXT_SKELETON_WIDTH,
|
||||
backgroundColor: SKELETON_ELEMENT_COLOR,
|
||||
borderRadius: TEXT_SKELETON_BORDER_RADIUS,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Controls Skeleton */}
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: CONTROLS_BOTTOM_POSITION,
|
||||
left: 0,
|
||||
right: 0,
|
||||
paddingHorizontal: HORIZONTAL_PADDING,
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: CONTROLS_GAP,
|
||||
}}
|
||||
>
|
||||
{/* Play Button Skeleton */}
|
||||
<View
|
||||
style={{
|
||||
height: PLAY_BUTTON_SKELETON_HEIGHT,
|
||||
flex: 1,
|
||||
maxWidth: MAX_BUTTON_WIDTH,
|
||||
backgroundColor: SKELETON_ELEMENT_COLOR,
|
||||
borderRadius: PLAY_BUTTON_BORDER_RADIUS,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Played Status Skeleton */}
|
||||
<View
|
||||
style={{
|
||||
width: PLAYED_STATUS_SKELETON_SIZE,
|
||||
height: PLAYED_STATUS_SKELETON_SIZE,
|
||||
backgroundColor: SKELETON_ELEMENT_COLOR,
|
||||
borderRadius: PLAYED_STATUS_BORDER_RADIUS,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Dots Skeleton */}
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: DOTS_BOTTOM_POSITION,
|
||||
left: 0,
|
||||
right: 0,
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: DOT_GAP,
|
||||
}}
|
||||
>
|
||||
{[1, 2, 3].map((_, index) => (
|
||||
<View
|
||||
key={index}
|
||||
style={{
|
||||
width: index === 0 ? DOT_ACTIVE_WIDTH : DOT_INACTIVE_WIDTH,
|
||||
height: DOT_HEIGHT,
|
||||
backgroundColor:
|
||||
index === 0
|
||||
? SKELETON_ACTIVE_DOT_COLOR
|
||||
: SKELETON_ELEMENT_COLOR,
|
||||
borderRadius: DOT_BORDER_RADIUS,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const renderItem = (item: BaseItemDto, _index: number) => {
|
||||
const itemLogoUrl = api ? getLogoImageUrlById({ api, item }) : null;
|
||||
|
||||
return (
|
||||
<View
|
||||
key={item.Id}
|
||||
style={{
|
||||
width: screenWidth,
|
||||
height: CAROUSEL_HEIGHT,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{/* Background Backdrop */}
|
||||
<ItemImage
|
||||
item={item}
|
||||
variant='Backdrop'
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
position: "absolute",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Dark Overlay */}
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: `rgba(0, 0, 0, ${OVERLAY_OPACITY})`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Gradient Fade to Black at Top */}
|
||||
<LinearGradient
|
||||
colors={["rgba(0,0,0,1)", "rgba(0,0,0,0.2)", "transparent"]}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
height: GRADIENT_HEIGHT_TOP,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Gradient Fade to Black at Bottom */}
|
||||
<LinearGradient
|
||||
colors={["transparent", "rgba(0,0,0,0.8)", "rgba(0,0,0,1)"]}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: GRADIENT_HEIGHT_BOTTOM,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Logo Section */}
|
||||
{itemLogoUrl && (
|
||||
<TouchableOpacity
|
||||
onPress={() => navigateToItem(item)}
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: LOGO_BOTTOM_POSITION,
|
||||
left: 0,
|
||||
right: 0,
|
||||
paddingHorizontal: HORIZONTAL_PADDING,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
source={{
|
||||
uri: itemLogoUrl,
|
||||
}}
|
||||
style={{
|
||||
height: LOGO_HEIGHT,
|
||||
width: LOGO_WIDTH_PERCENTAGE,
|
||||
}}
|
||||
contentFit='contain'
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* Type and Genres Section */}
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: GENRES_BOTTOM_POSITION,
|
||||
left: 0,
|
||||
right: 0,
|
||||
paddingHorizontal: HORIZONTAL_PADDING,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<TouchableOpacity onPress={() => navigateToItem(item)}>
|
||||
<Animated.Text
|
||||
style={{
|
||||
color: `rgba(255, 255, 255, ${TEXT_OPACITY})`,
|
||||
fontSize: GENRES_FONT_SIZE,
|
||||
fontWeight: "500",
|
||||
textAlign: "center",
|
||||
textShadowColor: TEXT_SHADOW_COLOR,
|
||||
textShadowOffset: { width: 0, height: 1 },
|
||||
textShadowRadius: TEXT_SHADOW_RADIUS,
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
let typeLabel = "";
|
||||
|
||||
if (item.Type === "Episode") {
|
||||
// For episodes, show season and episode number
|
||||
const season = item.ParentIndexNumber;
|
||||
const episode = item.IndexNumber;
|
||||
if (season && episode) {
|
||||
typeLabel = `S${season} • E${episode}`;
|
||||
} else {
|
||||
typeLabel = "Episode";
|
||||
}
|
||||
} else {
|
||||
typeLabel =
|
||||
item.Type === "Series"
|
||||
? "TV Show"
|
||||
: item.Type === "Movie"
|
||||
? "Movie"
|
||||
: item.Type || "";
|
||||
}
|
||||
|
||||
const genres =
|
||||
item.Genres && item.Genres.length > 0
|
||||
? item.Genres.slice(0, MAX_GENRES_COUNT).join(" • ")
|
||||
: "";
|
||||
|
||||
if (typeLabel && genres) {
|
||||
return `${typeLabel} • ${genres}`;
|
||||
} else if (typeLabel) {
|
||||
return typeLabel;
|
||||
} else if (genres) {
|
||||
return genres;
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
})()}
|
||||
</Animated.Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Controls Section */}
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: CONTROLS_BOTTOM_POSITION,
|
||||
left: 0,
|
||||
right: 0,
|
||||
paddingHorizontal: HORIZONTAL_PADDING,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: CONTROLS_GAP,
|
||||
}}
|
||||
>
|
||||
{/* Play Button */}
|
||||
<View style={{ flex: 1, maxWidth: MAX_BUTTON_WIDTH }}>
|
||||
{selectedOptions && (
|
||||
<PlayButton
|
||||
item={item}
|
||||
selectedOptions={selectedOptions}
|
||||
colors={currentItemColors}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Mark as Played */}
|
||||
<PlayedStatus items={[item]} size='large' />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// Handle loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
height: CAROUSEL_HEIGHT,
|
||||
backgroundColor: "#000",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{renderSkeletonLoader()}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle empty items
|
||||
if (!hasItems) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
height: CAROUSEL_HEIGHT, // Fixed height instead of flex: 1
|
||||
backgroundColor: "#000",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<GestureDetector gesture={panGesture}>
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
height: CAROUSEL_HEIGHT, // Fixed height instead of flex: 1
|
||||
flexDirection: "row",
|
||||
width: screenWidth * items.length,
|
||||
},
|
||||
containerAnimatedStyle,
|
||||
]}
|
||||
>
|
||||
{items.map((item, index) => renderItem(item, index))}
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
|
||||
{/* Animated Dots Indicator */}
|
||||
{renderDots()}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useMemo } from "react";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
|
||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||
const DropdownMenu = require("zeego/dropdown-menu");
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text } from "./common/Text";
|
||||
@@ -19,8 +19,6 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
||||
selected,
|
||||
...props
|
||||
}) => {
|
||||
const isTv = Platform.isTV;
|
||||
|
||||
const audioStreams = useMemo(
|
||||
() => source?.MediaStreams?.filter((x) => x.Type === "Audio"),
|
||||
[source],
|
||||
@@ -33,8 +31,6 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (isTv) return null;
|
||||
|
||||
return (
|
||||
<View
|
||||
className='flex shrink'
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { Text } from "./common/Text";
|
||||
import { FilterSheet } from "./filters/FilterSheet";
|
||||
|
||||
export type Bitrate = {
|
||||
key: string;
|
||||
value: number | undefined;
|
||||
};
|
||||
|
||||
export const BITRATES: Bitrate[] = [
|
||||
{
|
||||
key: "Max",
|
||||
value: undefined,
|
||||
},
|
||||
{
|
||||
key: "8 Mb/s",
|
||||
value: 8000000,
|
||||
height: 1080,
|
||||
},
|
||||
{
|
||||
key: "4 Mb/s",
|
||||
value: 4000000,
|
||||
height: 1080,
|
||||
},
|
||||
{
|
||||
key: "2 Mb/s",
|
||||
value: 2000000,
|
||||
},
|
||||
{
|
||||
key: "1 Mb/s",
|
||||
value: 1000000,
|
||||
},
|
||||
{
|
||||
key: "500 Kb/s",
|
||||
value: 500000,
|
||||
},
|
||||
{
|
||||
key: "250 Kb/s",
|
||||
value: 250000,
|
||||
},
|
||||
].sort(
|
||||
(a, b) =>
|
||||
(b.value || Number.POSITIVE_INFINITY) -
|
||||
(a.value || Number.POSITIVE_INFINITY),
|
||||
);
|
||||
|
||||
interface Props extends React.ComponentProps<typeof View> {
|
||||
onChange: (value: Bitrate) => void;
|
||||
selected?: Bitrate | null;
|
||||
inverted?: boolean | null;
|
||||
}
|
||||
|
||||
export const BitrateSheet: React.FC<Props> = ({
|
||||
onChange,
|
||||
selected,
|
||||
inverted,
|
||||
...props
|
||||
}) => {
|
||||
const isTv = Platform.isTV;
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
if (inverted)
|
||||
return BITRATES.slice().sort(
|
||||
(a, b) =>
|
||||
(a.value || Number.POSITIVE_INFINITY) -
|
||||
(b.value || Number.POSITIVE_INFINITY),
|
||||
);
|
||||
return BITRATES.slice().sort(
|
||||
(a, b) =>
|
||||
(b.value || Number.POSITIVE_INFINITY) -
|
||||
(a.value || Number.POSITIVE_INFINITY),
|
||||
);
|
||||
}, [inverted]);
|
||||
|
||||
if (isTv) return null;
|
||||
|
||||
return (
|
||||
<View
|
||||
className='flex shrink'
|
||||
style={{
|
||||
minWidth: 60,
|
||||
maxWidth: 200,
|
||||
}}
|
||||
>
|
||||
<View className='flex flex-col' {...props}>
|
||||
<Text className='opacity-50 mb-1 text-xs'>
|
||||
{t("item_card.quality")}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'
|
||||
onPress={() => setOpen(true)}
|
||||
>
|
||||
<Text numberOfLines={1}>
|
||||
{BITRATES.find((b) => b.value === selected?.value)?.key}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<FilterSheet
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
title={t("item_card.quality")}
|
||||
data={sorted}
|
||||
values={selected ? [selected] : []}
|
||||
multiple={false}
|
||||
searchFilter={(item, query) => {
|
||||
const label = (item as any).key || "";
|
||||
return label.toLowerCase().includes(query.toLowerCase());
|
||||
}}
|
||||
renderItemLabel={(item) => <Text>{(item as any).key || ""}</Text>}
|
||||
set={(vals) => {
|
||||
const chosen = vals[0] as Bitrate | undefined;
|
||||
if (chosen) onChange(chosen);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
|
||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||
const DropdownMenu = require("zeego/dropdown-menu");
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -60,26 +60,22 @@ export const BitrateSelector: React.FC<Props> = ({
|
||||
inverted,
|
||||
...props
|
||||
}) => {
|
||||
const isTv = Platform.isTV;
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
if (inverted)
|
||||
return BITRATES.slice().sort(
|
||||
return BITRATES.sort(
|
||||
(a, b) =>
|
||||
(a.value || Number.POSITIVE_INFINITY) -
|
||||
(b.value || Number.POSITIVE_INFINITY),
|
||||
);
|
||||
return BITRATES.slice().sort(
|
||||
return BITRATES.sort(
|
||||
(a, b) =>
|
||||
(b.value || Number.POSITIVE_INFINITY) -
|
||||
(a.value || Number.POSITIVE_INFINITY),
|
||||
);
|
||||
}, [inverted]);
|
||||
}, []);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (isTv) return null;
|
||||
|
||||
return (
|
||||
<View
|
||||
className='flex shrink'
|
||||
|
||||
@@ -1,20 +1,6 @@
|
||||
import type React from "react";
|
||||
import {
|
||||
type PropsWithChildren,
|
||||
type ReactNode,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
Animated,
|
||||
Easing,
|
||||
Platform,
|
||||
Pressable,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { type PropsWithChildren, type ReactNode, useMemo } from "react";
|
||||
import { Text, TouchableOpacity, View } from "react-native";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { Loader } from "./Loader";
|
||||
|
||||
@@ -45,23 +31,10 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
||||
justify = "center",
|
||||
...props
|
||||
}) => {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const scale = useRef(new Animated.Value(1)).current;
|
||||
|
||||
const animateTo = (v: number) =>
|
||||
Animated.timing(scale, {
|
||||
toValue: v,
|
||||
duration: 130,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
|
||||
const colorClasses = useMemo(() => {
|
||||
switch (color) {
|
||||
case "purple":
|
||||
return focused
|
||||
? "bg-purple-500 border-2 border-white"
|
||||
: "bg-purple-600 border border-purple-700";
|
||||
return "bg-purple-600 active:bg-purple-700";
|
||||
case "red":
|
||||
return "bg-red-600";
|
||||
case "black":
|
||||
@@ -69,43 +42,11 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
||||
case "transparent":
|
||||
return "bg-transparent";
|
||||
}
|
||||
}, [color, focused]);
|
||||
}, [color]);
|
||||
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
|
||||
return Platform.isTV ? (
|
||||
<Pressable
|
||||
className='w-full'
|
||||
onPress={onPress}
|
||||
onFocus={() => {
|
||||
setFocused(true);
|
||||
animateTo(1.08);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setFocused(false);
|
||||
animateTo(1);
|
||||
}}
|
||||
>
|
||||
<Animated.View
|
||||
style={{
|
||||
transform: [{ scale }],
|
||||
shadowColor: "#a855f7",
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: focused ? 0.9 : 0,
|
||||
shadowRadius: focused ? 18 : 0,
|
||||
elevation: focused ? 12 : 0, // Android glow
|
||||
}}
|
||||
>
|
||||
<View
|
||||
className={`rounded-2xl py-5 items-center justify-center
|
||||
${focused ? "bg-purple-500 border-2 border-white" : "bg-purple-600 border border-purple-700"}
|
||||
${className}`}
|
||||
>
|
||||
<Text className='text-white text-xl font-bold'>{children}</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
) : (
|
||||
return (
|
||||
<TouchableOpacity
|
||||
className={`
|
||||
p-3 rounded-xl items-center justify-center
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { Platform, TouchableOpacity } from "react-native";
|
||||
import { Platform, type ViewProps } from "react-native";
|
||||
import GoogleCast, {
|
||||
CastButton,
|
||||
CastContext,
|
||||
@@ -11,6 +11,12 @@ import GoogleCast, {
|
||||
} from "react-native-google-cast";
|
||||
import { RoundButton } from "./RoundButton";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
width?: number;
|
||||
height?: number;
|
||||
background?: "blur" | "transparent";
|
||||
}
|
||||
|
||||
export function Chromecast({
|
||||
width = 48,
|
||||
height = 48,
|
||||
@@ -38,26 +44,14 @@ export function Chromecast({
|
||||
// Android requires the cast button to be present for startDiscovery to work
|
||||
const AndroidCastButton = useCallback(
|
||||
() =>
|
||||
Platform.OS === "android" ? <CastButton tintColor='transparent' /> : null,
|
||||
Platform.OS === "android" ? (
|
||||
<CastButton tintColor='transparent' />
|
||||
) : (
|
||||
<></>
|
||||
),
|
||||
[Platform.OS],
|
||||
);
|
||||
|
||||
if (Platform.OS === "ios") {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
className='mr-4'
|
||||
onPress={() => {
|
||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||
else CastContext.showCastDialog();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<AndroidCastButton />
|
||||
<Feather name='cast' size={22} color={"white"} />
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
if (background === "transparent")
|
||||
return (
|
||||
<RoundButton
|
||||
|
||||
1
components/ContextMenu.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "zeego/context-menu";
|
||||
@@ -6,7 +6,6 @@ import type React from "react";
|
||||
import { useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { ProgressBar } from "./common/ProgressBar";
|
||||
import { WatchedIndicator } from "./WatchedIndicator";
|
||||
|
||||
type ContinueWatchingPosterProps = {
|
||||
@@ -63,6 +62,18 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
|
||||
}, [item]);
|
||||
|
||||
const progress = useMemo(() => {
|
||||
if (item.Type === "Program") {
|
||||
const startDate = new Date(item.StartDate || "");
|
||||
const endDate = new Date(item.EndDate || "");
|
||||
const now = new Date();
|
||||
const total = endDate.getTime() - startDate.getTime();
|
||||
const elapsed = now.getTime() - startDate.getTime();
|
||||
return (elapsed / total) * 100;
|
||||
}
|
||||
return item.UserData?.PlayedPercentage || 0;
|
||||
}, [item]);
|
||||
|
||||
if (!url)
|
||||
return <View className='aspect-video border border-neutral-800 w-44' />;
|
||||
|
||||
@@ -90,8 +101,22 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{!item.UserData?.Played && <WatchedIndicator item={item} />}
|
||||
<ProgressBar item={item} />
|
||||
{!progress && <WatchedIndicator item={item} />}
|
||||
{progress > 0 && (
|
||||
<>
|
||||
<View
|
||||
className={
|
||||
"absolute w-100 bottom-0 left-0 h-1 bg-neutral-700 opacity-80 w-full"
|
||||
}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
width: `${progress}%`,
|
||||
}}
|
||||
className={"absolute bottom-0 left-0 h-1 bg-purple-600 w-full"}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,20 +9,21 @@ import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { type Href, router } from "expo-router";
|
||||
import { type Href, router, useFocusEffect } from "expo-router";
|
||||
import { t } from "i18next";
|
||||
import { useAtom } from "jotai";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Alert, Platform, Switch, View, type ViewProps } from "react-native";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { Alert, Platform, View, type ViewProps } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { queueAtom } from "@/utils/atoms/queue";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
import { getDownloadUrl } from "@/utils/jellyfin/media/getDownloadUrl";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server";
|
||||
import download from "@/utils/profiles/download";
|
||||
import { AudioTrackSelector } from "./AudioTrackSelector";
|
||||
import { type Bitrate, BitrateSelector } from "./BitrateSelector";
|
||||
import { Button } from "./Button";
|
||||
@@ -33,13 +34,6 @@ import ProgressCircle from "./ProgressCircle";
|
||||
import { RoundButton } from "./RoundButton";
|
||||
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
|
||||
|
||||
export type SelectedOptions = {
|
||||
bitrate: Bitrate;
|
||||
mediaSource: MediaSourceInfo | undefined;
|
||||
audioIndex: number | undefined;
|
||||
subtitleIndex: number;
|
||||
};
|
||||
|
||||
interface DownloadProps extends ViewProps {
|
||||
items: BaseItemDto[];
|
||||
MissingDownloadIconComponent: () => React.ReactElement;
|
||||
@@ -60,29 +54,33 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
}) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const [queue, _setQueue] = useAtom(queueAtom);
|
||||
const { settings } = useSettings();
|
||||
const [downloadUnwatchedOnly, setDownloadUnwatchedOnly] = useState(false);
|
||||
const [queue, setQueue] = useAtom(queueAtom);
|
||||
const [settings] = useSettings();
|
||||
|
||||
const { processes, startBackgroundDownload, getDownloadedItems } =
|
||||
useDownload();
|
||||
const downloadedFiles = getDownloadedItems();
|
||||
const { processes, startBackgroundDownload, downloadedFiles } = useDownload();
|
||||
//const { startRemuxing } = useRemuxHlsToMp4();
|
||||
|
||||
const [selectedOptions, setSelectedOptions] = useState<
|
||||
SelectedOptions | undefined
|
||||
const [selectedMediaSource, setSelectedMediaSource] = useState<
|
||||
MediaSourceInfo | undefined | null
|
||||
>(undefined);
|
||||
|
||||
const {
|
||||
defaultAudioIndex,
|
||||
defaultBitrate,
|
||||
defaultMediaSource,
|
||||
defaultSubtitleIndex,
|
||||
} = useDefaultPlaySettings(items[0], settings);
|
||||
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
||||
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
||||
useState<number>(0);
|
||||
const [maxBitrate, setMaxBitrate] = useState<Bitrate>(
|
||||
settings?.defaultBitrate ?? {
|
||||
key: "Max",
|
||||
value: undefined,
|
||||
},
|
||||
);
|
||||
|
||||
const userCanDownload = useMemo(
|
||||
() => user?.Policy?.EnableContentDownloading,
|
||||
[user],
|
||||
);
|
||||
const usingOptimizedServer = useMemo(
|
||||
() => settings?.downloadMethod === DownloadMethod.Optimized,
|
||||
[settings],
|
||||
);
|
||||
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
|
||||
@@ -90,12 +88,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
bottomSheetModalRef.current?.present();
|
||||
}, []);
|
||||
|
||||
const handleSheetChanges = useCallback((index: number) => {
|
||||
// Ensure modal is fully dismissed when index is -1
|
||||
if (index === -1) {
|
||||
// Modal is fully closed
|
||||
}
|
||||
}, []);
|
||||
const handleSheetChanges = useCallback((_index: number) => {}, []);
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
bottomSheetModalRef.current?.dismiss();
|
||||
@@ -109,28 +102,6 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
[items, downloadedFiles],
|
||||
);
|
||||
|
||||
// Initialize selectedOptions with default values
|
||||
useEffect(() => {
|
||||
setSelectedOptions(() => ({
|
||||
bitrate: defaultBitrate,
|
||||
mediaSource: defaultMediaSource,
|
||||
subtitleIndex: defaultSubtitleIndex ?? -1,
|
||||
audioIndex: defaultAudioIndex,
|
||||
}));
|
||||
}, [
|
||||
defaultAudioIndex,
|
||||
defaultBitrate,
|
||||
defaultSubtitleIndex,
|
||||
defaultMediaSource,
|
||||
]);
|
||||
|
||||
const itemsToDownload = useMemo(() => {
|
||||
if (downloadUnwatchedOnly) {
|
||||
return itemsNotDownloaded.filter((item) => !item.UserData?.Played);
|
||||
}
|
||||
return itemsNotDownloaded;
|
||||
}, [itemsNotDownloaded, downloadUnwatchedOnly]);
|
||||
|
||||
const allItemsDownloaded = useMemo(() => {
|
||||
if (items.length === 0) return false;
|
||||
return itemsNotDownloaded.length === 0;
|
||||
@@ -173,103 +144,99 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const acceptDownloadOptions = useCallback(() => {
|
||||
if (userCanDownload === true) {
|
||||
if (itemsNotDownloaded.some((i) => !i.Id)) {
|
||||
throw new Error("No item id");
|
||||
}
|
||||
closeModal();
|
||||
|
||||
initiateDownload(...itemsNotDownloaded);
|
||||
} else {
|
||||
toast.error(
|
||||
t("home.downloads.toasts.you_are_not_allowed_to_download_files"),
|
||||
);
|
||||
}
|
||||
}, [
|
||||
queue,
|
||||
setQueue,
|
||||
itemsNotDownloaded,
|
||||
usingOptimizedServer,
|
||||
userCanDownload,
|
||||
maxBitrate,
|
||||
selectedMediaSource,
|
||||
selectedAudioStream,
|
||||
selectedSubtitleStream,
|
||||
]);
|
||||
|
||||
const initiateDownload = useCallback(
|
||||
async (...items: BaseItemDto[]) => {
|
||||
if (
|
||||
!api ||
|
||||
!user?.Id ||
|
||||
items.some((p) => !p.Id) ||
|
||||
(itemsNotDownloaded.length === 1 && !selectedOptions?.mediaSource?.Id)
|
||||
(itemsNotDownloaded.length === 1 && !selectedMediaSource?.Id)
|
||||
) {
|
||||
throw new Error(
|
||||
"DownloadItem ~ initiateDownload: No api or user or item",
|
||||
);
|
||||
}
|
||||
const downloadDetailsPromises = items.map(async (item) => {
|
||||
const { mediaSource, audioIndex, subtitleIndex } =
|
||||
itemsNotDownloaded.length > 1
|
||||
? getDefaultPlaySettings(item, settings!)
|
||||
: {
|
||||
mediaSource: selectedOptions?.mediaSource,
|
||||
audioIndex: selectedOptions?.audioIndex,
|
||||
subtitleIndex: selectedOptions?.subtitleIndex,
|
||||
};
|
||||
let mediaSource = selectedMediaSource;
|
||||
let audioIndex: number | undefined = selectedAudioStream;
|
||||
let subtitleIndex: number | undefined = selectedSubtitleStream;
|
||||
|
||||
const downloadDetails = await getDownloadUrl({
|
||||
for (const item of items) {
|
||||
if (itemsNotDownloaded.length > 1) {
|
||||
const defaults = getDefaultPlaySettings(item, settings!);
|
||||
mediaSource = defaults.mediaSource;
|
||||
audioIndex = defaults.audioIndex;
|
||||
subtitleIndex = defaults.subtitleIndex;
|
||||
}
|
||||
|
||||
const res = await getStreamUrl({
|
||||
api,
|
||||
item,
|
||||
userId: user.Id!,
|
||||
mediaSource: mediaSource!,
|
||||
audioStreamIndex: audioIndex ?? -1,
|
||||
subtitleStreamIndex: subtitleIndex ?? -1,
|
||||
maxBitrate: selectedOptions?.bitrate || defaultBitrate,
|
||||
deviceId: api.deviceInfo.id,
|
||||
startTimeTicks: 0,
|
||||
userId: user?.Id,
|
||||
audioStreamIndex: audioIndex,
|
||||
maxStreamingBitrate: maxBitrate.value,
|
||||
mediaSourceId: mediaSource?.Id,
|
||||
subtitleStreamIndex: subtitleIndex,
|
||||
deviceProfile: download,
|
||||
download: true,
|
||||
// deviceId: mediaSource?.Id,
|
||||
});
|
||||
|
||||
return {
|
||||
url: downloadDetails?.url,
|
||||
item,
|
||||
mediaSource: downloadDetails?.mediaSource,
|
||||
};
|
||||
});
|
||||
|
||||
const downloadDetails = await Promise.all(downloadDetailsPromises);
|
||||
for (const { url, item, mediaSource } of downloadDetails) {
|
||||
if (!url) {
|
||||
if (!res) {
|
||||
Alert.alert(
|
||||
t("home.downloads.something_went_wrong"),
|
||||
t("home.downloads.could_not_get_stream_url_from_jellyfin"),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (!mediaSource) {
|
||||
console.error(`Could not get download URL for ${item.Name}`);
|
||||
toast.error(
|
||||
t("home.downloads.toasts.could_not_get_download_url_for_item", {
|
||||
itemName: item.Name,
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
await startBackgroundDownload(
|
||||
url,
|
||||
item,
|
||||
mediaSource,
|
||||
selectedOptions?.bitrate || defaultBitrate,
|
||||
);
|
||||
|
||||
const { mediaSource: source, url } = res;
|
||||
|
||||
if (!url || !source) throw new Error("No url");
|
||||
|
||||
saveDownloadItemInfoToDiskTmp(item, source, url);
|
||||
await startBackgroundDownload(url, item, source, maxBitrate);
|
||||
}
|
||||
},
|
||||
[
|
||||
api,
|
||||
user?.Id,
|
||||
itemsNotDownloaded,
|
||||
selectedOptions,
|
||||
selectedMediaSource,
|
||||
selectedAudioStream,
|
||||
selectedSubtitleStream,
|
||||
settings,
|
||||
defaultBitrate,
|
||||
maxBitrate,
|
||||
usingOptimizedServer,
|
||||
startBackgroundDownload,
|
||||
],
|
||||
);
|
||||
|
||||
const acceptDownloadOptions = useCallback(async () => {
|
||||
if (userCanDownload === true) {
|
||||
if (itemsToDownload.some((i) => !i.Id)) {
|
||||
throw new Error("No item id");
|
||||
}
|
||||
|
||||
// Ensure modal is dismissed before starting download
|
||||
await closeModal();
|
||||
|
||||
// Small delay to ensure modal is fully dismissed
|
||||
setTimeout(() => {
|
||||
initiateDownload(...itemsToDownload);
|
||||
}, 100);
|
||||
} else {
|
||||
toast.error(
|
||||
t("home.downloads.toasts.you_are_not_allowed_to_download_files"),
|
||||
);
|
||||
}
|
||||
}, [closeModal, initiateDownload, itemsToDownload, userCanDownload]);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: BottomSheetBackdropProps) => (
|
||||
<BottomSheetBackdrop
|
||||
@@ -280,6 +247,19 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
),
|
||||
[],
|
||||
);
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (!settings) return;
|
||||
if (itemsNotDownloaded.length !== 1) return;
|
||||
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
|
||||
getDefaultPlaySettings(items[0], settings);
|
||||
|
||||
setSelectedMediaSource(mediaSource ?? undefined);
|
||||
setSelectedAudioStream(audioIndex ?? 0);
|
||||
setSelectedSubtitleStream(subtitleIndex ?? -1);
|
||||
setMaxBitrate(bitrate);
|
||||
}, [items, itemsNotDownloaded, settings]),
|
||||
);
|
||||
|
||||
const renderButtonContent = () => {
|
||||
if (processes.length > 0 && itemsProcesses.length > 0) {
|
||||
@@ -336,15 +316,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
backgroundColor: "#171717",
|
||||
}}
|
||||
onChange={handleSheetChanges}
|
||||
onDismiss={() => {
|
||||
// Ensure any pending state is cleared when modal is dismissed
|
||||
}}
|
||||
backdropComponent={renderBackdrop}
|
||||
enablePanDownToClose
|
||||
enableDismissOnClose
|
||||
android_keyboardInputMode='adjustResize'
|
||||
keyboardBehavior='interactive'
|
||||
keyboardBlurBehavior='restore'
|
||||
>
|
||||
<BottomSheetView>
|
||||
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
||||
@@ -355,78 +327,40 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
<Text className='text-neutral-300'>
|
||||
{subtitle ||
|
||||
t("item_card.download.download_x_item", {
|
||||
item_count: itemsToDownload.length,
|
||||
item_count: itemsNotDownloaded.length,
|
||||
})}
|
||||
</Text>
|
||||
</View>
|
||||
<View className='flex flex-col space-y-2 w-full items-start'>
|
||||
<BitrateSelector
|
||||
inverted
|
||||
onChange={(val) =>
|
||||
setSelectedOptions(
|
||||
(prev) => prev && { ...prev, bitrate: val },
|
||||
)
|
||||
}
|
||||
selected={selectedOptions?.bitrate}
|
||||
onChange={setMaxBitrate}
|
||||
selected={maxBitrate}
|
||||
/>
|
||||
{itemsNotDownloaded.length > 1 && (
|
||||
<View className='flex flex-row items-center justify-between w-full py-2'>
|
||||
<Text>{t("item_card.download.download_unwatched_only")}</Text>
|
||||
<Switch
|
||||
onValueChange={setDownloadUnwatchedOnly}
|
||||
value={downloadUnwatchedOnly}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
{itemsNotDownloaded.length === 1 && (
|
||||
<View>
|
||||
<>
|
||||
<MediaSourceSelector
|
||||
item={items[0]}
|
||||
onChange={(val) =>
|
||||
setSelectedOptions(
|
||||
(prev) =>
|
||||
prev && {
|
||||
...prev,
|
||||
mediaSource: val,
|
||||
},
|
||||
)
|
||||
}
|
||||
selected={selectedOptions?.mediaSource}
|
||||
onChange={setSelectedMediaSource}
|
||||
selected={selectedMediaSource}
|
||||
/>
|
||||
{selectedOptions?.mediaSource && (
|
||||
{selectedMediaSource && (
|
||||
<View className='flex flex-col space-y-2'>
|
||||
<AudioTrackSelector
|
||||
source={selectedOptions.mediaSource}
|
||||
onChange={(val) => {
|
||||
setSelectedOptions(
|
||||
(prev) =>
|
||||
prev && {
|
||||
...prev,
|
||||
audioIndex: val,
|
||||
},
|
||||
);
|
||||
}}
|
||||
selected={selectedOptions.audioIndex}
|
||||
source={selectedMediaSource}
|
||||
onChange={setSelectedAudioStream}
|
||||
selected={selectedAudioStream}
|
||||
/>
|
||||
<SubtitleTrackSelector
|
||||
source={selectedOptions.mediaSource}
|
||||
onChange={(val) => {
|
||||
setSelectedOptions(
|
||||
(prev) =>
|
||||
prev && {
|
||||
...prev,
|
||||
subtitleIndex: val,
|
||||
},
|
||||
);
|
||||
}}
|
||||
selected={selectedOptions.subtitleIndex}
|
||||
source={selectedMediaSource}
|
||||
onChange={setSelectedSubtitleStream}
|
||||
selected={selectedSubtitleStream}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Button
|
||||
className='mt-auto'
|
||||
onPress={acceptDownloadOptions}
|
||||
@@ -434,6 +368,13 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
>
|
||||
{t("item_card.download.download_button")}
|
||||
</Button>
|
||||
<View className='opacity-70 text-center w-full flex items-center'>
|
||||
<Text className='text-xs'>
|
||||
{usingOptimizedServer
|
||||
? t("item_card.download.using_optimized_server")
|
||||
: t("item_card.download.using_default_method")}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</BottomSheetView>
|
||||
</BottomSheetModal>
|
||||
|
||||
@@ -6,10 +6,10 @@ import { Image } from "expo-image";
|
||||
import { useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View } from "react-native";
|
||||
import { View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { type Bitrate } from "@/components/BitrateSelector";
|
||||
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
||||
import { type Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
||||
import { ItemImage } from "@/components/common/ItemImage";
|
||||
import { DownloadSingleItem } from "@/components/DownloadItem";
|
||||
import { OverviewText } from "@/components/OverviewText";
|
||||
@@ -18,26 +18,25 @@ import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
import { PlayButton } from "@/components/PlayButton";
|
||||
import { PlayedStatus } from "@/components/PlayedStatus";
|
||||
import { SimilarItems } from "@/components/SimilarItems";
|
||||
import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector";
|
||||
import { CastAndCrew } from "@/components/series/CastAndCrew";
|
||||
import { CurrentSeries } from "@/components/series/CurrentSeries";
|
||||
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
|
||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
|
||||
import { useImageColors } from "@/hooks/useImageColors";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import { AddToFavorites } from "./AddToFavorites";
|
||||
import { BitrateSheet } from "./BitRateSheet";
|
||||
import { ItemHeader } from "./ItemHeader";
|
||||
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
||||
import { MediaSourceSheet } from "./MediaSourceSheet";
|
||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
||||
import { PlayInRemoteSessionButton } from "./PlayInRemoteSession";
|
||||
import { TrackSheet } from "./TrackSheet";
|
||||
|
||||
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
|
||||
const Chromecast = require("./Chromecast");
|
||||
|
||||
export type SelectedOptions = {
|
||||
bitrate: Bitrate;
|
||||
@@ -46,22 +45,16 @@ export type SelectedOptions = {
|
||||
subtitleIndex: number;
|
||||
};
|
||||
|
||||
interface ItemContentProps {
|
||||
item: BaseItemDto;
|
||||
isOffline: boolean;
|
||||
}
|
||||
|
||||
export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
({ item, isOffline }) => {
|
||||
export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
({ item }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const { settings } = useSettings();
|
||||
const [settings] = useSettings();
|
||||
const { orientation } = useOrientation();
|
||||
const navigation = useNavigation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [user] = useAtom(userAtom);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const itemColors = useImageColorsReturn({ item });
|
||||
useImageColors({ item });
|
||||
|
||||
const [loadingLogo, setLoadingLogo] = useState(true);
|
||||
const [headerHeight, setHeaderHeight] = useState(350);
|
||||
@@ -75,16 +68,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
defaultBitrate,
|
||||
defaultMediaSource,
|
||||
defaultSubtitleIndex,
|
||||
} = useDefaultPlaySettings(item!, settings);
|
||||
|
||||
const logoUrl = useMemo(
|
||||
() => (item ? getLogoImageUrlById({ api, item }) : null),
|
||||
[api, item],
|
||||
);
|
||||
|
||||
const loading = useMemo(() => {
|
||||
return Boolean(logoUrl && loadingLogo);
|
||||
}, [loadingLogo, logoUrl]);
|
||||
} = useDefaultPlaySettings(item, settings);
|
||||
|
||||
// Needs to automatically change the selected to the default values for default indexes.
|
||||
useEffect(() => {
|
||||
@@ -102,59 +86,40 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!Platform.isTV) {
|
||||
navigation.setOptions({
|
||||
headerRight: () =>
|
||||
item &&
|
||||
(Platform.OS === "ios" ? (
|
||||
<View className='flex flex-row items-center pl-2'>
|
||||
<Chromecast.Chromecast width={22} height={22} />
|
||||
{item.Type !== "Program" && (
|
||||
<View className='flex flex-row items-center'>
|
||||
{!Platform.isTV && (
|
||||
<DownloadSingleItem item={item} size='large' />
|
||||
)}
|
||||
{user?.Policy?.IsAdministrator && (
|
||||
<PlayInRemoteSessionButton item={item} size='large' />
|
||||
)}
|
||||
navigation.setOptions({
|
||||
headerRight: () =>
|
||||
item && (
|
||||
<View className='flex flex-row items-center space-x-2'>
|
||||
<Chromecast.Chromecast background='blur' width={22} height={22} />
|
||||
{item.Type !== "Program" && (
|
||||
<View className='flex flex-row items-center space-x-2'>
|
||||
<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'>
|
||||
<Chromecast.Chromecast width={22} height={22} />
|
||||
{item.Type !== "Program" && (
|
||||
<View className='flex flex-row items-center space-x-2'>
|
||||
{!Platform.isTV && (
|
||||
<DownloadSingleItem item={item} size='large' />
|
||||
)}
|
||||
{user?.Policy?.IsAdministrator && (
|
||||
<PlayInRemoteSessionButton item={item} size='large' />
|
||||
)}
|
||||
|
||||
<PlayedStatus items={[item]} size='large' />
|
||||
<AddToFavorites item={item} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)),
|
||||
});
|
||||
}
|
||||
}, [item, navigation, user]);
|
||||
<PlayedStatus items={[item]} size='large' />
|
||||
<AddToFavorites item={item} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
),
|
||||
});
|
||||
}, [item]);
|
||||
|
||||
useEffect(() => {
|
||||
if (item) {
|
||||
if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP)
|
||||
setHeaderHeight(230);
|
||||
else if (item.Type === "Movie") setHeaderHeight(500);
|
||||
else setHeaderHeight(350);
|
||||
}
|
||||
}, [item, orientation]);
|
||||
if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP)
|
||||
setHeaderHeight(230);
|
||||
else if (item.Type === "Movie") setHeaderHeight(500);
|
||||
else setHeaderHeight(350);
|
||||
}, [item.Type, orientation]);
|
||||
|
||||
if (!item || !selectedOptions) return null;
|
||||
const logoUrl = useMemo(() => getLogoImageUrlById({ api, item }), [item]);
|
||||
|
||||
const loading = useMemo(() => {
|
||||
return Boolean(logoUrl && loadingLogo);
|
||||
}, [loadingLogo, logoUrl]);
|
||||
if (!selectedOptions) return null;
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -190,22 +155,20 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
style={{
|
||||
height: 130,
|
||||
width: "100%",
|
||||
resizeMode: "contain",
|
||||
}}
|
||||
contentFit='contain'
|
||||
onLoad={() => setLoadingLogo(false)}
|
||||
onError={() => setLoadingLogo(false)}
|
||||
/>
|
||||
) : (
|
||||
<View />
|
||||
)
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<View className='flex flex-col bg-transparent shrink'>
|
||||
<View className='flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink'>
|
||||
<ItemHeader item={item} className='mb-2' />
|
||||
{item.Type !== "Program" && !Platform.isTV && !isOffline && (
|
||||
<ItemHeader item={item} className='mb-4' />
|
||||
{item.Type !== "Program" && (
|
||||
<View className='flex flex-row items-center justify-start w-full h-16'>
|
||||
<BitrateSheet
|
||||
<BitrateSelector
|
||||
className='mr-1'
|
||||
onChange={(val) =>
|
||||
setSelectedOptions(
|
||||
@@ -214,7 +177,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
}
|
||||
selected={selectedOptions.bitrate}
|
||||
/>
|
||||
<MediaSourceSheet
|
||||
<MediaSourceSelector
|
||||
className='mr-1'
|
||||
item={item}
|
||||
onChange={(val) =>
|
||||
@@ -228,10 +191,8 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
}
|
||||
selected={selectedOptions.mediaSource}
|
||||
/>
|
||||
<TrackSheet
|
||||
<AudioTrackSelector
|
||||
className='mr-1'
|
||||
streamType='Audio'
|
||||
title={t("item_card.audio")}
|
||||
source={selectedOptions.mediaSource}
|
||||
onChange={(val) => {
|
||||
setSelectedOptions(
|
||||
@@ -244,10 +205,8 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
}}
|
||||
selected={selectedOptions.audioIndex}
|
||||
/>
|
||||
<TrackSheet
|
||||
<SubtitleTrackSelector
|
||||
source={selectedOptions.mediaSource}
|
||||
streamType='Subtitle'
|
||||
title={t("item_card.subtitles")}
|
||||
onChange={(val) =>
|
||||
setSelectedOptions(
|
||||
(prev) =>
|
||||
@@ -266,35 +225,25 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
className='grow'
|
||||
selectedOptions={selectedOptions}
|
||||
item={item}
|
||||
isOffline={isOffline}
|
||||
colors={itemColors}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{item.Type === "Episode" && (
|
||||
<SeasonEpisodesCarousel
|
||||
item={item}
|
||||
loading={loading}
|
||||
isOffline={isOffline}
|
||||
/>
|
||||
<SeasonEpisodesCarousel item={item} loading={loading} />
|
||||
)}
|
||||
|
||||
{!isOffline && (
|
||||
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
|
||||
)}
|
||||
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
|
||||
<OverviewText text={item.Overview} className='px-4 mb-4' />
|
||||
|
||||
{item.Type !== "Program" && (
|
||||
<>
|
||||
{item.Type === "Episode" && !isOffline && (
|
||||
{item.Type === "Episode" && (
|
||||
<CurrentSeries item={item} className='mb-4' />
|
||||
)}
|
||||
|
||||
{!isOffline && (
|
||||
<CastAndCrew item={item} className='mb-4' loading={loading} />
|
||||
)}
|
||||
<CastAndCrew item={item} className='mb-4' loading={loading} />
|
||||
|
||||
{item.People && item.People.length > 0 && !isOffline && (
|
||||
{item.People && item.People.length > 0 && (
|
||||
<View className='mb-4'>
|
||||
{item.People.slice(0, 3).map((person, idx) => (
|
||||
<MoreMoviesWithActor
|
||||
@@ -307,7 +256,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!isOffline && <SimilarItems itemId={item.Id} />}
|
||||
<SimilarItems itemId={item.Id} />
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -33,16 +33,16 @@ export const ItemHeader: React.FC<Props> = ({ item, ...props }) => {
|
||||
<ItemActions item={item} />
|
||||
</View>
|
||||
{item.Type === "Episode" && (
|
||||
<View>
|
||||
<>
|
||||
<EpisodeTitleHeader item={item} />
|
||||
<GenreTags genres={item.Genres!} />
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
{item.Type === "Movie" && (
|
||||
<View>
|
||||
<>
|
||||
<MoviesTitleHeader item={item} />
|
||||
<GenreTags genres={item.Genres!} />
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -21,7 +21,7 @@ interface Props {
|
||||
source?: MediaSourceInfo;
|
||||
}
|
||||
|
||||
export const ItemTechnicalDetails: React.FC<Props> = ({ source }) => {
|
||||
export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -53,7 +53,7 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source }) => {
|
||||
>
|
||||
<BottomSheetScrollView>
|
||||
<View className='flex flex-col space-y-2 p-4 mb-4'>
|
||||
<View>
|
||||
<View className=''>
|
||||
<Text className='text-lg font-bold mb-4'>
|
||||
{t("item_card.video")}
|
||||
</Text>
|
||||
@@ -62,7 +62,7 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source }) => {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<View className=''>
|
||||
<Text className='text-lg font-bold mb-2'>
|
||||
{t("item_card.audio")}
|
||||
</Text>
|
||||
@@ -75,7 +75,7 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source }) => {
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<View className=''>
|
||||
<Text className='text-lg font-bold mb-2'>
|
||||
{t("item_card.subtitles")}
|
||||
</Text>
|
||||
@@ -175,13 +175,15 @@ const AudioStreamInfo = ({ audioStreams }: { audioStreams: MediaStream[] }) => {
|
||||
};
|
||||
|
||||
const VideoStreamInfo = ({ source }: { source?: MediaSourceInfo }) => {
|
||||
const videoStream = useMemo(() => {
|
||||
return source?.MediaStreams?.find((stream) => stream.Type === "Video") as
|
||||
| MediaStream
|
||||
| undefined;
|
||||
}, [source?.MediaStreams]);
|
||||
if (!source) return null;
|
||||
|
||||
if (!source || !videoStream) return null;
|
||||
const videoStream = useMemo(() => {
|
||||
return source.MediaStreams?.find(
|
||||
(stream) => stream.Type === "Video",
|
||||
) as MediaStream;
|
||||
}, [source.MediaStreams]);
|
||||
|
||||
if (!videoStream) return null;
|
||||
|
||||
return (
|
||||
<View className='flex-row flex-wrap gap-2'>
|
||||
@@ -219,11 +221,7 @@ const VideoStreamInfo = ({ source }: { source?: MediaSourceInfo }) => {
|
||||
<Badge
|
||||
variant='gray'
|
||||
iconLeft={<Ionicons name='play-outline' size={16} color='white' />}
|
||||
text={
|
||||
videoStream.AverageFrameRate != null
|
||||
? `${videoStream.AverageFrameRate.toFixed(0)} fps`
|
||||
: ""
|
||||
}
|
||||
text={`${videoStream.AverageFrameRate?.toFixed(0)} fps`}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
@@ -236,7 +234,6 @@ const formatFileSize = (bytes?: number | null) => {
|
||||
if (bytes === 0) return "0 Byte";
|
||||
const i = Number.parseInt(
|
||||
Math.floor(Math.log(bytes) / Math.log(1024)).toString(),
|
||||
10,
|
||||
);
|
||||
return `${Math.round((bytes / 1024 ** i) * 100) / 100} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
|
||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||
@@ -22,31 +22,37 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
||||
selected,
|
||||
...props
|
||||
}) => {
|
||||
const isTv = Platform.isTV;
|
||||
if (Platform.isTV) return null;
|
||||
const selectedName = useMemo(
|
||||
() =>
|
||||
item.MediaSources?.find((x) => x.Id === selected?.Id)?.MediaStreams?.find(
|
||||
(x) => x.Type === "Video",
|
||||
)?.DisplayTitle || "",
|
||||
[item, selected],
|
||||
);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getDisplayName = useCallback((source: MediaSourceInfo) => {
|
||||
const videoStream = source.MediaStreams?.find((x) => x.Type === "Video");
|
||||
if (videoStream?.DisplayTitle) {
|
||||
return videoStream.DisplayTitle;
|
||||
const commonPrefix = useMemo(() => {
|
||||
const mediaSources = item.MediaSources || [];
|
||||
if (!mediaSources.length) return "";
|
||||
|
||||
let commonPrefix = "";
|
||||
for (let i = 0; i < mediaSources[0].Name!.length; i++) {
|
||||
const char = mediaSources[0].Name![i];
|
||||
if (mediaSources.every((source) => source.Name![i] === char)) {
|
||||
commonPrefix += char;
|
||||
} else {
|
||||
commonPrefix = commonPrefix.slice(0, -1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return commonPrefix;
|
||||
}, [item.MediaSources]);
|
||||
|
||||
// Fallback to source name
|
||||
if (source.Name) {
|
||||
return source.Name;
|
||||
}
|
||||
|
||||
// Last resort fallback
|
||||
return `Source ${source.Id}`;
|
||||
}, []);
|
||||
|
||||
const selectedName = useMemo(() => {
|
||||
if (!selected) return "";
|
||||
return getDisplayName(selected);
|
||||
}, [selected, getDisplayName]);
|
||||
|
||||
if (isTv) return null;
|
||||
const name = (name?: string | null) => {
|
||||
return name?.replace(commonPrefix, "").toLowerCase();
|
||||
};
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -84,7 +90,7 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{getDisplayName(source)}
|
||||
{`${name(source.Name)}`}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { Text } from "./common/Text";
|
||||
import { FilterSheet } from "./filters/FilterSheet";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof View> {
|
||||
item: BaseItemDto;
|
||||
onChange: (value: MediaSourceInfo) => void;
|
||||
selected?: MediaSourceInfo | null;
|
||||
}
|
||||
|
||||
export const MediaSourceSheet: React.FC<Props> = ({
|
||||
item,
|
||||
onChange,
|
||||
selected,
|
||||
...props
|
||||
}) => {
|
||||
const isTv = Platform.isTV;
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const getDisplayName = useCallback((source: MediaSourceInfo) => {
|
||||
const videoStream = source.MediaStreams?.find((x) => x.Type === "Video");
|
||||
if (source.Name) return source.Name;
|
||||
if (videoStream?.DisplayTitle) return videoStream.DisplayTitle;
|
||||
return `Source ${source.Id}`;
|
||||
}, []);
|
||||
|
||||
const selectedName = useMemo(() => {
|
||||
if (!selected) return "";
|
||||
return getDisplayName(selected);
|
||||
}, [selected, getDisplayName]);
|
||||
|
||||
if (isTv || (item.MediaSources && item.MediaSources.length <= 1)) return null;
|
||||
|
||||
return (
|
||||
<View className='flex shrink' style={{ minWidth: 75 }}>
|
||||
<View className='flex flex-col' {...props}>
|
||||
<Text className='opacity-50 mb-1 text-xs'>{t("item_card.video")}</Text>
|
||||
<TouchableOpacity
|
||||
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center'
|
||||
onPress={() => setOpen(true)}
|
||||
>
|
||||
<Text numberOfLines={1}>{selectedName}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<FilterSheet
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
title={t("item_card.video")}
|
||||
data={item.MediaSources || []}
|
||||
values={selected ? [selected] : []}
|
||||
multiple={false}
|
||||
searchFilter={(src, query) =>
|
||||
getDisplayName(src as MediaSourceInfo)
|
||||
.toLowerCase()
|
||||
.includes(query.toLowerCase())
|
||||
}
|
||||
renderItemLabel={(src) => (
|
||||
<Text>{getDisplayName(src as MediaSourceInfo)}</Text>
|
||||
)}
|
||||
set={(vals) => {
|
||||
const chosen = vals[0] as MediaSourceInfo | undefined;
|
||||
if (chosen) onChange(chosen);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||