Compare commits
3 Commits
view-passw
...
chore/expo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09189e125e | ||
|
|
1ac10d8f34 | ||
|
|
d5fe354986 |
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(find:*)",
|
||||
"Bash(bun install:*)",
|
||||
"Bash(bunx expo prebuild:*)",
|
||||
"Bash(bunx expo run:*)",
|
||||
"Bash(npx expo prebuild:*)",
|
||||
"Bash(npx expo run:*)",
|
||||
"Bash(xcodebuild:*)"
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
description: Don't write code directly in the ios folder.
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
We never write code directly in the ios folder. This code is generated by expo plugins.
|
||||
@@ -1 +0,0 @@
|
||||
EXPO_PUBLIC_WRITE_DEBUG=1
|
||||
@@ -1 +0,0 @@
|
||||
EXPO_PUBLIC_WRITE_DEBUG=0
|
||||
3
.eslintrc.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals"]
|
||||
}
|
||||
1
.gitattributes
vendored
@@ -1 +0,0 @@
|
||||
.modules/vlc-player/Frameworks/*.xcframework filter=lfs diff=lfs merge=lfs -text
|
||||
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!
|
||||
59
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
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.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.
|
||||
86
.github/pull_request_template.md
vendored
@@ -1,86 +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.
|
||||
-->
|
||||
|
||||
## 🔖 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)
|
||||
-->
|
||||
|
||||
### ⚠️ 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)
|
||||
<!--
|
||||
Provide more context or background. Explain any non-obvious decisions.
|
||||
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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
93
.github/workflows/build-android.yml
vendored
@@ -1,93 +0,0 @@
|
||||
name: 🤖 Android APK Build (Phone + TV)
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
branches: [develop, master]
|
||||
push:
|
||||
branches: [develop, master]
|
||||
|
||||
jobs:
|
||||
build-android:
|
||||
if: (!contains(github.event.head_commit.message, '[skip ci]'))
|
||||
runs-on: ubuntu-24.04
|
||||
name: 🏗️ Build Android APK
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
target: [phone, tv]
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-bun-develop
|
||||
${{ runner.os }}-bun-develop
|
||||
|
||||
- name: 📦 Install dependencies and reload submodules
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
bun run submodule-reload
|
||||
|
||||
- name: 💾 Cache Gradle global
|
||||
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||
restore-keys: ${{ runner.os }}-gradle-develop
|
||||
|
||||
- name: 🛠️ Generate project files
|
||||
run: |
|
||||
if [ "${{ matrix.target }}" = "tv" ]; then
|
||||
bun run prebuild:tv
|
||||
else
|
||||
bun run prebuild
|
||||
fi
|
||||
|
||||
- name: 💾 Cache project Gradle (.gradle)
|
||||
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
with:
|
||||
path: android/.gradle
|
||||
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||
restore-keys: ${{ runner.os }}-android-gradle-develop
|
||||
|
||||
- name: 🚀 Build APK
|
||||
env:
|
||||
EXPO_TV: ${{ matrix.target == 'tv' && 1 || 0 }}
|
||||
run: bun run build:android:local
|
||||
|
||||
- name: 📅 Set date tag
|
||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||
|
||||
- name: 📤 Upload APK artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: streamyfin-android-${{ matrix.target }}-apk-${{ env.DATE_TAG }}
|
||||
path: |
|
||||
android/app/build/outputs/apk/release/*.apk
|
||||
retention-days: 7
|
||||
49
.github/workflows/build-ios.yaml
vendored
Normal file
@@ -0,0 +1,49 @@
|
||||
name: Automatic Build and Deploy
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: macos-15
|
||||
name: Build IOS
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
name: Check out repository
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
- run: |
|
||||
bun i && bun run submodule-reload
|
||||
npx expo prebuild
|
||||
- uses: sparkfabrik/ios-build-action@v2.3.0
|
||||
with:
|
||||
upload-to-testflight: false
|
||||
increment-build-number: false
|
||||
build-pods: true
|
||||
pods-path: "ios/Podfile"
|
||||
configuration: Release
|
||||
# Change later to app-store if wanted
|
||||
export-method: appstore
|
||||
#export-method: ad-hoc
|
||||
workspace-path: "ios/Streamyfin.xcodeproj/project.xcworkspace/"
|
||||
project-path: "ios/Streamyfin.xcodeproj"
|
||||
scheme: Streamyfin
|
||||
apple-key-id: ${{ secrets.APPLE_KEY_ID }}
|
||||
apple-key-issuer-id: ${{ secrets.APPLE_KEY_ISSUER_ID }}
|
||||
apple-key-content: ${{ secrets.APPLE_KEY_CONTENT }}
|
||||
team-id: ${{ secrets.TEAM_ID }}
|
||||
team-name: ${{ secrets.TEAM_NAME }}
|
||||
#match-password: ${{ secrets.MATCH_PASSWORD }}
|
||||
#match-git-url: ${{ secrets.MATCH_GIT_URL }}
|
||||
#match-git-basic-authorization: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
|
||||
#match-build-type: "appstore"
|
||||
#browserstack-upload: true
|
||||
#browserstack-username: ${{ secrets.BROWSERSTACK_USERNAME }}
|
||||
#browserstack-access-key: ${{ secrets.BROWSERSTACK_ACCESS_KEY }}
|
||||
#fastlane-env: stage
|
||||
ios-app-id: com.stetsed.teststreamyfin
|
||||
output-path: build-${{ github.sha }}.ipa
|
||||
95
.github/workflows/build-ios.yml
vendored
@@ -1,95 +0,0 @@
|
||||
name: 🤖 iOS IPA Build (Phone + TV)
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
branches: [develop, master]
|
||||
paths-ignore:
|
||||
- '*.md'
|
||||
push:
|
||||
branches: [develop, master]
|
||||
paths-ignore:
|
||||
- '*.md'
|
||||
|
||||
jobs:
|
||||
build-ios:
|
||||
if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
|
||||
runs-on: macos-15
|
||||
name: 🏗️ Build iOS IPA
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
target: [phone]
|
||||
# target: [phone, tv]
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-cache
|
||||
|
||||
- name: 📦 Install dependencies and reload submodules
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
bun run submodule-reload
|
||||
|
||||
- name: 🛠️ Generate project files
|
||||
run: |
|
||||
if [ "${{ matrix.target }}" = "tv" ]; then
|
||||
bun run prebuild:tv
|
||||
else
|
||||
bun run prebuild
|
||||
fi
|
||||
|
||||
- name: 🏗️ Setup EAS
|
||||
uses: expo/expo-github-action@main
|
||||
with:
|
||||
eas-version: latest
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
eas-cache: true
|
||||
|
||||
- name: ⚙️ Ensure iOS/tvOS SDKs installed
|
||||
run: |
|
||||
if [ "${{ matrix.target }}" = "tv" ]; then
|
||||
xcodebuild -downloadPlatform tvOS
|
||||
else
|
||||
xcodebuild -downloadPlatform iOS
|
||||
fi
|
||||
|
||||
- name: 🚀 Build iOS app
|
||||
env:
|
||||
EXPO_TV: ${{ matrix.target == 'tv' && 1 || 0 }}
|
||||
run: eas build -p ios --local --non-interactive
|
||||
|
||||
- name: 📅 Set date tag
|
||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||
|
||||
- name: 📤 Upload IPA artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: streamyfin-ios-${{ matrix.target }}-ipa-${{ env.DATE_TAG }}
|
||||
path: build-*.ipa
|
||||
retention-days: 7
|
||||
46
.github/workflows/check-lockfile.yml
vendored
@@ -1,46 +0,0 @@
|
||||
name: 🔒 Lockfile Consistency Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [develop, master]
|
||||
push:
|
||||
branches: [develop, master]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check-lockfile:
|
||||
name: 🔍 Check bun.lock and package.json consistency
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
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: latest
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
with:
|
||||
path: |
|
||||
~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
|
||||
|
||||
- name: 🛡️ Verify lockfile consistency
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
echo "➡️ Checking for discrepancies between bun.lock and package.json..."
|
||||
bun install --frozen-lockfile --dry-run --ignore-scripts
|
||||
echo "✅ Lockfile is consistent with package.json!"
|
||||
43
.github/workflows/ci-codeql.yml
vendored
@@ -1,43 +0,0 @@
|
||||
name: 🛡️ CodeQL Analysis
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master, develop]
|
||||
pull_request:
|
||||
branches: [master, develop]
|
||||
schedule:
|
||||
- cron: '24 2 * * *'
|
||||
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: 🔎 Analyze with CodeQL
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'javascript-typescript', 'actions' ]
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
show-progress: false
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 🏁 Initialize CodeQL
|
||||
uses: github/codeql-action/init@2d92b76c45b91eb80fc44c74ce3fce0ee94e8f9d # v3.30.0
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: +security-extended,security-and-quality
|
||||
|
||||
- name: 🛠️ Autobuild
|
||||
uses: github/codeql-action/autobuild@2d92b76c45b91eb80fc44c74ce3fce0ee94e8f9d # v3.30.0
|
||||
|
||||
- name: 🧪 Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@2d92b76c45b91eb80fc44c74ce3fce0ee94e8f9d # v3.30.0
|
||||
24
.github/workflows/conflict.yml
vendored
@@ -1,24 +0,0 @@
|
||||
name: 🏷️🔀Merge Conflict Labeler
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [develop]
|
||||
pull_request_target:
|
||||
branches: [develop]
|
||||
types: [synchronize]
|
||||
|
||||
jobs:
|
||||
label:
|
||||
name: 🏷️ Labeling Merge Conflicts
|
||||
runs-on: ubuntu-24.04
|
||||
if: ${{ github.repository == 'streamyfin/streamyfin' }}
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: 🚩 Apply merge conflict label
|
||||
uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3
|
||||
with:
|
||||
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 }}'
|
||||
41
.github/workflows/lint-pr.yaml
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
name: "Lint PR"
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
- synchronize
|
||||
- reopened
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
main:
|
||||
name: Validate PR title
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: amannn/action-semantic-pull-request@v5
|
||||
id: lint_pr_title
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: marocchino/sticky-pull-request-comment@v2
|
||||
if: always() && (steps.lint_pr_title.outputs.error_message != null)
|
||||
with:
|
||||
header: pr-title-lint-error
|
||||
message: |
|
||||
Hey there and thank you for opening this pull request! 👋🏼
|
||||
|
||||
We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/) and it looks like your proposed title needs to be adjusted.
|
||||
|
||||
Details:
|
||||
|
||||
```
|
||||
${{ steps.lint_pr_title.outputs.error_message }}
|
||||
```
|
||||
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
|
||||
uses: marocchino/sticky-pull-request-comment@v2
|
||||
with:
|
||||
header: pr-title-lint-error
|
||||
delete: true
|
||||
122
.github/workflows/linting.yml
vendored
@@ -1,122 +0,0 @@
|
||||
name: 🚦 Security & Quality Gate
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, edited, synchronize, reopened]
|
||||
branches: [develop, master]
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [develop]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
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
|
||||
id: lint_pr_title
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4
|
||||
if: always() && (steps.lint_pr_title.outputs.error_message != null)
|
||||
with:
|
||||
header: pr-title-lint-error
|
||||
message: |
|
||||
Hey there and thank you for opening this pull request! 👋🏼
|
||||
We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/).
|
||||
|
||||
**Error details:**
|
||||
```
|
||||
${{ steps.lint_pr_title.outputs.error_message }}
|
||||
```
|
||||
|
||||
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
|
||||
uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4
|
||||
with:
|
||||
header: pr-title-lint-error
|
||||
delete: true
|
||||
|
||||
dependency-review:
|
||||
name: 🔍 Vulnerable Dependencies
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Dependency Review
|
||||
uses: actions/dependency-review-action@595b5aeba73380359d98a5e087f648dbb0edce1b # v4.7.3
|
||||
with:
|
||||
fail-on-severity: high
|
||||
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
|
||||
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
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
command:
|
||||
- "lint"
|
||||
- "check"
|
||||
- "format"
|
||||
- "typecheck"
|
||||
|
||||
steps:
|
||||
- name: "📥 Checkout PR code"
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
submodules: recursive
|
||||
fetch-depth: 0
|
||||
|
||||
- name: "🟢 Setup Node.js"
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: '24.x'
|
||||
|
||||
- name: "🍞 Setup Bun"
|
||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: "📦 Install dependencies"
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: "🚨 Run ${{ matrix.command }}"
|
||||
run: bun run ${{ matrix.command }}
|
||||
18
.github/workflows/notification.yaml
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
name: Discord Pull Request Notification
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, reopened]
|
||||
|
||||
jobs:
|
||||
notify:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: joelwmale/webhook-action@master
|
||||
with:
|
||||
url: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
body: |
|
||||
{
|
||||
"content": "New Pull Request: ${{ github.event.pull_request.title }}\nBy: ${{ github.event.pull_request.user.login }}\n\n${{ github.event.pull_request.html_url }}",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/193271640"
|
||||
}
|
||||
23
.github/workflows/notification.yml
vendored
@@ -1,23 +0,0 @@
|
||||
name: 🛎️ Discord Pull Request Notification
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, reopened]
|
||||
branches: [develop]
|
||||
|
||||
jobs:
|
||||
notify:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: 🛎️ Notify Discord
|
||||
uses: Ilshidur/action-discord@d2594079a10f1d6739ee50a2471f0ca57418b554 # 0.4.0
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
DISCORD_AVATAR: https://avatars.githubusercontent.com/u/193271640
|
||||
with:
|
||||
args: |
|
||||
📢 New Pull Request in **${{ github.repository }}**
|
||||
**Title:** ${{ github.event.pull_request.title }}
|
||||
**By:** ${{ github.event.pull_request.user.login }}
|
||||
**Branch:** ${{ github.event.pull_request.head.ref }}
|
||||
🔗 ${{ github.event.pull_request.html_url }}
|
||||
49
.github/workflows/stale.yml
vendored
@@ -1,49 +0,0 @@
|
||||
name: 🕒 Handle Stale Issues
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Runs daily at 1:30 AM UTC (3:30 AM CEST - France time)
|
||||
- cron: "30 1 * * *"
|
||||
|
||||
jobs:
|
||||
stale-issues:
|
||||
name: 🗑️ Cleanup Stale Issues
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: 🔄 Mark/Close Stale Issues
|
||||
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
|
||||
|
||||
# Issue configuration
|
||||
days-before-issue-stale: 90
|
||||
days-before-issue-close: 7
|
||||
stale-issue-label: "🕰️ stale"
|
||||
exempt-issue-labels: "Roadmap v1,help needed,enhancement"
|
||||
|
||||
# Notifications messages
|
||||
stale-issue-message: |
|
||||
⏳ This issue has been automatically marked as **stale** because it has had no activity for 90 days.
|
||||
|
||||
**Next steps:**
|
||||
- If this is still relevant, add a comment to keep it open
|
||||
- Otherwise, it will be closed in 7 days
|
||||
|
||||
Thank you for your contributions! 🙌
|
||||
|
||||
close-issue-message: |
|
||||
🚮 This issue has been automatically closed due to inactivity (7 days since being marked stale).
|
||||
|
||||
**Need to reopen?**
|
||||
Click "Reopen" and add a comment explaining why this should stay open.
|
||||
|
||||
# Disable PR handling
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
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@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.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 }})
|
||||
11
.gitignore
vendored
@@ -18,7 +18,9 @@ expo-env.d.ts
|
||||
|
||||
Streamyfin.app
|
||||
|
||||
build-*
|
||||
*.mp4
|
||||
build-*
|
||||
Streamyfin.app
|
||||
package-lock.json
|
||||
|
||||
@@ -39,11 +41,4 @@ credentials.json
|
||||
|
||||
.vscode/
|
||||
.idea/
|
||||
.ruby-lsp
|
||||
modules/hls-downloader/android/build
|
||||
streamyfin-4fec1-firebase-adminsdk.json
|
||||
.env
|
||||
.env.local
|
||||
*.aab
|
||||
/version-backup-*
|
||||
bun.lockb
|
||||
.ruby-lsp
|
||||
@@ -1 +0,0 @@
|
||||
lint-staged
|
||||
17
.vscode/settings.json
vendored
@@ -1,24 +1,15 @@
|
||||
{
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"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
|
||||
}
|
||||
}
|
||||
|
||||
6
Makefile
@@ -1,6 +0,0 @@
|
||||
e2e:
|
||||
maestro start-device --platform android
|
||||
maestro test login.yaml
|
||||
|
||||
e2e-setup:
|
||||
curl -fsSL "https://get.maestro.mobile.dev" | bash
|
||||
189
README.md
@@ -1,70 +1,60 @@
|
||||
# 📺 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>
|
||||
|
||||
Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Expo. If you're looking for an alternative to other Jellyfin clients, we hope you'll find Streamyfin to be 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
|
||||
|
||||
- 🚀 **Skip Intro / Credits Support**
|
||||
- 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking.
|
||||
- 🔊 **Background audio**: Stream music in the background, even when locking the phone.
|
||||
- 📥 **Download media** (Experimental): Save your media locally and watch it offline.
|
||||
- 📡 **Chromecast** (Experimental): Cast your media to any Chromecast-enabled device.
|
||||
- 📡 **Settings management** (Experimental): Manage app settings for all your users with a JF plugin.
|
||||
- 🤖 **Jellyseerr integration**: Request media directly in the app.
|
||||
- 👁️ **Sessions View:** View all active sessions currently streaming on your server.
|
||||
|
||||
## 🧪 Experimental Features
|
||||
|
||||
Streamyfin includes some exciting experimental features like media downloading and Chromecast support. These features are still in development, and your patience and feedback are much appreciated as we work to improve them.
|
||||
Streamyfin includes some exciting experimental features like media downloading and Chromecast support. These are still in development, and we appreciate your patience and feedback as we work to improve them.
|
||||
|
||||
### 📥 Downloading
|
||||
### Downloading
|
||||
|
||||
Downloading works by using ffmpeg to convert an HLS stream into a video file on the device. This means that you can download and view any file you can stream! The file is converted by Jellyfin on the server in real time as it is downloaded. This means a **bit longer download times** but supports any file that your server can transcode.
|
||||
|
||||
### 🎥 Chromecast
|
||||
### Chromecast
|
||||
|
||||
Chromecast support is still in development, and we're working on improving it. Currently, it supports casting videos, but we're working on adding support for subtitles and other features.
|
||||
Chromecast support is still in development, and we're working on improving it. Currently, it supports casting videos and audio, but we're working on adding support for subtitles and other features.
|
||||
|
||||
### 🧩 Streamyfin Plugin
|
||||
### Streamyfin Plugin
|
||||
|
||||
The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that holds all settings for the client Streamyfin. This allows you to synchronize settings across all your users, like for example:
|
||||
The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that hold all settings for the client Streamyfin. This allows you to syncronize settings accross all your users, like:
|
||||
|
||||
- Auto log in to Jellyseerr without the user having to do anything
|
||||
- Choose the default languages
|
||||
- Auto log in to Jellyseerr without the user having to do anythin
|
||||
- Choose the default languages
|
||||
- Set download method and search provider
|
||||
- Customize home screen
|
||||
- And much more...
|
||||
- Customize homescreen
|
||||
- And more...
|
||||
|
||||
[Streamyfin Plugin](https://github.com/streamyfin/jellyfin-plugin-streamyfin)
|
||||
|
||||
### 🔍 Jellysearch
|
||||
### 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.
|
||||
|
||||
## 🛣️ Roadmap for V1
|
||||
## Roadmap for V1
|
||||
|
||||
Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) To see what we're working on next, we are always open to feedback and suggestions. Please let us know if you have any ideas or feature requests.
|
||||
Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) to see what we're working on next. We are always open for feedback and suggestions, so please let us know if you have any ideas or feature requests.
|
||||
|
||||
## 📥 Get it now
|
||||
## Get it now
|
||||
|
||||
<div style="display: flex; gap: 5px;">
|
||||
<a href="https://apps.apple.com/app/streamyfin/id6593660679?l=en-GB"><img height=50 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/></a>
|
||||
@@ -73,11 +63,11 @@ Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) To
|
||||
|
||||
Or download the APKs [here on GitHub](https://github.com/streamyfin/streamyfin/releases) for Android.
|
||||
|
||||
### 🧪 Beta testing
|
||||
### Beta testing
|
||||
|
||||
To access the Streamyfin beta, you need to subscribe to the Member tier (or higher) on [Patreon](https://www.patreon.com/streamyfin). This will give you immediate access to the 🧪-public-beta channel on Discord and I'll know that you have subscribed. This is where I post APKs and IPAs. This won't give automatic access to the TestFlight, however, so you need to send me a DM with the email you use for Apple so that I can manually add you.
|
||||
To access the Streamyfin beta, you need to subscribe to the Member tier (or higher) on [Patreon](https://www.patreon.com/streamyfin). This will give you immediate access to the 🧪-public-beta channel on Discord and i'll know that you have subscribed. This is where I post APKs and IPAs. This won't give automatic access to the TestFlight, however, so you need to send me a DM with the email you use for Apple so that i can manually add you.
|
||||
|
||||
**Note**: Everyone who is actively contributing to the source code of Streamyfin will have automatic access to the betas.
|
||||
**Note**: Everyone who is actively contributing to the source code of Streamyfin will have automatic access to the betas.
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
@@ -90,14 +80,13 @@ To access the Streamyfin beta, you need to subscribe to the Member tier (or high
|
||||
|
||||
We welcome any help to make Streamyfin better. If you'd like to contribute, please fork the repository and submit a pull request. For major changes, it's best to open an issue first to discuss your ideas.
|
||||
|
||||
### 👨💻 Development info
|
||||
### Development info
|
||||
|
||||
1. Use node `>20`
|
||||
2. Install dependencies `bun i && bun run submodule-reload`
|
||||
3. Make sure you have xcode and/or android studio installed. (follow the guides for expo: https://docs.expo.dev/workflow/android-studio-emulator/)
|
||||
4. Install BiomeJS extension in VSCode/Your IDE (https://biomejs.dev/)
|
||||
3. Make sure you have xcode and/or android studio installed.
|
||||
4. run `npm run prebuild`
|
||||
5. Create an expo dev build by running `npm run ios` or `npm run android`. This will open a simulator on your computer and run the app.
|
||||
5. Create an expo dev build by running `npm run ios` or `nom run android`. This will open a simulator on your computer and run the app.
|
||||
|
||||
For the TV version suffix the npm commands with `:tv`.
|
||||
|
||||
@@ -116,23 +105,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)
|
||||
|
||||
## ❓ FAQ
|
||||
|
||||
1. Q: Why can't I see my libraries in Streamyfin?
|
||||
A: Make sure your server is running one of the latest versions and that you have at least one library that isn't audio only.
|
||||
2. Q: Why can't I see my music library?
|
||||
A: We don't currently support music and are unlikely to support music in the near future.
|
||||
- Email: [fredrik.burmester@gmail.com](mailto:fredrik.burmester@gmail.com)
|
||||
|
||||
## 📝 Credits
|
||||
|
||||
@@ -140,110 +122,13 @@ Streamyfin is developed by [Fredrik Burmester](https://github.com/fredrikburmest
|
||||
|
||||
## ✨ Acknowledgements
|
||||
|
||||
We would like to thank the Jellyfin team for their great software and awesome support on discord.
|
||||
|
||||
Special shoutout to the JF official clients for being an inspiration to ours.
|
||||
|
||||
### 🏆 Core Developers
|
||||
|
||||
Thanks to the following contributors for their significant contributions:
|
||||
|
||||
<div align="left">
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="https://github.com/Alexk2309">
|
||||
<img src="https://github.com/Alexk2309.png?size=55" width="55" 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%;" />
|
||||
<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%;" />
|
||||
<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%;" />
|
||||
<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%;" />
|
||||
<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%;" />
|
||||
<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%;" />
|
||||
<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%;" />
|
||||
<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%;" />
|
||||
<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%;" />
|
||||
<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.
|
||||
|
||||
I'd also like to thank the following people and projects for their contributions to Streamyfin:
|
||||
I'd like to thank the following people and projects for their contributions to Streamyfin:
|
||||
|
||||
- [Reiverr](https://github.com/aleksilassila/reiverr) for great help with understanding the Jellyfin API.
|
||||
- [Jellyfin TS SDK](https://github.com/jellyfin/jellyfin-sdk-typescript) for the TypeScript SDK.
|
||||
- [Jellyseerr](https://github.com/Fallenbagel/jellyseerr) for enabling API integration with their project.
|
||||
- The Jellyfin devs for always being helpful in the Discord.
|
||||
|
||||
## ⭐ Star History
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#streamyfin/streamyfin&Date)
|
||||
|
||||
## ⚠️ Disclaimer
|
||||
Streamyfin does not promote, support, or condone piracy in any form. The app is intended solely for streaming media that you personally own and control. It does not provide or include any media content. Any discussions or support requests related to piracy are strictly prohibited across all our channels.
|
||||
|
||||
## 🤝 Sponsorship
|
||||
VPS hosting generously provided by [Hexabyte](https://hexabyte.se/en/vps/?currency=eur) and [SweHosting](https://swehosting.se/en/#tj%C3%A4nster)
|
||||
|
||||
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,11 @@
|
||||
module.exports = ({ config }) => {
|
||||
if (process.env.EXPO_TV !== "1") {
|
||||
config.plugins.push("expo-background-task");
|
||||
|
||||
if (process.env.EXPO_TV != "1") {
|
||||
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");
|
||||
}
|
||||
return {
|
||||
android: {
|
||||
googleServicesFile: process.env.GOOGLE_SERVICES_JSON,
|
||||
},
|
||||
...config,
|
||||
};
|
||||
};
|
||||
|
||||
71
app.json
@@ -2,19 +2,28 @@
|
||||
"expo": {
|
||||
"name": "Streamyfin",
|
||||
"slug": "streamyfin",
|
||||
"version": "0.35.1",
|
||||
"version": "0.25.0",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "streamyfin",
|
||||
"userInterfaceStyle": "dark",
|
||||
"splash": {
|
||||
"image": "./assets/images/splash.png",
|
||||
"resizeMode": "contain"
|
||||
},
|
||||
"jsEngine": "hermes",
|
||||
"assetBundlePatterns": ["**/*"],
|
||||
"assetBundlePatterns": [
|
||||
"**/*"
|
||||
],
|
||||
"ios": {
|
||||
"requireFullScreen": true,
|
||||
"infoPlist": {
|
||||
"NSCameraUsageDescription": "The app needs access to your camera to scan barcodes.",
|
||||
"NSMicrophoneUsageDescription": "The app needs access to your microphone.",
|
||||
"UIBackgroundModes": ["audio", "fetch"],
|
||||
"UIBackgroundModes": [
|
||||
"audio",
|
||||
"fetch"
|
||||
],
|
||||
"NSLocalNetworkUsageDescription": "The app needs access to your local network to connect to your Jellyfin server.",
|
||||
"NSAppTransportSecurity": {
|
||||
"NSAllowsArbitraryLoads": true
|
||||
@@ -27,34 +36,26 @@
|
||||
"usesNonExemptEncryption": false
|
||||
},
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.fredrikburmester.streamyfin",
|
||||
"icon": {
|
||||
"dark": "./assets/images/icon-ios-plain.png",
|
||||
"light": "./assets/images/icon-ios-light.png",
|
||||
"tinted": "./assets/images/icon-ios-tinted.png"
|
||||
},
|
||||
"appleTeamId": "MWD5K362T8"
|
||||
"bundleIdentifier": "com.fredrikburmester.streamyfin"
|
||||
},
|
||||
"android": {
|
||||
"jsEngine": "hermes",
|
||||
"versionCode": 67,
|
||||
"versionCode": 50,
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/icon-android-plain.png",
|
||||
"monochromeImage": "./assets/images/icon-android-themed.png",
|
||||
"backgroundColor": "#2E2E2E"
|
||||
"foregroundImage": "./assets/images/adaptive_icon.png"
|
||||
},
|
||||
"package": "com.fredrikburmester.streamyfin",
|
||||
"permissions": [
|
||||
"android.permission.FOREGROUND_SERVICE",
|
||||
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
|
||||
"android.permission.WRITE_SETTINGS"
|
||||
],
|
||||
"googleServicesFile": "./google-services.json"
|
||||
]
|
||||
},
|
||||
"plugins": [
|
||||
"@react-native-tvos/config-tv",
|
||||
"expo-router",
|
||||
"expo-font",
|
||||
"@config-plugins/ffmpeg-kit-react-native",
|
||||
[
|
||||
"react-native-video",
|
||||
{
|
||||
@@ -76,10 +77,11 @@
|
||||
"useFrameworks": "static"
|
||||
},
|
||||
"android": {
|
||||
"compileSdkVersion": 35,
|
||||
"targetSdkVersion": 35,
|
||||
"buildToolsVersion": "35.0.0",
|
||||
"kotlinVersion": "2.0.21",
|
||||
"android": {
|
||||
"compileSdkVersion": 34,
|
||||
"targetSdkVersion": 34,
|
||||
"buildToolsVersion": "34.0.0"
|
||||
},
|
||||
"minSdkVersion": 24,
|
||||
"usesCleartextTraffic": true,
|
||||
"packagingOptions": {
|
||||
@@ -114,27 +116,18 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
["./plugins/withChangeNativeAndroidTextToWhite.js"],
|
||||
["./plugins/withAndroidManifest.js"],
|
||||
["./plugins/withTrustLocalCerts.js"],
|
||||
["./plugins/withGradleProperties.js"],
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
"backgroundColor": "#2e2e2e",
|
||||
"image": "./assets/images/icon-ios-plain.png",
|
||||
"imageWidth": 100
|
||||
}
|
||||
"react-native-bottom-tabs"
|
||||
],
|
||||
[
|
||||
"expo-notifications",
|
||||
{
|
||||
"icon": "./assets/images/notification.png",
|
||||
"color": "#9333EA"
|
||||
}
|
||||
"./plugins/withChangeNativeAndroidTextToWhite.js"
|
||||
],
|
||||
"./plugins/with-runtime-framework-headers.js",
|
||||
"react-native-bottom-tabs"
|
||||
[
|
||||
"./plugins/withGoogleCastActivity.js"
|
||||
],
|
||||
[
|
||||
"./plugins/withTrustLocalCerts.js"
|
||||
]
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
@@ -147,7 +140,7 @@
|
||||
"projectId": "e79219d1-797f-4fbe-9fa1-cfd360690a68"
|
||||
}
|
||||
},
|
||||
"owner": "streamyfin",
|
||||
"owner": "fredrikburmester",
|
||||
"runtimeVersion": {
|
||||
"policy": "appVersion"
|
||||
},
|
||||
@@ -156,4 +149,4 @@
|
||||
},
|
||||
"newArchEnabled": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Stack } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {Stack} from "expo-router";
|
||||
import { Platform } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function CustomMenuLayout() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
name='index'
|
||||
name="index"
|
||||
options={{
|
||||
headerShown: true,
|
||||
headerLargeTitle: true,
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||
import { useAtom } from "jotai/index";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FlatList, Platform, TouchableOpacity, View } from "react-native";
|
||||
import { Platform } from "react-native";
|
||||
import { FlatList, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ListItem } from "@/components/list/ListItem";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useAtom } from "jotai/index";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { ListItem } from "@/components/list/ListItem";
|
||||
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const WebBrowser = !Platform.isTV ? require("expo-web-browser") : null;
|
||||
|
||||
@@ -25,11 +26,11 @@ export default function menuLinks() {
|
||||
const getMenuLinks = useCallback(async () => {
|
||||
try {
|
||||
const response = await api?.axiosInstance.get(
|
||||
`${api?.basePath}/web/config.json`,
|
||||
api?.basePath + "/web/config.json"
|
||||
);
|
||||
const config = response?.data;
|
||||
|
||||
if (!config && !Object.hasOwn(config, "menuLinks")) {
|
||||
if (!config && !config.hasOwnProperty("menuLinks")) {
|
||||
console.error("Menu links not found");
|
||||
return;
|
||||
}
|
||||
@@ -45,7 +46,7 @@ export default function menuLinks() {
|
||||
}, []);
|
||||
return (
|
||||
<FlatList
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
contentContainerStyle={{
|
||||
paddingTop: 10,
|
||||
paddingLeft: insets.left,
|
||||
@@ -62,7 +63,7 @@ export default function menuLinks() {
|
||||
>
|
||||
<ListItem
|
||||
title={item.name}
|
||||
iconAfter={<Ionicons name='link' size={24} color='white' />}
|
||||
iconAfter={<Ionicons name="link" size={24} color="white" />}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
@@ -75,10 +76,8 @@ export default function menuLinks() {
|
||||
/>
|
||||
)}
|
||||
ListEmptyComponent={
|
||||
<View className='flex flex-col items-center justify-center h-full'>
|
||||
<Text className='font-bold text-xl text-neutral-500'>
|
||||
{t("custom_links.no_links")}
|
||||
</Text>
|
||||
<View className="flex flex-col items-center justify-center h-full">
|
||||
<Text className="font-bold text-xl text-neutral-500">{t("custom_links.no_links")}</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import { Stack } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform } from "react-native";
|
||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||
import { Stack } from "expo-router";
|
||||
import { Platform } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function SearchLayout() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
name='index'
|
||||
name="index"
|
||||
options={{
|
||||
headerShown: !Platform.isTV,
|
||||
headerShown: true,
|
||||
headerLargeTitle: true,
|
||||
headerTitle: t("tabs.favorites"),
|
||||
headerLargeStyle: {
|
||||
backgroundColor: "black",
|
||||
},
|
||||
headerBlurEffect: "prominent",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { RefreshControl, ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Favorites } from "@/components/home/Favorites";
|
||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { RefreshControl, ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
export default function favorites() {
|
||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||
@@ -18,7 +18,7 @@ export default function favorites() {
|
||||
return (
|
||||
<ScrollView
|
||||
nestedScrollEnabled
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={loading} onRefresh={refetch} />
|
||||
}
|
||||
@@ -28,7 +28,7 @@ export default function favorites() {
|
||||
paddingBottom: 16,
|
||||
}}
|
||||
>
|
||||
<View className='my-4'>
|
||||
<View className="my-4">
|
||||
<Favorites />
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
@@ -1,41 +1,41 @@
|
||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||
import { Stack, useRouter } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||
|
||||
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
|
||||
|
||||
import { useAtom } from "jotai";
|
||||
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
|
||||
import { userAtom } from "@/providers/JellyfinProvider";
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { Stack, useRouter } from "expo-router";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { lazy } from "react";
|
||||
// const Chromecast = !Platform.isTV ? require("@/components/Chromecast") : null;
|
||||
const Chromecast = lazy(() => import("@/components/Chromecast"));
|
||||
|
||||
export default function IndexLayout() {
|
||||
const _router = useRouter();
|
||||
const [user] = useAtom(userAtom);
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
name='index'
|
||||
name="index"
|
||||
options={{
|
||||
headerShown: !Platform.isTV,
|
||||
headerShown: true,
|
||||
headerLargeTitle: true,
|
||||
headerTitle: t("tabs.home"),
|
||||
headerBlurEffect: "prominent",
|
||||
headerLargeStyle: {
|
||||
backgroundColor: "black",
|
||||
},
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||
headerShadowVisible: false,
|
||||
headerRight: () => (
|
||||
<View className='flex flex-row items-center space-x-2'>
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
{!Platform.isTV && (
|
||||
<>
|
||||
<Chromecast.Chromecast />
|
||||
{user?.Policy?.IsAdministrator && <SessionsButton />}
|
||||
<SettingsButton />
|
||||
<Chromecast />
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push("/(auth)/settings");
|
||||
}}
|
||||
>
|
||||
<Feather name="settings" color={"white"} size={22} />
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
@@ -43,55 +43,55 @@ export default function IndexLayout() {
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='downloads/index'
|
||||
name="downloads/index"
|
||||
options={{
|
||||
title: t("home.downloads.downloads_title"),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='downloads/[seriesId]'
|
||||
name="downloads/[seriesId]"
|
||||
options={{
|
||||
title: t("home.downloads.tvseries"),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='sessions/index'
|
||||
options={{
|
||||
title: t("home.sessions.title"),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='settings'
|
||||
name="settings"
|
||||
options={{
|
||||
title: t("home.settings.settings_title"),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='settings/marlin-search/page'
|
||||
name="settings/optimized-server/page"
|
||||
options={{
|
||||
title: "",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='settings/jellyseerr/page'
|
||||
name="settings/marlin-search/page"
|
||||
options={{
|
||||
title: "",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='settings/hide-libraries/page'
|
||||
name="settings/jellyseerr/page"
|
||||
options={{
|
||||
title: "",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='settings/logs/page'
|
||||
name="settings/hide-libraries/page"
|
||||
options={{
|
||||
title: "",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='intro/page'
|
||||
name="settings/logs/page"
|
||||
options={{
|
||||
title: "",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="intro/page"
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
@@ -102,50 +102,15 @@ export default function IndexLayout() {
|
||||
<Stack.Screen key={name} name={name} options={options} />
|
||||
))}
|
||||
<Stack.Screen
|
||||
name='collections/[collectionId]'
|
||||
name="collections/[collectionId]"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: true,
|
||||
headerBlurEffect: "prominent",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const SettingsButton = () => {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push("/(auth)/settings");
|
||||
}}
|
||||
>
|
||||
<Feather name='settings' color={"white"} size={22} />
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const SessionsButton = () => {
|
||||
const router = useRouter();
|
||||
const { sessions = [] } = useSessions({} as useSessionsProps);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push("/(auth)/sessions");
|
||||
}}
|
||||
>
|
||||
<View className='mr-4'>
|
||||
<Ionicons
|
||||
name='play-circle'
|
||||
color={sessions.length === 0 ? "white" : "#9333ea"}
|
||||
size={25}
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { ScrollView, TouchableOpacity, View, Alert } from "react-native";
|
||||
import { EpisodeCard } from "@/components/downloads/EpisodeCard";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import {
|
||||
SeasonDropdown,
|
||||
type SeasonIndexState,
|
||||
SeasonIndexState,
|
||||
} from "@/components/series/SeasonDropdown";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
|
||||
export default function page() {
|
||||
const navigation = useNavigation();
|
||||
@@ -21,53 +21,23 @@ export default function page() {
|
||||
};
|
||||
|
||||
const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>(
|
||||
{},
|
||||
{}
|
||||
);
|
||||
const { getDownloadedItems, deleteItems } = useDownload();
|
||||
const { downloadedFiles, deleteItems } = useDownload();
|
||||
|
||||
const series = useMemo(() => {
|
||||
try {
|
||||
return (
|
||||
getDownloadedItems()
|
||||
?.filter((f) => f.item.SeriesId === seriesId)
|
||||
downloadedFiles
|
||||
?.filter((f) => f.item.SeriesId == seriesId)
|
||||
?.sort(
|
||||
(a, b) => a?.item.ParentIndexNumber! - b.item.ParentIndexNumber!,
|
||||
(a, b) => a?.item.ParentIndexNumber! - b.item.ParentIndexNumber!
|
||||
) || []
|
||||
);
|
||||
} 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,14 +45,26 @@ 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(
|
||||
() =>
|
||||
Object.values(groupBySeason)?.[0]?.ParentIndexNumber ??
|
||||
series?.[0]?.item?.ParentIndexNumber,
|
||||
[groupBySeason],
|
||||
[groupBySeason]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -110,17 +92,17 @@ export default function page() {
|
||||
onPress: () => deleteItems(groupBySeason),
|
||||
style: "destructive",
|
||||
},
|
||||
],
|
||||
]
|
||||
);
|
||||
}, [groupBySeason]);
|
||||
|
||||
return (
|
||||
<View className='flex-1'>
|
||||
<View className="flex-1">
|
||||
{series.length > 0 && (
|
||||
<View className='flex flex-row items-center justify-start my-2 px-4'>
|
||||
<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) => {
|
||||
@@ -130,17 +112,17 @@ export default function page() {
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center ml-2'>
|
||||
<Text className='text-xs font-bold'>{groupBySeason.length}</Text>
|
||||
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center ml-2">
|
||||
<Text className="text-xs font-bold">{groupBySeason.length}</Text>
|
||||
</View>
|
||||
<View className='bg-neutral-800/80 rounded-full h-9 w-9 flex items-center justify-center ml-auto'>
|
||||
<View className="bg-neutral-800/80 rounded-full h-9 w-9 flex items-center justify-center ml-auto">
|
||||
<TouchableOpacity onPress={deleteSeries}>
|
||||
<Ionicons name='trash' size={20} color='white' />
|
||||
<Ionicons name="trash" size={20} color="white" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
<ScrollView key={seasonIndex} className='px-4'>
|
||||
<ScrollView key={seasonIndex} className="px-4">
|
||||
{groupBySeason.map((episode, index) => (
|
||||
<EpisodeCard key={index} item={episode} />
|
||||
))}
|
||||
|
||||
@@ -1,74 +1,43 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ActiveDownloads } from "@/components/downloads/ActiveDownloads";
|
||||
import { MovieCard } from "@/components/downloads/MovieCard";
|
||||
import { SeriesCard } from "@/components/downloads/SeriesCard";
|
||||
import { DownloadedItem, useDownload } from "@/providers/DownloadProvider";
|
||||
import { queueAtom } from "@/utils/atoms/queue";
|
||||
import {DownloadMethod, useSettings} from "@/utils/atoms/settings";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useNavigation, useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useEffect, useMemo, useRef } from "react";
|
||||
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import { Button } from "@/components/Button";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { t } from 'i18next';
|
||||
import { DownloadSize } from "@/components/downloads/DownloadSize";
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
type BottomSheetBackdropProps,
|
||||
BottomSheetBackdropProps,
|
||||
BottomSheetModal,
|
||||
BottomSheetView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { useNavigation, useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
|
||||
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 { 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 { queueAtom } from "@/utils/atoms/queue";
|
||||
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]);
|
||||
@@ -76,7 +45,7 @@ export default function page() {
|
||||
const groupedBySeries = useMemo(() => {
|
||||
try {
|
||||
const episodes = downloadedFiles?.filter(
|
||||
(f) => f.item.Type === "Episode",
|
||||
(f) => f.item.Type === "Episode"
|
||||
);
|
||||
const series: { [key: string]: DownloadedItem[] } = {};
|
||||
episodes?.forEach((e) => {
|
||||
@@ -85,11 +54,13 @@ export default function page() {
|
||||
});
|
||||
return Object.values(series);
|
||||
} catch {
|
||||
setShowMigration(true);
|
||||
migration_20241124();
|
||||
return [];
|
||||
}
|
||||
}, [downloadedFiles]);
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerRight: () => (
|
||||
@@ -100,30 +71,16 @@ export default function page() {
|
||||
});
|
||||
}, [downloadedFiles]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showMigration) {
|
||||
migration_20241124();
|
||||
}
|
||||
}, [showMigration]);
|
||||
|
||||
const deleteMovies = () =>
|
||||
deleteFileByType("Movie")
|
||||
.then(() =>
|
||||
toast.success(
|
||||
t("home.downloads.toasts.deleted_all_movies_successfully"),
|
||||
),
|
||||
)
|
||||
.then(() => toast.success(t("home.downloads.toasts.deleted_all_movies_successfully")))
|
||||
.catch((reason) => {
|
||||
writeToLog("ERROR", reason);
|
||||
toast.error(t("home.downloads.toasts.failed_to_delete_all_movies"));
|
||||
});
|
||||
const deleteShows = () =>
|
||||
deleteFileByType("Episode")
|
||||
.then(() =>
|
||||
toast.success(
|
||||
t("home.downloads.toasts.deleted_all_tvseries_successfully"),
|
||||
),
|
||||
)
|
||||
.then(() => toast.success(t("home.downloads.toasts.deleted_all_tvseries_successfully")))
|
||||
.catch((reason) => {
|
||||
writeToLog("ERROR", reason);
|
||||
toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
|
||||
@@ -133,29 +90,33 @@ export default function page() {
|
||||
|
||||
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'>
|
||||
<View className='bg-neutral-900 p-4 rounded-2xl'>
|
||||
<Text className='text-lg font-bold'>
|
||||
{t("home.downloads.queue")}
|
||||
</Text>
|
||||
<Text className='text-xs opacity-70 text-red-600'>
|
||||
<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")}</Text>
|
||||
<Text className="text-xs opacity-70 text-red-600">
|
||||
{t("home.downloads.queue_hint")}
|
||||
</Text>
|
||||
<View className='flex flex-col space-y-2 mt-2'>
|
||||
<View className="flex flex-col space-y-2 mt-2">
|
||||
{queue.map((q, index) => (
|
||||
<TouchableOpacity
|
||||
onPress={() =>
|
||||
router.push(`/(auth)/items/page?id=${q.item.Id}`)
|
||||
}
|
||||
className='relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between'
|
||||
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
|
||||
key={index}
|
||||
>
|
||||
<View>
|
||||
<Text className='font-semibold'>{q.item.Name}</Text>
|
||||
<Text className='text-xs opacity-50'>
|
||||
<Text className="font-semibold">{q.item.Name}</Text>
|
||||
<Text className="text-xs opacity-50">
|
||||
{q.item.Type}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -168,86 +129,74 @@ export default function page() {
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Ionicons name='close' size={24} color='red' />
|
||||
<Ionicons name="close" size={24} color="red" />
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{queue.length === 0 && (
|
||||
<Text className='opacity-50'>
|
||||
{t("home.downloads.no_items_in_queue")}
|
||||
</Text>
|
||||
<Text className="opacity-50">{t("home.downloads.no_items_in_queue")}</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>
|
||||
)}
|
||||
{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
|
||||
@@ -266,14 +215,14 @@ export default function page() {
|
||||
)}
|
||||
>
|
||||
<BottomSheetView>
|
||||
<View className='p-4 space-y-4 mb-4'>
|
||||
<Button color='purple' onPress={deleteMovies}>
|
||||
<View className="p-4 space-y-4 mb-4">
|
||||
<Button color="purple" onPress={deleteMovies}>
|
||||
{t("home.downloads.delete_all_movies_button")}
|
||||
</Button>
|
||||
<Button color='purple' onPress={deleteShows}>
|
||||
<Button color="purple" onPress={deleteShows}>
|
||||
{t("home.downloads.delete_all_tvseries_button")}
|
||||
</Button>
|
||||
<Button color='red' onPress={deleteAllMedia}>
|
||||
<Button color="red" onPress={deleteAllMedia}>
|
||||
{t("home.downloads.delete_all_button")}
|
||||
</Button>
|
||||
</View>
|
||||
@@ -282,3 +231,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(),
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,498 @@
|
||||
import { HomeIndex } from "@/components/settings/HomeIndex";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel";
|
||||
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||
import { Api } from "@jellyfin/sdk";
|
||||
import {
|
||||
BaseItemDto,
|
||||
BaseItemKind,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import {
|
||||
getItemsApi,
|
||||
getSuggestionsApi,
|
||||
getTvShowsApi,
|
||||
getUserLibraryApi,
|
||||
getUserViewsApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import NetInfo from "@react-native-community/netinfo";
|
||||
import { QueryFunction, useQuery } from "@tanstack/react-query";
|
||||
import { useNavigation, useRouter } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import {
|
||||
useSplashScreenLoading,
|
||||
useSplashScreenVisible,
|
||||
} from "@/providers/SplashScreenProvider";
|
||||
|
||||
export default function page() {
|
||||
return <HomeIndex />;
|
||||
type ScrollingCollectionListSection = {
|
||||
type: "ScrollingCollectionList";
|
||||
title?: string;
|
||||
queryKey: (string | undefined | null)[];
|
||||
queryFn: QueryFunction<BaseItemDto[]>;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
};
|
||||
|
||||
type MediaListSection = {
|
||||
type: "MediaListSection";
|
||||
queryKey: (string | undefined)[];
|
||||
queryFn: QueryFunction<BaseItemDto>;
|
||||
};
|
||||
|
||||
type Section = ScrollingCollectionListSection | MediaListSection;
|
||||
|
||||
export default function index() {
|
||||
const router = useRouter();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [
|
||||
settings,
|
||||
updateSettings,
|
||||
pluginSettings,
|
||||
setPluginSettings,
|
||||
refreshStreamyfinPluginSettings,
|
||||
] = useSettings();
|
||||
|
||||
const [isConnected, setIsConnected] = useState<boolean | null>(null);
|
||||
const [loadingRetry, setLoadingRetry] = useState(false);
|
||||
|
||||
const navigation = useNavigation();
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
if (!Platform.isTV) {
|
||||
const { downloadedFiles, cleanCacheDirectory } = useDownload();
|
||||
useEffect(() => {
|
||||
const hasDownloads = downloadedFiles && downloadedFiles.length > 0;
|
||||
navigation.setOptions({
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push("/(auth)/downloads");
|
||||
}}
|
||||
className="p-2"
|
||||
>
|
||||
<Feather
|
||||
name="download"
|
||||
color={hasDownloads ? Colors.primary : "white"}
|
||||
size={22}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
),
|
||||
});
|
||||
}, [downloadedFiles, navigation, router]);
|
||||
|
||||
useEffect(() => {
|
||||
cleanCacheDirectory().catch((e) =>
|
||||
console.error("Something went wrong cleaning cache directory")
|
||||
);
|
||||
}, []);
|
||||
}
|
||||
|
||||
const checkConnection = useCallback(async () => {
|
||||
setLoadingRetry(true);
|
||||
const state = await NetInfo.fetch();
|
||||
setIsConnected(state.isConnected);
|
||||
setLoadingRetry(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = NetInfo.addEventListener((state) => {
|
||||
if (state.isConnected == false || state.isInternetReachable === false)
|
||||
setIsConnected(false);
|
||||
else setIsConnected(true);
|
||||
});
|
||||
|
||||
NetInfo.fetch().then((state) => {
|
||||
setIsConnected(state.isConnected);
|
||||
});
|
||||
|
||||
// cleanCacheDirectory().catch((e) =>
|
||||
// console.error("Something went wrong cleaning cache directory")
|
||||
// );
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const {
|
||||
data,
|
||||
isError: e1,
|
||||
isLoading: l1,
|
||||
} = useQuery({
|
||||
queryKey: ["home", "userViews", user?.Id],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await getUserViewsApi(api).getUserViews({
|
||||
userId: user.Id,
|
||||
});
|
||||
|
||||
return response.data.Items || null;
|
||||
},
|
||||
enabled: !!api && !!user?.Id,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
// show splash screen until query loaded
|
||||
useSplashScreenLoading(l1);
|
||||
const splashScreenVisible = useSplashScreenVisible();
|
||||
|
||||
const userViews = useMemo(
|
||||
() => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
|
||||
[data, settings?.hiddenLibraries]
|
||||
);
|
||||
|
||||
const collections = useMemo(() => {
|
||||
const allow = ["movies", "tvshows"];
|
||||
return (
|
||||
userViews?.filter(
|
||||
(c) => c.CollectionType && allow.includes(c.CollectionType)
|
||||
) || []
|
||||
);
|
||||
}, [userViews]);
|
||||
|
||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||
|
||||
const refetch = useCallback(async () => {
|
||||
setLoading(true);
|
||||
await refreshStreamyfinPluginSettings();
|
||||
await invalidateCache();
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const createCollectionConfig = useCallback(
|
||||
(
|
||||
title: string,
|
||||
queryKey: string[],
|
||||
includeItemTypes: BaseItemKind[],
|
||||
parentId: string | undefined
|
||||
): ScrollingCollectionListSection => ({
|
||||
title,
|
||||
queryKey,
|
||||
queryFn: async () => {
|
||||
if (!api) return [];
|
||||
return (
|
||||
(
|
||||
await getUserLibraryApi(api).getLatestMedia({
|
||||
userId: user?.Id,
|
||||
limit: 20,
|
||||
fields: ["PrimaryImageAspectRatio", "Path"],
|
||||
imageTypeLimit: 1,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
includeItemTypes,
|
||||
parentId,
|
||||
})
|
||||
).data || []
|
||||
);
|
||||
},
|
||||
type: "ScrollingCollectionList",
|
||||
}),
|
||||
[api, user?.Id]
|
||||
);
|
||||
|
||||
let sections: Section[] = [];
|
||||
if (!settings?.home || !settings?.home?.sections) {
|
||||
sections = useMemo(() => {
|
||||
if (!api || !user?.Id) return [];
|
||||
|
||||
const latestMediaViews = collections.map((c) => {
|
||||
const includeItemTypes: BaseItemKind[] =
|
||||
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"];
|
||||
const title = t("home.recently_added_in", { libraryName: c.Name });
|
||||
const queryKey = [
|
||||
"home",
|
||||
"recentlyAddedIn" + c.CollectionType,
|
||||
user?.Id!,
|
||||
c.Id!,
|
||||
];
|
||||
return createCollectionConfig(
|
||||
title || "",
|
||||
queryKey,
|
||||
includeItemTypes,
|
||||
c.Id
|
||||
);
|
||||
});
|
||||
|
||||
const ss: Section[] = [
|
||||
{
|
||||
title: t("home.continue_watching"),
|
||||
queryKey: ["home", "resumeItems"],
|
||||
queryFn: async () =>
|
||||
(
|
||||
await getItemsApi(api).getResumeItems({
|
||||
userId: user.Id,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||
})
|
||||
).data.Items || [],
|
||||
type: "ScrollingCollectionList",
|
||||
orientation: "horizontal",
|
||||
},
|
||||
{
|
||||
title: t("home.next_up"),
|
||||
queryKey: ["home", "nextUp-all"],
|
||||
queryFn: async () =>
|
||||
(
|
||||
await getTvShowsApi(api).getNextUp({
|
||||
userId: user?.Id,
|
||||
fields: ["MediaSourceCount"],
|
||||
limit: 20,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
enableResumable: false,
|
||||
})
|
||||
).data.Items || [],
|
||||
type: "ScrollingCollectionList",
|
||||
orientation: "horizontal",
|
||||
},
|
||||
...latestMediaViews,
|
||||
// ...(mediaListCollections?.map(
|
||||
// (ml) =>
|
||||
// ({
|
||||
// title: ml.Name,
|
||||
// queryKey: ["home", "mediaList", ml.Id!],
|
||||
// queryFn: async () => ml,
|
||||
// type: "MediaListSection",
|
||||
// orientation: "vertical",
|
||||
// } as Section)
|
||||
// ) || []),
|
||||
{
|
||||
title: t("home.suggested_movies"),
|
||||
queryKey: ["home", "suggestedMovies", user?.Id],
|
||||
queryFn: async () =>
|
||||
(
|
||||
await getSuggestionsApi(api).getSuggestions({
|
||||
userId: user?.Id,
|
||||
limit: 10,
|
||||
mediaType: ["Video"],
|
||||
type: ["Movie"],
|
||||
})
|
||||
).data.Items || [],
|
||||
type: "ScrollingCollectionList",
|
||||
orientation: "vertical",
|
||||
},
|
||||
{
|
||||
title: t("home.suggested_episodes"),
|
||||
queryKey: ["home", "suggestedEpisodes", user?.Id],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const suggestions = await getSuggestions(api, user.Id);
|
||||
const nextUpPromises = suggestions.map((series) =>
|
||||
getNextUp(api, user.Id, series.Id)
|
||||
);
|
||||
const nextUpResults = await Promise.all(nextUpPromises);
|
||||
|
||||
return nextUpResults.filter((item) => item !== null) || [];
|
||||
} catch (error) {
|
||||
console.error("Error fetching data:", error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
type: "ScrollingCollectionList",
|
||||
orientation: "horizontal",
|
||||
},
|
||||
];
|
||||
return ss;
|
||||
}, [api, user?.Id, collections]);
|
||||
} else {
|
||||
sections = useMemo(() => {
|
||||
if (!api || !user?.Id) return [];
|
||||
const ss: Section[] = [];
|
||||
|
||||
for (const key in settings.home?.sections) {
|
||||
// @ts-expect-error
|
||||
const section = settings.home?.sections[key];
|
||||
const id = section.title || key;
|
||||
ss.push({
|
||||
title: id,
|
||||
queryKey: ["home", id],
|
||||
queryFn: async () => {
|
||||
if (section.items) {
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user?.Id,
|
||||
limit: section.items?.limit || 25,
|
||||
recursive: true,
|
||||
includeItemTypes: section.items?.includeItemTypes,
|
||||
sortBy: section.items?.sortBy,
|
||||
sortOrder: section.items?.sortOrder,
|
||||
filters: section.items?.filters,
|
||||
parentId: section.items?.parentId,
|
||||
});
|
||||
return response.data.Items || [];
|
||||
} else if (section.nextUp) {
|
||||
const response = await getTvShowsApi(api).getNextUp({
|
||||
userId: user?.Id,
|
||||
fields: ["MediaSourceCount"],
|
||||
limit: section.items?.limit || 25,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
enableResumable: section.items?.enableResumable || false,
|
||||
enableRewatching: section.items?.enableRewatching || false,
|
||||
});
|
||||
return response.data.Items || [];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
type: "ScrollingCollectionList",
|
||||
orientation: section?.orientation || "vertical",
|
||||
});
|
||||
}
|
||||
return ss;
|
||||
}, [api, user?.Id, settings.home?.sections]);
|
||||
}
|
||||
|
||||
if (isConnected === false) {
|
||||
return (
|
||||
<View className="flex flex-col items-center justify-center h-full -mt-6 px-8">
|
||||
<Text className="text-3xl font-bold mb-2">{t("home.no_internet")}</Text>
|
||||
<Text className="text-center opacity-70">
|
||||
{t("home.no_internet_message")}
|
||||
</Text>
|
||||
<View className="mt-4">
|
||||
<Button
|
||||
color="purple"
|
||||
onPress={() => router.push("/(auth)/downloads")}
|
||||
justify="center"
|
||||
iconRight={
|
||||
<Ionicons name="arrow-forward" size={20} color="white" />
|
||||
}
|
||||
>
|
||||
{t("home.go_to_downloads")}
|
||||
</Button>
|
||||
<Button
|
||||
color="black"
|
||||
onPress={() => {
|
||||
checkConnection();
|
||||
}}
|
||||
justify="center"
|
||||
className="mt-2"
|
||||
iconRight={
|
||||
loadingRetry ? null : (
|
||||
<Ionicons name="refresh" size={20} color="white" />
|
||||
)
|
||||
}
|
||||
>
|
||||
{loadingRetry ? (
|
||||
<ActivityIndicator size={"small"} color={"white"} />
|
||||
) : (
|
||||
"Retry"
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (e1)
|
||||
return (
|
||||
<View className="flex flex-col items-center justify-center h-full -mt-6">
|
||||
<Text className="text-3xl font-bold mb-2">{t("home.oops")}</Text>
|
||||
<Text className="text-center opacity-70">
|
||||
{t("home.error_message")}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
// this spinner should only show up, when user navigates here
|
||||
// on launch the splash screen is used for loading
|
||||
if (l1 && !splashScreenVisible)
|
||||
return (
|
||||
<View className="justify-center items-center h-full">
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
nestedScrollEnabled
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={loading} onRefresh={refetch} />
|
||||
}
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: 16,
|
||||
}}
|
||||
>
|
||||
<View className="flex flex-col space-y-4">
|
||||
<LargeMovieCarousel />
|
||||
|
||||
{sections.map((section, index) => {
|
||||
if (section.type === "ScrollingCollectionList") {
|
||||
return (
|
||||
<ScrollingCollectionList
|
||||
key={index}
|
||||
title={section.title}
|
||||
queryKey={section.queryKey}
|
||||
queryFn={section.queryFn}
|
||||
orientation={section.orientation}
|
||||
hideIfEmpty
|
||||
/>
|
||||
);
|
||||
} else if (section.type === "MediaListSection") {
|
||||
return (
|
||||
<MediaListSection
|
||||
key={index}
|
||||
queryKey={section.queryKey}
|
||||
queryFn={section.queryFn}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
// Function to get suggestions
|
||||
async function getSuggestions(api: Api, userId: string | undefined) {
|
||||
if (!userId) return [];
|
||||
const response = await getSuggestionsApi(api).getSuggestions({
|
||||
userId,
|
||||
limit: 10,
|
||||
mediaType: ["Unknown"],
|
||||
type: ["Series"],
|
||||
});
|
||||
return response.data.Items ?? [];
|
||||
}
|
||||
|
||||
// Function to get the next up TV show for a series
|
||||
async function getNextUp(
|
||||
api: Api,
|
||||
userId: string | undefined,
|
||||
seriesId: string | undefined
|
||||
) {
|
||||
if (!userId || !seriesId) return null;
|
||||
const response = await getTvShowsApi(api).getNextUp({
|
||||
userId,
|
||||
seriesId,
|
||||
limit: 1,
|
||||
});
|
||||
return response.data.Items?.[0] ?? null;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||
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 { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { Linking, TouchableOpacity, View } from "react-native";
|
||||
|
||||
export default function page() {
|
||||
const router = useRouter();
|
||||
@@ -15,28 +15,26 @@ export default function page() {
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
storage.set("hasShownIntro", true);
|
||||
}, []),
|
||||
}, [])
|
||||
);
|
||||
|
||||
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'>
|
||||
<Text className="text-3xl font-bold text-center mb-2">
|
||||
{t("home.intro.welcome_to_streamyfin")}
|
||||
</Text>
|
||||
<Text className='text-center'>
|
||||
<Text className="text-center">
|
||||
{t("home.intro.a_free_and_open_source_client_for_jellyfin")}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<Text className='text-lg font-bold'>
|
||||
<Text className="text-lg font-bold">
|
||||
{t("home.intro.features_title")}
|
||||
</Text>
|
||||
<Text className='text-xs'>{t("home.intro.features_description")}</Text>
|
||||
<View className='flex flex-row items-center mt-4'>
|
||||
<Text className="text-xs">{t("home.intro.features_description")}</Text>
|
||||
<View className="flex flex-row items-center mt-4">
|
||||
<Image
|
||||
source={require("@/assets/icons/jellyseerr-logo.svg")}
|
||||
style={{
|
||||
@@ -44,87 +42,76 @@ export default function page() {
|
||||
height: 50,
|
||||
}}
|
||||
/>
|
||||
<View className='shrink ml-2'>
|
||||
<Text className='font-bold mb-1'>Jellyseerr</Text>
|
||||
<Text className='shrink text-xs'>
|
||||
<View className="shrink ml-2">
|
||||
<Text className="font-bold mb-1">Jellyseerr</Text>
|
||||
<Text className="shrink text-xs">
|
||||
{t("home.intro.jellyseerr_feature_description")}
|
||||
</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 className="flex flex-row items-center mt-4">
|
||||
<View
|
||||
style={{
|
||||
width: 50,
|
||||
height: 50,
|
||||
}}
|
||||
className='flex items-center justify-center'
|
||||
className="flex items-center justify-center"
|
||||
>
|
||||
<Feather name='settings' size={28} color={"white"} />
|
||||
<Ionicons name="cloud-download-outline" size={32} color="white" />
|
||||
</View>
|
||||
<View className='shrink ml-2'>
|
||||
<Text className='font-bold mb-1'>
|
||||
<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"
|
||||
>
|
||||
<Feather name="settings" size={28} color={"white"} />
|
||||
</View>
|
||||
<View className="shrink ml-2">
|
||||
<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",
|
||||
"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>
|
||||
@@ -133,7 +120,7 @@ export default function page() {
|
||||
onPress={() => {
|
||||
router.back();
|
||||
}}
|
||||
className='mt-4'
|
||||
className="mt-4"
|
||||
>
|
||||
{t("home.intro.done_button")}
|
||||
</Button>
|
||||
@@ -142,9 +129,9 @@ export default function page() {
|
||||
router.back();
|
||||
router.push("/settings");
|
||||
}}
|
||||
className='mt-4'
|
||||
className="mt-4"
|
||||
>
|
||||
<Text className='text-purple-600 text-center'>
|
||||
<Text className="text-purple-600 text-center">
|
||||
{t("home.intro.go_to_settings_button")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -1,550 +0,0 @@
|
||||
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import {
|
||||
HardwareAccelerationType,
|
||||
type SessionInfoDto,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import {
|
||||
GeneralCommandType,
|
||||
PlaystateCommand,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { Badge } from "@/components/Badge";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import Poster from "@/components/posters/Poster";
|
||||
import { useInterval } from "@/hooks/useInterval";
|
||||
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { formatBitrate } from "@/utils/bitrate";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { formatTimeString } from "@/utils/time";
|
||||
|
||||
export default function page() {
|
||||
const { sessions, isLoading } = useSessions({} as useSessionsProps);
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<View className='justify-center items-center h-full'>
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
|
||||
if (!sessions || sessions.length === 0)
|
||||
return (
|
||||
<View className='h-full w-full flex justify-center items-center'>
|
||||
<Text className='text-lg text-neutral-500'>
|
||||
{t("home.sessions.no_active_sessions")}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<FlashList
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentContainerStyle={{
|
||||
paddingTop: 17,
|
||||
paddingHorizontal: 17,
|
||||
paddingBottom: 150,
|
||||
}}
|
||||
data={sessions}
|
||||
renderItem={({ item }) => <SessionCard session={item} />}
|
||||
keyExtractor={(item) => item.Id || ""}
|
||||
estimatedItemSize={200}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface SessionCardProps {
|
||||
session: SessionInfoDto;
|
||||
}
|
||||
|
||||
const SessionCard = ({ session }: SessionCardProps) => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const [remainingTicks, setRemainingTicks] = useState<number>(0);
|
||||
|
||||
const tick = () => {
|
||||
if (session.PlayState?.IsPaused) return;
|
||||
setRemainingTicks(remainingTicks - 10000000);
|
||||
};
|
||||
|
||||
const getProgressPercentage = () => {
|
||||
if (!session.NowPlayingItem || !session.NowPlayingItem.RunTimeTicks) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.round(
|
||||
(100 / session.NowPlayingItem?.RunTimeTicks) *
|
||||
(session.NowPlayingItem?.RunTimeTicks - remainingTicks),
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const currentTime = session.PlayState?.PositionTicks;
|
||||
const duration = session.NowPlayingItem?.RunTimeTicks;
|
||||
if (
|
||||
duration !== null &&
|
||||
duration !== undefined &&
|
||||
currentTime !== null &&
|
||||
currentTime !== undefined
|
||||
) {
|
||||
const remainingTimeTicks = duration - currentTime;
|
||||
setRemainingTicks(remainingTimeTicks);
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
const { data: ipInfo } = useQuery<{
|
||||
cityName?: string;
|
||||
countryCode?: string;
|
||||
}>({
|
||||
queryKey: ["ipinfo", session.RemoteEndPoint],
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
queryFn: async () => {
|
||||
const resp = await api!.axiosInstance.get(
|
||||
`https://freeipapi.com/api/json/${session.RemoteEndPoint}`,
|
||||
);
|
||||
return resp.data;
|
||||
},
|
||||
enabled: !!api,
|
||||
});
|
||||
|
||||
// Handle session controls
|
||||
const [isControlLoading, setIsControlLoading] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
|
||||
const handleSystemCommand = async (command: GeneralCommandType) => {
|
||||
if (!api || !session.Id) return false;
|
||||
|
||||
setIsControlLoading({ ...isControlLoading, [command]: true });
|
||||
|
||||
try {
|
||||
getSessionApi(api).sendSystemCommand({
|
||||
sessionId: session.Id,
|
||||
command,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Error sending ${command} command:`, error);
|
||||
return false;
|
||||
} finally {
|
||||
setIsControlLoading({ ...isControlLoading, [command]: false });
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlaystateCommand = async (command: PlaystateCommand) => {
|
||||
if (!api || !session.Id) return false;
|
||||
|
||||
setIsControlLoading({ ...isControlLoading, [command]: true });
|
||||
|
||||
try {
|
||||
getSessionApi(api).sendPlaystateCommand({
|
||||
sessionId: session.Id,
|
||||
command,
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Error sending playstate ${command} command:`, error);
|
||||
return false;
|
||||
} finally {
|
||||
setIsControlLoading({ ...isControlLoading, [command]: false });
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlayPause = async () => {
|
||||
console.log("handlePlayPause");
|
||||
await handlePlaystateCommand(PlaystateCommand.PlayPause);
|
||||
};
|
||||
|
||||
const handleStop = async () => {
|
||||
await handlePlaystateCommand(PlaystateCommand.Stop);
|
||||
};
|
||||
|
||||
const handlePrevious = async () => {
|
||||
await handlePlaystateCommand(PlaystateCommand.PreviousTrack);
|
||||
};
|
||||
|
||||
const handleNext = async () => {
|
||||
await handlePlaystateCommand(PlaystateCommand.NextTrack);
|
||||
};
|
||||
|
||||
const handleToggleMute = async () => {
|
||||
await handleSystemCommand(GeneralCommandType.ToggleMute);
|
||||
};
|
||||
const handleVolumeUp = async () => {
|
||||
await handleSystemCommand(GeneralCommandType.VolumeUp);
|
||||
};
|
||||
const handleVolumeDown = async () => {
|
||||
await handleSystemCommand(GeneralCommandType.VolumeDown);
|
||||
};
|
||||
|
||||
useInterval(tick, 1000);
|
||||
|
||||
return (
|
||||
<View className='flex flex-col shadow-md bg-neutral-900 rounded-2xl mb-4'>
|
||||
<View className='flex flex-row p-4'>
|
||||
<View className='w-20 pr-4'>
|
||||
<Poster
|
||||
id={session.NowPlayingItem?.Id}
|
||||
url={getPrimaryImageUrl({ api, item: session.NowPlayingItem })}
|
||||
/>
|
||||
</View>
|
||||
<View className='w-full flex-1'>
|
||||
<View className='flex flex-row justify-between'>
|
||||
<View className='flex-1 pr-4'>
|
||||
{session.NowPlayingItem?.Type === "Episode" ? (
|
||||
<>
|
||||
<Text className='font-bold'>
|
||||
{session.NowPlayingItem?.Name}
|
||||
</Text>
|
||||
<Text numberOfLines={1} className='text-xs opacity-50'>
|
||||
{`S${session.NowPlayingItem.ParentIndexNumber?.toString()}:E${session.NowPlayingItem.IndexNumber?.toString()}`}
|
||||
{" - "}
|
||||
{session.NowPlayingItem.SeriesName}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text className='font-bold'>
|
||||
{session.NowPlayingItem?.Name}
|
||||
</Text>
|
||||
<Text className='text-xs opacity-50'>
|
||||
{session.NowPlayingItem?.ProductionYear}
|
||||
</Text>
|
||||
<Text className='text-xs opacity-50'>
|
||||
{session.NowPlayingItem?.SeriesName}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
<Text className='text-xs opacity-50 align-right text-right'>
|
||||
{session.UserName}
|
||||
{"\n"}
|
||||
{session.Client}
|
||||
{"\n"}
|
||||
{session.DeviceName}
|
||||
{"\n"}
|
||||
{ipInfo?.cityName} {ipInfo?.countryCode}
|
||||
</Text>
|
||||
</View>
|
||||
<View className='flex-1' />
|
||||
<View className='flex flex-col align-bottom'>
|
||||
<View className='flex flex-row justify-between align-bottom mb-1'>
|
||||
<Text className='-ml-0.5 text-xs opacity-50 align-left text-left'>
|
||||
{!session.PlayState?.IsPaused ? (
|
||||
<Ionicons name='play' size={14} color='white' />
|
||||
) : (
|
||||
<Ionicons name='pause' size={14} color='white' />
|
||||
)}
|
||||
</Text>
|
||||
<Text className='text-xs opacity-50 align-right text-right'>
|
||||
{formatTimeString(remainingTicks, "tick")} left
|
||||
</Text>
|
||||
</View>
|
||||
<View className='align-bottom bg-gray-800 h-1'>
|
||||
<View
|
||||
className={"bg-purple-600 h-full"}
|
||||
style={{
|
||||
width: `${getProgressPercentage()}%`,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Session controls */}
|
||||
<View className='flex flex-row mt-2 space-x-4 justify-center'>
|
||||
<TouchableOpacity
|
||||
onPress={handlePrevious}
|
||||
disabled={isControlLoading[PlaystateCommand.PreviousTrack]}
|
||||
style={{
|
||||
opacity: isControlLoading[PlaystateCommand.PreviousTrack]
|
||||
? 0.5
|
||||
: 1,
|
||||
}}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name='skip-previous'
|
||||
size={24}
|
||||
color='white'
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={handlePlayPause}
|
||||
disabled={isControlLoading[PlaystateCommand.PlayPause]}
|
||||
style={{
|
||||
opacity: isControlLoading[PlaystateCommand.PlayPause]
|
||||
? 0.5
|
||||
: 1,
|
||||
}}
|
||||
>
|
||||
{session.PlayState?.IsPaused ? (
|
||||
<Ionicons name='play' size={24} color='white' />
|
||||
) : (
|
||||
<Ionicons name='pause' size={24} color='white' />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={handleStop}
|
||||
disabled={isControlLoading[PlaystateCommand.Stop]}
|
||||
style={{
|
||||
opacity: isControlLoading[PlaystateCommand.Stop] ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<Ionicons name='stop' size={24} color='white' />
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={handleNext}
|
||||
disabled={isControlLoading[PlaystateCommand.NextTrack]}
|
||||
style={{
|
||||
opacity: isControlLoading[PlaystateCommand.NextTrack]
|
||||
? 0.5
|
||||
: 1,
|
||||
}}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name='skip-next'
|
||||
size={24}
|
||||
color='white'
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={handleVolumeDown}
|
||||
disabled={isControlLoading[GeneralCommandType.VolumeDown]}
|
||||
style={{
|
||||
opacity: isControlLoading[GeneralCommandType.VolumeDown]
|
||||
? 0.5
|
||||
: 1,
|
||||
}}
|
||||
>
|
||||
<Ionicons name='volume-low' size={24} color='white' />
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={handleToggleMute}
|
||||
disabled={isControlLoading[GeneralCommandType.ToggleMute]}
|
||||
style={{
|
||||
opacity: isControlLoading[GeneralCommandType.ToggleMute]
|
||||
? 0.5
|
||||
: 1,
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name='volume-mute'
|
||||
size={24}
|
||||
color={session.PlayState?.IsMuted ? "red" : "white"}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={handleVolumeUp}
|
||||
disabled={isControlLoading[GeneralCommandType.VolumeUp]}
|
||||
style={{
|
||||
opacity: isControlLoading[GeneralCommandType.VolumeUp]
|
||||
? 0.5
|
||||
: 1,
|
||||
}}
|
||||
>
|
||||
<Ionicons name='volume-high' size={24} color='white' />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<TranscodingView session={session} />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
interface TranscodingBadgesProps {
|
||||
properties: StreamProps;
|
||||
}
|
||||
|
||||
const TranscodingBadges = ({ properties }: TranscodingBadgesProps) => {
|
||||
const iconMap = {
|
||||
bitrate: <Ionicons name='speedometer-outline' size={12} color='white' />,
|
||||
codec: <Ionicons name='layers-outline' size={12} color='white' />,
|
||||
videoRange: (
|
||||
<Ionicons name='color-palette-outline' size={12} color='white' />
|
||||
),
|
||||
resolution: <Ionicons name='film-outline' size={12} color='white' />,
|
||||
language: <Ionicons name='language-outline' size={12} color='white' />,
|
||||
audioChannels: <Ionicons name='mic-outline' size={12} color='white' />,
|
||||
hwType: <Ionicons name='hardware-chip-outline' size={12} color='white' />,
|
||||
} as const;
|
||||
|
||||
const icon = (val: string) => {
|
||||
return (
|
||||
iconMap[val as keyof typeof iconMap] ?? (
|
||||
<Ionicons name='layers-outline' size={12} color='white' />
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const formatVal = (key: string, val: any) => {
|
||||
switch (key) {
|
||||
case "bitrate":
|
||||
return formatBitrate(val);
|
||||
case "hwType":
|
||||
return val === HardwareAccelerationType.None ? "sw" : "hw";
|
||||
default:
|
||||
return val;
|
||||
}
|
||||
};
|
||||
|
||||
return Object.entries(properties)
|
||||
.filter(([_, value]) => value !== undefined && value !== null)
|
||||
.map(([key]) => (
|
||||
<Badge
|
||||
key={key}
|
||||
variant='gray'
|
||||
className='m-0 p-0 pt-0.5 mr-1'
|
||||
text={formatVal(key, properties[key as keyof StreamProps])}
|
||||
iconLeft={icon(key)}
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
||||
interface StreamProps {
|
||||
hwType?: HardwareAccelerationType | null | undefined;
|
||||
resolution?: string | null | undefined;
|
||||
language?: string | null | undefined;
|
||||
codec?: string | null | undefined;
|
||||
bitrate?: number | null | undefined;
|
||||
videoRange?: string | null | undefined;
|
||||
audioChannels?: string | null | undefined;
|
||||
}
|
||||
|
||||
interface TranscodingStreamViewProps {
|
||||
title: string | undefined;
|
||||
value?: string;
|
||||
isTranscoding: boolean;
|
||||
transcodeValue?: string | undefined | null;
|
||||
properties: StreamProps;
|
||||
transcodeProperties?: StreamProps;
|
||||
}
|
||||
|
||||
const TranscodingStreamView = ({
|
||||
title,
|
||||
isTranscoding,
|
||||
properties,
|
||||
transcodeProperties,
|
||||
}: TranscodingStreamViewProps) => {
|
||||
return (
|
||||
<View className='flex flex-col pt-2 first:pt-0'>
|
||||
<View className='flex flex-row'>
|
||||
<Text className='text-xs opacity-50 w-20 font-bold text-right pr-4'>
|
||||
{title}
|
||||
</Text>
|
||||
<Text className='flex-1'>
|
||||
<TranscodingBadges properties={properties} />
|
||||
</Text>
|
||||
</View>
|
||||
{isTranscoding && transcodeProperties ? (
|
||||
<View className='flex flex-row'>
|
||||
<Text className='-mt-0 text-xs opacity-50 w-20 font-bold text-right pr-4'>
|
||||
<MaterialCommunityIcons
|
||||
name='arrow-right-bottom'
|
||||
size={14}
|
||||
color='white'
|
||||
/>
|
||||
</Text>
|
||||
<Text className='flex-1 text-sm mt-1'>
|
||||
<TranscodingBadges properties={transcodeProperties} />
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const TranscodingView = ({ session }: SessionCardProps) => {
|
||||
const videoStream = useMemo(() => {
|
||||
return session.NowPlayingItem?.MediaStreams?.filter(
|
||||
(s) => s.Type === "Video",
|
||||
)[0];
|
||||
}, [session]);
|
||||
|
||||
const audioStream = useMemo(() => {
|
||||
const index = session.PlayState?.AudioStreamIndex;
|
||||
return index !== null && index !== undefined
|
||||
? session.NowPlayingItem?.MediaStreams?.[index]
|
||||
: undefined;
|
||||
}, [session.PlayState?.AudioStreamIndex]);
|
||||
|
||||
const subtitleStream = useMemo(() => {
|
||||
const index = session.PlayState?.SubtitleStreamIndex;
|
||||
return index !== null && index !== undefined
|
||||
? session.NowPlayingItem?.MediaStreams?.[index]
|
||||
: undefined;
|
||||
}, [session.PlayState?.SubtitleStreamIndex]);
|
||||
|
||||
const isTranscoding = useMemo(() => {
|
||||
return (
|
||||
session.PlayState?.PlayMethod === "Transcode" && session.TranscodingInfo
|
||||
);
|
||||
}, [session.PlayState?.PlayMethod, session.TranscodingInfo]);
|
||||
|
||||
const videoStreamTitle = () => {
|
||||
return videoStream?.DisplayTitle?.split(" ")[0];
|
||||
};
|
||||
|
||||
return (
|
||||
<View className='flex flex-col bg-neutral-800 rounded-b-2xl p-4 pt-2'>
|
||||
<TranscodingStreamView
|
||||
title='Video'
|
||||
properties={{
|
||||
resolution: videoStreamTitle(),
|
||||
bitrate: videoStream?.BitRate,
|
||||
codec: videoStream?.Codec,
|
||||
}}
|
||||
transcodeProperties={{
|
||||
hwType: session.TranscodingInfo?.HardwareAccelerationType,
|
||||
bitrate: session.TranscodingInfo?.Bitrate,
|
||||
codec: session.TranscodingInfo?.VideoCodec,
|
||||
}}
|
||||
isTranscoding={
|
||||
!!(isTranscoding && !session.TranscodingInfo?.IsVideoDirect)
|
||||
}
|
||||
/>
|
||||
|
||||
<TranscodingStreamView
|
||||
title='Audio'
|
||||
properties={{
|
||||
language: audioStream?.Language,
|
||||
bitrate: audioStream?.BitRate,
|
||||
codec: audioStream?.Codec,
|
||||
audioChannels: audioStream?.ChannelLayout,
|
||||
}}
|
||||
transcodeProperties={{
|
||||
codec: session.TranscodingInfo?.AudioCodec,
|
||||
audioChannels: session.TranscodingInfo?.AudioChannels?.toString(),
|
||||
}}
|
||||
isTranscoding={
|
||||
!!(isTranscoding && !session.TranscodingInfo?.IsVideoDirect)
|
||||
}
|
||||
/>
|
||||
|
||||
{subtitleStream && (
|
||||
<TranscodingStreamView
|
||||
title='Subtitle'
|
||||
isTranscoding={false}
|
||||
properties={{
|
||||
language: subtitleStream?.Language,
|
||||
codec: subtitleStream?.Codec,
|
||||
}}
|
||||
transcodeValue={null}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -1,17 +1,8 @@
|
||||
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 { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Platform } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ListGroup } from "@/components/list/ListGroup";
|
||||
import { ListItem } from "@/components/list/ListItem";
|
||||
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";
|
||||
@@ -19,16 +10,24 @@ import { PluginSettings } from "@/components/settings/PluginSettings";
|
||||
import { QuickConnect } from "@/components/settings/QuickConnect";
|
||||
import { StorageSettings } from "@/components/settings/StorageSettings";
|
||||
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
|
||||
import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
|
||||
import { UserInfo } from "@/components/settings/UserInfo";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useJellyfin } from "@/providers/JellyfinProvider";
|
||||
import { clearLogs } from "@/utils/log";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useNavigation, useRouter } from "expo-router";
|
||||
import { t } from "i18next";
|
||||
import React, { lazy, useEffect } from "react";
|
||||
import { ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
const DownloadSettings = lazy(
|
||||
() => import("@/components/settings/DownloadSettings")
|
||||
);
|
||||
|
||||
export default function settings() {
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [_user] = useAtom(userAtom);
|
||||
const { logout } = useJellyfin();
|
||||
const successHapticFeedback = useHaptic("success");
|
||||
|
||||
@@ -46,7 +45,7 @@ export default function settings() {
|
||||
logout();
|
||||
}}
|
||||
>
|
||||
<Text className='text-red-600'>
|
||||
<Text className="text-red-600">
|
||||
{t("home.settings.log_out_button")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
@@ -61,16 +60,14 @@ export default function settings() {
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<View className='p-4 flex flex-col gap-y-4'>
|
||||
<View className="p-4 flex flex-col gap-y-4">
|
||||
<UserInfo />
|
||||
|
||||
<QuickConnect className='mb-4' />
|
||||
<QuickConnect className="mb-4" />
|
||||
|
||||
<MediaProvider>
|
||||
<MediaToggles className='mb-4' />
|
||||
<GestureControls className='mb-4' />
|
||||
<AudioToggles className='mb-4' />
|
||||
<SubtitleToggles className='mb-4' />
|
||||
<MediaToggles className="mb-4" />
|
||||
<AudioToggles className="mb-4" />
|
||||
<SubtitleToggles className="mb-4" />
|
||||
</MediaProvider>
|
||||
|
||||
<OtherSettings />
|
||||
@@ -81,8 +78,6 @@ export default function settings() {
|
||||
|
||||
<AppLanguageSelector />
|
||||
|
||||
{!Platform.isTV && <ChromecastSettings />}
|
||||
|
||||
<ListGroup title={"Intro"}>
|
||||
<ListItem
|
||||
onPress={() => {
|
||||
@@ -91,7 +86,7 @@ export default function settings() {
|
||||
title={t("home.settings.intro.show_intro")}
|
||||
/>
|
||||
<ListItem
|
||||
textColor='red'
|
||||
textColor="red"
|
||||
onPress={() => {
|
||||
storage.set("hasShownIntro", false);
|
||||
}}
|
||||
@@ -99,7 +94,7 @@ export default function settings() {
|
||||
/>
|
||||
</ListGroup>
|
||||
|
||||
<View className='mb-4'>
|
||||
<View className="mb-4">
|
||||
<ListGroup title={t("home.settings.logs.logs_title")}>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/logs/page")}
|
||||
@@ -107,14 +102,14 @@ export default function settings() {
|
||||
title={t("home.settings.logs.logs_title")}
|
||||
/>
|
||||
<ListItem
|
||||
textColor='red'
|
||||
textColor="red"
|
||||
onPress={onClearLogsClicked}
|
||||
title={t("home.settings.logs.delete_all_logs")}
|
||||
/>
|
||||
</ListGroup>
|
||||
</View>
|
||||
|
||||
{!Platform.isTV && <StorageSettings />}
|
||||
<StorageSettings />
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ListGroup } from "@/components/list/ListGroup";
|
||||
import { ListItem } from "@/components/list/ListItem";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Switch, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { ListGroup } from "@/components/list/ListGroup";
|
||||
import { ListItem } from "@/components/list/ListItem";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
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);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
const { data, isLoading: isLoading } = useQuery({
|
||||
queryKey: ["user-views", user?.Id],
|
||||
queryFn: async () => {
|
||||
const response = await getUserViewsApi(api!).getUserViews({
|
||||
@@ -33,7 +33,7 @@ export default function page() {
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<View className='mt-4'>
|
||||
<View className="mt-4">
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
@@ -41,7 +41,7 @@ export default function page() {
|
||||
return (
|
||||
<DisabledSetting
|
||||
disabled={pluginSettings?.hiddenLibraries?.locked === true}
|
||||
className='px-4'
|
||||
className="px-4"
|
||||
>
|
||||
<ListGroup>
|
||||
{data?.map((view) => (
|
||||
@@ -59,8 +59,8 @@ export default function page() {
|
||||
</ListItem>
|
||||
))}
|
||||
</ListGroup>
|
||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||
{t("home.settings.other.select_liraries_you_want_to_hide")}
|
||||
<Text className="px-4 text-xs text-neutral-500 mt-1">
|
||||
{t("home.settings.other.select_liraries_you_want_to_hide")}
|
||||
</Text>
|
||||
</DisabledSetting>
|
||||
);
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
|
||||
export default function page() {
|
||||
const { pluginSettings } = useSettings();
|
||||
const [settings, updateSettings, pluginSettings] = useSettings();
|
||||
|
||||
return (
|
||||
<DisabledSetting
|
||||
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
|
||||
className='p-4'
|
||||
className="p-4"
|
||||
>
|
||||
<JellyseerrSettings />
|
||||
</DisabledSetting>
|
||||
|
||||
@@ -1,162 +1,35 @@
|
||||
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 { useTranslation } from "react-i18next";
|
||||
import { ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import Collapsible from "react-native-collapsible";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { FilterButton } from "@/components/filters/FilterButton";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { LogLevel, useLog, writeErrorLog } from "@/utils/log";
|
||||
import { useLog } from "@/utils/log";
|
||||
import { ScrollView, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function Page() {
|
||||
const navigation = useNavigation();
|
||||
export default function page() {
|
||||
const { logs } = useLog();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const orderFilterId = useId();
|
||||
const levelsFilterId = useId();
|
||||
|
||||
const defaultLevels: LogLevel[] = ["INFO", "ERROR", "DEBUG", "WARN"];
|
||||
const codeBlockStyle = {
|
||||
backgroundColor: "#000",
|
||||
padding: 10,
|
||||
fontFamily: "monospace",
|
||||
maxHeight: 300,
|
||||
};
|
||||
|
||||
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
|
||||
?.filter((log) => levels.includes(log.level))
|
||||
?.[
|
||||
// Already in asc order as they are recorded. just reverse for desc
|
||||
order === "desc" ? "reverse" : "concat"
|
||||
]?.(),
|
||||
[logs, order, levels],
|
||||
);
|
||||
|
||||
// Sharing it as txt while its formatted allows us to share it with many more applications
|
||||
const share = useCallback(async () => {
|
||||
const uri = `${FileSystem.documentDirectory}logs.txt`;
|
||||
|
||||
setLoading(true);
|
||||
FileSystem.writeAsStringAsync(uri, JSON.stringify(filteredLogs))
|
||||
.then(() => {
|
||||
setLoading(false);
|
||||
Sharing.shareAsync(uri, { mimeType: "txt", UTI: "txt" });
|
||||
})
|
||||
.catch((e) =>
|
||||
writeErrorLog("Something went wrong attempting to export", e),
|
||||
)
|
||||
.finally(() => setLoading(false));
|
||||
}, [filteredLogs]);
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerRight: () =>
|
||||
loading ? (
|
||||
<Loader />
|
||||
) : (
|
||||
<TouchableOpacity onPress={share}>
|
||||
<Text>{t("home.settings.logs.export_logs")}</Text>
|
||||
</TouchableOpacity>
|
||||
),
|
||||
});
|
||||
}, [share, loading]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<View className='flex flex-row justify-end py-2 px-4 space-x-2'>
|
||||
<FilterButton
|
||||
id={orderFilterId}
|
||||
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}
|
||||
/>
|
||||
<FilterButton
|
||||
id={levelsFilterId}
|
||||
queryKey='log'
|
||||
queryFn={async () => defaultLevels}
|
||||
set={setLevels}
|
||||
values={levels}
|
||||
title={t("home.settings.logs.level")}
|
||||
renderItemLabel={(level) => level}
|
||||
disableSearch={true}
|
||||
multiple={true}
|
||||
/>
|
||||
</View>
|
||||
<ScrollView className='pb-4 px-4'>
|
||||
<View className='flex flex-col space-y-2'>
|
||||
{filteredLogs?.map((log, index) => (
|
||||
<View className='bg-neutral-900 rounded-xl p-3' key={index}>
|
||||
<TouchableOpacity
|
||||
disabled={!log.data}
|
||||
onPress={() =>
|
||||
setState((v) => ({
|
||||
...v,
|
||||
[log.timestamp]: !v[log.timestamp],
|
||||
}))
|
||||
}
|
||||
>
|
||||
<View className='flex flex-row justify-between'>
|
||||
<Text
|
||||
className={`mb-1
|
||||
${log.level === "INFO" && "text-blue-500"}
|
||||
${log.level === "ERROR" && "text-red-500"}
|
||||
${log.level === "DEBUG" && "text-purple-500"}
|
||||
`}
|
||||
>
|
||||
{log.level}
|
||||
</Text>
|
||||
|
||||
<Text className='text-xs'>
|
||||
{new Date(log.timestamp).toLocaleString()}
|
||||
</Text>
|
||||
</View>
|
||||
<Text selectable className='text-xs'>
|
||||
{log.message}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{log.data && (
|
||||
<>
|
||||
{!state[log.timestamp] && (
|
||||
<Text className='text-xs mt-0.5'>
|
||||
{t("home.settings.logs.click_for_more_info")}
|
||||
</Text>
|
||||
)}
|
||||
<Collapsible collapsed={!state[log.timestamp]}>
|
||||
<View className='mt-2 flex flex-col space-y-2'>
|
||||
<ScrollView className='rounded-xl' style={codeBlockStyle}>
|
||||
<Text>{JSON.stringify(log.data, null, 2)}</Text>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</Collapsible>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
{filteredLogs?.length === 0 && (
|
||||
<Text className='opacity-50'>
|
||||
{t("home.settings.logs.no_logs_available")}
|
||||
<ScrollView className="p-4">
|
||||
<View className="flex flex-col space-y-2">
|
||||
{logs?.map((log, index) => (
|
||||
<View key={index} className="bg-neutral-900 rounded-xl p-3">
|
||||
<Text
|
||||
className={`
|
||||
mb-1
|
||||
${log.level === "INFO" && "text-blue-500"}
|
||||
${log.level === "ERROR" && "text-red-500"}
|
||||
`}
|
||||
>
|
||||
{log.level}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</>
|
||||
<Text uiTextView selectable className="text-xs">
|
||||
{log.message}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
{logs?.length === 0 && (
|
||||
<Text className="opacity-50">{t("home.settings.logs.no_logs_available")}</Text>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ListGroup } from "@/components/list/ListGroup";
|
||||
import { ListItem } from "@/components/list/ListItem";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigation } from "expo-router";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import React, {useEffect, useMemo, useState} from "react";
|
||||
import {
|
||||
Linking,
|
||||
Switch,
|
||||
@@ -10,18 +15,14 @@ import {
|
||||
View,
|
||||
} from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ListGroup } from "@/components/list/ListGroup";
|
||||
import { ListItem } from "@/components/list/ListItem";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function page() {
|
||||
const navigation = useNavigation();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||
const [settings, updateSettings, pluginSettings] = useSettings();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
|
||||
@@ -38,10 +39,7 @@ export default function page() {
|
||||
};
|
||||
|
||||
const disabled = useMemo(() => {
|
||||
return (
|
||||
pluginSettings?.searchEngine?.locked === true &&
|
||||
pluginSettings?.marlinServerUrl?.locked === true
|
||||
);
|
||||
return pluginSettings?.searchEngine?.locked === true && pluginSettings?.marlinServerUrl?.locked === true
|
||||
}, [pluginSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -49,9 +47,7 @@ export default function page() {
|
||||
navigation.setOptions({
|
||||
headerRight: () => (
|
||||
<TouchableOpacity onPress={() => onSave(value)}>
|
||||
<Text className='text-blue-500'>
|
||||
{t("home.settings.plugins.marlin_search.save_button")}
|
||||
</Text>
|
||||
<Text className="text-blue-500">{t("home.settings.plugins.marlin_search.save_button")}</Text>
|
||||
</TouchableOpacity>
|
||||
),
|
||||
});
|
||||
@@ -61,16 +57,17 @@ export default function page() {
|
||||
if (!settings) return null;
|
||||
|
||||
return (
|
||||
<DisabledSetting disabled={disabled} className='px-4'>
|
||||
<DisabledSetting
|
||||
disabled={disabled}
|
||||
className="px-4"
|
||||
>
|
||||
<ListGroup>
|
||||
<DisabledSetting
|
||||
disabled={pluginSettings?.searchEngine?.locked === true}
|
||||
showText={!pluginSettings?.marlinServerUrl?.locked}
|
||||
>
|
||||
<ListItem
|
||||
title={t(
|
||||
"home.settings.plugins.marlin_search.enable_marlin_search",
|
||||
)}
|
||||
title={t("home.settings.plugins.marlin_search.enable_marlin_search")}
|
||||
onPress={() => {
|
||||
updateSettings({ searchEngine: "Jellyfin" });
|
||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||
@@ -90,30 +87,28 @@ export default function page() {
|
||||
<DisabledSetting
|
||||
disabled={pluginSettings?.marlinServerUrl?.locked === true}
|
||||
showText={!pluginSettings?.searchEngine?.locked}
|
||||
className='mt-2 flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4'
|
||||
className="mt-2 flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4"
|
||||
>
|
||||
<View className={"flex flex-row items-center bg-neutral-900 h-11 pr-4"}>
|
||||
<Text className='mr-4'>
|
||||
{t("home.settings.plugins.marlin_search.url")}
|
||||
</Text>
|
||||
<View
|
||||
className={`flex flex-row items-center bg-neutral-900 h-11 pr-4`}
|
||||
>
|
||||
<Text className="mr-4">{t("home.settings.plugins.marlin_search.url")}</Text>
|
||||
<TextInput
|
||||
editable={settings.searchEngine === "Marlin"}
|
||||
className='text-white'
|
||||
placeholder={t(
|
||||
"home.settings.plugins.marlin_search.server_url_placeholder",
|
||||
)}
|
||||
className="text-white"
|
||||
placeholder={t("home.settings.plugins.marlin_search.server_url_placeholder")}
|
||||
value={value}
|
||||
keyboardType='url'
|
||||
returnKeyType='done'
|
||||
autoCapitalize='none'
|
||||
textContentType='URL'
|
||||
keyboardType="url"
|
||||
returnKeyType="done"
|
||||
autoCapitalize="none"
|
||||
textContentType="URL"
|
||||
onChangeText={(text) => setValue(text)}
|
||||
/>
|
||||
</View>
|
||||
</DisabledSetting>
|
||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||
<Text className="px-4 text-xs text-neutral-500 mt-1">
|
||||
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
|
||||
<Text className='text-blue-500' onPress={handleOpenLink}>
|
||||
<Text className="text-blue-500" onPress={handleOpenLink}>
|
||||
{t("home.settings.plugins.marlin_search.read_more_about_marlin")}
|
||||
</Text>
|
||||
</Text>
|
||||
|
||||
89
app/(auth)/(tabs)/(home)/settings/optimized-server/page.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
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";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
|
||||
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: settings?.optimizedVersionsServerUrl,
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +1,42 @@
|
||||
import type { BaseItemDtoQueryResult } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { OverviewText } from "@/components/OverviewText";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
import { InfiniteHorizontalScroll } from "@/components/common/InfiniteHorrizontalScroll";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import { BaseItemDtoQueryResult } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
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 { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
|
||||
import { OverviewText } from "@/components/OverviewText";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
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(
|
||||
@@ -79,12 +79,12 @@ const page: React.FC = () => {
|
||||
quality: 90,
|
||||
width: 1000,
|
||||
}),
|
||||
[item],
|
||||
[item]
|
||||
);
|
||||
|
||||
if (l1)
|
||||
return (
|
||||
<View className='justify-center items-center h-full'>
|
||||
<View className="justify-center items-center h-full">
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
@@ -105,13 +105,13 @@ const page: React.FC = () => {
|
||||
/>
|
||||
}
|
||||
>
|
||||
<View className='flex flex-col space-y-4 my-4'>
|
||||
<View className='px-4 mb-4'>
|
||||
<MoviesTitleHeader item={item} className='mb-4' />
|
||||
<View className="flex flex-col space-y-4 my-4">
|
||||
<View className="px-4 mb-4">
|
||||
<MoviesTitleHeader item={item} className="mb-4" />
|
||||
<OverviewText text={item.Overview} />
|
||||
</View>
|
||||
|
||||
<Text className='px-4 text-2xl font-bold mb-2 text-neutral-100'>
|
||||
<Text className="px-4 text-2xl font-bold mb-2 text-neutral-100">
|
||||
{t("item_card.appeared_in")}
|
||||
</Text>
|
||||
<InfiniteHorizontalScroll
|
||||
@@ -131,9 +131,9 @@ const page: React.FC = () => {
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
queryFn={fetchItems}
|
||||
queryKey={["actor", "movies", personId]}
|
||||
queryKey={["actor", "movies", actorId]}
|
||||
/>
|
||||
<View className='h-12' />
|
||||
<View className="h-12"></View>
|
||||
</View>
|
||||
</ParallaxScrollView>
|
||||
);
|
||||
@@ -1,4 +1,22 @@
|
||||
import type {
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import { FilterButton } from "@/components/filters/FilterButton";
|
||||
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
genreFilterAtom,
|
||||
sortByAtom,
|
||||
SortByOption,
|
||||
sortOptions,
|
||||
sortOrderAtom,
|
||||
SortOrderOption,
|
||||
sortOrderOptions,
|
||||
tagsFilterAtom,
|
||||
yearFilterAtom,
|
||||
} from "@/utils/atoms/filters";
|
||||
import {
|
||||
BaseItemDto,
|
||||
BaseItemDtoQueryResult,
|
||||
ItemSortBy,
|
||||
@@ -11,30 +29,11 @@ import {
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FlatList, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import { FilterButton } from "@/components/filters/FilterButton";
|
||||
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
genreFilterAtom,
|
||||
SortByOption,
|
||||
SortOrderOption,
|
||||
sortByAtom,
|
||||
sortOptions,
|
||||
sortOrderAtom,
|
||||
sortOrderOptions,
|
||||
tagsFilterAtom,
|
||||
yearFilterAtom,
|
||||
} from "@/utils/atoms/filters";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { FlatList, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const page: React.FC = () => {
|
||||
const searchParams = useLocalSearchParams();
|
||||
@@ -43,8 +42,8 @@ const page: React.FC = () => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const navigation = useNavigation();
|
||||
const [orientation, _setOrientation] = useState(
|
||||
ScreenOrientation.Orientation.PORTRAIT_UP,
|
||||
const [orientation, setOrientation] = useState(
|
||||
ScreenOrientation.Orientation.PORTRAIT_UP
|
||||
);
|
||||
|
||||
const { t } = useTranslation();
|
||||
@@ -112,7 +111,7 @@ const page: React.FC = () => {
|
||||
recursive: true,
|
||||
genres: selectedGenres,
|
||||
tags: selectedTags,
|
||||
years: selectedYears.map((year) => Number.parseInt(year, 10)),
|
||||
years: selectedYears.map((year) => parseInt(year)),
|
||||
includeItemTypes: ["Movie", "Series"],
|
||||
});
|
||||
|
||||
@@ -127,7 +126,7 @@ const page: React.FC = () => {
|
||||
selectedTags,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||
@@ -152,13 +151,14 @@ const page: React.FC = () => {
|
||||
const totalItems = lastPage.TotalRecordCount;
|
||||
const accumulatedItems = pages.reduce(
|
||||
(acc, curr) => acc + (curr?.Items?.length || 0),
|
||||
0,
|
||||
0
|
||||
);
|
||||
|
||||
if (accumulatedItems < totalItems) {
|
||||
return lastPage?.Items?.length * pages.length;
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
initialPageParam: 0,
|
||||
enabled: !!api && !!user?.Id && !!collection,
|
||||
@@ -188,8 +188,8 @@ const page: React.FC = () => {
|
||||
index % 3 === 0
|
||||
? "flex-end"
|
||||
: (index + 1) % 3 === 0
|
||||
? "flex-start"
|
||||
: "center",
|
||||
? "flex-start"
|
||||
: "center",
|
||||
width: "89%",
|
||||
}}
|
||||
>
|
||||
@@ -199,14 +199,14 @@ const page: React.FC = () => {
|
||||
</View>
|
||||
</TouchableItemRouter>
|
||||
),
|
||||
[orientation],
|
||||
[orientation]
|
||||
);
|
||||
|
||||
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||
|
||||
const ListHeaderComponent = useCallback(
|
||||
() => (
|
||||
<View className=''>
|
||||
<View className="">
|
||||
<FlatList
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
@@ -232,13 +232,13 @@ const page: React.FC = () => {
|
||||
key: "genre",
|
||||
component: (
|
||||
<FilterButton
|
||||
className='mr-1'
|
||||
id={collectionId}
|
||||
queryKey='genreFilter'
|
||||
className="mr-1"
|
||||
collectionId={collectionId}
|
||||
queryKey="genreFilter"
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api,
|
||||
api
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: collectionId,
|
||||
@@ -259,13 +259,13 @@ const page: React.FC = () => {
|
||||
key: "year",
|
||||
component: (
|
||||
<FilterButton
|
||||
className='mr-1'
|
||||
id={collectionId}
|
||||
queryKey='yearFilter'
|
||||
className="mr-1"
|
||||
collectionId={collectionId}
|
||||
queryKey="yearFilter"
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api,
|
||||
api
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: collectionId,
|
||||
@@ -284,13 +284,13 @@ const page: React.FC = () => {
|
||||
key: "tags",
|
||||
component: (
|
||||
<FilterButton
|
||||
className='mr-1'
|
||||
id={collectionId}
|
||||
queryKey='tagsFilter'
|
||||
className="mr-1"
|
||||
collectionId={collectionId}
|
||||
queryKey="tagsFilter"
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api,
|
||||
api
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: collectionId,
|
||||
@@ -311,9 +311,9 @@ const page: React.FC = () => {
|
||||
key: "sortBy",
|
||||
component: (
|
||||
<FilterButton
|
||||
className='mr-1'
|
||||
id={collectionId}
|
||||
queryKey='sortBy'
|
||||
className="mr-1"
|
||||
collectionId={collectionId}
|
||||
queryKey="sortBy"
|
||||
queryFn={async () => sortOptions.map((s) => s.key)}
|
||||
set={setSortBy}
|
||||
values={sortBy}
|
||||
@@ -331,9 +331,9 @@ const page: React.FC = () => {
|
||||
key: "sortOrder",
|
||||
component: (
|
||||
<FilterButton
|
||||
className='mr-1'
|
||||
id={collectionId}
|
||||
queryKey='sortOrder'
|
||||
className="mr-1"
|
||||
collectionId={collectionId}
|
||||
queryKey="sortOrder"
|
||||
queryFn={async () => sortOrderOptions.map((s) => s.key)}
|
||||
set={setSortOrder}
|
||||
values={sortOrder}
|
||||
@@ -368,7 +368,7 @@ const page: React.FC = () => {
|
||||
sortOrder,
|
||||
setSortOrder,
|
||||
isFetching,
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
if (!collection) return null;
|
||||
@@ -376,10 +376,8 @@ const page: React.FC = () => {
|
||||
return (
|
||||
<FlashList
|
||||
ListEmptyComponent={
|
||||
<View className='flex flex-col items-center justify-center h-full'>
|
||||
<Text className='font-bold text-xl text-neutral-500'>
|
||||
{t("search.no_results")}
|
||||
</Text>
|
||||
<View className="flex flex-col items-center justify-center h-full">
|
||||
<Text className="font-bold text-xl text-neutral-500">{t("search.no_results")}</Text>
|
||||
</View>
|
||||
}
|
||||
extraData={[
|
||||
@@ -389,7 +387,7 @@ const page: React.FC = () => {
|
||||
sortBy,
|
||||
sortOrder,
|
||||
]}
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
data={flatData}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
@@ -411,7 +409,7 @@ const page: React.FC = () => {
|
||||
width: 10,
|
||||
height: 10,
|
||||
}}
|
||||
/>
|
||||
></View>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ItemContent } from "@/components/ItemContent";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import type React from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useEffect } from "react";
|
||||
import { View } from "react-native";
|
||||
import Animated, {
|
||||
runOnJS,
|
||||
@@ -9,18 +13,30 @@ import Animated, {
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ItemContent } from "@/components/ItemContent";
|
||||
import { useItemQuery } from "@/hooks/useItemQuery";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
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(() => {
|
||||
@@ -59,38 +75,38 @@ const Page: React.FC = () => {
|
||||
|
||||
if (isError)
|
||||
return (
|
||||
<View className='flex flex-col items-center justify-center h-screen w-screen'>
|
||||
<View className="flex flex-col items-center justify-center h-screen w-screen">
|
||||
<Text>{t("item_card.could_not_load_item")}</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View className='flex flex-1 relative'>
|
||||
<View className="flex flex-1 relative">
|
||||
<Animated.View
|
||||
pointerEvents={"none"}
|
||||
style={[animatedStyle]}
|
||||
className='absolute top-0 left-0 flex flex-col items-start h-screen w-screen px-4 z-50 bg-black'
|
||||
className="absolute top-0 left-0 flex flex-col items-start h-screen w-screen px-4 z-50 bg-black"
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
height: item?.Type === "Episode" ? 300 : 450,
|
||||
}}
|
||||
className='bg-transparent rounded-lg mb-4 w-full'
|
||||
/>
|
||||
<View className='h-6 bg-neutral-900 rounded mb-4 w-14' />
|
||||
<View className='h-10 bg-neutral-900 rounded-lg mb-2 w-1/2' />
|
||||
<View className='h-3 bg-neutral-900 rounded mb-3 w-8' />
|
||||
<View className='flex flex-row space-x-1 mb-8'>
|
||||
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
|
||||
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
|
||||
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
|
||||
className="bg-transparent rounded-lg mb-4 w-full"
|
||||
></View>
|
||||
<View className="h-6 bg-neutral-900 rounded mb-4 w-14"></View>
|
||||
<View className="h-10 bg-neutral-900 rounded-lg mb-2 w-1/2"></View>
|
||||
<View className="h-3 bg-neutral-900 rounded mb-3 w-8"></View>
|
||||
<View className="flex flex-row space-x-1 mb-8">
|
||||
<View className="h-6 bg-neutral-900 rounded mb-3 w-14"></View>
|
||||
<View className="h-6 bg-neutral-900 rounded mb-3 w-14"></View>
|
||||
<View className="h-6 bg-neutral-900 rounded mb-3 w-14"></View>
|
||||
</View>
|
||||
<View className='h-3 bg-neutral-900 rounded w-2/3 mb-1' />
|
||||
<View className='h-10 bg-neutral-900 rounded-lg w-full mb-2' />
|
||||
<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' />
|
||||
<View className="h-3 bg-neutral-900 rounded w-2/3 mb-1"></View>
|
||||
<View className="h-10 bg-neutral-900 rounded-lg w-full mb-2"></View>
|
||||
<View className="h-12 bg-neutral-900 rounded-lg w-full mb-2"></View>
|
||||
<View className="h-24 bg-neutral-900 rounded-lg mb-1 w-full"></View>
|
||||
</Animated.View>
|
||||
{item && <ItemContent item={item} isOffline={isOffline} />}
|
||||
{item && <ItemContent item={item} />}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,44 +1,45 @@
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { uniqBy } from "lodash";
|
||||
import { useMemo } from "react";
|
||||
import {router, useLocalSearchParams, useSegments,} from "expo-router";
|
||||
import React, {useMemo,} from "react";
|
||||
import {TouchableOpacity} from "react-native";
|
||||
import {useInfiniteQuery} from "@tanstack/react-query";
|
||||
import {Endpoints, useJellyseerr} from "@/hooks/useJellyseerr";
|
||||
import {Text} from "@/components/common/Text";
|
||||
import {Image} from "expo-image";
|
||||
import Poster from "@/components/posters/Poster";
|
||||
import JellyseerrMediaIcon from "@/components/jellyseerr/JellyseerrMediaIcon";
|
||||
import {DiscoverSliderType} from "@/utils/jellyseerr/server/constants/discover";
|
||||
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
|
||||
import {MovieResult, Results, TvResult} from "@/utils/jellyseerr/server/models/Search";
|
||||
import {COMPANY_LOGO_IMAGE_FILTER} from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
|
||||
import {uniqBy} from "lodash";
|
||||
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";
|
||||
import { COMPANY_LOGO_IMAGE_FILTER } from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
|
||||
|
||||
export default function page() {
|
||||
const local = useLocalSearchParams();
|
||||
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
|
||||
const {jellyseerrApi} = useJellyseerr();
|
||||
|
||||
const { companyId, image, type } = local as unknown as {
|
||||
companyId: string;
|
||||
name: string;
|
||||
image: string;
|
||||
type: DiscoverSliderType;
|
||||
const {companyId, name, image, type} = local as unknown as {
|
||||
companyId: string,
|
||||
name: string,
|
||||
image: string,
|
||||
type: DiscoverSliderType
|
||||
};
|
||||
|
||||
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||
const {data, fetchNextPage, hasNextPage} = useInfiniteQuery({
|
||||
queryKey: ["jellyseerr", "company", type, companyId],
|
||||
queryFn: async ({ pageParam }) => {
|
||||
const params: any = {
|
||||
queryFn: async ({pageParam}) => {
|
||||
let params: any = {
|
||||
page: Number(pageParam),
|
||||
};
|
||||
|
||||
return jellyseerrApi?.discover(
|
||||
`${
|
||||
type === DiscoverSliderType.NETWORKS
|
||||
? Endpoints.DISCOVER_TV_NETWORK
|
||||
: Endpoints.DISCOVER_MOVIES_STUDIO
|
||||
}/${companyId}`,
|
||||
params,
|
||||
);
|
||||
(
|
||||
type == DiscoverSliderType.NETWORKS
|
||||
? Endpoints.DISCOVER_TV_NETWORK
|
||||
: Endpoints.DISCOVER_MOVIES_STUDIO
|
||||
) + `/${companyId}`,
|
||||
params
|
||||
)
|
||||
},
|
||||
enabled: !!jellyseerrApi && !!companyId,
|
||||
initialPageParam: 1,
|
||||
@@ -49,59 +50,46 @@ export default function page() {
|
||||
});
|
||||
|
||||
const flatData = useMemo(
|
||||
() =>
|
||||
uniqBy(
|
||||
data?.pages
|
||||
?.filter((p) => p?.results.length)
|
||||
.flatMap(
|
||||
(p) =>
|
||||
p?.results.filter((r) => isJellyseerrMovieOrTvResult(r)) ?? [],
|
||||
),
|
||||
"id",
|
||||
) ?? [],
|
||||
[data],
|
||||
() => uniqBy(data?.pages?.filter((p) => p?.results.length).flatMap((p) => p?.results ?? []), "id")?? [],
|
||||
[data]
|
||||
);
|
||||
|
||||
const backdrops = useMemo(
|
||||
() =>
|
||||
jellyseerrApi
|
||||
? flatData.map((r) =>
|
||||
jellyseerrApi.imageProxy(
|
||||
(r as TvResult | MovieResult).backdropPath,
|
||||
"w1920_and_h800_multi_faces",
|
||||
),
|
||||
)
|
||||
: [],
|
||||
[jellyseerrApi, flatData],
|
||||
() => jellyseerrApi
|
||||
? flatData.map((r) => jellyseerrApi.imageProxy((r as TvResult | MovieResult).backdropPath, "w1920_and_h800_multi_faces"))
|
||||
: [],
|
||||
[jellyseerrApi, flatData]
|
||||
);
|
||||
|
||||
return (
|
||||
<ParallaxSlideShow
|
||||
data={flatData}
|
||||
images={backdrops}
|
||||
listHeader=''
|
||||
listHeader=""
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
onEndReached={() => {
|
||||
if (hasNextPage) {
|
||||
fetchNextPage();
|
||||
fetchNextPage()
|
||||
}
|
||||
}}
|
||||
logo={
|
||||
<Image
|
||||
id={companyId}
|
||||
key={companyId}
|
||||
className='bottom-1 w-1/2'
|
||||
className="bottom-1 w-1/2"
|
||||
source={{
|
||||
uri: jellyseerrApi?.imageProxy(image, COMPANY_LOGO_IMAGE_FILTER),
|
||||
}}
|
||||
cachePolicy={"memory-disk"}
|
||||
contentFit='contain'
|
||||
contentFit="contain"
|
||||
style={{
|
||||
aspectRatio: "4/3",
|
||||
}}
|
||||
/>
|
||||
}
|
||||
renderItem={(item, _index) => <JellyseerrPoster item={item} />}
|
||||
renderItem={(item, index) =>
|
||||
<JellyseerrPoster item={item as MovieResult | TvResult} />
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,38 +1,42 @@
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { uniqBy } from "lodash";
|
||||
import { useMemo } from "react";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { textShadowStyle } from "@/components/jellyseerr/discover/GenericSlideCard";
|
||||
import {router, useLocalSearchParams, useSegments,} from "expo-router";
|
||||
import React, {useMemo,} from "react";
|
||||
import {TouchableOpacity} from "react-native";
|
||||
import {useInfiniteQuery} from "@tanstack/react-query";
|
||||
import {Endpoints, useJellyseerr} from "@/hooks/useJellyseerr";
|
||||
import {Text} from "@/components/common/Text";
|
||||
import Poster from "@/components/posters/Poster";
|
||||
import JellyseerrMediaIcon from "@/components/jellyseerr/JellyseerrMediaIcon";
|
||||
import {DiscoverSliderType} from "@/utils/jellyseerr/server/constants/discover";
|
||||
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
|
||||
import {MovieResult, Results, TvResult} from "@/utils/jellyseerr/server/models/Search";
|
||||
import {uniqBy} from "lodash";
|
||||
import {textShadowStyle} from "@/components/jellyseerr/discover/GenericSlideCard";
|
||||
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
||||
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||
|
||||
export default function page() {
|
||||
const local = useLocalSearchParams();
|
||||
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
|
||||
const {jellyseerrApi} = useJellyseerr();
|
||||
|
||||
const { genreId, name, type } = local as unknown as {
|
||||
genreId: string;
|
||||
name: string;
|
||||
type: DiscoverSliderType;
|
||||
const {genreId, name, type} = local as unknown as {
|
||||
genreId: string,
|
||||
name: string,
|
||||
type: DiscoverSliderType
|
||||
};
|
||||
|
||||
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||
const {data, fetchNextPage, hasNextPage} = useInfiniteQuery({
|
||||
queryKey: ["jellyseerr", "company", type, genreId],
|
||||
queryFn: async ({ pageParam }) => {
|
||||
const params: any = {
|
||||
queryFn: async ({pageParam}) => {
|
||||
let params: any = {
|
||||
page: Number(pageParam),
|
||||
genre: genreId,
|
||||
genre: genreId
|
||||
};
|
||||
|
||||
return jellyseerrApi?.discover(
|
||||
type === DiscoverSliderType.MOVIE_GENRES
|
||||
? Endpoints.DISCOVER_MOVIES
|
||||
: Endpoints.DISCOVER_TV,
|
||||
params,
|
||||
);
|
||||
type == DiscoverSliderType.MOVIE_GENRES
|
||||
? Endpoints.DISCOVER_MOVIES
|
||||
: Endpoints.DISCOVER_TV,
|
||||
params
|
||||
)
|
||||
},
|
||||
enabled: !!jellyseerrApi && !!genreId,
|
||||
initialPageParam: 1,
|
||||
@@ -43,55 +47,41 @@ export default function page() {
|
||||
});
|
||||
|
||||
const flatData = useMemo(
|
||||
() =>
|
||||
uniqBy(
|
||||
data?.pages
|
||||
?.filter((p) => p?.results.length)
|
||||
.flatMap(
|
||||
(p) =>
|
||||
p?.results.filter((r) => isJellyseerrMovieOrTvResult(r)) ?? [],
|
||||
),
|
||||
"id",
|
||||
) ?? [],
|
||||
[data],
|
||||
() => uniqBy(data?.pages?.filter((p) => p?.results.length).flatMap((p) => p?.results ?? []), "id")?? [],
|
||||
[data]
|
||||
);
|
||||
|
||||
const backdrops = useMemo(
|
||||
() =>
|
||||
jellyseerrApi
|
||||
? flatData.map((r) =>
|
||||
jellyseerrApi.imageProxy(
|
||||
r.backdropPath,
|
||||
"w1920_and_h800_multi_faces",
|
||||
),
|
||||
)
|
||||
: [],
|
||||
[jellyseerrApi, flatData],
|
||||
() => jellyseerrApi
|
||||
? flatData.map((r) => jellyseerrApi.imageProxy((r as TvResult | MovieResult).backdropPath, "w1920_and_h800_multi_faces"))
|
||||
: [],
|
||||
[jellyseerrApi, flatData]
|
||||
);
|
||||
|
||||
return (
|
||||
<ParallaxSlideShow
|
||||
data={flatData}
|
||||
images={backdrops}
|
||||
listHeader=''
|
||||
listHeader=""
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
onEndReached={() => {
|
||||
if (hasNextPage) {
|
||||
fetchNextPage();
|
||||
fetchNextPage()
|
||||
}
|
||||
}}
|
||||
logo={
|
||||
<Text
|
||||
className='text-4xl font-bold text-center bottom-1'
|
||||
className="text-4xl font-bold text-center bottom-1"
|
||||
style={{
|
||||
...textShadowStyle.shadow,
|
||||
shadowRadius: 10,
|
||||
}}
|
||||
>
|
||||
shadowRadius: 10
|
||||
}}>
|
||||
{name}
|
||||
</Text>
|
||||
}
|
||||
renderItem={(item, _index) => <JellyseerrPoster item={item} />}
|
||||
renderItem={(item, index) =>
|
||||
<JellyseerrPoster item={item as MovieResult | TvResult} />
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,3 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
type BottomSheetBackdropProps,
|
||||
BottomSheetModal,
|
||||
BottomSheetTextInput,
|
||||
BottomSheetView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams, useNavigation, useRouter } from "expo-router";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { GenreTags } from "@/components/GenreTags";
|
||||
@@ -27,44 +11,56 @@ import { ItemActions } from "@/components/series/SeriesActions";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
|
||||
import {
|
||||
type IssueType,
|
||||
IssueType,
|
||||
IssueTypeName,
|
||||
} from "@/utils/jellyseerr/server/constants/issue";
|
||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||
import type {
|
||||
MovieResult,
|
||||
TvResult,
|
||||
} from "@/utils/jellyseerr/server/models/Search";
|
||||
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||
|
||||
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
|
||||
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetBackdropProps,
|
||||
BottomSheetModal,
|
||||
BottomSheetTextInput,
|
||||
BottomSheetView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||
|
||||
import RequestModal from "@/components/jellyseerr/RequestModal";
|
||||
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
|
||||
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
||||
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||
import { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
||||
|
||||
const Page: React.FC = () => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const params = useLocalSearchParams();
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
const { mediaTitle, releaseYear, posterSrc, mediaType, ...result } =
|
||||
const { mediaTitle, releaseYear, posterSrc, ...result } =
|
||||
params as unknown as {
|
||||
mediaTitle: string;
|
||||
releaseYear: number;
|
||||
canRequest: string;
|
||||
posterSrc: string;
|
||||
mediaType: MediaType;
|
||||
} & Partial<MovieResult | TvResult | MovieDetails | TvDetails>;
|
||||
} & Partial<MovieResult | TvResult>;
|
||||
|
||||
const navigation = useNavigation();
|
||||
const { jellyseerrApi, requestMedia } = useJellyseerr();
|
||||
|
||||
const [issueType, setIssueType] = useState<IssueType>();
|
||||
const [issueMessage, setIssueMessage] = useState<string>();
|
||||
const [requestBody, _setRequestBody] = useState<MediaRequestBody>();
|
||||
const advancedReqModalRef = useRef<BottomSheetModal>(null);
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
|
||||
@@ -75,7 +71,7 @@ const Page: React.FC = () => {
|
||||
refetch,
|
||||
} = useQuery({
|
||||
enabled: !!jellyseerrApi && !!result && !!result.id,
|
||||
queryKey: ["jellyseerr", "detail", mediaType, result.id],
|
||||
queryKey: ["jellyseerr", "detail", result.mediaType, result.id],
|
||||
staleTime: 0,
|
||||
refetchOnMount: true,
|
||||
refetchOnReconnect: true,
|
||||
@@ -83,9 +79,9 @@ const Page: React.FC = () => {
|
||||
retryOnMount: true,
|
||||
refetchInterval: 0,
|
||||
queryFn: async () => {
|
||||
return mediaType === MediaType.MOVIE
|
||||
? jellyseerrApi?.movieDetails(result.id!)
|
||||
: jellyseerrApi?.tvDetails(result.id!);
|
||||
return result.mediaType === MediaType.MOVIE
|
||||
? jellyseerrApi?.movieDetails(result.id!!)
|
||||
: jellyseerrApi?.tvDetails(result.id!!);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -100,7 +96,7 @@ const Page: React.FC = () => {
|
||||
appearsOnIndex={0}
|
||||
/>
|
||||
),
|
||||
[],
|
||||
[]
|
||||
);
|
||||
|
||||
const submitIssue = useCallback(() => {
|
||||
@@ -115,18 +111,10 @@ const Page: React.FC = () => {
|
||||
}
|
||||
}, [jellyseerrApi, details, result, issueType, issueMessage]);
|
||||
|
||||
const setRequestBody = useCallback(
|
||||
(body: MediaRequestBody) => {
|
||||
_setRequestBody(body);
|
||||
advancedReqModalRef?.current?.present?.();
|
||||
},
|
||||
[requestBody, _setRequestBody, advancedReqModalRef],
|
||||
);
|
||||
|
||||
const request = useCallback(async () => {
|
||||
const body: MediaRequestBody = {
|
||||
mediaId: Number(result.id!),
|
||||
mediaType: mediaType!,
|
||||
mediaId: Number(result.id!!),
|
||||
mediaType: result.mediaType!!,
|
||||
tvdbId: details?.externalIds?.tvdbId,
|
||||
seasons: (details as TvDetails)?.seasons
|
||||
?.filter?.((s) => s.seasonNumber !== 0)
|
||||
@@ -134,7 +122,7 @@ const Page: React.FC = () => {
|
||||
};
|
||||
|
||||
if (hasAdvancedRequestPermission) {
|
||||
setRequestBody(body);
|
||||
advancedReqModalRef?.current?.present?.(body);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -144,15 +132,15 @@ const Page: React.FC = () => {
|
||||
const isAnime = useMemo(
|
||||
() =>
|
||||
(details?.keywords.some((k) => k.id === ANIME_KEYWORD_ID) || false) &&
|
||||
mediaType === MediaType.TV,
|
||||
[details],
|
||||
result.mediaType === MediaType.TV,
|
||||
[details]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (details) {
|
||||
navigation.setOptions({
|
||||
headerRight: () => (
|
||||
<TouchableOpacity className='rounded-full p-2 bg-neutral-800/80'>
|
||||
<TouchableOpacity className="rounded-full p-2 bg-neutral-800/80">
|
||||
<ItemActions item={details} />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
@@ -162,14 +150,14 @@ const Page: React.FC = () => {
|
||||
|
||||
return (
|
||||
<View
|
||||
className='flex-1 relative'
|
||||
className="flex-1 relative"
|
||||
style={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<ParallaxScrollView
|
||||
className='flex-1 opacity-100'
|
||||
className="flex-1 opacity-100"
|
||||
headerHeight={300}
|
||||
headerImage={
|
||||
<View>
|
||||
@@ -184,7 +172,7 @@ const Page: React.FC = () => {
|
||||
source={{
|
||||
uri: jellyseerrApi?.imageProxy(
|
||||
result.backdropPath,
|
||||
"w1920_and_h800_multi_faces",
|
||||
"w1920_and_h800_multi_faces"
|
||||
),
|
||||
}}
|
||||
/>
|
||||
@@ -194,12 +182,12 @@ const Page: React.FC = () => {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
className='flex flex-col items-center justify-center border border-neutral-800 bg-neutral-900'
|
||||
className="flex flex-col items-center justify-center border border-neutral-800 bg-neutral-900"
|
||||
>
|
||||
<Ionicons
|
||||
name='image-outline'
|
||||
name="image-outline"
|
||||
size={24}
|
||||
color='white'
|
||||
color="white"
|
||||
style={{ opacity: 0.4 }}
|
||||
/>
|
||||
</View>
|
||||
@@ -207,27 +195,23 @@ const Page: React.FC = () => {
|
||||
</View>
|
||||
}
|
||||
>
|
||||
<View className='flex flex-col'>
|
||||
<View className='space-y-4'>
|
||||
<View className='px-4'>
|
||||
<View className='flex flex-row justify-between w-full'>
|
||||
<View className='flex flex-col w-56'>
|
||||
<JellyserrRatings
|
||||
result={
|
||||
result as
|
||||
| MovieResult
|
||||
| TvResult
|
||||
| MovieDetails
|
||||
| TvDetails
|
||||
}
|
||||
/>
|
||||
<Text selectable className='font-bold text-2xl mb-1'>
|
||||
<View className="flex flex-col">
|
||||
<View className="space-y-4">
|
||||
<View className="px-4">
|
||||
<View className="flex flex-row justify-between w-full">
|
||||
<View className="flex flex-col w-56">
|
||||
<JellyserrRatings result={result as MovieResult | TvResult} />
|
||||
<Text
|
||||
uiTextView
|
||||
selectable
|
||||
className="font-bold text-2xl mb-1"
|
||||
>
|
||||
{mediaTitle}
|
||||
</Text>
|
||||
<Text className='opacity-50'>{releaseYear}</Text>
|
||||
<Text className="opacity-50">{releaseYear}</Text>
|
||||
</View>
|
||||
<Image
|
||||
className='absolute bottom-1 right-1 rounded-lg w-28 aspect-[10/15] border-2 border-neutral-800/50 drop-shadow-2xl'
|
||||
className="absolute bottom-1 right-1 rounded-lg w-28 aspect-[10/15] border-2 border-neutral-800/50 drop-shadow-2xl"
|
||||
cachePolicy={"memory-disk"}
|
||||
transition={300}
|
||||
source={{
|
||||
@@ -235,82 +219,48 @@ const Page: React.FC = () => {
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
<View>
|
||||
<View className="mb-4">
|
||||
<GenreTags genres={details?.genres?.map((g) => g.name) || []} />
|
||||
</View>
|
||||
{isLoading || isFetching ? (
|
||||
<Button
|
||||
loading={true}
|
||||
disabled={true}
|
||||
color='purple'
|
||||
className='mt-4'
|
||||
/>
|
||||
<Button loading={true} disabled={true} color="purple"></Button>
|
||||
) : canRequest ? (
|
||||
<Button color='purple' onPress={request} className='mt-4'>
|
||||
<Button color="purple" onPress={request}>
|
||||
{t("jellyseerr.request_button")}
|
||||
</Button>
|
||||
) : (
|
||||
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-purple-600/50 border-purple-400 ring-purple-400 text-purple-100'
|
||||
onPress={() => {
|
||||
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' />
|
||||
}
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
}}
|
||||
>
|
||||
<Text className='text-sm'>Play</Text>
|
||||
</Button>
|
||||
</View>
|
||||
)
|
||||
<Button
|
||||
className="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={24} color="white" />
|
||||
}
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
}}
|
||||
>
|
||||
{t("jellyseerr.report_issue_button")}
|
||||
</Button>
|
||||
)}
|
||||
<OverviewText text={result.overview} className='mt-4' />
|
||||
<OverviewText text={result.overview} className="mt-4" />
|
||||
</View>
|
||||
|
||||
{mediaType === MediaType.TV && (
|
||||
{result.mediaType === MediaType.TV && (
|
||||
<JellyseerrSeasons
|
||||
isLoading={isLoading || isFetching}
|
||||
result={result as TvResult}
|
||||
details={details as TvDetails}
|
||||
refetch={refetch}
|
||||
hasAdvancedRequest={hasAdvancedRequestPermission}
|
||||
onAdvancedRequest={(data) => setRequestBody(data)}
|
||||
onAdvancedRequest={(data) =>
|
||||
advancedReqModalRef?.current?.present(data)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<DetailFacts
|
||||
className='p-2 border border-neutral-800 bg-neutral-900 rounded-xl'
|
||||
className="p-2 border border-neutral-800 bg-neutral-900 rounded-xl"
|
||||
details={details}
|
||||
/>
|
||||
<Cast details={details} />
|
||||
@@ -319,107 +269,101 @@ const Page: React.FC = () => {
|
||||
</ParallaxScrollView>
|
||||
<RequestModal
|
||||
ref={advancedReqModalRef}
|
||||
requestBody={requestBody}
|
||||
title={mediaTitle}
|
||||
id={result.id!}
|
||||
type={mediaType}
|
||||
id={result.id!!}
|
||||
type={result.mediaType as MediaType}
|
||||
isAnime={isAnime}
|
||||
onRequested={() => {
|
||||
_setRequestBody(undefined);
|
||||
advancedReqModalRef?.current?.close();
|
||||
refetch();
|
||||
}}
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
import {
|
||||
useLocalSearchParams,
|
||||
useSegments,
|
||||
} from "expo-router";
|
||||
import React, { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { orderBy, uniqBy } from "lodash";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
|
||||
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 { Text } from "@/components/common/Text";
|
||||
import { Image } from "expo-image";
|
||||
import { OverviewText } from "@/components/OverviewText";
|
||||
import {orderBy, uniqBy} from "lodash";
|
||||
import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
|
||||
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
|
||||
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
||||
import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function page() {
|
||||
const local = useLocalSearchParams();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const {
|
||||
jellyseerrApi,
|
||||
jellyseerrRegion: region,
|
||||
jellyseerrLocale: locale,
|
||||
} = useJellyseerr();
|
||||
const { jellyseerrApi, jellyseerrUser } = 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),
|
||||
@@ -32,29 +32,29 @@ export default function page() {
|
||||
enabled: !!jellyseerrApi && !!personId,
|
||||
});
|
||||
|
||||
const locale = useMemo(() => {
|
||||
return jellyseerrUser?.settings?.locale || "en";
|
||||
}, [jellyseerrUser]);
|
||||
|
||||
const region = useMemo(
|
||||
() => jellyseerrUser?.settings?.region || "US",
|
||||
[jellyseerrUser]
|
||||
);
|
||||
|
||||
const castedRoles: PersonCreditCast[] = useMemo(
|
||||
() =>
|
||||
uniqBy(
|
||||
orderBy(
|
||||
data?.combinedCredits?.cast,
|
||||
["voteCount", "voteAverage"],
|
||||
"desc",
|
||||
),
|
||||
"id",
|
||||
),
|
||||
[data?.combinedCredits],
|
||||
uniqBy(orderBy(
|
||||
data?.combinedCredits?.cast,
|
||||
["voteCount", "voteAverage"],
|
||||
"desc"
|
||||
), 'id'),
|
||||
[data?.combinedCredits]
|
||||
);
|
||||
const backdrops = useMemo(
|
||||
() =>
|
||||
jellyseerrApi
|
||||
? castedRoles.map((c) =>
|
||||
jellyseerrApi.imageProxy(
|
||||
c.backdropPath,
|
||||
"w1920_and_h800_multi_faces",
|
||||
),
|
||||
)
|
||||
: [],
|
||||
[jellyseerrApi, data?.combinedCredits],
|
||||
() => jellyseerrApi
|
||||
? castedRoles.map((c) => jellyseerrApi.imageProxy(c.backdropPath, "w1920_and_h800_multi_faces"))
|
||||
: [],
|
||||
[jellyseerrApi, data?.combinedCredits]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -67,15 +67,15 @@ export default function page() {
|
||||
<Image
|
||||
key={data?.details?.id}
|
||||
id={data?.details?.id.toString()}
|
||||
className='rounded-full bottom-1'
|
||||
className="rounded-full bottom-1"
|
||||
source={{
|
||||
uri: jellyseerrApi?.imageProxy(
|
||||
data?.details?.profilePath,
|
||||
"w600_and_h600_bestv2",
|
||||
"w600_and_h600_bestv2"
|
||||
),
|
||||
}}
|
||||
cachePolicy={"memory-disk"}
|
||||
contentFit='cover'
|
||||
contentFit="cover"
|
||||
style={{
|
||||
width: 125,
|
||||
height: 125,
|
||||
@@ -84,25 +84,27 @@ export default function page() {
|
||||
}
|
||||
HeaderContent={() => (
|
||||
<>
|
||||
<Text className='font-bold text-2xl mb-1'>{data?.details?.name}</Text>
|
||||
<Text className='opacity-50'>
|
||||
<Text className="font-bold text-2xl mb-1">
|
||||
{data?.details?.name}
|
||||
</Text>
|
||||
<Text className="opacity-50">
|
||||
{t("jellyseerr.born")}{" "}
|
||||
{new Date(data?.details?.birthday!).toLocaleDateString(
|
||||
{new Date(data?.details?.birthday!!).toLocaleDateString(
|
||||
`${locale}-${region}`,
|
||||
{
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
},
|
||||
}
|
||||
)}{" "}
|
||||
| {data?.details?.placeOfBirth}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
MainContent={() => (
|
||||
<OverviewText text={data?.details?.biography} className='mt-4' />
|
||||
<OverviewText text={data?.details?.biography} className="mt-4" />
|
||||
)}
|
||||
renderItem={(item, _index) => <JellyseerrPoster item={item} />}
|
||||
renderItem={(item, index) => <JellyseerrPoster item={item as MovieResult | TvResult} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import {
|
||||
createMaterialTopTabNavigator,
|
||||
import type {
|
||||
MaterialTopTabNavigationEventMap,
|
||||
MaterialTopTabNavigationOptions,
|
||||
} from "@react-navigation/material-top-tabs";
|
||||
import type {
|
||||
ParamListBase,
|
||||
TabNavigationState,
|
||||
} from "@react-navigation/native";
|
||||
import { createMaterialTopTabNavigator } from "@react-navigation/material-top-tabs";
|
||||
import { ParamListBase, TabNavigationState } from "@react-navigation/native";
|
||||
import { Stack, withLayoutContext } from "expo-router";
|
||||
import React from "react";
|
||||
|
||||
const { Navigator } = createMaterialTopTabNavigator();
|
||||
|
||||
@@ -23,8 +21,8 @@ const Layout = () => {
|
||||
<>
|
||||
<Stack.Screen options={{ title: "Live TV" }} />
|
||||
<Tab
|
||||
initialRouteName='programs'
|
||||
keyboardDismissMode='none'
|
||||
initialRouteName="programs"
|
||||
keyboardDismissMode="none"
|
||||
screenOptions={{
|
||||
tabBarBounces: true,
|
||||
tabBarLabelStyle: { fontSize: 10 },
|
||||
@@ -39,10 +37,10 @@ const Layout = () => {
|
||||
tabBarScrollEnabled: true,
|
||||
}}
|
||||
>
|
||||
<Tab.Screen name='programs' />
|
||||
<Tab.Screen name='guide' />
|
||||
<Tab.Screen name='channels' />
|
||||
<Tab.Screen name='recordings' />
|
||||
<Tab.Screen name="programs" />
|
||||
<Tab.Screen name="guide" />
|
||||
<Tab.Screen name="channels" />
|
||||
<Tab.Screen name="recordings" />
|
||||
</Tab>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { ItemImage } from "@/components/common/ItemImage";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
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 React from "react";
|
||||
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 insets = useSafeAreaInsets();
|
||||
|
||||
const { data: channels } = useQuery({
|
||||
queryKey: ["livetv", "channels"],
|
||||
@@ -30,13 +31,13 @@ export default function page() {
|
||||
});
|
||||
|
||||
return (
|
||||
<View className='flex flex-1'>
|
||||
<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'>
|
||||
<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",
|
||||
@@ -46,7 +47,7 @@ export default function page() {
|
||||
item={item}
|
||||
/>
|
||||
</View>
|
||||
<Text className='font-bold'>{item.Name}</Text>
|
||||
<Text className="font-bold">{item.Name}</Text>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
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 { TAB_HEIGHT } from "@/constants/Values";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
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, useMemo, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Dimensions,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const HOUR_HEIGHT = 30;
|
||||
const ITEMS_PER_PAGE = 20;
|
||||
@@ -21,9 +28,17 @@ export default function page() {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const insets = useSafeAreaInsets();
|
||||
const [date, _setDate] = useState<Date>(new Date());
|
||||
const [date, setDate] = useState<Date>(new Date());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
const { data: guideInfo } = useQuery({
|
||||
queryKey: ["livetv", "guideInfo"],
|
||||
queryFn: async () => {
|
||||
const res = await getLiveTvApi(api!).getGuideInfo();
|
||||
return res.data;
|
||||
},
|
||||
});
|
||||
|
||||
const { data: channels } = useQuery({
|
||||
queryKey: ["livetv", "channels", currentPage],
|
||||
queryFn: async () => {
|
||||
@@ -56,7 +71,7 @@ export default function page() {
|
||||
MaxStartDate: endOfDay.toISOString(),
|
||||
MinEndDate: isToday ? now.toISOString() : startOfDay.toISOString(),
|
||||
ChannelIds: channels?.Items?.map((c) => c.Id).filter(
|
||||
Boolean,
|
||||
Boolean
|
||||
) as string[],
|
||||
ImageTypeLimit: 1,
|
||||
EnableImages: false,
|
||||
@@ -85,7 +100,7 @@ export default function page() {
|
||||
return (
|
||||
<ScrollView
|
||||
nestedScrollEnabled
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
key={"home"}
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
@@ -102,22 +117,22 @@ export default function page() {
|
||||
}
|
||||
/>
|
||||
|
||||
<View className='flex flex-row'>
|
||||
<View className='flex flex-col w-[64px]'>
|
||||
<View className="flex flex-row">
|
||||
<View className="flex flex-col w-[64px]">
|
||||
<View
|
||||
style={{
|
||||
height: HOUR_HEIGHT,
|
||||
}}
|
||||
className='bg-neutral-800'
|
||||
/>
|
||||
className="bg-neutral-800"
|
||||
></View>
|
||||
{channels?.Items?.map((c, i) => (
|
||||
<View className='h-16 w-16 mr-4 rounded-lg overflow-hidden' key={i}>
|
||||
<View className="h-16 w-16 mr-4 rounded-lg overflow-hidden" key={i}>
|
||||
<ItemImage
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
resizeMode: "contain",
|
||||
}}
|
||||
contentFit='contain'
|
||||
item={c}
|
||||
/>
|
||||
</View>
|
||||
@@ -133,9 +148,9 @@ export default function page() {
|
||||
setScrollX(e.nativeEvent.contentOffset.x);
|
||||
}}
|
||||
>
|
||||
<View className='flex flex-col'>
|
||||
<View className="flex flex-col">
|
||||
<HourHeader height={HOUR_HEIGHT} />
|
||||
{channels?.Items?.map((c, _i) => (
|
||||
{channels?.Items?.map((c, i) => (
|
||||
<MemoizedLiveTVGuideRow
|
||||
channel={c}
|
||||
programs={programs?.Items}
|
||||
@@ -165,14 +180,14 @@ const PageButtons: React.FC<PageButtonsProps> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View className='flex flex-row justify-between items-center bg-neutral-800 w-full px-4 py-2'>
|
||||
<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'
|
||||
className="flex flex-row items-center"
|
||||
>
|
||||
<Ionicons
|
||||
name='chevron-back'
|
||||
name="chevron-back"
|
||||
size={24}
|
||||
color={currentPage === 1 ? "gray" : "white"}
|
||||
/>
|
||||
@@ -184,11 +199,11 @@ const PageButtons: React.FC<PageButtonsProps> = ({
|
||||
{t("live_tv.previous")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<Text className='text-white'>Page {currentPage}</Text>
|
||||
<Text className="text-white">Page {currentPage}</Text>
|
||||
<TouchableOpacity
|
||||
onPress={onNextPage}
|
||||
disabled={isNextDisabled}
|
||||
className='flex flex-row items-center'
|
||||
className="flex flex-row items-center"
|
||||
>
|
||||
<Text
|
||||
className={`mr-1 ${isNextDisabled ? "text-gray-500" : "text-white"}`}
|
||||
@@ -196,7 +211,7 @@ const PageButtons: React.FC<PageButtonsProps> = ({
|
||||
{t("live_tv.next")}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-forward'
|
||||
name="chevron-forward"
|
||||
size={24}
|
||||
color={isNextDisabled ? "gray" : "white"}
|
||||
/>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
||||
import { TAB_HEIGHT } from "@/constants/Values";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { 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 React from "react";
|
||||
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";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function page() {
|
||||
const [api] = useAtom(apiAtom);
|
||||
@@ -17,7 +19,7 @@ export default function page() {
|
||||
return (
|
||||
<ScrollView
|
||||
nestedScrollEnabled
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
key={"home"}
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
@@ -26,7 +28,7 @@ export default function page() {
|
||||
paddingTop: 8,
|
||||
}}
|
||||
>
|
||||
<View className='flex flex-col space-y-2'>
|
||||
<View className="flex flex-col space-y-2">
|
||||
<ScrollingCollectionList
|
||||
queryKey={["livetv", "recommended"]}
|
||||
title={t("live_tv.on_now")}
|
||||
@@ -43,7 +45,7 @@ export default function page() {
|
||||
});
|
||||
return res.data.Items || [];
|
||||
}}
|
||||
orientation='horizontal'
|
||||
orientation="horizontal"
|
||||
/>
|
||||
<ScrollingCollectionList
|
||||
queryKey={["livetv", "shows"]}
|
||||
@@ -65,7 +67,7 @@ export default function page() {
|
||||
});
|
||||
return res.data.Items || [];
|
||||
}}
|
||||
orientation='horizontal'
|
||||
orientation="horizontal"
|
||||
/>
|
||||
<ScrollingCollectionList
|
||||
queryKey={["livetv", "movies"]}
|
||||
@@ -83,7 +85,7 @@ export default function page() {
|
||||
});
|
||||
return res.data.Items || [];
|
||||
}}
|
||||
orientation='horizontal'
|
||||
orientation="horizontal"
|
||||
/>
|
||||
<ScrollingCollectionList
|
||||
queryKey={["livetv", "sports"]}
|
||||
@@ -101,7 +103,7 @@ export default function page() {
|
||||
});
|
||||
return res.data.Items || [];
|
||||
}}
|
||||
orientation='horizontal'
|
||||
orientation="horizontal"
|
||||
/>
|
||||
<ScrollingCollectionList
|
||||
queryKey={["livetv", "kids"]}
|
||||
@@ -119,7 +121,7 @@ export default function page() {
|
||||
});
|
||||
return res.data.Items || [];
|
||||
}}
|
||||
orientation='horizontal'
|
||||
orientation="horizontal"
|
||||
/>
|
||||
<ScrollingCollectionList
|
||||
queryKey={["livetv", "news"]}
|
||||
@@ -137,7 +139,7 @@ export default function page() {
|
||||
});
|
||||
return res.data.Items || [];
|
||||
}}
|
||||
orientation='horizontal'
|
||||
orientation="horizontal"
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import React from "react";
|
||||
import { View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function page() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View className='flex items-center justify-center h-full -mt-12'>
|
||||
<View className="flex items-center justify-center h-full -mt-12">
|
||||
<Text>{t("live_tv.coming_soon")}</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,3 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import type React from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View } from "react-native";
|
||||
import { AddToFavorites } from "@/components/AddToFavorites";
|
||||
import { DownloadItems } from "@/components/DownloadItem";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
@@ -18,6 +8,15 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useEffect, useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const page: React.FC = () => {
|
||||
const navigation = useNavigation();
|
||||
@@ -50,7 +49,7 @@ const page: React.FC = () => {
|
||||
quality: 90,
|
||||
width: 1000,
|
||||
}),
|
||||
[item],
|
||||
[item]
|
||||
);
|
||||
|
||||
const logoUrl = useMemo(
|
||||
@@ -59,7 +58,7 @@ const page: React.FC = () => {
|
||||
api,
|
||||
item,
|
||||
}),
|
||||
[item],
|
||||
[item]
|
||||
);
|
||||
|
||||
const { data: allEpisodes, isLoading } = useQuery({
|
||||
@@ -69,18 +68,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,
|
||||
});
|
||||
@@ -92,25 +83,23 @@ const page: React.FC = () => {
|
||||
item &&
|
||||
allEpisodes &&
|
||||
allEpisodes.length > 0 && (
|
||||
<View className='flex flex-row items-center space-x-2'>
|
||||
<AddToFavorites item={item} />
|
||||
{!Platform.isTV && (
|
||||
<DownloadItems
|
||||
size='large'
|
||||
title={t("item_card.download.download_series")}
|
||||
items={allEpisodes || []}
|
||||
MissingDownloadIconComponent={() => (
|
||||
<Ionicons name='download' size={22} color='white' />
|
||||
)}
|
||||
DownloadedIconComponent={() => (
|
||||
<Ionicons
|
||||
name='checkmark-done-outline'
|
||||
size={24}
|
||||
color='#9333ea'
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
<AddToFavorites item={item} type="series" />
|
||||
<DownloadItems
|
||||
size="large"
|
||||
title={t("item_card.download.download_series")}
|
||||
items={allEpisodes || []}
|
||||
MissingDownloadIconComponent={() => (
|
||||
<Ionicons name="download" size={22} color="white" />
|
||||
)}
|
||||
DownloadedIconComponent={() => (
|
||||
<Ionicons
|
||||
name="checkmark-done-outline"
|
||||
size={24}
|
||||
color="#9333ea"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
),
|
||||
});
|
||||
@@ -133,23 +122,25 @@ const page: React.FC = () => {
|
||||
/>
|
||||
}
|
||||
logo={
|
||||
logoUrl ? (
|
||||
<Image
|
||||
source={{
|
||||
uri: logoUrl,
|
||||
}}
|
||||
style={{
|
||||
height: 130,
|
||||
width: "100%",
|
||||
}}
|
||||
contentFit='contain'
|
||||
/>
|
||||
) : undefined
|
||||
<>
|
||||
{logoUrl ? (
|
||||
<Image
|
||||
source={{
|
||||
uri: logoUrl,
|
||||
}}
|
||||
style={{
|
||||
height: 130,
|
||||
width: "100%",
|
||||
resizeMode: "contain",
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<View className='flex flex-col pt-4'>
|
||||
<View className="flex flex-col pt-4">
|
||||
<SeriesHeader item={item} />
|
||||
<View className='mb-4'>
|
||||
<View className="mb-4">
|
||||
<NextUp seriesId={seriesId} />
|
||||
</View>
|
||||
<SeasonPicker item={item} initialSeasonIndex={Number(seasonIndex)} />
|
||||
|
||||
@@ -1,4 +1,35 @@
|
||||
import type {
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useEffect, useMemo } from "react";
|
||||
import { FlatList, useWindowDimensions, View } from "react-native";
|
||||
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import { FilterButton } from "@/components/filters/FilterButton";
|
||||
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
genreFilterAtom,
|
||||
getSortByPreference,
|
||||
getSortOrderPreference,
|
||||
sortByAtom,
|
||||
SortByOption,
|
||||
sortByPreferenceAtom,
|
||||
sortOptions,
|
||||
sortOrderAtom,
|
||||
SortOrderOption,
|
||||
sortOrderOptions,
|
||||
sortOrderPreferenceAtom,
|
||||
tagsFilterAtom,
|
||||
yearFilterAtom,
|
||||
} from "@/utils/atoms/filters";
|
||||
import {
|
||||
BaseItemDto,
|
||||
BaseItemDtoQueryResult,
|
||||
BaseItemKind,
|
||||
@@ -9,38 +40,8 @@ import {
|
||||
getUserLibraryApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FlatList, useWindowDimensions, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import { FilterButton } from "@/components/filters/FilterButton";
|
||||
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
genreFilterAtom,
|
||||
getSortByPreference,
|
||||
getSortOrderPreference,
|
||||
SortByOption,
|
||||
SortOrderOption,
|
||||
sortByAtom,
|
||||
sortByPreferenceAtom,
|
||||
sortOptions,
|
||||
sortOrderAtom,
|
||||
sortOrderOptions,
|
||||
sortOrderPreferenceAtom,
|
||||
tagsFilterAtom,
|
||||
yearFilterAtom,
|
||||
} from "@/utils/atoms/filters";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const Page = () => {
|
||||
const searchParams = useLocalSearchParams();
|
||||
@@ -57,7 +58,7 @@ const Page = () => {
|
||||
const [sortOrder, _setSortOrder] = useAtom(sortOrderAtom);
|
||||
const [sortByPreference, setSortByPreference] = useAtom(sortByPreferenceAtom);
|
||||
const [sortOrderPreference, setOderByPreference] = useAtom(
|
||||
sortOrderPreferenceAtom,
|
||||
sortOrderPreferenceAtom
|
||||
);
|
||||
|
||||
const { orientation } = useOrientation();
|
||||
@@ -87,7 +88,7 @@ const Page = () => {
|
||||
}
|
||||
_setSortBy(sortBy);
|
||||
},
|
||||
[libraryId, sortByPreference],
|
||||
[libraryId, sortByPreference]
|
||||
);
|
||||
|
||||
const setSortOrder = useCallback(
|
||||
@@ -101,7 +102,7 @@ const Page = () => {
|
||||
}
|
||||
_setSortOrder(sortOrder);
|
||||
},
|
||||
[libraryId, sortOrderPreference],
|
||||
[libraryId, sortOrderPreference]
|
||||
);
|
||||
|
||||
const nrOfCols = useMemo(() => {
|
||||
@@ -168,7 +169,7 @@ const Page = () => {
|
||||
fields: ["PrimaryImageAspectRatio", "SortName"],
|
||||
genres: selectedGenres,
|
||||
tags: selectedTags,
|
||||
years: selectedYears.map((year) => Number.parseInt(year, 10)),
|
||||
years: selectedYears.map((year) => parseInt(year)),
|
||||
includeItemTypes: itemType ? [itemType] : undefined,
|
||||
});
|
||||
|
||||
@@ -184,7 +185,7 @@ const Page = () => {
|
||||
selectedTags,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
|
||||
@@ -210,13 +211,14 @@ const Page = () => {
|
||||
const totalItems = lastPage.TotalRecordCount;
|
||||
const accumulatedItems = pages.reduce(
|
||||
(acc, curr) => acc + (curr?.Items?.length || 0),
|
||||
0,
|
||||
0
|
||||
);
|
||||
|
||||
if (accumulatedItems < totalItems) {
|
||||
return lastPage?.Items?.length * pages.length;
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
initialPageParam: 0,
|
||||
enabled: !!api && !!user?.Id && !!library,
|
||||
@@ -246,8 +248,8 @@ const Page = () => {
|
||||
? index % nrOfCols === 0
|
||||
? "flex-end"
|
||||
: (index + 1) % nrOfCols === 0
|
||||
? "flex-start"
|
||||
: "center"
|
||||
? "flex-start"
|
||||
: "center"
|
||||
: "center",
|
||||
width: "89%",
|
||||
}}
|
||||
@@ -258,14 +260,14 @@ const Page = () => {
|
||||
</View>
|
||||
</TouchableItemRouter>
|
||||
),
|
||||
[orientation],
|
||||
[orientation]
|
||||
);
|
||||
|
||||
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||
|
||||
const ListHeaderComponent = useCallback(
|
||||
() => (
|
||||
<View className=''>
|
||||
<View className="">
|
||||
<FlatList
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
@@ -284,13 +286,13 @@ const Page = () => {
|
||||
key: "genre",
|
||||
component: (
|
||||
<FilterButton
|
||||
className='mr-1'
|
||||
id={libraryId}
|
||||
queryKey='genreFilter'
|
||||
className="mr-1"
|
||||
collectionId={libraryId}
|
||||
queryKey="genreFilter"
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api,
|
||||
api
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: libraryId,
|
||||
@@ -311,13 +313,13 @@ const Page = () => {
|
||||
key: "year",
|
||||
component: (
|
||||
<FilterButton
|
||||
className='mr-1'
|
||||
id={libraryId}
|
||||
queryKey='yearFilter'
|
||||
className="mr-1"
|
||||
collectionId={libraryId}
|
||||
queryKey="yearFilter"
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api,
|
||||
api
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: libraryId,
|
||||
@@ -336,13 +338,13 @@ const Page = () => {
|
||||
key: "tags",
|
||||
component: (
|
||||
<FilterButton
|
||||
className='mr-1'
|
||||
id={libraryId}
|
||||
queryKey='tagsFilter'
|
||||
className="mr-1"
|
||||
collectionId={libraryId}
|
||||
queryKey="tagsFilter"
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api,
|
||||
api
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: libraryId,
|
||||
@@ -363,9 +365,9 @@ const Page = () => {
|
||||
key: "sortBy",
|
||||
component: (
|
||||
<FilterButton
|
||||
className='mr-1'
|
||||
id={libraryId}
|
||||
queryKey='sortBy'
|
||||
className="mr-1"
|
||||
collectionId={libraryId}
|
||||
queryKey="sortBy"
|
||||
queryFn={async () => sortOptions.map((s) => s.key)}
|
||||
set={setSortBy}
|
||||
values={sortBy}
|
||||
@@ -383,9 +385,9 @@ const Page = () => {
|
||||
key: "sortOrder",
|
||||
component: (
|
||||
<FilterButton
|
||||
className='mr-1'
|
||||
id={libraryId}
|
||||
queryKey='sortOrder'
|
||||
className="mr-1"
|
||||
collectionId={libraryId}
|
||||
queryKey="sortOrder"
|
||||
queryFn={async () => sortOrderOptions.map((s) => s.key)}
|
||||
set={setSortOrder}
|
||||
values={sortOrder}
|
||||
@@ -420,29 +422,34 @@ const Page = () => {
|
||||
sortOrder,
|
||||
setSortOrder,
|
||||
isFetching,
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
if (isLoading || isLibraryLoading)
|
||||
return (
|
||||
<View className='w-full h-full flex items-center justify-center'>
|
||||
<View className="w-full h-full flex items-center justify-center">
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
|
||||
if (flatData.length === 0)
|
||||
return (
|
||||
<View className="h-full w-full flex justify-center items-center">
|
||||
<Text className="text-lg text-neutral-500">{t("library.no_items_found")}</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<FlashList
|
||||
key={orientation}
|
||||
ListEmptyComponent={
|
||||
<View className='flex flex-col items-center justify-center h-full'>
|
||||
<Text className='font-bold text-xl text-neutral-500'>
|
||||
{t("library.no_results")}
|
||||
</Text>
|
||||
<View className="flex flex-col items-center justify-center h-full">
|
||||
<Text className="font-bold text-xl text-neutral-500">{t("library.no_results")}</Text>
|
||||
</View>
|
||||
}
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
data={flatData}
|
||||
renderItem={renderItem}
|
||||
extraData={[orientation, nrOfCols]}
|
||||
@@ -467,7 +474,7 @@ const Page = () => {
|
||||
width: 10,
|
||||
height: 10,
|
||||
}}
|
||||
/>
|
||||
></View>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Stack } from "expo-router";
|
||||
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 [settings, updateSettings, pluginSettings] = useSettings();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -18,16 +16,16 @@ export default function IndexLayout() {
|
||||
return (
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
name='index'
|
||||
name="index"
|
||||
options={{
|
||||
headerShown: !Platform.isTV,
|
||||
headerShown: true,
|
||||
headerLargeTitle: true,
|
||||
headerTitle: t("tabs.library"),
|
||||
headerBlurEffect: "prominent",
|
||||
headerLargeStyle: {
|
||||
backgroundColor: "black",
|
||||
},
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||
headerShadowVisible: false,
|
||||
headerRight: () =>
|
||||
!pluginSettings?.libraryOptions?.locked &&
|
||||
@@ -35,9 +33,9 @@ export default function IndexLayout() {
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<Ionicons
|
||||
name='ellipsis-horizontal-outline'
|
||||
name="ellipsis-horizontal-outline"
|
||||
size={24}
|
||||
color='white'
|
||||
color="white"
|
||||
/>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
@@ -52,9 +50,9 @@ export default function IndexLayout() {
|
||||
<DropdownMenu.Label>
|
||||
{t("library.options.display")}
|
||||
</DropdownMenu.Label>
|
||||
<DropdownMenu.Group key='display-group'>
|
||||
<DropdownMenu.Group key="display-group">
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger key='image-style-trigger'>
|
||||
<DropdownMenu.SubTrigger key="image-style-trigger">
|
||||
{t("library.options.display")}
|
||||
</DropdownMenu.SubTrigger>
|
||||
<DropdownMenu.SubContent
|
||||
@@ -65,7 +63,7 @@ export default function IndexLayout() {
|
||||
sideOffset={10}
|
||||
>
|
||||
<DropdownMenu.CheckboxItem
|
||||
key='display-option-1'
|
||||
key="display-option-1"
|
||||
value={settings.libraryOptions.display === "row"}
|
||||
onValueChange={() =>
|
||||
updateSettings({
|
||||
@@ -77,12 +75,12 @@ export default function IndexLayout() {
|
||||
}
|
||||
>
|
||||
<DropdownMenu.ItemIndicator />
|
||||
<DropdownMenu.ItemTitle key='display-title-1'>
|
||||
<DropdownMenu.ItemTitle key="display-title-1">
|
||||
{t("library.options.row")}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
<DropdownMenu.CheckboxItem
|
||||
key='display-option-2'
|
||||
key="display-option-2"
|
||||
value={settings.libraryOptions.display === "list"}
|
||||
onValueChange={() =>
|
||||
updateSettings({
|
||||
@@ -94,14 +92,14 @@ export default function IndexLayout() {
|
||||
}
|
||||
>
|
||||
<DropdownMenu.ItemIndicator />
|
||||
<DropdownMenu.ItemTitle key='display-title-2'>
|
||||
<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'>
|
||||
<DropdownMenu.SubTrigger key="image-style-trigger">
|
||||
{t("library.options.image_style")}
|
||||
</DropdownMenu.SubTrigger>
|
||||
<DropdownMenu.SubContent
|
||||
@@ -112,7 +110,7 @@ export default function IndexLayout() {
|
||||
sideOffset={10}
|
||||
>
|
||||
<DropdownMenu.CheckboxItem
|
||||
key='poster-option'
|
||||
key="poster-option"
|
||||
value={
|
||||
settings.libraryOptions.imageStyle === "poster"
|
||||
}
|
||||
@@ -126,12 +124,12 @@ export default function IndexLayout() {
|
||||
}
|
||||
>
|
||||
<DropdownMenu.ItemIndicator />
|
||||
<DropdownMenu.ItemTitle key='poster-title'>
|
||||
<DropdownMenu.ItemTitle key="poster-title">
|
||||
{t("library.options.poster")}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
<DropdownMenu.CheckboxItem
|
||||
key='cover-option'
|
||||
key="cover-option"
|
||||
value={settings.libraryOptions.imageStyle === "cover"}
|
||||
onValueChange={() =>
|
||||
updateSettings({
|
||||
@@ -143,48 +141,48 @@ export default function IndexLayout() {
|
||||
}
|
||||
>
|
||||
<DropdownMenu.ItemIndicator />
|
||||
<DropdownMenu.ItemTitle key='cover-title'>
|
||||
<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.Group key="show-titles-group">
|
||||
<DropdownMenu.CheckboxItem
|
||||
disabled={settings.libraryOptions.imageStyle === "poster"}
|
||||
key='show-titles-option'
|
||||
key="show-titles-option"
|
||||
value={settings.libraryOptions.showTitles}
|
||||
onValueChange={(newValue: string) => {
|
||||
onValueChange={(newValue) => {
|
||||
if (settings.libraryOptions.imageStyle === "poster")
|
||||
return;
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
showTitles: newValue === "on",
|
||||
showTitles: newValue === "on" ? true : false,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemIndicator />
|
||||
<DropdownMenu.ItemTitle key='show-titles-title'>
|
||||
<DropdownMenu.ItemTitle key="show-titles-title">
|
||||
{t("library.options.show_titles")}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
<DropdownMenu.CheckboxItem
|
||||
key='show-stats-option'
|
||||
key="show-stats-option"
|
||||
value={settings.libraryOptions.showStats}
|
||||
onValueChange={(newValue: string) => {
|
||||
onValueChange={(newValue) => {
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
showStats: newValue === "on",
|
||||
showStats: newValue === "on" ? true : false,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemIndicator />
|
||||
<DropdownMenu.ItemTitle key='show-stats-title'>
|
||||
<DropdownMenu.ItemTitle key="show-stats-title">
|
||||
{t("library.options.show_stats")}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
@@ -197,12 +195,12 @@ export default function IndexLayout() {
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='[libraryId]'
|
||||
name="[libraryId]"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: !Platform.isTV,
|
||||
headerShown: true,
|
||||
headerBlurEffect: "prominent",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
@@ -210,12 +208,12 @@ export default function IndexLayout() {
|
||||
<Stack.Screen key={name} name={name} options={options} />
|
||||
))}
|
||||
<Stack.Screen
|
||||
name='collections/[collectionId]'
|
||||
name="collections/[collectionId]"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: !Platform.isTV,
|
||||
headerShown: true,
|
||||
headerBlurEffect: "prominent",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { LibraryItemCard } from "@/components/library/LibraryItemCard";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import {
|
||||
getUserLibraryApi,
|
||||
getUserViewsApi,
|
||||
@@ -6,24 +11,19 @@ import { FlashList } from "@shopify/flash-list";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { LibraryItemCard } from "@/components/library/LibraryItemCard";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function index() {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const queryClient = useQueryClient();
|
||||
const { settings } = useSettings();
|
||||
const [settings] = useSettings();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
const { data, isLoading: isLoading } = useQuery({
|
||||
queryKey: ["user-views", user?.Id],
|
||||
queryFn: async () => {
|
||||
const response = await getUserViewsApi(api!).getUserViews({
|
||||
@@ -41,7 +41,7 @@ export default function index() {
|
||||
?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!))
|
||||
.filter((l) => l.CollectionType !== "music")
|
||||
.filter((l) => l.CollectionType !== "books") || [],
|
||||
[data, settings?.hiddenLibraries],
|
||||
[data, settings?.hiddenLibraries]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -65,24 +65,22 @@ export default function index() {
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<View className='justify-center items-center h-full'>
|
||||
<View className="justify-center items-center h-full">
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
|
||||
if (!libraries)
|
||||
return (
|
||||
<View className='h-full w-full flex justify-center items-center'>
|
||||
<Text className='text-lg text-neutral-500'>
|
||||
{t("library.no_libraries_found")}
|
||||
</Text>
|
||||
<View className="h-full w-full flex justify-center items-center">
|
||||
<Text className="text-lg text-neutral-500">{t("library.no_libraries_found")}</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<FlashList
|
||||
extraData={settings}
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
contentContainerStyle={{
|
||||
paddingTop: 17,
|
||||
paddingHorizontal: settings?.libraryOptions?.display === "row" ? 0 : 17,
|
||||
@@ -99,10 +97,10 @@ export default function index() {
|
||||
style={{
|
||||
height: StyleSheet.hairlineWidth,
|
||||
}}
|
||||
className='bg-neutral-800 mx-2 my-4'
|
||||
/>
|
||||
className="bg-neutral-800 mx-2 my-4"
|
||||
></View>
|
||||
) : (
|
||||
<View className='h-4' />
|
||||
<View className="h-4" />
|
||||
)
|
||||
}
|
||||
estimatedItemSize={200}
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
import { Stack } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform } from "react-native";
|
||||
import {
|
||||
commonScreenOptions,
|
||||
nestedTabPageScreenOptions,
|
||||
} from "@/components/stacks/NestedTabPageStack";
|
||||
import { Stack } from "expo-router";
|
||||
import { Platform } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function SearchLayout() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
name='index'
|
||||
name="index"
|
||||
options={{
|
||||
headerShown: !Platform.isTV,
|
||||
headerShown: true,
|
||||
headerLargeTitle: true,
|
||||
headerTitle: t("tabs.search"),
|
||||
headerLargeStyle: {
|
||||
backgroundColor: "black",
|
||||
},
|
||||
headerBlurEffect: "prominent",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
@@ -28,28 +28,19 @@ export default function SearchLayout() {
|
||||
<Stack.Screen key={name} name={name} options={options} />
|
||||
))}
|
||||
<Stack.Screen
|
||||
name='collections/[collectionId]'
|
||||
name="collections/[collectionId]"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: !Platform.isTV,
|
||||
headerShown: true,
|
||||
headerBlurEffect: "prominent",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name='jellyseerr/page' options={commonScreenOptions} />
|
||||
<Stack.Screen
|
||||
name='jellyseerr/person/[personId]'
|
||||
options={commonScreenOptions}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='jellyseerr/company/[companyId]'
|
||||
options={commonScreenOptions}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='jellyseerr/genre/[genreId]'
|
||||
options={commonScreenOptions}
|
||||
/>
|
||||
<Stack.Screen name="jellyseerr/page" options={commonScreenOptions} />
|
||||
<Stack.Screen name="jellyseerr/person/[personId]" options={commonScreenOptions} />
|
||||
<Stack.Screen name="jellyseerr/company/[companyId]" options={commonScreenOptions} />
|
||||
<Stack.Screen name="jellyseerr/genre/[genreId]" options={commonScreenOptions} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,36 +1,10 @@
|
||||
import type {
|
||||
BaseItemDto,
|
||||
BaseItemKind,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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";
|
||||
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||
import { Tag } from "@/components/GenreTags";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import {
|
||||
JellyseerrSearchSort,
|
||||
JellyserrIndexPage,
|
||||
} from "@/components/jellyseerr/JellyseerrIndexPage";
|
||||
import { JellyserrIndexPage } from "@/components/jellyseerr/JellyseerrIndexPage";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import SeriesPoster from "@/components/posters/SeriesPoster";
|
||||
import { LoadingSkeleton } from "@/components/search/LoadingSkeleton";
|
||||
@@ -38,7 +12,26 @@ import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { eventBus } from "@/utils/eventBus";
|
||||
import {
|
||||
BaseItemDto,
|
||||
BaseItemKind,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi, getSearchApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
import { Href, router, useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useDebounce } from "use-debounce";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type SearchType = "Library" | "Discover";
|
||||
|
||||
@@ -55,14 +48,9 @@ export default function search() {
|
||||
const params = useLocalSearchParams();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const searchFilterId = useId();
|
||||
const orderFilterId = useId();
|
||||
|
||||
const { q } = params as { q: string };
|
||||
const { q, prev } = params as { q: string; prev: Href<string> };
|
||||
|
||||
const [searchType, setSearchType] = useState<SearchType>("Library");
|
||||
const [search, setSearch] = useState<string>("");
|
||||
@@ -70,27 +58,17 @@ export default function search() {
|
||||
const [debouncedSearch] = useDebounce(search, 500);
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const { settings } = useSettings();
|
||||
const [settings] = useSettings();
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
const [jellyseerrOrderBy, setJellyseerrOrderBy] =
|
||||
useState<JellyseerrSearchSort>(
|
||||
JellyseerrSearchSort[
|
||||
JellyseerrSearchSort.DEFAULT
|
||||
] as unknown as JellyseerrSearchSort,
|
||||
);
|
||||
const [jellyseerrSortOrder, setJellyseerrSortOrder] = useState<
|
||||
"asc" | "desc"
|
||||
>("desc");
|
||||
|
||||
const searchEngine = useMemo(() => {
|
||||
return settings?.searchEngine || "Jellyfin";
|
||||
}, [settings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (q && q.length > 0) {
|
||||
setSearch(q);
|
||||
}
|
||||
if (q && q.length > 0) setSearch(q);
|
||||
}, [q]);
|
||||
|
||||
const searchFn = useCallback(
|
||||
@@ -101,94 +79,63 @@ export default function search() {
|
||||
types: BaseItemKind[];
|
||||
query: string;
|
||||
}): Promise<BaseItemDto[]> => {
|
||||
if (!api || !query) {
|
||||
return [];
|
||||
}
|
||||
if (!api || !query) return [];
|
||||
|
||||
try {
|
||||
if (searchEngine === "Jellyfin") {
|
||||
const searchApi = await getItemsApi(api).getItems({
|
||||
const searchApi = await getSearchApi(api).getSearchHints({
|
||||
searchTerm: query,
|
||||
limit: 10,
|
||||
includeItemTypes: types,
|
||||
recursive: true,
|
||||
userId: user?.Id,
|
||||
});
|
||||
|
||||
return (searchApi.data.Items as BaseItemDto[]) || [];
|
||||
return (searchApi.data.SearchHints as BaseItemDto[]) || [];
|
||||
} else {
|
||||
if (!settings?.marlinServerUrl) return [];
|
||||
|
||||
const url = `${
|
||||
settings.marlinServerUrl
|
||||
}/search?q=${encodeURIComponent(query)}&includeItemTypes=${types
|
||||
.map((type) => encodeURIComponent(type))
|
||||
.join("&includeItemTypes=")}`;
|
||||
|
||||
const response1 = await axios.get(url);
|
||||
|
||||
const ids = response1.data.ids;
|
||||
|
||||
if (!ids || !ids.length) return [];
|
||||
|
||||
const response2 = await getItemsApi(api).getItems({
|
||||
ids,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
});
|
||||
|
||||
return (response2.data.Items as BaseItemDto[]) || [];
|
||||
}
|
||||
if (!settings?.marlinServerUrl) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const url = `${
|
||||
settings.marlinServerUrl
|
||||
}/search?q=${encodeURIComponent(query)}&includeItemTypes=${types
|
||||
.map((type) => encodeURIComponent(type))
|
||||
.join("&includeItemTypes=")}`;
|
||||
|
||||
const response1 = await axios.get(url);
|
||||
|
||||
const ids = response1.data.ids;
|
||||
|
||||
if (!ids || !ids.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const response2 = await getItemsApi(api).getItems({
|
||||
ids,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
});
|
||||
|
||||
return (response2.data.Items as BaseItemDto[]) || [];
|
||||
} catch (error) {
|
||||
console.error("Error during search:", error);
|
||||
return []; // Ensure an empty array is returned in case of an error
|
||||
}
|
||||
},
|
||||
[api, searchEngine, settings],
|
||||
[api, searchEngine, settings]
|
||||
);
|
||||
|
||||
type HeaderSearchBarRef = {
|
||||
focus: () => void;
|
||||
blur: () => void;
|
||||
setText: (text: string) => void;
|
||||
clearText: () => void;
|
||||
cancelSearch: () => void;
|
||||
};
|
||||
|
||||
const searchBarRef = useRef<HeaderSearchBarRef>(null);
|
||||
const navigation = useNavigation();
|
||||
useLayoutEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerSearchBarOptions: {
|
||||
ref: searchBarRef,
|
||||
placeholder: t("search.search"),
|
||||
onChangeText: (e: any) => {
|
||||
router.setParams({ q: "" });
|
||||
setSearch(e.nativeEvent.text);
|
||||
if (Platform.OS === "ios")
|
||||
navigation.setOptions({
|
||||
headerSearchBarOptions: {
|
||||
placeholder: t("search.search"),
|
||||
onChangeText: (e: any) => {
|
||||
router.setParams({ q: "" });
|
||||
setSearch(e.nativeEvent.text);
|
||||
},
|
||||
hideWhenScrolling: false,
|
||||
autoFocus: true,
|
||||
},
|
||||
hideWhenScrolling: false,
|
||||
autoFocus: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
}, [navigation]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = eventBus.on("searchTabPressed", () => {
|
||||
// Screen not active
|
||||
if (!searchBarRef.current) {
|
||||
return;
|
||||
}
|
||||
// Screen is active, focus search bar
|
||||
searchBarRef.current?.focus();
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { data: movies, isFetching: l1 } = useQuery({
|
||||
queryKey: ["search", "movies", debouncedSearch],
|
||||
queryFn: () =>
|
||||
@@ -254,223 +201,172 @@ 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
|
||||
}
|
||||
<View className="flex flex-col">
|
||||
{Platform.OS === "android" && (
|
||||
<View className="mb-4 px-4">
|
||||
<Input
|
||||
autoCorrect={false}
|
||||
returnKeyType="done"
|
||||
keyboardType="web-search"
|
||||
placeholder={t("search.search_here")}
|
||||
value={search}
|
||||
onChangeText={(text) => setSearch(text)}
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
{jellyseerrApi && (
|
||||
<View 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>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<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")}
|
||||
ids={movies?.map((m) => m.Id!)}
|
||||
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
|
||||
ids={series?.map((m) => m.Id!)}
|
||||
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
|
||||
ids={episodes?.map((m) => m.Id!)}
|
||||
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
|
||||
ids={collections?.map((m) => m.Id!)}
|
||||
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
|
||||
ids={actors?.map((m) => m.Id!)}
|
||||
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} />
|
||||
)}
|
||||
|
||||
{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)}
|
||||
key={e}
|
||||
className="mb-2"
|
||||
>
|
||||
<Text className="text-purple-600">{e}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,33 +1,36 @@
|
||||
import React, { useCallback, useRef } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useFocusEffect, useRouter, withLayoutContext } from "expo-router";
|
||||
|
||||
import {
|
||||
createNativeBottomTabNavigator,
|
||||
type NativeBottomTabNavigationEventMap,
|
||||
type NativeBottomTabNavigationOptions,
|
||||
NativeBottomTabNavigationEventMap,
|
||||
} from "@bottom-tabs/react-navigation";
|
||||
|
||||
const { Navigator } = createNativeBottomTabNavigator();
|
||||
|
||||
import { BottomTabNavigationOptions } from "@react-navigation/bottom-tabs";
|
||||
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
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";
|
||||
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();
|
||||
|
||||
@@ -43,33 +46,28 @@ export default function TabLayout() {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}
|
||||
}, []),
|
||||
}, [])
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SystemBars hidden={false} style='light' />
|
||||
<SystemBars hidden={false} style="light" />
|
||||
<NativeTabs
|
||||
sidebarAdaptable={false}
|
||||
tabBarStyle={{
|
||||
backgroundColor: "#121212",
|
||||
}}
|
||||
ignoresTopSafeArea
|
||||
barTintColor={Platform.OS === "android" ? "#121212" : undefined}
|
||||
tabBarActiveTintColor={Colors.primary}
|
||||
scrollEdgeAppearance='default'
|
||||
scrollEdgeAppearance="default"
|
||||
>
|
||||
<NativeTabs.Screen redirect name='index' />
|
||||
<NativeTabs.Screen redirect name="index" />
|
||||
<NativeTabs.Screen
|
||||
listeners={(_e) => ({
|
||||
tabPress: (_e) => {
|
||||
eventBus.emit("scrollToTop");
|
||||
},
|
||||
})}
|
||||
name='(home)'
|
||||
name="(home)"
|
||||
options={{
|
||||
title: t("tabs.home"),
|
||||
tabBarIcon:
|
||||
Platform.OS === "android"
|
||||
? (_e) => require("@/assets/icons/house.fill.png")
|
||||
Platform.OS == "android"
|
||||
? ({ color, focused, size }) =>
|
||||
require("@/assets/icons/house.fill.png")
|
||||
: ({ focused }) =>
|
||||
focused
|
||||
? { sfSymbol: "house.fill" }
|
||||
@@ -77,17 +75,13 @@ export default function TabLayout() {
|
||||
}}
|
||||
/>
|
||||
<NativeTabs.Screen
|
||||
listeners={(_e) => ({
|
||||
tabPress: (_e) => {
|
||||
eventBus.emit("searchTabPressed");
|
||||
},
|
||||
})}
|
||||
name='(search)'
|
||||
name="(search)"
|
||||
options={{
|
||||
title: t("tabs.search"),
|
||||
tabBarIcon:
|
||||
Platform.OS === "android"
|
||||
? (_e) => require("@/assets/icons/magnifyingglass.png")
|
||||
Platform.OS == "android"
|
||||
? ({ color, focused, size }) =>
|
||||
require("@/assets/icons/magnifyingglass.png")
|
||||
: ({ focused }) =>
|
||||
focused
|
||||
? { sfSymbol: "magnifyingglass" }
|
||||
@@ -95,12 +89,12 @@ export default function TabLayout() {
|
||||
}}
|
||||
/>
|
||||
<NativeTabs.Screen
|
||||
name='(favorites)'
|
||||
name="(favorites)"
|
||||
options={{
|
||||
title: t("tabs.favorites"),
|
||||
tabBarIcon:
|
||||
Platform.OS === "android"
|
||||
? ({ focused }) =>
|
||||
Platform.OS == "android"
|
||||
? ({ color, focused, size }) =>
|
||||
focused
|
||||
? require("@/assets/icons/heart.fill.png")
|
||||
: require("@/assets/icons/heart.png")
|
||||
@@ -111,12 +105,13 @@ export default function TabLayout() {
|
||||
}}
|
||||
/>
|
||||
<NativeTabs.Screen
|
||||
name='(libraries)'
|
||||
name="(libraries)"
|
||||
options={{
|
||||
title: t("tabs.library"),
|
||||
tabBarIcon:
|
||||
Platform.OS === "android"
|
||||
? (_e) => require("@/assets/icons/server.rack.png")
|
||||
Platform.OS == "android"
|
||||
? ({ color, focused, size }) =>
|
||||
require("@/assets/icons/server.rack.png")
|
||||
: ({ focused }) =>
|
||||
focused
|
||||
? { sfSymbol: "rectangle.stack.fill" }
|
||||
@@ -124,13 +119,14 @@ export default function TabLayout() {
|
||||
}}
|
||||
/>
|
||||
<NativeTabs.Screen
|
||||
name='(custom-links)'
|
||||
name="(custom-links)"
|
||||
options={{
|
||||
title: t("tabs.custom_links"),
|
||||
tabBarItemHidden: !settings?.showCustomMenuLinks,
|
||||
// @ts-expect-error
|
||||
tabBarItemHidden: settings?.showCustomMenuLinks ? false : true,
|
||||
tabBarIcon:
|
||||
Platform.OS === "android"
|
||||
? (_e) => require("@/assets/icons/list.png")
|
||||
Platform.OS == "android"
|
||||
? ({ focused }) => require("@/assets/icons/list.png")
|
||||
: ({ focused }) =>
|
||||
focused
|
||||
? { sfSymbol: "list.dash.fill" }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Stack } from "expo-router";
|
||||
import React from "react";
|
||||
import { SystemBars } from "react-native-edge-to-edge";
|
||||
|
||||
export default function Layout() {
|
||||
@@ -7,7 +8,16 @@ export default function Layout() {
|
||||
<SystemBars hidden />
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
name='direct-player'
|
||||
name="direct-player"
|
||||
options={{
|
||||
headerShown: false,
|
||||
autoHideHomeIndicator: true,
|
||||
title: "",
|
||||
animation: "fade",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="transcoding-player"
|
||||
options={{
|
||||
headerShown: false,
|
||||
autoHideHomeIndicator: true,
|
||||
|
||||
547
app/(auth)/player/transcoding-player.tsx
Normal file
@@ -0,0 +1,547 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { Controls } from "@/components/video-player/controls/Controls";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
|
||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||
import { TrackInfo } from "@/modules/vlc-player";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import transcoding from "@/utils/profiles/transcoding";
|
||||
import { secondsToTicks } from "@/utils/secondsToTicks";
|
||||
import { Api } from "@jellyfin/sdk";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import {
|
||||
getPlaystateApi,
|
||||
getUserLibraryApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useFocusEffect, useLocalSearchParams } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { View } from "react-native";
|
||||
import { useSharedValue } from "react-native-reanimated";
|
||||
import Video, {
|
||||
OnProgressData,
|
||||
SelectedTrack,
|
||||
SelectedTrackType,
|
||||
VideoRef,
|
||||
} from "react-native-video";
|
||||
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const Player = () => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const [settings] = useSettings();
|
||||
const videoRef = useRef<VideoRef | null>(null);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const firstTime = useRef(true);
|
||||
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
|
||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
||||
const [showControls, _setShowControls] = useState(true);
|
||||
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isBuffering, setIsBuffering] = useState(true);
|
||||
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
||||
|
||||
const setShowControls = useCallback((show: boolean) => {
|
||||
_setShowControls(show);
|
||||
lightHapticFeedback();
|
||||
}, []);
|
||||
|
||||
const progress = useSharedValue(0);
|
||||
const isSeeking = useSharedValue(false);
|
||||
const cacheProgress = useSharedValue(0);
|
||||
|
||||
const {
|
||||
itemId,
|
||||
audioIndex: audioIndexStr,
|
||||
subtitleIndex: subtitleIndexStr,
|
||||
mediaSourceId,
|
||||
bitrateValue: bitrateValueStr,
|
||||
} = useLocalSearchParams<{
|
||||
itemId: string;
|
||||
audioIndex: string;
|
||||
subtitleIndex: string;
|
||||
mediaSourceId: string;
|
||||
bitrateValue: string;
|
||||
}>();
|
||||
|
||||
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
|
||||
const subtitleIndex = subtitleIndexStr
|
||||
? parseInt(subtitleIndexStr, 10)
|
||||
: undefined;
|
||||
const bitrateValue = bitrateValueStr
|
||||
? parseInt(bitrateValueStr, 10)
|
||||
: undefined;
|
||||
|
||||
const {
|
||||
data: item,
|
||||
isLoading: isLoadingItem,
|
||||
isError: isErrorItem,
|
||||
} = useQuery({
|
||||
queryKey: ["item", itemId],
|
||||
queryFn: async () => {
|
||||
if (!api) {
|
||||
throw new Error("No api");
|
||||
}
|
||||
|
||||
if (!itemId) {
|
||||
console.warn("No itemId");
|
||||
return null;
|
||||
}
|
||||
|
||||
const res = await getUserLibraryApi(api).getItem({
|
||||
itemId,
|
||||
userId: user?.Id,
|
||||
});
|
||||
|
||||
return res.data;
|
||||
},
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
// TODO: NEED TO FIND A WAY TO FROM SWITCHING TO IMAGE BASED TO TEXT BASED SUBTITLES, THERE IS A BUG.
|
||||
// MOST LIKELY LIKELY NEED A MASSIVE REFACTOR.
|
||||
const {
|
||||
data: stream,
|
||||
isLoading: isLoadingStreamUrl,
|
||||
isError: isErrorStreamUrl,
|
||||
} = useQuery({
|
||||
queryKey: ["stream-url", itemId, bitrateValue, mediaSourceId, audioIndex],
|
||||
|
||||
queryFn: async () => {
|
||||
if (!api) {
|
||||
throw new Error("No api");
|
||||
}
|
||||
|
||||
if (!item) {
|
||||
console.warn("No item", itemId, item);
|
||||
return null;
|
||||
}
|
||||
|
||||
const res = await getStreamUrl({
|
||||
api,
|
||||
item,
|
||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
||||
userId: user?.Id,
|
||||
audioStreamIndex: audioIndex,
|
||||
maxStreamingBitrate: bitrateValue,
|
||||
mediaSourceId: mediaSourceId,
|
||||
subtitleStreamIndex: subtitleIndex,
|
||||
deviceProfile: transcoding,
|
||||
});
|
||||
|
||||
if (!res) return null;
|
||||
|
||||
const { mediaSource, sessionId, url } = res;
|
||||
|
||||
if (!sessionId || !mediaSource || !url) {
|
||||
console.warn("No sessionId or mediaSource or url", url);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
mediaSource,
|
||||
sessionId,
|
||||
url,
|
||||
};
|
||||
},
|
||||
enabled: !!item,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const poster = usePoster(item, api);
|
||||
const videoSource = useVideoSource(item, api, poster, stream?.url);
|
||||
|
||||
const togglePlay = useCallback(async () => {
|
||||
lightHapticFeedback();
|
||||
if (isPlaying) {
|
||||
videoRef.current?.pause();
|
||||
await getPlaystateApi(api!).onPlaybackProgress({
|
||||
itemId: item?.Id!,
|
||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||
mediaSourceId: mediaSourceId,
|
||||
positionTicks: Math.floor(progress.value),
|
||||
isPaused: true,
|
||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||
playSessionId: stream?.sessionId,
|
||||
});
|
||||
} else {
|
||||
videoRef.current?.resume();
|
||||
await getPlaystateApi(api!).onPlaybackProgress({
|
||||
itemId: item?.Id!,
|
||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||
mediaSourceId: mediaSourceId,
|
||||
positionTicks: Math.floor(progress.value),
|
||||
isPaused: false,
|
||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||
playSessionId: stream?.sessionId,
|
||||
});
|
||||
}
|
||||
}, [
|
||||
isPlaying,
|
||||
api,
|
||||
item,
|
||||
videoRef,
|
||||
settings,
|
||||
stream,
|
||||
audioIndex,
|
||||
subtitleIndex,
|
||||
mediaSourceId,
|
||||
]);
|
||||
|
||||
const play = useCallback(() => {
|
||||
videoRef.current?.resume();
|
||||
reportPlaybackStart();
|
||||
}, [videoRef]);
|
||||
|
||||
const pause = useCallback(() => {
|
||||
videoRef.current?.pause();
|
||||
}, [videoRef]);
|
||||
|
||||
const seek = useCallback(
|
||||
(seconds: number) => {
|
||||
videoRef.current?.seek(seconds);
|
||||
},
|
||||
[videoRef]
|
||||
);
|
||||
|
||||
const reportPlaybackStopped = async () => {
|
||||
if (!item?.Id) return;
|
||||
await getPlaystateApi(api!).onPlaybackStopped({
|
||||
itemId: item.Id,
|
||||
mediaSourceId: mediaSourceId,
|
||||
positionTicks: Math.floor(progress.value),
|
||||
playSessionId: stream?.sessionId,
|
||||
});
|
||||
revalidateProgressCache();
|
||||
};
|
||||
|
||||
const stop = useCallback(() => {
|
||||
reportPlaybackStopped();
|
||||
videoRef.current?.pause();
|
||||
setIsPlaybackStopped(true);
|
||||
}, [videoRef, reportPlaybackStopped]);
|
||||
|
||||
const reportPlaybackStart = async () => {
|
||||
if (!item?.Id) return;
|
||||
await getPlaystateApi(api!).onPlaybackStart({
|
||||
itemId: item.Id,
|
||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||
mediaSourceId: mediaSourceId,
|
||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||
playSessionId: stream?.sessionId,
|
||||
});
|
||||
};
|
||||
|
||||
const onProgress = useCallback(
|
||||
async (data: OnProgressData) => {
|
||||
if (isSeeking.value === true) return;
|
||||
if (isPlaybackStopped === true) return;
|
||||
|
||||
const ticks = secondsToTicks(data.currentTime);
|
||||
|
||||
progress.value = ticks;
|
||||
cacheProgress.value = secondsToTicks(data.playableDuration);
|
||||
|
||||
// TODO: Use this when streaming with HLS url, but NOT when direct playing
|
||||
// TODO: since playable duration is always 0 then.
|
||||
setIsBuffering(data.playableDuration === 0);
|
||||
|
||||
if (!item?.Id || data.currentTime === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await getPlaystateApi(api!).onPlaybackProgress({
|
||||
itemId: item.Id,
|
||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||
mediaSourceId: mediaSourceId,
|
||||
positionTicks: Math.round(ticks),
|
||||
isPaused: !isPlaying,
|
||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||
playSessionId: stream?.sessionId,
|
||||
});
|
||||
},
|
||||
[
|
||||
item,
|
||||
isPlaying,
|
||||
api,
|
||||
isPlaybackStopped,
|
||||
isSeeking,
|
||||
stream,
|
||||
mediaSourceId,
|
||||
audioIndex,
|
||||
subtitleIndex,
|
||||
]
|
||||
);
|
||||
|
||||
useOrientation();
|
||||
useOrientationSettings();
|
||||
|
||||
useWebSocket({
|
||||
isPlaying: isPlaying,
|
||||
togglePlay: togglePlay,
|
||||
stopPlayback: stop,
|
||||
offline: false,
|
||||
});
|
||||
|
||||
const [selectedTextTrack, setSelectedTextTrack] = useState<
|
||||
SelectedTrack | undefined
|
||||
>();
|
||||
|
||||
const [embededTextTracks, setEmbededTextTracks] = useState<
|
||||
{
|
||||
index: number;
|
||||
language?: string | undefined;
|
||||
selected?: boolean | undefined;
|
||||
title?: string | undefined;
|
||||
type: any;
|
||||
}[]
|
||||
>([]);
|
||||
|
||||
const [audioTracks, setAudioTracks] = useState<TrackInfo[]>([]);
|
||||
const [selectedAudioTrack, setSelectedAudioTrack] = useState<
|
||||
SelectedTrack | undefined
|
||||
>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTextTrack === undefined) {
|
||||
const subtitleHelper = new SubtitleHelper(
|
||||
stream?.mediaSource.MediaStreams ?? []
|
||||
);
|
||||
const embeddedTrackIndex = subtitleHelper.getEmbeddedTrackIndex(
|
||||
subtitleIndex!
|
||||
);
|
||||
|
||||
// Most likely the subtitle is burned in.
|
||||
if (embeddedTrackIndex === -1) return;
|
||||
|
||||
setSelectedTextTrack({
|
||||
type: SelectedTrackType.INDEX,
|
||||
value: embeddedTrackIndex,
|
||||
});
|
||||
}
|
||||
}, [embededTextTracks]);
|
||||
|
||||
const getAudioTracks = (): TrackInfo[] => {
|
||||
return audioTracks.map((t) => ({
|
||||
name: t.name,
|
||||
index: t.index,
|
||||
}));
|
||||
};
|
||||
|
||||
const getSubtitleTracks = (): TrackInfo[] => {
|
||||
return embededTextTracks.map((t) => ({
|
||||
name: t.title ?? "",
|
||||
index: t.index,
|
||||
language: t.language,
|
||||
}));
|
||||
};
|
||||
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
return async () => {
|
||||
stop();
|
||||
};
|
||||
}, [])
|
||||
);
|
||||
|
||||
if (isLoadingItem || isLoadingStreamUrl)
|
||||
return (
|
||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
|
||||
if (isErrorItem || isErrorStreamUrl)
|
||||
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>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: "black" }}>
|
||||
<View
|
||||
style={{
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
position: "relative",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{videoSource ? (
|
||||
<>
|
||||
<Video
|
||||
ref={videoRef}
|
||||
source={videoSource}
|
||||
style={{
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
}}
|
||||
resizeMode={ignoreSafeAreas ? "cover" : "contain"}
|
||||
onProgress={onProgress}
|
||||
onError={(e) => {
|
||||
console.error("Error playing video", e);
|
||||
}}
|
||||
onLoad={() => {
|
||||
if (firstTime.current === true) {
|
||||
play();
|
||||
firstTime.current = false;
|
||||
}
|
||||
}}
|
||||
progressUpdateInterval={500}
|
||||
playWhenInactive={true}
|
||||
allowsExternalPlayback={true}
|
||||
playInBackground={true}
|
||||
showNotificationControls={true}
|
||||
ignoreSilentSwitch="ignore"
|
||||
fullscreen={false}
|
||||
onPlaybackStateChanged={(state) => {
|
||||
if (isSeeking.value === false) setIsPlaying(state.isPlaying);
|
||||
}}
|
||||
onTextTracks={(data) => {
|
||||
setEmbededTextTracks(data.textTracks as any);
|
||||
}}
|
||||
onBuffer={(e) => {
|
||||
setIsBuffering(e.isBuffering);
|
||||
}}
|
||||
onAudioTracks={(e) => {
|
||||
setAudioTracks(
|
||||
e.audioTracks.map((t) => ({
|
||||
index: t.index,
|
||||
name: t.title ?? "",
|
||||
language: t.language,
|
||||
}))
|
||||
);
|
||||
}}
|
||||
selectedTextTrack={selectedTextTrack}
|
||||
selectedAudioTrack={selectedAudioTrack}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Text>{t("player.no_video_source")}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{item && (
|
||||
<Controls
|
||||
mediaSource={stream?.mediaSource}
|
||||
videoRef={videoRef}
|
||||
enableTrickplay={true}
|
||||
item={item}
|
||||
togglePlay={togglePlay}
|
||||
isPlaying={isPlaying}
|
||||
isSeeking={isSeeking}
|
||||
progress={progress}
|
||||
cacheProgress={cacheProgress}
|
||||
isBuffering={isBuffering}
|
||||
showControls={showControls}
|
||||
setShowControls={setShowControls}
|
||||
setIgnoreSafeAreas={setIgnoreSafeAreas}
|
||||
ignoreSafeAreas={ignoreSafeAreas}
|
||||
seek={seek}
|
||||
play={play}
|
||||
pause={pause}
|
||||
stop={stop}
|
||||
getSubtitleTracks={getSubtitleTracks}
|
||||
setSubtitleTrack={(i) => {
|
||||
if (i === -1) {
|
||||
setSelectedTextTrack({
|
||||
type: SelectedTrackType.DISABLED,
|
||||
value: undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setSelectedTextTrack({
|
||||
type: SelectedTrackType.INDEX,
|
||||
value: i,
|
||||
});
|
||||
}}
|
||||
getAudioTracks={getAudioTracks}
|
||||
setAudioTrack={(i) => {
|
||||
setSelectedAudioTrack({
|
||||
type: SelectedTrackType.INDEX,
|
||||
value: i,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export function usePoster(
|
||||
item: BaseItemDto | null | undefined,
|
||||
api: Api | null
|
||||
): string | undefined {
|
||||
const poster = useMemo(() => {
|
||||
if (!item || !api) return undefined;
|
||||
return item.Type === "Audio"
|
||||
? `${api.basePath}/Items/${item.AlbumId}/Images/Primary?tag=${item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
|
||||
: getBackdropUrl({
|
||||
api,
|
||||
item: item,
|
||||
quality: 70,
|
||||
width: 200,
|
||||
});
|
||||
}, [item, api]);
|
||||
|
||||
return poster ?? undefined;
|
||||
}
|
||||
|
||||
export function useVideoSource(
|
||||
item: BaseItemDto | null | undefined,
|
||||
api: Api | null,
|
||||
poster: string | undefined,
|
||||
url?: string | null
|
||||
) {
|
||||
const videoSource = useMemo(() => {
|
||||
if (!item || !api || !url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startPosition = item?.UserData?.PlaybackPositionTicks
|
||||
? Math.round(item.UserData.PlaybackPositionTicks / 10000)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
uri: url,
|
||||
isNetwork: true,
|
||||
startPosition,
|
||||
headers: getAuthHeaders(api),
|
||||
metadata: {
|
||||
title: item?.Name || "Unknown",
|
||||
description: item?.Overview ?? undefined,
|
||||
imageUri: poster,
|
||||
subtitle: item?.Album ?? undefined,
|
||||
},
|
||||
};
|
||||
}, [item, api, poster, url]);
|
||||
|
||||
return videoSource;
|
||||
}
|
||||
|
||||
export default Player;
|
||||
@@ -7,13 +7,13 @@ import { type PropsWithChildren } from "react";
|
||||
*/
|
||||
export default function Root({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<html lang='en'>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet='utf-8' />
|
||||
<meta httpEquiv='X-UA-Compatible' content='IE=edge' />
|
||||
<meta charSet="utf-8" />
|
||||
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta
|
||||
name='viewport'
|
||||
content='width=device-width, initial-scale=1, shrink-to-fit=no'
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, shrink-to-fit=no"
|
||||
/>
|
||||
|
||||
{/*
|
||||
|
||||
@@ -9,9 +9,9 @@ export default function NotFoundScreen() {
|
||||
<>
|
||||
<Stack.Screen options={{ title: "Oops!" }} />
|
||||
<ThemedView style={styles.container}>
|
||||
<ThemedText type='title'>This screen doesn't exist.</ThemedText>
|
||||
<ThemedText type="title">This screen doesn't exist.</ThemedText>
|
||||
<Link href={"/home"} style={styles.link}>
|
||||
<ThemedText type='link'>Go to home screen!</ThemedText>
|
||||
<ThemedText type="link">Go to home screen!</ThemedText>
|
||||
</Link>
|
||||
</ThemedView>
|
||||
</>
|
||||
|
||||
566
app/_layout.tsx
@@ -1,69 +1,52 @@
|
||||
import "@/augmentations";
|
||||
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||
import { Platform } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import i18n from "@/i18n";
|
||||
import { DownloadProvider } from "@/providers/DownloadProvider";
|
||||
import {
|
||||
apiAtom,
|
||||
getOrSetDeviceId,
|
||||
getTokenFromStorage,
|
||||
JellyfinProvider,
|
||||
} from "@/providers/JellyfinProvider";
|
||||
import { JobQueueProvider } from "@/providers/JobQueueProvider";
|
||||
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
||||
import {
|
||||
SplashScreenProvider,
|
||||
useSplashScreenLoading,
|
||||
} from "@/providers/SplashScreenProvider";
|
||||
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
||||
import { type Settings, useSettings } from "@/utils/atoms/settings";
|
||||
import {
|
||||
BACKGROUND_FETCH_TASK,
|
||||
BACKGROUND_FETCH_TASK_SESSIONS,
|
||||
registerBackgroundFetchAsyncSessions,
|
||||
} from "@/utils/background-tasks";
|
||||
import {
|
||||
LogProvider,
|
||||
writeDebugLog,
|
||||
writeErrorLog,
|
||||
writeToLog,
|
||||
} from "@/utils/log";
|
||||
import { Settings, useSettings } from "@/utils/atoms/settings";
|
||||
import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks";
|
||||
import { LogProvider, writeToLog } from "@/utils/log";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
|
||||
import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server";
|
||||
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
const BackGroundDownloader = !Platform.isTV
|
||||
? require("@kesha-antonov/react-native-background-downloader")
|
||||
: null;
|
||||
|
||||
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";
|
||||
const BackgroundFetch = !Platform.isTV
|
||||
? require("expo-background-fetch")
|
||||
: null;
|
||||
import * as FileSystem from "expo-file-system";
|
||||
|
||||
import { useFonts } from "expo-font";
|
||||
import { useKeepAwake } from "expo-keep-awake";
|
||||
const Notifications = !Platform.isTV ? require("expo-notifications") : null;
|
||||
|
||||
import { router, Stack } from "expo-router";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
const TaskManager = !Platform.isTV ? require("expo-task-manager") : null;
|
||||
import { getLocales } from "expo-localization";
|
||||
import { router, Stack, useSegments } from "expo-router";
|
||||
import * as SplashScreen from "expo-splash-screen";
|
||||
|
||||
import * as TaskManager from "expo-task-manager";
|
||||
import { Provider as JotaiProvider } from "jotai";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { I18nextProvider, useTranslation } 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";
|
||||
import type {
|
||||
Notification,
|
||||
NotificationResponse,
|
||||
} from "expo-notifications/build/Notifications.types";
|
||||
import type { ExpoPushToken } from "expo-notifications/build/Tokens.types";
|
||||
import { useAtom } from "jotai";
|
||||
import { Toaster } from "sonner-native";
|
||||
import { userAtom } from "@/providers/JellyfinProvider";
|
||||
import { store } from "@/utils/store";
|
||||
|
||||
if (!Platform.isTV) {
|
||||
Notifications.setNotificationHandler({
|
||||
@@ -75,41 +58,30 @@ if (!Platform.isTV) {
|
||||
});
|
||||
}
|
||||
|
||||
// Keep the splash screen visible while we fetch resources
|
||||
SplashScreen.preventAutoHideAsync();
|
||||
|
||||
// Set the animation options. This is optional.
|
||||
SplashScreen.setOptions({
|
||||
duration: 500,
|
||||
fade: true,
|
||||
});
|
||||
|
||||
function useNotificationObserver() {
|
||||
useEffect(() => {
|
||||
if (Platform.isTV) return;
|
||||
if (Platform.isTV) return;
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
function redirect(notification: typeof Notifications.Notification) {
|
||||
function redirect(notification: Notifications.Notification) {
|
||||
const url = notification.request.content.data?.url;
|
||||
if (url) {
|
||||
router.push(url);
|
||||
}
|
||||
}
|
||||
|
||||
Notifications.getLastNotificationResponseAsync().then(
|
||||
(response: { notification: any }) => {
|
||||
if (!isMounted || !response?.notification) {
|
||||
return;
|
||||
}
|
||||
redirect(response?.notification);
|
||||
},
|
||||
);
|
||||
Notifications.getLastNotificationResponseAsync().then((response) => {
|
||||
if (!isMounted || !response?.notification) {
|
||||
return;
|
||||
}
|
||||
redirect(response?.notification);
|
||||
});
|
||||
|
||||
const subscription = Notifications.addNotificationResponseReceivedListener(
|
||||
(response: { notification: any }) => {
|
||||
(response) => {
|
||||
redirect(response.notification);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
@@ -120,84 +92,129 @@ function useNotificationObserver() {
|
||||
}
|
||||
|
||||
if (!Platform.isTV) {
|
||||
TaskManager.defineTask(BACKGROUND_FETCH_TASK_SESSIONS, async () => {
|
||||
console.log("TaskManager ~ sessions trigger");
|
||||
|
||||
const api = store.get(apiAtom);
|
||||
if (api === null || api === undefined) return;
|
||||
|
||||
const response = await getSessionApi(api).getSessions({
|
||||
activeWithinSeconds: 360,
|
||||
});
|
||||
|
||||
const result = response.data.filter((s) => s.NowPlayingItem);
|
||||
Notifications.setBadgeCountAsync(result.length);
|
||||
|
||||
return BackgroundTask.BackgroundTaskResult.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 BackgroundFetch.BackgroundFetchResult.NoData;
|
||||
|
||||
const settings: Partial<Settings> = JSON.parse(settingsData);
|
||||
const url = settings?.optimizedVersionsServerUrl;
|
||||
|
||||
if (!settings?.autoDownload)
|
||||
return BackgroundTask.BackgroundTaskResult.Failed;
|
||||
if (!settings?.autoDownload || !url)
|
||||
return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||
|
||||
const token = getTokenFromStorage();
|
||||
const deviceId = getOrSetDeviceId();
|
||||
const baseDirectory = FileSystem.documentDirectory;
|
||||
|
||||
if (!token || !deviceId || !baseDirectory)
|
||||
return BackgroundTask.BackgroundTaskResult.Failed;
|
||||
return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||
|
||||
const jobs = await getAllJobsByDeviceId({
|
||||
deviceId,
|
||||
authHeader: token,
|
||||
url,
|
||||
});
|
||||
|
||||
console.log("TaskManager ~ Active jobs: ", jobs.length);
|
||||
|
||||
for (let job of jobs) {
|
||||
if (job.status === "completed") {
|
||||
const downloadUrl = url + "download/" + job.id;
|
||||
const tasks = await BackGroundDownloader.checkForExistingDownloads();
|
||||
|
||||
if (tasks.find((task) => 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) => {
|
||||
console.log("TaskManager ~ Download error: ", job.id, error);
|
||||
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 BackgroundFetch.BackgroundFetchResult.NewData;
|
||||
});
|
||||
}
|
||||
|
||||
const checkAndRequestPermissions = async () => {
|
||||
try {
|
||||
const hasAskedBefore = storage.getString(
|
||||
"hasAskedForNotificationPermission",
|
||||
"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",
|
||||
"Error checking/requesting notification permissions:",
|
||||
error,
|
||||
error
|
||||
);
|
||||
console.error("Error checking/requesting notification permissions:", error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -205,15 +222,17 @@ export default function RootLayout() {
|
||||
Appearance.setColorScheme("dark");
|
||||
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<JotaiProvider>
|
||||
<ActionSheetProvider>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<Layout />
|
||||
</I18nextProvider>
|
||||
</ActionSheetProvider>
|
||||
</JotaiProvider>
|
||||
</GestureHandlerRootView>
|
||||
<SplashScreenProvider>
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<JotaiProvider>
|
||||
<ActionSheetProvider>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<Layout />
|
||||
</I18nextProvider>
|
||||
</ActionSheetProvider>
|
||||
</JotaiProvider>
|
||||
</GestureHandlerRootView>
|
||||
</SplashScreenProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -230,243 +249,146 @@ const queryClient = new QueryClient({
|
||||
});
|
||||
|
||||
function Layout() {
|
||||
const { settings } = useSettings();
|
||||
const [user] = useAtom(userAtom);
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [settings] = useSettings();
|
||||
const appState = useRef(AppState.currentState);
|
||||
const segments = useSegments();
|
||||
|
||||
useEffect(() => {
|
||||
i18n.changeLanguage(
|
||||
settings?.preferedLanguage ?? getLocales()[0].languageCode ?? "en",
|
||||
settings?.preferedLanguage ?? getLocales()[0].languageCode ?? "en"
|
||||
);
|
||||
}, [settings?.preferedLanguage, i18n]);
|
||||
|
||||
useNotificationObserver();
|
||||
if (!Platform.isTV) {
|
||||
useKeepAwake();
|
||||
useNotificationObserver();
|
||||
|
||||
const [expoPushToken, setExpoPushToken] = useState<ExpoPushToken>();
|
||||
const notificationListener = useRef<EventSubscription>(null);
|
||||
const responseListener = useRef<EventSubscription>(null);
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
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"),
|
||||
useEffect(() => {
|
||||
checkAndRequestPermissions();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (settings?.autoRotate === true)
|
||||
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.DEFAULT);
|
||||
else
|
||||
ScreenOrientation.lockAsync(
|
||||
ScreenOrientation.OrientationLock.PORTRAIT_UP
|
||||
);
|
||||
} else console.log("No token available");
|
||||
}, [api, expoPushToken, user]);
|
||||
}, [settings]);
|
||||
|
||||
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",
|
||||
});
|
||||
}
|
||||
|
||||
const granted = await checkAndRequestPermissions();
|
||||
if (!granted) {
|
||||
console.log(
|
||||
"Notification permissions not granted, skipping background fetch and push token registration.",
|
||||
useEffect(() => {
|
||||
const subscription = AppState.addEventListener(
|
||||
"change",
|
||||
(nextAppState) => {
|
||||
if (
|
||||
appState.current.match(/inactive|background/) &&
|
||||
nextAppState === "active"
|
||||
) {
|
||||
BackGroundDownloader.checkForExistingDownloads();
|
||||
}
|
||||
}
|
||||
);
|
||||
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();
|
||||
|
||||
notificationListener.current =
|
||||
Notifications?.addNotificationReceivedListener(
|
||||
(notification: Notification) => {
|
||||
console.log(
|
||||
"Notification received while app running",
|
||||
notification,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
responseListener.current =
|
||||
Notifications?.addNotificationResponseReceivedListener(
|
||||
(response: NotificationResponse) => {
|
||||
// Currently the notifications supported by the plugin will send data for deep links.
|
||||
const { title, data } = response.notification.request.content;
|
||||
writeDebugLog(
|
||||
`Notification ${title} opened`,
|
||||
response.notification.request.content,
|
||||
);
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
const type = (data?.type ?? "").toString().toLowerCase();
|
||||
const itemId = data?.id;
|
||||
|
||||
switch (type) {
|
||||
case "movie":
|
||||
router.push(`/(auth)/(tabs)/home/items/page?id=${itemId}`);
|
||||
break;
|
||||
case "episode":
|
||||
// We just clicked a notification for an individual episode.
|
||||
if (itemId) {
|
||||
router.push(`/(auth)/(tabs)/home/items/page?id=${itemId}`);
|
||||
// summarized season notification for multiple episodes. Bring them to series season
|
||||
} else {
|
||||
const seriesId = data.seriesId;
|
||||
const seasonIndex = data.seasonIndex;
|
||||
if (seasonIndex) {
|
||||
router.push(
|
||||
`/(auth)/(tabs)/home/series/${seriesId}?seasonIndex=${seasonIndex}`,
|
||||
);
|
||||
} else {
|
||||
router.push(`/(auth)/(tabs)/home/series/${seriesId}`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
BackGroundDownloader.checkForExistingDownloads();
|
||||
|
||||
return () => {
|
||||
notificationListener.current?.remove();
|
||||
responseListener.current?.remove();
|
||||
subscription.remove();
|
||||
};
|
||||
}
|
||||
}, [user, api]);
|
||||
}, []);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.isTV) {
|
||||
return;
|
||||
}
|
||||
const [loaded] = useFonts({
|
||||
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
|
||||
});
|
||||
|
||||
if (segments.includes("direct-player" as never)) {
|
||||
if (
|
||||
!settings.followDeviceOrientation &&
|
||||
settings.defaultVideoOrientation
|
||||
) {
|
||||
ScreenOrientation.lockAsync(settings.defaultVideoOrientation);
|
||||
}
|
||||
return;
|
||||
}
|
||||
useSplashScreenLoading(!loaded);
|
||||
|
||||
if (settings.followDeviceOrientation === true) {
|
||||
ScreenOrientation.unlockAsync();
|
||||
} else {
|
||||
ScreenOrientation.lockAsync(
|
||||
ScreenOrientation.OrientationLock.PORTRAIT_UP,
|
||||
);
|
||||
}
|
||||
}, [
|
||||
settings.followDeviceOrientation,
|
||||
settings.defaultVideoOrientation,
|
||||
segments,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.isTV) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subscription = AppState.addEventListener("change", (nextAppState) => {
|
||||
if (
|
||||
appState.current.match(/inactive|background/) &&
|
||||
nextAppState === "active"
|
||||
) {
|
||||
BackGroundDownloader.checkForExistingDownloads();
|
||||
}
|
||||
});
|
||||
|
||||
BackGroundDownloader.checkForExistingDownloads();
|
||||
|
||||
return () => {
|
||||
subscription.remove();
|
||||
};
|
||||
}, []);
|
||||
if (!loaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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>
|
||||
<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");
|
||||
let 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);
|
||||
}
|
||||
}
|
||||
|
||||
522
app/login.tsx
@@ -1,40 +1,35 @@
|
||||
import { Button } from "@/components/Button";
|
||||
import { Input } from "@/components/common/Input";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
|
||||
import { PreviousServersList } from "@/components/PreviousServersList";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
||||
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { t } from "i18next";
|
||||
import { useAtomValue } from "jotai";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Keyboard,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
SafeAreaView,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Input } from "@/components/common/Input";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
|
||||
import { PasswordInput } from "@/components/PasswordInput";
|
||||
import { PreviousServersList } from "@/components/PreviousServersList";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
||||
|
||||
import { t } from 'i18next';
|
||||
const CredentialsSchema = z.object({
|
||||
username: z.string().min(1, t("login.username_required")),
|
||||
});
|
||||
username: z.string().min(1, t("login.username_required")),});
|
||||
|
||||
const Login: React.FC = () => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const navigation = useNavigation();
|
||||
const params = useLocalSearchParams();
|
||||
const Login: React.FC = () => {
|
||||
const { setServer, login, removeServer, initiateQuickConnect } =
|
||||
useJellyfin();
|
||||
const [api] = useAtom(apiAtom);
|
||||
const params = useLocalSearchParams();
|
||||
|
||||
const {
|
||||
apiUrl: _apiUrl,
|
||||
@@ -42,8 +37,6 @@ const Login: React.FC = () => {
|
||||
password: _password,
|
||||
} = params as { apiUrl: string; username: string; password: string };
|
||||
|
||||
const [loadingServerCheck, setLoadingServerCheck] = useState<boolean>(false);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [serverURL, setServerURL] = useState<string>(_apiUrl);
|
||||
const [serverName, setServerName] = useState<string>("");
|
||||
const [credentials, setCredentials] = useState<{
|
||||
@@ -53,15 +46,13 @@ const Login: React.FC = () => {
|
||||
username: _username,
|
||||
password: _password,
|
||||
});
|
||||
const [showPassword, setShowPassword] = useState<boolean>(false);
|
||||
|
||||
/**
|
||||
* A way to auto login based on a link
|
||||
*/
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
// we might re-use the checkUrl function here to check the url as well
|
||||
// however, I don't think it should be necessary for now
|
||||
if (_apiUrl) {
|
||||
await setServer({
|
||||
setServer({
|
||||
address: _apiUrl,
|
||||
});
|
||||
|
||||
@@ -75,6 +66,7 @@ const Login: React.FC = () => {
|
||||
})();
|
||||
}, [_apiUrl, _username, _password]);
|
||||
|
||||
const navigation = useNavigation();
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerTitle: serverName,
|
||||
@@ -84,20 +76,18 @@ const Login: React.FC = () => {
|
||||
onPress={() => {
|
||||
removeServer();
|
||||
}}
|
||||
className='flex flex-row items-center'
|
||||
className="flex flex-row items-center"
|
||||
>
|
||||
<Ionicons name='chevron-back' size={18} color={Colors.primary} />
|
||||
<Text className='ml-2 text-purple-600'>
|
||||
{t("login.change_server")}
|
||||
</Text>
|
||||
<Ionicons name="chevron-back" size={18} color={Colors.primary} />
|
||||
<Text className="ml-2 text-purple-600">{t("login.change_server")}</Text>
|
||||
</TouchableOpacity>
|
||||
) : null,
|
||||
});
|
||||
}, [serverName, navigation, api?.basePath]);
|
||||
|
||||
const handleLogin = async () => {
|
||||
Keyboard.dismiss();
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const handleLogin = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = CredentialsSchema.safeParse(credentials);
|
||||
@@ -108,16 +98,15 @@ const Login: React.FC = () => {
|
||||
if (error instanceof Error) {
|
||||
Alert.alert(t("login.connection_failed"), error.message);
|
||||
} else {
|
||||
Alert.alert(
|
||||
t("login.connection_failed"),
|
||||
t("login.an_unexpected_error_occured"),
|
||||
);
|
||||
Alert.alert(t("login.connection_failed"), t("login.an_unexpected_error_occured"));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const [loadingServerCheck, setLoadingServerCheck] = useState<boolean>(false);
|
||||
|
||||
/**
|
||||
* Checks the availability and validity of a Jellyfin server URL.
|
||||
*
|
||||
@@ -135,52 +124,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.
|
||||
*
|
||||
@@ -199,301 +163,165 @@ 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;
|
||||
}
|
||||
|
||||
setServer({ address: url });
|
||||
}, []);
|
||||
|
||||
const handleQuickConnect = async () => {
|
||||
try {
|
||||
const code = await initiateQuickConnect();
|
||||
if (code) {
|
||||
Alert.alert(
|
||||
t("login.quick_connect"),
|
||||
t("login.enter_code_to_login", { code: code }),
|
||||
[
|
||||
{
|
||||
text: t("login.got_it"),
|
||||
},
|
||||
],
|
||||
);
|
||||
Alert.alert(t("login.quick_connect"), t("login.enter_code_to_login", {code: code}), [
|
||||
{
|
||||
text: t("login.got_it"),
|
||||
},
|
||||
]);
|
||||
}
|
||||
} catch (_error) {
|
||||
Alert.alert(
|
||||
t("login.error_title"),
|
||||
t("login.failed_to_initiate_quick_connect"),
|
||||
);
|
||||
} catch (error) {
|
||||
Alert.alert(t("login.error_title"), t("login.failed_to_initiate_quick_connect"));
|
||||
}
|
||||
};
|
||||
|
||||
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-2'
|
||||
/>
|
||||
|
||||
{/* Password */}
|
||||
<View className='relative mb-2'>
|
||||
<PasswordInput
|
||||
value={credentials.password}
|
||||
onChangeText={(text: string) =>
|
||||
setCredentials({ ...credentials, password: text })
|
||||
}
|
||||
placeholder={t("login.password_placeholder")}
|
||||
showPassword={showPassword}
|
||||
onShowPasswordChange={setShowPassword}
|
||||
topOffset={16}
|
||||
layout='tv'
|
||||
/>
|
||||
</View>
|
||||
<View className='mt-3'>
|
||||
<Button onPress={handleLogin}>{t("login.login_button")}</Button>
|
||||
</View>
|
||||
<View className='mt-2'>
|
||||
<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-4'>
|
||||
<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"}
|
||||
>
|
||||
{api?.basePath ? (
|
||||
<View className='flex flex-col h-full relative items-center justify-center'>
|
||||
<View className='px-4 -mt-20 w-full'>
|
||||
<View className='flex flex-col space-y-2'>
|
||||
<Text className='text-2xl font-bold -mb-2'>
|
||||
{serverName ? (
|
||||
<>
|
||||
{`${t("login.login_to_title")} `}
|
||||
<Text className='text-purple-600'>{serverName}</Text>
|
||||
</>
|
||||
) : (
|
||||
t("login.login_title")
|
||||
)}
|
||||
<>
|
||||
<View className="flex flex-col h-full relative items-center justify-center">
|
||||
<View className="px-4 -mt-20 w-full">
|
||||
<View className="flex flex-col space-y-2">
|
||||
<Text className="text-2xl font-bold -mb-2">
|
||||
<>
|
||||
{serverName ? (
|
||||
<>
|
||||
{t("login.login_to_title") + " "}
|
||||
<Text className="text-purple-600">{serverName}</Text>
|
||||
</>
|
||||
) : t("login.login_title")}
|
||||
</>
|
||||
</Text>
|
||||
<Text className='text-xs text-neutral-400'>{api.basePath}</Text>
|
||||
<Input
|
||||
placeholder={t("login.username_placeholder")}
|
||||
onChangeText={(text) =>
|
||||
setCredentials({ ...credentials, username: text })
|
||||
}
|
||||
value={credentials.username}
|
||||
keyboardType='default'
|
||||
returnKeyType='done'
|
||||
autoCapitalize='none'
|
||||
// Changed from username to oneTimeCode because it is a known issue in RN
|
||||
// https://github.com/facebook/react-native/issues/47106#issuecomment-2521270037
|
||||
textContentType='oneTimeCode'
|
||||
clearButtonMode='while-editing'
|
||||
maxLength={500}
|
||||
extraClassName=''
|
||||
/>
|
||||
<Text className="text-xs text-neutral-400">
|
||||
{api.basePath}
|
||||
</Text>
|
||||
<Input
|
||||
placeholder={t("login.username_placeholder")}
|
||||
onChangeText={(text) =>
|
||||
setCredentials({ ...credentials, username: text })
|
||||
}
|
||||
value={credentials.username}
|
||||
autoFocus
|
||||
secureTextEntry={false}
|
||||
keyboardType="default"
|
||||
returnKeyType="done"
|
||||
autoCapitalize="none"
|
||||
textContentType="username"
|
||||
clearButtonMode="while-editing"
|
||||
maxLength={500}
|
||||
/>
|
||||
|
||||
<View className='relative'>
|
||||
<PasswordInput
|
||||
value={credentials.password}
|
||||
<Input
|
||||
placeholder={t("login.password_placeholder")}
|
||||
onChangeText={(text) =>
|
||||
setCredentials({ ...credentials, password: text })
|
||||
}
|
||||
placeholder={t("login.password_placeholder")}
|
||||
showPassword={showPassword}
|
||||
onShowPasswordChange={setShowPassword}
|
||||
topOffset={12}
|
||||
layout='mobile'
|
||||
value={credentials.password}
|
||||
secureTextEntry
|
||||
keyboardType="default"
|
||||
returnKeyType="done"
|
||||
autoCapitalize="none"
|
||||
textContentType="password"
|
||||
clearButtonMode="while-editing"
|
||||
maxLength={500}
|
||||
/>
|
||||
</View>
|
||||
<View className='flex flex-row items-center justify-between'>
|
||||
<Button
|
||||
onPress={handleLogin}
|
||||
loading={loading}
|
||||
className='flex-1 mr-2'
|
||||
>
|
||||
{t("login.login_button")}
|
||||
</Button>
|
||||
<TouchableOpacity
|
||||
onPress={handleQuickConnect}
|
||||
className='p-3 bg-neutral-900 rounded-xl h-13 w-13 flex items-center justify-center'
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name='cellphone-lock'
|
||||
size={24}
|
||||
color='white'
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<View className="flex flex-row items-center justify-between">
|
||||
<Button
|
||||
onPress={handleLogin}
|
||||
loading={loading}
|
||||
className="flex-1 mr-2"
|
||||
>
|
||||
{t("login.login_button")}
|
||||
</Button>
|
||||
<TouchableOpacity
|
||||
onPress={handleQuickConnect}
|
||||
className="p-2 bg-neutral-900 rounded-xl h-12 w-12 flex items-center justify-center"
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="cellphone-lock"
|
||||
size={24}
|
||||
color="white"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='absolute bottom-0 left-0 w-full px-4 mb-2' />
|
||||
</View>
|
||||
) : (
|
||||
<View className='flex flex-col h-full items-center justify-center w-full'>
|
||||
<View className='flex flex-col gap-y-2 px-4 w-full -mt-36'>
|
||||
<Image
|
||||
style={{
|
||||
width: 100,
|
||||
height: 100,
|
||||
marginLeft: -23,
|
||||
marginBottom: -20,
|
||||
}}
|
||||
source={require("@/assets/images/icon-ios-plain.png")}
|
||||
/>
|
||||
<Text className='text-3xl font-bold'>Streamyfin</Text>
|
||||
<Text className='text-neutral-500'>
|
||||
{t("server.enter_url_to_jellyfin_server")}
|
||||
</Text>
|
||||
<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}
|
||||
/>
|
||||
<Button
|
||||
loading={loadingServerCheck}
|
||||
disabled={loadingServerCheck}
|
||||
onPress={async () => {
|
||||
await handleConnect(serverURL);
|
||||
}}
|
||||
className='w-full grow'
|
||||
>
|
||||
{t("server.connect_button")}
|
||||
</Button>
|
||||
<JellyfinServerDiscovery
|
||||
onServerSelect={async (server) => {
|
||||
setServerURL(server.address);
|
||||
if (server.serverName) {
|
||||
setServerName(server.serverName);
|
||||
}
|
||||
await handleConnect(server.address);
|
||||
}}
|
||||
/>
|
||||
<PreviousServersList
|
||||
onServerSelect={async (s) => {
|
||||
await handleConnect(s.address);
|
||||
}}
|
||||
/>
|
||||
<View className="absolute bottom-0 left-0 w-full px-4 mb-2"></View>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<View className="flex flex-col h-full items-center justify-center w-full">
|
||||
<View className="flex flex-col gap-y-2 px-4 w-full -mt-36">
|
||||
<Image
|
||||
style={{
|
||||
width: 100,
|
||||
height: 100,
|
||||
marginLeft: -23,
|
||||
marginBottom: -20,
|
||||
}}
|
||||
source={require("@/assets/images/StreamyFinFinal.png")}
|
||||
/>
|
||||
<Text className="text-3xl font-bold">Streamyfin</Text>
|
||||
<Text className="text-neutral-500">
|
||||
{t("server.enter_url_to_jellyfin_server")}
|
||||
</Text>
|
||||
<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}
|
||||
/>
|
||||
<Button
|
||||
loading={loadingServerCheck}
|
||||
disabled={loadingServerCheck}
|
||||
onPress={async () => await handleConnect(serverURL)}
|
||||
className="w-full grow"
|
||||
>
|
||||
{t("server.connect_button")}
|
||||
</Button>
|
||||
<JellyfinServerDiscovery
|
||||
onServerSelect={(server) => {
|
||||
setServerURL(server.address);
|
||||
if (server.serverName) {
|
||||
setServerName(server.serverName);
|
||||
}
|
||||
handleConnect(server.address);
|
||||
}}
|
||||
/>
|
||||
<PreviousServersList
|
||||
onServerSelect={(s) => {
|
||||
handleConnect(s.address);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
|
||||
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: 91 KiB |
|
Before Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 305 KiB |
|
Before Width: | Height: | Size: 326 KiB |
|
Before Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 49 KiB |
@@ -1,21 +1,17 @@
|
||||
import { Api, AUTHORIZATION_HEADER } from "@jellyfin/sdk";
|
||||
import type { AxiosRequestConfig, AxiosResponse } from "axios";
|
||||
import type { StreamyfinPluginConfig } from "@/utils/atoms/settings";
|
||||
import { AxiosRequestConfig, AxiosResponse } from "axios";
|
||||
import { StreamyfinPluginConfig } from "@/utils/atoms/settings";
|
||||
|
||||
declare module "@jellyfin/sdk" {
|
||||
interface Api {
|
||||
get<T, D = any>(
|
||||
url: string,
|
||||
config?: AxiosRequestConfig<D>,
|
||||
config?: AxiosRequestConfig<D>
|
||||
): Promise<AxiosResponse<T>>;
|
||||
post<T, D = any>(
|
||||
url: string,
|
||||
data: D,
|
||||
config?: AxiosRequestConfig<D>,
|
||||
): Promise<AxiosResponse<T>>;
|
||||
delete<T, D = any>(
|
||||
url: string,
|
||||
config?: AxiosRequestConfig<D>,
|
||||
config?: AxiosRequestConfig<D>
|
||||
): Promise<AxiosResponse<T>>;
|
||||
getStreamyfinPluginConfig(): Promise<AxiosResponse<StreamyfinPluginConfig>>;
|
||||
}
|
||||
@@ -23,7 +19,7 @@ declare module "@jellyfin/sdk" {
|
||||
|
||||
Api.prototype.get = function <T, D = any>(
|
||||
url: string,
|
||||
config: AxiosRequestConfig<D> = {},
|
||||
config: AxiosRequestConfig<D> = {}
|
||||
): Promise<AxiosResponse<T>> {
|
||||
return this.axiosInstance.get<T>(`${this.basePath}${url}`, {
|
||||
...(config ?? {}),
|
||||
@@ -34,20 +30,11 @@ Api.prototype.get = function <T, D = any>(
|
||||
Api.prototype.post = function <T, D = any>(
|
||||
url: string,
|
||||
data: D,
|
||||
config: AxiosRequestConfig<D>,
|
||||
config: AxiosRequestConfig<D>
|
||||
): Promise<AxiosResponse<T>> {
|
||||
return this.axiosInstance.post<T>(`${this.basePath}${url}`, data, {
|
||||
...(config || {}),
|
||||
headers: { [AUTHORIZATION_HEADER]: this.authorizationHeader },
|
||||
});
|
||||
};
|
||||
|
||||
Api.prototype.delete = function <T, D = any>(
|
||||
url: string,
|
||||
config: AxiosRequestConfig<D>,
|
||||
): Promise<AxiosResponse<T>> {
|
||||
return this.axiosInstance.delete<T>(`${this.basePath}${url}`, {
|
||||
return this.axiosInstance.post<T>(`${this.basePath}${url}`, {
|
||||
...(config || {}),
|
||||
data,
|
||||
headers: { [AUTHORIZATION_HEADER]: this.authorizationHeader },
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,33 +1,22 @@
|
||||
import { MMKV } from "react-native-mmkv";
|
||||
import {MMKV} from "react-native-mmkv";
|
||||
|
||||
declare module "react-native-mmkv" {
|
||||
interface MMKV {
|
||||
get<T>(key: string): T | undefined;
|
||||
setAny(key: string, value: any | undefined): void;
|
||||
get<T>(key: string): T | undefined
|
||||
setAny(key: string, value: any | undefined): void
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
};
|
||||
MMKV.prototype.get = function <T> (key: string): T | 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));
|
||||
}
|
||||
}
|
||||
@@ -7,17 +7,17 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
Number.prototype.bytesToReadable = function (decimals = 2) {
|
||||
Number.prototype.bytesToReadable = function (decimals: number = 2) {
|
||||
const bytes = this.valueOf();
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return `${Number.parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`;
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
Number.prototype.secondsToMilliseconds = function () {
|
||||
|
||||
@@ -5,10 +5,12 @@ declare global {
|
||||
}
|
||||
|
||||
String.prototype.toTitle = function () {
|
||||
return this.replaceAll("_", " ").replace(
|
||||
/\w\S*/g,
|
||||
(text) => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase(),
|
||||
);
|
||||
};
|
||||
return this
|
||||
.replaceAll("_", " ")
|
||||
.replace(
|
||||
/\w\S*/g,
|
||||
text => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
export {};
|
||||
export {};
|
||||
@@ -1,4 +1,4 @@
|
||||
module.exports = (api) => {
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ["babel-preset-expo"],
|
||||
|
||||
61
biome.json
@@ -1,61 +0,0 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.2.2/schema.json",
|
||||
"files": {
|
||||
"includes": [
|
||||
"**/*",
|
||||
"!node_modules",
|
||||
"!ios",
|
||||
"!android",
|
||||
"!Streamyfin.app",
|
||||
"!utils/jellyseerr",
|
||||
"!.expo"
|
||||
]
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"style": {
|
||||
"useImportType": "off",
|
||||
"noNonNullAssertion": "off",
|
||||
"noParameterAssign": "off",
|
||||
"useLiteralEnumMembers": "off"
|
||||
},
|
||||
"complexity": {
|
||||
"noForEach": "off"
|
||||
},
|
||||
"recommended": true,
|
||||
"correctness": {
|
||||
"useExhaustiveDependencies": "off"
|
||||
},
|
||||
"suspicious": {
|
||||
"noExplicitAny": "off",
|
||||
"noArrayIndexKey": "off"
|
||||
}
|
||||
}
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"formatWithErrors": true,
|
||||
"attributePosition": "auto",
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineEnding": "lf",
|
||||
"lineWidth": 80
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"arrowParentheses": "always",
|
||||
"bracketSameLine": false,
|
||||
"bracketSpacing": true,
|
||||
"jsxQuoteStyle": "single",
|
||||
"quoteProperties": "asNeeded",
|
||||
"semicolons": "always",
|
||||
"lineWidth": 80
|
||||
}
|
||||
},
|
||||
"json": {
|
||||
"formatter": {
|
||||
"trailingCommas": "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,113 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import type { FC } from "react";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { RoundButton } from "@/components/RoundButton";
|
||||
import { useFavorite } from "@/hooks/useFavorite";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { TouchableOpacityProps, View, ViewProps } from "react-native";
|
||||
import { RoundButton } from "./RoundButton";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
item: BaseItemDto;
|
||||
type: "item" | "series";
|
||||
}
|
||||
|
||||
export const AddToFavorites: FC<Props> = ({ item, ...props }) => {
|
||||
const { isFavorite, toggleFavorite } = useFavorite(item);
|
||||
export const AddToFavorites: React.FC<Props> = ({ item, type, ...props }) => {
|
||||
const queryClient = useQueryClient();
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const isFavorite = useMemo(() => {
|
||||
return item.UserData?.IsFavorite;
|
||||
}, [item.UserData?.IsFavorite]);
|
||||
|
||||
const updateItemInQueries = (newData: Partial<BaseItemDto>) => {
|
||||
queryClient.setQueryData<BaseItemDto | undefined>(
|
||||
[type, item.Id],
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
...newData,
|
||||
UserData: { ...old.UserData, ...newData.UserData },
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const markFavoriteMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (api && user) {
|
||||
await getUserLibraryApi(api).markFavoriteItem({
|
||||
userId: user.Id,
|
||||
itemId: item.Id!,
|
||||
});
|
||||
}
|
||||
},
|
||||
onMutate: async () => {
|
||||
await queryClient.cancelQueries({ queryKey: [type, item.Id] });
|
||||
const previousItem = queryClient.getQueryData<BaseItemDto>([
|
||||
type,
|
||||
item.Id,
|
||||
]);
|
||||
updateItemInQueries({ UserData: { IsFavorite: true } });
|
||||
|
||||
return { previousItem };
|
||||
},
|
||||
onError: (err, variables, context) => {
|
||||
if (context?.previousItem) {
|
||||
queryClient.setQueryData([type, item.Id], context.previousItem);
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [type, item.Id] });
|
||||
queryClient.invalidateQueries({ queryKey: ["home", "favorites"] });
|
||||
},
|
||||
});
|
||||
|
||||
const unmarkFavoriteMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (api && user) {
|
||||
await getUserLibraryApi(api).unmarkFavoriteItem({
|
||||
userId: user.Id,
|
||||
itemId: item.Id!,
|
||||
});
|
||||
}
|
||||
},
|
||||
onMutate: async () => {
|
||||
await queryClient.cancelQueries({ queryKey: [type, item.Id] });
|
||||
const previousItem = queryClient.getQueryData<BaseItemDto>([
|
||||
type,
|
||||
item.Id,
|
||||
]);
|
||||
updateItemInQueries({ UserData: { IsFavorite: false } });
|
||||
|
||||
return { previousItem };
|
||||
},
|
||||
onError: (err, variables, context) => {
|
||||
if (context?.previousItem) {
|
||||
queryClient.setQueryData([type, item.Id], context.previousItem);
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [type, item.Id] });
|
||||
queryClient.invalidateQueries({ queryKey: ["home", "favorites"] });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<RoundButton
|
||||
size='large'
|
||||
size="large"
|
||||
icon={isFavorite ? "heart" : "heart-outline"}
|
||||
fillColor={isFavorite ? "primary" : undefined}
|
||||
onPress={toggleFavorite}
|
||||
onPress={() => {
|
||||
if (isFavorite) {
|
||||
unmarkFavoriteMutation.mutate();
|
||||
} else {
|
||||
markFavoriteMutation.mutate();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useMemo } from "react";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
|
||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text } from "./common/Text";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof View> {
|
||||
source?: MediaSourceInfo;
|
||||
@@ -19,37 +17,34 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
||||
selected,
|
||||
...props
|
||||
}) => {
|
||||
const isTv = Platform.isTV;
|
||||
|
||||
if (Platform.isTV) return null;
|
||||
const audioStreams = useMemo(
|
||||
() => source?.MediaStreams?.filter((x) => x.Type === "Audio"),
|
||||
[source],
|
||||
[source]
|
||||
);
|
||||
|
||||
const selectedAudioSteam = useMemo(
|
||||
() => audioStreams?.find((x) => x.Index === selected),
|
||||
[audioStreams, selected],
|
||||
[audioStreams, selected]
|
||||
);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (isTv) return null;
|
||||
|
||||
return (
|
||||
<View
|
||||
className='flex shrink'
|
||||
className="flex shrink"
|
||||
style={{
|
||||
minWidth: 50,
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className='flex flex-col' {...props}>
|
||||
<Text className='opacity-50 mb-1 text-xs'>
|
||||
<View className="flex flex-col" {...props}>
|
||||
<Text className="opacity-50 mb-1 text-xs">
|
||||
{t("item_card.audio")}
|
||||
</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 className='' numberOfLines={1}>
|
||||
<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 className="" numberOfLines={1}>
|
||||
{selectedAudioSteam?.DisplayTitle}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
@@ -57,8 +52,8 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side='bottom'
|
||||
align='start'
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { View, ViewProps } from "react-native";
|
||||
import { Text } from "./common/Text";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
@@ -22,7 +22,7 @@ export const Badge: React.FC<Props> = ({
|
||||
${variant === "gray" && "bg-neutral-800"}
|
||||
`}
|
||||
>
|
||||
{iconLeft && <View className='mr-1'>{iconLeft}</View>}
|
||||
{iconLeft && <View className="mr-1">{iconLeft}</View>}
|
||||
<Text
|
||||
className={`
|
||||
text-xs
|
||||
|
||||
@@ -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,10 +1,8 @@
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
|
||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||
|
||||
import { Text } from "./common/Text";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text } from "./common/Text";
|
||||
|
||||
export type Bitrate = {
|
||||
key: string;
|
||||
@@ -42,11 +40,7 @@ export const BITRATES: Bitrate[] = [
|
||||
key: "250 Kb/s",
|
||||
value: 250000,
|
||||
},
|
||||
].sort(
|
||||
(a, b) =>
|
||||
(b.value || Number.POSITIVE_INFINITY) -
|
||||
(a.value || Number.POSITIVE_INFINITY),
|
||||
);
|
||||
].sort((a, b) => (b.value || Infinity) - (a.value || Infinity));
|
||||
|
||||
interface Props extends React.ComponentProps<typeof View> {
|
||||
onChange: (value: Bitrate) => void;
|
||||
@@ -60,29 +54,22 @@ export const BitrateSelector: React.FC<Props> = ({
|
||||
inverted,
|
||||
...props
|
||||
}) => {
|
||||
const isTv = Platform.isTV;
|
||||
|
||||
if (Platform.isTV) return null;
|
||||
const sorted = useMemo(() => {
|
||||
if (inverted)
|
||||
return BITRATES.slice().sort(
|
||||
(a, b) =>
|
||||
(a.value || Number.POSITIVE_INFINITY) -
|
||||
(b.value || Number.POSITIVE_INFINITY),
|
||||
return BITRATES.sort(
|
||||
(a, b) => (a.value || Infinity) - (b.value || Infinity)
|
||||
);
|
||||
return BITRATES.slice().sort(
|
||||
(a, b) =>
|
||||
(b.value || Number.POSITIVE_INFINITY) -
|
||||
(a.value || Number.POSITIVE_INFINITY),
|
||||
return BITRATES.sort(
|
||||
(a, b) => (b.value || Infinity) - (a.value || Infinity)
|
||||
);
|
||||
}, [inverted]);
|
||||
}, []);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (isTv) return null;
|
||||
|
||||
return (
|
||||
<View
|
||||
className='flex shrink'
|
||||
className="flex shrink"
|
||||
style={{
|
||||
minWidth: 60,
|
||||
maxWidth: 200,
|
||||
@@ -90,12 +77,12 @@ export const BitrateSelector: React.FC<Props> = ({
|
||||
>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className='flex flex-col' {...props}>
|
||||
<Text className='opacity-50 mb-1 text-xs'>
|
||||
<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'>
|
||||
<Text style={{}} className='' numberOfLines={1}>
|
||||
<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}>
|
||||
{BITRATES.find((b) => b.value === selected?.value)?.key}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
@@ -103,8 +90,8 @@ export const BitrateSelector: React.FC<Props> = ({
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={false}
|
||||
side='bottom'
|
||||
align='center'
|
||||
side="bottom"
|
||||
align="center"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={0}
|
||||
|
||||
@@ -1,21 +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 { useHaptic } from "@/hooks/useHaptic";
|
||||
import React, { PropsWithChildren, ReactNode, useMemo } from "react";
|
||||
import { Platform, Text, TouchableOpacity, View } from "react-native";
|
||||
import { Loader } from "./Loader";
|
||||
|
||||
export interface ButtonProps
|
||||
@@ -45,23 +30,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 +41,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
|
||||
@@ -123,7 +63,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
||||
{...props}
|
||||
>
|
||||
{loading ? (
|
||||
<View className='p-0.5'>
|
||||
<View className="p-0.5">
|
||||
<Loader />
|
||||
</View>
|
||||
) : (
|
||||
@@ -132,7 +72,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
||||
flex flex-row items-center justify-between w-full
|
||||
${justify === "between" ? "justify-between" : "justify-center"}`}
|
||||
>
|
||||
{iconLeft ? iconLeft : <View className='w-4' />}
|
||||
{iconLeft ? iconLeft : <View className="w-4"></View>}
|
||||
<Text
|
||||
className={`
|
||||
text-white font-bold text-base
|
||||
@@ -144,7 +84,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
{iconRight ? iconRight : <View className='w-4' />}
|
||||
{iconRight ? iconRight : <View className="w-4"></View>}
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import React, { useCallback, useEffect } from "react";
|
||||
import { Platform, TouchableOpacity, ViewProps } from "react-native";
|
||||
import GoogleCast, {
|
||||
CastButton,
|
||||
CastContext,
|
||||
@@ -11,7 +11,13 @@ import GoogleCast, {
|
||||
} from "react-native-google-cast";
|
||||
import { RoundButton } from "./RoundButton";
|
||||
|
||||
export function Chromecast({
|
||||
interface Props extends ViewProps {
|
||||
width?: number;
|
||||
height?: number;
|
||||
background?: "blur" | "transparent";
|
||||
}
|
||||
|
||||
export default function Chromecast({
|
||||
width = 48,
|
||||
height = 48,
|
||||
background = "transparent",
|
||||
@@ -38,15 +44,19 @@ 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],
|
||||
Platform.OS === "android" ? (
|
||||
<CastButton tintColor="transparent" />
|
||||
) : (
|
||||
<></>
|
||||
),
|
||||
[Platform.OS]
|
||||
);
|
||||
|
||||
if (background === "transparent")
|
||||
return (
|
||||
<RoundButton
|
||||
size='large'
|
||||
className='mr-2'
|
||||
size="large"
|
||||
className="mr-2"
|
||||
background={false}
|
||||
onPress={() => {
|
||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||
@@ -55,13 +65,13 @@ export function Chromecast({
|
||||
{...props}
|
||||
>
|
||||
<AndroidCastButton />
|
||||
<Feather name='cast' size={22} color={"white"} />
|
||||
<Feather name="cast" size={22} color={"white"} />
|
||||
</RoundButton>
|
||||
);
|
||||
|
||||
return (
|
||||
<RoundButton
|
||||
size='large'
|
||||
size="large"
|
||||
onPress={() => {
|
||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||
else CastContext.showCastDialog();
|
||||
@@ -69,7 +79,7 @@ export function Chromecast({
|
||||
{...props}
|
||||
>
|
||||
<AndroidCastButton />
|
||||
<Feather name='cast' size={22} color={"white"} />
|
||||
<Feather name="cast" size={22} color={"white"} />
|
||||
</RoundButton>
|
||||
);
|
||||
}
|
||||
|
||||