mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-16 16:18:09 +00:00
Compare commits
74 Commits
build-perf
...
fix/github
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c7a9a1218 | ||
|
|
94362169b6 | ||
|
|
8aefdac50f | ||
|
|
665a79924a | ||
|
|
b9ddcf8404 | ||
|
|
64ffc8db8b | ||
|
|
2a61124a0d | ||
|
|
36178c2082 | ||
|
|
e1c69a9ec9 | ||
|
|
01110b8d13 | ||
|
|
21034f5671 | ||
|
|
1439bcee0d | ||
|
|
9a906e6d39 | ||
|
|
48de7b7c6d | ||
|
|
85e5c25206 | ||
|
|
3dc84818e8 | ||
|
|
18102a3045 | ||
|
|
2be78a232c | ||
|
|
30dc3980e3 | ||
|
|
f7da29b9c9 | ||
|
|
7a5f0b52b6 | ||
|
|
62dfe7c9e1 | ||
|
|
50d559d528 | ||
|
|
38aba3d67a | ||
|
|
5765793d79 | ||
|
|
222ba13529 | ||
|
|
de6c2072c9 | ||
|
|
76ec8e0e46 | ||
|
|
389d9e2d31 | ||
|
|
485dc6eeac | ||
|
|
154788cf91 | ||
|
|
3e181eca72 | ||
|
|
f5b9e03dd9 | ||
|
|
196f91400b | ||
|
|
51a14c6058 | ||
|
|
5432476ca1 | ||
|
|
9d9ec974ff | ||
|
|
0dadfd3d90 | ||
|
|
96d6220f5e | ||
|
|
b847baa314 | ||
|
|
a4cce27737 | ||
|
|
ce37351099 | ||
|
|
e4e1e556bf | ||
|
|
1ff723b29f | ||
|
|
11d35c846d | ||
|
|
ce82f3044b | ||
|
|
df638dae28 | ||
|
|
9a5e49ae16 | ||
|
|
4f2120f85d | ||
|
|
8eeea35441 | ||
|
|
781464b768 | ||
|
|
61d60c2e74 | ||
|
|
6c722e1fd9 | ||
|
|
e117e98c8e | ||
|
|
f5c791cce4 | ||
|
|
eaebb79f4c | ||
|
|
7e82d9c7b6 | ||
|
|
c62c8ca077 | ||
|
|
25cfcf0735 | ||
|
|
f19b33eff6 | ||
|
|
1c4f54584f | ||
|
|
87d02078c8 | ||
|
|
1e9fba6f85 | ||
|
|
dbcf5759da | ||
|
|
37364b235f | ||
|
|
fcb8e83845 | ||
|
|
16046a4a15 | ||
|
|
13b29bdee0 | ||
|
|
bf4a1f9462 | ||
|
|
264439858d | ||
|
|
6576a58884 | ||
|
|
3eae2f3fde | ||
|
|
deae6ab21a | ||
|
|
5478179367 |
@@ -1,15 +1 @@
|
|||||||
# Streamyfin-specific debug flag
|
EXPO_PUBLIC_WRITE_DEBUG=1
|
||||||
EXPO_PUBLIC_WRITE_DEBUG=1
|
|
||||||
|
|
||||||
# Performance optimization (official Metro flag)
|
|
||||||
EXPO_USE_METRO_REQUIRE=1
|
|
||||||
|
|
||||||
# TV development support (used in metro.config.js)
|
|
||||||
# EXPO_TV=1
|
|
||||||
# Uncomment the above line ONLY when working on TV features. Leave commented for mobile-only development to avoid issues.
|
|
||||||
|
|
||||||
# Fast resolver optimization (2025 feature)
|
|
||||||
EXPO_USE_FAST_RESOLVER=1
|
|
||||||
|
|
||||||
# Bundle analysis for monitoring
|
|
||||||
EXPO_ATLAS=1
|
|
||||||
@@ -1,26 +1 @@
|
|||||||
# Streamyfin Production Configuration
|
EXPO_PUBLIC_WRITE_DEBUG=0
|
||||||
EXPO_PUBLIC_WRITE_DEBUG=0
|
|
||||||
|
|
||||||
# Production Performance Optimizations
|
|
||||||
NODE_ENV=production
|
|
||||||
EXPO_USE_METRO_REQUIRE=1
|
|
||||||
EXPO_USE_FAST_RESOLVER=1
|
|
||||||
|
|
||||||
# Production Build Optimizations
|
|
||||||
EXPO_OPTIMIZE_BUNDLE_SIZE=1
|
|
||||||
EXPO_NO_CLIENT_ENV_VARS=1
|
|
||||||
EXPO_LEGACY_BUNDLER=0
|
|
||||||
|
|
||||||
# Bundle Analysis (for monitoring)
|
|
||||||
EXPO_ATLAS=0
|
|
||||||
|
|
||||||
# Production Cache Optimizations
|
|
||||||
METRO_CACHE=1
|
|
||||||
|
|
||||||
# Security & Performance
|
|
||||||
EXPO_NO_DOTENV=1
|
|
||||||
FAST_REFRESH=0
|
|
||||||
|
|
||||||
# Production Bundle Features
|
|
||||||
EXPO_USE_HERMES=1
|
|
||||||
EXPO_MINIFY=1
|
|
||||||
33
.github/CONTRIBUTING.md
vendored
33
.github/CONTRIBUTING.md
vendored
@@ -1,11 +1,13 @@
|
|||||||
# Contributing to Streamyfin
|
# Contributing to Streamyfin
|
||||||
|
|
||||||
Thank you for your interest in contributing to the Streamyfin mobile app project! This document provides guidelines to smoothly collaborate on the Streamyfin codebase and help improve the app for all users.
|
Thank you for your interest in contributing to the Streamyfin project. This document outlines the guidelines for effective collaboration across the Streamyfin codebase and aims to ensure a smooth, productive experience for all contributors.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
|
|
||||||
|
- [AI Assistance Disclosure](#ai-assistance-disclosure)
|
||||||
- [Reporting Issues](#reporting-issues)
|
- [Reporting Issues](#reporting-issues)
|
||||||
- [Reporting Security Vulnerabilities](#reporting-security-vulnerabilities)
|
- [Reporting Security Vulnerabilities](#reporting-security-vulnerabilities)
|
||||||
- [Requesting Features & Enhancements](#requesting-features--enhancements)
|
- [Requesting Features & Enhancements](#requesting-features--enhancements)
|
||||||
@@ -19,6 +21,25 @@ Thank you for your interest in contributing to the Streamyfin mobile app project
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## AI Assistance Disclosure
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> If any AI tool was used while contributing to Streamyfin, it must be disclosed in the pull request.
|
||||||
|
|
||||||
|
State in your PR whether AI assistance was used and to what extent (for example, *docs only* or *code generation*).
|
||||||
|
If AI-generated text was used in PR discussions or responses, disclose that as well.
|
||||||
|
Minor autocomplete or keyword suggestions do not require disclosure.
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
> This PR was written primarily by Claude Code.
|
||||||
|
> I used Cursor to explore parts of the codebase, but the implementation is fully manual.
|
||||||
|
|
||||||
|
Failing to disclose AI usage wastes maintainers’ time and complicates review efforts.
|
||||||
|
AI-assisted contributions are welcome, but contributors remain fully responsible for the code they submit.
|
||||||
|
|
||||||
|
Always disclose AI involvement to maintain transparency and respect for maintainers’ time.
|
||||||
|
|
||||||
## Reporting Issues
|
## Reporting Issues
|
||||||
|
|
||||||
Streamyfin uses GitHub issues to track bugs and improvements. Before opening a new issue:
|
Streamyfin uses GitHub issues to track bugs and improvements. Before opening a new issue:
|
||||||
@@ -46,11 +67,11 @@ When creating a new feature request:
|
|||||||
|
|
||||||
- Check if the idea or similar request exists.
|
- Check if the idea or similar request exists.
|
||||||
- Use reactions like 👍 to support existing requests.
|
- Use reactions like 👍 to support existing requests.
|
||||||
- Provide a clear explanation of the use case and benefits.
|
- Clearly describe the use case and potential benefits.
|
||||||
|
- Include screenshots when relevant.
|
||||||
---
|
---
|
||||||
|
|
||||||
## Developing the Mobile App
|
## Developing Streamyfin
|
||||||
|
|
||||||
### Codebase Overview
|
### Codebase Overview
|
||||||
|
|
||||||
@@ -136,6 +157,8 @@ When opening a PR:
|
|||||||
- Provide a detailed description in the PR body, explaining what, why, and any impacts.
|
- Provide a detailed description in the PR body, explaining what, why, and any impacts.
|
||||||
- Include screenshots or recordings if UI changes are involved.
|
- Include screenshots or recordings if UI changes are involved.
|
||||||
- Ensure CI checks are green (lint, type-check, build).
|
- Ensure CI checks are green (lint, type-check, build).
|
||||||
|
- Confirm that the branch is **up to date with `main`** before submission.
|
||||||
|
- Mention if AI-generated code or content was used (see [AI Assistance Disclosure](#ai-assistance-disclosure)).
|
||||||
- Do not include secrets, tokens, or production credentials. Redact sensitive data in logs and screenshots.
|
- Do not include secrets, tokens, or production credentials. Redact sensitive data in logs and screenshots.
|
||||||
- Keep PRs focused; avoid bundling unrelated changes together.
|
- Keep PRs focused; avoid bundling unrelated changes together.
|
||||||
|
|
||||||
@@ -159,4 +182,4 @@ PRs require review and approval by maintainers before merging.---
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Thank you for helping make Streamyfin a better app for everyone!
|
Thank you for contributing to make Streamyfin better for everyone!
|
||||||
|
|||||||
2
.github/copilot-instructions.md
vendored
2
.github/copilot-instructions.md
vendored
@@ -79,7 +79,7 @@ and provides seamless media streaming with offline capabilities and Chromecast s
|
|||||||
|
|
||||||
## Commit Messages
|
## Commit Messages
|
||||||
|
|
||||||
Use [Conventional Commits](https://www.conventionalcommits.org/):
|
Use Conventional Commits (https://www.conventionalcommits.org/):
|
||||||
Exemples:
|
Exemples:
|
||||||
- `feat(player): add Chromecast support`
|
- `feat(player): add Chromecast support`
|
||||||
- `fix(auth): handle expired JWT tokens`
|
- `fix(auth): handle expired JWT tokens`
|
||||||
|
|||||||
30
.github/workflows/build-apps.yml
vendored
30
.github/workflows/build-apps.yml
vendored
@@ -76,7 +76,7 @@ jobs:
|
|||||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: 📤 Upload APK artifact
|
- name: 📤 Upload APK artifact
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||||
with:
|
with:
|
||||||
name: streamyfin-android-phone-apk-${{ env.DATE_TAG }}
|
name: streamyfin-android-phone-apk-${{ env.DATE_TAG }}
|
||||||
path: |
|
path: |
|
||||||
@@ -147,7 +147,7 @@ jobs:
|
|||||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: 📤 Upload APK artifact
|
- name: 📤 Upload APK artifact
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||||
with:
|
with:
|
||||||
name: streamyfin-android-tv-apk-${{ env.DATE_TAG }}
|
name: streamyfin-android-tv-apk-${{ env.DATE_TAG }}
|
||||||
path: |
|
path: |
|
||||||
@@ -156,7 +156,7 @@ jobs:
|
|||||||
|
|
||||||
build-ios-phone:
|
build-ios-phone:
|
||||||
if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
|
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
|
runs-on: macos-26
|
||||||
name: 🍎 Build iOS IPA (Phone)
|
name: 🍎 Build iOS IPA (Phone)
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -170,6 +170,11 @@ jobs:
|
|||||||
submodules: recursive
|
submodules: recursive
|
||||||
show-progress: false
|
show-progress: false
|
||||||
|
|
||||||
|
- name: 🟢 Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "24"
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||||
with:
|
with:
|
||||||
@@ -191,6 +196,11 @@ jobs:
|
|||||||
- name: 🛠️ Generate project files
|
- name: 🛠️ Generate project files
|
||||||
run: bun run prebuild
|
run: bun run prebuild
|
||||||
|
|
||||||
|
- name: 🔧 Setup Xcode
|
||||||
|
uses: maxim-lobanov/setup-xcode@v1
|
||||||
|
with:
|
||||||
|
xcode-version: "26.0.1"
|
||||||
|
|
||||||
- name: 🏗️ Setup EAS
|
- name: 🏗️ Setup EAS
|
||||||
uses: expo/expo-github-action@main
|
uses: expo/expo-github-action@main
|
||||||
with:
|
with:
|
||||||
@@ -210,7 +220,7 @@ jobs:
|
|||||||
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: 📤 Upload IPA artifact
|
- name: 📤 Upload IPA artifact
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||||
with:
|
with:
|
||||||
name: streamyfin-ios-phone-ipa-${{ env.DATE_TAG }}
|
name: streamyfin-ios-phone-ipa-${{ env.DATE_TAG }}
|
||||||
path: build-*.ipa
|
path: build-*.ipa
|
||||||
@@ -219,7 +229,7 @@ jobs:
|
|||||||
# Disabled for now - uncomment when ready to build iOS TV
|
# Disabled for now - uncomment when ready to build iOS TV
|
||||||
# build-ios-tv:
|
# build-ios-tv:
|
||||||
# if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
|
# 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
|
# runs-on: macos-26
|
||||||
# name: 🍎 Build iOS IPA (TV)
|
# name: 🍎 Build iOS IPA (TV)
|
||||||
# permissions:
|
# permissions:
|
||||||
# contents: read
|
# contents: read
|
||||||
@@ -233,6 +243,11 @@ jobs:
|
|||||||
# submodules: recursive
|
# submodules: recursive
|
||||||
# show-progress: false
|
# show-progress: false
|
||||||
#
|
#
|
||||||
|
# - name: 🟢 Setup Node.js
|
||||||
|
# uses: actions/setup-node@v4
|
||||||
|
# with:
|
||||||
|
# node-version: '20'
|
||||||
|
#
|
||||||
# - name: 🍞 Setup Bun
|
# - name: 🍞 Setup Bun
|
||||||
# uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
# uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||||
# with:
|
# with:
|
||||||
@@ -254,6 +269,11 @@ jobs:
|
|||||||
# - name: 🛠️ Generate project files
|
# - name: 🛠️ Generate project files
|
||||||
# run: bun run prebuild:tv
|
# run: bun run prebuild:tv
|
||||||
#
|
#
|
||||||
|
# - name: 🔧 Setup Xcode
|
||||||
|
# uses: maxim-lobanov/setup-xcode@v1
|
||||||
|
# with:
|
||||||
|
# xcode-version: '26.0.1'
|
||||||
|
#
|
||||||
# - name: 🏗️ Setup EAS
|
# - name: 🏗️ Setup EAS
|
||||||
# uses: expo/expo-github-action@main
|
# uses: expo/expo-github-action@main
|
||||||
# with:
|
# with:
|
||||||
|
|||||||
6
.github/workflows/ci-codeql.yml
vendored
6
.github/workflows/ci-codeql.yml
vendored
@@ -31,13 +31,13 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: 🏁 Initialize CodeQL
|
- name: 🏁 Initialize CodeQL
|
||||||
uses: github/codeql-action/init@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7
|
uses: github/codeql-action/init@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
queries: +security-extended,security-and-quality
|
queries: +security-extended,security-and-quality
|
||||||
|
|
||||||
- name: 🛠️ Autobuild
|
- name: 🛠️ Autobuild
|
||||||
uses: github/codeql-action/autobuild@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7
|
uses: github/codeql-action/autobuild@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
|
||||||
|
|
||||||
- name: 🧪 Perform CodeQL Analysis
|
- name: 🧪 Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7
|
uses: github/codeql-action/analyze@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
|
||||||
|
|||||||
2
.github/workflows/crowdin.yml
vendored
2
.github/workflows/crowdin.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: 🌐 Sync Translations with Crowdin
|
- name: 🌐 Sync Translations with Crowdin
|
||||||
uses: crowdin/github-action@0749939f635900a2521aa6aac7a3766642b2dc71 # v2.11.0
|
uses: crowdin/github-action@08713f00a50548bfe39b37e8f44afb53e7a802d4 # v2.12.0
|
||||||
with:
|
with:
|
||||||
upload_sources: true
|
upload_sources: true
|
||||||
upload_translations: true
|
upload_translations: true
|
||||||
|
|||||||
4
.github/workflows/linting.yml
vendored
4
.github/workflows/linting.yml
vendored
@@ -57,7 +57,7 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Dependency Review
|
- name: Dependency Review
|
||||||
uses: actions/dependency-review-action@56339e523c0409420f6c2c9a2f4292bbb3c07dd3 # v4.8.0
|
uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2
|
||||||
with:
|
with:
|
||||||
fail-on-severity: high
|
fail-on-severity: high
|
||||||
base-ref: ${{ github.event.pull_request.base.sha || 'develop' }}
|
base-ref: ${{ github.event.pull_request.base.sha || 'develop' }}
|
||||||
@@ -107,7 +107,7 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: "🟢 Setup Node.js"
|
- name: "🟢 Setup Node.js"
|
||||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||||
with:
|
with:
|
||||||
node-version: '24.x'
|
node-version: '24.x'
|
||||||
|
|
||||||
|
|||||||
2
.github/workflows/update-issue-form.yml
vendored
2
.github/workflows/update-issue-form.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
|||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
|
|
||||||
- name: "🟢 Setup Node.js"
|
- name: "🟢 Setup Node.js"
|
||||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||||
with:
|
with:
|
||||||
node-version: '24.x'
|
node-version: '24.x'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -64,4 +64,5 @@ credentials.json
|
|||||||
streamyfin-4fec1-firebase-adminsdk.json
|
streamyfin-4fec1-firebase-adminsdk.json
|
||||||
|
|
||||||
# Version and Backup Files
|
# Version and Backup Files
|
||||||
/version-backup-*
|
/version-backup-*
|
||||||
|
modules/background-downloader/android/build/*
|
||||||
|
|||||||
177
.vscode/settings.json
vendored
177
.vscode/settings.json
vendored
@@ -1,178 +1,25 @@
|
|||||||
{
|
{
|
||||||
// ==========================================
|
|
||||||
// FORMATTING & LINTING
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
// Biome as default formatter
|
|
||||||
"editor.defaultFormatter": "biomejs.biome",
|
"editor.defaultFormatter": "biomejs.biome",
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
"editor.formatOnPaste": true,
|
"editor.codeActionsOnSave": {
|
||||||
"editor.formatOnType": false,
|
"source.fixAll.biome": "explicit"
|
||||||
|
|
||||||
// Language-specific formatters
|
|
||||||
"[javascript]": {
|
|
||||||
"editor.defaultFormatter": "biomejs.biome",
|
|
||||||
"editor.formatOnSave": true
|
|
||||||
},
|
},
|
||||||
"[typescript]": {
|
"[typescript]": {
|
||||||
"editor.defaultFormatter": "biomejs.biome",
|
"editor.defaultFormatter": "biomejs.biome"
|
||||||
"editor.formatOnSave": true
|
|
||||||
},
|
},
|
||||||
"[typescriptreact]": {
|
"[typescriptreact]": {
|
||||||
"editor.defaultFormatter": "biomejs.biome",
|
"editor.defaultFormatter": "biomejs.biome"
|
||||||
"editor.formatOnSave": true
|
},
|
||||||
|
"[javascript]": {
|
||||||
|
"editor.defaultFormatter": "biomejs.biome"
|
||||||
},
|
},
|
||||||
"[javascriptreact]": {
|
"[javascriptreact]": {
|
||||||
"editor.defaultFormatter": "biomejs.biome",
|
"editor.defaultFormatter": "biomejs.biome"
|
||||||
"editor.formatOnSave": true
|
|
||||||
},
|
},
|
||||||
"[json]": {
|
"[json]": {
|
||||||
"editor.defaultFormatter": "biomejs.biome",
|
"editor.defaultFormatter": "biomejs.biome"
|
||||||
"editor.formatOnSave": true
|
|
||||||
},
|
},
|
||||||
"[jsonc]": {
|
"typescript.tsdk": "node_modules/typescript/lib",
|
||||||
"editor.defaultFormatter": "biomejs.biome",
|
"typescript.enablePromptUseWorkspaceTsdk": true,
|
||||||
"editor.formatOnSave": true
|
"editor.formatOnSaveMode": "file"
|
||||||
},
|
|
||||||
"[swift]": {
|
|
||||||
"editor.insertSpaces": true,
|
|
||||||
"editor.tabSize": 2
|
|
||||||
},
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// TYPESCRIPT & JAVASCRIPT
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
// TypeScript performance optimizations
|
|
||||||
"typescript.preferences.includePackageJsonAutoImports": "auto",
|
|
||||||
"typescript.suggest.autoImports": true,
|
|
||||||
"typescript.updateImportsOnFileMove.enabled": "always",
|
|
||||||
"typescript.preferences.preferTypeOnlyAutoImports": true,
|
|
||||||
"typescript.preferences.importModuleSpecifier": "relative",
|
|
||||||
"typescript.preferences.includeCompletionsForImportStatements": true,
|
|
||||||
"typescript.preferences.includeCompletionsWithSnippetText": true,
|
|
||||||
|
|
||||||
// JavaScript settings
|
|
||||||
"javascript.preferences.importModuleSpecifier": "relative",
|
|
||||||
"javascript.suggest.autoImports": true,
|
|
||||||
"javascript.updateImportsOnFileMove.enabled": "always",
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// REACT NATIVE & EXPO
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
// File associations for React Native
|
|
||||||
"files.associations": {
|
|
||||||
"*.expo.ts": "typescript",
|
|
||||||
"*.expo.tsx": "typescriptreact",
|
|
||||||
"*.expo.js": "javascript",
|
|
||||||
"*.expo.jsx": "javascriptreact",
|
|
||||||
"metro.config.js": "javascript",
|
|
||||||
"babel.config.js": "javascript",
|
|
||||||
"app.config.js": "javascript",
|
|
||||||
"eas.json": "jsonc"
|
|
||||||
},
|
|
||||||
|
|
||||||
// React Native specific settings
|
|
||||||
"emmet.includeLanguages": {
|
|
||||||
"typescriptreact": "html",
|
|
||||||
"javascriptreact": "html"
|
|
||||||
},
|
|
||||||
"emmet.triggerExpansionOnTab": true,
|
|
||||||
|
|
||||||
// Exclude build directories from search
|
|
||||||
"search.exclude": {
|
|
||||||
"**/node_modules": true
|
|
||||||
},
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// EDITOR PERFORMANCE & UX
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
// Performance optimizations
|
|
||||||
"editor.largeFileOptimizations": true,
|
|
||||||
"files.watcherExclude": {
|
|
||||||
"**/.git/objects/**": true,
|
|
||||||
"**/.git/subtree-cache/**": true,
|
|
||||||
"**/node_modules/**": true,
|
|
||||||
"**/.expo/**": true,
|
|
||||||
"**/ios/**": true,
|
|
||||||
"**/android/**": true,
|
|
||||||
"**/build/**": true,
|
|
||||||
"**/dist/**": true
|
|
||||||
},
|
|
||||||
|
|
||||||
// Better editor behavior
|
|
||||||
"editor.suggestSelection": "first",
|
|
||||||
"editor.quickSuggestions": {
|
|
||||||
"strings": true,
|
|
||||||
"comments": true,
|
|
||||||
"other": true
|
|
||||||
},
|
|
||||||
"editor.snippetSuggestions": "top",
|
|
||||||
"editor.tabCompletion": "on",
|
|
||||||
"editor.wordBasedSuggestions": "off",
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// TERMINAL & DEVELOPMENT
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
// Terminal settings for Bun (Windows-specific)
|
|
||||||
"terminal.integrated.profiles.windows": {
|
|
||||||
"Command Prompt": {
|
|
||||||
"path": "C:\\Windows\\System32\\cmd.exe",
|
|
||||||
"env": {
|
|
||||||
"PATH": "${env:PATH};./node_modules/.bin"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// WORKSPACE & NAVIGATION
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
// Better workspace navigation
|
|
||||||
"explorer.fileNesting.enabled": true,
|
|
||||||
"explorer.fileNesting.expand": false,
|
|
||||||
"explorer.fileNesting.patterns": {
|
|
||||||
"*.ts": "${capture}.js",
|
|
||||||
"*.tsx": "${capture}.js",
|
|
||||||
"*.js": "${capture}.js,${capture}.js.map,${capture}.min.js,${capture}.d.ts",
|
|
||||||
"*.jsx": "${capture}.js",
|
|
||||||
"package.json": "package-lock.json,yarn.lock,bun.lock,bun.lockb,.yarnrc,.yarnrc.yml",
|
|
||||||
"tsconfig.json": "tsconfig.*.json",
|
|
||||||
".env": ".env.*",
|
|
||||||
"app.json": "app.config.js,eas.json,expo-env.d.ts",
|
|
||||||
"README.md": "LICENSE.txt,SECURITY.md,CODE_OF_CONDUCT.md,CONTRIBUTING.md"
|
|
||||||
},
|
|
||||||
|
|
||||||
// Better breadcrumbs and navigation
|
|
||||||
"breadcrumbs.enabled": true,
|
|
||||||
"outline.showVariables": true,
|
|
||||||
"outline.showConstants": true,
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// GIT & VERSION CONTROL
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
// Git integration
|
|
||||||
"git.autofetch": true,
|
|
||||||
"git.enableSmartCommit": true,
|
|
||||||
"git.confirmSync": false,
|
|
||||||
"git.ignoreLimitWarning": true,
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// CODE QUALITY & ERRORS
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
// Better error detection
|
|
||||||
"typescript.validate.enable": true,
|
|
||||||
"javascript.validate.enable": true,
|
|
||||||
"editor.codeActionsOnSave": {
|
|
||||||
"source.fixAll": "explicit",
|
|
||||||
"source.organizeImports": "explicit"
|
|
||||||
},
|
|
||||||
|
|
||||||
// Problem matcher for better error display
|
|
||||||
"typescript.tsc.autoDetect": "on"
|
|
||||||
}
|
}
|
||||||
|
|||||||
232
GLOBAL_MODAL_GUIDE.md
Normal file
232
GLOBAL_MODAL_GUIDE.md
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
# Global Modal System with Gorhom Bottom Sheet
|
||||||
|
|
||||||
|
This guide explains how to use the global modal system implemented in this project.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The global modal system allows you to trigger a bottom sheet modal from anywhere in your app programmatically, and render any component inside it.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The system consists of three main parts:
|
||||||
|
|
||||||
|
1. **GlobalModalProvider** (`providers/GlobalModalProvider.tsx`) - Context provider that manages modal state
|
||||||
|
2. **GlobalModal** (`components/GlobalModal.tsx`) - The actual modal component rendered at root level
|
||||||
|
3. **useGlobalModal** hook - Hook to interact with the modal from anywhere
|
||||||
|
|
||||||
|
## Setup (Already Configured)
|
||||||
|
|
||||||
|
The system is already integrated into your app:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// In app/_layout.tsx
|
||||||
|
<BottomSheetModalProvider>
|
||||||
|
<GlobalModalProvider>
|
||||||
|
{/* Your app content */}
|
||||||
|
<GlobalModal />
|
||||||
|
</GlobalModalProvider>
|
||||||
|
</BottomSheetModalProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Basic Usage
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||||
|
import { View, Text } from "react-native";
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
const { showModal, hideModal } = useGlobalModal();
|
||||||
|
|
||||||
|
const handleOpenModal = () => {
|
||||||
|
showModal(
|
||||||
|
<View className='p-6'>
|
||||||
|
<Text className='text-white text-2xl'>Hello from Modal!</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button onPress={handleOpenModal} title="Open Modal" />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Custom Options
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const handleOpenModal = () => {
|
||||||
|
showModal(
|
||||||
|
<YourCustomComponent />,
|
||||||
|
{
|
||||||
|
snapPoints: ["25%", "50%", "90%"], // Custom snap points
|
||||||
|
enablePanDownToClose: true, // Allow swipe to close
|
||||||
|
backgroundStyle: { // Custom background
|
||||||
|
backgroundColor: "#000000",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Programmatic Control
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Open modal
|
||||||
|
showModal(<Content />);
|
||||||
|
|
||||||
|
// Close modal from within the modal content
|
||||||
|
function ModalContent() {
|
||||||
|
const { hideModal } = useGlobalModal();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<Button onPress={hideModal} title="Close" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal from outside
|
||||||
|
hideModal();
|
||||||
|
```
|
||||||
|
|
||||||
|
### In Event Handlers or Functions
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function useApiCall() {
|
||||||
|
const { showModal } = useGlobalModal();
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
const result = await api.fetch();
|
||||||
|
|
||||||
|
// Show success modal
|
||||||
|
showModal(
|
||||||
|
<SuccessMessage data={result} />
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// Show error modal
|
||||||
|
showModal(
|
||||||
|
<ErrorMessage error={error} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return fetchData;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### `useGlobalModal()`
|
||||||
|
|
||||||
|
Returns an object with the following properties:
|
||||||
|
|
||||||
|
- **`showModal(content, options?)`** - Show the modal with given content
|
||||||
|
- `content: ReactNode` - Any React component or element to render
|
||||||
|
- `options?: ModalOptions` - Optional configuration object
|
||||||
|
|
||||||
|
- **`hideModal()`** - Programmatically hide the modal
|
||||||
|
|
||||||
|
- **`isVisible: boolean`** - Current visibility state of the modal
|
||||||
|
|
||||||
|
### `ModalOptions`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ModalOptions {
|
||||||
|
enableDynamicSizing?: boolean; // Auto-size based on content (default: true)
|
||||||
|
snapPoints?: (string | number)[]; // Fixed snap points (e.g., ["50%", "90%"])
|
||||||
|
enablePanDownToClose?: boolean; // Allow swipe down to close (default: true)
|
||||||
|
backgroundStyle?: object; // Custom background styles
|
||||||
|
handleIndicatorStyle?: object; // Custom handle indicator styles
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
See `components/ExampleGlobalModalUsage.tsx` for comprehensive examples including:
|
||||||
|
- Simple content modal
|
||||||
|
- Modal with custom snap points
|
||||||
|
- Complex component in modal
|
||||||
|
- Success/error modals triggered from functions
|
||||||
|
|
||||||
|
## Default Styling
|
||||||
|
|
||||||
|
The modal uses these default styles (can be overridden via options):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
enableDynamicSizing: true,
|
||||||
|
enablePanDownToClose: true,
|
||||||
|
backgroundStyle: {
|
||||||
|
backgroundColor: "#171717", // Dark background
|
||||||
|
},
|
||||||
|
handleIndicatorStyle: {
|
||||||
|
backgroundColor: "white",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Keep content in separate components** - Don't inline large JSX in `showModal()` calls
|
||||||
|
2. **Use the hook in custom hooks** - Create specialized hooks like `useShowSuccessModal()` for reusable modal patterns
|
||||||
|
3. **Handle cleanup** - The modal automatically clears content when closed
|
||||||
|
4. **Avoid nesting** - Don't show modals from within modals
|
||||||
|
5. **Consider UX** - Only use for important, contextual information that requires user attention
|
||||||
|
|
||||||
|
## Using with PlatformDropdown
|
||||||
|
|
||||||
|
When using `PlatformDropdown` with option groups, avoid setting a `title` on the `OptionGroup` if you're already passing a `title` prop to `PlatformDropdown`. This prevents nested menu behavior on iOS where users have to click through an extra layer.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Good - No title in option group (title is on PlatformDropdown)
|
||||||
|
const optionGroups: OptionGroup[] = [
|
||||||
|
{
|
||||||
|
options: items.map((item) => ({
|
||||||
|
type: "radio",
|
||||||
|
label: item.name,
|
||||||
|
value: item,
|
||||||
|
selected: item.id === selected?.id,
|
||||||
|
onPress: () => onChange(item),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
<PlatformDropdown
|
||||||
|
groups={optionGroups}
|
||||||
|
title="Select Item" // Title here
|
||||||
|
// ...
|
||||||
|
/>
|
||||||
|
|
||||||
|
// Bad - Causes nested menu on iOS
|
||||||
|
const optionGroups: OptionGroup[] = [
|
||||||
|
{
|
||||||
|
title: "Items", // This creates a nested Picker on iOS
|
||||||
|
options: items.map((item) => ({
|
||||||
|
type: "radio",
|
||||||
|
label: item.name,
|
||||||
|
value: item,
|
||||||
|
selected: item.id === selected?.id,
|
||||||
|
onPress: () => onChange(item),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Modal doesn't appear
|
||||||
|
- Ensure `GlobalModalProvider` is above the component calling `useGlobalModal()`
|
||||||
|
- Check that `BottomSheetModalProvider` is present in the tree
|
||||||
|
- Verify `GlobalModal` component is rendered
|
||||||
|
|
||||||
|
### Content is cut off
|
||||||
|
- Use `enableDynamicSizing: true` for auto-sizing
|
||||||
|
- Or specify appropriate `snapPoints`
|
||||||
|
|
||||||
|
### Modal won't close
|
||||||
|
- Ensure `enablePanDownToClose` is `true`
|
||||||
|
- Check that backdrop is clickable
|
||||||
|
- Use `hideModal()` for programmatic closing
|
||||||
161
README.md
161
README.md
@@ -5,146 +5,135 @@
|
|||||||
<img src="https://raw.githubusercontent.com/streamyfin/.github/refs/heads/main/streamyfin-github-banner.png" alt="Streamyfin" width="100%">
|
<img src="https://raw.githubusercontent.com/streamyfin/.github/refs/heads/main/streamyfin-github-banner.png" alt="Streamyfin" width="100%">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
**Streamyfin is a simple, user-friendly Jellyfin video streaming client built with Expo. Designed as an alternative to other Jellyfin clients, it aims to offer a smooth and reliable streaming experience. We hope you'll find it a valuable addition to your media streaming toolbox.**
|
**Streamyfin is a user-friendly Jellyfin video streaming client built with Expo. Designed as an alternative to other Jellyfin clients, it aims to offer a smooth and reliable streaming experience. We hope you'll find it a valuable addition to your media streaming toolbox.**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="./assets/images/screenshots/screenshot1.png" width="22%">
|
<img src="./assets/images/screenshots/screenshot1.png" width="20%">
|
||||||
|
|
||||||
<img src="./assets/images/screenshots/screenshot3.png" width="22%">
|
<img src="./assets/images/screenshots/screenshot3.png" width="20%">
|
||||||
|
|
||||||
<img src="./assets/images/screenshots/screenshot2.png" width="22%">
|
<img src="./assets/images/screenshots/screenshot2.png" width="20%">
|
||||||
|
|
||||||
<img src="./assets/images/jellyseerr.PNG" width="23%">
|
<img src="./assets/images/jellyseerr.PNG" width="21%">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
||||||
## 🌟 Features
|
## 🌟 Features
|
||||||
|
|
||||||
- 🚀 **Skip Intro / Credits Support**
|
- 🚀 **Skip Intro / Credits Support**: Lets you quickly skip intros and credits during playback
|
||||||
- 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking.
|
- 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking
|
||||||
- 📥 **Download media** (Experimental): Save your media locally and watch it offline.
|
- 📥 **Download media**: Save your media locally and watch it offline
|
||||||
- 📡 **Chromecast** (Experimental): Cast your media to any Chromecast-enabled device.
|
- ⚙️ **Settings management**: Manage app configurations for all users through our plugin
|
||||||
- 📡 **Settings management** (Experimental): Manage app settings for all your users with a JF plugin.
|
- 🤖 **Seerr (formerly Jellyseerr) integration**: Request media directly in the app
|
||||||
- 🤖 **Jellyseerr integration**: Request media directly in the app.
|
- 👁️ **Sessions view:** View all active sessions currently streaming on your server
|
||||||
- 👁️ **Sessions View:** View all active sessions currently streaming on your server.
|
- 📡 **Chromecast**: Cast your media to any Chromecast-enabled device
|
||||||
|
|
||||||
## 🧪 Experimental Features
|
## 🧪 Experimental Features
|
||||||
|
|
||||||
Streamyfin includes some exciting experimental features like media downloading and Chromecast support. These features are still in development, and your patience and feedback are much appreciated as we work to improve them.
|
Streamyfin offers exciting experimental features such as media downloading and Chromecast support. These features are under active development, and your feedback and patience help us make them even better.
|
||||||
|
|
||||||
### 📥 Downloading
|
### 📥 Downloading
|
||||||
|
|
||||||
Downloading works by using ffmpeg to convert an HLS stream into a video file on the device. This means that you can download and view any file you can stream! The file is converted by Jellyfin on the server in real time as it is downloaded. This means a **bit longer download times** but supports any file that your server can transcode.
|
Downloading works by using FFmpeg to convert an HLS stream into a video file on your device. This lets you download and watch any content that you can stream. The conversion is handled in real time by Jellyfin on the server during the download. While this may take a bit longer, it ensures compatibility with any file your server can transcode.
|
||||||
|
|
||||||
### 🎥 Chromecast
|
|
||||||
|
|
||||||
Chromecast support is still in development, and we're working on improving it. Currently, it supports casting videos, but we're working on adding support for subtitles and other features.
|
|
||||||
|
|
||||||
### 🧩 Streamyfin Plugin
|
### 🧩 Streamyfin Plugin
|
||||||
|
|
||||||
The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that holds all settings for the client Streamyfin. This allows you to synchronize settings across all your users, like for example:
|
The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that holds all settings for the client Streamyfin. This allows you to synchronize settings across all your users, like for example:
|
||||||
|
|
||||||
- Auto log in to Jellyseerr without the user having to do anything
|
- Automatic Seerr login with no user input required
|
||||||
- Choose the default languages
|
- Set your preferred default languages
|
||||||
- Set download method and search provider
|
- Configure download method and search provider
|
||||||
- Customize home screen
|
- Personalize your home screen
|
||||||
- And much more...
|
- And much more
|
||||||
|
|
||||||
[Streamyfin Plugin](https://github.com/streamyfin/jellyfin-plugin-streamyfin)
|
[Streamyfin Plugin](https://github.com/streamyfin/jellyfin-plugin-streamyfin)
|
||||||
|
|
||||||
|
### 📡 Chromecast
|
||||||
|
|
||||||
|
Chromecast support is currently under development. Video casting is already available, and we're actively working on adding subtitle support and additional features.
|
||||||
|
|
||||||
### 🔍 Jellysearch
|
### 🔍 Jellysearch
|
||||||
|
|
||||||
[Jellysearch](https://gitlab.com/DomiStyle/jellysearch) now works with Streamyfin!
|
[Jellysearch](https://gitlab.com/DomiStyle/jellysearch) works with Streamyfin
|
||||||
|
|
||||||
> A fast full-text search proxy for Jellyfin. Integrates seamlessly with most Jellyfin clients.
|
> A fast full-text search proxy for Jellyfin. Integrates seamlessly with most Jellyfin clients.
|
||||||
|
|
||||||
## 🛣️ Roadmap for V1
|
## 🛣️ Roadmap
|
||||||
|
|
||||||
Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) To see what we're working on next, we are always open to feedback and suggestions. Please let us know if you have any ideas or feature requests.
|
Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) To see what we're working on next, we are always open to feedback and suggestions. Please let us know if you have any ideas or feature requests.
|
||||||
|
|
||||||
## 📥 Get it now
|
## 📥 Download Streamyfin
|
||||||
|
|
||||||
<div style="display: flex; gap: 5px;">
|
<div style="display: flex; gap: 5px;">
|
||||||
<a href="https://apps.apple.com/app/streamyfin/id6593660679?l=en-GB"><img height=50 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/></a>
|
<a href="https://apps.apple.com/app/streamyfin/id6593660679?l=en-GB"><img height=50 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/></a>
|
||||||
<a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin"><img height=50 alt="Get the beta on Google Play" src="./assets/Google_Play_Store_badge_EN.svg"/></a>
|
<a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin"><img height=50 alt="Get Streamyfin on Google Play Store" src="./assets/Google_Play_Store_badge_EN.svg"/></a>
|
||||||
|
<a href="https://github.com/streamyfin/streamyfin/releases/latest"><img height=50 alt="Get Streamyfin on Github" src="./assets/Download_on_Github_.png"/></a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
Or download the APKs [here on GitHub](https://github.com/streamyfin/streamyfin/releases) for Android.
|
### 🧪 Beta Testing
|
||||||
|
|
||||||
### 🧪 Beta testing
|
To access the Streamyfin beta, you need to subscribe to the Member tier (or higher) on [Patreon](https://www.patreon.com/streamyfin). This grants you immediate access to the 🧪-beta-releases channel on Discord and lets me know you’ve subscribed. This is where I share APKs and IPAs. It does not provide automatic TestFlight access, so please send me a DM (Cagemaster) with the email you use for Apple so we can add you manually.
|
||||||
|
|
||||||
To access the Streamyfin beta, you need to subscribe to the Member tier (or higher) on [Patreon](https://www.patreon.com/streamyfin). This will give you immediate access to the 🧪-public-beta channel on Discord and I'll know that you have subscribed. This is where I post APKs and IPAs. This won't give automatic access to the TestFlight, however, so you need to send me a DM with the email you use for Apple so that I can manually add you.
|
**Note**: Anyone actively contributing to Streamyfin’s source code will receive automatic access to beta releases.
|
||||||
|
|
||||||
**Note**: Everyone who is actively contributing to the source code of Streamyfin will have automatic access to the betas.
|
|
||||||
|
|
||||||
## 🚀 Getting Started
|
## 🚀 Getting Started
|
||||||
|
|
||||||
### Prerequisites
|
### ⚙️ Prerequisites
|
||||||
|
|
||||||
- Ensure you have an active Jellyfin server.
|
- Your device is on the same network as the Jellyfin server (for local connections)
|
||||||
- Make sure your device is connected to the same network as your Jellyfin server.
|
- Your Jellyfin server is up and running with remote access enabled if you plan to connect from outside your local network
|
||||||
|
- Your server version is up to date (older versions may cause compatibility issues)
|
||||||
|
- You have a valid Jellyfin user account with access to the media libraries you want to view
|
||||||
|
- If using features such as **downloads** or **Seerr integration**, confirm the required plugins are installed and configured on your Jellyfin server
|
||||||
|
|
||||||
## 🙌 Contributing
|
## 🙌 Contributing
|
||||||
|
|
||||||
We welcome any help to make Streamyfin better. If you'd like to contribute, please fork the repository and submit a pull request. For major changes, it's best to open an issue first to discuss your ideas.
|
We welcome contributions that improve Streamyfin. Start by forking the repository and submitting a pull request. For major changes or new features, please open an issue first to discuss your ideas and ensure alignment with the project.
|
||||||
|
|
||||||
### 👨💻 Development info
|
## 🌍 Translations
|
||||||
|
|
||||||
|
[](https://crowdin.com/project/streamyfin)
|
||||||
|
|
||||||
|
Streamyfin is available in multiple languages, and we’re always looking for contributors to help make the app accessible worldwide.
|
||||||
|
You can contribute translations directly on our [Crowdin project page](https://crowdin.com/project/streamyfin).
|
||||||
|
|
||||||
|
### 👨💻 Development Info
|
||||||
|
|
||||||
1. Use node `>20`
|
1. Use node `>20`
|
||||||
2. Install dependencies `bun i && bun run submodule-reload`
|
2. Install dependencies `bun i && bun run submodule-reload`
|
||||||
3. Make sure you have xcode and/or android studio installed. (follow the guides for expo: https://docs.expo.dev/workflow/android-studio-emulator/)
|
3. Make sure you have xcode and/or android studio installed. (follow the guides for expo: https://docs.expo.dev/workflow/android-studio-emulator/)
|
||||||
4. Install BiomeJS extension in VSCode/Your IDE (https://biomejs.dev/)
|
4. Install BiomeJS extension in VSCode/Your IDE (https://biomejs.dev/)
|
||||||
4. run `npm run prebuild`
|
4. run `npm run prebuild`
|
||||||
5. Create an expo dev build by running `npm run ios` or `npm run android`. This will open a simulator on your computer and run the app.
|
5. Create an expo dev build by running `npm run ios` or `npm run android`. This will open a simulator on your computer and run the app
|
||||||
|
|
||||||
For the TV version suffix the npm commands with `:tv`.
|
For the TV version suffix the npm commands with `:tv`.
|
||||||
|
|
||||||
`npm run prebuild:tv`
|
`npm run prebuild:tv`
|
||||||
`npm run ios:tv or npm run android:tv`
|
`npm run ios:tv or npm run android:tv`
|
||||||
|
|
||||||
## 📄 License
|
## 👋 Get in Touch with Us
|
||||||
|
|
||||||
Streamyfin is licensed under the Mozilla Public License 2.0 (MPL-2.0).
|
Need assistance or have any questions?
|
||||||
This means you are free to use, modify, and distribute this software. The MPL-2.0 is a copyleft license that allows for more flexibility in combining the software with proprietary code.
|
|
||||||
Key points of the MPL-2.0:
|
|
||||||
|
|
||||||
- You can use the software for any purpose
|
- **Discord:** [Join our server](https://discord.gg/BuGG9ZNhaE)
|
||||||
- You can modify the software and distribute modified versions
|
- **GitHub Issues:** [Report bugs or request features](https://github.com/streamyfin/streamyfin/issues)
|
||||||
- You must include the original copyright and license notices
|
- **Email:** [developer@streamyfin.app](mailto:developer@streamyfin.app)
|
||||||
- You must disclose your source code for any modifications to the covered files
|
|
||||||
- Larger works may combine MPL code with code under other licenses
|
|
||||||
- MPL-licensed components must remain under the MPL, but the larger work can be under a different license
|
|
||||||
- For the full text of the license, please see the LICENSE file in this repository
|
|
||||||
|
|
||||||
## 🌐 Connect with Us
|
|
||||||
|
|
||||||
Join our Discord: [](https://discord.gg/BuGG9ZNhaE)
|
|
||||||
|
|
||||||
Need support or have questions:
|
|
||||||
|
|
||||||
- GitHub Issues: Report bugs or request features here.
|
|
||||||
- Email: [developer@streamyfin.app](mailto:developer@streamyfin.app)
|
|
||||||
|
|
||||||
## ❓ FAQ
|
## ❓ FAQ
|
||||||
|
|
||||||
1. Q: Why can't I see my libraries in Streamyfin?
|
1. Q: Why can't I see my libraries in Streamyfin?
|
||||||
A: Make sure your server is running one of the latest versions and that you have at least one library that isn't audio only.
|
A: Make sure your server is running one of the latest versions and that you have at least one library that isn't audio only
|
||||||
2. Q: Why can't I see my music library?
|
2. Q: Why can't I see my music library?
|
||||||
A: We don't currently support music and are unlikely to support music in the near future.
|
A: We don't currently support music and are unlikely to support music in the near future
|
||||||
|
|
||||||
## 📝 Credits
|
## 📝 Credits
|
||||||
|
|
||||||
Streamyfin is developed by [Fredrik Burmester](https://github.com/fredrikburmester) and is not affiliated with Jellyfin. The app is built with Expo, React Native, and other open-source libraries.
|
Streamyfin is developed by [Fredrik Burmester](https://github.com/fredrikburmester) and is not affiliated with Jellyfin. The app is built using Expo, React Native, and other open-source libraries.
|
||||||
|
|
||||||
## ✨ Acknowledgements
|
## 🎖️ Core Developers
|
||||||
|
|
||||||
We would like to thank the Jellyfin team for their great software and awesome support on discord.
|
|
||||||
|
|
||||||
Special shoutout to the JF official clients for being an inspiration to ours.
|
|
||||||
|
|
||||||
### 🏆 Core Developers
|
|
||||||
|
|
||||||
Thanks to the following contributors for their significant contributions:
|
Thanks to the following contributors for their significant contributions:
|
||||||
|
|
||||||
@@ -229,21 +218,41 @@ Thanks to the following contributors for their significant contributions:
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
And all other developers who have contributed to Streamyfin, thank you for your contributions.
|
## ✨ Acknowledgements
|
||||||
|
|
||||||
I'd also like to thank the following people and projects for their contributions to Streamyfin:
|
We would like to thank the Jellyfin team for their excellent software and support on Discord.
|
||||||
|
|
||||||
|
Special thanks to the official Jellyfin clients, which have served as an inspiration for Streamyfin.
|
||||||
|
|
||||||
|
We also thank all other developers who have contributed to Streamyfin, your efforts are greatly appreciated.
|
||||||
|
|
||||||
|
A special mention to the following people and projects for their contributions:
|
||||||
|
|
||||||
|
- [Reiverr](https://github.com/aleksilassila/reiverr) for invaluable help with understanding the Jellyfin API
|
||||||
|
- [Jellyfin TS SDK](https://github.com/jellyfin/jellyfin-sdk-typescript) for providing the TypeScript SDK
|
||||||
|
- [Seerr](https://github.com/seerr-team/seerr) for enabling API integration with their project
|
||||||
|
|
||||||
- [Reiverr](https://github.com/aleksilassila/reiverr) for great help with understanding the Jellyfin API.
|
|
||||||
- [Jellyfin TS SDK](https://github.com/jellyfin/jellyfin-sdk-typescript) for the TypeScript SDK.
|
|
||||||
- [Jellyseerr](https://github.com/Fallenbagel/jellyseerr) for enabling API integration with their project.
|
|
||||||
- The Jellyfin devs for always being helpful in the Discord.
|
|
||||||
|
|
||||||
## ⭐ Star History
|
## ⭐ Star History
|
||||||
|
|
||||||
[](https://star-history.com/#streamyfin/streamyfin&Date)
|
[](https://star-history.com/#streamyfin/streamyfin&Date)
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
Streamyfin is licensed under the Mozilla Public License 2.0 (MPL-2.0).
|
||||||
|
This means you are free to use, modify, and distribute this software. The MPL-2.0 is a copyleft license that allows for more flexibility in combining the software with proprietary code.
|
||||||
|
Key points of the MPL-2.0:
|
||||||
|
|
||||||
|
- You can use the software for any purpose
|
||||||
|
- You can modify the software and distribute modified versions
|
||||||
|
- You must include the original copyright and license notices
|
||||||
|
- You must disclose your source code for any modifications to the covered files
|
||||||
|
- Larger works may combine MPL code with code under other licenses
|
||||||
|
- MPL-licensed components must remain under the MPL, but the larger work can be under a different license
|
||||||
|
- For the full text of the license, please see the LICENSE file in this repository
|
||||||
|
|
||||||
## ⚠️ Disclaimer
|
## ⚠️ Disclaimer
|
||||||
Streamyfin does not promote, support, or condone piracy in any form. The app is intended solely for streaming media that you personally own and control. It does not provide or include any media content. Any discussions or support requests related to piracy are strictly prohibited across all our channels.
|
Streamyfin does not promote, support, or condone piracy in any form. The app is intended solely for streaming media that you personally own and control. It does not provide or include any media content. Any discussions, support requests, or references to piracy, as well as any tools, software, or websites related to piracy, are strictly prohibited across all our channels.
|
||||||
|
|
||||||
## 🤝 Sponsorship
|
## 🤝 Sponsorship
|
||||||
VPS hosting generously provided by [Hexabyte](https://hexabyte.se/en/vps/?currency=eur) and [SweHosting](https://swehosting.se/en/#tj%C3%A4nster)
|
VPS hosting generously provided by [Hexabyte](https://hexabyte.se/en/vps/?currency=eur) and [SweHosting](https://swehosting.se/en/#tj%C3%A4nster)
|
||||||
|
|||||||
@@ -1,23 +1,21 @@
|
|||||||
module.exports = ({ config }) => {
|
module.exports = ({ config }) => {
|
||||||
if (process.env.EXPO_TV === "1") {
|
if (process.env.EXPO_TV !== "1") {
|
||||||
// Add TV-specific plugin for TV builds
|
|
||||||
config.plugins.push("@react-native-tvos/config-tv");
|
|
||||||
} else {
|
|
||||||
// Add non-TV specific plugins for phone builds
|
|
||||||
config.plugins.push("expo-background-task");
|
config.plugins.push("expo-background-task");
|
||||||
|
|
||||||
config.plugins.push([
|
config.plugins.push([
|
||||||
"react-native-google-cast",
|
"react-native-google-cast",
|
||||||
{ useDefaultExpandedMediaControls: true },
|
{ useDefaultExpandedMediaControls: true },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Add the background downloader plugin only for non-TV builds
|
|
||||||
config.plugins.push("./plugins/withRNBackgroundDownloader.js");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only override googleServicesFile if env var is set
|
||||||
|
const androidConfig = {};
|
||||||
|
if (process.env.GOOGLE_SERVICES_JSON) {
|
||||||
|
androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
android: {
|
...(Object.keys(androidConfig).length > 0 && { android: androidConfig }),
|
||||||
googleServicesFile: process.env.GOOGLE_SERVICES_JSON,
|
|
||||||
},
|
|
||||||
...config,
|
...config,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
28
app.json
28
app.json
@@ -2,12 +2,13 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.39.0",
|
"version": "0.47.1",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "streamyfin",
|
"scheme": "streamyfin",
|
||||||
"userInterfaceStyle": "dark",
|
"userInterfaceStyle": "dark",
|
||||||
"jsEngine": "hermes",
|
"jsEngine": "hermes",
|
||||||
|
"newArchEnabled": true,
|
||||||
"assetBundlePatterns": ["**/*"],
|
"assetBundlePatterns": ["**/*"],
|
||||||
"ios": {
|
"ios": {
|
||||||
"requireFullScreen": true,
|
"requireFullScreen": true,
|
||||||
@@ -28,16 +29,12 @@
|
|||||||
},
|
},
|
||||||
"supportsTablet": true,
|
"supportsTablet": true,
|
||||||
"bundleIdentifier": "com.fredrikburmester.streamyfin",
|
"bundleIdentifier": "com.fredrikburmester.streamyfin",
|
||||||
"icon": {
|
"icon": "./assets/images/icon-ios-liquid-glass.icon",
|
||||||
"dark": "./assets/images/icon-ios-plain.png",
|
|
||||||
"light": "./assets/images/icon-ios-light.png",
|
|
||||||
"tinted": "./assets/images/icon-ios-tinted.png"
|
|
||||||
},
|
|
||||||
"appleTeamId": "MWD5K362T8"
|
"appleTeamId": "MWD5K362T8"
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"jsEngine": "hermes",
|
"jsEngine": "hermes",
|
||||||
"versionCode": 71,
|
"versionCode": 84,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/images/icon-android-plain.png",
|
"foregroundImage": "./assets/images/icon-android-plain.png",
|
||||||
"monochromeImage": "./assets/images/icon-android-themed.png",
|
"monochromeImage": "./assets/images/icon-android-themed.png",
|
||||||
@@ -53,6 +50,7 @@
|
|||||||
"googleServicesFile": "./google-services.json"
|
"googleServicesFile": "./google-services.json"
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
|
"@react-native-tvos/config-tv",
|
||||||
"expo-router",
|
"expo-router",
|
||||||
"expo-font",
|
"expo-font",
|
||||||
[
|
[
|
||||||
@@ -76,6 +74,7 @@
|
|||||||
"useFrameworks": "static"
|
"useFrameworks": "static"
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
|
"buildArchs": ["arm64-v8a", "x86_64"],
|
||||||
"compileSdkVersion": 35,
|
"compileSdkVersion": 35,
|
||||||
"targetSdkVersion": 35,
|
"targetSdkVersion": 35,
|
||||||
"buildToolsVersion": "35.0.0",
|
"buildToolsVersion": "35.0.0",
|
||||||
@@ -114,10 +113,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
["./plugins/withChangeNativeAndroidTextToWhite.js"],
|
|
||||||
["./plugins/withAndroidManifest.js"],
|
|
||||||
["./plugins/withTrustLocalCerts.js"],
|
|
||||||
["./plugins/withGradleProperties.js"],
|
|
||||||
[
|
[
|
||||||
"expo-splash-screen",
|
"expo-splash-screen",
|
||||||
{
|
{
|
||||||
@@ -133,8 +128,12 @@
|
|||||||
"color": "#9333EA"
|
"color": "#9333EA"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"./plugins/with-runtime-framework-headers.js",
|
"expo-web-browser",
|
||||||
"react-native-bottom-tabs"
|
["./plugins/with-runtime-framework-headers.js"],
|
||||||
|
["./plugins/withChangeNativeAndroidTextToWhite.js"],
|
||||||
|
["./plugins/withAndroidManifest.js"],
|
||||||
|
["./plugins/withTrustLocalCerts.js"],
|
||||||
|
["./plugins/withGradleProperties.js"]
|
||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
"typedRoutes": true
|
"typedRoutes": true
|
||||||
@@ -153,7 +152,6 @@
|
|||||||
},
|
},
|
||||||
"updates": {
|
"updates": {
|
||||||
"url": "https://u.expo.dev/e79219d1-797f-4fbe-9fa1-cfd360690a68"
|
"url": "https://u.expo.dev/e79219d1-797f-4fbe-9fa1-cfd360690a68"
|
||||||
},
|
}
|
||||||
"newArchEnabled": false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export default function CustomMenuLayout() {
|
|||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='index'
|
name='index'
|
||||||
options={{
|
options={{
|
||||||
headerShown: true,
|
headerShown: Platform.OS !== "ios",
|
||||||
headerLargeTitle: true,
|
headerLargeTitle: true,
|
||||||
headerTitle: t("tabs.custom_links"),
|
headerTitle: t("tabs.custom_links"),
|
||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { RefreshControl, ScrollView, View } from "react-native";
|
import { Platform, RefreshControl, ScrollView, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Favorites } from "@/components/home/Favorites";
|
import { Favorites } from "@/components/home/Favorites";
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
@@ -28,7 +28,7 @@ export default function favorites() {
|
|||||||
paddingBottom: 16,
|
paddingBottom: 16,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className='my-4'>
|
<View style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}>
|
||||||
<Favorites />
|
<Favorites />
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ export default function IndexLayout() {
|
|||||||
{!Platform.isTV && (
|
{!Platform.isTV && (
|
||||||
<>
|
<>
|
||||||
<Chromecast.Chromecast background='transparent' />
|
<Chromecast.Chromecast background='transparent' />
|
||||||
|
|
||||||
{user?.Policy?.IsAdministrator && <SessionsButton />}
|
{user?.Policy?.IsAdministrator && <SessionsButton />}
|
||||||
<SettingsButton />
|
<SettingsButton />
|
||||||
</>
|
</>
|
||||||
@@ -42,49 +41,237 @@ export default function IndexLayout() {
|
|||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='downloads/index'
|
name='downloads/index'
|
||||||
options={{
|
options={{
|
||||||
|
headerShown: true,
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
title: t("home.downloads.downloads_title"),
|
title: t("home.downloads.downloads_title"),
|
||||||
|
headerLeft: () => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => _router.back()}
|
||||||
|
className='pl-0.5'
|
||||||
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
|
>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='downloads/[seriesId]'
|
name='downloads/[seriesId]'
|
||||||
options={{
|
options={{
|
||||||
|
headerShown: true,
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
headerShadowVisible: false,
|
||||||
title: t("home.downloads.tvseries"),
|
title: t("home.downloads.tvseries"),
|
||||||
|
headerLeft: () => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => _router.back()}
|
||||||
|
className='pl-0.5'
|
||||||
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
|
>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='sessions/index'
|
name='sessions/index'
|
||||||
options={{
|
options={{
|
||||||
title: t("home.sessions.title"),
|
title: t("home.sessions.title"),
|
||||||
|
headerShown: true,
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
headerShadowVisible: false,
|
||||||
|
headerLeft: () => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => _router.back()}
|
||||||
|
className='pl-0.5'
|
||||||
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
|
>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='settings'
|
name='settings'
|
||||||
options={{
|
options={{
|
||||||
title: t("home.settings.settings_title"),
|
title: t("home.settings.settings_title"),
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
headerShadowVisible: false,
|
||||||
|
headerLeft: () => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => _router.back()}
|
||||||
|
className='pl-0.5'
|
||||||
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
|
>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='settings/marlin-search/page'
|
name='settings/playback-controls/page'
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: t("home.settings.playback_controls.title"),
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
headerShadowVisible: false,
|
||||||
|
headerLeft: () => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => _router.back()}
|
||||||
|
className='pl-0.5'
|
||||||
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
|
>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='settings/jellyseerr/page'
|
name='settings/audio-subtitles/page'
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: t("home.settings.audio_subtitles.title"),
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
headerShadowVisible: false,
|
||||||
|
headerLeft: () => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => _router.back()}
|
||||||
|
className='pl-0.5'
|
||||||
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
|
>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='settings/hide-libraries/page'
|
name='settings/appearance/page'
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: t("home.settings.appearance.title"),
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
headerShadowVisible: false,
|
||||||
|
headerLeft: () => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => _router.back()}
|
||||||
|
className='pl-0.5'
|
||||||
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
|
>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='settings/appearance/hide-libraries/page'
|
||||||
|
options={{
|
||||||
|
title: t("home.settings.other.hide_libraries"),
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
headerShadowVisible: false,
|
||||||
|
headerLeft: () => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => _router.back()}
|
||||||
|
className='pl-0.5'
|
||||||
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
|
>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='settings/plugins/page'
|
||||||
|
options={{
|
||||||
|
title: t("home.settings.plugins.plugins_title"),
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
headerShadowVisible: false,
|
||||||
|
headerLeft: () => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => _router.back()}
|
||||||
|
className='pl-0.5'
|
||||||
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
|
>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='settings/plugins/marlin-search/page'
|
||||||
|
options={{
|
||||||
|
title: "Marlin Search",
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
headerShadowVisible: false,
|
||||||
|
headerLeft: () => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => _router.back()}
|
||||||
|
className='pl-0.5'
|
||||||
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
|
>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='settings/plugins/jellyseerr/page'
|
||||||
|
options={{
|
||||||
|
title: "Jellyseerr",
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
headerShadowVisible: false,
|
||||||
|
headerLeft: () => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => _router.back()}
|
||||||
|
className='pl-0.5'
|
||||||
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
|
>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='settings/intro/page'
|
||||||
|
options={{
|
||||||
|
title: t("home.settings.intro.title"),
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
headerShadowVisible: false,
|
||||||
|
headerLeft: () => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => _router.back()}
|
||||||
|
className='pl-0.5'
|
||||||
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
|
>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name='settings/logs/page'
|
name='settings/logs/page'
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: t("home.settings.logs.logs_title"),
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
headerShadowVisible: false,
|
||||||
|
headerLeft: () => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => _router.back()}
|
||||||
|
className='pl-0.5'
|
||||||
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
|
>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -92,6 +279,11 @@ export default function IndexLayout() {
|
|||||||
options={{
|
options={{
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
title: "",
|
title: "",
|
||||||
|
headerLeft: () => (
|
||||||
|
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
presentation: "modal",
|
presentation: "modal",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -102,6 +294,11 @@ export default function IndexLayout() {
|
|||||||
name='collections/[collectionId]'
|
name='collections/[collectionId]'
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: "",
|
||||||
|
headerLeft: () => (
|
||||||
|
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
headerShown: true,
|
headerShown: true,
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
|
import { Alert, Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { EpisodeCard } from "@/components/downloads/EpisodeCard";
|
import { EpisodeCard } from "@/components/downloads/EpisodeCard";
|
||||||
import {
|
import {
|
||||||
@@ -23,21 +25,23 @@ export default function page() {
|
|||||||
const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>(
|
const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>(
|
||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
const { getDownloadedItems, deleteItems } = useDownload();
|
const { downloadedItems, deleteItems } = useDownload();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const series = useMemo(() => {
|
const series = useMemo(() => {
|
||||||
try {
|
try {
|
||||||
return (
|
return (
|
||||||
getDownloadedItems()
|
downloadedItems
|
||||||
?.filter((f) => f.item.SeriesId === seriesId)
|
?.filter((f) => f.item.SeriesId === seriesId)
|
||||||
?.sort(
|
?.sort(
|
||||||
(a, b) => a?.item.ParentIndexNumber! - b.item.ParentIndexNumber!,
|
(a, b) =>
|
||||||
|
(a.item.ParentIndexNumber ?? 0) - (b.item.ParentIndexNumber ?? 0),
|
||||||
) || []
|
) || []
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}, [getDownloadedItems]);
|
}, [downloadedItems, seriesId]);
|
||||||
|
|
||||||
// Group episodes by season in a single pass
|
// Group episodes by season in a single pass
|
||||||
const seasonGroups = useMemo(() => {
|
const seasonGroups = useMemo(() => {
|
||||||
@@ -70,8 +74,9 @@ export default function page() {
|
|||||||
}, [seasonGroups]);
|
}, [seasonGroups]);
|
||||||
|
|
||||||
const seasonIndex =
|
const seasonIndex =
|
||||||
seasonIndexState[series?.[0]?.item?.ParentId ?? ""] ||
|
seasonIndexState[series?.[0]?.item?.ParentId ?? ""] ??
|
||||||
episodeSeasonIndex ||
|
episodeSeasonIndex ??
|
||||||
|
series?.[0]?.item?.ParentIndexNumber ??
|
||||||
"";
|
"";
|
||||||
|
|
||||||
const groupBySeason = useMemo<BaseItemDto[]>(() => {
|
const groupBySeason = useMemo<BaseItemDto[]>(() => {
|
||||||
@@ -80,9 +85,9 @@ export default function page() {
|
|||||||
|
|
||||||
const initialSeasonIndex = useMemo(
|
const initialSeasonIndex = useMemo(
|
||||||
() =>
|
() =>
|
||||||
Object.values(groupBySeason)?.[0]?.ParentIndexNumber ??
|
groupBySeason?.[0]?.ParentIndexNumber ??
|
||||||
series?.[0]?.item?.ParentIndexNumber,
|
series?.[0]?.item?.ParentIndexNumber,
|
||||||
[groupBySeason],
|
[groupBySeason, series],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -91,7 +96,7 @@ export default function page() {
|
|||||||
title: series[0].item.SeriesName,
|
title: series[0].item.SeriesName,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
storage.delete(seriesId);
|
storage.remove(seriesId);
|
||||||
router.back();
|
router.back();
|
||||||
}
|
}
|
||||||
}, [series]);
|
}, [series]);
|
||||||
@@ -107,44 +112,70 @@ export default function page() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: "Delete",
|
text: "Delete",
|
||||||
onPress: () => deleteItems(groupBySeason),
|
onPress: () =>
|
||||||
|
deleteItems(
|
||||||
|
groupBySeason
|
||||||
|
.map((item) => item.Id)
|
||||||
|
.filter((id) => id !== undefined),
|
||||||
|
),
|
||||||
style: "destructive",
|
style: "destructive",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}, [groupBySeason]);
|
}, [groupBySeason, deleteItems]);
|
||||||
|
|
||||||
|
const ListHeaderComponent = useCallback(() => {
|
||||||
|
if (series.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='flex flex-row items-center justify-start pb-2'>
|
||||||
|
<SeasonDropdown
|
||||||
|
item={series[0].item}
|
||||||
|
seasons={uniqueSeasons}
|
||||||
|
state={seasonIndexState}
|
||||||
|
initialSeasonIndex={initialSeasonIndex!}
|
||||||
|
onSelect={(season) => {
|
||||||
|
setSeasonIndexState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[series[0].item.ParentId ?? ""]: season.ParentIndexNumber,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<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'>
|
||||||
|
<TouchableOpacity onPress={deleteSeries}>
|
||||||
|
<Ionicons name='trash' size={20} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
series,
|
||||||
|
uniqueSeasons,
|
||||||
|
seasonIndexState,
|
||||||
|
initialSeasonIndex,
|
||||||
|
groupBySeason,
|
||||||
|
deleteSeries,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='flex-1'>
|
<View className='flex-1'>
|
||||||
{series.length > 0 && (
|
<FlashList
|
||||||
<View className='flex flex-row items-center justify-start my-2 px-4'>
|
key={seasonIndex}
|
||||||
<SeasonDropdown
|
data={groupBySeason}
|
||||||
item={series[0].item}
|
renderItem={({ item }) => <EpisodeCard item={item} />}
|
||||||
seasons={uniqueSeasons}
|
keyExtractor={(item, index) => item.Id ?? `episode-${index}`}
|
||||||
state={seasonIndexState}
|
ListHeaderComponent={ListHeaderComponent}
|
||||||
initialSeasonIndex={initialSeasonIndex!}
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
onSelect={(season) => {
|
contentContainerStyle={{
|
||||||
setSeasonIndexState((prev) => ({
|
paddingHorizontal: 16,
|
||||||
...prev,
|
paddingLeft: insets.left + 16,
|
||||||
[series[0].item.ParentId ?? ""]: season.ParentIndexNumber,
|
paddingRight: insets.right + 16,
|
||||||
}));
|
paddingTop: Platform.OS === "android" ? 10 : 8,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<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'>
|
|
||||||
<TouchableOpacity onPress={deleteSeries}>
|
|
||||||
<Ionicons name='trash' size={20} color='white' />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
<ScrollView key={seasonIndex} className='px-4'>
|
|
||||||
{groupBySeason.map((episode, index) => (
|
|
||||||
<EpisodeCard key={index} item={episode} />
|
|
||||||
))}
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { BottomSheetModal } from "@gorhom/bottom-sheet";
|
||||||
import {
|
|
||||||
BottomSheetBackdrop,
|
|
||||||
type BottomSheetBackdropProps,
|
|
||||||
BottomSheetModal,
|
|
||||||
BottomSheetView,
|
|
||||||
} from "@gorhom/bottom-sheet";
|
|
||||||
import { useNavigation, useRouter } from "expo-router";
|
import { useNavigation, useRouter } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
|
import {
|
||||||
|
Alert,
|
||||||
|
Platform,
|
||||||
|
ScrollView,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { Button } from "@/components/Button";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
import ActiveDownloads from "@/components/downloads/ActiveDownloads";
|
import ActiveDownloads from "@/components/downloads/ActiveDownloads";
|
||||||
@@ -26,18 +26,15 @@ import { writeToLog } from "@/utils/log";
|
|||||||
export default function page() {
|
export default function page() {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [queue, setQueue] = useAtom(queueAtom);
|
const [_queue, _setQueue] = useAtom(queueAtom);
|
||||||
const {
|
const { downloadedItems, deleteFileByType, deleteAllFiles } = useDownload();
|
||||||
removeProcess,
|
|
||||||
getDownloadedItems,
|
|
||||||
deleteFileByType,
|
|
||||||
deleteAllFiles,
|
|
||||||
} = useDownload();
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
|
|
||||||
const [showMigration, setShowMigration] = useState(false);
|
const [showMigration, setShowMigration] = useState(false);
|
||||||
|
|
||||||
|
const _insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const migration_20241124 = () => {
|
const migration_20241124 = () => {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("home.downloads.new_app_version_requires_re_download"),
|
t("home.downloads.new_app_version_requires_re_download"),
|
||||||
@@ -62,7 +59,7 @@ export default function page() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const downloadedFiles = getDownloadedItems();
|
const downloadedFiles = useMemo(() => downloadedItems, [downloadedItems]);
|
||||||
|
|
||||||
const movies = useMemo(() => {
|
const movies = useMemo(() => {
|
||||||
try {
|
try {
|
||||||
@@ -106,7 +103,10 @@ export default function page() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<TouchableOpacity onPress={bottomSheetModalRef.current?.present}>
|
<TouchableOpacity
|
||||||
|
onPress={bottomSheetModalRef.current?.present}
|
||||||
|
className='px-2'
|
||||||
|
>
|
||||||
<DownloadSize items={downloadedFiles?.map((f) => f.item) || []} />
|
<DownloadSize items={downloadedFiles?.map((f) => f.item) || []} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
),
|
),
|
||||||
@@ -119,7 +119,7 @@ export default function page() {
|
|||||||
}
|
}
|
||||||
}, [showMigration]);
|
}, [showMigration]);
|
||||||
|
|
||||||
const deleteMovies = () =>
|
const _deleteMovies = () =>
|
||||||
deleteFileByType("Movie")
|
deleteFileByType("Movie")
|
||||||
.then(() =>
|
.then(() =>
|
||||||
toast.success(
|
toast.success(
|
||||||
@@ -130,7 +130,7 @@ export default function page() {
|
|||||||
writeToLog("ERROR", reason);
|
writeToLog("ERROR", reason);
|
||||||
toast.error(t("home.downloads.toasts.failed_to_delete_all_movies"));
|
toast.error(t("home.downloads.toasts.failed_to_delete_all_movies"));
|
||||||
});
|
});
|
||||||
const deleteShows = () =>
|
const _deleteShows = () =>
|
||||||
deleteFileByType("Episode")
|
deleteFileByType("Episode")
|
||||||
.then(() =>
|
.then(() =>
|
||||||
toast.success(
|
toast.success(
|
||||||
@@ -141,38 +141,39 @@ export default function page() {
|
|||||||
writeToLog("ERROR", reason);
|
writeToLog("ERROR", reason);
|
||||||
toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
|
toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
|
||||||
});
|
});
|
||||||
const deleteOtherMedia = () =>
|
const _deleteOtherMedia = () =>
|
||||||
Promise.all(
|
Promise.all(
|
||||||
otherMedia.map((item) =>
|
otherMedia
|
||||||
deleteFileByType(item.item.Type)
|
.filter((item) => item.item.Type)
|
||||||
.then(() =>
|
.map((item) =>
|
||||||
toast.success(
|
deleteFileByType(item.item.Type!)
|
||||||
t("home.downloads.toasts.deleted_media_successfully", {
|
.then(() =>
|
||||||
type: item.item.Type,
|
toast.success(
|
||||||
}),
|
t("home.downloads.toasts.deleted_media_successfully", {
|
||||||
),
|
type: item.item.Type,
|
||||||
)
|
}),
|
||||||
.catch((reason) => {
|
),
|
||||||
writeToLog("ERROR", reason);
|
)
|
||||||
toast.error(
|
.catch((reason) => {
|
||||||
t("home.downloads.toasts.failed_to_delete_media", {
|
writeToLog("ERROR", reason);
|
||||||
type: item.item.Type,
|
toast.error(
|
||||||
}),
|
t("home.downloads.toasts.failed_to_delete_media", {
|
||||||
);
|
type: item.item.Type,
|
||||||
}),
|
}),
|
||||||
),
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const deleteAllMedia = async () =>
|
|
||||||
await Promise.all([deleteMovies(), deleteShows(), deleteOtherMedia()]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<ScrollView
|
||||||
<View style={{ flex: 1 }}>
|
showsVerticalScrollIndicator={false}
|
||||||
<ScrollView showsVerticalScrollIndicator={false} className='flex-1'>
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
<View className='py-4'>
|
>
|
||||||
<View className='mb-4 flex flex-col space-y-4 px-4'>
|
<View style={{ paddingTop: Platform.OS === "android" ? 17 : 0 }}>
|
||||||
<View className='bg-neutral-900 p-4 rounded-2xl'>
|
<View className='mb-4 flex flex-col space-y-4 px-4'>
|
||||||
|
{/* Queue card - hidden */}
|
||||||
|
{/* <View className='bg-neutral-900 p-4 rounded-2xl'>
|
||||||
<Text className='text-lg font-bold'>
|
<Text className='text-lg font-bold'>
|
||||||
{t("home.downloads.queue")}
|
{t("home.downloads.queue")}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -214,139 +215,96 @@ export default function page() {
|
|||||||
{t("home.downloads.no_items_in_queue")}
|
{t("home.downloads.no_items_in_queue")}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View> */}
|
||||||
|
|
||||||
<ActiveDownloads />
|
<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>
|
</View>
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
{movies.length > 0 && (
|
<View className='px-4 flex flex-row'>
|
||||||
<View className='mb-4'>
|
{movies?.map((item) => (
|
||||||
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
<TouchableItemRouter
|
||||||
<Text className='text-lg font-bold'>
|
item={item.item}
|
||||||
{t("home.downloads.movies")}
|
isOffline
|
||||||
</Text>
|
key={item.item.Id}
|
||||||
<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>
|
<MovieCard item={item.item} />
|
||||||
</View>
|
</TouchableItemRouter>
|
||||||
</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>
|
</View>
|
||||||
)}
|
</ScrollView>
|
||||||
{groupedBySeries.length > 0 && (
|
</View>
|
||||||
<View className='mb-4'>
|
)}
|
||||||
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
{groupedBySeries.length > 0 && (
|
||||||
<Text className='text-lg font-bold'>
|
<View className='mb-4'>
|
||||||
{t("home.downloads.tvseries")}
|
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
||||||
</Text>
|
<Text className='text-lg font-bold'>
|
||||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
{t("home.downloads.tvseries")}
|
||||||
<Text className='text-xs font-bold'>
|
</Text>
|
||||||
{groupedBySeries?.length}
|
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
||||||
</Text>
|
<Text className='text-xs font-bold'>
|
||||||
</View>
|
{groupedBySeries?.length}
|
||||||
</View>
|
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
|
||||||
<View className='px-4 flex flex-row'>
|
|
||||||
{groupedBySeries?.map((items) => (
|
|
||||||
<View
|
|
||||||
className='mb-2 last:mb-0'
|
|
||||||
key={items[0].item.SeriesId}
|
|
||||||
>
|
|
||||||
<SeriesCard
|
|
||||||
items={items.map((i) => i.item)}
|
|
||||||
key={items[0].item.SeriesId}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{otherMedia.length > 0 && (
|
|
||||||
<View className='mb-4'>
|
|
||||||
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
|
||||||
<Text className='text-lg font-bold'>
|
|
||||||
{t("home.downloads.other_media")}
|
|
||||||
</Text>
|
|
||||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
|
||||||
<Text className='text-xs font-bold'>
|
|
||||||
{otherMedia?.length}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
|
||||||
<View className='px-4 flex flex-row'>
|
|
||||||
{otherMedia?.map((item) => (
|
|
||||||
<TouchableItemRouter
|
|
||||||
item={item.item}
|
|
||||||
isOffline
|
|
||||||
key={item.item.Id}
|
|
||||||
>
|
|
||||||
<MovieCard item={item.item} />
|
|
||||||
</TouchableItemRouter>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{downloadedFiles?.length === 0 && (
|
|
||||||
<View className='flex px-4'>
|
|
||||||
<Text className='opacity-50'>
|
|
||||||
{t("home.downloads.no_downloaded_items")}
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</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>
|
</View>
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
<BottomSheetModal
|
|
||||||
ref={bottomSheetModalRef}
|
|
||||||
enableDynamicSizing
|
|
||||||
handleIndicatorStyle={{
|
|
||||||
backgroundColor: "white",
|
|
||||||
}}
|
|
||||||
backgroundStyle={{
|
|
||||||
backgroundColor: "#171717",
|
|
||||||
}}
|
|
||||||
backdropComponent={(props: BottomSheetBackdropProps) => (
|
|
||||||
<BottomSheetBackdrop
|
|
||||||
{...props}
|
|
||||||
disappearsOnIndex={-1}
|
|
||||||
appearsOnIndex={0}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
>
|
|
||||||
<BottomSheetView>
|
{otherMedia.length > 0 && (
|
||||||
<View className='p-4 space-y-4 mb-4'>
|
<View className='mb-4'>
|
||||||
<Button color='purple' onPress={deleteMovies}>
|
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
||||||
{t("home.downloads.delete_all_movies_button")}
|
<Text className='text-lg font-bold'>
|
||||||
</Button>
|
{t("home.downloads.other_media")}
|
||||||
<Button color='purple' onPress={deleteShows}>
|
</Text>
|
||||||
{t("home.downloads.delete_all_tvseries_button")}
|
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
||||||
</Button>
|
<Text className='text-xs font-bold'>{otherMedia?.length}</Text>
|
||||||
{otherMedia.length > 0 && (
|
</View>
|
||||||
<Button color='purple' onPress={deleteOtherMedia}>
|
</View>
|
||||||
{t("home.downloads.delete_all_other_media_button")}
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
</Button>
|
<View className='px-4 flex flex-row'>
|
||||||
)}
|
{otherMedia?.map((item) => (
|
||||||
<Button color='red' onPress={deleteAllMedia}>
|
<TouchableItemRouter
|
||||||
{t("home.downloads.delete_all_button")}
|
item={item.item}
|
||||||
</Button>
|
isOffline
|
||||||
|
key={item.item.Id}
|
||||||
|
>
|
||||||
|
<MovieCard item={item.item} />
|
||||||
|
</TouchableItemRouter>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
</BottomSheetView>
|
)}
|
||||||
</BottomSheetModal>
|
{downloadedFiles?.length === 0 && (
|
||||||
</>
|
<View className='flex px-4'>
|
||||||
|
<Text className='opacity-50'>
|
||||||
|
{t("home.downloads.no_downloaded_items")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
import { HomeIndex } from "@/components/settings/HomeIndex";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { Home } from "../../../../components/home/Home";
|
||||||
|
import { HomeWithCarousel } from "../../../../components/home/HomeWithCarousel";
|
||||||
|
|
||||||
export default function page() {
|
const Index = () => {
|
||||||
return <HomeIndex />;
|
const { settings } = useSettings();
|
||||||
}
|
const showLargeHomeCarousel = settings.showLargeHomeCarousel ?? false;
|
||||||
|
|
||||||
|
if (showLargeHomeCarousel) {
|
||||||
|
return <HomeWithCarousel />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Home />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Index;
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
import {
|
import { HardwareAccelerationType } from "@jellyfin/sdk/lib/generated-client";
|
||||||
HardwareAccelerationType,
|
|
||||||
type SessionInfoDto,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import {
|
import {
|
||||||
GeneralCommandType,
|
GeneralCommandType,
|
||||||
PlaystateCommand,
|
PlaystateCommand,
|
||||||
|
SessionInfoDto,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
@@ -13,7 +11,7 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import { Badge } from "@/components/Badge";
|
import { Badge } from "@/components/Badge";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
@@ -49,14 +47,13 @@ export default function page() {
|
|||||||
<FlashList
|
<FlashList
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingTop: 17,
|
paddingTop: Platform.OS === "android" ? 17 : 0,
|
||||||
paddingHorizontal: 17,
|
paddingHorizontal: 17,
|
||||||
paddingBottom: 150,
|
paddingBottom: 150,
|
||||||
}}
|
}}
|
||||||
data={sessions}
|
data={sessions}
|
||||||
renderItem={({ item }) => <SessionCard session={item} />}
|
renderItem={({ item }) => <SessionCard session={item} />}
|
||||||
keyExtractor={(item) => item.Id || ""}
|
keyExtractor={(item) => item.Id || ""}
|
||||||
estimatedItemSize={200}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,34 +8,16 @@ import { Text } from "@/components/common/Text";
|
|||||||
import { ListGroup } from "@/components/list/ListGroup";
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
|
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";
|
|
||||||
import { PluginSettings } from "@/components/settings/PluginSettings";
|
|
||||||
import { QuickConnect } from "@/components/settings/QuickConnect";
|
import { QuickConnect } from "@/components/settings/QuickConnect";
|
||||||
import { StorageSettings } from "@/components/settings/StorageSettings";
|
import { StorageSettings } from "@/components/settings/StorageSettings";
|
||||||
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
|
|
||||||
import { UserInfo } from "@/components/settings/UserInfo";
|
import { UserInfo } from "@/components/settings/UserInfo";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
|
||||||
import { useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
import { useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { clearLogs } from "@/utils/log";
|
|
||||||
import { storage } from "@/utils/mmkv";
|
|
||||||
|
|
||||||
export default function settings() {
|
export default function settings() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [_user] = useAtom(userAtom);
|
const [_user] = useAtom(userAtom);
|
||||||
const { logout } = useJellyfin();
|
const { logout } = useJellyfin();
|
||||||
const successHapticFeedback = useHaptic("success");
|
|
||||||
|
|
||||||
const onClearLogsClicked = async () => {
|
|
||||||
clearLogs();
|
|
||||||
successHapticFeedback();
|
|
||||||
};
|
|
||||||
|
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -46,7 +28,7 @@ export default function settings() {
|
|||||||
logout();
|
logout();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text className='text-red-600'>
|
<Text className='text-red-600 px-2'>
|
||||||
{t("home.settings.log_out_button")}
|
{t("home.settings.log_out_button")}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -56,61 +38,58 @@ export default function settings() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingLeft: insets.left,
|
paddingLeft: insets.left,
|
||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className='p-4 flex flex-col gap-y-4'>
|
<View
|
||||||
<UserInfo />
|
className='p-4 flex flex-col'
|
||||||
|
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||||
|
>
|
||||||
|
<View className='mb-4'>
|
||||||
|
<UserInfo />
|
||||||
|
</View>
|
||||||
|
|
||||||
<QuickConnect className='mb-4' />
|
<QuickConnect className='mb-4' />
|
||||||
|
|
||||||
<MediaProvider>
|
<View className='mb-4'>
|
||||||
<MediaToggles className='mb-4' />
|
<AppLanguageSelector />
|
||||||
<GestureControls className='mb-4' />
|
</View>
|
||||||
<AudioToggles className='mb-4' />
|
|
||||||
<SubtitleToggles className='mb-4' />
|
|
||||||
</MediaProvider>
|
|
||||||
|
|
||||||
<OtherSettings />
|
|
||||||
|
|
||||||
{!Platform.isTV && <DownloadSettings />}
|
|
||||||
|
|
||||||
<PluginSettings />
|
|
||||||
|
|
||||||
<AppLanguageSelector />
|
|
||||||
|
|
||||||
{!Platform.isTV && <ChromecastSettings />}
|
|
||||||
|
|
||||||
<ListGroup title={"Intro"}>
|
|
||||||
<ListItem
|
|
||||||
onPress={() => {
|
|
||||||
router.push("/intro/page");
|
|
||||||
}}
|
|
||||||
title={t("home.settings.intro.show_intro")}
|
|
||||||
/>
|
|
||||||
<ListItem
|
|
||||||
textColor='red'
|
|
||||||
onPress={() => {
|
|
||||||
storage.set("hasShownIntro", false);
|
|
||||||
}}
|
|
||||||
title={t("home.settings.intro.reset_intro")}
|
|
||||||
/>
|
|
||||||
</ListGroup>
|
|
||||||
|
|
||||||
<View className='mb-4'>
|
<View className='mb-4'>
|
||||||
<ListGroup title={t("home.settings.logs.logs_title")}>
|
<ListGroup title={t("home.settings.categories.title")}>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => router.push("/settings/playback-controls/page")}
|
||||||
|
showArrow
|
||||||
|
title={t("home.settings.playback_controls.title")}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => router.push("/settings/audio-subtitles/page")}
|
||||||
|
showArrow
|
||||||
|
title={t("home.settings.audio_subtitles.title")}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => router.push("/settings/appearance/page")}
|
||||||
|
showArrow
|
||||||
|
title={t("home.settings.appearance.title")}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => router.push("/settings/plugins/page")}
|
||||||
|
showArrow
|
||||||
|
title={t("home.settings.plugins.plugins_title")}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => router.push("/settings/intro/page")}
|
||||||
|
showArrow
|
||||||
|
title={t("home.settings.intro.title")}
|
||||||
|
/>
|
||||||
<ListItem
|
<ListItem
|
||||||
onPress={() => router.push("/settings/logs/page")}
|
onPress={() => router.push("/settings/logs/page")}
|
||||||
showArrow
|
showArrow
|
||||||
title={t("home.settings.logs.logs_title")}
|
title={t("home.settings.logs.logs_title")}
|
||||||
/>
|
/>
|
||||||
<ListItem
|
|
||||||
textColor='red'
|
|
||||||
onPress={onClearLogsClicked}
|
|
||||||
title={t("home.settings.logs.delete_all_logs")}
|
|
||||||
/>
|
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ScrollView, Switch, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
|
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 user = useAtomValue(userAtom);
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ["user-views", user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await getUserViewsApi(api!).getUserViews({
|
||||||
|
userId: user?.Id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data.Items || null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!settings) return null;
|
||||||
|
|
||||||
|
if (isLoading)
|
||||||
|
return (
|
||||||
|
<View className='mt-4'>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DisabledSetting
|
||||||
|
disabled={pluginSettings?.hiddenLibraries?.locked === true}
|
||||||
|
className='px-4'
|
||||||
|
>
|
||||||
|
<ListGroup title={t("home.settings.other.hide_libraries")}>
|
||||||
|
{data?.map((view) => (
|
||||||
|
<ListItem key={view.Id} title={view.Name} onPress={() => {}}>
|
||||||
|
<Switch
|
||||||
|
value={settings.hiddenLibraries?.includes(view.Id!) || false}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
updateSettings({
|
||||||
|
hiddenLibraries: value
|
||||||
|
? [...(settings.hiddenLibraries || []), view.Id!]
|
||||||
|
: settings.hiddenLibraries?.filter(
|
||||||
|
(id) => id !== view.Id,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</ListGroup>
|
||||||
|
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||||
|
{t("home.settings.other.select_liraries_you_want_to_hide")}
|
||||||
|
</Text>
|
||||||
|
</DisabledSetting>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
app/(auth)/(tabs)/(home)/settings/appearance/page.tsx
Normal file
25
app/(auth)/(tabs)/(home)/settings/appearance/page.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { Platform, ScrollView, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { AppearanceSettings } from "@/components/settings/AppearanceSettings";
|
||||||
|
|
||||||
|
export default function AppearancePage() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className='p-4 flex flex-col'
|
||||||
|
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||||
|
>
|
||||||
|
<AppearanceSettings />
|
||||||
|
<View className='h-24' />
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
app/(auth)/(tabs)/(home)/settings/audio-subtitles/page.tsx
Normal file
29
app/(auth)/(tabs)/(home)/settings/audio-subtitles/page.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { Platform, ScrollView, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { AudioToggles } from "@/components/settings/AudioToggles";
|
||||||
|
import { MediaProvider } from "@/components/settings/MediaContext";
|
||||||
|
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
|
||||||
|
|
||||||
|
export default function AudioSubtitlesPage() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className='p-4 flex flex-col'
|
||||||
|
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||||
|
>
|
||||||
|
<MediaProvider>
|
||||||
|
<AudioToggles className='mb-4' />
|
||||||
|
<SubtitleToggles className='mb-4' />
|
||||||
|
</MediaProvider>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
app/(auth)/(tabs)/(home)/settings/intro/page.tsx
Normal file
45
app/(auth)/(tabs)/(home)/settings/intro/page.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform, ScrollView, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
|
import { storage } from "@/utils/mmkv";
|
||||||
|
|
||||||
|
export default function IntroPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className='p-4 flex flex-col'
|
||||||
|
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||||
|
>
|
||||||
|
<ListGroup title={t("home.settings.intro.title")}>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => {
|
||||||
|
router.push("/intro/page");
|
||||||
|
}}
|
||||||
|
title={t("home.settings.intro.show_intro")}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
textColor='red'
|
||||||
|
onPress={() => {
|
||||||
|
storage.set("hasShownIntro", false);
|
||||||
|
}}
|
||||||
|
title={t("home.settings.intro.reset_intro")}
|
||||||
|
/>
|
||||||
|
</ListGroup>
|
||||||
|
<View className='h-24' />
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
|
||||||
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
|
|
||||||
export default function page() {
|
|
||||||
const { pluginSettings } = useSettings();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DisabledSetting
|
|
||||||
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
|
|
||||||
className='p-4'
|
|
||||||
>
|
|
||||||
<JellyseerrSettings />
|
|
||||||
</DisabledSetting>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,21 @@
|
|||||||
import * as FileSystem from "expo-file-system";
|
import { File, Paths } from "expo-file-system";
|
||||||
import { useNavigation } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
import * as Sharing from "expo-sharing";
|
import type * as SharingType from "expo-sharing";
|
||||||
import { useCallback, useEffect, useId, useMemo, useState } from "react";
|
import { useCallback, useEffect, useId, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ScrollView, TouchableOpacity, View } from "react-native";
|
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
import Collapsible from "react-native-collapsible";
|
import Collapsible from "react-native-collapsible";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { FilterButton } from "@/components/filters/FilterButton";
|
import { FilterButton } from "@/components/filters/FilterButton";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { LogLevel, useLog, writeErrorLog } from "@/utils/log";
|
import { LogLevel, useLog, writeErrorLog } from "@/utils/log";
|
||||||
|
|
||||||
|
// Conditionally import expo-sharing only on non-TV platforms
|
||||||
|
const Sharing = Platform.isTV
|
||||||
|
? null
|
||||||
|
: (require("expo-sharing") as typeof SharingType);
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const { logs } = useLog();
|
const { logs } = useLog();
|
||||||
@@ -33,6 +39,7 @@ export default function Page() {
|
|||||||
|
|
||||||
const _orderId = useId();
|
const _orderId = useId();
|
||||||
const _levelsId = useId();
|
const _levelsId = useId();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const filteredLogs = useMemo(
|
const filteredLogs = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -47,27 +54,30 @@ export default function Page() {
|
|||||||
|
|
||||||
// Sharing it as txt while its formatted allows us to share it with many more applications
|
// Sharing it as txt while its formatted allows us to share it with many more applications
|
||||||
const share = useCallback(async () => {
|
const share = useCallback(async () => {
|
||||||
const uri = `${FileSystem.documentDirectory}logs.txt`;
|
if (!Sharing) return;
|
||||||
|
|
||||||
|
const logsFile = new File(Paths.document, "logs.txt");
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
FileSystem.writeAsStringAsync(uri, JSON.stringify(filteredLogs))
|
try {
|
||||||
.then(() => {
|
logsFile.write(JSON.stringify(filteredLogs));
|
||||||
setLoading(false);
|
await Sharing.shareAsync(logsFile.uri, { mimeType: "txt", UTI: "txt" });
|
||||||
Sharing.shareAsync(uri, { mimeType: "txt", UTI: "txt" });
|
} catch (e: any) {
|
||||||
})
|
writeErrorLog("Something went wrong attempting to export", e);
|
||||||
.catch((e) =>
|
} finally {
|
||||||
writeErrorLog("Something went wrong attempting to export", e),
|
setLoading(false);
|
||||||
)
|
}
|
||||||
.finally(() => setLoading(false));
|
}, [filteredLogs, Sharing]);
|
||||||
}, [filteredLogs]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () =>
|
headerRight: () =>
|
||||||
loading ? (
|
loading ? (
|
||||||
<Loader />
|
<Loader />
|
||||||
) : (
|
) : (
|
||||||
<TouchableOpacity onPress={share}>
|
<TouchableOpacity onPress={share} className='px-2'>
|
||||||
<Text>{t("home.settings.logs.export_logs")}</Text>
|
<Text>{t("home.settings.logs.export_logs")}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
),
|
),
|
||||||
@@ -75,7 +85,12 @@ export default function Page() {
|
|||||||
}, [share, loading]);
|
}, [share, loading]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<View
|
||||||
|
className='flex-1'
|
||||||
|
style={{
|
||||||
|
paddingTop: insets.top + 48,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<View className='flex flex-row justify-end py-2 px-4 space-x-2'>
|
<View className='flex flex-row justify-end py-2 px-4 space-x-2'>
|
||||||
<FilterButton
|
<FilterButton
|
||||||
id={orderFilterId}
|
id={orderFilterId}
|
||||||
@@ -157,6 +172,6 @@ export default function Page() {
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,122 +0,0 @@
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useNavigation } from "expo-router";
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
Linking,
|
|
||||||
Switch,
|
|
||||||
TextInput,
|
|
||||||
TouchableOpacity,
|
|
||||||
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 queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
|
|
||||||
|
|
||||||
const onSave = (val: string) => {
|
|
||||||
updateSettings({
|
|
||||||
marlinServerUrl: !val.endsWith("/") ? val : val.slice(0, -1),
|
|
||||||
});
|
|
||||||
toast.success(t("home.settings.plugins.marlin_search.toasts.saved"));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpenLink = () => {
|
|
||||||
Linking.openURL("https://github.com/fredrikburmester/marlin-search");
|
|
||||||
};
|
|
||||||
|
|
||||||
const disabled = useMemo(() => {
|
|
||||||
return (
|
|
||||||
pluginSettings?.searchEngine?.locked === true &&
|
|
||||||
pluginSettings?.marlinServerUrl?.locked === true
|
|
||||||
);
|
|
||||||
}, [pluginSettings]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!pluginSettings?.marlinServerUrl?.locked) {
|
|
||||||
navigation.setOptions({
|
|
||||||
headerRight: () => (
|
|
||||||
<TouchableOpacity onPress={() => onSave(value)}>
|
|
||||||
<Text className='text-blue-500'>
|
|
||||||
{t("home.settings.plugins.marlin_search.save_button")}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [navigation, value]);
|
|
||||||
|
|
||||||
if (!settings) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<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",
|
|
||||||
)}
|
|
||||||
onPress={() => {
|
|
||||||
updateSettings({ searchEngine: "Jellyfin" });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
value={settings.searchEngine === "Marlin"}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
updateSettings({ searchEngine: value ? "Marlin" : "Jellyfin" });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
</DisabledSetting>
|
|
||||||
</ListGroup>
|
|
||||||
|
|
||||||
<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'
|
|
||||||
>
|
|
||||||
<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",
|
|
||||||
)}
|
|
||||||
value={value}
|
|
||||||
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'>
|
|
||||||
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
|
|
||||||
<Text className='text-blue-500' onPress={handleOpenLink}>
|
|
||||||
{t("home.settings.plugins.marlin_search.read_more_about_marlin")}
|
|
||||||
</Text>
|
|
||||||
</Text>
|
|
||||||
</DisabledSetting>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
35
app/(auth)/(tabs)/(home)/settings/playback-controls/page.tsx
Normal file
35
app/(auth)/(tabs)/(home)/settings/playback-controls/page.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { Platform, ScrollView, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { GestureControls } from "@/components/settings/GestureControls";
|
||||||
|
import { MediaProvider } from "@/components/settings/MediaContext";
|
||||||
|
import { MediaToggles } from "@/components/settings/MediaToggles";
|
||||||
|
import { PlaybackControlsSettings } from "@/components/settings/PlaybackControlsSettings";
|
||||||
|
import { ChromecastSettings } from "../../../../../../components/settings/ChromecastSettings";
|
||||||
|
|
||||||
|
export default function PlaybackControlsPage() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className='p-4 flex flex-col'
|
||||||
|
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||||
|
>
|
||||||
|
<View className='mb-4'>
|
||||||
|
<MediaProvider>
|
||||||
|
<MediaToggles className='mb-4' />
|
||||||
|
<GestureControls className='mb-4' />
|
||||||
|
<PlaybackControlsSettings />
|
||||||
|
</MediaProvider>
|
||||||
|
</View>
|
||||||
|
{!Platform.isTV && <ChromecastSettings />}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { ScrollView } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
|
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const { pluginSettings } = useSettings();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DisabledSetting
|
||||||
|
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
|
||||||
|
className='px-4'
|
||||||
|
>
|
||||||
|
<JellyseerrSettings />
|
||||||
|
</DisabledSetting>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
138
app/(auth)/(tabs)/(home)/settings/plugins/marlin-search/page.tsx
Normal file
138
app/(auth)/(tabs)/(home)/settings/plugins/marlin-search/page.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useNavigation } from "expo-router";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Linking,
|
||||||
|
ScrollView,
|
||||||
|
Switch,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
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 insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
|
||||||
|
|
||||||
|
const onSave = (val: string) => {
|
||||||
|
updateSettings({
|
||||||
|
marlinServerUrl: !val.endsWith("/") ? val : val.slice(0, -1),
|
||||||
|
});
|
||||||
|
toast.success(t("home.settings.plugins.marlin_search.toasts.saved"));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenLink = () => {
|
||||||
|
Linking.openURL("https://github.com/fredrikburmester/marlin-search");
|
||||||
|
};
|
||||||
|
|
||||||
|
const disabled = useMemo(() => {
|
||||||
|
return (
|
||||||
|
pluginSettings?.searchEngine?.locked === true &&
|
||||||
|
pluginSettings?.marlinServerUrl?.locked === true
|
||||||
|
);
|
||||||
|
}, [pluginSettings]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pluginSettings?.marlinServerUrl?.locked) {
|
||||||
|
navigation.setOptions({
|
||||||
|
headerRight: () => (
|
||||||
|
<TouchableOpacity onPress={() => onSave(value)} className='px-2'>
|
||||||
|
<Text className='text-blue-500'>
|
||||||
|
{t("home.settings.plugins.marlin_search.save_button")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [navigation, value]);
|
||||||
|
|
||||||
|
if (!settings) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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",
|
||||||
|
)}
|
||||||
|
onPress={() => {
|
||||||
|
updateSettings({ searchEngine: "Jellyfin" });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
value={settings.searchEngine === "Marlin"}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
updateSettings({
|
||||||
|
searchEngine: value ? "Marlin" : "Jellyfin",
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
</DisabledSetting>
|
||||||
|
</ListGroup>
|
||||||
|
|
||||||
|
<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'
|
||||||
|
>
|
||||||
|
<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",
|
||||||
|
)}
|
||||||
|
value={value}
|
||||||
|
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'>
|
||||||
|
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
|
||||||
|
<Text className='text-blue-500' onPress={handleOpenLink}>
|
||||||
|
{t("home.settings.plugins.marlin_search.read_more_about_marlin")}
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</DisabledSetting>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
app/(auth)/(tabs)/(home)/settings/plugins/page.tsx
Normal file
24
app/(auth)/(tabs)/(home)/settings/plugins/page.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { Platform, ScrollView, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { PluginSettings } from "@/components/settings/PluginSettings";
|
||||||
|
|
||||||
|
export default function PluginsPage() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className='px-4 flex flex-col'
|
||||||
|
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||||
|
>
|
||||||
|
<PluginSettings />
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import type React from "react";
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { FlatList, View } from "react-native";
|
import { FlatList, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
import { FilterButton } from "@/components/filters/FilterButton";
|
import { FilterButton } from "@/components/filters/FilterButton";
|
||||||
@@ -204,154 +205,154 @@ const page: React.FC = () => {
|
|||||||
|
|
||||||
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||||
|
|
||||||
|
const _insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const ListHeaderComponent = useCallback(
|
const ListHeaderComponent = useCallback(
|
||||||
() => (
|
() => (
|
||||||
<View className=''>
|
<FlatList
|
||||||
<FlatList
|
horizontal
|
||||||
horizontal
|
showsHorizontalScrollIndicator={false}
|
||||||
showsHorizontalScrollIndicator={false}
|
contentContainerStyle={{
|
||||||
contentContainerStyle={{
|
display: "flex",
|
||||||
display: "flex",
|
paddingHorizontal: 15,
|
||||||
paddingHorizontal: 15,
|
paddingVertical: 16,
|
||||||
paddingVertical: 16,
|
flexDirection: "row",
|
||||||
flexDirection: "row",
|
}}
|
||||||
}}
|
extraData={[
|
||||||
extraData={[
|
selectedGenres,
|
||||||
selectedGenres,
|
selectedYears,
|
||||||
selectedYears,
|
selectedTags,
|
||||||
selectedTags,
|
sortBy,
|
||||||
sortBy,
|
sortOrder,
|
||||||
sortOrder,
|
]}
|
||||||
]}
|
data={[
|
||||||
data={[
|
{
|
||||||
{
|
key: "reset",
|
||||||
key: "reset",
|
component: <ResetFiltersButton />,
|
||||||
component: <ResetFiltersButton />,
|
},
|
||||||
},
|
{
|
||||||
{
|
key: "genre",
|
||||||
key: "genre",
|
component: (
|
||||||
component: (
|
<FilterButton
|
||||||
<FilterButton
|
className='mr-1'
|
||||||
className='mr-1'
|
id={collectionId}
|
||||||
id={collectionId}
|
queryKey='genreFilter'
|
||||||
queryKey='genreFilter'
|
queryFn={async () => {
|
||||||
queryFn={async () => {
|
if (!api) return null;
|
||||||
if (!api) return null;
|
const response = await getFilterApi(
|
||||||
const response = await getFilterApi(
|
api,
|
||||||
api,
|
).getQueryFiltersLegacy({
|
||||||
).getQueryFiltersLegacy({
|
userId: user?.Id,
|
||||||
userId: user?.Id,
|
parentId: collectionId,
|
||||||
parentId: collectionId,
|
});
|
||||||
});
|
return response.data.Genres || [];
|
||||||
return response.data.Genres || [];
|
}}
|
||||||
}}
|
set={setSelectedGenres}
|
||||||
set={setSelectedGenres}
|
values={selectedGenres}
|
||||||
values={selectedGenres}
|
title={t("library.filters.genres")}
|
||||||
title={t("library.filters.genres")}
|
renderItemLabel={(item) => item.toString()}
|
||||||
renderItemLabel={(item) => item.toString()}
|
searchFilter={(item, search) =>
|
||||||
searchFilter={(item, search) =>
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
}
|
||||||
}
|
/>
|
||||||
/>
|
),
|
||||||
),
|
},
|
||||||
},
|
{
|
||||||
{
|
key: "year",
|
||||||
key: "year",
|
component: (
|
||||||
component: (
|
<FilterButton
|
||||||
<FilterButton
|
className='mr-1'
|
||||||
className='mr-1'
|
id={collectionId}
|
||||||
id={collectionId}
|
queryKey='yearFilter'
|
||||||
queryKey='yearFilter'
|
queryFn={async () => {
|
||||||
queryFn={async () => {
|
if (!api) return null;
|
||||||
if (!api) return null;
|
const response = await getFilterApi(
|
||||||
const response = await getFilterApi(
|
api,
|
||||||
api,
|
).getQueryFiltersLegacy({
|
||||||
).getQueryFiltersLegacy({
|
userId: user?.Id,
|
||||||
userId: user?.Id,
|
parentId: collectionId,
|
||||||
parentId: collectionId,
|
});
|
||||||
});
|
return response.data.Years || [];
|
||||||
return response.data.Years || [];
|
}}
|
||||||
}}
|
set={setSelectedYears}
|
||||||
set={setSelectedYears}
|
values={selectedYears}
|
||||||
values={selectedYears}
|
title={t("library.filters.years")}
|
||||||
title={t("library.filters.years")}
|
renderItemLabel={(item) => item.toString()}
|
||||||
renderItemLabel={(item) => item.toString()}
|
searchFilter={(item, search) => item.includes(search)}
|
||||||
searchFilter={(item, search) => item.includes(search)}
|
/>
|
||||||
/>
|
),
|
||||||
),
|
},
|
||||||
},
|
{
|
||||||
{
|
key: "tags",
|
||||||
key: "tags",
|
component: (
|
||||||
component: (
|
<FilterButton
|
||||||
<FilterButton
|
className='mr-1'
|
||||||
className='mr-1'
|
id={collectionId}
|
||||||
id={collectionId}
|
queryKey='tagsFilter'
|
||||||
queryKey='tagsFilter'
|
queryFn={async () => {
|
||||||
queryFn={async () => {
|
if (!api) return null;
|
||||||
if (!api) return null;
|
const response = await getFilterApi(
|
||||||
const response = await getFilterApi(
|
api,
|
||||||
api,
|
).getQueryFiltersLegacy({
|
||||||
).getQueryFiltersLegacy({
|
userId: user?.Id,
|
||||||
userId: user?.Id,
|
parentId: collectionId,
|
||||||
parentId: collectionId,
|
});
|
||||||
});
|
return response.data.Tags || [];
|
||||||
return response.data.Tags || [];
|
}}
|
||||||
}}
|
set={setSelectedTags}
|
||||||
set={setSelectedTags}
|
values={selectedTags}
|
||||||
values={selectedTags}
|
title={t("library.filters.tags")}
|
||||||
title={t("library.filters.tags")}
|
renderItemLabel={(item) => item.toString()}
|
||||||
renderItemLabel={(item) => item.toString()}
|
searchFilter={(item, search) =>
|
||||||
searchFilter={(item, search) =>
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
}
|
||||||
}
|
/>
|
||||||
/>
|
),
|
||||||
),
|
},
|
||||||
},
|
{
|
||||||
{
|
key: "sortBy",
|
||||||
key: "sortBy",
|
component: (
|
||||||
component: (
|
<FilterButton
|
||||||
<FilterButton
|
className='mr-1'
|
||||||
className='mr-1'
|
id={collectionId}
|
||||||
id={collectionId}
|
queryKey='sortBy'
|
||||||
queryKey='sortBy'
|
queryFn={async () => sortOptions.map((s) => s.key)}
|
||||||
queryFn={async () => sortOptions.map((s) => s.key)}
|
set={setSortBy}
|
||||||
set={setSortBy}
|
values={sortBy}
|
||||||
values={sortBy}
|
title={t("library.filters.sort_by")}
|
||||||
title={t("library.filters.sort_by")}
|
renderItemLabel={(item) =>
|
||||||
renderItemLabel={(item) =>
|
sortOptions.find((i) => i.key === item)?.value || ""
|
||||||
sortOptions.find((i) => i.key === item)?.value || ""
|
}
|
||||||
}
|
searchFilter={(item, search) =>
|
||||||
searchFilter={(item, search) =>
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
}
|
||||||
}
|
/>
|
||||||
/>
|
),
|
||||||
),
|
},
|
||||||
},
|
{
|
||||||
{
|
key: "sortOrder",
|
||||||
key: "sortOrder",
|
component: (
|
||||||
component: (
|
<FilterButton
|
||||||
<FilterButton
|
className='mr-1'
|
||||||
className='mr-1'
|
id={collectionId}
|
||||||
id={collectionId}
|
queryKey='sortOrder'
|
||||||
queryKey='sortOrder'
|
queryFn={async () => sortOrderOptions.map((s) => s.key)}
|
||||||
queryFn={async () => sortOrderOptions.map((s) => s.key)}
|
set={setSortOrder}
|
||||||
set={setSortOrder}
|
values={sortOrder}
|
||||||
values={sortOrder}
|
title={t("library.filters.sort_order")}
|
||||||
title={t("library.filters.sort_order")}
|
renderItemLabel={(item) =>
|
||||||
renderItemLabel={(item) =>
|
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
||||||
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
}
|
||||||
}
|
searchFilter={(item, search) =>
|
||||||
searchFilter={(item, search) =>
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
}
|
||||||
}
|
/>
|
||||||
/>
|
),
|
||||||
),
|
},
|
||||||
},
|
]}
|
||||||
]}
|
renderItem={({ item }) => item.component}
|
||||||
renderItem={({ item }) => item.component}
|
keyExtractor={(item) => item.key}
|
||||||
keyExtractor={(item) => item.key}
|
/>
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
),
|
),
|
||||||
[
|
[
|
||||||
collectionId,
|
collectionId,
|
||||||
@@ -393,7 +394,6 @@ const page: React.FC = () => {
|
|||||||
data={flatData}
|
data={flatData}
|
||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
keyExtractor={keyExtractor}
|
keyExtractor={keyExtractor}
|
||||||
estimatedItemSize={255}
|
|
||||||
numColumns={
|
numColumns={
|
||||||
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5
|
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,31 +19,29 @@ import { Text } from "@/components/common/Text";
|
|||||||
import { GenreTags } from "@/components/GenreTags";
|
import { GenreTags } from "@/components/GenreTags";
|
||||||
import Cast from "@/components/jellyseerr/Cast";
|
import Cast from "@/components/jellyseerr/Cast";
|
||||||
import DetailFacts from "@/components/jellyseerr/DetailFacts";
|
import DetailFacts from "@/components/jellyseerr/DetailFacts";
|
||||||
|
import RequestModal from "@/components/jellyseerr/RequestModal";
|
||||||
import { OverviewText } from "@/components/OverviewText";
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
|
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||||
import { JellyserrRatings } from "@/components/Ratings";
|
import { JellyserrRatings } from "@/components/Ratings";
|
||||||
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
|
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
|
||||||
import { ItemActions } from "@/components/series/SeriesActions";
|
import { ItemActions } from "@/components/series/SeriesActions";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
|
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
|
||||||
|
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
|
||||||
import {
|
import {
|
||||||
type IssueType,
|
type IssueType,
|
||||||
IssueTypeName,
|
IssueTypeName,
|
||||||
} from "@/utils/jellyseerr/server/constants/issue";
|
} from "@/utils/jellyseerr/server/constants/issue";
|
||||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||||
|
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
||||||
|
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||||
import type {
|
import type {
|
||||||
MovieResult,
|
MovieResult,
|
||||||
TvResult,
|
TvResult,
|
||||||
} from "@/utils/jellyseerr/server/models/Search";
|
} from "@/utils/jellyseerr/server/models/Search";
|
||||||
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||||
|
|
||||||
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";
|
|
||||||
|
|
||||||
const Page: React.FC = () => {
|
const Page: React.FC = () => {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const params = useLocalSearchParams();
|
const params = useLocalSearchParams();
|
||||||
@@ -65,6 +63,7 @@ const Page: React.FC = () => {
|
|||||||
const [issueType, setIssueType] = useState<IssueType>();
|
const [issueType, setIssueType] = useState<IssueType>();
|
||||||
const [issueMessage, setIssueMessage] = useState<string>();
|
const [issueMessage, setIssueMessage] = useState<string>();
|
||||||
const [requestBody, _setRequestBody] = useState<MediaRequestBody>();
|
const [requestBody, _setRequestBody] = useState<MediaRequestBody>();
|
||||||
|
const [issueTypeDropdownOpen, setIssueTypeDropdownOpen] = useState(false);
|
||||||
const advancedReqModalRef = useRef<BottomSheetModal>(null);
|
const advancedReqModalRef = useRef<BottomSheetModal>(null);
|
||||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
|
|
||||||
@@ -115,6 +114,10 @@ const Page: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [jellyseerrApi, details, result, issueType, issueMessage]);
|
}, [jellyseerrApi, details, result, issueType, issueMessage]);
|
||||||
|
|
||||||
|
const handleIssueModalDismiss = useCallback(() => {
|
||||||
|
setIssueTypeDropdownOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const setRequestBody = useCallback(
|
const setRequestBody = useCallback(
|
||||||
(body: MediaRequestBody) => {
|
(body: MediaRequestBody) => {
|
||||||
_setRequestBody(body);
|
_setRequestBody(body);
|
||||||
@@ -128,9 +131,11 @@ const Page: React.FC = () => {
|
|||||||
mediaId: Number(result.id!),
|
mediaId: Number(result.id!),
|
||||||
mediaType: mediaType!,
|
mediaType: mediaType!,
|
||||||
tvdbId: details?.externalIds?.tvdbId,
|
tvdbId: details?.externalIds?.tvdbId,
|
||||||
seasons: (details as TvDetails)?.seasons
|
...(mediaType === MediaType.TV && {
|
||||||
?.filter?.((s) => s.seasonNumber !== 0)
|
seasons: (details as TvDetails)?.seasons
|
||||||
?.map?.((s) => s.seasonNumber),
|
?.filter?.((s) => s.seasonNumber !== 0)
|
||||||
|
?.map?.((s) => s.seasonNumber),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (hasAdvancedRequestPermission) {
|
if (hasAdvancedRequestPermission) {
|
||||||
@@ -156,11 +161,31 @@ const Page: React.FC = () => {
|
|||||||
[details],
|
[details],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const issueTypeOptionGroups = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
title: t("jellyseerr.types"),
|
||||||
|
options: Object.entries(IssueTypeName)
|
||||||
|
.reverse()
|
||||||
|
.map(([key, value]) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: value,
|
||||||
|
value: key,
|
||||||
|
selected: key === String(issueType),
|
||||||
|
onPress: () => setIssueType(key as unknown as IssueType),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[issueType, t],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (details) {
|
if (details) {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<TouchableOpacity className='rounded-full p-2 bg-neutral-800/80'>
|
<TouchableOpacity
|
||||||
|
className={`rounded-full pl-1.5 ${Platform.OS === "android" ? "" : "bg-neutral-800/80"}`}
|
||||||
|
>
|
||||||
<ItemActions item={details} />
|
<ItemActions item={details} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
),
|
),
|
||||||
@@ -355,6 +380,8 @@ const Page: React.FC = () => {
|
|||||||
backgroundColor: "#171717",
|
backgroundColor: "#171717",
|
||||||
}}
|
}}
|
||||||
backdropComponent={renderBackdrop}
|
backdropComponent={renderBackdrop}
|
||||||
|
stackBehavior='push'
|
||||||
|
onDismiss={handleIssueModalDismiss}
|
||||||
>
|
>
|
||||||
<BottomSheetView>
|
<BottomSheetView>
|
||||||
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
||||||
@@ -364,50 +391,25 @@ const Page: React.FC = () => {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className='flex flex-col space-y-2 items-start'>
|
<View className='flex flex-col space-y-2 items-start'>
|
||||||
<View className='flex flex-col'>
|
<View className='flex flex-col w-full'>
|
||||||
<DropdownMenu.Root>
|
<Text className='opacity-50 mb-1 text-xs'>
|
||||||
<DropdownMenu.Trigger>
|
{t("jellyseerr.issue_type")}
|
||||||
<View className='flex flex-col'>
|
</Text>
|
||||||
<Text className='opacity-50 mb-1 text-xs'>
|
<PlatformDropdown
|
||||||
{t("jellyseerr.issue_type")}
|
groups={issueTypeOptionGroups}
|
||||||
|
trigger={
|
||||||
|
<View className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||||
|
<Text numberOfLines={1}>
|
||||||
|
{issueType
|
||||||
|
? IssueTypeName[issueType]
|
||||||
|
: t("jellyseerr.select_an_issue")}
|
||||||
</Text>
|
</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>
|
</View>
|
||||||
</DropdownMenu.Trigger>
|
}
|
||||||
<DropdownMenu.Content
|
title={t("jellyseerr.types")}
|
||||||
loop={false}
|
open={issueTypeDropdownOpen}
|
||||||
side='bottom'
|
onOpenChange={setIssueTypeDropdownOpen}
|
||||||
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>
|
||||||
|
|
||||||
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full'>
|
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full'>
|
||||||
|
|||||||
@@ -87,14 +87,15 @@ export default function page() {
|
|||||||
<Text className='font-bold text-2xl mb-1'>{data?.details?.name}</Text>
|
<Text className='font-bold text-2xl mb-1'>{data?.details?.name}</Text>
|
||||||
<Text className='opacity-50'>
|
<Text className='opacity-50'>
|
||||||
{t("jellyseerr.born")}{" "}
|
{t("jellyseerr.born")}{" "}
|
||||||
{new Date(data?.details?.birthday!).toLocaleDateString(
|
{data?.details?.birthday &&
|
||||||
`${locale}-${region}`,
|
new Date(data.details.birthday).toLocaleDateString(
|
||||||
{
|
`${locale}-${region}`,
|
||||||
year: "numeric",
|
{
|
||||||
month: "long",
|
year: "numeric",
|
||||||
day: "numeric",
|
month: "long",
|
||||||
},
|
day: "numeric",
|
||||||
)}{" "}
|
},
|
||||||
|
)}{" "}
|
||||||
| {data?.details?.placeOfBirth}
|
| {data?.details?.placeOfBirth}
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ export default function page() {
|
|||||||
<View className='flex flex-1'>
|
<View className='flex flex-1'>
|
||||||
<FlashList
|
<FlashList
|
||||||
data={channels?.Items}
|
data={channels?.Items}
|
||||||
estimatedItemSize={76}
|
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
<View className='flex flex-row items-center px-4 mb-2'>
|
<View className='flex flex-row items-center px-4 mb-2'>
|
||||||
<View className='w-22 mr-4 rounded-lg overflow-hidden'>
|
<View className='w-22 mr-4 rounded-lg overflow-hidden'>
|
||||||
|
|||||||
@@ -65,9 +65,11 @@ const page: React.FC = () => {
|
|||||||
const { data: allEpisodes, isLoading } = useQuery({
|
const { data: allEpisodes, isLoading } = useQuery({
|
||||||
queryKey: ["AllEpisodes", item?.Id],
|
queryKey: ["AllEpisodes", item?.Id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await getTvShowsApi(api!).getEpisodes({
|
if (!api || !user?.Id || !item?.Id) return [];
|
||||||
seriesId: item?.Id!,
|
|
||||||
userId: user?.Id!,
|
const res = await getTvShowsApi(api).getEpisodes({
|
||||||
|
seriesId: item.Id,
|
||||||
|
userId: user.Id,
|
||||||
enableUserData: true,
|
enableUserData: true,
|
||||||
// Note: Including trick play is necessary to enable trick play downloads
|
// Note: Including trick play is necessary to enable trick play downloads
|
||||||
fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
|
fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
|
||||||
|
|||||||
@@ -271,145 +271,143 @@ const Page = () => {
|
|||||||
|
|
||||||
const ListHeaderComponent = useCallback(
|
const ListHeaderComponent = useCallback(
|
||||||
() => (
|
() => (
|
||||||
<View className=''>
|
<FlatList
|
||||||
<FlatList
|
horizontal
|
||||||
horizontal
|
showsHorizontalScrollIndicator={false}
|
||||||
showsHorizontalScrollIndicator={false}
|
contentContainerStyle={{
|
||||||
contentContainerStyle={{
|
display: "flex",
|
||||||
display: "flex",
|
paddingHorizontal: 15,
|
||||||
paddingHorizontal: 15,
|
paddingVertical: 16,
|
||||||
paddingVertical: 16,
|
flexDirection: "row",
|
||||||
flexDirection: "row",
|
}}
|
||||||
}}
|
data={[
|
||||||
data={[
|
{
|
||||||
{
|
key: "reset",
|
||||||
key: "reset",
|
component: <ResetFiltersButton />,
|
||||||
component: <ResetFiltersButton />,
|
},
|
||||||
},
|
{
|
||||||
{
|
key: "genre",
|
||||||
key: "genre",
|
component: (
|
||||||
component: (
|
<FilterButton
|
||||||
<FilterButton
|
className='mr-1'
|
||||||
className='mr-1'
|
id={libraryId}
|
||||||
id={libraryId}
|
queryKey='genreFilter'
|
||||||
queryKey='genreFilter'
|
queryFn={async () => {
|
||||||
queryFn={async () => {
|
if (!api) return null;
|
||||||
if (!api) return null;
|
const response = await getFilterApi(
|
||||||
const response = await getFilterApi(
|
api,
|
||||||
api,
|
).getQueryFiltersLegacy({
|
||||||
).getQueryFiltersLegacy({
|
userId: user?.Id,
|
||||||
userId: user?.Id,
|
parentId: libraryId,
|
||||||
parentId: libraryId,
|
});
|
||||||
});
|
return response.data.Genres || [];
|
||||||
return response.data.Genres || [];
|
}}
|
||||||
}}
|
set={setSelectedGenres}
|
||||||
set={setSelectedGenres}
|
values={selectedGenres}
|
||||||
values={selectedGenres}
|
title={t("library.filters.genres")}
|
||||||
title={t("library.filters.genres")}
|
renderItemLabel={(item) => item.toString()}
|
||||||
renderItemLabel={(item) => item.toString()}
|
searchFilter={(item, search) =>
|
||||||
searchFilter={(item, search) =>
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
}
|
||||||
}
|
/>
|
||||||
/>
|
),
|
||||||
),
|
},
|
||||||
},
|
{
|
||||||
{
|
key: "year",
|
||||||
key: "year",
|
component: (
|
||||||
component: (
|
<FilterButton
|
||||||
<FilterButton
|
className='mr-1'
|
||||||
className='mr-1'
|
id={libraryId}
|
||||||
id={libraryId}
|
queryKey='yearFilter'
|
||||||
queryKey='yearFilter'
|
queryFn={async () => {
|
||||||
queryFn={async () => {
|
if (!api) return null;
|
||||||
if (!api) return null;
|
const response = await getFilterApi(
|
||||||
const response = await getFilterApi(
|
api,
|
||||||
api,
|
).getQueryFiltersLegacy({
|
||||||
).getQueryFiltersLegacy({
|
userId: user?.Id,
|
||||||
userId: user?.Id,
|
parentId: libraryId,
|
||||||
parentId: libraryId,
|
});
|
||||||
});
|
return response.data.Years || [];
|
||||||
return response.data.Years || [];
|
}}
|
||||||
}}
|
set={setSelectedYears}
|
||||||
set={setSelectedYears}
|
values={selectedYears}
|
||||||
values={selectedYears}
|
title={t("library.filters.years")}
|
||||||
title={t("library.filters.years")}
|
renderItemLabel={(item) => item.toString()}
|
||||||
renderItemLabel={(item) => item.toString()}
|
searchFilter={(item, search) => item.includes(search)}
|
||||||
searchFilter={(item, search) => item.includes(search)}
|
/>
|
||||||
/>
|
),
|
||||||
),
|
},
|
||||||
},
|
{
|
||||||
{
|
key: "tags",
|
||||||
key: "tags",
|
component: (
|
||||||
component: (
|
<FilterButton
|
||||||
<FilterButton
|
className='mr-1'
|
||||||
className='mr-1'
|
id={libraryId}
|
||||||
id={libraryId}
|
queryKey='tagsFilter'
|
||||||
queryKey='tagsFilter'
|
queryFn={async () => {
|
||||||
queryFn={async () => {
|
if (!api) return null;
|
||||||
if (!api) return null;
|
const response = await getFilterApi(
|
||||||
const response = await getFilterApi(
|
api,
|
||||||
api,
|
).getQueryFiltersLegacy({
|
||||||
).getQueryFiltersLegacy({
|
userId: user?.Id,
|
||||||
userId: user?.Id,
|
parentId: libraryId,
|
||||||
parentId: libraryId,
|
});
|
||||||
});
|
return response.data.Tags || [];
|
||||||
return response.data.Tags || [];
|
}}
|
||||||
}}
|
set={setSelectedTags}
|
||||||
set={setSelectedTags}
|
values={selectedTags}
|
||||||
values={selectedTags}
|
title={t("library.filters.tags")}
|
||||||
title={t("library.filters.tags")}
|
renderItemLabel={(item) => item.toString()}
|
||||||
renderItemLabel={(item) => item.toString()}
|
searchFilter={(item, search) =>
|
||||||
searchFilter={(item, search) =>
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
}
|
||||||
}
|
/>
|
||||||
/>
|
),
|
||||||
),
|
},
|
||||||
},
|
{
|
||||||
{
|
key: "sortBy",
|
||||||
key: "sortBy",
|
component: (
|
||||||
component: (
|
<FilterButton
|
||||||
<FilterButton
|
className='mr-1'
|
||||||
className='mr-1'
|
id={libraryId}
|
||||||
id={libraryId}
|
queryKey='sortBy'
|
||||||
queryKey='sortBy'
|
queryFn={async () => sortOptions.map((s) => s.key)}
|
||||||
queryFn={async () => sortOptions.map((s) => s.key)}
|
set={setSortBy}
|
||||||
set={setSortBy}
|
values={sortBy}
|
||||||
values={sortBy}
|
title={t("library.filters.sort_by")}
|
||||||
title={t("library.filters.sort_by")}
|
renderItemLabel={(item) =>
|
||||||
renderItemLabel={(item) =>
|
sortOptions.find((i) => i.key === item)?.value || ""
|
||||||
sortOptions.find((i) => i.key === item)?.value || ""
|
}
|
||||||
}
|
searchFilter={(item, search) =>
|
||||||
searchFilter={(item, search) =>
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
}
|
||||||
}
|
/>
|
||||||
/>
|
),
|
||||||
),
|
},
|
||||||
},
|
{
|
||||||
{
|
key: "sortOrder",
|
||||||
key: "sortOrder",
|
component: (
|
||||||
component: (
|
<FilterButton
|
||||||
<FilterButton
|
className='mr-1'
|
||||||
className='mr-1'
|
id={libraryId}
|
||||||
id={libraryId}
|
queryKey='sortOrder'
|
||||||
queryKey='sortOrder'
|
queryFn={async () => sortOrderOptions.map((s) => s.key)}
|
||||||
queryFn={async () => sortOrderOptions.map((s) => s.key)}
|
set={setSortOrder}
|
||||||
set={setSortOrder}
|
values={sortOrder}
|
||||||
values={sortOrder}
|
title={t("library.filters.sort_order")}
|
||||||
title={t("library.filters.sort_order")}
|
renderItemLabel={(item) =>
|
||||||
renderItemLabel={(item) =>
|
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
||||||
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
}
|
||||||
}
|
searchFilter={(item, search) =>
|
||||||
searchFilter={(item, search) =>
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
}
|
||||||
}
|
/>
|
||||||
/>
|
),
|
||||||
),
|
},
|
||||||
},
|
]}
|
||||||
]}
|
renderItem={({ item }) => item.component}
|
||||||
renderItem={({ item }) => item.component}
|
keyExtractor={(item) => item.key}
|
||||||
keyExtractor={(item) => item.key}
|
/>
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
),
|
),
|
||||||
[
|
[
|
||||||
libraryId,
|
libraryId,
|
||||||
@@ -453,7 +451,6 @@ const Page = () => {
|
|||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
extraData={[orientation, nrOfCols]}
|
extraData={[orientation, nrOfCols]}
|
||||||
keyExtractor={keyExtractor}
|
keyExtractor={keyExtractor}
|
||||||
estimatedItemSize={244}
|
|
||||||
numColumns={nrOfCols}
|
numColumns={nrOfCols}
|
||||||
onEndReached={() => {
|
onEndReached={() => {
|
||||||
if (hasNextPage) {
|
if (hasNextPage) {
|
||||||
|
|||||||
@@ -1,85 +1,208 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
import { useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, TouchableOpacity } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { LibraryOptionsSheet } from "@/components/settings/LibraryOptionsSheet";
|
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function IndexLayout() {
|
export default function IndexLayout() {
|
||||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
const [optionsSheetOpen, setOptionsSheetOpen] = useState(false);
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// Reset dropdown state when component unmounts or navigates away
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
setDropdownOpen(false);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Memoize callbacks to prevent recreating on every render
|
||||||
|
const handleDisplayRow = useCallback(() => {
|
||||||
|
updateSettings({
|
||||||
|
libraryOptions: {
|
||||||
|
...settings.libraryOptions,
|
||||||
|
display: "row",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [settings.libraryOptions, updateSettings]);
|
||||||
|
|
||||||
|
const handleDisplayList = useCallback(() => {
|
||||||
|
updateSettings({
|
||||||
|
libraryOptions: {
|
||||||
|
...settings.libraryOptions,
|
||||||
|
display: "list",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [settings.libraryOptions, updateSettings]);
|
||||||
|
|
||||||
|
const handleImageStylePoster = useCallback(() => {
|
||||||
|
updateSettings({
|
||||||
|
libraryOptions: {
|
||||||
|
...settings.libraryOptions,
|
||||||
|
imageStyle: "poster",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [settings.libraryOptions, updateSettings]);
|
||||||
|
|
||||||
|
const handleImageStyleCover = useCallback(() => {
|
||||||
|
updateSettings({
|
||||||
|
libraryOptions: {
|
||||||
|
...settings.libraryOptions,
|
||||||
|
imageStyle: "cover",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [settings.libraryOptions, updateSettings]);
|
||||||
|
|
||||||
|
const handleToggleTitles = useCallback(() => {
|
||||||
|
updateSettings({
|
||||||
|
libraryOptions: {
|
||||||
|
...settings.libraryOptions,
|
||||||
|
showTitles: !settings.libraryOptions.showTitles,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [settings.libraryOptions, updateSettings]);
|
||||||
|
|
||||||
|
const handleToggleStats = useCallback(() => {
|
||||||
|
updateSettings({
|
||||||
|
libraryOptions: {
|
||||||
|
...settings.libraryOptions,
|
||||||
|
showStats: !settings.libraryOptions.showStats,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [settings.libraryOptions, updateSettings]);
|
||||||
|
|
||||||
|
// Memoize groups to prevent recreating the array on every render
|
||||||
|
const dropdownGroups = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
title: t("library.options.display"),
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
type: "radio" as const,
|
||||||
|
label: t("library.options.row"),
|
||||||
|
value: "row",
|
||||||
|
selected: settings.libraryOptions.display === "row",
|
||||||
|
onPress: handleDisplayRow,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "radio" as const,
|
||||||
|
label: t("library.options.list"),
|
||||||
|
value: "list",
|
||||||
|
selected: settings.libraryOptions.display === "list",
|
||||||
|
onPress: handleDisplayList,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("library.options.image_style"),
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
type: "radio" as const,
|
||||||
|
label: t("library.options.poster"),
|
||||||
|
value: "poster",
|
||||||
|
selected: settings.libraryOptions.imageStyle === "poster",
|
||||||
|
onPress: handleImageStylePoster,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "radio" as const,
|
||||||
|
label: t("library.options.cover"),
|
||||||
|
value: "cover",
|
||||||
|
selected: settings.libraryOptions.imageStyle === "cover",
|
||||||
|
onPress: handleImageStyleCover,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Options",
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
type: "toggle" as const,
|
||||||
|
label: t("library.options.show_titles"),
|
||||||
|
value: settings.libraryOptions.showTitles,
|
||||||
|
onToggle: handleToggleTitles,
|
||||||
|
disabled: settings.libraryOptions.imageStyle === "poster",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "toggle" as const,
|
||||||
|
label: t("library.options.show_stats"),
|
||||||
|
value: settings.libraryOptions.showStats,
|
||||||
|
onToggle: handleToggleStats,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
t,
|
||||||
|
settings.libraryOptions,
|
||||||
|
handleDisplayRow,
|
||||||
|
handleDisplayList,
|
||||||
|
handleImageStylePoster,
|
||||||
|
handleImageStyleCover,
|
||||||
|
handleToggleTitles,
|
||||||
|
handleToggleStats,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
if (!settings?.libraryOptions) return null;
|
if (!settings?.libraryOptions) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Stack>
|
||||||
<Stack>
|
<Stack.Screen
|
||||||
<Stack.Screen
|
name='index'
|
||||||
name='index'
|
options={{
|
||||||
options={{
|
headerShown: !Platform.isTV,
|
||||||
headerShown: !Platform.isTV,
|
headerTitle: t("tabs.library"),
|
||||||
headerTitle: t("tabs.library"),
|
headerBlurEffect: "none",
|
||||||
headerBlurEffect: "none",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerShadowVisible: false,
|
||||||
headerShadowVisible: false,
|
headerRight: () =>
|
||||||
headerRight: () =>
|
!pluginSettings?.libraryOptions?.locked &&
|
||||||
!pluginSettings?.libraryOptions?.locked &&
|
!Platform.isTV && (
|
||||||
!Platform.isTV && (
|
<PlatformDropdown
|
||||||
<TouchableOpacity
|
open={dropdownOpen}
|
||||||
onPress={() => setOptionsSheetOpen(true)}
|
onOpenChange={setDropdownOpen}
|
||||||
className='flex flex-row items-center justify-center w-9 h-9'
|
trigger={
|
||||||
>
|
<View className='pl-1.5'>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name='ellipsis-horizontal-outline'
|
name='ellipsis-horizontal-outline'
|
||||||
size={24}
|
size={24}
|
||||||
color='white'
|
color='white'
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</View>
|
||||||
),
|
}
|
||||||
}}
|
title={t("library.options.display")}
|
||||||
/>
|
groups={dropdownGroups}
|
||||||
<Stack.Screen
|
/>
|
||||||
name='[libraryId]'
|
),
|
||||||
options={{
|
}}
|
||||||
title: "",
|
|
||||||
headerShown: !Platform.isTV,
|
|
||||||
headerBlurEffect: "none",
|
|
||||||
headerTransparent: Platform.OS === "ios",
|
|
||||||
headerShadowVisible: false,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
|
||||||
<Stack.Screen key={name} name={name} options={options} />
|
|
||||||
))}
|
|
||||||
<Stack.Screen
|
|
||||||
name='collections/[collectionId]'
|
|
||||||
options={{
|
|
||||||
title: "",
|
|
||||||
headerShown: !Platform.isTV,
|
|
||||||
headerBlurEffect: "none",
|
|
||||||
headerTransparent: Platform.OS === "ios",
|
|
||||||
headerShadowVisible: false,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
<LibraryOptionsSheet
|
|
||||||
open={optionsSheetOpen}
|
|
||||||
setOpen={setOptionsSheetOpen}
|
|
||||||
settings={settings.libraryOptions}
|
|
||||||
updateSettings={(options) =>
|
|
||||||
updateSettings({
|
|
||||||
libraryOptions: {
|
|
||||||
...settings.libraryOptions,
|
|
||||||
...options,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
disabled={pluginSettings?.libraryOptions?.locked}
|
|
||||||
/>
|
/>
|
||||||
</>
|
<Stack.Screen
|
||||||
|
name='[libraryId]'
|
||||||
|
options={{
|
||||||
|
title: "",
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
headerShadowVisible: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||||
|
<Stack.Screen key={name} name={name} options={options} />
|
||||||
|
))}
|
||||||
|
<Stack.Screen
|
||||||
|
name='collections/[collectionId]'
|
||||||
|
options={{
|
||||||
|
title: "",
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
headerShadowVisible: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { StyleSheet, View } from "react-native";
|
import { Platform, StyleSheet, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
@@ -84,11 +84,11 @@ export default function index() {
|
|||||||
extraData={settings}
|
extraData={settings}
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingTop: 17,
|
paddingTop: Platform.OS === "android" ? 17 : 0,
|
||||||
paddingHorizontal: settings?.libraryOptions?.display === "row" ? 0 : 17,
|
paddingHorizontal: settings?.libraryOptions?.display === "row" ? 0 : 17,
|
||||||
paddingBottom: 150,
|
paddingBottom: 150,
|
||||||
paddingLeft: insets.left,
|
paddingLeft: insets.left + 17,
|
||||||
paddingRight: insets.right,
|
paddingRight: insets.right + 17,
|
||||||
}}
|
}}
|
||||||
data={libraries}
|
data={libraries}
|
||||||
renderItem={({ item }) => <LibraryItemCard library={item} />}
|
renderItem={({ item }) => <LibraryItemCard library={item} />}
|
||||||
@@ -105,7 +105,6 @@ export default function index() {
|
|||||||
<View className='h-4' />
|
<View className='h-4' />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
estimatedItemSize={200}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export default function SearchLayout() {
|
|||||||
options={{
|
options={{
|
||||||
title: "",
|
title: "",
|
||||||
headerShown: !Platform.isTV,
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -24,8 +24,6 @@ import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
|||||||
import { Input } from "@/components/common/Input";
|
import { Input } from "@/components/common/Input";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
import { FilterButton } from "@/components/filters/FilterButton";
|
|
||||||
import { Tag } from "@/components/GenreTags";
|
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
import {
|
import {
|
||||||
JellyseerrSearchSort,
|
JellyseerrSearchSort,
|
||||||
@@ -33,8 +31,10 @@ import {
|
|||||||
} from "@/components/jellyseerr/JellyseerrIndexPage";
|
} from "@/components/jellyseerr/JellyseerrIndexPage";
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
import SeriesPoster from "@/components/posters/SeriesPoster";
|
import SeriesPoster from "@/components/posters/SeriesPoster";
|
||||||
|
import { DiscoverFilters } from "@/components/search/DiscoverFilters";
|
||||||
import { LoadingSkeleton } from "@/components/search/LoadingSkeleton";
|
import { LoadingSkeleton } from "@/components/search/LoadingSkeleton";
|
||||||
import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
|
import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
|
||||||
|
import { SearchTabButtons } from "@/components/search/SearchTabButtons";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
@@ -284,67 +284,30 @@ export default function search() {
|
|||||||
)}
|
)}
|
||||||
<View
|
<View
|
||||||
className='flex flex-col'
|
className='flex flex-col'
|
||||||
style={{
|
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||||
marginTop: Platform.OS === "android" ? 16 : 0,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{jellyseerrApi && (
|
{jellyseerrApi && (
|
||||||
<ScrollView
|
<View className='pl-4 pr-4 flex flex-row'>
|
||||||
horizontal
|
<SearchTabButtons
|
||||||
className='flex flex-row flex-wrap space-x-2 px-4 mb-2'
|
searchType={searchType}
|
||||||
>
|
setSearchType={setSearchType}
|
||||||
<TouchableOpacity onPress={() => setSearchType("Library")}>
|
t={t}
|
||||||
<Tag
|
/>
|
||||||
text={t("search.library")}
|
|
||||||
textClass='p-1'
|
|
||||||
className={
|
|
||||||
searchType === "Library" ? "bg-purple-600" : undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity onPress={() => setSearchType("Discover")}>
|
|
||||||
<Tag
|
|
||||||
text={t("search.discover")}
|
|
||||||
textClass='p-1'
|
|
||||||
className={
|
|
||||||
searchType === "Discover" ? "bg-purple-600" : undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
{searchType === "Discover" &&
|
{searchType === "Discover" &&
|
||||||
!loading &&
|
!loading &&
|
||||||
noResults &&
|
noResults &&
|
||||||
debouncedSearch.length > 0 && (
|
debouncedSearch.length > 0 && (
|
||||||
<View className='flex flex-row justify-end items-center space-x-1'>
|
<DiscoverFilters
|
||||||
<FilterButton
|
searchFilterId={searchFilterId}
|
||||||
id={searchFilterId}
|
orderFilterId={orderFilterId}
|
||||||
queryKey='jellyseerr_search'
|
jellyseerrOrderBy={jellyseerrOrderBy}
|
||||||
queryFn={async () =>
|
setJellyseerrOrderBy={setJellyseerrOrderBy}
|
||||||
Object.keys(JellyseerrSearchSort).filter((v) =>
|
jellyseerrSortOrder={jellyseerrSortOrder}
|
||||||
Number.isNaN(Number(v)),
|
setJellyseerrSortOrder={setJellyseerrSortOrder}
|
||||||
)
|
t={t}
|
||||||
}
|
/>
|
||||||
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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<View className='mt-2'>
|
<View className='mt-2'>
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export default function TabLayout() {
|
|||||||
backgroundColor: "#121212",
|
backgroundColor: "#121212",
|
||||||
}}
|
}}
|
||||||
tabBarActiveTintColor={Colors.primary}
|
tabBarActiveTintColor={Colors.primary}
|
||||||
|
activeIndicatorColor={"#392c3b"}
|
||||||
scrollEdgeAppearance='default'
|
scrollEdgeAppearance='default'
|
||||||
>
|
>
|
||||||
<NativeTabs.Screen redirect name='index' />
|
<NativeTabs.Screen redirect name='index' />
|
||||||
@@ -70,10 +71,7 @@ export default function TabLayout() {
|
|||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS === "android"
|
Platform.OS === "android"
|
||||||
? (_e) => require("@/assets/icons/house.fill.png")
|
? (_e) => require("@/assets/icons/house.fill.png")
|
||||||
: ({ focused }) =>
|
: (_e) => ({ sfSymbol: "house.fill" }),
|
||||||
focused
|
|
||||||
? { sfSymbol: "house.fill" }
|
|
||||||
: { sfSymbol: "house" },
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
@@ -84,14 +82,12 @@ export default function TabLayout() {
|
|||||||
})}
|
})}
|
||||||
name='(search)'
|
name='(search)'
|
||||||
options={{
|
options={{
|
||||||
|
role: "search",
|
||||||
title: t("tabs.search"),
|
title: t("tabs.search"),
|
||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS === "android"
|
Platform.OS === "android"
|
||||||
? (_e) => require("@/assets/icons/magnifyingglass.png")
|
? (_e) => require("@/assets/icons/magnifyingglass.png")
|
||||||
: ({ focused }) =>
|
: (_e) => ({ sfSymbol: "magnifyingglass" }),
|
||||||
focused
|
|
||||||
? { sfSymbol: "magnifyingglass" }
|
|
||||||
: { sfSymbol: "magnifyingglass" },
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
@@ -100,14 +96,8 @@ export default function TabLayout() {
|
|||||||
title: t("tabs.favorites"),
|
title: t("tabs.favorites"),
|
||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS === "android"
|
Platform.OS === "android"
|
||||||
? ({ focused }) =>
|
? (_e) => require("@/assets/icons/heart.fill.png")
|
||||||
focused
|
: (_e) => ({ sfSymbol: "heart.fill" }),
|
||||||
? require("@/assets/icons/heart.fill.png")
|
|
||||||
: require("@/assets/icons/heart.png")
|
|
||||||
: ({ focused }) =>
|
|
||||||
focused
|
|
||||||
? { sfSymbol: "heart.fill" }
|
|
||||||
: { sfSymbol: "heart" },
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
@@ -117,10 +107,7 @@ export default function TabLayout() {
|
|||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS === "android"
|
Platform.OS === "android"
|
||||||
? (_e) => require("@/assets/icons/server.rack.png")
|
? (_e) => require("@/assets/icons/server.rack.png")
|
||||||
: ({ focused }) =>
|
: (_e) => ({ sfSymbol: "rectangle.stack.fill" }),
|
||||||
focused
|
|
||||||
? { sfSymbol: "rectangle.stack.fill" }
|
|
||||||
: { sfSymbol: "rectangle.stack" },
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
@@ -131,10 +118,7 @@ export default function TabLayout() {
|
|||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS === "android"
|
Platform.OS === "android"
|
||||||
? (_e) => require("@/assets/icons/list.png")
|
? (_e) => require("@/assets/icons/list.png")
|
||||||
: ({ focused }) =>
|
: (_e) => ({ sfSymbol: "list.dash.fill" }),
|
||||||
focused
|
|
||||||
? { sfSymbol: "list.dash.fill" }
|
|
||||||
: { sfSymbol: "list.dash" },
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</NativeTabs>
|
</NativeTabs>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
VLCColor,
|
VLCColor,
|
||||||
} from "@/constants/SubtitleConstants";
|
} from "@/constants/SubtitleConstants";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||||
@@ -56,6 +57,7 @@ export default function page() {
|
|||||||
|
|
||||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
||||||
const [showControls, _setShowControls] = useState(true);
|
const [showControls, _setShowControls] = useState(true);
|
||||||
|
const [isPipMode, setIsPipMode] = useState(false);
|
||||||
const [aspectRatio, setAspectRatio] = useState<
|
const [aspectRatio, setAspectRatio] = useState<
|
||||||
"default" | "16:9" | "4:3" | "1:1" | "21:9"
|
"default" | "16:9" | "4:3" | "1:1" | "21:9"
|
||||||
>("default");
|
>("default");
|
||||||
@@ -75,7 +77,10 @@ export default function page() {
|
|||||||
: require("react-native-volume-manager");
|
: require("react-native-volume-manager");
|
||||||
|
|
||||||
const downloadUtils = useDownload();
|
const downloadUtils = useDownload();
|
||||||
const downloadedFiles = downloadUtils.getDownloadedItems();
|
const downloadedFiles = useMemo(
|
||||||
|
() => downloadUtils.getDownloadedItems(),
|
||||||
|
[downloadUtils.getDownloadedItems],
|
||||||
|
);
|
||||||
|
|
||||||
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
||||||
|
|
||||||
@@ -105,6 +110,7 @@ export default function page() {
|
|||||||
playbackPosition?: string;
|
playbackPosition?: string;
|
||||||
}>();
|
}>();
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
|
const { lockOrientation, unlockOrientation } = useOrientation();
|
||||||
|
|
||||||
const offline = offlineStr === "true";
|
const offline = offlineStr === "true";
|
||||||
const playbackManager = usePlaybackManager();
|
const playbackManager = usePlaybackManager();
|
||||||
@@ -167,6 +173,16 @@ export default function page() {
|
|||||||
}
|
}
|
||||||
}, [itemId, offline, api, user?.Id]);
|
}, [itemId, offline, api, user?.Id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (settings?.defaultVideoOrientation) {
|
||||||
|
lockOrientation(settings.defaultVideoOrientation);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unlockOrientation();
|
||||||
|
};
|
||||||
|
}, [settings?.defaultVideoOrientation]);
|
||||||
|
|
||||||
interface Stream {
|
interface Stream {
|
||||||
mediaSource: MediaSourceInfo;
|
mediaSource: MediaSourceInfo;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@@ -282,12 +298,14 @@ export default function page() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const reportPlaybackStopped = useCallback(async () => {
|
const reportPlaybackStopped = useCallback(async () => {
|
||||||
|
if (!item?.Id || !stream?.sessionId) return;
|
||||||
|
|
||||||
const currentTimeInTicks = msToTicks(progress.get());
|
const currentTimeInTicks = msToTicks(progress.get());
|
||||||
await getPlaystateApi(api!).onPlaybackStopped({
|
await getPlaystateApi(api!).onPlaybackStopped({
|
||||||
itemId: item?.Id!,
|
itemId: item.Id,
|
||||||
mediaSourceId: mediaSourceId,
|
mediaSourceId: mediaSourceId,
|
||||||
positionTicks: currentTimeInTicks,
|
positionTicks: currentTimeInTicks,
|
||||||
playSessionId: stream?.sessionId!,
|
playSessionId: stream.sessionId,
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
api,
|
api,
|
||||||
@@ -318,9 +336,9 @@ export default function page() {
|
|||||||
}, [navigation, stop]);
|
}, [navigation, stop]);
|
||||||
|
|
||||||
const currentPlayStateInfo = useCallback(() => {
|
const currentPlayStateInfo = useCallback(() => {
|
||||||
if (!stream) return;
|
if (!stream || !item?.Id) return;
|
||||||
return {
|
return {
|
||||||
itemId: item?.Id!,
|
itemId: item.Id,
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||||
mediaSourceId: mediaSourceId,
|
mediaSourceId: mediaSourceId,
|
||||||
@@ -727,9 +745,12 @@ export default function page() {
|
|||||||
);
|
);
|
||||||
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
||||||
}}
|
}}
|
||||||
|
onPipStarted={(e) => {
|
||||||
|
setIsPipMode(e.nativeEvent.pipStarted);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
{isMounted === true && item && (
|
{isMounted === true && item && !isPipMode && (
|
||||||
<Controls
|
<Controls
|
||||||
mediaSource={stream?.mediaSource}
|
mediaSource={stream?.mediaSource}
|
||||||
item={item}
|
item={item}
|
||||||
@@ -761,6 +782,7 @@ export default function page() {
|
|||||||
setAspectRatio={setAspectRatio}
|
setAspectRatio={setAspectRatio}
|
||||||
setScaleFactor={setScaleFactor}
|
setScaleFactor={setScaleFactor}
|
||||||
isVlc
|
isVlc
|
||||||
|
api={api}
|
||||||
downloadedFiles={downloadedFiles}
|
downloadedFiles={downloadedFiles}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
266
app/_layout.tsx
266
app/_layout.tsx
@@ -1,18 +1,24 @@
|
|||||||
import "@/augmentations";
|
import "@/augmentations";
|
||||||
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
||||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||||
|
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import * as BackgroundTask from "expo-background-task";
|
||||||
|
import * as Device from "expo-device";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
|
import { GlobalModal } from "@/components/GlobalModal";
|
||||||
import i18n from "@/i18n";
|
import i18n from "@/i18n";
|
||||||
import { DownloadProvider } from "@/providers/DownloadProvider";
|
import { DownloadProvider } from "@/providers/DownloadProvider";
|
||||||
|
import { GlobalModalProvider } from "@/providers/GlobalModalProvider";
|
||||||
import {
|
import {
|
||||||
apiAtom,
|
apiAtom,
|
||||||
getOrSetDeviceId,
|
getOrSetDeviceId,
|
||||||
getTokenFromStorage,
|
|
||||||
JellyfinProvider,
|
JellyfinProvider,
|
||||||
} from "@/providers/JellyfinProvider";
|
} from "@/providers/JellyfinProvider";
|
||||||
|
import { NetworkStatusProvider } from "@/providers/NetworkStatusProvider";
|
||||||
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
||||||
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
||||||
import { type Settings, useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import {
|
import {
|
||||||
BACKGROUND_FETCH_TASK,
|
BACKGROUND_FETCH_TASK,
|
||||||
BACKGROUND_FETCH_TASK_SESSIONS,
|
BACKGROUND_FETCH_TASK_SESSIONS,
|
||||||
@@ -26,44 +32,29 @@ import {
|
|||||||
} from "@/utils/log";
|
} from "@/utils/log";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
|
|
||||||
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";
|
|
||||||
import * as FileSystem from "expo-file-system";
|
|
||||||
|
|
||||||
const Notifications = !Platform.isTV ? require("expo-notifications") : null;
|
const Notifications = !Platform.isTV ? require("expo-notifications") : null;
|
||||||
|
|
||||||
import { getLocales } from "expo-localization";
|
|
||||||
import { router, Stack, useSegments } from "expo-router";
|
|
||||||
import * as SplashScreen from "expo-splash-screen";
|
|
||||||
|
|
||||||
import * as TaskManager from "expo-task-manager";
|
|
||||||
import { Provider as JotaiProvider } from "jotai";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { I18nextProvider } from "react-i18next";
|
|
||||||
import { Appearance, AppState } from "react-native";
|
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
|
||||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
|
||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
|
||||||
import "react-native-reanimated";
|
|
||||||
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
||||||
|
import { getLocales } from "expo-localization";
|
||||||
import type { EventSubscription } from "expo-modules-core";
|
import type { EventSubscription } from "expo-modules-core";
|
||||||
import type {
|
import type {
|
||||||
Notification,
|
Notification,
|
||||||
NotificationResponse,
|
NotificationResponse,
|
||||||
} from "expo-notifications/build/Notifications.types";
|
} from "expo-notifications/build/Notifications.types";
|
||||||
import type { ExpoPushToken } from "expo-notifications/build/Tokens.types";
|
import type { ExpoPushToken } from "expo-notifications/build/Tokens.types";
|
||||||
import { useAtom } from "jotai";
|
import { router, Stack, useSegments } from "expo-router";
|
||||||
import { Toaster } from "sonner-native";
|
import * as SplashScreen from "expo-splash-screen";
|
||||||
|
import * as TaskManager from "expo-task-manager";
|
||||||
|
import { Provider as JotaiProvider, useAtom } from "jotai";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { I18nextProvider } from "react-i18next";
|
||||||
|
import { Appearance } from "react-native";
|
||||||
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
|
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||||
import { userAtom } from "@/providers/JellyfinProvider";
|
import { userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { store } from "@/utils/store";
|
import { store } from "@/utils/store";
|
||||||
|
import "react-native-reanimated";
|
||||||
|
import { Toaster } from "sonner-native";
|
||||||
|
|
||||||
if (!Platform.isTV) {
|
if (!Platform.isTV) {
|
||||||
Notifications.setNotificationHandler({
|
Notifications.setNotificationHandler({
|
||||||
@@ -131,24 +122,7 @@ if (!Platform.isTV) {
|
|||||||
|
|
||||||
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
|
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
|
||||||
console.log("TaskManager ~ trigger");
|
console.log("TaskManager ~ trigger");
|
||||||
|
// Background fetch task placeholder - currently unused
|
||||||
const settingsData = storage.getString("settings");
|
|
||||||
|
|
||||||
if (!settingsData) return BackgroundTask.BackgroundTaskResult.Failed;
|
|
||||||
|
|
||||||
const settings: Partial<Settings> = JSON.parse(settingsData);
|
|
||||||
|
|
||||||
if (!settings?.autoDownload)
|
|
||||||
return BackgroundTask.BackgroundTaskResult.Failed;
|
|
||||||
|
|
||||||
const token = getTokenFromStorage();
|
|
||||||
const deviceId = getOrSetDeviceId();
|
|
||||||
const baseDirectory = FileSystem.documentDirectory;
|
|
||||||
|
|
||||||
if (!token || !deviceId || !baseDirectory)
|
|
||||||
return BackgroundTask.BackgroundTaskResult.Failed;
|
|
||||||
|
|
||||||
// Be sure to return the successful result type!
|
|
||||||
return BackgroundTask.BackgroundTaskResult.Success;
|
return BackgroundTask.BackgroundTaskResult.Success;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -213,11 +187,7 @@ export default function RootLayout() {
|
|||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
staleTime: 0,
|
staleTime: 30000,
|
||||||
refetchOnMount: true,
|
|
||||||
refetchOnReconnect: true,
|
|
||||||
refetchOnWindowFocus: true,
|
|
||||||
retryOnMount: true,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -226,8 +196,7 @@ function Layout() {
|
|||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const appState = useRef(AppState.currentState);
|
const _segments = useSegments();
|
||||||
const segments = useSegments();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
i18n.changeLanguage(
|
i18n.changeLanguage(
|
||||||
@@ -256,7 +225,7 @@ function Layout() {
|
|||||||
} else console.log("No token available");
|
} else console.log("No token available");
|
||||||
}, [api, expoPushToken, user]);
|
}, [api, expoPushToken, user]);
|
||||||
|
|
||||||
async function registerNotifications() {
|
const registerNotifications = useCallback(async () => {
|
||||||
if (Platform.OS === "android") {
|
if (Platform.OS === "android") {
|
||||||
console.log("Setting android notification channel 'default'");
|
console.log("Setting android notification channel 'default'");
|
||||||
await Notifications?.setNotificationChannelAsync("default", {
|
await Notifications?.setNotificationChannelAsync("default", {
|
||||||
@@ -287,11 +256,21 @@ function Layout() {
|
|||||||
|
|
||||||
// only create push token for real devices (pointless for emulators)
|
// only create push token for real devices (pointless for emulators)
|
||||||
if (Device.isDevice) {
|
if (Device.isDevice) {
|
||||||
Notifications?.getExpoPushTokenAsync()
|
Notifications?.getExpoPushTokenAsync({
|
||||||
.then((token: ExpoPushToken) => token && setExpoPushToken(token))
|
projectId: "e79219d1-797f-4fbe-9fa1-cfd360690a68",
|
||||||
.catch((reason: any) => console.log("Failed to get token", reason));
|
})
|
||||||
|
.then((token: ExpoPushToken) => {
|
||||||
|
if (token) {
|
||||||
|
console.log("Expo push token obtained:", token.data);
|
||||||
|
setExpoPushToken(token);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((reason: any) => {
|
||||||
|
console.error("Failed to get push token:", reason);
|
||||||
|
writeErrorLog("Failed to get Expo push token", reason);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}, [user]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!Platform.isTV) {
|
if (!Platform.isTV) {
|
||||||
@@ -355,119 +334,70 @@ function Layout() {
|
|||||||
responseListener.current?.remove();
|
responseListener.current?.remove();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [user, api]);
|
}, [user]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (Platform.isTV) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (segments.includes("direct-player" as never)) {
|
|
||||||
if (
|
|
||||||
!settings.followDeviceOrientation &&
|
|
||||||
settings.defaultVideoOrientation
|
|
||||||
) {
|
|
||||||
ScreenOrientation.lockAsync(settings.defaultVideoOrientation);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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().catch(
|
|
||||||
(error: unknown) => {
|
|
||||||
writeErrorLog("Failed to resume background downloads", error);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
BackGroundDownloader.checkForExistingDownloads().catch((error: unknown) => {
|
|
||||||
writeErrorLog("Failed to resume background downloads", error);
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
subscription.remove();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<JellyfinProvider>
|
<JellyfinProvider>
|
||||||
<PlaySettingsProvider>
|
<NetworkStatusProvider>
|
||||||
<LogProvider>
|
<PlaySettingsProvider>
|
||||||
<WebSocketProvider>
|
<LogProvider>
|
||||||
<DownloadProvider>
|
<WebSocketProvider>
|
||||||
<BottomSheetModalProvider>
|
<DownloadProvider>
|
||||||
<SystemBars style='light' hidden={false} />
|
<GlobalModalProvider>
|
||||||
<ThemeProvider value={DarkTheme}>
|
<BottomSheetModalProvider>
|
||||||
<Stack initialRouteName='(auth)/(tabs)'>
|
<ThemeProvider value={DarkTheme}>
|
||||||
<Stack.Screen
|
<SystemBars style='light' hidden={false} />
|
||||||
name='(auth)/(tabs)'
|
<Stack initialRouteName='(auth)/(tabs)'>
|
||||||
options={{
|
<Stack.Screen
|
||||||
headerShown: false,
|
name='(auth)/(tabs)'
|
||||||
title: "",
|
options={{
|
||||||
header: () => null,
|
headerShown: false,
|
||||||
}}
|
title: "",
|
||||||
/>
|
header: () => null,
|
||||||
<Stack.Screen
|
}}
|
||||||
name='(auth)/player'
|
/>
|
||||||
options={{
|
<Stack.Screen
|
||||||
headerShown: false,
|
name='(auth)/player'
|
||||||
title: "",
|
options={{
|
||||||
header: () => null,
|
headerShown: false,
|
||||||
}}
|
title: "",
|
||||||
/>
|
header: () => null,
|
||||||
<Stack.Screen
|
}}
|
||||||
name='login'
|
/>
|
||||||
options={{
|
<Stack.Screen
|
||||||
headerShown: true,
|
name='login'
|
||||||
title: "",
|
options={{
|
||||||
headerTransparent: true,
|
headerShown: true,
|
||||||
}}
|
title: "",
|
||||||
/>
|
headerTransparent: Platform.OS === "ios",
|
||||||
<Stack.Screen name='+not-found' />
|
}}
|
||||||
</Stack>
|
/>
|
||||||
<Toaster
|
<Stack.Screen name='+not-found' />
|
||||||
duration={4000}
|
</Stack>
|
||||||
toastOptions={{
|
<Toaster
|
||||||
style: {
|
duration={4000}
|
||||||
backgroundColor: "#262626",
|
toastOptions={{
|
||||||
borderColor: "#363639",
|
style: {
|
||||||
borderWidth: 1,
|
backgroundColor: "#262626",
|
||||||
},
|
borderColor: "#363639",
|
||||||
titleStyle: {
|
borderWidth: 1,
|
||||||
color: "white",
|
},
|
||||||
},
|
titleStyle: {
|
||||||
}}
|
color: "white",
|
||||||
closeButton
|
},
|
||||||
/>
|
}}
|
||||||
</ThemeProvider>
|
closeButton
|
||||||
</BottomSheetModalProvider>
|
/>
|
||||||
</DownloadProvider>
|
<GlobalModal />
|
||||||
</WebSocketProvider>
|
</ThemeProvider>
|
||||||
</LogProvider>
|
</BottomSheetModalProvider>
|
||||||
</PlaySettingsProvider>
|
</GlobalModalProvider>
|
||||||
|
</DownloadProvider>
|
||||||
|
</WebSocketProvider>
|
||||||
|
</LogProvider>
|
||||||
|
</PlaySettingsProvider>
|
||||||
|
</NetworkStatusProvider>
|
||||||
</JellyfinProvider>
|
</JellyfinProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,17 +4,16 @@ import { Image } from "expo-image";
|
|||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import type React from "react";
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Keyboard,
|
Keyboard,
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
Platform,
|
Platform,
|
||||||
SafeAreaView,
|
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { Input } from "@/components/common/Input";
|
import { Input } from "@/components/common/Input";
|
||||||
@@ -43,14 +42,14 @@ const Login: React.FC = () => {
|
|||||||
|
|
||||||
const [loadingServerCheck, setLoadingServerCheck] = useState<boolean>(false);
|
const [loadingServerCheck, setLoadingServerCheck] = useState<boolean>(false);
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
const [serverURL, setServerURL] = useState<string>(_apiUrl);
|
const [serverURL, setServerURL] = useState<string>(_apiUrl || "");
|
||||||
const [serverName, setServerName] = useState<string>("");
|
const [serverName, setServerName] = useState<string>("");
|
||||||
const [credentials, setCredentials] = useState<{
|
const [credentials, setCredentials] = useState<{
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
}>({
|
}>({
|
||||||
username: _username,
|
username: _username || "",
|
||||||
password: _password,
|
password: _password || "",
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -63,12 +62,13 @@ const Login: React.FC = () => {
|
|||||||
address: _apiUrl,
|
address: _apiUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Wait for server setup and state updates to complete
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (_username && _password) {
|
if (_username && _password) {
|
||||||
setCredentials({ username: _username, password: _password });
|
setCredentials({ username: _username, password: _password });
|
||||||
login(_username, _password);
|
login(_username, _password);
|
||||||
}
|
}
|
||||||
}, 300);
|
}, 0);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [_apiUrl, _username, _password]);
|
}, [_apiUrl, _username, _password]);
|
||||||
@@ -82,10 +82,10 @@ const Login: React.FC = () => {
|
|||||||
onPress={() => {
|
onPress={() => {
|
||||||
removeServer();
|
removeServer();
|
||||||
}}
|
}}
|
||||||
className='flex flex-row items-center'
|
className='flex flex-row items-center pr-2 pl-1'
|
||||||
>
|
>
|
||||||
<Ionicons name='chevron-back' size={18} color={Colors.primary} />
|
<Ionicons name='chevron-back' size={18} color={Colors.primary} />
|
||||||
<Text className='ml-2 text-purple-600'>
|
<Text className=' ml-1 text-purple-600'>
|
||||||
{t("login.change_server")}
|
{t("login.change_server")}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -264,6 +264,12 @@ const Login: React.FC = () => {
|
|||||||
onChangeText={(text: string) =>
|
onChangeText={(text: string) =>
|
||||||
setCredentials({ ...credentials, username: text })
|
setCredentials({ ...credentials, username: text })
|
||||||
}
|
}
|
||||||
|
onEndEditing={(e) => {
|
||||||
|
const newValue = e.nativeEvent.text;
|
||||||
|
if (newValue && newValue !== credentials.username) {
|
||||||
|
setCredentials({ ...credentials, username: newValue });
|
||||||
|
}
|
||||||
|
}}
|
||||||
value={credentials.username}
|
value={credentials.username}
|
||||||
keyboardType='default'
|
keyboardType='default'
|
||||||
returnKeyType='done'
|
returnKeyType='done'
|
||||||
@@ -272,6 +278,8 @@ const Login: React.FC = () => {
|
|||||||
clearButtonMode='while-editing'
|
clearButtonMode='while-editing'
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
extraClassName='mb-4'
|
extraClassName='mb-4'
|
||||||
|
autoFocus={false}
|
||||||
|
blurOnSubmit={true}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Password */}
|
{/* Password */}
|
||||||
@@ -280,6 +288,12 @@ const Login: React.FC = () => {
|
|||||||
onChangeText={(text: string) =>
|
onChangeText={(text: string) =>
|
||||||
setCredentials({ ...credentials, password: text })
|
setCredentials({ ...credentials, password: text })
|
||||||
}
|
}
|
||||||
|
onEndEditing={(e) => {
|
||||||
|
const newValue = e.nativeEvent.text;
|
||||||
|
if (newValue && newValue !== credentials.password) {
|
||||||
|
setCredentials({ ...credentials, password: newValue });
|
||||||
|
}
|
||||||
|
}}
|
||||||
value={credentials.password}
|
value={credentials.password}
|
||||||
secureTextEntry
|
secureTextEntry
|
||||||
keyboardType='default'
|
keyboardType='default'
|
||||||
@@ -289,10 +303,17 @@ const Login: React.FC = () => {
|
|||||||
clearButtonMode='while-editing'
|
clearButtonMode='while-editing'
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
extraClassName='mb-4'
|
extraClassName='mb-4'
|
||||||
|
autoFocus={false}
|
||||||
|
blurOnSubmit={true}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<View className='mt-4'>
|
<View className='mt-4'>
|
||||||
<Button onPress={handleLogin}>{t("login.login_button")}</Button>
|
<Button
|
||||||
|
onPress={handleLogin}
|
||||||
|
disabled={!credentials.username.trim()}
|
||||||
|
>
|
||||||
|
{t("login.login_button")}
|
||||||
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
<View className='mt-3'>
|
<View className='mt-3'>
|
||||||
<Button
|
<Button
|
||||||
@@ -334,6 +355,8 @@ const Login: React.FC = () => {
|
|||||||
autoCapitalize='none'
|
autoCapitalize='none'
|
||||||
textContentType='URL'
|
textContentType='URL'
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
|
autoFocus={false}
|
||||||
|
blurOnSubmit={true}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Full-width primary button */}
|
{/* Full-width primary button */}
|
||||||
@@ -371,10 +394,11 @@ const Login: React.FC = () => {
|
|||||||
// Mobile layout
|
// Mobile layout
|
||||||
<SafeAreaView style={{ flex: 1, paddingBottom: 16 }}>
|
<SafeAreaView style={{ flex: 1, paddingBottom: 16 }}>
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||||
|
style={{ flex: 1 }}
|
||||||
>
|
>
|
||||||
{api?.basePath ? (
|
{api?.basePath ? (
|
||||||
<View className='flex flex-col h-full relative items-center justify-center'>
|
<View className='flex flex-col flex-1 items-center justify-center'>
|
||||||
<View className='px-4 -mt-20 w-full'>
|
<View className='px-4 -mt-20 w-full'>
|
||||||
<View className='flex flex-col space-y-2'>
|
<View className='flex flex-col space-y-2'>
|
||||||
<Text className='text-2xl font-bold -mb-2'>
|
<Text className='text-2xl font-bold -mb-2'>
|
||||||
@@ -393,6 +417,12 @@ const Login: React.FC = () => {
|
|||||||
onChangeText={(text) =>
|
onChangeText={(text) =>
|
||||||
setCredentials({ ...credentials, username: text })
|
setCredentials({ ...credentials, username: text })
|
||||||
}
|
}
|
||||||
|
onEndEditing={(e) => {
|
||||||
|
const newValue = e.nativeEvent.text;
|
||||||
|
if (newValue && newValue !== credentials.username) {
|
||||||
|
setCredentials({ ...credentials, username: newValue });
|
||||||
|
}
|
||||||
|
}}
|
||||||
value={credentials.username}
|
value={credentials.username}
|
||||||
keyboardType='default'
|
keyboardType='default'
|
||||||
returnKeyType='done'
|
returnKeyType='done'
|
||||||
@@ -409,6 +439,12 @@ const Login: React.FC = () => {
|
|||||||
onChangeText={(text) =>
|
onChangeText={(text) =>
|
||||||
setCredentials({ ...credentials, password: text })
|
setCredentials({ ...credentials, password: text })
|
||||||
}
|
}
|
||||||
|
onEndEditing={(e) => {
|
||||||
|
const newValue = e.nativeEvent.text;
|
||||||
|
if (newValue && newValue !== credentials.password) {
|
||||||
|
setCredentials({ ...credentials, password: newValue });
|
||||||
|
}
|
||||||
|
}}
|
||||||
value={credentials.password}
|
value={credentials.password}
|
||||||
secureTextEntry
|
secureTextEntry
|
||||||
keyboardType='default'
|
keyboardType='default'
|
||||||
@@ -422,6 +458,7 @@ const Login: React.FC = () => {
|
|||||||
<Button
|
<Button
|
||||||
onPress={handleLogin}
|
onPress={handleLogin}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
disabled={!credentials.username.trim()}
|
||||||
className='flex-1 mr-2'
|
className='flex-1 mr-2'
|
||||||
>
|
>
|
||||||
{t("login.login_button")}
|
{t("login.login_button")}
|
||||||
@@ -443,7 +480,7 @@ const Login: React.FC = () => {
|
|||||||
<View className='absolute bottom-0 left-0 w-full px-4 mb-2' />
|
<View className='absolute bottom-0 left-0 w-full px-4 mb-2' />
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View className='flex flex-col h-full items-center justify-center w-full'>
|
<View className='flex flex-col flex-1 items-center justify-center w-full'>
|
||||||
<View className='flex flex-col gap-y-2 px-4 w-full -mt-36'>
|
<View className='flex flex-col gap-y-2 px-4 w-full -mt-36'>
|
||||||
<Image
|
<Image
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
BIN
assets/Download_on_Github_.png
Normal file
BIN
assets/Download_on_Github_.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.5 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 384 415" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||||
|
<g transform="matrix(0.133333,0,0,-0.133333,-110.933,512.698)">
|
||||||
|
<g id="g10">
|
||||||
|
<path id="path88" d="M3547.01,1831.49C3493.38,1822.66 3262.53,1779.28 2992.01,1820.24C2424.16,1906.21 2154.85,2275.8 1882,2420.24C1473.31,2636.6 1060.97,2644.95 832,2592.03L832,1445.92C832,1321.76 863.078,1198.06 925.307,1090.27C1057.09,862.011 1323.38,718.405 1586.6,736.145C1695.48,743.482 1801.3,777.735 1895.64,832.199L3357.51,1676.21C3424.47,1714.87 3482.92,1761.76 3532.01,1815.41L3547.01,1831.49Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(2879.19,0,0,2879.19,832.651,2289.93)"><stop offset="0" style="stop-color:rgb(149,41,235);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(98,22,247);stop-opacity:1"/></linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 384 415" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||||
|
<g transform="matrix(0.133333,0,0,-0.133333,-110.933,512.698)">
|
||||||
|
<g id="g10">
|
||||||
|
<path id="path66" d="M3357.51,2903.64L1895.64,3747.65C1670.29,3877.76 1412.33,3877.76 1186.98,3747.65C961.629,3617.55 832.648,3394.14 832.648,3133.93L832.648,1445.92C832.648,1185.71 961.629,962.305 1186.98,832.199C1412.33,702.094 1670.29,702.094 1895.64,832.199L3357.51,1676.21C3582.86,1806.31 3711.84,2029.71 3711.84,2289.93C3711.84,2550.14 3582.86,2773.54 3357.51,2903.64ZM1721.48,3213.68L3098.31,2454.7C3163.9,2418.55 3193.45,2364.85 3193.45,2289.93C3193.45,2215 3163.93,2161.32 3098.31,2125.15L1721.48,1366.18C1655.87,1330.01 1596.09,1328.72 1531.21,1366.18C1466.34,1403.63 1436.08,1456.03 1436.08,1530.96L1436.08,3048.89C1436.08,3123.77 1466.35,3176.23 1531.21,3213.68C1596.08,3251.11 1655.89,3249.83 1721.48,3213.68" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(2879.19,0,0,2879.19,832.651,2289.93)"><stop offset="0" style="stop-color:rgb(188,74,241);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(227,105,219);stop-opacity:1"/></linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 384 415" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||||
|
<g>
|
||||||
|
<g id="g10">
|
||||||
|
<path id="path88" d="M0,319.909L0,234C17.667,234.844 138.649,236.708 195,190C220.441,168.912 271.21,169.515 294.001,178.788C332.576,194.487 378.643,259.549 360,270.644C353.455,277.797 345.662,284.049 336.734,289.204L141.818,401.738C129.24,409 115.13,413.567 100.613,414.546C65.517,416.911 30.012,397.763 12.441,367.329C4.144,352.957 0,336.464 0,319.909Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(2879.19,0,0,2879.19,832.651,2289.93)"><stop offset="0" style="stop-color:rgb(225,102,222);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(204,88,233);stop-opacity:1"/></linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 384 415" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||||
|
<g transform="matrix(0.133333,0,0,-0.133333,-110.933,512.698)">
|
||||||
|
<g id="g10">
|
||||||
|
<path id="path28" d="M1427.29,1523.37C1427.29,1447.7 1457.85,1394.77 1523.38,1356.94C1588.91,1319.11 1649.28,1320.41 1715.55,1356.94L3106.14,2123.5C3172.42,2160.03 3202.24,2214.25 3202.24,2289.93C3202.24,2365.6 3172.39,2419.83 3106.14,2456.35L1715.55,3222.91C1649.31,3259.43 1588.89,3260.73 1523.38,3222.91C1457.87,3185.1 1427.29,3132.11 1427.29,3056.48L1427.29,1523.37" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(1.17673e-13,-1921.74,1921.74,1.17673e-13,2314.76,3250.79)"><stop offset="0" style="stop-color:rgb(93,17,250);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(143,40,236);stop-opacity:1"/></linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
184
assets/images/icon-ios-liquid-glass.icon/icon.json
Normal file
184
assets/images/icon-ios-liquid-glass.icon/icon.json
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
{
|
||||||
|
"fill": {
|
||||||
|
"solid": "display-p3:0.18039,0.18039,0.18039,1.00000"
|
||||||
|
},
|
||||||
|
"groups": [
|
||||||
|
{
|
||||||
|
"blur-material": 0.3,
|
||||||
|
"layers": [
|
||||||
|
{
|
||||||
|
"fill-specializations": [
|
||||||
|
{
|
||||||
|
"value": "none"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearance": "tinted",
|
||||||
|
"value": {
|
||||||
|
"automatic-gradient": "display-p3:0.76482,0.76482,0.76482,0.84903"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"glass": true,
|
||||||
|
"hidden": false,
|
||||||
|
"image-name": "streamyfin_logo_layer1.svg",
|
||||||
|
"name": "streamyfin_logo_layer1"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"opacity": 1,
|
||||||
|
"position": {
|
||||||
|
"scale": 1.7,
|
||||||
|
"translation-in-points": [30, 0]
|
||||||
|
},
|
||||||
|
"shadow": {
|
||||||
|
"kind": "none",
|
||||||
|
"opacity": 1
|
||||||
|
},
|
||||||
|
"specular": true,
|
||||||
|
"translucency": {
|
||||||
|
"enabled": true,
|
||||||
|
"value": 0.6
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"blend-mode": "normal",
|
||||||
|
"blur-material": 0.8,
|
||||||
|
"hidden": false,
|
||||||
|
"layers": [
|
||||||
|
{
|
||||||
|
"blend-mode": "normal",
|
||||||
|
"fill-specializations": [
|
||||||
|
{
|
||||||
|
"value": "none"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearance": "tinted",
|
||||||
|
"value": {
|
||||||
|
"automatic-gradient": "gray:0.75000,1.00000"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"hidden": false,
|
||||||
|
"image-name": "streamyfin_logo_layer2.svg",
|
||||||
|
"name": "streamyfin_logo_layer2",
|
||||||
|
"opacity": 1,
|
||||||
|
"position": {
|
||||||
|
"scale": 1,
|
||||||
|
"translation-in-points": [0, 0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"lighting": "individual",
|
||||||
|
"name": "Group",
|
||||||
|
"opacity": 1,
|
||||||
|
"position": {
|
||||||
|
"scale": 1.7,
|
||||||
|
"translation-in-points": [30, -0.01613253252572302]
|
||||||
|
},
|
||||||
|
"shadow": {
|
||||||
|
"kind": "layer-color",
|
||||||
|
"opacity": 0.35
|
||||||
|
},
|
||||||
|
"specular": true,
|
||||||
|
"translucency-specializations": [
|
||||||
|
{
|
||||||
|
"value": {
|
||||||
|
"enabled": true,
|
||||||
|
"value": 0.5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearance": "tinted",
|
||||||
|
"value": {
|
||||||
|
"enabled": true,
|
||||||
|
"value": 0.8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"blend-mode": "normal",
|
||||||
|
"blur-material": 0.5,
|
||||||
|
"layers": [
|
||||||
|
{
|
||||||
|
"fill-specializations": [
|
||||||
|
{
|
||||||
|
"appearance": "tinted",
|
||||||
|
"value": {
|
||||||
|
"automatic-gradient": "gray:0.29000,1.00000"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"glass": true,
|
||||||
|
"hidden": false,
|
||||||
|
"image-name": "streamyfin_logo_layer3.svg",
|
||||||
|
"name": "streamyfin_logo_layer3",
|
||||||
|
"opacity": 0.9
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"name": "Group",
|
||||||
|
"opacity": 0.8,
|
||||||
|
"position": {
|
||||||
|
"scale": 1.7,
|
||||||
|
"translation-in-points": [30, 0]
|
||||||
|
},
|
||||||
|
"shadow": {
|
||||||
|
"kind": "none",
|
||||||
|
"opacity": 0.5
|
||||||
|
},
|
||||||
|
"specular": true,
|
||||||
|
"translucency": {
|
||||||
|
"enabled": true,
|
||||||
|
"value": 0.7
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"blur-material": 0.5,
|
||||||
|
"hidden": false,
|
||||||
|
"layers": [
|
||||||
|
{
|
||||||
|
"glass": true,
|
||||||
|
"hidden-specializations": [
|
||||||
|
{
|
||||||
|
"value": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearance": "tinted",
|
||||||
|
"value": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"image-name": "streamyfin_logo_layer4.svg",
|
||||||
|
"name": "streamyfin_logo_layer4",
|
||||||
|
"opacity-specializations": [
|
||||||
|
{
|
||||||
|
"value": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearance": "tinted",
|
||||||
|
"value": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"lighting": "combined",
|
||||||
|
"name": "Group",
|
||||||
|
"opacity": 0.9,
|
||||||
|
"position": {
|
||||||
|
"scale": 1.7,
|
||||||
|
"translation-in-points": [30, 0]
|
||||||
|
},
|
||||||
|
"shadow": {
|
||||||
|
"kind": "neutral",
|
||||||
|
"opacity": 0.5
|
||||||
|
},
|
||||||
|
"specular": false,
|
||||||
|
"translucency": {
|
||||||
|
"enabled": true,
|
||||||
|
"value": 0.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"supported-platforms": {
|
||||||
|
"circles": ["watchOS"],
|
||||||
|
"squares": "shared"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { MMKV } from "react-native-mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
|
|
||||||
declare module "react-native-mmkv" {
|
declare module "react-native-mmkv" {
|
||||||
interface MMKV {
|
interface MMKV {
|
||||||
@@ -9,7 +9,7 @@ declare module "react-native-mmkv" {
|
|||||||
|
|
||||||
// Add the augmentation methods directly to the MMKV prototype
|
// Add the augmentation methods directly to the MMKV prototype
|
||||||
// This follows the recommended pattern while adding the helper methods your app uses
|
// This follows the recommended pattern while adding the helper methods your app uses
|
||||||
MMKV.prototype.get = function <T>(key: string): T | undefined {
|
(storage as any).get = function <T>(key: string): T | undefined {
|
||||||
try {
|
try {
|
||||||
const serializedItem = this.getString(key);
|
const serializedItem = this.getString(key);
|
||||||
if (!serializedItem) return undefined;
|
if (!serializedItem) return undefined;
|
||||||
@@ -20,10 +20,10 @@ MMKV.prototype.get = function <T>(key: string): T | undefined {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
MMKV.prototype.setAny = function (key: string, value: any | undefined): void {
|
(storage as any).setAny = function (key: string, value: any | undefined): void {
|
||||||
try {
|
try {
|
||||||
if (value === undefined) {
|
if (value === undefined) {
|
||||||
this.delete(key);
|
this.remove(key);
|
||||||
} else {
|
} else {
|
||||||
this.set(key, JSON.stringify(value));
|
this.set(key, JSON.stringify(value));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,6 @@ module.exports = (api) => {
|
|||||||
api.cache(true);
|
api.cache(true);
|
||||||
return {
|
return {
|
||||||
presets: ["babel-preset-expo"],
|
presets: ["babel-preset-expo"],
|
||||||
plugins: ["nativewind/babel", "react-native-reanimated/plugin"],
|
plugins: ["nativewind/babel", "react-native-worklets/plugin"],
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.2.5/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.3.5/schema.json",
|
||||||
"files": {
|
"files": {
|
||||||
"includes": [
|
"includes": [
|
||||||
"**/*",
|
"**/*",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { Platform, View, type ViewProps } from "react-native";
|
import { View, type ViewProps } from "react-native";
|
||||||
import { RoundButton } from "@/components/RoundButton";
|
import { RoundButton } from "@/components/RoundButton";
|
||||||
import { useFavorite } from "@/hooks/useFavorite";
|
import { useFavorite } from "@/hooks/useFavorite";
|
||||||
|
|
||||||
@@ -11,24 +11,11 @@ interface Props extends ViewProps {
|
|||||||
export const AddToFavorites: FC<Props> = ({ item, ...props }) => {
|
export const AddToFavorites: FC<Props> = ({ item, ...props }) => {
|
||||||
const { isFavorite, toggleFavorite } = useFavorite(item);
|
const { isFavorite, toggleFavorite } = useFavorite(item);
|
||||||
|
|
||||||
if (Platform.OS === "ios") {
|
|
||||||
return (
|
|
||||||
<View {...props}>
|
|
||||||
<RoundButton
|
|
||||||
size='large'
|
|
||||||
icon={isFavorite ? "heart" : "heart-outline"}
|
|
||||||
onPress={toggleFavorite}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<RoundButton
|
<RoundButton
|
||||||
size='large'
|
size='large'
|
||||||
icon={isFavorite ? "heart" : "heart-outline"}
|
icon={isFavorite ? "heart" : "heart-outline"}
|
||||||
fillColor={isFavorite ? "primary" : undefined}
|
|
||||||
onPress={toggleFavorite}
|
onPress={toggleFavorite}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
|
||||||
|
|
||||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
|
||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof View> {
|
interface Props extends React.ComponentProps<typeof View> {
|
||||||
source?: MediaSourceInfo;
|
source?: MediaSourceInfo;
|
||||||
@@ -20,6 +18,8 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
|||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const isTv = Platform.isTV;
|
const isTv = Platform.isTV;
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const audioStreams = useMemo(
|
const audioStreams = useMemo(
|
||||||
() => source?.MediaStreams?.filter((x) => x.Type === "Audio"),
|
() => source?.MediaStreams?.filter((x) => x.Type === "Audio"),
|
||||||
@@ -31,55 +31,58 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
|||||||
[audioStreams, selected],
|
[audioStreams, selected],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const optionGroups: OptionGroup[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
options:
|
||||||
|
audioStreams?.map((audio, idx) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: audio.DisplayTitle || `Audio Stream ${idx + 1}`,
|
||||||
|
value: audio.Index ?? idx,
|
||||||
|
selected: audio.Index === selected,
|
||||||
|
onPress: () => {
|
||||||
|
if (audio.Index !== null && audio.Index !== undefined) {
|
||||||
|
onChange(audio.Index);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})) || [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[audioStreams, selected, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOptionSelect = () => {
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const trigger = (
|
||||||
|
<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'
|
||||||
|
onPress={() => setOpen(true)}
|
||||||
|
>
|
||||||
|
<Text numberOfLines={1}>{selectedAudioSteam?.DisplayTitle}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
if (isTv) return null;
|
if (isTv) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<PlatformDropdown
|
||||||
className='flex shrink'
|
groups={optionGroups}
|
||||||
style={{
|
trigger={trigger}
|
||||||
minWidth: 50,
|
title={t("item_card.audio")}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
onOptionSelect={handleOptionSelect}
|
||||||
|
expoUIConfig={{
|
||||||
|
hostStyle: { flex: 1 },
|
||||||
}}
|
}}
|
||||||
>
|
bottomSheetConfig={{
|
||||||
<DropdownMenu.Root>
|
enablePanDownToClose: true,
|
||||||
<DropdownMenu.Trigger>
|
}}
|
||||||
<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}>
|
|
||||||
{selectedAudioSteam?.DisplayTitle}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
loop={true}
|
|
||||||
side='bottom'
|
|
||||||
align='start'
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={8}
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Label>Audio streams</DropdownMenu.Label>
|
|
||||||
{audioStreams?.map((audio, idx: number) => (
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={idx.toString()}
|
|
||||||
onSelect={() => {
|
|
||||||
if (audio.Index !== null && audio.Index !== undefined)
|
|
||||||
onChange(audio.Index);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>
|
|
||||||
{audio.DisplayTitle}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
))}
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
|
||||||
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||||
|
|
||||||
export type Bitrate = {
|
export type Bitrate = {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -61,6 +59,8 @@ export const BitrateSelector: React.FC<Props> = ({
|
|||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const isTv = Platform.isTV;
|
const isTv = Platform.isTV;
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const sorted = useMemo(() => {
|
const sorted = useMemo(() => {
|
||||||
if (inverted)
|
if (inverted)
|
||||||
@@ -76,53 +76,59 @@ export const BitrateSelector: React.FC<Props> = ({
|
|||||||
);
|
);
|
||||||
}, [inverted]);
|
}, [inverted]);
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const optionGroups: OptionGroup[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
options: sorted.map((bitrate) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: bitrate.key,
|
||||||
|
value: bitrate,
|
||||||
|
selected: bitrate.value === selected?.value,
|
||||||
|
onPress: () => onChange(bitrate),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[sorted, selected, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOptionSelect = (optionId: string) => {
|
||||||
|
const selectedBitrate = sorted.find((b) => b.key === optionId);
|
||||||
|
if (selectedBitrate) {
|
||||||
|
onChange(selectedBitrate);
|
||||||
|
}
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const trigger = (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
|
||||||
if (isTv) return null;
|
if (isTv) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<PlatformDropdown
|
||||||
className='flex shrink'
|
groups={optionGroups}
|
||||||
style={{
|
trigger={trigger}
|
||||||
minWidth: 60,
|
title={t("item_card.quality")}
|
||||||
maxWidth: 200,
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
onOptionSelect={handleOptionSelect}
|
||||||
|
expoUIConfig={{
|
||||||
|
hostStyle: { flex: 1 },
|
||||||
}}
|
}}
|
||||||
>
|
bottomSheetConfig={{
|
||||||
<DropdownMenu.Root>
|
enablePanDownToClose: true,
|
||||||
<DropdownMenu.Trigger>
|
}}
|
||||||
<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}>
|
|
||||||
{BITRATES.find((b) => b.value === selected?.value)?.key}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
loop={false}
|
|
||||||
side='bottom'
|
|
||||||
align='center'
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={0}
|
|
||||||
sideOffset={0}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Label>Bitrates</DropdownMenu.Label>
|
|
||||||
{sorted.map((b) => (
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={b.key}
|
|
||||||
onSelect={() => {
|
|
||||||
onChange(b);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>{b.key}</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
))}
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -64,9 +64,8 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const [downloadUnwatchedOnly, setDownloadUnwatchedOnly] = useState(false);
|
const [downloadUnwatchedOnly, setDownloadUnwatchedOnly] = useState(false);
|
||||||
|
|
||||||
const { processes, startBackgroundDownload, getDownloadedItems } =
|
const { processes, startBackgroundDownload, downloadedItems } = useDownload();
|
||||||
useDownload();
|
const downloadedFiles = downloadedItems;
|
||||||
const downloadedFiles = getDownloadedItems();
|
|
||||||
|
|
||||||
const [selectedOptions, setSelectedOptions] = useState<
|
const [selectedOptions, setSelectedOptions] = useState<
|
||||||
SelectedOptions | undefined
|
SelectedOptions | undefined
|
||||||
@@ -90,11 +89,8 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
bottomSheetModalRef.current?.present();
|
bottomSheetModalRef.current?.present();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSheetChanges = useCallback((index: number) => {
|
const handleSheetChanges = useCallback((_index: number) => {
|
||||||
// Ensure modal is fully dismissed when index is -1
|
// Modal state tracking handled by BottomSheetModal
|
||||||
if (index === -1) {
|
|
||||||
// Modal is fully closed
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const closeModal = useCallback(() => {
|
const closeModal = useCallback(() => {
|
||||||
@@ -136,13 +132,15 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
return itemsNotDownloaded.length === 0;
|
return itemsNotDownloaded.length === 0;
|
||||||
}, [items, itemsNotDownloaded]);
|
}, [items, itemsNotDownloaded]);
|
||||||
const itemsProcesses = useMemo(
|
const itemsProcesses = useMemo(
|
||||||
() => processes?.filter((p) => itemIds.includes(p.item.Id)),
|
() =>
|
||||||
|
processes?.filter((p) => p?.item?.Id && itemIds.includes(p.item.Id)) ||
|
||||||
|
[],
|
||||||
[processes, itemIds],
|
[processes, itemIds],
|
||||||
);
|
);
|
||||||
|
|
||||||
const progress = useMemo(() => {
|
const progress = useMemo(() => {
|
||||||
if (itemIds.length === 1)
|
if (itemIds.length === 1)
|
||||||
return itemsProcesses.reduce((acc, p) => acc + p.progress, 0);
|
return itemsProcesses.reduce((acc, p) => acc + (p.progress || 0), 0);
|
||||||
return (
|
return (
|
||||||
((itemIds.length -
|
((itemIds.length -
|
||||||
queue.filter((q) => itemIds.includes(q.item.Id)).length) /
|
queue.filter((q) => itemIds.includes(q.item.Id)).length) /
|
||||||
@@ -157,6 +155,13 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
itemsNotDownloaded.every((p) => queue.some((q) => p.Id === q.item.Id))
|
itemsNotDownloaded.every((p) => queue.some((q) => p.Id === q.item.Id))
|
||||||
);
|
);
|
||||||
}, [queue, itemsNotDownloaded]);
|
}, [queue, itemsNotDownloaded]);
|
||||||
|
|
||||||
|
const itemsInProgressOrQueued = useMemo(() => {
|
||||||
|
const inProgress = itemsProcesses.length;
|
||||||
|
const inQueue = queue.filter((q) => itemIds.includes(q.item.Id)).length;
|
||||||
|
return inProgress + inQueue;
|
||||||
|
}, [itemsProcesses, queue, itemIds]);
|
||||||
|
|
||||||
const navigateToDownloads = () => router.push("/downloads");
|
const navigateToDownloads = () => router.push("/downloads");
|
||||||
|
|
||||||
const onDownloadedPress = () => {
|
const onDownloadedPress = () => {
|
||||||
@@ -256,13 +261,12 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
throw new Error("No item id");
|
throw new Error("No item id");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure modal is dismissed before starting download
|
closeModal();
|
||||||
await closeModal();
|
|
||||||
|
|
||||||
// Small delay to ensure modal is fully dismissed
|
// Wait for modal dismiss animation to complete
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
initiateDownload(...itemsToDownload);
|
initiateDownload(...itemsToDownload);
|
||||||
}, 100);
|
}, 300);
|
||||||
} else {
|
} else {
|
||||||
toast.error(
|
toast.error(
|
||||||
t("home.downloads.toasts.you_are_not_allowed_to_download_files"),
|
t("home.downloads.toasts.you_are_not_allowed_to_download_files"),
|
||||||
@@ -282,7 +286,14 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const renderButtonContent = () => {
|
const renderButtonContent = () => {
|
||||||
if (processes.length > 0 && itemsProcesses.length > 0) {
|
// For single item downloads, show progress if item is being processed
|
||||||
|
// For multi-item downloads (season/series), show progress only if 2+ items are in progress or queued
|
||||||
|
const shouldShowProgress =
|
||||||
|
itemIds.length === 1
|
||||||
|
? itemsProcesses.length > 0
|
||||||
|
: itemsInProgressOrQueued > 1;
|
||||||
|
|
||||||
|
if (processes.length > 0 && shouldShowProgress) {
|
||||||
return progress === 0 ? (
|
return progress === 0 ? (
|
||||||
<Loader />
|
<Loader />
|
||||||
) : (
|
) : (
|
||||||
@@ -336,9 +347,6 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
backgroundColor: "#171717",
|
backgroundColor: "#171717",
|
||||||
}}
|
}}
|
||||||
onChange={handleSheetChanges}
|
onChange={handleSheetChanges}
|
||||||
onDismiss={() => {
|
|
||||||
// Ensure any pending state is cleared when modal is dismissed
|
|
||||||
}}
|
|
||||||
backdropComponent={renderBackdrop}
|
backdropComponent={renderBackdrop}
|
||||||
enablePanDownToClose
|
enablePanDownToClose
|
||||||
enableDismissOnClose
|
enableDismissOnClose
|
||||||
@@ -359,16 +367,18 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className='flex flex-col space-y-2 w-full items-start'>
|
<View className='flex flex-col space-y-2 w-full'>
|
||||||
<BitrateSelector
|
<View className='items-start'>
|
||||||
inverted
|
<BitrateSelector
|
||||||
onChange={(val) =>
|
inverted
|
||||||
setSelectedOptions(
|
onChange={(val) =>
|
||||||
(prev) => prev && { ...prev, bitrate: val },
|
setSelectedOptions(
|
||||||
)
|
(prev) => prev && { ...prev, bitrate: val },
|
||||||
}
|
)
|
||||||
selected={selectedOptions?.bitrate}
|
}
|
||||||
/>
|
selected={selectedOptions?.bitrate}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
{itemsNotDownloaded.length > 1 && (
|
{itemsNotDownloaded.length > 1 && (
|
||||||
<View className='flex flex-row items-center justify-between w-full py-2'>
|
<View className='flex flex-row items-center justify-between w-full py-2'>
|
||||||
<Text>{t("item_card.download.download_unwatched_only")}</Text>
|
<Text>{t("item_card.download.download_unwatched_only")}</Text>
|
||||||
@@ -380,21 +390,23 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
)}
|
)}
|
||||||
{itemsNotDownloaded.length === 1 && (
|
{itemsNotDownloaded.length === 1 && (
|
||||||
<View>
|
<View>
|
||||||
<MediaSourceSelector
|
<View className='items-start'>
|
||||||
item={items[0]}
|
<MediaSourceSelector
|
||||||
onChange={(val) =>
|
item={items[0]}
|
||||||
setSelectedOptions(
|
onChange={(val) =>
|
||||||
(prev) =>
|
setSelectedOptions(
|
||||||
prev && {
|
(prev) =>
|
||||||
...prev,
|
prev && {
|
||||||
mediaSource: val,
|
...prev,
|
||||||
},
|
mediaSource: val,
|
||||||
)
|
},
|
||||||
}
|
)
|
||||||
selected={selectedOptions?.mediaSource}
|
}
|
||||||
/>
|
selected={selectedOptions?.mediaSource}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
{selectedOptions?.mediaSource && (
|
{selectedOptions?.mediaSource && (
|
||||||
<View className='flex flex-col space-y-2'>
|
<View className='flex flex-col space-y-2 items-start'>
|
||||||
<AudioTrackSelector
|
<AudioTrackSelector
|
||||||
source={selectedOptions.mediaSource}
|
source={selectedOptions.mediaSource}
|
||||||
onChange={(val) => {
|
onChange={(val) => {
|
||||||
@@ -427,11 +439,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Button
|
<Button onPress={acceptDownloadOptions} color='purple'>
|
||||||
className='mt-auto'
|
|
||||||
onPress={acceptDownloadOptions}
|
|
||||||
color='purple'
|
|
||||||
>
|
|
||||||
{t("item_card.download.download_button")}
|
{t("item_card.download.download_button")}
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
203
components/ExampleGlobalModalUsage.tsx
Normal file
203
components/ExampleGlobalModalUsage.tsx
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
/**
|
||||||
|
* Example Usage of Global Modal
|
||||||
|
*
|
||||||
|
* This file demonstrates how to use the global modal system from anywhere in your app.
|
||||||
|
* You can delete this file after understanding how it works.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { TouchableOpacity, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example 1: Simple Content Modal
|
||||||
|
*/
|
||||||
|
export const SimpleModalExample = () => {
|
||||||
|
const { showModal } = useGlobalModal();
|
||||||
|
|
||||||
|
const handleOpenModal = () => {
|
||||||
|
showModal(
|
||||||
|
<View className='p-6'>
|
||||||
|
<Text className='text-2xl font-bold mb-4 text-white'>Simple Modal</Text>
|
||||||
|
<Text className='text-white mb-4'>
|
||||||
|
This is a simple modal with just some text content.
|
||||||
|
</Text>
|
||||||
|
<Text className='text-neutral-400'>
|
||||||
|
Swipe down or tap outside to close.
|
||||||
|
</Text>
|
||||||
|
</View>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleOpenModal}
|
||||||
|
className='bg-purple-600 px-4 py-2 rounded-lg'
|
||||||
|
>
|
||||||
|
<Text className='text-white font-semibold'>Open Simple Modal</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example 2: Modal with Custom Snap Points
|
||||||
|
*/
|
||||||
|
export const CustomSnapPointsExample = () => {
|
||||||
|
const { showModal } = useGlobalModal();
|
||||||
|
|
||||||
|
const handleOpenModal = () => {
|
||||||
|
showModal(
|
||||||
|
<View className='p-6' style={{ minHeight: 400 }}>
|
||||||
|
<Text className='text-2xl font-bold mb-4 text-white'>
|
||||||
|
Custom Snap Points
|
||||||
|
</Text>
|
||||||
|
<Text className='text-white mb-4'>
|
||||||
|
This modal has custom snap points (25%, 50%, 90%).
|
||||||
|
</Text>
|
||||||
|
<View className='bg-neutral-800 p-4 rounded-lg'>
|
||||||
|
<Text className='text-white'>
|
||||||
|
Try dragging the modal to different heights!
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>,
|
||||||
|
{
|
||||||
|
snapPoints: ["25%", "50%", "90%"],
|
||||||
|
enableDynamicSizing: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleOpenModal}
|
||||||
|
className='bg-blue-600 px-4 py-2 rounded-lg'
|
||||||
|
>
|
||||||
|
<Text className='text-white font-semibold'>Custom Snap Points</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example 3: Complex Component in Modal
|
||||||
|
*/
|
||||||
|
const SettingsModalContent = () => {
|
||||||
|
const { hideModal } = useGlobalModal();
|
||||||
|
|
||||||
|
const settings = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: "Notifications",
|
||||||
|
icon: "notifications-outline" as const,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{ id: 2, title: "Dark Mode", icon: "moon-outline" as const, enabled: true },
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: "Auto-play",
|
||||||
|
icon: "play-outline" as const,
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='p-6'>
|
||||||
|
<Text className='text-2xl font-bold mb-6 text-white'>Settings</Text>
|
||||||
|
|
||||||
|
{settings.map((setting, index) => (
|
||||||
|
<View
|
||||||
|
key={setting.id}
|
||||||
|
className={`flex-row items-center justify-between py-4 ${
|
||||||
|
index !== settings.length - 1 ? "border-b border-neutral-700" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<View className='flex-row items-center gap-3'>
|
||||||
|
<Ionicons name={setting.icon} size={24} color='white' />
|
||||||
|
<Text className='text-white text-lg'>{setting.title}</Text>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
className={`w-12 h-7 rounded-full ${
|
||||||
|
setting.enabled ? "bg-purple-600" : "bg-neutral-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className={`w-5 h-5 rounded-full bg-white shadow-md transform ${
|
||||||
|
setting.enabled ? "translate-x-6" : "translate-x-1"
|
||||||
|
}`}
|
||||||
|
style={{ marginTop: 4 }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={hideModal}
|
||||||
|
className='bg-purple-600 px-4 py-3 rounded-lg mt-6'
|
||||||
|
>
|
||||||
|
<Text className='text-white font-semibold text-center'>Close</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ComplexModalExample = () => {
|
||||||
|
const { showModal } = useGlobalModal();
|
||||||
|
|
||||||
|
const handleOpenModal = () => {
|
||||||
|
showModal(<SettingsModalContent />);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleOpenModal}
|
||||||
|
className='bg-green-600 px-4 py-2 rounded-lg'
|
||||||
|
>
|
||||||
|
<Text className='text-white font-semibold'>Complex Component</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example 4: Modal Triggered from Function (e.g., API response)
|
||||||
|
*/
|
||||||
|
export const useShowSuccessModal = () => {
|
||||||
|
const { showModal } = useGlobalModal();
|
||||||
|
|
||||||
|
return (message: string) => {
|
||||||
|
showModal(
|
||||||
|
<View className='p-6 items-center'>
|
||||||
|
<View className='bg-green-500 rounded-full p-4 mb-4'>
|
||||||
|
<Ionicons name='checkmark' size={48} color='white' />
|
||||||
|
</View>
|
||||||
|
<Text className='text-2xl font-bold mb-2 text-white'>Success!</Text>
|
||||||
|
<Text className='text-white text-center'>{message}</Text>
|
||||||
|
</View>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main Demo Component
|
||||||
|
*/
|
||||||
|
export const GlobalModalDemo = () => {
|
||||||
|
const showSuccess = useShowSuccessModal();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='p-6 gap-4'>
|
||||||
|
<Text className='text-2xl font-bold mb-4 text-white'>
|
||||||
|
Global Modal Examples
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<SimpleModalExample />
|
||||||
|
<CustomSnapPointsExample />
|
||||||
|
<ComplexModalExample />
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => showSuccess("Operation completed successfully!")}
|
||||||
|
className='bg-orange-600 px-4 py-2 rounded-lg'
|
||||||
|
>
|
||||||
|
<Text className='text-white font-semibold'>Show Success Modal</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
73
components/GlobalModal.tsx
Normal file
73
components/GlobalModal.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import {
|
||||||
|
BottomSheetBackdrop,
|
||||||
|
type BottomSheetBackdropProps,
|
||||||
|
BottomSheetModal,
|
||||||
|
} from "@gorhom/bottom-sheet";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GlobalModal Component
|
||||||
|
*
|
||||||
|
* This component renders a global bottom sheet modal that can be controlled
|
||||||
|
* from anywhere in the app using the useGlobalModal hook.
|
||||||
|
*
|
||||||
|
* Place this component at the root level of your app (in _layout.tsx)
|
||||||
|
* after BottomSheetModalProvider.
|
||||||
|
*/
|
||||||
|
export const GlobalModal = () => {
|
||||||
|
const { hideModal, modalState, modalRef } = useGlobalModal();
|
||||||
|
|
||||||
|
const handleSheetChanges = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
if (index === -1) {
|
||||||
|
hideModal();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[hideModal],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderBackdrop = useCallback(
|
||||||
|
(props: BottomSheetBackdropProps) => (
|
||||||
|
<BottomSheetBackdrop
|
||||||
|
{...props}
|
||||||
|
disappearsOnIndex={-1}
|
||||||
|
appearsOnIndex={0}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const defaultOptions = {
|
||||||
|
enableDynamicSizing: true,
|
||||||
|
enablePanDownToClose: true,
|
||||||
|
backgroundStyle: {
|
||||||
|
backgroundColor: "#171717",
|
||||||
|
},
|
||||||
|
handleIndicatorStyle: {
|
||||||
|
backgroundColor: "white",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Merge default options with provided options
|
||||||
|
const modalOptions = { ...defaultOptions, ...modalState.options };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BottomSheetModal
|
||||||
|
ref={modalRef}
|
||||||
|
{...(modalOptions.snapPoints
|
||||||
|
? { snapPoints: modalOptions.snapPoints }
|
||||||
|
: { enableDynamicSizing: modalOptions.enableDynamicSizing })}
|
||||||
|
onChange={handleSheetChanges}
|
||||||
|
backdropComponent={renderBackdrop}
|
||||||
|
handleIndicatorStyle={modalOptions.handleIndicatorStyle}
|
||||||
|
backgroundStyle={modalOptions.backgroundStyle}
|
||||||
|
enablePanDownToClose={modalOptions.enablePanDownToClose}
|
||||||
|
enableDismissOnClose
|
||||||
|
stackBehavior='push'
|
||||||
|
style={{ zIndex: 1000 }}
|
||||||
|
>
|
||||||
|
{modalState.content}
|
||||||
|
</BottomSheetModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -204,7 +204,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
<View className='flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink'>
|
<View className='flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink'>
|
||||||
<ItemHeader item={item} className='mb-2' />
|
<ItemHeader item={item} className='mb-2' />
|
||||||
{item.Type !== "Program" && !Platform.isTV && !isOffline && (
|
{item.Type !== "Program" && !Platform.isTV && !isOffline && (
|
||||||
<View className='flex flex-row items-center justify-start w-full h-16'>
|
<View className='flex flex-row items-center justify-start w-full h-16 mb-2'>
|
||||||
<BitrateSheet
|
<BitrateSheet
|
||||||
className='mr-1'
|
className='mr-1'
|
||||||
onChange={(val) =>
|
onChange={(val) =>
|
||||||
|
|||||||
@@ -2,13 +2,11 @@ import type {
|
|||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
|
||||||
|
|
||||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
|
||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof View> {
|
interface Props extends React.ComponentProps<typeof View> {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -23,7 +21,7 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
|||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const isTv = Platform.isTV;
|
const isTv = Platform.isTV;
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const getDisplayName = useCallback((source: MediaSourceInfo) => {
|
const getDisplayName = useCallback((source: MediaSourceInfo) => {
|
||||||
@@ -46,50 +44,60 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
|||||||
return getDisplayName(selected);
|
return getDisplayName(selected);
|
||||||
}, [selected, getDisplayName]);
|
}, [selected, getDisplayName]);
|
||||||
|
|
||||||
|
const optionGroups: OptionGroup[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
options:
|
||||||
|
item.MediaSources?.map((source) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: getDisplayName(source),
|
||||||
|
value: source,
|
||||||
|
selected: source.Id === selected?.Id,
|
||||||
|
onPress: () => onChange(source),
|
||||||
|
})) || [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[item.MediaSources, selected, getDisplayName, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOptionSelect = (optionId: string) => {
|
||||||
|
const selectedSource = item.MediaSources?.find(
|
||||||
|
(source, idx) => `${source.Id || idx}` === optionId,
|
||||||
|
);
|
||||||
|
if (selectedSource) {
|
||||||
|
onChange(selectedSource);
|
||||||
|
}
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const trigger = (
|
||||||
|
<View className='flex flex-col' {...props}>
|
||||||
|
<Text className='opacity-50 mb-1 text-xs'>{t("item_card.video")}</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center'
|
||||||
|
onPress={() => setOpen(true)}
|
||||||
|
>
|
||||||
|
<Text numberOfLines={1}>{selectedName}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
if (isTv) return null;
|
if (isTv) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<PlatformDropdown
|
||||||
className='flex shrink'
|
groups={optionGroups}
|
||||||
style={{
|
trigger={trigger}
|
||||||
minWidth: 50,
|
title={t("item_card.video")}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
onOptionSelect={handleOptionSelect}
|
||||||
|
expoUIConfig={{
|
||||||
|
hostStyle: { flex: 1 },
|
||||||
}}
|
}}
|
||||||
>
|
bottomSheetConfig={{
|
||||||
<DropdownMenu.Root>
|
enablePanDownToClose: true,
|
||||||
<DropdownMenu.Trigger>
|
}}
|
||||||
<View className='flex flex-col' {...props}>
|
/>
|
||||||
<Text className='opacity-50 mb-1 text-xs'>
|
|
||||||
{t("item_card.video")}
|
|
||||||
</Text>
|
|
||||||
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center'>
|
|
||||||
<Text numberOfLines={1}>{selectedName}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
loop={true}
|
|
||||||
side='bottom'
|
|
||||||
align='start'
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={8}
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Label>Media sources</DropdownMenu.Label>
|
|
||||||
{item.MediaSources?.map((source, idx: number) => (
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={idx.toString()}
|
|
||||||
onSelect={() => {
|
|
||||||
onChange(source);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>
|
|
||||||
{getDisplayName(source)}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
))}
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
style={{
|
style={{
|
||||||
top: -50,
|
top: -50,
|
||||||
}}
|
}}
|
||||||
className='relative flex-1 bg-transparent pb-24'
|
className='relative flex-1 bg-transparent pb-4'
|
||||||
>
|
>
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
// Background Linear Gradient
|
// Background Linear Gradient
|
||||||
|
|||||||
337
components/PlatformDropdown.tsx
Normal file
337
components/PlatformDropdown.tsx
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
import { Button, ContextMenu, Host, Picker } from "@expo/ui/swift-ui";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
||||||
|
import React, { useEffect } from "react";
|
||||||
|
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||||
|
|
||||||
|
// Option types
|
||||||
|
export type RadioOption<T = any> = {
|
||||||
|
type: "radio";
|
||||||
|
label: string;
|
||||||
|
value: T;
|
||||||
|
selected: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ToggleOption = {
|
||||||
|
type: "toggle";
|
||||||
|
label: string;
|
||||||
|
value: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Option = RadioOption | ToggleOption;
|
||||||
|
|
||||||
|
// Option group structure
|
||||||
|
export type OptionGroup = {
|
||||||
|
title?: string;
|
||||||
|
options: Option[];
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PlatformDropdownProps {
|
||||||
|
trigger?: React.ReactNode;
|
||||||
|
title?: string;
|
||||||
|
groups: OptionGroup[];
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
onOptionSelect?: (value?: any) => void;
|
||||||
|
expoUIConfig?: {
|
||||||
|
hostStyle?: any;
|
||||||
|
};
|
||||||
|
bottomSheetConfig?: {
|
||||||
|
enableDynamicSizing?: boolean;
|
||||||
|
enablePanDownToClose?: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToggleSwitch: React.FC<{ value: boolean }> = ({ value }) => (
|
||||||
|
<View
|
||||||
|
className={`w-12 h-7 rounded-full ${value ? "bg-purple-600" : "bg-neutral-600"} flex-row items-center`}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className={`w-5 h-5 rounded-full bg-white shadow-md transform transition-transform ${
|
||||||
|
value ? "translate-x-6" : "translate-x-1"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
const OptionItem: React.FC<{ option: Option; isLast?: boolean }> = ({
|
||||||
|
option,
|
||||||
|
isLast,
|
||||||
|
}) => {
|
||||||
|
const isToggle = option.type === "toggle";
|
||||||
|
const handlePress = isToggle ? option.onToggle : option.onPress;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handlePress}
|
||||||
|
disabled={option.disabled}
|
||||||
|
className={`px-4 py-3 flex flex-row items-center justify-between ${
|
||||||
|
option.disabled ? "opacity-50" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Text className='flex-1 text-white'>{option.label}</Text>
|
||||||
|
{isToggle ? (
|
||||||
|
<ToggleSwitch value={option.value} />
|
||||||
|
) : option.selected ? (
|
||||||
|
<Ionicons name='checkmark-circle' size={24} color='#9333ea' />
|
||||||
|
) : (
|
||||||
|
<Ionicons name='ellipse-outline' size={24} color='#6b7280' />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
{!isLast && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: StyleSheet.hairlineWidth,
|
||||||
|
}}
|
||||||
|
className='bg-neutral-700 mx-4'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const OptionGroupComponent: React.FC<{ group: OptionGroup }> = ({ group }) => (
|
||||||
|
<View className='mb-6'>
|
||||||
|
{group.title && (
|
||||||
|
<Text className='text-lg font-semibold mb-3 text-neutral-300'>
|
||||||
|
{group.title}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
borderRadius: 12,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
className='bg-neutral-800 rounded-xl overflow-hidden'
|
||||||
|
>
|
||||||
|
{group.options.map((option, index) => (
|
||||||
|
<OptionItem
|
||||||
|
key={index}
|
||||||
|
option={option}
|
||||||
|
isLast={index === group.options.length - 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
const BottomSheetContent: React.FC<{
|
||||||
|
title?: string;
|
||||||
|
groups: OptionGroup[];
|
||||||
|
onOptionSelect?: (value?: any) => void;
|
||||||
|
onClose?: () => void;
|
||||||
|
}> = ({ title, groups, onOptionSelect, onClose }) => {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
// Wrap the groups to call onOptionSelect when an option is pressed
|
||||||
|
const wrappedGroups = groups.map((group) => ({
|
||||||
|
...group,
|
||||||
|
options: group.options.map((option) => {
|
||||||
|
if (option.type === "radio") {
|
||||||
|
return {
|
||||||
|
...option,
|
||||||
|
onPress: () => {
|
||||||
|
option.onPress();
|
||||||
|
onOptionSelect?.(option.value);
|
||||||
|
onClose?.();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (option.type === "toggle") {
|
||||||
|
return {
|
||||||
|
...option,
|
||||||
|
onToggle: () => {
|
||||||
|
option.onToggle();
|
||||||
|
onOptionSelect?.(option.value);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return option;
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BottomSheetScrollView
|
||||||
|
className='px-4 pb-8 pt-2'
|
||||||
|
style={{
|
||||||
|
paddingLeft: Math.max(16, insets.left),
|
||||||
|
paddingRight: Math.max(16, insets.right),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title && <Text className='font-bold text-2xl mb-6'>{title}</Text>}
|
||||||
|
{wrappedGroups.map((group, index) => (
|
||||||
|
<OptionGroupComponent key={index} group={group} />
|
||||||
|
))}
|
||||||
|
</BottomSheetScrollView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const PlatformDropdownComponent = ({
|
||||||
|
trigger,
|
||||||
|
title,
|
||||||
|
groups,
|
||||||
|
open: controlledOpen,
|
||||||
|
onOpenChange: controlledOnOpenChange,
|
||||||
|
onOptionSelect,
|
||||||
|
expoUIConfig,
|
||||||
|
bottomSheetConfig,
|
||||||
|
}: PlatformDropdownProps) => {
|
||||||
|
const { showModal, hideModal } = useGlobalModal();
|
||||||
|
|
||||||
|
// Handle controlled open state for Android
|
||||||
|
useEffect(() => {
|
||||||
|
if (Platform.OS === "android" && controlledOpen === true) {
|
||||||
|
showModal(
|
||||||
|
<BottomSheetContent
|
||||||
|
title={title}
|
||||||
|
groups={groups}
|
||||||
|
onOptionSelect={onOptionSelect}
|
||||||
|
onClose={() => {
|
||||||
|
hideModal();
|
||||||
|
controlledOnOpenChange?.(false);
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
{
|
||||||
|
snapPoints: ["90%"],
|
||||||
|
enablePanDownToClose: bottomSheetConfig?.enablePanDownToClose ?? true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [controlledOpen]);
|
||||||
|
|
||||||
|
if (Platform.OS === "ios") {
|
||||||
|
return (
|
||||||
|
<Host style={expoUIConfig?.hostStyle}>
|
||||||
|
<ContextMenu>
|
||||||
|
<ContextMenu.Trigger>
|
||||||
|
<View className=''>
|
||||||
|
{trigger || <Button variant='bordered'>Show Menu</Button>}
|
||||||
|
</View>
|
||||||
|
</ContextMenu.Trigger>
|
||||||
|
<ContextMenu.Items>
|
||||||
|
{groups.flatMap((group, groupIndex) => {
|
||||||
|
// Check if this group has radio options
|
||||||
|
const radioOptions = group.options.filter(
|
||||||
|
(opt) => opt.type === "radio",
|
||||||
|
) as RadioOption[];
|
||||||
|
const toggleOptions = group.options.filter(
|
||||||
|
(opt) => opt.type === "toggle",
|
||||||
|
) as ToggleOption[];
|
||||||
|
|
||||||
|
const items = [];
|
||||||
|
|
||||||
|
// Add Picker for radio options ONLY if there's a group title
|
||||||
|
// Otherwise render as individual buttons
|
||||||
|
if (radioOptions.length > 0) {
|
||||||
|
if (group.title) {
|
||||||
|
// Use Picker for grouped options
|
||||||
|
items.push(
|
||||||
|
<Picker
|
||||||
|
key={`picker-${groupIndex}`}
|
||||||
|
label={group.title}
|
||||||
|
options={radioOptions.map((opt) => opt.label)}
|
||||||
|
variant='menu'
|
||||||
|
selectedIndex={radioOptions.findIndex(
|
||||||
|
(opt) => opt.selected,
|
||||||
|
)}
|
||||||
|
onOptionSelected={(event: any) => {
|
||||||
|
const index = event.nativeEvent.index;
|
||||||
|
const selectedOption = radioOptions[index];
|
||||||
|
selectedOption?.onPress();
|
||||||
|
onOptionSelect?.(selectedOption?.value);
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Render radio options as direct buttons
|
||||||
|
radioOptions.forEach((option, optionIndex) => {
|
||||||
|
items.push(
|
||||||
|
<Button
|
||||||
|
key={`radio-${groupIndex}-${optionIndex}`}
|
||||||
|
systemImage={
|
||||||
|
option.selected ? "checkmark.circle.fill" : "circle"
|
||||||
|
}
|
||||||
|
onPress={() => {
|
||||||
|
option.onPress();
|
||||||
|
onOptionSelect?.(option.value);
|
||||||
|
}}
|
||||||
|
disabled={option.disabled}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Button>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add Buttons for toggle options
|
||||||
|
toggleOptions.forEach((option, optionIndex) => {
|
||||||
|
items.push(
|
||||||
|
<Button
|
||||||
|
key={`toggle-${groupIndex}-${optionIndex}`}
|
||||||
|
systemImage={
|
||||||
|
option.value ? "checkmark.circle.fill" : "circle"
|
||||||
|
}
|
||||||
|
onPress={() => {
|
||||||
|
option.onToggle();
|
||||||
|
onOptionSelect?.(option.value);
|
||||||
|
}}
|
||||||
|
disabled={option.disabled}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Button>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return items;
|
||||||
|
})}
|
||||||
|
</ContextMenu.Items>
|
||||||
|
</ContextMenu>
|
||||||
|
</Host>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Android: Direct modal trigger
|
||||||
|
const handlePress = () => {
|
||||||
|
showModal(
|
||||||
|
<BottomSheetContent
|
||||||
|
title={title}
|
||||||
|
groups={groups}
|
||||||
|
onOptionSelect={onOptionSelect}
|
||||||
|
onClose={hideModal}
|
||||||
|
/>,
|
||||||
|
{
|
||||||
|
snapPoints: ["90%"],
|
||||||
|
enablePanDownToClose: bottomSheetConfig?.enablePanDownToClose ?? true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity onPress={handlePress} activeOpacity={0.7}>
|
||||||
|
{trigger || <Text className='text-white'>Open Menu</Text>}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Memoize to prevent unnecessary re-renders when parent re-renders
|
||||||
|
export const PlatformDropdown = React.memo(
|
||||||
|
PlatformDropdownComponent,
|
||||||
|
(prevProps, nextProps) => {
|
||||||
|
// Custom comparison - only re-render if these props actually change
|
||||||
|
return (
|
||||||
|
prevProps.title === nextProps.title &&
|
||||||
|
prevProps.open === nextProps.open &&
|
||||||
|
prevProps.groups === nextProps.groups && // Reference equality (works because we memoize groups in caller)
|
||||||
|
prevProps.trigger === nextProps.trigger // Reference equality
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -33,10 +33,9 @@ import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
|||||||
import { chromecast } from "@/utils/profiles/chromecast";
|
import { chromecast } from "@/utils/profiles/chromecast";
|
||||||
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
|
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
|
||||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
import type { Button } from "./Button";
|
|
||||||
import type { SelectedOptions } from "./ItemContent";
|
import type { SelectedOptions } from "./ItemContent";
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof Button> {
|
interface Props extends React.ComponentProps<typeof TouchableOpacity> {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
selectedOptions: SelectedOptions;
|
selectedOptions: SelectedOptions;
|
||||||
isOffline?: boolean;
|
isOffline?: boolean;
|
||||||
@@ -51,7 +50,6 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
selectedOptions,
|
selectedOptions,
|
||||||
isOffline,
|
isOffline,
|
||||||
colors,
|
colors,
|
||||||
...props
|
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
const client = useRemoteMediaClient();
|
const client = useRemoteMediaClient();
|
||||||
@@ -165,7 +163,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
api,
|
api,
|
||||||
item,
|
item,
|
||||||
deviceProfile: enableH265 ? chromecasth265 : chromecast,
|
deviceProfile: enableH265 ? chromecasth265 : chromecast,
|
||||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
startTimeTicks: item?.UserData?.PlaybackPositionTicks ?? 0,
|
||||||
userId: user.Id,
|
userId: user.Id,
|
||||||
audioStreamIndex: selectedOptions.audioIndex,
|
audioStreamIndex: selectedOptions.audioIndex,
|
||||||
maxStreamingBitrate: selectedOptions.bitrate?.value,
|
maxStreamingBitrate: selectedOptions.bitrate?.value,
|
||||||
@@ -364,6 +362,52 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
* *********************
|
* *********************
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// if (Platform.OS === "ios")
|
||||||
|
// return (
|
||||||
|
// <Host
|
||||||
|
// style={{
|
||||||
|
// height: 50,
|
||||||
|
// flex: 1,
|
||||||
|
// flexShrink: 0,
|
||||||
|
// }}
|
||||||
|
// >
|
||||||
|
// <Button
|
||||||
|
// variant='glassProminent'
|
||||||
|
// onPress={onPress}
|
||||||
|
// color={effectiveColors.primary}
|
||||||
|
// modifiers={[fixedSize()]}
|
||||||
|
// >
|
||||||
|
// <View className='flex flex-row items-center space-x-2 h-full w-full justify-center -mb-3.5 '>
|
||||||
|
// <Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
|
||||||
|
// {runtimeTicksToMinutes(
|
||||||
|
// (item?.RunTimeTicks || 0) -
|
||||||
|
// (item?.UserData?.PlaybackPositionTicks || 0),
|
||||||
|
// )}
|
||||||
|
// {(item?.UserData?.PlaybackPositionTicks || 0) > 0 && " left"}
|
||||||
|
// </Animated.Text>
|
||||||
|
// <Animated.Text style={animatedTextStyle}>
|
||||||
|
// <Ionicons name='play-circle' size={24} />
|
||||||
|
// </Animated.Text>
|
||||||
|
// {client && (
|
||||||
|
// <Animated.Text style={animatedTextStyle}>
|
||||||
|
// <Feather name='cast' size={22} />
|
||||||
|
// <CastButton tintColor='transparent' />
|
||||||
|
// </Animated.Text>
|
||||||
|
// )}
|
||||||
|
// {!client && settings?.openInVLC && (
|
||||||
|
// <Animated.Text style={animatedTextStyle}>
|
||||||
|
// <MaterialCommunityIcons
|
||||||
|
// name='vlc'
|
||||||
|
// size={18}
|
||||||
|
// color={animatedTextStyle.color}
|
||||||
|
// />
|
||||||
|
// </Animated.Text>
|
||||||
|
// )}
|
||||||
|
// </View>
|
||||||
|
// </Button>
|
||||||
|
// </Host>
|
||||||
|
// );
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
disabled={!item}
|
disabled={!item}
|
||||||
@@ -371,7 +415,6 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
accessibilityHint='Tap to play the media'
|
accessibilityHint='Tap to play the media'
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
className={"relative"}
|
className={"relative"}
|
||||||
{...props}
|
|
||||||
>
|
>
|
||||||
<View className='absolute w-full h-full top-0 left-0 rounded-full z-10 overflow-hidden'>
|
<View className='absolute w-full h-full top-0 left-0 rounded-full z-10 overflow-hidden'>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
@@ -399,7 +442,11 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
>
|
>
|
||||||
<View className='flex flex-row items-center space-x-2'>
|
<View className='flex flex-row items-center space-x-2'>
|
||||||
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
|
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
|
||||||
{runtimeTicksToMinutes(item?.RunTimeTicks)}
|
{runtimeTicksToMinutes(
|
||||||
|
(item?.RunTimeTicks || 0) -
|
||||||
|
(item?.UserData?.PlaybackPositionTicks || 0),
|
||||||
|
)}
|
||||||
|
{(item?.UserData?.PlaybackPositionTicks || 0) > 0 && " left"}
|
||||||
</Animated.Text>
|
</Animated.Text>
|
||||||
<Animated.Text style={animatedTextStyle}>
|
<Animated.Text style={animatedTextStyle}>
|
||||||
<Ionicons name='play-circle' size={24} />
|
<Ionicons name='play-circle' size={24} />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { Platform, View, type ViewProps } from "react-native";
|
import { View, type ViewProps } from "react-native";
|
||||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||||
import { RoundButton } from "./RoundButton";
|
import { RoundButton } from "./RoundButton";
|
||||||
|
|
||||||
@@ -14,25 +14,10 @@ export const PlayedStatus: React.FC<Props> = ({ items, ...props }) => {
|
|||||||
const allPlayed = items.every((item) => item.UserData?.Played);
|
const allPlayed = items.every((item) => item.UserData?.Played);
|
||||||
const toggle = useMarkAsPlayed(items);
|
const toggle = useMarkAsPlayed(items);
|
||||||
|
|
||||||
if (Platform.OS === "ios") {
|
|
||||||
return (
|
|
||||||
<View {...props}>
|
|
||||||
<RoundButton
|
|
||||||
color={allPlayed ? "purple" : "white"}
|
|
||||||
icon={allPlayed ? "checkmark" : "checkmark"}
|
|
||||||
onPress={async () => {
|
|
||||||
await toggle(!allPlayed);
|
|
||||||
}}
|
|
||||||
size={props.size}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<RoundButton
|
<RoundButton
|
||||||
fillColor={allPlayed ? "primary" : undefined}
|
color={allPlayed ? "purple" : "white"}
|
||||||
icon={allPlayed ? "checkmark" : "checkmark"}
|
icon={allPlayed ? "checkmark" : "checkmark"}
|
||||||
onPress={async () => {
|
onPress={async () => {
|
||||||
await toggle(!allPlayed);
|
await toggle(!allPlayed);
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={handlePress}
|
onPress={handlePress}
|
||||||
className={`rounded-full ${buttonSize} flex items-center justify-center ${
|
className={`rounded-full ${buttonSize} flex items-center justify-center ${
|
||||||
fillColor ? fillColorClass : "bg-neutral-800/80"
|
fillColor ? fillColorClass : "bg-transparent"
|
||||||
}`}
|
}`}
|
||||||
{...(viewProps as any)}
|
{...(viewProps as any)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import { tc } from "@/utils/textTools";
|
import { tc } from "@/utils/textTools";
|
||||||
|
|
||||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
|
||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof View> {
|
interface Props extends React.ComponentProps<typeof View> {
|
||||||
source?: MediaSourceInfo;
|
source?: MediaSourceInfo;
|
||||||
@@ -21,6 +19,8 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
|||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const subtitleStreams = useMemo(() => {
|
const subtitleStreams = useMemo(() => {
|
||||||
return source?.MediaStreams?.filter((x) => x.Type === "Subtitle");
|
return source?.MediaStreams?.filter((x) => x.Type === "Subtitle");
|
||||||
}, [source]);
|
}, [source]);
|
||||||
@@ -30,64 +30,83 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
|||||||
[subtitleStreams, selected],
|
[subtitleStreams, selected],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const optionGroups: OptionGroup[] = useMemo(() => {
|
||||||
|
const options = [
|
||||||
|
{
|
||||||
|
type: "radio" as const,
|
||||||
|
label: t("item_card.none"),
|
||||||
|
value: -1,
|
||||||
|
selected: selected === -1,
|
||||||
|
onPress: () => onChange(-1),
|
||||||
|
},
|
||||||
|
...(subtitleStreams?.map((subtitle, idx) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: subtitle.DisplayTitle || `Subtitle Stream ${idx + 1}`,
|
||||||
|
value: subtitle.Index,
|
||||||
|
selected: subtitle.Index === selected,
|
||||||
|
onPress: () => onChange(subtitle.Index ?? -1),
|
||||||
|
})) || []),
|
||||||
|
];
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
options,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [subtitleStreams, selected, t, onChange]);
|
||||||
|
|
||||||
|
const handleOptionSelect = (optionId: string) => {
|
||||||
|
if (optionId === "none") {
|
||||||
|
onChange(-1);
|
||||||
|
} else {
|
||||||
|
const selectedStream = subtitleStreams?.find(
|
||||||
|
(subtitle, idx) => `${subtitle.Index || idx}` === optionId,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
selectedStream &&
|
||||||
|
selectedStream.Index !== undefined &&
|
||||||
|
selectedStream.Index !== null
|
||||||
|
) {
|
||||||
|
onChange(selectedStream.Index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const trigger = (
|
||||||
|
<View className='flex flex-col' {...props}>
|
||||||
|
<Text numberOfLines={1} className='opacity-50 mb-1 text-xs'>
|
||||||
|
{t("item_card.subtitles")}
|
||||||
|
</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>
|
||||||
|
{selectedSubtitleSteam
|
||||||
|
? tc(selectedSubtitleSteam?.DisplayTitle, 7)
|
||||||
|
: t("item_card.none")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
if (Platform.isTV || subtitleStreams?.length === 0) return null;
|
if (Platform.isTV || subtitleStreams?.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<PlatformDropdown
|
||||||
className='flex col shrink justify-start place-self-start items-start'
|
groups={optionGroups}
|
||||||
style={{
|
trigger={trigger}
|
||||||
minWidth: 60,
|
title={t("item_card.subtitles")}
|
||||||
maxWidth: 200,
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
onOptionSelect={handleOptionSelect}
|
||||||
|
expoUIConfig={{
|
||||||
|
hostStyle: { flex: 1 },
|
||||||
}}
|
}}
|
||||||
>
|
bottomSheetConfig={{
|
||||||
<DropdownMenu.Root>
|
enablePanDownToClose: true,
|
||||||
<DropdownMenu.Trigger>
|
}}
|
||||||
<View className='flex flex-col ' {...props}>
|
/>
|
||||||
<Text numberOfLines={1} className='opacity-50 mb-1 text-xs'>
|
|
||||||
{t("item_card.subtitles")}
|
|
||||||
</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=' '>
|
|
||||||
{selectedSubtitleSteam
|
|
||||||
? tc(selectedSubtitleSteam?.DisplayTitle, 7)
|
|
||||||
: t("item_card.none")}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
loop={true}
|
|
||||||
side='bottom'
|
|
||||||
align='start'
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={8}
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Label>Subtitle tracks</DropdownMenu.Label>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={"-1"}
|
|
||||||
onSelect={() => {
|
|
||||||
onChange(-1);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>None</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
{subtitleStreams?.map((subtitle, idx: number) => (
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={idx.toString()}
|
|
||||||
onSelect={() => {
|
|
||||||
if (subtitle.Index !== undefined && subtitle.Index !== null)
|
|
||||||
onChange(subtitle.Index);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>
|
|
||||||
{subtitle.DisplayTitle}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
))}
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -33,12 +33,27 @@ export const TrackSheet: React.FC<Props> = ({
|
|||||||
() => streams?.find((x) => x.Index === selected),
|
() => streams?.find((x) => x.Index === selected),
|
||||||
[streams, selected],
|
[streams, selected],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const noneOption = useMemo(
|
||||||
|
() => ({ Index: -1, DisplayTitle: t("common.none") }),
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Creates a modified data array that includes a "None" option for subtitles
|
||||||
|
// We might want to possibly do this for other places, like audio?
|
||||||
|
const addNoneToSubtitles = useMemo(() => {
|
||||||
|
if (streamType === "Subtitle") {
|
||||||
|
const result = streams ? [noneOption, ...streams] : [noneOption];
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return streams;
|
||||||
|
}, [streams, streamType, noneOption]);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
if (isTv || (streams && streams.length === 0)) return null;
|
if (isTv || (streams && streams.length === 0)) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='flex shrink' style={{ minWidth: 25 }} {...props}>
|
<View className='flex shrink' style={{ minWidth: 60 }} {...props}>
|
||||||
<View className='flex flex-col'>
|
<View className='flex flex-col'>
|
||||||
<Text className='opacity-50 mb-1 text-xs'>{title}</Text>
|
<Text className='opacity-50 mb-1 text-xs'>{title}</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -46,7 +61,9 @@ export const TrackSheet: React.FC<Props> = ({
|
|||||||
onPress={() => setOpen(true)}
|
onPress={() => setOpen(true)}
|
||||||
>
|
>
|
||||||
<Text numberOfLines={1}>
|
<Text numberOfLines={1}>
|
||||||
{selectedSteam?.DisplayTitle || t("common.select", "Select")}
|
{selected === -1 && streamType === "Subtitle"
|
||||||
|
? t("common.none")
|
||||||
|
: selectedSteam?.DisplayTitle || t("common.select", "Select")}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
@@ -54,8 +71,14 @@ export const TrackSheet: React.FC<Props> = ({
|
|||||||
open={open}
|
open={open}
|
||||||
setOpen={setOpen}
|
setOpen={setOpen}
|
||||||
title={title}
|
title={title}
|
||||||
data={streams || []}
|
data={addNoneToSubtitles || []}
|
||||||
values={selectedSteam ? [selectedSteam] : []}
|
values={
|
||||||
|
selected === -1 && streamType === "Subtitle"
|
||||||
|
? [{ Index: -1, DisplayTitle: t("common.none") }]
|
||||||
|
: selectedSteam
|
||||||
|
? [selectedSteam]
|
||||||
|
: []
|
||||||
|
}
|
||||||
multiple={false}
|
multiple={false}
|
||||||
searchFilter={(item, query) => {
|
searchFilter={(item, query) => {
|
||||||
const label = (item as any).DisplayTitle || "";
|
const label = (item as any).DisplayTitle || "";
|
||||||
|
|||||||
@@ -10,45 +10,52 @@ import { LinearGradient } from "expo-linear-gradient";
|
|||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { Dimensions, Pressable, TouchableOpacity, View } from "react-native";
|
import {
|
||||||
|
Pressable,
|
||||||
|
TouchableOpacity,
|
||||||
|
useWindowDimensions,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
Easing,
|
Easing,
|
||||||
|
interpolate,
|
||||||
runOnJS,
|
runOnJS,
|
||||||
|
type SharedValue,
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
useSharedValue,
|
useSharedValue,
|
||||||
withTiming,
|
withTiming,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||||
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
|
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
|
||||||
|
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||||
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
import { ItemImage } from "./common/ItemImage";
|
import { ItemImage } from "../common/ItemImage";
|
||||||
import { getItemNavigation } from "./common/TouchableItemRouter";
|
import { getItemNavigation } from "../common/TouchableItemRouter";
|
||||||
import type { SelectedOptions } from "./ItemContent";
|
import type { SelectedOptions } from "../ItemContent";
|
||||||
import { PlayButton } from "./PlayButton";
|
import { PlayButton } from "../PlayButton";
|
||||||
import { PlayedStatus } from "./PlayedStatus";
|
import { MarkAsPlayedLargeButton } from "./MarkAsPlayedLargeButton";
|
||||||
|
|
||||||
interface AppleTVCarouselProps {
|
interface AppleTVCarouselProps {
|
||||||
initialIndex?: number;
|
initialIndex?: number;
|
||||||
onItemChange?: (index: number) => void;
|
onItemChange?: (index: number) => void;
|
||||||
|
scrollOffset?: SharedValue<number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { width: screenWidth, height: screenHeight } = Dimensions.get("window");
|
|
||||||
|
|
||||||
// Layout Constants
|
// Layout Constants
|
||||||
const CAROUSEL_HEIGHT = screenHeight / 1.45;
|
|
||||||
const GRADIENT_HEIGHT_TOP = 150;
|
const GRADIENT_HEIGHT_TOP = 150;
|
||||||
const GRADIENT_HEIGHT_BOTTOM = 150;
|
const GRADIENT_HEIGHT_BOTTOM = 150;
|
||||||
const LOGO_HEIGHT = 80;
|
const LOGO_HEIGHT = 80;
|
||||||
|
|
||||||
// Position Constants
|
// Position Constants
|
||||||
const LOGO_BOTTOM_POSITION = 210;
|
const LOGO_BOTTOM_POSITION = 260;
|
||||||
const GENRES_BOTTOM_POSITION = 170;
|
const GENRES_BOTTOM_POSITION = 220;
|
||||||
const CONTROLS_BOTTOM_POSITION = 100;
|
const OVERVIEW_BOTTOM_POSITION = 165;
|
||||||
const DOTS_BOTTOM_POSITION = 60;
|
const CONTROLS_BOTTOM_POSITION = 80;
|
||||||
|
const DOTS_BOTTOM_POSITION = 40;
|
||||||
|
|
||||||
// Size Constants
|
// Size Constants
|
||||||
const DOT_HEIGHT = 6;
|
const DOT_HEIGHT = 6;
|
||||||
@@ -58,13 +65,15 @@ const PLAY_BUTTON_SKELETON_HEIGHT = 50;
|
|||||||
const PLAYED_STATUS_SKELETON_SIZE = 40;
|
const PLAYED_STATUS_SKELETON_SIZE = 40;
|
||||||
const TEXT_SKELETON_HEIGHT = 20;
|
const TEXT_SKELETON_HEIGHT = 20;
|
||||||
const TEXT_SKELETON_WIDTH = 250;
|
const TEXT_SKELETON_WIDTH = 250;
|
||||||
|
const OVERVIEW_SKELETON_HEIGHT = 16;
|
||||||
|
const OVERVIEW_SKELETON_WIDTH = 400;
|
||||||
const _EMPTY_STATE_ICON_SIZE = 64;
|
const _EMPTY_STATE_ICON_SIZE = 64;
|
||||||
|
|
||||||
// Spacing Constants
|
// Spacing Constants
|
||||||
const HORIZONTAL_PADDING = 40;
|
const HORIZONTAL_PADDING = 40;
|
||||||
const DOT_PADDING = 2;
|
const DOT_PADDING = 2;
|
||||||
const DOT_GAP = 4;
|
const DOT_GAP = 4;
|
||||||
const CONTROLS_GAP = 20;
|
const CONTROLS_GAP = 10;
|
||||||
const _TEXT_MARGIN_TOP = 16;
|
const _TEXT_MARGIN_TOP = 16;
|
||||||
|
|
||||||
// Border Radius Constants
|
// Border Radius Constants
|
||||||
@@ -83,13 +92,16 @@ const VELOCITY_THRESHOLD = 400;
|
|||||||
|
|
||||||
// Text Constants
|
// Text Constants
|
||||||
const GENRES_FONT_SIZE = 16;
|
const GENRES_FONT_SIZE = 16;
|
||||||
|
const OVERVIEW_FONT_SIZE = 14;
|
||||||
const _EMPTY_STATE_FONT_SIZE = 18;
|
const _EMPTY_STATE_FONT_SIZE = 18;
|
||||||
const TEXT_SHADOW_RADIUS = 2;
|
const TEXT_SHADOW_RADIUS = 2;
|
||||||
const MAX_GENRES_COUNT = 2;
|
const MAX_GENRES_COUNT = 2;
|
||||||
const MAX_BUTTON_WIDTH = 300;
|
const MAX_BUTTON_WIDTH = 300;
|
||||||
|
const OVERVIEW_MAX_LINES = 2;
|
||||||
|
const OVERVIEW_MAX_WIDTH = "80%";
|
||||||
|
|
||||||
// Opacity Constants
|
// Opacity Constants
|
||||||
const OVERLAY_OPACITY = 0.4;
|
const OVERLAY_OPACITY = 0.3;
|
||||||
const DOT_INACTIVE_OPACITY = 0.6;
|
const DOT_INACTIVE_OPACITY = 0.6;
|
||||||
const TEXT_OPACITY = 0.9;
|
const TEXT_OPACITY = 0.9;
|
||||||
|
|
||||||
@@ -147,14 +159,21 @@ const DotIndicator = ({
|
|||||||
export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
||||||
initialIndex = 0,
|
initialIndex = 0,
|
||||||
onItemChange,
|
onItemChange,
|
||||||
|
scrollOffset,
|
||||||
}) => {
|
}) => {
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const user = useAtomValue(userAtom);
|
const user = useAtomValue(userAtom);
|
||||||
const { isConnected, serverConnected } = useNetworkStatus();
|
const { isConnected, serverConnected } = useNetworkStatus();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { width: screenWidth, height: screenHeight } = useWindowDimensions();
|
||||||
|
const isLandscape = screenWidth >= screenHeight;
|
||||||
|
const carouselHeight = useMemo(
|
||||||
|
() => (isLandscape ? screenHeight * 0.9 : screenHeight / 1.45),
|
||||||
|
[isLandscape, screenHeight],
|
||||||
|
);
|
||||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||||
const translateX = useSharedValue(-currentIndex * screenWidth);
|
const translateX = useSharedValue(-initialIndex * screenWidth);
|
||||||
|
|
||||||
const isQueryEnabled =
|
const isQueryEnabled =
|
||||||
!!api && !!user?.Id && isConnected && serverConnected === true;
|
!!api && !!user?.Id && isConnected && serverConnected === true;
|
||||||
@@ -168,7 +187,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
userId: user.Id,
|
userId: user.Id,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||||
fields: ["Genres"],
|
fields: ["Genres", "Overview"],
|
||||||
limit: 2,
|
limit: 2,
|
||||||
});
|
});
|
||||||
return response.data.Items || [];
|
return response.data.Items || [];
|
||||||
@@ -183,7 +202,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
if (!api || !user?.Id) return [];
|
if (!api || !user?.Id) return [];
|
||||||
const response = await getTvShowsApi(api).getNextUp({
|
const response = await getTvShowsApi(api).getNextUp({
|
||||||
userId: user.Id,
|
userId: user.Id,
|
||||||
fields: ["MediaSourceCount", "Genres"],
|
fields: ["MediaSourceCount", "Genres", "Overview"],
|
||||||
limit: 2,
|
limit: 2,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||||
enableResumable: false,
|
enableResumable: false,
|
||||||
@@ -202,7 +221,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
const response = await getUserLibraryApi(api).getLatestMedia({
|
const response = await getUserLibraryApi(api).getLatestMedia({
|
||||||
userId: user.Id,
|
userId: user.Id,
|
||||||
limit: 2,
|
limit: 2,
|
||||||
fields: ["PrimaryImageAspectRatio", "Path", "Genres"],
|
fields: ["PrimaryImageAspectRatio", "Path", "Genres", "Overview"],
|
||||||
imageTypeLimit: 1,
|
imageTypeLimit: 1,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||||
});
|
});
|
||||||
@@ -218,11 +237,21 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
const nextItems = nextUpData ?? [];
|
const nextItems = nextUpData ?? [];
|
||||||
const recentItems = recentlyAddedData ?? [];
|
const recentItems = recentlyAddedData ?? [];
|
||||||
|
|
||||||
return [
|
const allItems = [
|
||||||
...continueItems.slice(0, 2),
|
...continueItems.slice(0, 2),
|
||||||
...nextItems.slice(0, 2),
|
...nextItems.slice(0, 2),
|
||||||
...recentItems.slice(0, 2),
|
...recentItems.slice(0, 2),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Deduplicate by item ID to prevent duplicate keys
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return allItems.filter((item) => {
|
||||||
|
if (item.Id && !seen.has(item.Id)) {
|
||||||
|
seen.add(item.Id);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
}, [continueWatchingData, nextUpData, recentlyAddedData]);
|
}, [continueWatchingData, nextUpData, recentlyAddedData]);
|
||||||
|
|
||||||
const isLoading =
|
const isLoading =
|
||||||
@@ -281,7 +310,11 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
translateX.value = -newIndex * screenWidth;
|
translateX.value = -newIndex * screenWidth;
|
||||||
return newIndex;
|
return newIndex;
|
||||||
});
|
});
|
||||||
}, [hasItems, items, initialIndex, translateX]);
|
}, [hasItems, items, initialIndex, screenWidth, translateX]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
translateX.value = -currentIndex * screenWidth;
|
||||||
|
}, [currentIndex, screenWidth, translateX]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasItems) {
|
if (hasItems) {
|
||||||
@@ -301,7 +334,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
setCurrentIndex(index);
|
setCurrentIndex(index);
|
||||||
onItemChange?.(index);
|
onItemChange?.(index);
|
||||||
},
|
},
|
||||||
[hasItems, items, onItemChange, translateX],
|
[hasItems, items, onItemChange, screenWidth, translateX],
|
||||||
);
|
);
|
||||||
|
|
||||||
const navigateToItem = useCallback(
|
const navigateToItem = useCallback(
|
||||||
@@ -348,6 +381,30 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const togglePlayedStatus = useMarkAsPlayed(items);
|
||||||
|
|
||||||
|
const headerAnimatedStyle = useAnimatedStyle(() => {
|
||||||
|
if (!scrollOffset) return {};
|
||||||
|
return {
|
||||||
|
transform: [
|
||||||
|
{
|
||||||
|
translateY: interpolate(
|
||||||
|
scrollOffset.value,
|
||||||
|
[-carouselHeight, 0, carouselHeight],
|
||||||
|
[-carouselHeight / 2, 0, carouselHeight * 0.75],
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scale: interpolate(
|
||||||
|
scrollOffset.value,
|
||||||
|
[-carouselHeight, 0, carouselHeight],
|
||||||
|
[2, 1, 1],
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const renderDots = () => {
|
const renderDots = () => {
|
||||||
if (!hasItems || items.length <= 1) return null;
|
if (!hasItems || items.length <= 1) return null;
|
||||||
|
|
||||||
@@ -381,7 +438,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: screenWidth,
|
width: screenWidth,
|
||||||
height: CAROUSEL_HEIGHT,
|
height: carouselHeight,
|
||||||
backgroundColor: "#000",
|
backgroundColor: "#000",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -473,6 +530,36 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* Overview Skeleton */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: OVERVIEW_BOTTOM_POSITION,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
paddingHorizontal: HORIZONTAL_PADDING,
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: OVERVIEW_SKELETON_HEIGHT,
|
||||||
|
width: OVERVIEW_SKELETON_WIDTH,
|
||||||
|
backgroundColor: SKELETON_ELEMENT_COLOR,
|
||||||
|
borderRadius: TEXT_SKELETON_BORDER_RADIUS,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: OVERVIEW_SKELETON_HEIGHT,
|
||||||
|
width: OVERVIEW_SKELETON_WIDTH * 0.7,
|
||||||
|
backgroundColor: SKELETON_ELEMENT_COLOR,
|
||||||
|
borderRadius: TEXT_SKELETON_BORDER_RADIUS,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
{/* Controls Skeleton */}
|
{/* Controls Skeleton */}
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
@@ -549,20 +636,30 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
key={item.Id}
|
key={item.Id}
|
||||||
style={{
|
style={{
|
||||||
width: screenWidth,
|
width: screenWidth,
|
||||||
height: CAROUSEL_HEIGHT,
|
height: carouselHeight,
|
||||||
position: "relative",
|
position: "relative",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Background Backdrop */}
|
{/* Background Backdrop */}
|
||||||
<ItemImage
|
<Animated.View
|
||||||
item={item}
|
style={[
|
||||||
variant='Backdrop'
|
{
|
||||||
style={{
|
width: "100%",
|
||||||
width: "100%",
|
height: "100%",
|
||||||
height: "100%",
|
position: "absolute",
|
||||||
position: "absolute",
|
},
|
||||||
}}
|
headerAnimatedStyle,
|
||||||
/>
|
]}
|
||||||
|
>
|
||||||
|
<ItemImage
|
||||||
|
item={item}
|
||||||
|
variant='Backdrop'
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
{/* Dark Overlay */}
|
{/* Dark Overlay */}
|
||||||
<View
|
<View
|
||||||
@@ -689,6 +786,39 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* Overview Section - for Episodes and Movies */}
|
||||||
|
{(item.Type === "Episode" || item.Type === "Movie") &&
|
||||||
|
item.Overview && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: OVERVIEW_BOTTOM_POSITION,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
paddingHorizontal: HORIZONTAL_PADDING,
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TouchableOpacity onPress={() => navigateToItem(item)}>
|
||||||
|
<Animated.Text
|
||||||
|
numberOfLines={OVERVIEW_MAX_LINES}
|
||||||
|
style={{
|
||||||
|
color: `rgba(255, 255, 255, ${TEXT_OPACITY * 0.85})`,
|
||||||
|
fontSize: OVERVIEW_FONT_SIZE,
|
||||||
|
fontWeight: "400",
|
||||||
|
textAlign: "center",
|
||||||
|
maxWidth: OVERVIEW_MAX_WIDTH,
|
||||||
|
textShadowColor: TEXT_SHADOW_COLOR,
|
||||||
|
textShadowOffset: { width: 0, height: 1 },
|
||||||
|
textShadowRadius: TEXT_SHADOW_RADIUS,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.Overview}
|
||||||
|
</Animated.Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Controls Section */}
|
{/* Controls Section */}
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
@@ -719,7 +849,10 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Mark as Played */}
|
{/* Mark as Played */}
|
||||||
<PlayedStatus items={[item]} size='large' />
|
<MarkAsPlayedLargeButton
|
||||||
|
isPlayed={item.UserData?.Played ?? false}
|
||||||
|
onToggle={togglePlayedStatus}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -731,7 +864,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
height: CAROUSEL_HEIGHT,
|
height: carouselHeight,
|
||||||
backgroundColor: "#000",
|
backgroundColor: "#000",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
}}
|
}}
|
||||||
@@ -749,7 +882,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
height: CAROUSEL_HEIGHT, // Fixed height instead of flex: 1
|
height: carouselHeight, // Fixed height instead of flex: 1
|
||||||
backgroundColor: "#000",
|
backgroundColor: "#000",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
}}
|
}}
|
||||||
@@ -758,7 +891,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
<Animated.View
|
<Animated.View
|
||||||
style={[
|
style={[
|
||||||
{
|
{
|
||||||
height: CAROUSEL_HEIGHT, // Fixed height instead of flex: 1
|
height: carouselHeight, // Fixed height instead of flex: 1
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
width: screenWidth * items.length,
|
width: screenWidth * items.length,
|
||||||
},
|
},
|
||||||
51
components/apple-tv-carousel/MarkAsPlayedLargeButton.tsx
Normal file
51
components/apple-tv-carousel/MarkAsPlayedLargeButton.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { Button, Host } from "@expo/ui/swift-ui";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Platform, View } from "react-native";
|
||||||
|
import { RoundButton } from "../RoundButton";
|
||||||
|
|
||||||
|
interface MarkAsPlayedLargeButtonProps {
|
||||||
|
isPlayed: boolean;
|
||||||
|
onToggle: (isPlayed: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MarkAsPlayedLargeButton: React.FC<
|
||||||
|
MarkAsPlayedLargeButtonProps
|
||||||
|
> = ({ isPlayed, onToggle }) => {
|
||||||
|
if (Platform.OS === "ios")
|
||||||
|
return (
|
||||||
|
<Host
|
||||||
|
style={{
|
||||||
|
flex: 0,
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
flexDirection: "row",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button onPress={() => onToggle(isPlayed)} variant='glass'>
|
||||||
|
<View>
|
||||||
|
<Ionicons
|
||||||
|
name='checkmark'
|
||||||
|
size={24}
|
||||||
|
color='white'
|
||||||
|
style={{
|
||||||
|
marginTop: 6,
|
||||||
|
marginLeft: 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</Button>
|
||||||
|
</Host>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<RoundButton
|
||||||
|
size='large'
|
||||||
|
icon={isPlayed ? "checkmark" : "checkmark"}
|
||||||
|
onPress={() => onToggle(isPlayed)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
|
||||||
|
|
||||||
import {
|
|
||||||
type PropsWithChildren,
|
|
||||||
type ReactNode,
|
|
||||||
useEffect,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { Platform, TouchableOpacity, View, type ViewProps } from "react-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
|
||||||
|
|
||||||
interface Props<T> {
|
|
||||||
data: T[];
|
|
||||||
disabled?: boolean;
|
|
||||||
placeholderText?: string;
|
|
||||||
keyExtractor: (item: T) => string;
|
|
||||||
titleExtractor: (item: T) => string | undefined;
|
|
||||||
title: string | ReactNode;
|
|
||||||
label: string;
|
|
||||||
onSelected: (...item: T[]) => void;
|
|
||||||
multiple?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Dropdown = <T,>({
|
|
||||||
data,
|
|
||||||
disabled,
|
|
||||||
placeholderText,
|
|
||||||
keyExtractor,
|
|
||||||
titleExtractor,
|
|
||||||
title,
|
|
||||||
label,
|
|
||||||
onSelected,
|
|
||||||
multiple = false,
|
|
||||||
...props
|
|
||||||
}: PropsWithChildren<Props<T> & ViewProps>) => {
|
|
||||||
const isTv = Platform.isTV;
|
|
||||||
|
|
||||||
const [selected, setSelected] = useState<T[]>();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selected !== undefined) {
|
|
||||||
onSelected(...selected);
|
|
||||||
}
|
|
||||||
}, [selected, onSelected]);
|
|
||||||
|
|
||||||
if (isTv) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DisabledSetting disabled={disabled === true} showText={false} {...props}>
|
|
||||||
<DropdownMenu.Root>
|
|
||||||
<DropdownMenu.Trigger>
|
|
||||||
{typeof title === "string" ? (
|
|
||||||
<View className='flex flex-col'>
|
|
||||||
<Text className='opacity-50 mb-1 text-xs'>{title}</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}>
|
|
||||||
{selected?.length !== undefined
|
|
||||||
? selected.map(titleExtractor).join(",")
|
|
||||||
: placeholderText}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
title
|
|
||||||
)}
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
loop={false}
|
|
||||||
side='bottom'
|
|
||||||
align='center'
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={0}
|
|
||||||
sideOffset={0}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Label>{label}</DropdownMenu.Label>
|
|
||||||
{data.map((item, _idx) =>
|
|
||||||
multiple ? (
|
|
||||||
<DropdownMenu.CheckboxItem
|
|
||||||
value={
|
|
||||||
selected?.some((s) => keyExtractor(s) === keyExtractor(item))
|
|
||||||
? "on"
|
|
||||||
: "off"
|
|
||||||
}
|
|
||||||
key={keyExtractor(item)}
|
|
||||||
onValueChange={(
|
|
||||||
next: "on" | "off",
|
|
||||||
_previous: "on" | "off",
|
|
||||||
) => {
|
|
||||||
setSelected((p) => {
|
|
||||||
const prev = p || [];
|
|
||||||
if (next === "on") {
|
|
||||||
return [...prev, item];
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
...prev.filter(
|
|
||||||
(p) => keyExtractor(p) !== keyExtractor(item),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>
|
|
||||||
{titleExtractor(item)}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.CheckboxItem>
|
|
||||||
) : (
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={keyExtractor(item)}
|
|
||||||
onSelect={() => setSelected([item])}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>
|
|
||||||
{titleExtractor(item)}
|
|
||||||
</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</DisabledSetting>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Dropdown;
|
|
||||||
@@ -55,7 +55,7 @@ export const HeaderBackButton: React.FC<Props> = ({
|
|||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => router.back()}
|
onPress={() => router.back()}
|
||||||
className=' bg-neutral-800/80 rounded-full p-2'
|
className=' rounded-full p-2'
|
||||||
{...touchableOpacityProps}
|
{...touchableOpacityProps}
|
||||||
>
|
>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
|
|||||||
@@ -3,17 +3,12 @@ import React, { useImperativeHandle, useRef } from "react";
|
|||||||
import { View, type ViewStyle } from "react-native";
|
import { View, type ViewStyle } from "react-native";
|
||||||
import { Text } from "./Text";
|
import { Text } from "./Text";
|
||||||
|
|
||||||
type PartialExcept<T, K extends keyof T> = Partial<T> & Pick<T, K>;
|
|
||||||
|
|
||||||
export interface HorizontalScrollRef {
|
export interface HorizontalScrollRef {
|
||||||
scrollToIndex: (index: number, viewOffset: number) => void;
|
scrollToIndex: (index: number, viewOffset: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HorizontalScrollProps<T>
|
interface HorizontalScrollProps<T>
|
||||||
extends PartialExcept<
|
extends Omit<FlashListProps<T>, "renderItem" | "estimatedItemSize" | "data"> {
|
||||||
Omit<FlashListProps<T>, "renderItem">,
|
|
||||||
"estimatedItemSize"
|
|
||||||
> {
|
|
||||||
data?: T[] | null;
|
data?: T[] | null;
|
||||||
renderItem: (item: T, index: number) => React.ReactNode;
|
renderItem: (item: T, index: number) => React.ReactNode;
|
||||||
keyExtractor?: (item: T, index: number) => string;
|
keyExtractor?: (item: T, index: number) => string;
|
||||||
@@ -44,7 +39,7 @@ export const HorizontalScroll = <T,>(
|
|||||||
...restProps
|
...restProps
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const flashListRef = useRef<FlashList<T>>(null);
|
const flashListRef = useRef<React.ComponentRef<typeof FlashList<T>>>(null);
|
||||||
|
|
||||||
useImperativeHandle(ref!, () => ({
|
useImperativeHandle(ref!, () => ({
|
||||||
scrollToIndex: (index: number, viewOffset: number) => {
|
scrollToIndex: (index: number, viewOffset: number) => {
|
||||||
@@ -78,7 +73,6 @@ export const HorizontalScroll = <T,>(
|
|||||||
extraData={extraData}
|
extraData={extraData}
|
||||||
renderItem={renderFlashListItem}
|
renderItem={renderFlashListItem}
|
||||||
horizontal
|
horizontal
|
||||||
estimatedItemSize={200}
|
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export function InfiniteHorizontalScroll({
|
|||||||
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||||
queryKey,
|
queryKey,
|
||||||
queryFn,
|
queryFn,
|
||||||
|
staleTime: 60 * 1000, // 1 minute
|
||||||
getNextPageParam: (lastPage, pages) => {
|
getNextPageParam: (lastPage, pages) => {
|
||||||
if (
|
if (
|
||||||
!lastPage?.Items ||
|
!lastPage?.Items ||
|
||||||
@@ -119,7 +120,6 @@ export function InfiniteHorizontalScroll({
|
|||||||
renderItem={({ item, index }) => (
|
renderItem={({ item, index }) => (
|
||||||
<View className='mr-2'>{renderItem(item, index)}</View>
|
<View className='mr-2'>{renderItem(item, index)}</View>
|
||||||
)}
|
)}
|
||||||
estimatedItemSize={height}
|
|
||||||
horizontal
|
horizontal
|
||||||
onEndReached={() => {
|
onEndReached={() => {
|
||||||
if (hasNextPage) {
|
if (hasNextPage) {
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ export function Input(props: InputProps) {
|
|||||||
const [isFocused, setIsFocused] = useState(false);
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
|
||||||
return Platform.isTV ? (
|
return Platform.isTV ? (
|
||||||
<TouchableOpacity onFocus={() => inputRef?.current?.focus?.()}>
|
<TouchableOpacity
|
||||||
|
onPress={() => inputRef?.current?.focus?.()}
|
||||||
|
activeOpacity={1}
|
||||||
|
>
|
||||||
<TextInput
|
<TextInput
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
className={`
|
className={`
|
||||||
|
|||||||
@@ -1,14 +1,8 @@
|
|||||||
import { useRouter, useSegments } from "expo-router";
|
import { useRouter, useSegments } from "expo-router";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { type PropsWithChildren, useCallback, useMemo } from "react";
|
import { type PropsWithChildren } from "react";
|
||||||
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
|
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
|
||||||
import * as ContextMenu from "zeego/context-menu";
|
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
|
||||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||||
import {
|
|
||||||
hasPermission,
|
|
||||||
Permission,
|
|
||||||
} from "@/utils/jellyseerr/server/lib/permissions";
|
|
||||||
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||||
import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
|
import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
|
||||||
import type {
|
import type {
|
||||||
@@ -38,90 +32,33 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
|
|
||||||
|
|
||||||
const from = (segments as string[])[2] || "(home)";
|
const from = (segments as string[])[2] || "(home)";
|
||||||
|
|
||||||
const autoApprove = useMemo(() => {
|
|
||||||
return (
|
|
||||||
jellyseerrUser &&
|
|
||||||
hasPermission(Permission.AUTO_APPROVE, jellyseerrUser.permissions, {
|
|
||||||
type: "or",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}, [jellyseerrApi, jellyseerrUser]);
|
|
||||||
|
|
||||||
const request = useCallback(() => {
|
|
||||||
if (!result) return;
|
|
||||||
requestMedia(mediaTitle, {
|
|
||||||
mediaId: result.id,
|
|
||||||
mediaType,
|
|
||||||
});
|
|
||||||
}, [jellyseerrApi, result]);
|
|
||||||
|
|
||||||
if (from === "(home)" || from === "(search)" || from === "(libraries)")
|
if (from === "(home)" || from === "(search)" || from === "(libraries)")
|
||||||
return (
|
return (
|
||||||
<ContextMenu.Root>
|
<TouchableOpacity
|
||||||
<ContextMenu.Trigger>
|
onPress={() => {
|
||||||
<TouchableOpacity
|
if (!result) return;
|
||||||
onPress={() => {
|
|
||||||
if (!result) return;
|
|
||||||
|
|
||||||
router.push({
|
router.push({
|
||||||
pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`,
|
pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`,
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
params: {
|
params: {
|
||||||
...result,
|
...result,
|
||||||
mediaTitle,
|
mediaTitle,
|
||||||
releaseYear,
|
releaseYear,
|
||||||
canRequest: canRequest.toString(),
|
canRequest: canRequest.toString(),
|
||||||
posterSrc,
|
posterSrc,
|
||||||
mediaType,
|
mediaType,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</ContextMenu.Trigger>
|
|
||||||
<ContextMenu.Content
|
|
||||||
avoidCollisions
|
|
||||||
alignOffset={0}
|
|
||||||
collisionPadding={0}
|
|
||||||
loop={false}
|
|
||||||
key={"content"}
|
|
||||||
>
|
|
||||||
<ContextMenu.Label key='label-1'>Actions</ContextMenu.Label>
|
|
||||||
{canRequest && mediaType === MediaType.MOVIE && (
|
|
||||||
<ContextMenu.Item
|
|
||||||
key='item-1'
|
|
||||||
onSelect={() => {
|
|
||||||
if (autoApprove) {
|
|
||||||
request();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
shouldDismissMenuOnSelect
|
|
||||||
>
|
|
||||||
<ContextMenu.ItemTitle key='item-1-title'>
|
|
||||||
Request
|
|
||||||
</ContextMenu.ItemTitle>
|
|
||||||
<ContextMenu.ItemIcon
|
|
||||||
ios={{
|
|
||||||
name: "arrow.down.to.line",
|
|
||||||
pointSize: 18,
|
|
||||||
weight: "semibold",
|
|
||||||
scale: "medium",
|
|
||||||
hierarchicalColor: {
|
|
||||||
dark: "purple",
|
|
||||||
light: "purple",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
androidIconName='download'
|
|
||||||
/>
|
|
||||||
</ContextMenu.Item>
|
|
||||||
)}
|
|
||||||
</ContextMenu.Content>
|
|
||||||
</ContextMenu.Root>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ interface ActiveDownloadsProps extends ViewProps {}
|
|||||||
|
|
||||||
export default function ActiveDownloads({ ...props }: ActiveDownloadsProps) {
|
export default function ActiveDownloads({ ...props }: ActiveDownloadsProps) {
|
||||||
const { processes } = useDownload();
|
const { processes } = useDownload();
|
||||||
if (processes?.length === 0)
|
|
||||||
|
// Filter out any invalid processes before rendering
|
||||||
|
const validProcesses = processes?.filter((p) => p?.item?.Id) || [];
|
||||||
|
|
||||||
|
if (validProcesses.length === 0)
|
||||||
return (
|
return (
|
||||||
<View {...props} className='bg-neutral-900 p-4 rounded-2xl'>
|
<View {...props} className='bg-neutral-900 p-4 rounded-2xl'>
|
||||||
<Text className='text-lg font-bold'>
|
<Text className='text-lg font-bold'>
|
||||||
@@ -26,9 +30,9 @@ export default function ActiveDownloads({ ...props }: ActiveDownloadsProps) {
|
|||||||
<Text className='text-lg font-bold mb-2'>
|
<Text className='text-lg font-bold mb-2'>
|
||||||
{t("home.downloads.active_downloads")}
|
{t("home.downloads.active_downloads")}
|
||||||
</Text>
|
</Text>
|
||||||
<View className='space-y-2'>
|
<View className='gap-y-2'>
|
||||||
{processes?.map((p: JobStatus) => (
|
{validProcesses.map((p: JobStatus) => (
|
||||||
<DownloadCard key={p.item.Id} process={p} />
|
<DownloadCard key={p.id} process={p} />
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { t } from "i18next";
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Platform,
|
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
type TouchableOpacityProps,
|
type TouchableOpacityProps,
|
||||||
View,
|
View,
|
||||||
@@ -14,49 +13,36 @@ import {
|
|||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
|
import { calculateSmoothedETA } from "@/providers/Downloads/hooks/useDownloadSpeedCalculator";
|
||||||
import { JobStatus } from "@/providers/Downloads/types";
|
import { JobStatus } from "@/providers/Downloads/types";
|
||||||
|
import { estimateDownloadSize } from "@/utils/download";
|
||||||
import { storage } from "@/utils/mmkv";
|
import { storage } from "@/utils/mmkv";
|
||||||
import { formatTimeString } from "@/utils/time";
|
import { formatTimeString } from "@/utils/time";
|
||||||
import { Button } from "../Button";
|
|
||||||
|
|
||||||
const bytesToMB = (bytes: number) => {
|
const bytesToMB = (bytes: number) => {
|
||||||
return bytes / 1024 / 1024;
|
return bytes / 1024 / 1024;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatBytes = (bytes: number): string => {
|
||||||
|
if (bytes >= 1024 * 1024 * 1024) {
|
||||||
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||||
|
}
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(0)} MB`;
|
||||||
|
};
|
||||||
|
|
||||||
interface DownloadCardProps extends TouchableOpacityProps {
|
interface DownloadCardProps extends TouchableOpacityProps {
|
||||||
process: JobStatus;
|
process: JobStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||||
const { startDownload, pauseDownload, resumeDownload, removeProcess } =
|
const { cancelDownload } = useDownload();
|
||||||
useDownload();
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const handlePause = async (id: string) => {
|
|
||||||
try {
|
|
||||||
await pauseDownload(id);
|
|
||||||
toast.success(t("home.downloads.toasts.download_paused"));
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error pausing download:", error);
|
|
||||||
toast.error(t("home.downloads.toasts.could_not_pause_download"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleResume = async (id: string) => {
|
|
||||||
try {
|
|
||||||
await resumeDownload(id);
|
|
||||||
toast.success(t("home.downloads.toasts.download_resumed"));
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error resuming download:", error);
|
|
||||||
toast.error(t("home.downloads.toasts.could_not_resume_download"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
await removeProcess(id);
|
await cancelDownload(id);
|
||||||
toast.success(t("home.downloads.toasts.download_deleted"));
|
// cancelDownload already shows a toast, so don't show another one
|
||||||
queryClient.invalidateQueries({ queryKey: ["downloads"] });
|
queryClient.invalidateQueries({ queryKey: ["downloads"] });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error deleting download:", error);
|
console.error("Error deleting download:", error);
|
||||||
@@ -64,31 +50,75 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const eta = (p: JobStatus) => {
|
const eta = useMemo(() => {
|
||||||
if (!p.speed || p.speed <= 0 || !p.estimatedTotalSizeBytes) return null;
|
if (!process?.estimatedTotalSizeBytes || !process?.bytesDownloaded) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const bytesRemaining = p.estimatedTotalSizeBytes - (p.bytesDownloaded || 0);
|
const secondsRemaining = calculateSmoothedETA(
|
||||||
if (bytesRemaining <= 0) return null;
|
process.id,
|
||||||
|
process.bytesDownloaded,
|
||||||
|
process.estimatedTotalSizeBytes,
|
||||||
|
);
|
||||||
|
|
||||||
const secondsRemaining = bytesRemaining / p.speed;
|
if (!secondsRemaining || secondsRemaining <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return formatTimeString(secondsRemaining, "s");
|
return formatTimeString(secondsRemaining, "s");
|
||||||
};
|
}, [process?.id, process?.bytesDownloaded, process?.estimatedTotalSizeBytes]);
|
||||||
|
|
||||||
|
const estimatedSize = useMemo(() => {
|
||||||
|
if (process?.estimatedTotalSizeBytes)
|
||||||
|
return process.estimatedTotalSizeBytes;
|
||||||
|
|
||||||
|
// Calculate from bitrate + duration (only if bitrate value is defined)
|
||||||
|
if (process?.maxBitrate?.value && process?.item?.RunTimeTicks) {
|
||||||
|
return estimateDownloadSize(
|
||||||
|
process.maxBitrate.value,
|
||||||
|
process.item.RunTimeTicks,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}, [
|
||||||
|
process?.maxBitrate?.value,
|
||||||
|
process?.item?.RunTimeTicks,
|
||||||
|
process?.estimatedTotalSizeBytes,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const isTranscoding = process?.isTranscoding || false;
|
||||||
|
|
||||||
|
const downloadedAmount = useMemo(() => {
|
||||||
|
if (!process?.bytesDownloaded) return null;
|
||||||
|
return formatBytes(process.bytesDownloaded);
|
||||||
|
}, [process?.bytesDownloaded]);
|
||||||
|
|
||||||
const base64Image = useMemo(() => {
|
const base64Image = useMemo(() => {
|
||||||
return storage.getString(process.item.Id!);
|
try {
|
||||||
}, []);
|
const itemId = process?.item?.Id;
|
||||||
|
if (!itemId) return undefined;
|
||||||
|
return storage.getString(itemId);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}, [process?.item?.Id]);
|
||||||
|
|
||||||
// Sanitize progress to ensure it's within valid bounds
|
// Sanitize progress to ensure it's within valid bounds
|
||||||
const sanitizedProgress = useMemo(() => {
|
const sanitizedProgress = useMemo(() => {
|
||||||
if (
|
if (
|
||||||
typeof process.progress !== "number" ||
|
typeof process?.progress !== "number" ||
|
||||||
Number.isNaN(process.progress)
|
Number.isNaN(process.progress)
|
||||||
) {
|
) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
return Math.max(0, Math.min(100, process.progress));
|
return Math.max(0, Math.min(100, process.progress));
|
||||||
}, [process.progress]);
|
}, [process?.progress]);
|
||||||
|
|
||||||
|
// Return null after all hooks have been called
|
||||||
|
if (!process || !process.item || !process.item.Id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -98,9 +128,7 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|||||||
>
|
>
|
||||||
{process.status === "downloading" && (
|
{process.status === "downloading" && (
|
||||||
<View
|
<View
|
||||||
className={`
|
className={`bg-purple-600 h-1 absolute bottom-0 left-0 ${isTranscoding ? "animate-pulse" : ""}`}
|
||||||
bg-purple-600 h-1 absolute bottom-0 left-0
|
|
||||||
`}
|
|
||||||
style={{
|
style={{
|
||||||
width:
|
width:
|
||||||
sanitizedProgress > 0
|
sanitizedProgress > 0
|
||||||
@@ -111,26 +139,10 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Action buttons in bottom right corner */}
|
{/* Action buttons in bottom right corner */}
|
||||||
<View className='absolute bottom-2 right-2 flex flex-row items-center space-x-2 z-10'>
|
<View className='absolute bottom-2 right-2 flex flex-row items-center z-10'>
|
||||||
{process.status === "downloading" && Platform.OS !== "ios" && (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => handlePause(process.id)}
|
|
||||||
className='p-1'
|
|
||||||
>
|
|
||||||
<Ionicons name='pause' size={20} color='white' />
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
{process.status === "paused" && Platform.OS !== "ios" && (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => handleResume(process.id)}
|
|
||||||
className='p-1'
|
|
||||||
>
|
|
||||||
<Ionicons name='play' size={20} color='white' />
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => handleDelete(process.id)}
|
onPress={() => handleDelete(process.id)}
|
||||||
className='p-1'
|
className='p-2 bg-neutral-800 rounded-full'
|
||||||
>
|
>
|
||||||
<Ionicons name='close' size={20} color='red' />
|
<Ionicons name='close' size={20} color='red' />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -152,47 +164,53 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
<View className='shrink mb-1 flex-1'>
|
<View className='shrink mb-1 flex-1 pr-12'>
|
||||||
<Text className='text-xs opacity-50'>{process.item.Type}</Text>
|
<Text className='text-xs opacity-50'>{process.item.Type}</Text>
|
||||||
<Text className='font-semibold shrink'>{process.item.Name}</Text>
|
<Text className='font-semibold shrink'>{process.item.Name}</Text>
|
||||||
<Text className='text-xs opacity-50'>
|
<Text className='text-xs opacity-50'>
|
||||||
{process.item.ProductionYear}
|
{process.item.ProductionYear}
|
||||||
</Text>
|
</Text>
|
||||||
<View className='flex flex-row items-center space-x-2 mt-1 text-purple-600'>
|
|
||||||
|
{isTranscoding && (
|
||||||
|
<View className='bg-purple-600/20 px-2 py-0.5 rounded-md mt-1 self-start'>
|
||||||
|
<Text className='text-xs text-purple-400'>Transcoding</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Row 1: Progress + Downloaded/Total */}
|
||||||
|
<View className='flex flex-row items-center gap-x-2 mt-1.5'>
|
||||||
{sanitizedProgress === 0 ? (
|
{sanitizedProgress === 0 ? (
|
||||||
<ActivityIndicator size={"small"} color={"white"} />
|
<ActivityIndicator size={"small"} color={"white"} />
|
||||||
) : (
|
) : (
|
||||||
<Text className='text-xs'>{sanitizedProgress.toFixed(0)}%</Text>
|
<Text className='text-xs font-semibold'>
|
||||||
)}
|
{sanitizedProgress.toFixed(0)}%
|
||||||
{process.speed && process.speed > 0 && (
|
|
||||||
<Text className='text-xs'>
|
|
||||||
{bytesToMB(process.speed).toFixed(2)} MB/s
|
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{eta(process) && (
|
{downloadedAmount && (
|
||||||
<Text className='text-xs'>
|
<Text className='text-xs opacity-75'>
|
||||||
{t("home.downloads.eta", { eta: eta(process) })}
|
{downloadedAmount}
|
||||||
|
{estimatedSize
|
||||||
|
? ` / ${isTranscoding ? "~" : ""}${formatBytes(estimatedSize)}`
|
||||||
|
: ""}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className='flex flex-row items-center space-x-2 mt-1 text-purple-600'>
|
{/* Row 2: Speed + ETA */}
|
||||||
<Text className='text-xs capitalize'>{process.status}</Text>
|
<View className='flex flex-row items-center gap-x-2 mt-0.5'>
|
||||||
|
{process.speed && process.speed > 0 && (
|
||||||
|
<Text className='text-xs text-purple-400'>
|
||||||
|
{bytesToMB(process.speed).toFixed(2)} MB/s
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{eta && (
|
||||||
|
<Text className='text-xs text-green-400'>
|
||||||
|
{t("home.downloads.eta", { eta: eta })}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
{process.status === "completed" && (
|
|
||||||
<View className='flex flex-row mt-4 space-x-4'>
|
|
||||||
<Button
|
|
||||||
onPress={() => {
|
|
||||||
startDownload(process);
|
|
||||||
}}
|
|
||||||
className='w-full'
|
|
||||||
>
|
|
||||||
Download now
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,14 +13,13 @@ export const DownloadSize: React.FC<DownloadSizeProps> = ({
|
|||||||
items,
|
items,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const { getDownloadedItemSize, getDownloadedItems } = useDownload();
|
const { getDownloadedItemSize, downloadedItems } = useDownload();
|
||||||
const downloadedFiles = getDownloadedItems();
|
|
||||||
const [size, setSize] = useState<string | undefined>();
|
const [size, setSize] = useState<string | undefined>();
|
||||||
|
|
||||||
const itemIds = useMemo(() => items.map((i) => i.Id), [items]);
|
const itemIds = useMemo(() => items.map((i) => i.Id), [items]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!downloadedFiles) return;
|
if (!downloadedItems) return;
|
||||||
|
|
||||||
let s = 0;
|
let s = 0;
|
||||||
|
|
||||||
@@ -32,7 +31,7 @@ export const DownloadSize: React.FC<DownloadSizeProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
setSize(s.bytesToReadable());
|
setSize(s.bytesToReadable());
|
||||||
}, [itemIds]);
|
}, [itemIds, downloadedItems, getDownloadedItemSize]);
|
||||||
|
|
||||||
const sizeText = useMemo(() => {
|
const sizeText = useMemo(() => {
|
||||||
if (!size) return "...";
|
if (!size) return "...";
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
|
|||||||
*/
|
*/
|
||||||
const handleDeleteFile = useCallback(() => {
|
const handleDeleteFile = useCallback(() => {
|
||||||
if (item.Id) {
|
if (item.Id) {
|
||||||
deleteFile(item.Id, "Episode");
|
deleteFile(item.Id);
|
||||||
successHapticFeedback();
|
successHapticFeedback();
|
||||||
}
|
}
|
||||||
}, [deleteFile, item.Id]);
|
}, [deleteFile, item.Id]);
|
||||||
|
|||||||
@@ -29,15 +29,15 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
|||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
|
|
||||||
const base64Image = useMemo(() => {
|
const base64Image = useMemo(() => {
|
||||||
return storage.getString(item?.Id!);
|
return item?.Id ? storage.getString(item.Id) : undefined;
|
||||||
}, []);
|
}, [item?.Id]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles deleting the file with haptic feedback.
|
* Handles deleting the file with haptic feedback.
|
||||||
*/
|
*/
|
||||||
const handleDeleteFile = useCallback(() => {
|
const handleDeleteFile = useCallback(() => {
|
||||||
if (item.Id) {
|
if (item.Id) {
|
||||||
deleteFile(item.Id, item.Type);
|
deleteFile(item.Id);
|
||||||
}
|
}
|
||||||
}, [deleteFile, item.Id]);
|
}, [deleteFile, item.Id]);
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,13 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
|
|||||||
return storage.getString(items[0].SeriesId!);
|
return storage.getString(items[0].SeriesId!);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const deleteSeries = useCallback(async () => deleteItems(items), [items]);
|
const deleteSeries = useCallback(
|
||||||
|
async () =>
|
||||||
|
deleteItems(
|
||||||
|
items.map((item) => item.Id).filter((id) => id !== undefined),
|
||||||
|
),
|
||||||
|
[items],
|
||||||
|
);
|
||||||
|
|
||||||
const showActionSheet = useCallback(() => {
|
const showActionSheet = useCallback(() => {
|
||||||
const options = ["Delete", "Cancel"];
|
const options = ["Delete", "Cancel"];
|
||||||
|
|||||||
@@ -109,11 +109,22 @@ export const FilterSheet = <T,>({
|
|||||||
// to implement efficient "load more" functionality
|
// to implement efficient "load more" functionality
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!_data || _data.length === 0) return;
|
if (!_data || _data.length === 0) return;
|
||||||
const tmp = new Set(data);
|
|
||||||
|
const newData = [...data];
|
||||||
|
|
||||||
for (let i = offset; i < Math.min(_data.length, offset + LIMIT); i++) {
|
for (let i = offset; i < Math.min(_data.length, offset + LIMIT); i++) {
|
||||||
tmp.add(_data[i]);
|
const item = _data[i];
|
||||||
|
// Check if this item already exists in our data array
|
||||||
|
// some dups happened with re-renders during testing
|
||||||
|
const exists = newData.some((existingItem) =>
|
||||||
|
isEqual(existingItem, item),
|
||||||
|
);
|
||||||
|
if (!exists) {
|
||||||
|
newData.push(item);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setData(Array.from(tmp));
|
|
||||||
|
setData(newData);
|
||||||
}, [offset, _data]);
|
}, [offset, _data]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -233,7 +244,7 @@ export const FilterSheet = <T,>({
|
|||||||
{data.length < (_data?.length || 0) && (
|
{data.length < (_data?.length || 0) && (
|
||||||
<Button
|
<Button
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
setOffset(offset + 100);
|
setOffset(offset + LIMIT);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Load more
|
Load more
|
||||||
|
|||||||
506
components/home/Home.tsx
Normal file
506
components/home/Home.tsx
Normal file
@@ -0,0 +1,506 @@
|
|||||||
|
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||||
|
import type {
|
||||||
|
BaseItemDto,
|
||||||
|
BaseItemDtoQueryResult,
|
||||||
|
BaseItemKind,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import {
|
||||||
|
getItemsApi,
|
||||||
|
getSuggestionsApi,
|
||||||
|
getTvShowsApi,
|
||||||
|
getUserLibraryApi,
|
||||||
|
getUserViewsApi,
|
||||||
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { type QueryFunction, useQuery } from "@tanstack/react-query";
|
||||||
|
import { useNavigation, useRouter, useSegments } from "expo-router";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Platform,
|
||||||
|
RefreshControl,
|
||||||
|
ScrollView,
|
||||||
|
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 { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
||||||
|
import { Colors } from "@/constants/Colors";
|
||||||
|
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
||||||
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { eventBus } from "@/utils/eventBus";
|
||||||
|
|
||||||
|
type InfiniteScrollingCollectionListSection = {
|
||||||
|
type: "InfiniteScrollingCollectionList";
|
||||||
|
title?: string;
|
||||||
|
queryKey: (string | undefined | null)[];
|
||||||
|
queryFn: QueryFunction<BaseItemDto[], any, number>;
|
||||||
|
orientation?: "horizontal" | "vertical";
|
||||||
|
pageSize?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MediaListSectionType = {
|
||||||
|
type: "MediaListSection";
|
||||||
|
queryKey: (string | undefined)[];
|
||||||
|
queryFn: QueryFunction<BaseItemDto>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Section = InfiniteScrollingCollectionListSection | MediaListSectionType;
|
||||||
|
|
||||||
|
export const Home = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { settings, refreshStreamyfinPluginSettings } = useSettings();
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const scrollRef = useRef<ScrollView>(null);
|
||||||
|
const { downloadedItems, cleanCacheDirectory } = useDownload();
|
||||||
|
const prevIsConnected = useRef<boolean | null>(false);
|
||||||
|
const {
|
||||||
|
isConnected,
|
||||||
|
serverConnected,
|
||||||
|
loading: retryLoading,
|
||||||
|
retryCheck,
|
||||||
|
} = useNetworkStatus();
|
||||||
|
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||||
|
const [scrollY, setScrollY] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isConnected && !prevIsConnected.current) {
|
||||||
|
invalidateCache();
|
||||||
|
}
|
||||||
|
prevIsConnected.current = isConnected;
|
||||||
|
}, [isConnected, invalidateCache]);
|
||||||
|
|
||||||
|
const hasDownloads = useMemo(() => {
|
||||||
|
if (Platform.isTV) return false;
|
||||||
|
return downloadedItems.length > 0;
|
||||||
|
}, [downloadedItems]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (Platform.isTV) {
|
||||||
|
navigation.setOptions({
|
||||||
|
headerLeft: () => null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigation.setOptions({
|
||||||
|
headerLeft: () => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
router.push("/(auth)/downloads");
|
||||||
|
}}
|
||||||
|
className='ml-1.5'
|
||||||
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
|
>
|
||||||
|
<Feather
|
||||||
|
name='download'
|
||||||
|
color={hasDownloads ? Colors.primary : "white"}
|
||||||
|
size={24}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}, [navigation, router, hasDownloads]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
cleanCacheDirectory().catch((_e) =>
|
||||||
|
console.error("Something went wrong cleaning cache directory"),
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const segments = useSegments();
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = eventBus.on("scrollToTop", () => {
|
||||||
|
if ((segments as string[])[2] === "(home)")
|
||||||
|
scrollRef.current?.scrollTo({
|
||||||
|
y: Platform.isTV ? -152 : -100,
|
||||||
|
animated: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
}, [segments]);
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
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 refetch = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
await refreshStreamyfinPluginSettings();
|
||||||
|
await invalidateCache();
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createCollectionConfig = useCallback(
|
||||||
|
(
|
||||||
|
title: string,
|
||||||
|
queryKey: string[],
|
||||||
|
includeItemTypes: BaseItemKind[],
|
||||||
|
parentId: string | undefined,
|
||||||
|
pageSize: number = 10,
|
||||||
|
): InfiniteScrollingCollectionListSection => ({
|
||||||
|
title,
|
||||||
|
queryKey,
|
||||||
|
queryFn: async ({ pageParam = 0 }) => {
|
||||||
|
if (!api) return [];
|
||||||
|
// getLatestMedia doesn't support startIndex, so we fetch all and slice client-side
|
||||||
|
const allData =
|
||||||
|
(
|
||||||
|
await getUserLibraryApi(api).getLatestMedia({
|
||||||
|
userId: user?.Id,
|
||||||
|
limit: 100, // Fetch a larger set for pagination
|
||||||
|
fields: ["PrimaryImageAspectRatio", "Path", "Genres"],
|
||||||
|
imageTypeLimit: 1,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||||
|
includeItemTypes,
|
||||||
|
parentId,
|
||||||
|
})
|
||||||
|
).data || [];
|
||||||
|
|
||||||
|
// Simulate pagination by slicing
|
||||||
|
return allData.slice(pageParam, pageParam + pageSize);
|
||||||
|
},
|
||||||
|
type: "InfiniteScrollingCollectionList",
|
||||||
|
pageSize,
|
||||||
|
}),
|
||||||
|
[api, user?.Id],
|
||||||
|
);
|
||||||
|
|
||||||
|
const defaultSections = useMemo(() => {
|
||||||
|
if (!api || !user?.Id) return [];
|
||||||
|
|
||||||
|
const latestMediaViews = collections.map((c) => {
|
||||||
|
const includeItemTypes: BaseItemKind[] =
|
||||||
|
c.CollectionType === "tvshows" || c.CollectionType === "movies"
|
||||||
|
? []
|
||||||
|
: ["Movie"];
|
||||||
|
const title = t("home.recently_added_in", { libraryName: c.Name });
|
||||||
|
const queryKey: string[] = [
|
||||||
|
"home",
|
||||||
|
`recentlyAddedIn${c.CollectionType}`,
|
||||||
|
user.Id!,
|
||||||
|
c.Id!,
|
||||||
|
];
|
||||||
|
return createCollectionConfig(
|
||||||
|
title || "",
|
||||||
|
queryKey,
|
||||||
|
includeItemTypes,
|
||||||
|
c.Id,
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const ss: Section[] = [
|
||||||
|
{
|
||||||
|
title: t("home.continue_watching"),
|
||||||
|
queryKey: ["home", "resumeItems"],
|
||||||
|
queryFn: async ({ pageParam = 0 }) =>
|
||||||
|
(
|
||||||
|
await getItemsApi(api).getResumeItems({
|
||||||
|
userId: user.Id,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||||
|
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||||
|
fields: ["Genres"],
|
||||||
|
startIndex: pageParam,
|
||||||
|
limit: 10,
|
||||||
|
})
|
||||||
|
).data.Items || [],
|
||||||
|
type: "InfiniteScrollingCollectionList",
|
||||||
|
orientation: "horizontal",
|
||||||
|
pageSize: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("home.next_up"),
|
||||||
|
queryKey: ["home", "nextUp-all"],
|
||||||
|
queryFn: async ({ pageParam = 0 }) =>
|
||||||
|
(
|
||||||
|
await getTvShowsApi(api).getNextUp({
|
||||||
|
userId: user?.Id,
|
||||||
|
fields: ["MediaSourceCount", "Genres"],
|
||||||
|
startIndex: pageParam,
|
||||||
|
limit: 10,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||||
|
enableResumable: false,
|
||||||
|
})
|
||||||
|
).data.Items || [],
|
||||||
|
type: "InfiniteScrollingCollectionList",
|
||||||
|
orientation: "horizontal",
|
||||||
|
pageSize: 10,
|
||||||
|
},
|
||||||
|
...latestMediaViews,
|
||||||
|
{
|
||||||
|
title: t("home.suggested_movies"),
|
||||||
|
queryKey: ["home", "suggestedMovies", user?.Id],
|
||||||
|
queryFn: async ({ pageParam = 0 }) =>
|
||||||
|
(
|
||||||
|
await getSuggestionsApi(api).getSuggestions({
|
||||||
|
userId: user?.Id,
|
||||||
|
startIndex: pageParam,
|
||||||
|
limit: 10,
|
||||||
|
mediaType: ["Video"],
|
||||||
|
type: ["Movie"],
|
||||||
|
})
|
||||||
|
).data.Items || [],
|
||||||
|
type: "InfiniteScrollingCollectionList",
|
||||||
|
orientation: "vertical",
|
||||||
|
pageSize: 10,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return ss;
|
||||||
|
}, [api, user?.Id, collections, t, createCollectionConfig]);
|
||||||
|
|
||||||
|
const customSections = useMemo(() => {
|
||||||
|
if (!api || !user?.Id || !settings?.home?.sections) return [];
|
||||||
|
const ss: Section[] = [];
|
||||||
|
settings.home.sections.forEach((section, index) => {
|
||||||
|
const id = section.title || `section-${index}`;
|
||||||
|
const pageSize = 10;
|
||||||
|
ss.push({
|
||||||
|
title: t(`${id}`),
|
||||||
|
queryKey: ["home", "custom", String(index), section.title ?? null],
|
||||||
|
queryFn: async ({ pageParam = 0 }) => {
|
||||||
|
if (section.items) {
|
||||||
|
const response = await getItemsApi(api).getItems({
|
||||||
|
userId: user?.Id,
|
||||||
|
startIndex: pageParam,
|
||||||
|
limit: section.items?.limit || pageSize,
|
||||||
|
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 || [];
|
||||||
|
}
|
||||||
|
if (section.nextUp) {
|
||||||
|
const response = await getTvShowsApi(api).getNextUp({
|
||||||
|
userId: user?.Id,
|
||||||
|
fields: ["MediaSourceCount", "Genres"],
|
||||||
|
startIndex: pageParam,
|
||||||
|
limit: section.nextUp?.limit || pageSize,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||||
|
enableResumable: section.nextUp?.enableResumable,
|
||||||
|
enableRewatching: section.nextUp?.enableRewatching,
|
||||||
|
});
|
||||||
|
return response.data.Items || [];
|
||||||
|
}
|
||||||
|
if (section.latest) {
|
||||||
|
// getLatestMedia doesn't support startIndex, so we fetch all and slice client-side
|
||||||
|
const allData =
|
||||||
|
(
|
||||||
|
await getUserLibraryApi(api).getLatestMedia({
|
||||||
|
userId: user?.Id,
|
||||||
|
includeItemTypes: section.latest?.includeItemTypes,
|
||||||
|
limit: section.latest?.limit || 100, // Fetch larger set
|
||||||
|
isPlayed: section.latest?.isPlayed,
|
||||||
|
groupItems: section.latest?.groupItems,
|
||||||
|
})
|
||||||
|
).data || [];
|
||||||
|
|
||||||
|
// Simulate pagination by slicing
|
||||||
|
return allData.slice(pageParam, pageParam + pageSize);
|
||||||
|
}
|
||||||
|
if (section.custom) {
|
||||||
|
const response = await api.get<BaseItemDtoQueryResult>(
|
||||||
|
section.custom.endpoint,
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
...(section.custom.query || {}),
|
||||||
|
userId: user?.Id,
|
||||||
|
startIndex: pageParam,
|
||||||
|
limit: pageSize,
|
||||||
|
},
|
||||||
|
headers: section.custom.headers || {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.data.Items || [];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
type: "InfiniteScrollingCollectionList",
|
||||||
|
orientation: section?.orientation || "vertical",
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return ss;
|
||||||
|
}, [api, user?.Id, settings?.home?.sections, t]);
|
||||||
|
|
||||||
|
const sections = settings?.home?.sections ? customSections : defaultSections;
|
||||||
|
|
||||||
|
if (!isConnected || serverConnected !== true) {
|
||||||
|
let title = "";
|
||||||
|
let subtitle = "";
|
||||||
|
|
||||||
|
if (!isConnected) {
|
||||||
|
title = t("home.no_internet");
|
||||||
|
subtitle = t("home.no_internet_message");
|
||||||
|
} else if (serverConnected === null) {
|
||||||
|
title = t("home.checking_server_connection");
|
||||||
|
subtitle = t("home.checking_server_connection_message");
|
||||||
|
} else if (!serverConnected) {
|
||||||
|
title = t("home.server_unreachable");
|
||||||
|
subtitle = t("home.server_unreachable_message");
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<View className='flex flex-col items-center justify-center h-full -mt-6 px-8'>
|
||||||
|
<Text className='text-3xl font-bold mb-2'>{title}</Text>
|
||||||
|
<Text className='text-center opacity-70'>{subtitle}</Text>
|
||||||
|
|
||||||
|
<View className='mt-4'>
|
||||||
|
{!Platform.isTV && (
|
||||||
|
<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={retryCheck}
|
||||||
|
justify='center'
|
||||||
|
className='mt-2'
|
||||||
|
iconRight={
|
||||||
|
retryLoading ? null : (
|
||||||
|
<Ionicons name='refresh' size={20} color='white' />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{retryLoading ? (
|
||||||
|
<ActivityIndicator size='small' color='white' />
|
||||||
|
) : (
|
||||||
|
t("home.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>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (l1)
|
||||||
|
return (
|
||||||
|
<View className='justify-center items-center h-full'>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
ref={scrollRef}
|
||||||
|
nestedScrollEnabled
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
|
onScroll={(event) => {
|
||||||
|
setScrollY(event.nativeEvent.contentOffset.y - 500);
|
||||||
|
}}
|
||||||
|
scrollEventThrottle={16}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={loading}
|
||||||
|
onRefresh={refetch}
|
||||||
|
tintColor='white'
|
||||||
|
colors={["white"]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
paddingBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className='flex flex-col space-y-4'
|
||||||
|
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||||
|
>
|
||||||
|
{sections.map((section, index) => {
|
||||||
|
if (section.type === "InfiniteScrollingCollectionList") {
|
||||||
|
return (
|
||||||
|
<InfiniteScrollingCollectionList
|
||||||
|
key={index}
|
||||||
|
title={section.title}
|
||||||
|
queryKey={section.queryKey}
|
||||||
|
queryFn={section.queryFn}
|
||||||
|
orientation={section.orientation}
|
||||||
|
hideIfEmpty
|
||||||
|
pageSize={section.pageSize}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (section.type === "MediaListSection") {
|
||||||
|
return (
|
||||||
|
<MediaListSection
|
||||||
|
key={index}
|
||||||
|
queryKey={section.queryKey}
|
||||||
|
queryFn={section.queryFn}
|
||||||
|
scrollY={scrollY}
|
||||||
|
enableLazyLoading={true}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||||
import type { Api } from "@jellyfin/sdk";
|
|
||||||
import type {
|
import type {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
BaseItemDtoQueryResult,
|
BaseItemDtoQueryResult,
|
||||||
@@ -20,15 +19,17 @@ import { useTranslation } from "react-i18next";
|
|||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Platform,
|
Platform,
|
||||||
RefreshControl,
|
|
||||||
ScrollView,
|
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
|
import Animated, {
|
||||||
|
useAnimatedRef,
|
||||||
|
useScrollViewOffset,
|
||||||
|
} from "react-native-reanimated";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
@@ -38,14 +39,15 @@ import { useDownload } from "@/providers/DownloadProvider";
|
|||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { eventBus } from "@/utils/eventBus";
|
import { eventBus } from "@/utils/eventBus";
|
||||||
import { AppleTVCarousel } from "../AppleTVCarousel";
|
import { AppleTVCarousel } from "../apple-tv-carousel/AppleTVCarousel";
|
||||||
|
|
||||||
type ScrollingCollectionListSection = {
|
type InfiniteScrollingCollectionListSection = {
|
||||||
type: "ScrollingCollectionList";
|
type: "InfiniteScrollingCollectionList";
|
||||||
title?: string;
|
title?: string;
|
||||||
queryKey: (string | undefined | null)[];
|
queryKey: (string | undefined | null)[];
|
||||||
queryFn: QueryFunction<BaseItemDto[]>;
|
queryFn: QueryFunction<BaseItemDto[], any, number>;
|
||||||
orientation?: "horizontal" | "vertical";
|
orientation?: "horizontal" | "vertical";
|
||||||
|
pageSize?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type MediaListSectionType = {
|
type MediaListSectionType = {
|
||||||
@@ -54,26 +56,21 @@ type MediaListSectionType = {
|
|||||||
queryFn: QueryFunction<BaseItemDto>;
|
queryFn: QueryFunction<BaseItemDto>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Section = ScrollingCollectionListSection | MediaListSectionType;
|
type Section = InfiniteScrollingCollectionListSection | MediaListSectionType;
|
||||||
|
|
||||||
export const HomeIndex = () => {
|
export const HomeWithCarousel = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const user = useAtomValue(userAtom);
|
const user = useAtomValue(userAtom);
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const { settings, refreshStreamyfinPluginSettings } = useSettings();
|
|
||||||
|
|
||||||
const navigation = useNavigation();
|
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
const [_loading, setLoading] = useState(false);
|
||||||
const scrollViewRef = useRef<ScrollView>(null);
|
const { settings, refreshStreamyfinPluginSettings } = useSettings();
|
||||||
|
const headerOverlayOffset = Platform.isTV ? 0 : 60;
|
||||||
const { getDownloadedItems, cleanCacheDirectory } = useDownload();
|
const navigation = useNavigation();
|
||||||
|
const animatedScrollRef = useAnimatedRef<Animated.ScrollView>();
|
||||||
|
const scrollOffset = useScrollViewOffset(animatedScrollRef);
|
||||||
|
const { downloadedItems, cleanCacheDirectory } = useDownload();
|
||||||
const prevIsConnected = useRef<boolean | null>(false);
|
const prevIsConnected = useRef<boolean | null>(false);
|
||||||
const {
|
const {
|
||||||
isConnected,
|
isConnected,
|
||||||
@@ -82,15 +79,20 @@ export const HomeIndex = () => {
|
|||||||
retryCheck,
|
retryCheck,
|
||||||
} = useNetworkStatus();
|
} = useNetworkStatus();
|
||||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||||
|
const [scrollY, setScrollY] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only invalidate cache when transitioning from offline to online
|
|
||||||
if (isConnected && !prevIsConnected.current) {
|
if (isConnected && !prevIsConnected.current) {
|
||||||
invalidateCache();
|
invalidateCache();
|
||||||
}
|
}
|
||||||
// Update the ref to the current state for the next render
|
|
||||||
prevIsConnected.current = isConnected;
|
prevIsConnected.current = isConnected;
|
||||||
}, [isConnected, invalidateCache]);
|
}, [isConnected, invalidateCache]);
|
||||||
|
|
||||||
|
const hasDownloads = useMemo(() => {
|
||||||
|
if (Platform.isTV) return false;
|
||||||
|
return downloadedItems.length > 0;
|
||||||
|
}, [downloadedItems]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.isTV) {
|
if (Platform.isTV) {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
@@ -98,24 +100,24 @@ export const HomeIndex = () => {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const hasDownloads = getDownloadedItems().length > 0;
|
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerLeft: () => (
|
headerLeft: () => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.push("/(auth)/downloads");
|
router.push("/(auth)/downloads");
|
||||||
}}
|
}}
|
||||||
className='p-2'
|
className='ml-1.5'
|
||||||
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
>
|
>
|
||||||
<Feather
|
<Feather
|
||||||
name='download'
|
name='download'
|
||||||
color={hasDownloads ? Colors.primary : "white"}
|
color={hasDownloads ? Colors.primary : "white"}
|
||||||
size={22}
|
size={24}
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}, [navigation, router]);
|
}, [navigation, router, hasDownloads]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
cleanCacheDirectory().catch((_e) =>
|
cleanCacheDirectory().catch((_e) =>
|
||||||
@@ -127,7 +129,7 @@ export const HomeIndex = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = eventBus.on("scrollToTop", () => {
|
const unsubscribe = eventBus.on("scrollToTop", () => {
|
||||||
if ((segments as string[])[2] === "(home)")
|
if ((segments as string[])[2] === "(home)")
|
||||||
scrollViewRef.current?.scrollTo({
|
animatedScrollRef.current?.scrollTo({
|
||||||
y: Platform.isTV ? -152 : -100,
|
y: Platform.isTV ? -152 : -100,
|
||||||
animated: true,
|
animated: true,
|
||||||
});
|
});
|
||||||
@@ -173,7 +175,7 @@ export const HomeIndex = () => {
|
|||||||
);
|
);
|
||||||
}, [userViews]);
|
}, [userViews]);
|
||||||
|
|
||||||
const refetch = async () => {
|
const _refetch = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
await refreshStreamyfinPluginSettings();
|
await refreshStreamyfinPluginSettings();
|
||||||
await invalidateCache();
|
await invalidateCache();
|
||||||
@@ -186,42 +188,48 @@ export const HomeIndex = () => {
|
|||||||
queryKey: string[],
|
queryKey: string[],
|
||||||
includeItemTypes: BaseItemKind[],
|
includeItemTypes: BaseItemKind[],
|
||||||
parentId: string | undefined,
|
parentId: string | undefined,
|
||||||
): ScrollingCollectionListSection => ({
|
pageSize: number = 10,
|
||||||
|
): InfiniteScrollingCollectionListSection => ({
|
||||||
title,
|
title,
|
||||||
queryKey,
|
queryKey,
|
||||||
queryFn: async () => {
|
queryFn: async ({ pageParam = 0 }) => {
|
||||||
if (!api) return [];
|
if (!api) return [];
|
||||||
return (
|
// getLatestMedia doesn't support startIndex, so we fetch all and slice client-side
|
||||||
|
const allData =
|
||||||
(
|
(
|
||||||
await getUserLibraryApi(api).getLatestMedia({
|
await getUserLibraryApi(api).getLatestMedia({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
limit: 20,
|
limit: 100, // Fetch a larger set for pagination
|
||||||
fields: ["PrimaryImageAspectRatio", "Path", "Genres"],
|
fields: ["PrimaryImageAspectRatio", "Path", "Genres"],
|
||||||
imageTypeLimit: 1,
|
imageTypeLimit: 1,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||||
includeItemTypes,
|
includeItemTypes,
|
||||||
parentId,
|
parentId,
|
||||||
})
|
})
|
||||||
).data || []
|
).data || [];
|
||||||
);
|
|
||||||
|
// Simulate pagination by slicing
|
||||||
|
return allData.slice(pageParam, pageParam + pageSize);
|
||||||
},
|
},
|
||||||
type: "ScrollingCollectionList",
|
type: "InfiniteScrollingCollectionList",
|
||||||
|
pageSize,
|
||||||
}),
|
}),
|
||||||
[api, user?.Id],
|
[api, user?.Id],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Always call useMemo() at the top-level, using computed dependencies for both "default"/custom sections
|
|
||||||
const defaultSections = useMemo(() => {
|
const defaultSections = useMemo(() => {
|
||||||
if (!api || !user?.Id) return [];
|
if (!api || !user?.Id) return [];
|
||||||
|
|
||||||
const latestMediaViews = collections.map((c) => {
|
const latestMediaViews = collections.map((c) => {
|
||||||
const includeItemTypes: BaseItemKind[] =
|
const includeItemTypes: BaseItemKind[] =
|
||||||
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"];
|
c.CollectionType === "tvshows" || c.CollectionType === "movies"
|
||||||
|
? []
|
||||||
|
: ["Movie"];
|
||||||
const title = t("home.recently_added_in", { libraryName: c.Name });
|
const title = t("home.recently_added_in", { libraryName: c.Name });
|
||||||
const queryKey = [
|
const queryKey: string[] = [
|
||||||
"home",
|
"home",
|
||||||
`recentlyAddedIn${c.CollectionType}`,
|
`recentlyAddedIn${c.CollectionType}`,
|
||||||
user?.Id!,
|
user.Id!,
|
||||||
c.Id!,
|
c.Id!,
|
||||||
];
|
];
|
||||||
return createCollectionConfig(
|
return createCollectionConfig(
|
||||||
@@ -229,6 +237,7 @@ export const HomeIndex = () => {
|
|||||||
queryKey,
|
queryKey,
|
||||||
includeItemTypes,
|
includeItemTypes,
|
||||||
c.Id,
|
c.Id,
|
||||||
|
10,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -236,79 +245,56 @@ export const HomeIndex = () => {
|
|||||||
{
|
{
|
||||||
title: t("home.continue_watching"),
|
title: t("home.continue_watching"),
|
||||||
queryKey: ["home", "resumeItems"],
|
queryKey: ["home", "resumeItems"],
|
||||||
queryFn: async () =>
|
queryFn: async ({ pageParam = 0 }) =>
|
||||||
(
|
(
|
||||||
await getItemsApi(api).getResumeItems({
|
await getItemsApi(api).getResumeItems({
|
||||||
userId: user.Id,
|
userId: user.Id,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||||
fields: ["Genres"],
|
fields: ["Genres"],
|
||||||
|
startIndex: pageParam,
|
||||||
|
limit: 10,
|
||||||
})
|
})
|
||||||
).data.Items || [],
|
).data.Items || [],
|
||||||
type: "ScrollingCollectionList",
|
type: "InfiniteScrollingCollectionList",
|
||||||
orientation: "horizontal",
|
orientation: "horizontal",
|
||||||
|
pageSize: 10,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("home.next_up"),
|
title: t("home.next_up"),
|
||||||
queryKey: ["home", "nextUp-all"],
|
queryKey: ["home", "nextUp-all"],
|
||||||
queryFn: async () =>
|
queryFn: async ({ pageParam = 0 }) =>
|
||||||
(
|
(
|
||||||
await getTvShowsApi(api).getNextUp({
|
await getTvShowsApi(api).getNextUp({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
fields: ["MediaSourceCount", "Genres"],
|
fields: ["MediaSourceCount", "Genres"],
|
||||||
limit: 20,
|
startIndex: pageParam,
|
||||||
|
limit: 10,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||||
enableResumable: false,
|
enableResumable: false,
|
||||||
})
|
})
|
||||||
).data.Items || [],
|
).data.Items || [],
|
||||||
type: "ScrollingCollectionList",
|
type: "InfiniteScrollingCollectionList",
|
||||||
orientation: "horizontal",
|
orientation: "horizontal",
|
||||||
|
pageSize: 10,
|
||||||
},
|
},
|
||||||
...latestMediaViews,
|
...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"),
|
title: t("home.suggested_movies"),
|
||||||
queryKey: ["home", "suggestedMovies", user?.Id],
|
queryKey: ["home", "suggestedMovies", user?.Id],
|
||||||
queryFn: async () =>
|
queryFn: async ({ pageParam = 0 }) =>
|
||||||
(
|
(
|
||||||
await getSuggestionsApi(api).getSuggestions({
|
await getSuggestionsApi(api).getSuggestions({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
|
startIndex: pageParam,
|
||||||
limit: 10,
|
limit: 10,
|
||||||
mediaType: ["Video"],
|
mediaType: ["Video"],
|
||||||
type: ["Movie"],
|
type: ["Movie"],
|
||||||
})
|
})
|
||||||
).data.Items || [],
|
).data.Items || [],
|
||||||
type: "ScrollingCollectionList",
|
type: "InfiniteScrollingCollectionList",
|
||||||
orientation: "vertical",
|
orientation: "vertical",
|
||||||
},
|
pageSize: 10,
|
||||||
{
|
|
||||||
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;
|
return ss;
|
||||||
@@ -317,16 +303,18 @@ export const HomeIndex = () => {
|
|||||||
const customSections = useMemo(() => {
|
const customSections = useMemo(() => {
|
||||||
if (!api || !user?.Id || !settings?.home?.sections) return [];
|
if (!api || !user?.Id || !settings?.home?.sections) return [];
|
||||||
const ss: Section[] = [];
|
const ss: Section[] = [];
|
||||||
for (const [index, section] of settings.home.sections.entries()) {
|
settings.home.sections.forEach((section, index) => {
|
||||||
const id = section.title || `section-${index}`;
|
const id = section.title || `section-${index}`;
|
||||||
|
const pageSize = 10;
|
||||||
ss.push({
|
ss.push({
|
||||||
title: t(`${id}`),
|
title: t(`${id}`),
|
||||||
queryKey: ["home", "custom", String(index), section.title ?? null],
|
queryKey: ["home", "custom", String(index), section.title ?? null],
|
||||||
queryFn: async () => {
|
queryFn: async ({ pageParam = 0 }) => {
|
||||||
if (section.items) {
|
if (section.items) {
|
||||||
const response = await getItemsApi(api).getItems({
|
const response = await getItemsApi(api).getItems({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
limit: section.items?.limit || 25,
|
startIndex: pageParam,
|
||||||
|
limit: section.items?.limit || pageSize,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
includeItemTypes: section.items?.includeItemTypes,
|
includeItemTypes: section.items?.includeItemTypes,
|
||||||
sortBy: section.items?.sortBy,
|
sortBy: section.items?.sortBy,
|
||||||
@@ -340,7 +328,8 @@ export const HomeIndex = () => {
|
|||||||
const response = await getTvShowsApi(api).getNextUp({
|
const response = await getTvShowsApi(api).getNextUp({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
fields: ["MediaSourceCount", "Genres"],
|
fields: ["MediaSourceCount", "Genres"],
|
||||||
limit: section.nextUp?.limit || 25,
|
startIndex: pageParam,
|
||||||
|
limit: section.nextUp?.limit || pageSize,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||||
enableResumable: section.nextUp?.enableResumable,
|
enableResumable: section.nextUp?.enableResumable,
|
||||||
enableRewatching: section.nextUp?.enableRewatching,
|
enableRewatching: section.nextUp?.enableRewatching,
|
||||||
@@ -348,20 +337,31 @@ export const HomeIndex = () => {
|
|||||||
return response.data.Items || [];
|
return response.data.Items || [];
|
||||||
}
|
}
|
||||||
if (section.latest) {
|
if (section.latest) {
|
||||||
const response = await getUserLibraryApi(api).getLatestMedia({
|
// getLatestMedia doesn't support startIndex, so we fetch all and slice client-side
|
||||||
userId: user?.Id,
|
const allData =
|
||||||
includeItemTypes: section.latest?.includeItemTypes,
|
(
|
||||||
limit: section.latest?.limit || 25,
|
await getUserLibraryApi(api).getLatestMedia({
|
||||||
isPlayed: section.latest?.isPlayed,
|
userId: user?.Id,
|
||||||
groupItems: section.latest?.groupItems,
|
includeItemTypes: section.latest?.includeItemTypes,
|
||||||
});
|
limit: section.latest?.limit || 100, // Fetch larger set
|
||||||
return response.data || [];
|
isPlayed: section.latest?.isPlayed,
|
||||||
|
groupItems: section.latest?.groupItems,
|
||||||
|
})
|
||||||
|
).data || [];
|
||||||
|
|
||||||
|
// Simulate pagination by slicing
|
||||||
|
return allData.slice(pageParam, pageParam + pageSize);
|
||||||
}
|
}
|
||||||
if (section.custom) {
|
if (section.custom) {
|
||||||
const response = await api.get<BaseItemDtoQueryResult>(
|
const response = await api.get<BaseItemDtoQueryResult>(
|
||||||
section.custom.endpoint,
|
section.custom.endpoint,
|
||||||
{
|
{
|
||||||
params: { ...(section.custom.query || {}), userId: user?.Id },
|
params: {
|
||||||
|
...(section.custom.query || {}),
|
||||||
|
userId: user?.Id,
|
||||||
|
startIndex: pageParam,
|
||||||
|
limit: pageSize,
|
||||||
|
},
|
||||||
headers: section.custom.headers || {},
|
headers: section.custom.headers || {},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -369,12 +369,13 @@ export const HomeIndex = () => {
|
|||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
},
|
},
|
||||||
type: "ScrollingCollectionList",
|
type: "InfiniteScrollingCollectionList",
|
||||||
orientation: section?.orientation || "vertical",
|
orientation: section?.orientation || "vertical",
|
||||||
|
pageSize,
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
return ss;
|
return ss;
|
||||||
}, [api, user?.Id, settings?.home?.sections]);
|
}, [api, user?.Id, settings?.home?.sections, t]);
|
||||||
|
|
||||||
const sections = settings?.home?.sections ? customSections : defaultSections;
|
const sections = settings?.home?.sections ? customSections : defaultSections;
|
||||||
|
|
||||||
@@ -383,15 +384,12 @@ export const HomeIndex = () => {
|
|||||||
let subtitle = "";
|
let subtitle = "";
|
||||||
|
|
||||||
if (!isConnected) {
|
if (!isConnected) {
|
||||||
// No network connection
|
|
||||||
title = t("home.no_internet");
|
title = t("home.no_internet");
|
||||||
subtitle = t("home.no_internet_message");
|
subtitle = t("home.no_internet_message");
|
||||||
} else if (serverConnected === null) {
|
} else if (serverConnected === null) {
|
||||||
// Network is up, but server is being checked
|
|
||||||
title = t("home.checking_server_connection");
|
title = t("home.checking_server_connection");
|
||||||
subtitle = t("home.checking_server_connection_message");
|
subtitle = t("home.checking_server_connection_message");
|
||||||
} else if (!serverConnected) {
|
} else if (!serverConnected) {
|
||||||
// Network is up, but server is unreachable
|
|
||||||
title = t("home.server_unreachable");
|
title = t("home.server_unreachable");
|
||||||
subtitle = t("home.server_unreachable_message");
|
subtitle = t("home.server_unreachable_message");
|
||||||
}
|
}
|
||||||
@@ -454,42 +452,41 @@ export const HomeIndex = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<Animated.ScrollView
|
||||||
scrollToOverflowEnabled={true}
|
scrollToOverflowEnabled={true}
|
||||||
ref={scrollViewRef}
|
ref={animatedScrollRef}
|
||||||
nestedScrollEnabled
|
nestedScrollEnabled
|
||||||
contentInsetAdjustmentBehavior='never'
|
contentInsetAdjustmentBehavior='never'
|
||||||
refreshControl={
|
scrollEventThrottle={16}
|
||||||
<RefreshControl
|
bounces={false}
|
||||||
refreshing={loading}
|
overScrollMode='never'
|
||||||
onRefresh={refetch}
|
style={{ marginTop: -headerOverlayOffset }}
|
||||||
tintColor='white' // For iOS
|
contentContainerStyle={{ paddingTop: headerOverlayOffset }}
|
||||||
colors={["white"]} // For Android
|
onScroll={(event) => {
|
||||||
progressViewOffset={200} // This offsets the refresh indicator to appear over the carousel
|
setScrollY(event.nativeEvent.contentOffset.y);
|
||||||
/>
|
}}
|
||||||
}
|
|
||||||
style={{ marginTop: Platform.isTV ? 0 : -100 }}
|
|
||||||
contentContainerStyle={{ paddingTop: Platform.isTV ? 0 : 100 }}
|
|
||||||
>
|
>
|
||||||
<AppleTVCarousel initialIndex={0} />
|
<AppleTVCarousel initialIndex={0} scrollOffset={scrollOffset} />
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
paddingLeft: insets.left,
|
paddingLeft: insets.left,
|
||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
paddingBottom: 16,
|
paddingBottom: 16,
|
||||||
|
paddingTop: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className='flex flex-col space-y-4'>
|
<View className='flex flex-col space-y-4'>
|
||||||
{sections.map((section, index) => {
|
{sections.map((section, index) => {
|
||||||
if (section.type === "ScrollingCollectionList") {
|
if (section.type === "InfiniteScrollingCollectionList") {
|
||||||
return (
|
return (
|
||||||
<ScrollingCollectionList
|
<InfiniteScrollingCollectionList
|
||||||
key={index}
|
key={index}
|
||||||
title={section.title}
|
title={section.title}
|
||||||
queryKey={section.queryKey}
|
queryKey={section.queryKey}
|
||||||
queryFn={section.queryFn}
|
queryFn={section.queryFn}
|
||||||
orientation={section.orientation}
|
orientation={section.orientation}
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
|
pageSize={section.pageSize}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -499,6 +496,8 @@ export const HomeIndex = () => {
|
|||||||
key={index}
|
key={index}
|
||||||
queryKey={section.queryKey}
|
queryKey={section.queryKey}
|
||||||
queryFn={section.queryFn}
|
queryFn={section.queryFn}
|
||||||
|
scrollY={scrollY}
|
||||||
|
enableLazyLoading={true}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -507,33 +506,6 @@ export const HomeIndex = () => {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View className='h-24' />
|
<View className='h-24' />
|
||||||
</ScrollView>
|
</Animated.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;
|
|
||||||
}
|
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
|
import { Colors } from "../../constants/Colors";
|
||||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||||
import { ItemCardText } from "../ItemCardText";
|
import { ItemCardText } from "../ItemCardText";
|
||||||
@@ -35,7 +36,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
queryFn,
|
queryFn,
|
||||||
queryKey,
|
queryKey,
|
||||||
hideIfEmpty = false,
|
hideIfEmpty = false,
|
||||||
pageSize = 20,
|
pageSize = 10,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
|
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
|
||||||
@@ -52,9 +53,9 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
return allPages.length * pageSize;
|
return allPages.length * pageSize;
|
||||||
},
|
},
|
||||||
initialPageParam: 0,
|
initialPageParam: 0,
|
||||||
staleTime: 0,
|
staleTime: 60 * 1000, // 1 minute
|
||||||
refetchOnMount: true,
|
refetchOnMount: false,
|
||||||
refetchOnWindowFocus: true,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: true,
|
refetchOnReconnect: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -179,8 +180,13 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
))}
|
))}
|
||||||
{/* Loading indicator for next page */}
|
{/* Loading indicator for next page */}
|
||||||
{isFetchingNextPage && (
|
{isFetchingNextPage && (
|
||||||
<View className='justify-center items-center w-16'>
|
<View
|
||||||
<ActivityIndicator size='small' color='#6366f1' />
|
style={{
|
||||||
|
marginLeft: 8,
|
||||||
|
marginTop: orientation === "horizontal" ? 37 : 70,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ActivityIndicator size='small' color={Colors.primary} />
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { ScrollView, View, type ViewProps } from "react-native";
|
import { ScrollView, View, type ViewProps } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
|
import { useInView } from "@/hooks/useInView";
|
||||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||||
import { ItemCardText } from "../ItemCardText";
|
import { ItemCardText } from "../ItemCardText";
|
||||||
@@ -21,6 +22,8 @@ interface Props extends ViewProps {
|
|||||||
queryFn: QueryFunction<BaseItemDto[]>;
|
queryFn: QueryFunction<BaseItemDto[]>;
|
||||||
hideIfEmpty?: boolean;
|
hideIfEmpty?: boolean;
|
||||||
isOffline?: boolean;
|
isOffline?: boolean;
|
||||||
|
scrollY?: number; // For lazy loading
|
||||||
|
enableLazyLoading?: boolean; // Enable/disable lazy loading
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ScrollingCollectionList: React.FC<Props> = ({
|
export const ScrollingCollectionList: React.FC<Props> = ({
|
||||||
@@ -31,33 +34,44 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
|||||||
queryKey,
|
queryKey,
|
||||||
hideIfEmpty = false,
|
hideIfEmpty = false,
|
||||||
isOffline = false,
|
isOffline = false,
|
||||||
|
scrollY = 0,
|
||||||
|
enableLazyLoading = false,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
|
const { ref, isInView, onLayout } = useInView(scrollY, {
|
||||||
|
enabled: enableLazyLoading,
|
||||||
|
});
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: queryKey,
|
queryKey: queryKey,
|
||||||
queryFn,
|
queryFn,
|
||||||
staleTime: 0,
|
staleTime: 60 * 1000, // 1 minute
|
||||||
refetchOnMount: true,
|
refetchOnMount: false,
|
||||||
refetchOnWindowFocus: true,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: true,
|
refetchOnReconnect: true,
|
||||||
|
enabled: enableLazyLoading ? isInView : true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (hideIfEmpty === true && data?.length === 0) return null;
|
// Show skeleton if loading OR if lazy loading is enabled and not in view yet
|
||||||
|
const shouldShowSkeleton = isLoading || (enableLazyLoading && !isInView);
|
||||||
|
|
||||||
|
if (hideIfEmpty === true && data?.length === 0 && !shouldShowSkeleton)
|
||||||
|
return null;
|
||||||
if (disabled || !title) return null;
|
if (disabled || !title) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View ref={ref} onLayout={onLayout} {...props}>
|
||||||
<Text className='px-4 text-lg font-bold mb-2 text-neutral-100'>
|
<Text className='px-4 text-lg font-bold mb-2 text-neutral-100'>
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
{isLoading === false && data?.length === 0 && (
|
{!shouldShowSkeleton && data?.length === 0 && (
|
||||||
<View className='px-4'>
|
<View className='px-4'>
|
||||||
<Text className='text-neutral-500'>{t("home.no_items")}</Text>
|
<Text className='text-neutral-500'>{t("home.no_items")}</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{isLoading ? (
|
{shouldShowSkeleton ? (
|
||||||
<View
|
<View
|
||||||
className={`
|
className={`
|
||||||
flex flex-row gap-2 px-4
|
flex flex-row gap-2 px-4
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ const CastSlide: React.FC<
|
|||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
data={details?.credits.cast}
|
data={details?.credits.cast}
|
||||||
ItemSeparatorComponent={() => <View className='w-2' />}
|
ItemSeparatorComponent={() => <View className='w-2' />}
|
||||||
estimatedItemSize={15}
|
|
||||||
keyExtractor={(item) => item?.id?.toString()}
|
keyExtractor={(item) => item?.id?.toString()}
|
||||||
contentContainerStyle={{ paddingHorizontal: 16 }}
|
contentContainerStyle={{ paddingHorizontal: 16 }}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
|
|||||||
@@ -143,7 +143,6 @@ const ParallaxSlideShow = <T,>({
|
|||||||
renderItem={({ item, index }) => renderItem(item, index)}
|
renderItem={({ item, index }) => renderItem(item, index)}
|
||||||
keyExtractor={keyExtractor}
|
keyExtractor={keyExtractor}
|
||||||
numColumns={3}
|
numColumns={3}
|
||||||
estimatedItemSize={214}
|
|
||||||
ItemSeparatorComponent={() => <View className='h-2 w-2' />}
|
ItemSeparatorComponent={() => <View className='h-2 w-2' />}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import { forwardRef, useCallback, useMemo, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { View, type ViewProps } from "react-native";
|
import { View, type ViewProps } from "react-native";
|
||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import Dropdown from "@/components/common/Dropdown";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import type {
|
import type {
|
||||||
QualityProfile,
|
QualityProfile,
|
||||||
@@ -48,8 +48,22 @@ const RequestModal = forwardRef<
|
|||||||
userId: jellyseerrUser?.id,
|
userId: jellyseerrUser?.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [qualityProfileOpen, setQualityProfileOpen] = useState(false);
|
||||||
|
const [rootFolderOpen, setRootFolderOpen] = useState(false);
|
||||||
|
const [tagsOpen, setTagsOpen] = useState(false);
|
||||||
|
const [usersOpen, setUsersOpen] = useState(false);
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// Reset all dropdown states when modal closes
|
||||||
|
const handleDismiss = useCallback(() => {
|
||||||
|
setQualityProfileOpen(false);
|
||||||
|
setRootFolderOpen(false);
|
||||||
|
setTagsOpen(false);
|
||||||
|
setUsersOpen(false);
|
||||||
|
onDismiss?.();
|
||||||
|
}, [onDismiss]);
|
||||||
|
|
||||||
const { data: serviceSettings } = useQuery({
|
const { data: serviceSettings } = useQuery({
|
||||||
queryKey: ["jellyseerr", "request", type, "service"],
|
queryKey: ["jellyseerr", "request", type, "service"],
|
||||||
queryFn: async () =>
|
queryFn: async () =>
|
||||||
@@ -130,14 +144,120 @@ const RequestModal = forwardRef<
|
|||||||
}, [defaultServiceDetails]);
|
}, [defaultServiceDetails]);
|
||||||
|
|
||||||
const seasonTitle = useMemo(() => {
|
const seasonTitle = useMemo(() => {
|
||||||
if (requestBody?.seasons && requestBody?.seasons?.length > 1) {
|
if (!requestBody?.seasons || requestBody.seasons.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (requestBody.seasons.length > 1) {
|
||||||
return t("jellyseerr.season_all");
|
return t("jellyseerr.season_all");
|
||||||
}
|
}
|
||||||
return t("jellyseerr.season_number", {
|
return t("jellyseerr.season_number", {
|
||||||
season_number: requestBody?.seasons,
|
season_number: requestBody.seasons[0],
|
||||||
});
|
});
|
||||||
}, [requestBody?.seasons]);
|
}, [requestBody?.seasons]);
|
||||||
|
|
||||||
|
const pathTitleExtractor = (item: RootFolder) =>
|
||||||
|
`${item.path} (${item.freeSpace.bytesToReadable()})`;
|
||||||
|
|
||||||
|
const qualityProfileOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
options:
|
||||||
|
defaultServiceDetails?.profiles.map((profile) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: profile.name,
|
||||||
|
value: profile.id.toString(),
|
||||||
|
selected:
|
||||||
|
(requestOverrides.profileId || defaultProfile?.id) ===
|
||||||
|
profile.id,
|
||||||
|
onPress: () =>
|
||||||
|
setRequestOverrides((prev) => ({
|
||||||
|
...prev,
|
||||||
|
profileId: profile.id,
|
||||||
|
})),
|
||||||
|
})) || [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
defaultServiceDetails?.profiles,
|
||||||
|
defaultProfile,
|
||||||
|
requestOverrides.profileId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const rootFolderOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
options:
|
||||||
|
defaultServiceDetails?.rootFolders.map((folder) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: pathTitleExtractor(folder),
|
||||||
|
value: folder.id.toString(),
|
||||||
|
selected:
|
||||||
|
(requestOverrides.rootFolder || defaultFolder?.path) ===
|
||||||
|
folder.path,
|
||||||
|
onPress: () =>
|
||||||
|
setRequestOverrides((prev) => ({
|
||||||
|
...prev,
|
||||||
|
rootFolder: folder.path,
|
||||||
|
})),
|
||||||
|
})) || [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
defaultServiceDetails?.rootFolders,
|
||||||
|
defaultFolder,
|
||||||
|
requestOverrides.rootFolder,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const tagsOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
options:
|
||||||
|
defaultServiceDetails?.tags.map((tag) => ({
|
||||||
|
type: "toggle" as const,
|
||||||
|
label: tag.label,
|
||||||
|
value:
|
||||||
|
requestOverrides.tags?.includes(tag.id) ||
|
||||||
|
defaultTags.some((dt) => dt.id === tag.id),
|
||||||
|
onToggle: () =>
|
||||||
|
setRequestOverrides((prev) => {
|
||||||
|
const currentTags = prev.tags || defaultTags.map((t) => t.id);
|
||||||
|
const hasTag = currentTags.includes(tag.id);
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
tags: hasTag
|
||||||
|
? currentTags.filter((id) => id !== tag.id)
|
||||||
|
: [...currentTags, tag.id],
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
})) || [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[defaultServiceDetails?.tags, defaultTags, requestOverrides.tags],
|
||||||
|
);
|
||||||
|
|
||||||
|
const usersOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
options:
|
||||||
|
users?.map((user) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: user.displayName,
|
||||||
|
value: user.id.toString(),
|
||||||
|
selected:
|
||||||
|
(requestOverrides.userId || jellyseerrUser?.id) === user.id,
|
||||||
|
onPress: () =>
|
||||||
|
setRequestOverrides((prev) => ({
|
||||||
|
...prev,
|
||||||
|
userId: user.id,
|
||||||
|
})),
|
||||||
|
})) || [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[users, jellyseerrUser, requestOverrides.userId],
|
||||||
|
);
|
||||||
|
|
||||||
const request = useCallback(() => {
|
const request = useCallback(() => {
|
||||||
const body = {
|
const body = {
|
||||||
is4k: defaultService?.is4k || defaultServiceDetails?.server.is4k,
|
is4k: defaultService?.is4k || defaultServiceDetails?.server.is4k,
|
||||||
@@ -163,15 +283,12 @@ const RequestModal = forwardRef<
|
|||||||
defaultTags,
|
defaultTags,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const pathTitleExtractor = (item: RootFolder) =>
|
|
||||||
`${item.path} (${item.freeSpace.bytesToReadable()})`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BottomSheetModal
|
<BottomSheetModal
|
||||||
ref={ref}
|
ref={ref}
|
||||||
enableDynamicSizing
|
enableDynamicSizing
|
||||||
enableDismissOnClose
|
enableDismissOnClose
|
||||||
onDismiss={onDismiss}
|
onDismiss={handleDismiss}
|
||||||
handleIndicatorStyle={{
|
handleIndicatorStyle={{
|
||||||
backgroundColor: "white",
|
backgroundColor: "white",
|
||||||
}}
|
}}
|
||||||
@@ -185,6 +302,7 @@ const RequestModal = forwardRef<
|
|||||||
appearsOnIndex={0}
|
appearsOnIndex={0}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
stackBehavior='push'
|
||||||
>
|
>
|
||||||
<BottomSheetView>
|
<BottomSheetView>
|
||||||
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
||||||
@@ -199,70 +317,112 @@ const RequestModal = forwardRef<
|
|||||||
<View className='flex flex-col space-y-2'>
|
<View className='flex flex-col space-y-2'>
|
||||||
{defaultService && defaultServiceDetails && users && (
|
{defaultService && defaultServiceDetails && users && (
|
||||||
<>
|
<>
|
||||||
<Dropdown
|
<View className='flex flex-col'>
|
||||||
data={defaultServiceDetails.profiles}
|
<Text className='opacity-50 mb-1 text-xs'>
|
||||||
titleExtractor={(item) => item.name}
|
{t("jellyseerr.quality_profile")}
|
||||||
placeholderText={
|
</Text>
|
||||||
requestOverrides.profileName || defaultProfile.name
|
<PlatformDropdown
|
||||||
}
|
groups={qualityProfileOptions}
|
||||||
keyExtractor={(item) => item.id.toString()}
|
trigger={
|
||||||
label={t("jellyseerr.quality_profile")}
|
<View className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||||
onSelected={(item) =>
|
<Text numberOfLines={1}>
|
||||||
item &&
|
{defaultServiceDetails.profiles.find(
|
||||||
setRequestOverrides((prev) => ({
|
(p) =>
|
||||||
...prev,
|
p.id ===
|
||||||
profileId: item?.id,
|
(requestOverrides.profileId ||
|
||||||
}))
|
defaultProfile?.id),
|
||||||
}
|
)?.name || defaultProfile?.name}
|
||||||
title={t("jellyseerr.quality_profile")}
|
</Text>
|
||||||
/>
|
</View>
|
||||||
<Dropdown
|
}
|
||||||
data={defaultServiceDetails.rootFolders}
|
title={t("jellyseerr.quality_profile")}
|
||||||
titleExtractor={pathTitleExtractor}
|
open={qualityProfileOpen}
|
||||||
placeholderText={
|
onOpenChange={setQualityProfileOpen}
|
||||||
defaultFolder ? pathTitleExtractor(defaultFolder) : ""
|
/>
|
||||||
}
|
</View>
|
||||||
keyExtractor={(item) => item.id.toString()}
|
|
||||||
label={t("jellyseerr.root_folder")}
|
<View className='flex flex-col'>
|
||||||
onSelected={(item) =>
|
<Text className='opacity-50 mb-1 text-xs'>
|
||||||
item &&
|
{t("jellyseerr.root_folder")}
|
||||||
setRequestOverrides((prev) => ({
|
</Text>
|
||||||
...prev,
|
<PlatformDropdown
|
||||||
rootFolder: item.path,
|
groups={rootFolderOptions}
|
||||||
}))
|
trigger={
|
||||||
}
|
<View className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||||
title={t("jellyseerr.root_folder")}
|
<Text numberOfLines={1}>
|
||||||
/>
|
{defaultServiceDetails.rootFolders.find(
|
||||||
<Dropdown
|
(f) =>
|
||||||
multiple
|
f.path ===
|
||||||
data={defaultServiceDetails.tags}
|
(requestOverrides.rootFolder ||
|
||||||
titleExtractor={(item) => item.label}
|
defaultFolder?.path),
|
||||||
placeholderText={defaultTags.map((t) => t.label).join(",")}
|
)
|
||||||
keyExtractor={(item) => item.id.toString()}
|
? pathTitleExtractor(
|
||||||
label={t("jellyseerr.tags")}
|
defaultServiceDetails.rootFolders.find(
|
||||||
onSelected={(...selected) =>
|
(f) =>
|
||||||
setRequestOverrides((prev) => ({
|
f.path ===
|
||||||
...prev,
|
(requestOverrides.rootFolder ||
|
||||||
tags: selected.map((i) => i.id),
|
defaultFolder?.path),
|
||||||
}))
|
)!,
|
||||||
}
|
)
|
||||||
title={t("jellyseerr.tags")}
|
: pathTitleExtractor(defaultFolder!)}
|
||||||
/>
|
</Text>
|
||||||
<Dropdown
|
</View>
|
||||||
data={users}
|
}
|
||||||
titleExtractor={(item) => item.displayName}
|
title={t("jellyseerr.root_folder")}
|
||||||
placeholderText={jellyseerrUser!.displayName}
|
open={rootFolderOpen}
|
||||||
keyExtractor={(item) => item.id.toString() || ""}
|
onOpenChange={setRootFolderOpen}
|
||||||
label={t("jellyseerr.request_as")}
|
/>
|
||||||
onSelected={(item) =>
|
</View>
|
||||||
item &&
|
|
||||||
setRequestOverrides((prev) => ({
|
<View className='flex flex-col'>
|
||||||
...prev,
|
<Text className='opacity-50 mb-1 text-xs'>
|
||||||
userId: item?.id,
|
{t("jellyseerr.tags")}
|
||||||
}))
|
</Text>
|
||||||
}
|
<PlatformDropdown
|
||||||
title={t("jellyseerr.request_as")}
|
groups={tagsOptions}
|
||||||
/>
|
trigger={
|
||||||
|
<View className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||||
|
<Text numberOfLines={1}>
|
||||||
|
{requestOverrides.tags
|
||||||
|
? defaultServiceDetails.tags
|
||||||
|
.filter((t) =>
|
||||||
|
requestOverrides.tags!.includes(t.id),
|
||||||
|
)
|
||||||
|
.map((t) => t.label)
|
||||||
|
.join(", ") ||
|
||||||
|
defaultTags.map((t) => t.label).join(", ")
|
||||||
|
: defaultTags.map((t) => t.label).join(", ")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
title={t("jellyseerr.tags")}
|
||||||
|
open={tagsOpen}
|
||||||
|
onOpenChange={setTagsOpen}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='flex flex-col'>
|
||||||
|
<Text className='opacity-50 mb-1 text-xs'>
|
||||||
|
{t("jellyseerr.request_as")}
|
||||||
|
</Text>
|
||||||
|
<PlatformDropdown
|
||||||
|
groups={usersOptions}
|
||||||
|
trigger={
|
||||||
|
<View className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||||
|
<Text numberOfLines={1}>
|
||||||
|
{users.find(
|
||||||
|
(u) =>
|
||||||
|
u.id ===
|
||||||
|
(requestOverrides.userId || jellyseerrUser?.id),
|
||||||
|
)?.displayName || jellyseerrUser!.displayName}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
title={t("jellyseerr.request_as")}
|
||||||
|
open={usersOpen}
|
||||||
|
onOpenChange={setUsersOpen}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import type { ContentStyle } from "@shopify/flash-list/src/FlashListProps";
|
|
||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
import { View, type ViewProps } from "react-native";
|
import { View, type ViewProps, type ViewStyle } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||||
import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
|
import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
|
||||||
|
|
||||||
export interface SlideProps {
|
export interface SlideProps {
|
||||||
slide: DiscoverSlider;
|
slide: DiscoverSlider;
|
||||||
contentContainerStyle?: ContentStyle;
|
contentContainerStyle?: ViewStyle;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props<T> extends SlideProps {
|
interface Props<T> extends SlideProps {
|
||||||
@@ -45,7 +44,6 @@ const Slide = <T,>({
|
|||||||
}}
|
}}
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
keyExtractor={keyExtractor}
|
keyExtractor={keyExtractor}
|
||||||
estimatedItemSize={250}
|
|
||||||
data={data}
|
data={data}
|
||||||
onEndReachedThreshold={1}
|
onEndReachedThreshold={1}
|
||||||
onEndReached={onEndReached}
|
onEndReached={onEndReached}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { View, type ViewProps } from "react-native";
|
import { View, type ViewProps } from "react-native";
|
||||||
|
import { useInView } from "@/hooks/useInView";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { InfiniteHorizontalScroll } from "../common/InfiniteHorizontalScroll";
|
import { InfiniteHorizontalScroll } from "../common/InfiniteHorizontalScroll";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
@@ -21,20 +22,29 @@ import MoviePoster from "../posters/MoviePoster";
|
|||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
queryKey: QueryKey;
|
queryKey: QueryKey;
|
||||||
queryFn: QueryFunction<BaseItemDto>;
|
queryFn: QueryFunction<BaseItemDto>;
|
||||||
|
scrollY?: number; // For lazy loading
|
||||||
|
enableLazyLoading?: boolean; // Enable/disable lazy loading
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MediaListSection: React.FC<Props> = ({
|
export const MediaListSection: React.FC<Props> = ({
|
||||||
queryFn,
|
queryFn,
|
||||||
queryKey,
|
queryKey,
|
||||||
|
scrollY = 0,
|
||||||
|
enableLazyLoading = false,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
|
const { ref, isInView, onLayout } = useInView(scrollY, {
|
||||||
|
enabled: enableLazyLoading,
|
||||||
|
});
|
||||||
|
|
||||||
const { data: collection } = useQuery({
|
const { data: collection } = useQuery({
|
||||||
queryKey,
|
queryKey,
|
||||||
queryFn,
|
queryFn,
|
||||||
staleTime: 0,
|
staleTime: 60 * 1000, // 1 minute
|
||||||
|
enabled: enableLazyLoading ? isInView : true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const fetchItems = useCallback(
|
const fetchItems = useCallback(
|
||||||
@@ -60,7 +70,7 @@ export const MediaListSection: React.FC<Props> = ({
|
|||||||
if (!collection) return null;
|
if (!collection) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View ref={ref} onLayout={onLayout} {...props}>
|
||||||
<Text className='px-4 text-lg font-bold mb-2 text-neutral-100'>
|
<Text className='px-4 text-lg font-bold mb-2 text-neutral-100'>
|
||||||
{collection.Name}
|
{collection.Name}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
115
components/search/DiscoverFilters.tsx
Normal file
115
components/search/DiscoverFilters.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { Button, ContextMenu, Host, Picker } from "@expo/ui/swift-ui";
|
||||||
|
import { Platform, View } from "react-native";
|
||||||
|
import { FilterButton } from "@/components/filters/FilterButton";
|
||||||
|
import { JellyseerrSearchSort } from "@/components/jellyseerr/JellyseerrIndexPage";
|
||||||
|
|
||||||
|
interface DiscoverFiltersProps {
|
||||||
|
searchFilterId: string;
|
||||||
|
orderFilterId: string;
|
||||||
|
jellyseerrOrderBy: JellyseerrSearchSort;
|
||||||
|
setJellyseerrOrderBy: (value: JellyseerrSearchSort) => void;
|
||||||
|
jellyseerrSortOrder: "asc" | "desc";
|
||||||
|
setJellyseerrSortOrder: (value: "asc" | "desc") => void;
|
||||||
|
t: (key: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortOptions = Object.keys(JellyseerrSearchSort).filter((v) =>
|
||||||
|
Number.isNaN(Number(v)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const orderOptions = ["asc", "desc"] as const;
|
||||||
|
|
||||||
|
export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
|
||||||
|
searchFilterId,
|
||||||
|
orderFilterId,
|
||||||
|
jellyseerrOrderBy,
|
||||||
|
setJellyseerrOrderBy,
|
||||||
|
jellyseerrSortOrder,
|
||||||
|
setJellyseerrSortOrder,
|
||||||
|
t,
|
||||||
|
}) => {
|
||||||
|
if (Platform.OS === "ios") {
|
||||||
|
return (
|
||||||
|
<Host
|
||||||
|
style={{
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
overflow: "visible",
|
||||||
|
height: 40,
|
||||||
|
width: 50,
|
||||||
|
marginLeft: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ContextMenu>
|
||||||
|
<ContextMenu.Trigger>
|
||||||
|
<Button
|
||||||
|
variant='glass'
|
||||||
|
modifiers={[]}
|
||||||
|
systemImage='line.3.horizontal.decrease.circle'
|
||||||
|
></Button>
|
||||||
|
</ContextMenu.Trigger>
|
||||||
|
<ContextMenu.Items>
|
||||||
|
<Picker
|
||||||
|
label={t("library.filters.sort_by")}
|
||||||
|
options={sortOptions.map((item) =>
|
||||||
|
t(`home.settings.plugins.jellyseerr.order_by.${item}`),
|
||||||
|
)}
|
||||||
|
variant='menu'
|
||||||
|
selectedIndex={sortOptions.indexOf(
|
||||||
|
jellyseerrOrderBy as unknown as string,
|
||||||
|
)}
|
||||||
|
onOptionSelected={(event: any) => {
|
||||||
|
const index = event.nativeEvent.index;
|
||||||
|
setJellyseerrOrderBy(
|
||||||
|
sortOptions[index] as unknown as JellyseerrSearchSort,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Picker
|
||||||
|
label={t("library.filters.sort_order")}
|
||||||
|
options={orderOptions.map((item) => t(`library.filters.${item}`))}
|
||||||
|
variant='menu'
|
||||||
|
selectedIndex={orderOptions.indexOf(jellyseerrSortOrder)}
|
||||||
|
onOptionSelected={(event: any) => {
|
||||||
|
const index = event.nativeEvent.index;
|
||||||
|
setJellyseerrSortOrder(orderOptions[index]);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ContextMenu.Items>
|
||||||
|
</ContextMenu>
|
||||||
|
</Host>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Android UI
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -34,7 +34,6 @@ export const SearchItemWrapper = <T,>({
|
|||||||
}}
|
}}
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
keyExtractor={(_, index) => index.toString()}
|
keyExtractor={(_, index) => index.toString()}
|
||||||
estimatedItemSize={250}
|
|
||||||
data={items}
|
data={items}
|
||||||
onEndReachedThreshold={1}
|
onEndReachedThreshold={1}
|
||||||
onEndReached={onEndReached}
|
onEndReached={onEndReached}
|
||||||
|
|||||||
76
components/search/SearchTabButtons.tsx
Normal file
76
components/search/SearchTabButtons.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { Button, Host } from "@expo/ui/swift-ui";
|
||||||
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
import { Tag } from "@/components/GenreTags";
|
||||||
|
|
||||||
|
type SearchType = "Library" | "Discover";
|
||||||
|
|
||||||
|
interface SearchTabButtonsProps {
|
||||||
|
searchType: SearchType;
|
||||||
|
setSearchType: (type: SearchType) => void;
|
||||||
|
t: (key: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SearchTabButtons: React.FC<SearchTabButtonsProps> = ({
|
||||||
|
searchType,
|
||||||
|
setSearchType,
|
||||||
|
t,
|
||||||
|
}) => {
|
||||||
|
if (Platform.OS === "ios") {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Host
|
||||||
|
style={{
|
||||||
|
height: 40,
|
||||||
|
width: 80,
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 10,
|
||||||
|
justifyContent: "space-between",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant={searchType === "Library" ? "glassProminent" : "glass"}
|
||||||
|
onPress={() => setSearchType("Library")}
|
||||||
|
>
|
||||||
|
{t("search.library")}
|
||||||
|
</Button>
|
||||||
|
</Host>
|
||||||
|
<Host
|
||||||
|
style={{
|
||||||
|
height: 40,
|
||||||
|
width: 100,
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 10,
|
||||||
|
justifyContent: "space-between",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant={searchType === "Discover" ? "glassProminent" : "glass"}
|
||||||
|
onPress={() => setSearchType("Discover")}
|
||||||
|
>
|
||||||
|
{t("search.discover")}
|
||||||
|
</Button>
|
||||||
|
</Host>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Android UI
|
||||||
|
return (
|
||||||
|
<View className='flex flex-row gap-1 mr-1'>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user