mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-16 08:08:18 +00:00
Compare commits
105 Commits
codeql-fix
...
v0.51.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24d04c1003 | ||
|
|
7da52441ab | ||
|
|
70268e6120 | ||
|
|
96fbb9fe1f | ||
|
|
3b104b91fc | ||
|
|
e4134d6f9a | ||
|
|
5b2e7b3883 | ||
|
|
1fde3c82a3 | ||
|
|
054fb05651 | ||
|
|
a2058a8009 | ||
|
|
d22827bc9b | ||
|
|
4121502bfe | ||
|
|
b6e59aab01 | ||
|
|
ab3465aec5 | ||
|
|
b1da9f8777 | ||
|
|
36d24176ae | ||
|
|
bfdc2c053b | ||
|
|
245c9597c4 | ||
|
|
966a8e8f24 | ||
|
|
f941c88457 | ||
|
|
bd4e5bb70a | ||
|
|
9334263414 | ||
|
|
4ae3c44d02 | ||
|
|
4fb3fb195c | ||
|
|
e8089cfd20 | ||
|
|
039bf9729a | ||
|
|
3ff7c47b7f | ||
|
|
1d8d92175a | ||
|
|
60b0040681 | ||
|
|
9cd55cf544 | ||
|
|
090e0cb170 | ||
|
|
85d707ef45 | ||
|
|
792eef20a9 | ||
|
|
6487c8b5a1 | ||
|
|
baa96d222f | ||
|
|
74d86b5d12 | ||
|
|
d1795c9df8 | ||
|
|
149609f46e | ||
|
|
cf269ba83e | ||
|
|
24d5fdefdf | ||
|
|
c05cef295e | ||
|
|
3c57829360 | ||
|
|
06349a4319 | ||
|
|
55ac9ae9d4 | ||
|
|
c8bdcc4df0 | ||
|
|
e7013edd84 | ||
|
|
991b45de06 | ||
|
|
97fe899cb0 | ||
|
|
86d7642dca | ||
|
|
631a5ef94e | ||
|
|
8b8b928837 | ||
|
|
56a3c62ed2 | ||
|
|
82683407da | ||
|
|
7b146e30bd | ||
|
|
5f48bec0f2 | ||
|
|
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 |
9
.github/ISSUE_TEMPLATE/issue_report.yml
vendored
9
.github/ISSUE_TEMPLATE/issue_report.yml
vendored
@@ -77,13 +77,8 @@ body:
|
|||||||
label: Streamyfin Version
|
label: Streamyfin Version
|
||||||
description: What version of Streamyfin are you running?
|
description: What version of Streamyfin are you running?
|
||||||
options:
|
options:
|
||||||
|
- 0.47.1
|
||||||
- 0.30.2
|
- 0.30.2
|
||||||
- 0.29.0
|
|
||||||
- 0.28.0
|
|
||||||
- 0.27.0
|
|
||||||
- 0.26.1
|
|
||||||
- 0.26.0
|
|
||||||
- 0.25.0
|
|
||||||
- older
|
- older
|
||||||
- TestFlight/Development build
|
- TestFlight/Development build
|
||||||
validations:
|
validations:
|
||||||
@@ -116,4 +111,4 @@ body:
|
|||||||
id: additional-info
|
id: additional-info
|
||||||
attributes:
|
attributes:
|
||||||
label: Additional information
|
label: Additional information
|
||||||
description: Any additional context that might help us understand and reproduce the issue.
|
description: Any additional context that might help us understand and reproduce the issue.
|
||||||
|
|||||||
14
.github/workflows/build-apps.yml
vendored
14
.github/workflows/build-apps.yml
vendored
@@ -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
|
||||||
@@ -191,6 +191,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@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # 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:
|
||||||
@@ -219,7 +224,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
|
||||||
@@ -254,6 +259,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:
|
||||||
|
|||||||
10
.github/workflows/ci-codeql.yml
vendored
10
.github/workflows/ci-codeql.yml
vendored
@@ -25,19 +25,15 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout repository
|
- name: 📥 Checkout repository
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
with:
|
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
|
||||||
show-progress: false
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: 🏁 Initialize CodeQL
|
- name: 🏁 Initialize CodeQL
|
||||||
uses: github/codeql-action/init@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0
|
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@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0
|
uses: github/codeql-action/autobuild@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
|
||||||
|
|
||||||
- name: 🧪 Perform CodeQL Analysis
|
- name: 🧪 Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0
|
uses: github/codeql-action/analyze@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
|
||||||
|
|||||||
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@40c09b7dc99638e5ddb0bfd91c1673effc064d8a # v4.8.1
|
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@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.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@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||||
with:
|
with:
|
||||||
node-version: '24.x'
|
node-version: '24.x'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -51,6 +51,7 @@ npm-debug.*
|
|||||||
.ruby-lsp
|
.ruby-lsp
|
||||||
.cursor/
|
.cursor/
|
||||||
.claude/
|
.claude/
|
||||||
|
CLAUDE.md
|
||||||
|
|
||||||
# Environment and Configuration
|
# Environment and Configuration
|
||||||
expo-env.d.ts
|
expo-env.d.ts
|
||||||
@@ -64,4 +65,8 @@ 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/*
|
||||||
|
/modules/sf-player/android/build
|
||||||
|
/modules/music-controls/android/build
|
||||||
|
/modules/mpv-player/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"
|
|
||||||
}
|
}
|
||||||
|
|||||||
119
CLAUDE.md
Normal file
119
CLAUDE.md
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native). It supports mobile (iOS/Android) and TV platforms, with features including offline downloads, Chromecast support, and Jellyseerr integration.
|
||||||
|
|
||||||
|
## Development Commands
|
||||||
|
|
||||||
|
**CRITICAL: Always use `bun` for package management. Never use `npm`, `yarn`, or `npx`.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Setup
|
||||||
|
bun i && bun run submodule-reload
|
||||||
|
|
||||||
|
# Development builds
|
||||||
|
bun run prebuild # Mobile prebuild
|
||||||
|
bun run ios # Run iOS
|
||||||
|
bun run android # Run Android
|
||||||
|
|
||||||
|
# TV builds (suffix with :tv)
|
||||||
|
bun run prebuild:tv
|
||||||
|
bun run ios:tv
|
||||||
|
bun run android:tv
|
||||||
|
|
||||||
|
# Code quality
|
||||||
|
bun run typecheck # TypeScript check
|
||||||
|
bun run check # BiomeJS check
|
||||||
|
bun run lint # BiomeJS lint + fix
|
||||||
|
bun run format # BiomeJS format
|
||||||
|
bun run test # Run all checks (typecheck, lint, format, doctor)
|
||||||
|
|
||||||
|
# iOS-specific
|
||||||
|
bun run ios:install-metal-toolchain # Fix "missing Metal Toolchain" build errors
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Runtime**: Bun
|
||||||
|
- **Framework**: React Native (Expo SDK 54)
|
||||||
|
- **Language**: TypeScript (strict mode)
|
||||||
|
- **State Management**: Jotai (global state atoms) + React Query (server state)
|
||||||
|
- **API**: Jellyfin SDK (`@jellyfin/sdk`)
|
||||||
|
- **Navigation**: Expo Router (file-based)
|
||||||
|
- **Linting/Formatting**: BiomeJS
|
||||||
|
- **Storage**: react-native-mmkv
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### File Structure
|
||||||
|
|
||||||
|
- `app/` - Expo Router screens with file-based routing
|
||||||
|
- `components/` - Reusable UI components
|
||||||
|
- `providers/` - React Context providers
|
||||||
|
- `hooks/` - Custom React hooks
|
||||||
|
- `utils/` - Utilities including Jotai atoms
|
||||||
|
- `modules/` - Native modules (vlc-player, mpv-player, background-downloader)
|
||||||
|
- `translations/` - i18n translation files
|
||||||
|
|
||||||
|
### Key Patterns
|
||||||
|
|
||||||
|
**State Management**:
|
||||||
|
- Global state uses Jotai atoms in `utils/atoms/`
|
||||||
|
- `settingsAtom` in `utils/atoms/settings.ts` for app settings
|
||||||
|
- `apiAtom` and `userAtom` in `providers/JellyfinProvider.tsx` for auth state
|
||||||
|
- Server state uses React Query with `@tanstack/react-query`
|
||||||
|
|
||||||
|
**Jellyfin API Access**:
|
||||||
|
- Use `apiAtom` from `JellyfinProvider` for authenticated API calls
|
||||||
|
- Access user via `userAtom`
|
||||||
|
- Use Jellyfin SDK utilities from `@jellyfin/sdk/lib/utils/api`
|
||||||
|
|
||||||
|
**Navigation**:
|
||||||
|
- File-based routing in `app/` directory
|
||||||
|
- Tab navigation: `(home)`, `(search)`, `(favorites)`, `(libraries)`, `(watchlists)`
|
||||||
|
- Shared routes use parenthesized groups like `(home,libraries,search,favorites,watchlists)`
|
||||||
|
|
||||||
|
**Providers** (wrapping order in `app/_layout.tsx`):
|
||||||
|
1. JotaiProvider
|
||||||
|
2. QueryClientProvider
|
||||||
|
3. JellyfinProvider (auth, API)
|
||||||
|
4. NetworkStatusProvider
|
||||||
|
5. PlaySettingsProvider
|
||||||
|
6. WebSocketProvider
|
||||||
|
7. DownloadProvider
|
||||||
|
8. MusicPlayerProvider
|
||||||
|
|
||||||
|
### Native Modules
|
||||||
|
|
||||||
|
Located in `modules/`:
|
||||||
|
- `vlc-player` - VLC video player integration
|
||||||
|
- `mpv-player` - MPV video player integration (iOS)
|
||||||
|
- `background-downloader` - Background download functionality
|
||||||
|
- `sf-player` - Swift player module
|
||||||
|
|
||||||
|
### Path Aliases
|
||||||
|
|
||||||
|
Use `@/` prefix for imports (configured in `tsconfig.json`):
|
||||||
|
```typescript
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
```
|
||||||
|
|
||||||
|
## Coding Standards
|
||||||
|
|
||||||
|
- Use TypeScript for all files (no .js)
|
||||||
|
- Use functional React components with hooks
|
||||||
|
- Use Jotai atoms for global state, React Query for server state
|
||||||
|
- Follow BiomeJS formatting rules (2-space indent, semicolons, LF line endings)
|
||||||
|
- Handle both mobile and TV navigation patterns
|
||||||
|
- Use existing atoms, hooks, and utilities before creating new ones
|
||||||
|
- Use Conventional Commits: `feat(scope):`, `fix(scope):`, `chore(scope):`
|
||||||
|
|
||||||
|
## Platform Considerations
|
||||||
|
|
||||||
|
- TV version uses `:tv` suffix for scripts
|
||||||
|
- Platform checks: `Platform.isTV`, `Platform.OS === "android"` or `"ios"`
|
||||||
|
- Some features disabled on TV (e.g., notifications, Chromecast)
|
||||||
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
|
||||||
@@ -70,6 +70,7 @@ Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) To
|
|||||||
<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 Streamyfin on Google Play Store" 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>
|
<a href="https://github.com/streamyfin/streamyfin/releases/latest"><img height=50 alt="Get Streamyfin on Github" src="./assets/Download_on_Github_.png"/></a>
|
||||||
|
<a href="https://apps.obtainium.imranr.dev/redirect.html?r=obtainium://add/https://github.com/streamyfin/streamyfin"><img height=50 alt="Add Streamyfin to Obtainium" src="./assets/Download_with_Obtainium.png"/></a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
### 🧪 Beta Testing
|
### 🧪 Beta Testing
|
||||||
@@ -104,6 +105,7 @@ You can contribute translations directly on our [Crowdin project page](https://c
|
|||||||
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/)
|
||||||
|
- If iOS builds fail with `missing Metal Toolchain` (KSPlayer shaders), run `npm run ios:install-metal-toolchain` once
|
||||||
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
|
||||||
|
|||||||
@@ -7,13 +7,18 @@ module.exports = ({ config }) => {
|
|||||||
{ useDefaultExpandedMediaControls: true },
|
{ useDefaultExpandedMediaControls: true },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Add the background downloader plugin only for non-TV builds
|
// KSPlayer for iOS (GPU acceleration + native PiP)
|
||||||
config.plugins.push("./plugins/withRNBackgroundDownloader.js");
|
config.plugins.push("./plugins/withKSPlayer.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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
46
app.json
46
app.json
@@ -2,12 +2,13 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.39.0",
|
"version": "0.51.0",
|
||||||
"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": 91,
|
||||||
"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",
|
||||||
@@ -56,28 +53,16 @@
|
|||||||
"@react-native-tvos/config-tv",
|
"@react-native-tvos/config-tv",
|
||||||
"expo-router",
|
"expo-router",
|
||||||
"expo-font",
|
"expo-font",
|
||||||
[
|
"./plugins/withExcludeMedia3Dash.js",
|
||||||
"react-native-video",
|
|
||||||
{
|
|
||||||
"enableNotificationControls": true,
|
|
||||||
"enableBackgroundAudio": true,
|
|
||||||
"androidExtensions": {
|
|
||||||
"useExoplayerRtsp": false,
|
|
||||||
"useExoplayerSmoothStreaming": false,
|
|
||||||
"useExoplayerHls": true,
|
|
||||||
"useExoplayerDash": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[
|
[
|
||||||
"expo-build-properties",
|
"expo-build-properties",
|
||||||
{
|
{
|
||||||
"ios": {
|
"ios": {
|
||||||
"deploymentTarget": "15.6",
|
"deploymentTarget": "15.6"
|
||||||
"useFrameworks": "static"
|
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"compileSdkVersion": 35,
|
"buildArchs": ["arm64-v8a", "x86_64"],
|
||||||
|
"compileSdkVersion": 36,
|
||||||
"targetSdkVersion": 35,
|
"targetSdkVersion": 35,
|
||||||
"buildToolsVersion": "35.0.0",
|
"buildToolsVersion": "35.0.0",
|
||||||
"kotlinVersion": "2.0.21",
|
"kotlinVersion": "2.0.21",
|
||||||
@@ -115,10 +100,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
["./plugins/withChangeNativeAndroidTextToWhite.js"],
|
|
||||||
["./plugins/withAndroidManifest.js"],
|
|
||||||
["./plugins/withTrustLocalCerts.js"],
|
|
||||||
["./plugins/withGradleProperties.js"],
|
|
||||||
[
|
[
|
||||||
"expo-splash-screen",
|
"expo-splash-screen",
|
||||||
{
|
{
|
||||||
@@ -134,8 +115,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
|
||||||
@@ -154,7 +139,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>
|
||||||
|
|||||||
212
app/(auth)/(tabs)/(favorites)/see-all.tsx
Normal file
212
app/(auth)/(tabs)/(favorites)/see-all.tsx
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import type { Api } from "@jellyfin/sdk";
|
||||||
|
import type {
|
||||||
|
BaseItemDto,
|
||||||
|
BaseItemKind,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
|
import { Stack, useLocalSearchParams } from "expo-router";
|
||||||
|
import { t } from "i18next";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useCallback, useMemo } from "react";
|
||||||
|
import { useWindowDimensions, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
|
type FavoriteTypes =
|
||||||
|
| "Series"
|
||||||
|
| "Movie"
|
||||||
|
| "Episode"
|
||||||
|
| "Video"
|
||||||
|
| "BoxSet"
|
||||||
|
| "Playlist";
|
||||||
|
|
||||||
|
const favoriteTypes: readonly FavoriteTypes[] = [
|
||||||
|
"Series",
|
||||||
|
"Movie",
|
||||||
|
"Episode",
|
||||||
|
"Video",
|
||||||
|
"BoxSet",
|
||||||
|
"Playlist",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function isFavoriteType(value: unknown): value is FavoriteTypes {
|
||||||
|
return (
|
||||||
|
typeof value === "string" &&
|
||||||
|
(favoriteTypes as readonly string[]).includes(value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FavoritesSeeAllScreen() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { width: screenWidth } = useWindowDimensions();
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
|
const searchParams = useLocalSearchParams<{
|
||||||
|
type?: string;
|
||||||
|
title?: string;
|
||||||
|
}>();
|
||||||
|
const typeParam = searchParams.type;
|
||||||
|
const titleParam = searchParams.title;
|
||||||
|
|
||||||
|
const itemType = useMemo(() => {
|
||||||
|
if (!isFavoriteType(typeParam)) return null;
|
||||||
|
return typeParam as BaseItemKind;
|
||||||
|
}, [typeParam]);
|
||||||
|
|
||||||
|
const headerTitle = useMemo(() => {
|
||||||
|
if (typeof titleParam === "string" && titleParam.trim().length > 0)
|
||||||
|
return titleParam;
|
||||||
|
return "";
|
||||||
|
}, [titleParam]);
|
||||||
|
|
||||||
|
const pageSize = 50;
|
||||||
|
|
||||||
|
const fetchItems = useCallback(
|
||||||
|
async ({ pageParam }: { pageParam: number }): Promise<BaseItemDto[]> => {
|
||||||
|
if (!api || !user?.Id || !itemType) return [];
|
||||||
|
|
||||||
|
const response = await getItemsApi(api as Api).getItems({
|
||||||
|
userId: user.Id,
|
||||||
|
sortBy: ["SeriesSortName", "SortName"],
|
||||||
|
sortOrder: ["Ascending"],
|
||||||
|
filters: ["IsFavorite"],
|
||||||
|
recursive: true,
|
||||||
|
fields: ["PrimaryImageAspectRatio"],
|
||||||
|
collapseBoxSetItems: false,
|
||||||
|
excludeLocationTypes: ["Virtual"],
|
||||||
|
enableTotalRecordCount: true,
|
||||||
|
startIndex: pageParam,
|
||||||
|
limit: pageSize,
|
||||||
|
includeItemTypes: [itemType],
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data.Items || [];
|
||||||
|
},
|
||||||
|
[api, itemType, user?.Id],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
|
||||||
|
useInfiniteQuery({
|
||||||
|
queryKey: ["favorites", "see-all", itemType],
|
||||||
|
queryFn: ({ pageParam = 0 }) => fetchItems({ pageParam }),
|
||||||
|
getNextPageParam: (lastPage, pages) => {
|
||||||
|
if (!lastPage || lastPage.length < pageSize) return undefined;
|
||||||
|
return pages.reduce((acc, page) => acc + page.length, 0);
|
||||||
|
},
|
||||||
|
initialPageParam: 0,
|
||||||
|
enabled: !!api && !!user?.Id && !!itemType,
|
||||||
|
});
|
||||||
|
|
||||||
|
const flatData = useMemo(() => data?.pages.flat() ?? [], [data]);
|
||||||
|
|
||||||
|
const nrOfCols = useMemo(() => {
|
||||||
|
if (screenWidth < 350) return 2;
|
||||||
|
if (screenWidth < 600) return 3;
|
||||||
|
if (screenWidth < 900) return 5;
|
||||||
|
return 6;
|
||||||
|
}, [screenWidth]);
|
||||||
|
|
||||||
|
const renderItem = useCallback(
|
||||||
|
({ item, index }: { item: BaseItemDto; index: number }) => (
|
||||||
|
<TouchableItemRouter
|
||||||
|
item={item}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
alignSelf:
|
||||||
|
index % nrOfCols === 0
|
||||||
|
? "flex-end"
|
||||||
|
: (index + 1) % nrOfCols === 0
|
||||||
|
? "flex-start"
|
||||||
|
: "center",
|
||||||
|
width: "89%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ItemPoster item={item} />
|
||||||
|
<ItemCardText item={item} />
|
||||||
|
</View>
|
||||||
|
</TouchableItemRouter>
|
||||||
|
),
|
||||||
|
[nrOfCols],
|
||||||
|
);
|
||||||
|
|
||||||
|
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||||
|
|
||||||
|
const handleEndReached = useCallback(() => {
|
||||||
|
if (hasNextPage) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
}, [fetchNextPage, hasNextPage]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen
|
||||||
|
options={{
|
||||||
|
headerTitle: headerTitle,
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTransparent: true,
|
||||||
|
headerShadowVisible: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{!itemType ? (
|
||||||
|
<View className='flex-1 items-center justify-center px-6'>
|
||||||
|
<Text className='text-neutral-500'>
|
||||||
|
{t("favorites.noData", { defaultValue: "No items found." })}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : isLoading ? (
|
||||||
|
<View className='justify-center items-center h-full'>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<FlashList
|
||||||
|
data={flatData}
|
||||||
|
renderItem={renderItem}
|
||||||
|
keyExtractor={keyExtractor}
|
||||||
|
numColumns={nrOfCols}
|
||||||
|
onEndReached={handleEndReached}
|
||||||
|
onEndReachedThreshold={0.8}
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingBottom: 24,
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}}
|
||||||
|
ItemSeparatorComponent={() => (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View className='flex flex-col items-center justify-center h-full py-12'>
|
||||||
|
<Text className='font-bold text-xl text-neutral-500'>
|
||||||
|
{t("home.no_items", { defaultValue: "No items" })}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
ListFooterComponent={
|
||||||
|
isFetching ? (
|
||||||
|
<View style={{ paddingVertical: 16 }}>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,291 @@ 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/music/page'
|
||||||
|
options={{
|
||||||
|
title: t("home.settings.music.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/plugins/streamystats/page'
|
||||||
|
options={{
|
||||||
|
title: "Streamystats",
|
||||||
|
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/kefinTweaks/page'
|
||||||
|
options={{
|
||||||
|
title: "KefinTweaks",
|
||||||
|
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 +333,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 +348,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,63 @@ 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/music/page")}
|
||||||
|
showArrow
|
||||||
|
title={t("home.settings.music.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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
177
app/(auth)/(tabs)/(home)/settings/music/page.tsx
Normal file
177
app/(auth)/(tabs)/(home)/settings/music/page.tsx
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform, ScrollView, View } from "react-native";
|
||||||
|
import { Switch } from "react-native-gesture-handler";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
|
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
|
const CACHE_SIZE_OPTIONS = [
|
||||||
|
{ label: "100 MB", value: 100 },
|
||||||
|
{ label: "250 MB", value: 250 },
|
||||||
|
{ label: "500 MB", value: 500 },
|
||||||
|
{ label: "1 GB", value: 1024 },
|
||||||
|
{ label: "2 GB", value: 2048 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const LOOKAHEAD_COUNT_OPTIONS = [
|
||||||
|
{ label: "1 song", value: 1 },
|
||||||
|
{ label: "2 songs", value: 2 },
|
||||||
|
{ label: "3 songs", value: 3 },
|
||||||
|
{ label: "5 songs", value: 5 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function MusicSettingsPage() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const cacheSizeOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
options: CACHE_SIZE_OPTIONS.map((option) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: option.label,
|
||||||
|
value: String(option.value),
|
||||||
|
selected: option.value === settings?.audioMaxCacheSizeMB,
|
||||||
|
onPress: () => updateSettings({ audioMaxCacheSizeMB: option.value }),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[settings?.audioMaxCacheSizeMB, updateSettings],
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentCacheSizeLabel =
|
||||||
|
CACHE_SIZE_OPTIONS.find((o) => o.value === settings?.audioMaxCacheSizeMB)
|
||||||
|
?.label ?? `${settings?.audioMaxCacheSizeMB} MB`;
|
||||||
|
|
||||||
|
const lookaheadCountOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
options: LOOKAHEAD_COUNT_OPTIONS.map((option) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: option.label,
|
||||||
|
value: String(option.value),
|
||||||
|
selected: option.value === settings?.audioLookaheadCount,
|
||||||
|
onPress: () => updateSettings({ audioLookaheadCount: option.value }),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[settings?.audioLookaheadCount, updateSettings],
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentLookaheadLabel =
|
||||||
|
LOOKAHEAD_COUNT_OPTIONS.find(
|
||||||
|
(o) => o.value === settings?.audioLookaheadCount,
|
||||||
|
)?.label ?? `${settings?.audioLookaheadCount} songs`;
|
||||||
|
|
||||||
|
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.music.playback_title")}
|
||||||
|
description={
|
||||||
|
<Text className='text-[#8E8D91] text-xs'>
|
||||||
|
{t("home.settings.music.playback_description")}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.music.prefer_downloaded")}
|
||||||
|
disabled={pluginSettings?.preferLocalAudio?.locked}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
value={settings.preferLocalAudio}
|
||||||
|
disabled={pluginSettings?.preferLocalAudio?.locked}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateSettings({ preferLocalAudio: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
</ListGroup>
|
||||||
|
|
||||||
|
<View className='mt-4'>
|
||||||
|
<ListGroup
|
||||||
|
title={t("home.settings.music.caching_title")}
|
||||||
|
description={
|
||||||
|
<Text className='text-[#8E8D91] text-xs'>
|
||||||
|
{t("home.settings.music.caching_description")}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.music.lookahead_enabled")}
|
||||||
|
disabled={pluginSettings?.audioLookaheadEnabled?.locked}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
value={settings.audioLookaheadEnabled}
|
||||||
|
disabled={pluginSettings?.audioLookaheadEnabled?.locked}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateSettings({ audioLookaheadEnabled: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.music.lookahead_count")}
|
||||||
|
disabled={
|
||||||
|
pluginSettings?.audioLookaheadCount?.locked ||
|
||||||
|
!settings.audioLookaheadEnabled
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PlatformDropdown
|
||||||
|
groups={lookaheadCountOptions}
|
||||||
|
trigger={
|
||||||
|
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||||
|
<Text className='mr-1 text-[#8E8D91]'>
|
||||||
|
{currentLookaheadLabel}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name='chevron-expand-sharp'
|
||||||
|
size={18}
|
||||||
|
color='#5A5960'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
title={t("home.settings.music.lookahead_count")}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.music.max_cache_size")}
|
||||||
|
disabled={pluginSettings?.audioMaxCacheSizeMB?.locked}
|
||||||
|
>
|
||||||
|
<PlatformDropdown
|
||||||
|
groups={cacheSizeOptions}
|
||||||
|
trigger={
|
||||||
|
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||||
|
<Text className='mr-1 text-[#8E8D91]'>
|
||||||
|
{currentCacheSizeLabel}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name='chevron-expand-sharp'
|
||||||
|
size={18}
|
||||||
|
color='#5A5960'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
title={t("home.settings.music.max_cache_size")}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
</ListGroup>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { ScrollView } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
|
import { KefinTweaksSettings } from "@/components/settings/KefinTweaks";
|
||||||
|
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?.useKefinTweaks?.locked === true}
|
||||||
|
className='px-4'
|
||||||
|
>
|
||||||
|
<KefinTweaksSettings />
|
||||||
|
</DisabledSetting>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
142
app/(auth)/(tabs)/(home)/settings/plugins/marlin-search/page.tsx
Normal file
142
app/(auth)/(tabs)/(home)/settings/plugins/marlin-search/page.tsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
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, pluginSettings?.marlinServerUrl?.locked, t]);
|
||||||
|
|
||||||
|
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 ||
|
||||||
|
!!pluginSettings?.streamyStatsServerUrl?.value
|
||||||
|
}
|
||||||
|
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"}
|
||||||
|
disabled={!!pluginSettings?.streamyStatsServerUrl?.value}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
262
app/(auth)/(tabs)/(home)/settings/plugins/streamystats/page.tsx
Normal file
262
app/(auth)/(tabs)/(home)/settings/plugins/streamystats/page.tsx
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useNavigation } from "expo-router";
|
||||||
|
import { useCallback, useEffect, 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 { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const {
|
||||||
|
settings,
|
||||||
|
updateSettings,
|
||||||
|
pluginSettings,
|
||||||
|
refreshStreamyfinPluginSettings,
|
||||||
|
} = useSettings();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Local state for all editable fields
|
||||||
|
const [url, setUrl] = useState<string>(settings?.streamyStatsServerUrl || "");
|
||||||
|
const [useForSearch, setUseForSearch] = useState<boolean>(
|
||||||
|
settings?.searchEngine === "Streamystats",
|
||||||
|
);
|
||||||
|
const [movieRecs, setMovieRecs] = useState<boolean>(
|
||||||
|
settings?.streamyStatsMovieRecommendations ?? false,
|
||||||
|
);
|
||||||
|
const [seriesRecs, setSeriesRecs] = useState<boolean>(
|
||||||
|
settings?.streamyStatsSeriesRecommendations ?? false,
|
||||||
|
);
|
||||||
|
const [promotedWatchlists, setPromotedWatchlists] = useState<boolean>(
|
||||||
|
settings?.streamyStatsPromotedWatchlists ?? false,
|
||||||
|
);
|
||||||
|
const [hideWatchlistsTab, setHideWatchlistsTab] = useState<boolean>(
|
||||||
|
settings?.hideWatchlistsTab ?? false,
|
||||||
|
);
|
||||||
|
|
||||||
|
const isUrlLocked = pluginSettings?.streamyStatsServerUrl?.locked === true;
|
||||||
|
const isStreamystatsEnabled = !!url;
|
||||||
|
|
||||||
|
const onSave = useCallback(() => {
|
||||||
|
const cleanUrl = url.endsWith("/") ? url.slice(0, -1) : url;
|
||||||
|
updateSettings({
|
||||||
|
streamyStatsServerUrl: cleanUrl,
|
||||||
|
searchEngine: useForSearch ? "Streamystats" : "Jellyfin",
|
||||||
|
streamyStatsMovieRecommendations: movieRecs,
|
||||||
|
streamyStatsSeriesRecommendations: seriesRecs,
|
||||||
|
streamyStatsPromotedWatchlists: promotedWatchlists,
|
||||||
|
hideWatchlistsTab: hideWatchlistsTab,
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["streamystats"] });
|
||||||
|
toast.success(t("home.settings.plugins.streamystats.toasts.saved"));
|
||||||
|
}, [
|
||||||
|
url,
|
||||||
|
useForSearch,
|
||||||
|
movieRecs,
|
||||||
|
seriesRecs,
|
||||||
|
promotedWatchlists,
|
||||||
|
hideWatchlistsTab,
|
||||||
|
updateSettings,
|
||||||
|
queryClient,
|
||||||
|
t,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Set up header save button
|
||||||
|
useEffect(() => {
|
||||||
|
navigation.setOptions({
|
||||||
|
headerRight: () => (
|
||||||
|
<TouchableOpacity onPress={onSave}>
|
||||||
|
<Text className='text-blue-500 font-medium'>
|
||||||
|
{t("home.settings.plugins.streamystats.save")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}, [navigation, onSave, t]);
|
||||||
|
|
||||||
|
const handleClearStreamystats = useCallback(() => {
|
||||||
|
setUrl("");
|
||||||
|
setUseForSearch(false);
|
||||||
|
setMovieRecs(false);
|
||||||
|
setSeriesRecs(false);
|
||||||
|
setPromotedWatchlists(false);
|
||||||
|
setHideWatchlistsTab(false);
|
||||||
|
updateSettings({
|
||||||
|
streamyStatsServerUrl: "",
|
||||||
|
searchEngine: "Jellyfin",
|
||||||
|
streamyStatsMovieRecommendations: false,
|
||||||
|
streamyStatsSeriesRecommendations: false,
|
||||||
|
streamyStatsPromotedWatchlists: false,
|
||||||
|
hideWatchlistsTab: false,
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["streamystats"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||||
|
toast.success(t("home.settings.plugins.streamystats.toasts.disabled"));
|
||||||
|
}, [updateSettings, queryClient, t]);
|
||||||
|
|
||||||
|
const handleOpenLink = () => {
|
||||||
|
Linking.openURL("https://github.com/fredrikburmester/streamystats");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRefreshFromServer = useCallback(async () => {
|
||||||
|
const newPluginSettings = await refreshStreamyfinPluginSettings(true);
|
||||||
|
// Update local state with new values
|
||||||
|
const newUrl = newPluginSettings?.streamyStatsServerUrl?.value || "";
|
||||||
|
setUrl(newUrl);
|
||||||
|
if (newUrl) {
|
||||||
|
setUseForSearch(true);
|
||||||
|
}
|
||||||
|
toast.success(t("home.settings.plugins.streamystats.toasts.refreshed"));
|
||||||
|
}, [refreshStreamyfinPluginSettings, t]);
|
||||||
|
|
||||||
|
if (!settings) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className='px-4'>
|
||||||
|
<ListGroup className='flex-1'>
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.plugins.streamystats.url")}
|
||||||
|
disabledByAdmin={isUrlLocked}
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
editable={!isUrlLocked}
|
||||||
|
className='text-white text-right flex-1'
|
||||||
|
placeholder={t(
|
||||||
|
"home.settings.plugins.streamystats.server_url_placeholder",
|
||||||
|
)}
|
||||||
|
value={url}
|
||||||
|
keyboardType='url'
|
||||||
|
returnKeyType='done'
|
||||||
|
autoCapitalize='none'
|
||||||
|
textContentType='URL'
|
||||||
|
onChangeText={setUrl}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
</ListGroup>
|
||||||
|
|
||||||
|
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||||
|
{t("home.settings.plugins.streamystats.streamystats_search_hint")}{" "}
|
||||||
|
<Text className='text-blue-500' onPress={handleOpenLink}>
|
||||||
|
{t(
|
||||||
|
"home.settings.plugins.streamystats.read_more_about_streamystats",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<ListGroup
|
||||||
|
title={t("home.settings.plugins.streamystats.features_title")}
|
||||||
|
className='mt-4'
|
||||||
|
>
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.plugins.streamystats.enable_search")}
|
||||||
|
disabledByAdmin={pluginSettings?.searchEngine?.locked === true}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
value={useForSearch}
|
||||||
|
disabled={!isStreamystatsEnabled}
|
||||||
|
onValueChange={setUseForSearch}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem
|
||||||
|
title={t(
|
||||||
|
"home.settings.plugins.streamystats.enable_movie_recommendations",
|
||||||
|
)}
|
||||||
|
disabledByAdmin={
|
||||||
|
pluginSettings?.streamyStatsMovieRecommendations?.locked === true
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
value={movieRecs}
|
||||||
|
onValueChange={setMovieRecs}
|
||||||
|
disabled={!isStreamystatsEnabled}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem
|
||||||
|
title={t(
|
||||||
|
"home.settings.plugins.streamystats.enable_series_recommendations",
|
||||||
|
)}
|
||||||
|
disabledByAdmin={
|
||||||
|
pluginSettings?.streamyStatsSeriesRecommendations?.locked === true
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
value={seriesRecs}
|
||||||
|
onValueChange={setSeriesRecs}
|
||||||
|
disabled={!isStreamystatsEnabled}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem
|
||||||
|
title={t(
|
||||||
|
"home.settings.plugins.streamystats.enable_promoted_watchlists",
|
||||||
|
)}
|
||||||
|
disabledByAdmin={
|
||||||
|
pluginSettings?.streamyStatsPromotedWatchlists?.locked === true
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
value={promotedWatchlists}
|
||||||
|
onValueChange={setPromotedWatchlists}
|
||||||
|
disabled={!isStreamystatsEnabled}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.plugins.streamystats.hide_watchlists_tab")}
|
||||||
|
disabledByAdmin={pluginSettings?.hideWatchlistsTab?.locked === true}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
value={hideWatchlistsTab}
|
||||||
|
onValueChange={setHideWatchlistsTab}
|
||||||
|
disabled={!isStreamystatsEnabled}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
</ListGroup>
|
||||||
|
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||||
|
{t("home.settings.plugins.streamystats.home_sections_hint")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleRefreshFromServer}
|
||||||
|
className='mt-6 py-3 rounded-xl bg-neutral-800'
|
||||||
|
>
|
||||||
|
<Text className='text-center text-blue-500'>
|
||||||
|
{t("home.settings.plugins.streamystats.refresh_from_server")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* Disable button - only show if URL is not locked and Streamystats is enabled */}
|
||||||
|
{!isUrlLocked && isStreamystatsEnabled && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleClearStreamystats}
|
||||||
|
className='mt-3 mb-4 py-3 rounded-xl bg-neutral-800'
|
||||||
|
>
|
||||||
|
<Text className='text-center text-red-500'>
|
||||||
|
{t("home.settings.plugins.streamystats.disable_streamystats")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</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
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ItemFields } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
@@ -20,7 +21,16 @@ const Page: React.FC = () => {
|
|||||||
const { offline } = useLocalSearchParams() as { offline?: string };
|
const { offline } = useLocalSearchParams() as { offline?: string };
|
||||||
const isOffline = offline === "true";
|
const isOffline = offline === "true";
|
||||||
|
|
||||||
const { data: item, isError } = useItemQuery(id, isOffline);
|
// Exclude MediaSources/MediaStreams from initial fetch for faster loading
|
||||||
|
// (especially important for plugins like Gelato)
|
||||||
|
const { data: item, isError } = useItemQuery(id, isOffline, undefined, [
|
||||||
|
ItemFields.MediaSources,
|
||||||
|
ItemFields.MediaSourceCount,
|
||||||
|
ItemFields.MediaStreams,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Lazily preload item with full media sources in background
|
||||||
|
const { data: itemWithSources } = useItemQuery(id, isOffline, undefined, []);
|
||||||
|
|
||||||
const opacity = useSharedValue(1);
|
const opacity = useSharedValue(1);
|
||||||
const animatedStyle = useAnimatedStyle(() => {
|
const animatedStyle = useAnimatedStyle(() => {
|
||||||
@@ -90,7 +100,13 @@ const Page: React.FC = () => {
|
|||||||
<View className='h-12 bg-neutral-900 rounded-lg w-full mb-2' />
|
<View className='h-12 bg-neutral-900 rounded-lg w-full mb-2' />
|
||||||
<View className='h-24 bg-neutral-900 rounded-lg mb-1 w-full' />
|
<View className='h-24 bg-neutral-900 rounded-lg mb-1 w-full' />
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
{item && <ItemContent item={item} isOffline={isOffline} />}
|
{item && (
|
||||||
|
<ItemContent
|
||||||
|
item={item}
|
||||||
|
isOffline={isOffline}
|
||||||
|
itemWithSources={itemWithSources}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -21,19 +21,18 @@ export default function page() {
|
|||||||
companyId: string;
|
companyId: string;
|
||||||
name: string;
|
name: string;
|
||||||
image: string;
|
image: string;
|
||||||
type: DiscoverSliderType;
|
type: DiscoverSliderType; //This gets converted to a string because it's a url param
|
||||||
};
|
};
|
||||||
|
|
||||||
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
const { data, fetchNextPage, hasNextPage, isLoading } = useInfiniteQuery({
|
||||||
queryKey: ["jellyseerr", "company", type, companyId],
|
queryKey: ["jellyseerr", "company", type, companyId],
|
||||||
queryFn: async ({ pageParam }) => {
|
queryFn: async ({ pageParam }) => {
|
||||||
const params: any = {
|
const params: any = {
|
||||||
page: Number(pageParam),
|
page: Number(pageParam),
|
||||||
};
|
};
|
||||||
|
|
||||||
return jellyseerrApi?.discover(
|
return jellyseerrApi?.discover(
|
||||||
`${
|
`${
|
||||||
type === DiscoverSliderType.NETWORKS
|
Number(type) === DiscoverSliderType.NETWORKS
|
||||||
? Endpoints.DISCOVER_TV_NETWORK
|
? Endpoints.DISCOVER_TV_NETWORK
|
||||||
: Endpoints.DISCOVER_MOVIES_STUDIO
|
: Endpoints.DISCOVER_MOVIES_STUDIO
|
||||||
}/${companyId}`,
|
}/${companyId}`,
|
||||||
@@ -86,6 +85,7 @@ export default function page() {
|
|||||||
fetchNextPage();
|
fetchNextPage();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
isLoading={isLoading}
|
||||||
logo={
|
logo={
|
||||||
<Image
|
<Image
|
||||||
id={companyId}
|
id={companyId}
|
||||||
@@ -14,36 +14,43 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { Text } from "@/components/common/Text";
|
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 {
|
||||||
|
MediaRequestStatus,
|
||||||
|
MediaType,
|
||||||
|
} from "@/utils/jellyseerr/server/constants/media";
|
||||||
|
import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
|
||||||
|
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
||||||
|
import {
|
||||||
|
hasPermission,
|
||||||
|
Permission,
|
||||||
|
} from "@/utils/jellyseerr/server/lib/permissions";
|
||||||
|
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();
|
||||||
@@ -60,11 +67,12 @@ const Page: React.FC = () => {
|
|||||||
} & Partial<MovieResult | TvResult | MovieDetails | TvDetails>;
|
} & Partial<MovieResult | TvResult | MovieDetails | TvDetails>;
|
||||||
|
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const { jellyseerrApi, requestMedia } = useJellyseerr();
|
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
@@ -92,6 +100,46 @@ const Page: React.FC = () => {
|
|||||||
const [canRequest, hasAdvancedRequestPermission] =
|
const [canRequest, hasAdvancedRequestPermission] =
|
||||||
useJellyseerrCanRequest(details);
|
useJellyseerrCanRequest(details);
|
||||||
|
|
||||||
|
const canManageRequests = useMemo(() => {
|
||||||
|
if (!jellyseerrUser) return false;
|
||||||
|
return hasPermission(
|
||||||
|
Permission.MANAGE_REQUESTS,
|
||||||
|
jellyseerrUser.permissions,
|
||||||
|
);
|
||||||
|
}, [jellyseerrUser]);
|
||||||
|
|
||||||
|
const pendingRequest = useMemo(() => {
|
||||||
|
return details?.mediaInfo?.requests?.find(
|
||||||
|
(r: MediaRequest) => r.status === MediaRequestStatus.PENDING,
|
||||||
|
);
|
||||||
|
}, [details]);
|
||||||
|
|
||||||
|
const handleApproveRequest = useCallback(async () => {
|
||||||
|
if (!pendingRequest?.id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await jellyseerrApi?.approveRequest(pendingRequest.id);
|
||||||
|
toast.success(t("jellyseerr.toasts.request_approved"));
|
||||||
|
refetch();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(t("jellyseerr.toasts.failed_to_approve_request"));
|
||||||
|
console.error("Failed to approve request:", error);
|
||||||
|
}
|
||||||
|
}, [jellyseerrApi, pendingRequest, refetch, t]);
|
||||||
|
|
||||||
|
const handleDeclineRequest = useCallback(async () => {
|
||||||
|
if (!pendingRequest?.id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await jellyseerrApi?.declineRequest(pendingRequest.id);
|
||||||
|
toast.success(t("jellyseerr.toasts.request_declined"));
|
||||||
|
refetch();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(t("jellyseerr.toasts.failed_to_decline_request"));
|
||||||
|
console.error("Failed to decline request:", error);
|
||||||
|
}
|
||||||
|
}, [jellyseerrApi, pendingRequest, refetch, t]);
|
||||||
|
|
||||||
const renderBackdrop = useCallback(
|
const renderBackdrop = useCallback(
|
||||||
(props: BottomSheetBackdropProps) => (
|
(props: BottomSheetBackdropProps) => (
|
||||||
<BottomSheetBackdrop
|
<BottomSheetBackdrop
|
||||||
@@ -115,6 +163,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 +180,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 +210,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>
|
||||||
),
|
),
|
||||||
@@ -309,6 +383,60 @@ const Page: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
{canManageRequests && pendingRequest && (
|
||||||
|
<View className='flex flex-col space-y-2 mt-4'>
|
||||||
|
<View className='flex flex-row items-center space-x-2'>
|
||||||
|
<Ionicons name='person-outline' size={16} color='#9CA3AF' />
|
||||||
|
<Text className='text-sm text-neutral-400'>
|
||||||
|
{t("jellyseerr.requested_by", {
|
||||||
|
user:
|
||||||
|
pendingRequest.requestedBy?.displayName ||
|
||||||
|
pendingRequest.requestedBy?.username ||
|
||||||
|
pendingRequest.requestedBy?.jellyfinUsername ||
|
||||||
|
t("jellyseerr.unknown_user"),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className='flex flex-row space-x-2'>
|
||||||
|
<Button
|
||||||
|
className='flex-1 bg-green-600/50 border-green-400 ring-green-400 text-green-100'
|
||||||
|
color='transparent'
|
||||||
|
onPress={handleApproveRequest}
|
||||||
|
iconLeft={
|
||||||
|
<Ionicons
|
||||||
|
name='checkmark-outline'
|
||||||
|
size={20}
|
||||||
|
color='white'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className='text-sm'>{t("jellyseerr.approve")}</Text>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className='flex-1 bg-red-600/50 border-red-400 ring-red-400 text-red-100'
|
||||||
|
color='transparent'
|
||||||
|
onPress={handleDeclineRequest}
|
||||||
|
iconLeft={
|
||||||
|
<Ionicons
|
||||||
|
name='close-outline'
|
||||||
|
size={20}
|
||||||
|
color='white'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className='text-sm'>{t("jellyseerr.decline")}</Text>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
<OverviewText text={result.overview} className='mt-4' />
|
<OverviewText text={result.overview} className='mt-4' />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -355,6 +483,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 +494,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'>
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getItemsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Dimensions,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
|
||||||
|
import { MusicTrackItem } from "@/components/music/MusicTrackItem";
|
||||||
|
import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet";
|
||||||
|
import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet";
|
||||||
|
import {
|
||||||
|
downloadTrack,
|
||||||
|
isPermanentlyDownloaded,
|
||||||
|
} from "@/providers/AudioStorage";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
|
||||||
|
import { getAudioStreamUrl } from "@/utils/jellyfin/audio/getAudioStreamUrl";
|
||||||
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
|
|
||||||
|
const { width: SCREEN_WIDTH } = Dimensions.get("window");
|
||||||
|
const ARTWORK_SIZE = SCREEN_WIDTH * 0.5;
|
||||||
|
|
||||||
|
export default function AlbumDetailScreen() {
|
||||||
|
const { albumId } = useLocalSearchParams<{ albumId: string }>();
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { playQueue } = useMusicPlayer();
|
||||||
|
|
||||||
|
const [selectedTrack, setSelectedTrack] = useState<BaseItemDto | null>(null);
|
||||||
|
const [trackOptionsOpen, setTrackOptionsOpen] = useState(false);
|
||||||
|
const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false);
|
||||||
|
const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false);
|
||||||
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
|
|
||||||
|
const handleTrackOptionsPress = useCallback((track: BaseItemDto) => {
|
||||||
|
setSelectedTrack(track);
|
||||||
|
setTrackOptionsOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAddToPlaylist = useCallback(() => {
|
||||||
|
setPlaylistPickerOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCreateNewPlaylist = useCallback(() => {
|
||||||
|
setCreatePlaylistOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { data: album, isLoading: loadingAlbum } = useQuery({
|
||||||
|
queryKey: ["music-album", albumId, user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await getUserLibraryApi(api!).getItem({
|
||||||
|
userId: user?.Id,
|
||||||
|
itemId: albumId!,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id && !!albumId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: tracks, isLoading: loadingTracks } = useQuery({
|
||||||
|
queryKey: ["music-album-tracks", albumId, user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await getItemsApi(api!).getItems({
|
||||||
|
userId: user?.Id,
|
||||||
|
parentId: albumId,
|
||||||
|
sortBy: ["IndexNumber"],
|
||||||
|
sortOrder: ["Ascending"],
|
||||||
|
});
|
||||||
|
return response.data.Items || [];
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id && !!albumId,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
navigation.setOptions({
|
||||||
|
title: album?.Name ?? "",
|
||||||
|
headerTransparent: true,
|
||||||
|
headerStyle: { backgroundColor: "transparent" },
|
||||||
|
headerShadowVisible: false,
|
||||||
|
});
|
||||||
|
}, [album?.Name, navigation]);
|
||||||
|
|
||||||
|
const imageUrl = useMemo(
|
||||||
|
() => (album ? getPrimaryImageUrl({ api, item: album }) : null),
|
||||||
|
[api, album],
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalDuration = useMemo(() => {
|
||||||
|
if (!tracks) return "";
|
||||||
|
const totalTicks = tracks.reduce(
|
||||||
|
(acc, track) => acc + (track.RunTimeTicks || 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
return runtimeTicksToMinutes(totalTicks);
|
||||||
|
}, [tracks]);
|
||||||
|
|
||||||
|
const handlePlayAll = useCallback(() => {
|
||||||
|
if (tracks && tracks.length > 0) {
|
||||||
|
playQueue(tracks, 0);
|
||||||
|
}
|
||||||
|
}, [playQueue, tracks]);
|
||||||
|
|
||||||
|
const handleShuffle = useCallback(() => {
|
||||||
|
if (tracks && tracks.length > 0) {
|
||||||
|
const shuffled = [...tracks].sort(() => Math.random() - 0.5);
|
||||||
|
playQueue(shuffled, 0);
|
||||||
|
}
|
||||||
|
}, [playQueue, tracks]);
|
||||||
|
|
||||||
|
// Check if all tracks are already permanently downloaded
|
||||||
|
const allTracksDownloaded = useMemo(() => {
|
||||||
|
if (!tracks || tracks.length === 0) return false;
|
||||||
|
return tracks.every((track) => isPermanentlyDownloaded(track.Id));
|
||||||
|
}, [tracks]);
|
||||||
|
|
||||||
|
const handleDownloadAlbum = useCallback(async () => {
|
||||||
|
if (!tracks || !api || !user?.Id || isDownloading) return;
|
||||||
|
|
||||||
|
setIsDownloading(true);
|
||||||
|
try {
|
||||||
|
for (const track of tracks) {
|
||||||
|
if (!track.Id || isPermanentlyDownloaded(track.Id)) continue;
|
||||||
|
const result = await getAudioStreamUrl(api, user.Id, track.Id);
|
||||||
|
if (result?.url && !result.isTranscoding) {
|
||||||
|
await downloadTrack(track.Id, result.url, {
|
||||||
|
permanent: true,
|
||||||
|
container: result.mediaSource?.Container || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silent fail
|
||||||
|
}
|
||||||
|
setIsDownloading(false);
|
||||||
|
}, [tracks, api, user?.Id, isDownloading]);
|
||||||
|
|
||||||
|
const isLoading = loadingAlbum || loadingTracks;
|
||||||
|
|
||||||
|
// Only show loading if we have no cached data to display
|
||||||
|
if (isLoading && !album) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black'>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!album) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black'>
|
||||||
|
<Text className='text-neutral-500'>{t("music.album_not_found")}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlashList
|
||||||
|
data={tracks || []}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingBottom: insets.bottom + 100,
|
||||||
|
}}
|
||||||
|
ListHeaderComponent={
|
||||||
|
<View
|
||||||
|
className='items-center px-4 pb-6 bg-black'
|
||||||
|
style={{ paddingTop: insets.top + 60 }}
|
||||||
|
>
|
||||||
|
{/* Album artwork */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: ARTWORK_SIZE,
|
||||||
|
height: ARTWORK_SIZE,
|
||||||
|
borderRadius: 8,
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 8 },
|
||||||
|
shadowOpacity: 0.3,
|
||||||
|
shadowRadius: 12,
|
||||||
|
elevation: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{imageUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: imageUrl }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
cachePolicy='memory-disk'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View className='flex-1 items-center justify-center bg-neutral-800'>
|
||||||
|
<Ionicons name='disc' size={60} color='#666' />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Album info */}
|
||||||
|
<Text className='text-white text-xl font-bold mt-4 text-center'>
|
||||||
|
{album.Name}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-purple-400 text-base mt-1'>
|
||||||
|
{album.AlbumArtist || album.Artists?.join(", ")}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-neutral-500 text-sm mt-1'>
|
||||||
|
{album.ProductionYear && `${album.ProductionYear} • `}
|
||||||
|
{tracks?.length} tracks • {totalDuration}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Play buttons */}
|
||||||
|
<View className='flex flex-row mt-4 items-center'>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handlePlayAll}
|
||||||
|
className='flex flex-row items-center bg-purple-600 px-6 py-3 rounded-full mr-3'
|
||||||
|
>
|
||||||
|
<Ionicons name='play' size={20} color='white' />
|
||||||
|
<Text className='text-white font-medium ml-2'>
|
||||||
|
{t("music.play")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleShuffle}
|
||||||
|
className='flex flex-row items-center bg-neutral-800 px-6 py-3 rounded-full mr-3'
|
||||||
|
>
|
||||||
|
<Ionicons name='shuffle' size={20} color='white' />
|
||||||
|
<Text className='text-white font-medium ml-2'>
|
||||||
|
{t("music.shuffle")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleDownloadAlbum}
|
||||||
|
disabled={allTracksDownloaded || isDownloading}
|
||||||
|
className='flex items-center justify-center bg-neutral-800 p-3 rounded-full'
|
||||||
|
>
|
||||||
|
{isDownloading ? (
|
||||||
|
<ActivityIndicator size={20} color='white' />
|
||||||
|
) : (
|
||||||
|
<Ionicons
|
||||||
|
name={
|
||||||
|
allTracksDownloaded
|
||||||
|
? "checkmark-circle"
|
||||||
|
: "download-outline"
|
||||||
|
}
|
||||||
|
size={20}
|
||||||
|
color={allTracksDownloaded ? "#22c55e" : "white"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
renderItem={({ item, index }) => (
|
||||||
|
<MusicTrackItem
|
||||||
|
track={item}
|
||||||
|
index={index + 1}
|
||||||
|
queue={tracks}
|
||||||
|
showArtwork={false}
|
||||||
|
onOptionsPress={handleTrackOptionsPress}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
keyExtractor={(item) => item.Id!}
|
||||||
|
ListFooterComponent={
|
||||||
|
<>
|
||||||
|
<TrackOptionsSheet
|
||||||
|
open={trackOptionsOpen}
|
||||||
|
setOpen={setTrackOptionsOpen}
|
||||||
|
track={selectedTrack}
|
||||||
|
onAddToPlaylist={handleAddToPlaylist}
|
||||||
|
/>
|
||||||
|
<PlaylistPickerSheet
|
||||||
|
open={playlistPickerOpen}
|
||||||
|
setOpen={setPlaylistPickerOpen}
|
||||||
|
trackToAdd={selectedTrack}
|
||||||
|
onCreateNew={handleCreateNewPlaylist}
|
||||||
|
/>
|
||||||
|
<CreatePlaylistModal
|
||||||
|
open={createPlaylistOpen}
|
||||||
|
setOpen={setCreatePlaylistOpen}
|
||||||
|
initialTrackId={selectedTrack?.Id}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getItemsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Dimensions, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { HorizontalScroll } from "@/components/common/HorizontalScroll";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
|
||||||
|
import { MusicAlbumCard } from "@/components/music/MusicAlbumCard";
|
||||||
|
import { MusicTrackItem } from "@/components/music/MusicTrackItem";
|
||||||
|
import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet";
|
||||||
|
import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
|
||||||
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
|
||||||
|
const { width: SCREEN_WIDTH } = Dimensions.get("window");
|
||||||
|
const ARTWORK_SIZE = SCREEN_WIDTH * 0.4;
|
||||||
|
|
||||||
|
export default function ArtistDetailScreen() {
|
||||||
|
const { artistId } = useLocalSearchParams<{ artistId: string }>();
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { playQueue } = useMusicPlayer();
|
||||||
|
|
||||||
|
const [selectedTrack, setSelectedTrack] = useState<BaseItemDto | null>(null);
|
||||||
|
const [trackOptionsOpen, setTrackOptionsOpen] = useState(false);
|
||||||
|
const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false);
|
||||||
|
const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleTrackOptionsPress = useCallback((track: BaseItemDto) => {
|
||||||
|
setSelectedTrack(track);
|
||||||
|
setTrackOptionsOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAddToPlaylist = useCallback(() => {
|
||||||
|
setPlaylistPickerOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCreateNewPlaylist = useCallback(() => {
|
||||||
|
setCreatePlaylistOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { data: artist, isLoading: loadingArtist } = useQuery({
|
||||||
|
queryKey: ["music-artist", artistId, user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await getUserLibraryApi(api!).getItem({
|
||||||
|
userId: user?.Id,
|
||||||
|
itemId: artistId!,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id && !!artistId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: albums, isLoading: loadingAlbums } = useQuery({
|
||||||
|
queryKey: ["music-artist-albums", artistId, user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await getItemsApi(api!).getItems({
|
||||||
|
userId: user?.Id,
|
||||||
|
artistIds: [artistId!],
|
||||||
|
includeItemTypes: ["MusicAlbum"],
|
||||||
|
sortBy: ["ProductionYear", "SortName"],
|
||||||
|
sortOrder: ["Descending", "Ascending"],
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
return response.data.Items || [];
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id && !!artistId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: topTracks, isLoading: loadingTracks } = useQuery({
|
||||||
|
queryKey: ["music-artist-top-tracks", artistId, user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await getItemsApi(api!).getItems({
|
||||||
|
userId: user?.Id,
|
||||||
|
artistIds: [artistId!],
|
||||||
|
includeItemTypes: ["Audio"],
|
||||||
|
sortBy: ["PlayCount"],
|
||||||
|
sortOrder: ["Descending"],
|
||||||
|
limit: 10,
|
||||||
|
recursive: true,
|
||||||
|
filters: ["IsPlayed"],
|
||||||
|
});
|
||||||
|
return response.data.Items || [];
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id && !!artistId,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
navigation.setOptions({
|
||||||
|
title: artist?.Name ?? "",
|
||||||
|
headerTransparent: true,
|
||||||
|
headerStyle: { backgroundColor: "transparent" },
|
||||||
|
headerShadowVisible: false,
|
||||||
|
});
|
||||||
|
}, [artist?.Name, navigation]);
|
||||||
|
|
||||||
|
const imageUrl = useMemo(
|
||||||
|
() => (artist ? getPrimaryImageUrl({ api, item: artist }) : null),
|
||||||
|
[api, artist],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePlayAllTracks = useCallback(() => {
|
||||||
|
if (topTracks && topTracks.length > 0) {
|
||||||
|
playQueue(topTracks, 0);
|
||||||
|
}
|
||||||
|
}, [playQueue, topTracks]);
|
||||||
|
|
||||||
|
const isLoading = loadingArtist || loadingAlbums || loadingTracks;
|
||||||
|
|
||||||
|
// Only show loading if we have no cached data to display
|
||||||
|
if (isLoading && !artist) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black'>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!artist) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black'>
|
||||||
|
<Text className='text-neutral-500'>{t("music.artist_not_found")}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sections = [];
|
||||||
|
|
||||||
|
// Top tracks section
|
||||||
|
if (topTracks && topTracks.length > 0) {
|
||||||
|
sections.push({
|
||||||
|
id: "top-tracks",
|
||||||
|
title: t("music.top_tracks"),
|
||||||
|
type: "tracks" as const,
|
||||||
|
data: topTracks,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Albums section
|
||||||
|
if (albums && albums.length > 0) {
|
||||||
|
sections.push({
|
||||||
|
id: "albums",
|
||||||
|
title: t("music.tabs.albums"),
|
||||||
|
type: "albums" as const,
|
||||||
|
data: albums,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlashList
|
||||||
|
data={sections}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingBottom: insets.bottom + 100,
|
||||||
|
}}
|
||||||
|
ListHeaderComponent={
|
||||||
|
<View
|
||||||
|
className='items-center px-4 pb-6 bg-black'
|
||||||
|
style={{ paddingTop: insets.top + 50 }}
|
||||||
|
>
|
||||||
|
{/* Artist image */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: ARTWORK_SIZE,
|
||||||
|
height: ARTWORK_SIZE,
|
||||||
|
borderRadius: ARTWORK_SIZE / 2,
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 8 },
|
||||||
|
shadowOpacity: 0.3,
|
||||||
|
shadowRadius: 12,
|
||||||
|
elevation: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{imageUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: imageUrl }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
cachePolicy='memory-disk'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View className='flex-1 items-center justify-center bg-neutral-800'>
|
||||||
|
<Ionicons name='person' size={60} color='#666' />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Artist info */}
|
||||||
|
<Text className='text-white text-2xl font-bold mt-4 text-center'>
|
||||||
|
{artist.Name}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-neutral-500 text-sm mt-1'>
|
||||||
|
{albums?.length || 0} {t("music.tabs.albums").toLowerCase()}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Play button */}
|
||||||
|
{topTracks && topTracks.length > 0 && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handlePlayAllTracks}
|
||||||
|
className='flex flex-row items-center bg-purple-600 px-6 py-3 rounded-full mt-4'
|
||||||
|
>
|
||||||
|
<Ionicons name='play' size={20} color='white' />
|
||||||
|
<Text className='text-white font-medium ml-2'>
|
||||||
|
{t("music.play_top_tracks")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
renderItem={({ item: section }) => (
|
||||||
|
<View className='mb-6'>
|
||||||
|
<Text className='text-lg font-bold px-4 mb-3'>{section.title}</Text>
|
||||||
|
{section.type === "albums" ? (
|
||||||
|
<HorizontalScroll
|
||||||
|
data={section.data}
|
||||||
|
height={200}
|
||||||
|
keyExtractor={(item) => item.Id!}
|
||||||
|
renderItem={(item) => <MusicAlbumCard album={item} />}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
section.data
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((track, index) => (
|
||||||
|
<MusicTrackItem
|
||||||
|
key={track.Id}
|
||||||
|
track={track}
|
||||||
|
index={index + 1}
|
||||||
|
queue={section.data}
|
||||||
|
onOptionsPress={handleTrackOptionsPress}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
ListFooterComponent={
|
||||||
|
<>
|
||||||
|
<TrackOptionsSheet
|
||||||
|
open={trackOptionsOpen}
|
||||||
|
setOpen={setTrackOptionsOpen}
|
||||||
|
track={selectedTrack}
|
||||||
|
onAddToPlaylist={handleAddToPlaylist}
|
||||||
|
/>
|
||||||
|
<PlaylistPickerSheet
|
||||||
|
open={playlistPickerOpen}
|
||||||
|
setOpen={setPlaylistPickerOpen}
|
||||||
|
trackToAdd={selectedTrack}
|
||||||
|
onCreateNew={handleCreateNewPlaylist}
|
||||||
|
/>
|
||||||
|
<CreatePlaylistModal
|
||||||
|
open={createPlaylistOpen}
|
||||||
|
setOpen={setCreatePlaylistOpen}
|
||||||
|
initialTrackId={selectedTrack?.Id}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getItemsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Dimensions,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
|
||||||
|
import { MusicTrackItem } from "@/components/music/MusicTrackItem";
|
||||||
|
import { PlaylistOptionsSheet } from "@/components/music/PlaylistOptionsSheet";
|
||||||
|
import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet";
|
||||||
|
import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet";
|
||||||
|
import { useRemoveFromPlaylist } from "@/hooks/usePlaylistMutations";
|
||||||
|
import { downloadTrack, getLocalPath } from "@/providers/AudioStorage";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
|
||||||
|
import { getAudioStreamUrl } from "@/utils/jellyfin/audio/getAudioStreamUrl";
|
||||||
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
|
|
||||||
|
const { width: SCREEN_WIDTH } = Dimensions.get("window");
|
||||||
|
const ARTWORK_SIZE = SCREEN_WIDTH * 0.5;
|
||||||
|
|
||||||
|
export default function PlaylistDetailScreen() {
|
||||||
|
const { playlistId } = useLocalSearchParams<{ playlistId: string }>();
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { playQueue } = useMusicPlayer();
|
||||||
|
|
||||||
|
const [selectedTrack, setSelectedTrack] = useState<BaseItemDto | null>(null);
|
||||||
|
const [trackOptionsOpen, setTrackOptionsOpen] = useState(false);
|
||||||
|
const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false);
|
||||||
|
const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false);
|
||||||
|
const [playlistOptionsOpen, setPlaylistOptionsOpen] = useState(false);
|
||||||
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
|
|
||||||
|
const removeFromPlaylist = useRemoveFromPlaylist();
|
||||||
|
|
||||||
|
const handleTrackOptionsPress = useCallback((track: BaseItemDto) => {
|
||||||
|
setSelectedTrack(track);
|
||||||
|
setTrackOptionsOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAddToPlaylist = useCallback(() => {
|
||||||
|
setPlaylistPickerOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCreateNewPlaylist = useCallback(() => {
|
||||||
|
setCreatePlaylistOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRemoveFromPlaylist = useCallback(() => {
|
||||||
|
if (selectedTrack?.Id && playlistId) {
|
||||||
|
removeFromPlaylist.mutate({
|
||||||
|
playlistId,
|
||||||
|
entryIds: [selectedTrack.PlaylistItemId ?? selectedTrack.Id],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [selectedTrack, playlistId, removeFromPlaylist]);
|
||||||
|
|
||||||
|
const { data: playlist, isLoading: loadingPlaylist } = useQuery({
|
||||||
|
queryKey: ["music-playlist", playlistId, user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await getUserLibraryApi(api!).getItem({
|
||||||
|
userId: user?.Id,
|
||||||
|
itemId: playlistId!,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id && !!playlistId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: tracks, isLoading: loadingTracks } = useQuery({
|
||||||
|
queryKey: ["music-playlist-tracks", playlistId, user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await getItemsApi(api!).getItems({
|
||||||
|
userId: user?.Id,
|
||||||
|
parentId: playlistId,
|
||||||
|
});
|
||||||
|
return response.data.Items || [];
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id && !!playlistId,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
navigation.setOptions({
|
||||||
|
title: playlist?.Name ?? "",
|
||||||
|
headerTransparent: true,
|
||||||
|
headerStyle: { backgroundColor: "transparent" },
|
||||||
|
headerShadowVisible: false,
|
||||||
|
headerRight: () => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setPlaylistOptionsOpen(true)}
|
||||||
|
className='p-1.5'
|
||||||
|
>
|
||||||
|
<Ionicons name='ellipsis-horizontal' size={24} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}, [playlist?.Name, navigation]);
|
||||||
|
|
||||||
|
const imageUrl = useMemo(
|
||||||
|
() => (playlist ? getPrimaryImageUrl({ api, item: playlist }) : null),
|
||||||
|
[api, playlist],
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalDuration = useMemo(() => {
|
||||||
|
if (!tracks) return "";
|
||||||
|
const totalTicks = tracks.reduce(
|
||||||
|
(acc, track) => acc + (track.RunTimeTicks || 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
return runtimeTicksToMinutes(totalTicks);
|
||||||
|
}, [tracks]);
|
||||||
|
|
||||||
|
const handlePlayAll = useCallback(() => {
|
||||||
|
if (tracks && tracks.length > 0) {
|
||||||
|
playQueue(tracks, 0);
|
||||||
|
}
|
||||||
|
}, [playQueue, tracks]);
|
||||||
|
|
||||||
|
const handleShuffle = useCallback(() => {
|
||||||
|
if (tracks && tracks.length > 0) {
|
||||||
|
const shuffled = [...tracks].sort(() => Math.random() - 0.5);
|
||||||
|
playQueue(shuffled, 0);
|
||||||
|
}
|
||||||
|
}, [playQueue, tracks]);
|
||||||
|
|
||||||
|
// Check if all tracks are already downloaded
|
||||||
|
const allTracksDownloaded = useMemo(() => {
|
||||||
|
if (!tracks || tracks.length === 0) return false;
|
||||||
|
return tracks.every((track) => !!getLocalPath(track.Id));
|
||||||
|
}, [tracks]);
|
||||||
|
|
||||||
|
const handleDownloadPlaylist = useCallback(async () => {
|
||||||
|
if (!tracks || !api || !user?.Id || isDownloading) return;
|
||||||
|
|
||||||
|
setIsDownloading(true);
|
||||||
|
try {
|
||||||
|
for (const track of tracks) {
|
||||||
|
if (!track.Id || getLocalPath(track.Id)) continue;
|
||||||
|
const result = await getAudioStreamUrl(api, user.Id, track.Id);
|
||||||
|
if (result?.url && !result.isTranscoding) {
|
||||||
|
await downloadTrack(track.Id, result.url, {
|
||||||
|
permanent: true,
|
||||||
|
container: result.mediaSource?.Container || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silent fail
|
||||||
|
}
|
||||||
|
setIsDownloading(false);
|
||||||
|
}, [tracks, api, user?.Id, isDownloading]);
|
||||||
|
|
||||||
|
const isLoading = loadingPlaylist || loadingTracks;
|
||||||
|
|
||||||
|
// Only show loading if we have no cached data to display
|
||||||
|
if (isLoading && !playlist) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black'>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!playlist) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black'>
|
||||||
|
<Text className='text-neutral-500'>
|
||||||
|
{t("music.playlist_not_found")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlashList
|
||||||
|
data={tracks || []}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingBottom: insets.bottom + 100,
|
||||||
|
}}
|
||||||
|
ListHeaderComponent={
|
||||||
|
<View
|
||||||
|
className='items-center px-4 pb-6 bg-black'
|
||||||
|
style={{ paddingTop: insets.top + 50 }}
|
||||||
|
>
|
||||||
|
{/* Playlist artwork */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: ARTWORK_SIZE,
|
||||||
|
height: ARTWORK_SIZE,
|
||||||
|
borderRadius: 8,
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 8 },
|
||||||
|
shadowOpacity: 0.3,
|
||||||
|
shadowRadius: 12,
|
||||||
|
elevation: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{imageUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: imageUrl }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
cachePolicy='memory-disk'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View className='flex-1 items-center justify-center bg-neutral-800'>
|
||||||
|
<Ionicons name='list' size={60} color='#666' />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Playlist info */}
|
||||||
|
<Text className='text-white text-xl font-bold mt-4 text-center'>
|
||||||
|
{playlist.Name}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-neutral-500 text-sm mt-1'>
|
||||||
|
{tracks?.length} tracks • {totalDuration}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Play buttons */}
|
||||||
|
<View className='flex flex-row mt-4 items-center'>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handlePlayAll}
|
||||||
|
className='flex flex-row items-center bg-purple-600 px-6 py-3 rounded-full mr-3'
|
||||||
|
>
|
||||||
|
<Ionicons name='play' size={20} color='white' />
|
||||||
|
<Text className='text-white font-medium ml-2'>
|
||||||
|
{t("music.play")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleShuffle}
|
||||||
|
className='flex flex-row items-center bg-neutral-800 px-6 py-3 rounded-full mr-3'
|
||||||
|
>
|
||||||
|
<Ionicons name='shuffle' size={20} color='white' />
|
||||||
|
<Text className='text-white font-medium ml-2'>
|
||||||
|
{t("music.shuffle")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleDownloadPlaylist}
|
||||||
|
disabled={allTracksDownloaded || isDownloading}
|
||||||
|
className='flex items-center justify-center bg-neutral-800 p-3 rounded-full'
|
||||||
|
>
|
||||||
|
{isDownloading ? (
|
||||||
|
<ActivityIndicator size={20} color='white' />
|
||||||
|
) : (
|
||||||
|
<Ionicons
|
||||||
|
name={
|
||||||
|
allTracksDownloaded
|
||||||
|
? "checkmark-circle"
|
||||||
|
: "download-outline"
|
||||||
|
}
|
||||||
|
size={20}
|
||||||
|
color={allTracksDownloaded ? "#22c55e" : "white"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
renderItem={({ item, index }) => (
|
||||||
|
<MusicTrackItem
|
||||||
|
track={item}
|
||||||
|
index={index + 1}
|
||||||
|
queue={tracks}
|
||||||
|
onOptionsPress={handleTrackOptionsPress}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
keyExtractor={(item) => item.Id!}
|
||||||
|
ListFooterComponent={
|
||||||
|
<>
|
||||||
|
<TrackOptionsSheet
|
||||||
|
open={trackOptionsOpen}
|
||||||
|
setOpen={setTrackOptionsOpen}
|
||||||
|
track={selectedTrack}
|
||||||
|
onAddToPlaylist={handleAddToPlaylist}
|
||||||
|
playlistId={playlistId}
|
||||||
|
onRemoveFromPlaylist={handleRemoveFromPlaylist}
|
||||||
|
/>
|
||||||
|
<PlaylistPickerSheet
|
||||||
|
open={playlistPickerOpen}
|
||||||
|
setOpen={setPlaylistPickerOpen}
|
||||||
|
trackToAdd={selectedTrack}
|
||||||
|
onCreateNew={handleCreateNewPlaylist}
|
||||||
|
/>
|
||||||
|
<CreatePlaylistModal
|
||||||
|
open={createPlaylistOpen}
|
||||||
|
setOpen={setCreatePlaylistOpen}
|
||||||
|
initialTrackId={selectedTrack?.Id}
|
||||||
|
/>
|
||||||
|
<PlaylistOptionsSheet
|
||||||
|
open={playlistOptionsOpen}
|
||||||
|
setOpen={setPlaylistOptionsOpen}
|
||||||
|
playlist={playlist}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"],
|
||||||
@@ -2,6 +2,7 @@ import type {
|
|||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
BaseItemDtoQueryResult,
|
BaseItemDtoQueryResult,
|
||||||
BaseItemKind,
|
BaseItemKind,
|
||||||
|
ItemFilter,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import {
|
import {
|
||||||
getFilterApi,
|
getFilterApi,
|
||||||
@@ -27,7 +28,11 @@ import { useOrientation } from "@/hooks/useOrientation";
|
|||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import {
|
import {
|
||||||
|
FilterByOption,
|
||||||
|
FilterByPreferenceAtom,
|
||||||
|
filterByAtom,
|
||||||
genreFilterAtom,
|
genreFilterAtom,
|
||||||
|
getFilterByPreference,
|
||||||
getSortByPreference,
|
getSortByPreference,
|
||||||
getSortOrderPreference,
|
getSortOrderPreference,
|
||||||
SortByOption,
|
SortByOption,
|
||||||
@@ -39,8 +44,10 @@ import {
|
|||||||
sortOrderOptions,
|
sortOrderOptions,
|
||||||
sortOrderPreferenceAtom,
|
sortOrderPreferenceAtom,
|
||||||
tagsFilterAtom,
|
tagsFilterAtom,
|
||||||
|
useFilterOptions,
|
||||||
yearFilterAtom,
|
yearFilterAtom,
|
||||||
} from "@/utils/atoms/filters";
|
} from "@/utils/atoms/filters";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
const Page = () => {
|
const Page = () => {
|
||||||
const searchParams = useLocalSearchParams();
|
const searchParams = useLocalSearchParams();
|
||||||
@@ -54,9 +61,13 @@ const Page = () => {
|
|||||||
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
||||||
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
|
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
|
||||||
const [sortBy, _setSortBy] = useAtom(sortByAtom);
|
const [sortBy, _setSortBy] = useAtom(sortByAtom);
|
||||||
|
const [filterBy, _setFilterBy] = useAtom(filterByAtom);
|
||||||
const [sortOrder, _setSortOrder] = useAtom(sortOrderAtom);
|
const [sortOrder, _setSortOrder] = useAtom(sortOrderAtom);
|
||||||
const [sortByPreference, setSortByPreference] = useAtom(sortByPreferenceAtom);
|
const [sortByPreference, setSortByPreference] = useAtom(sortByPreferenceAtom);
|
||||||
const [sortOrderPreference, setOderByPreference] = useAtom(
|
const [filterByPreference, setFilterByPreference] = useAtom(
|
||||||
|
FilterByPreferenceAtom,
|
||||||
|
);
|
||||||
|
const [sortOrderPreference, setOrderByPreference] = useAtom(
|
||||||
sortOrderPreferenceAtom,
|
sortOrderPreferenceAtom,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -77,12 +88,20 @@ const Page = () => {
|
|||||||
} else {
|
} else {
|
||||||
_setSortBy([SortByOption.SortName]);
|
_setSortBy([SortByOption.SortName]);
|
||||||
}
|
}
|
||||||
|
const fp = getFilterByPreference(libraryId, filterByPreference);
|
||||||
|
if (fp) {
|
||||||
|
_setFilterBy([fp]);
|
||||||
|
} else {
|
||||||
|
_setFilterBy([]);
|
||||||
|
}
|
||||||
}, [
|
}, [
|
||||||
libraryId,
|
libraryId,
|
||||||
sortOrderPreference,
|
sortOrderPreference,
|
||||||
sortByPreference,
|
sortByPreference,
|
||||||
_setSortOrder,
|
_setSortOrder,
|
||||||
_setSortBy,
|
_setSortBy,
|
||||||
|
filterByPreference,
|
||||||
|
_setFilterBy,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const setSortBy = useCallback(
|
const setSortBy = useCallback(
|
||||||
@@ -100,14 +119,28 @@ const Page = () => {
|
|||||||
(sortOrder: SortOrderOption[]) => {
|
(sortOrder: SortOrderOption[]) => {
|
||||||
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
|
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
|
||||||
if (sortOrder[0] !== sop) {
|
if (sortOrder[0] !== sop) {
|
||||||
setOderByPreference({
|
setOrderByPreference({
|
||||||
...sortOrderPreference,
|
...sortOrderPreference,
|
||||||
[libraryId]: sortOrder[0],
|
[libraryId]: sortOrder[0],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
_setSortOrder(sortOrder);
|
_setSortOrder(sortOrder);
|
||||||
},
|
},
|
||||||
[libraryId, sortOrderPreference, setOderByPreference, _setSortOrder],
|
[libraryId, sortOrderPreference, setOrderByPreference, _setSortOrder],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setFilter = useCallback(
|
||||||
|
(filterBy: FilterByOption[]) => {
|
||||||
|
const fp = getFilterByPreference(libraryId, filterByPreference);
|
||||||
|
if (filterBy[0] !== fp) {
|
||||||
|
setFilterByPreference({
|
||||||
|
...filterByPreference,
|
||||||
|
[libraryId]: filterBy[0],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_setFilterBy(filterBy);
|
||||||
|
},
|
||||||
|
[libraryId, filterByPreference, setFilterByPreference, _setFilterBy],
|
||||||
);
|
);
|
||||||
|
|
||||||
const nrOfCols = useMemo(() => {
|
const nrOfCols = useMemo(() => {
|
||||||
@@ -168,6 +201,7 @@ const Page = () => {
|
|||||||
sortBy: [sortBy[0], "SortName", "ProductionYear"],
|
sortBy: [sortBy[0], "SortName", "ProductionYear"],
|
||||||
sortOrder: [sortOrder[0]],
|
sortOrder: [sortOrder[0]],
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
|
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
|
||||||
|
filters: filterBy as ItemFilter[],
|
||||||
// true is needed for merged versions
|
// true is needed for merged versions
|
||||||
recursive: true,
|
recursive: true,
|
||||||
imageTypeLimit: 1,
|
imageTypeLimit: 1,
|
||||||
@@ -190,6 +224,7 @@ const Page = () => {
|
|||||||
selectedTags,
|
selectedTags,
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
|
filterBy,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -203,6 +238,7 @@ const Page = () => {
|
|||||||
selectedTags,
|
selectedTags,
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
|
filterBy,
|
||||||
],
|
],
|
||||||
queryFn: fetchItems,
|
queryFn: fetchItems,
|
||||||
getNextPageParam: (lastPage, pages) => {
|
getNextPageParam: (lastPage, pages) => {
|
||||||
@@ -268,148 +304,167 @@ const Page = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||||
|
const generalFilters = useFilterOptions();
|
||||||
|
const settings = useSettings();
|
||||||
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())
|
}
|
||||||
}
|
/>
|
||||||
/>
|
),
|
||||||
),
|
},
|
||||||
},
|
{
|
||||||
]}
|
key: "filterOptions",
|
||||||
renderItem={({ item }) => item.component}
|
component: (
|
||||||
keyExtractor={(item) => item.key}
|
<FilterButton
|
||||||
/>
|
className='mr-1'
|
||||||
</View>
|
id={libraryId}
|
||||||
|
queryKey='filters'
|
||||||
|
queryFn={async () => generalFilters.map((s) => s.key)}
|
||||||
|
set={setFilter}
|
||||||
|
values={filterBy}
|
||||||
|
title={t("library.filters.filter_by")}
|
||||||
|
renderItemLabel={(item) =>
|
||||||
|
generalFilters.find((i) => i.key === item)?.value || ""
|
||||||
|
}
|
||||||
|
searchFilter={(item, search) =>
|
||||||
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
renderItem={({ item }) => item.component}
|
||||||
|
keyExtractor={(item) => item.key}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
[
|
[
|
||||||
libraryId,
|
libraryId,
|
||||||
@@ -426,6 +481,9 @@ const Page = () => {
|
|||||||
sortOrder,
|
sortOrder,
|
||||||
setSortOrder,
|
setSortOrder,
|
||||||
isFetching,
|
isFetching,
|
||||||
|
filterBy,
|
||||||
|
setFilter,
|
||||||
|
settings,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -453,7 +511,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";
|
||||||
@@ -39,7 +39,6 @@ export default function index() {
|
|||||||
() =>
|
() =>
|
||||||
data
|
data
|
||||||
?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!))
|
?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!))
|
||||||
.filter((l) => l.CollectionType !== "music")
|
|
||||||
.filter((l) => l.CollectionType !== "books") || [],
|
.filter((l) => l.CollectionType !== "books") || [],
|
||||||
[data, settings?.hiddenLibraries],
|
[data, settings?.hiddenLibraries],
|
||||||
);
|
);
|
||||||
@@ -84,11 +83,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 +104,6 @@ export default function index() {
|
|||||||
<View className='h-4' />
|
<View className='h-4' />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
estimatedItemSize={200}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
87
app/(auth)/(tabs)/(libraries)/music/[libraryId]/_layout.tsx
Normal file
87
app/(auth)/(tabs)/(libraries)/music/[libraryId]/_layout.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import {
|
||||||
|
createMaterialTopTabNavigator,
|
||||||
|
MaterialTopTabNavigationEventMap,
|
||||||
|
MaterialTopTabNavigationOptions,
|
||||||
|
} from "@react-navigation/material-top-tabs";
|
||||||
|
import type {
|
||||||
|
ParamListBase,
|
||||||
|
TabNavigationState,
|
||||||
|
} from "@react-navigation/native";
|
||||||
|
import { Stack, useLocalSearchParams, withLayoutContext } from "expo-router";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
const { Navigator } = createMaterialTopTabNavigator();
|
||||||
|
|
||||||
|
const TAB_LABEL_FONT_SIZE = 13;
|
||||||
|
const TAB_ITEM_HORIZONTAL_PADDING = 18;
|
||||||
|
const TAB_ITEM_MIN_WIDTH = 110;
|
||||||
|
|
||||||
|
export const Tab = withLayoutContext<
|
||||||
|
MaterialTopTabNavigationOptions,
|
||||||
|
typeof Navigator,
|
||||||
|
TabNavigationState<ParamListBase>,
|
||||||
|
MaterialTopTabNavigationEventMap
|
||||||
|
>(Navigator);
|
||||||
|
|
||||||
|
const Layout = () => {
|
||||||
|
const { libraryId } = useLocalSearchParams<{ libraryId: string }>();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen
|
||||||
|
options={{
|
||||||
|
title: t("music.title"),
|
||||||
|
headerStyle: { backgroundColor: "black" },
|
||||||
|
headerShadowVisible: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tab
|
||||||
|
initialRouteName='suggestions'
|
||||||
|
keyboardDismissMode='none'
|
||||||
|
screenOptions={{
|
||||||
|
tabBarBounces: true,
|
||||||
|
tabBarLabelStyle: {
|
||||||
|
fontSize: TAB_LABEL_FONT_SIZE,
|
||||||
|
fontWeight: "600",
|
||||||
|
flexWrap: "nowrap",
|
||||||
|
},
|
||||||
|
tabBarItemStyle: {
|
||||||
|
width: "auto",
|
||||||
|
minWidth: TAB_ITEM_MIN_WIDTH,
|
||||||
|
paddingHorizontal: TAB_ITEM_HORIZONTAL_PADDING,
|
||||||
|
},
|
||||||
|
tabBarStyle: { backgroundColor: "black" },
|
||||||
|
animationEnabled: true,
|
||||||
|
lazy: true,
|
||||||
|
swipeEnabled: true,
|
||||||
|
tabBarIndicatorStyle: { backgroundColor: "#9334E9" },
|
||||||
|
tabBarScrollEnabled: true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tab.Screen
|
||||||
|
name='suggestions'
|
||||||
|
initialParams={{ libraryId }}
|
||||||
|
options={{ title: t("music.tabs.suggestions") }}
|
||||||
|
/>
|
||||||
|
<Tab.Screen
|
||||||
|
name='albums'
|
||||||
|
initialParams={{ libraryId }}
|
||||||
|
options={{ title: t("music.tabs.albums") }}
|
||||||
|
/>
|
||||||
|
<Tab.Screen
|
||||||
|
name='artists'
|
||||||
|
initialParams={{ libraryId }}
|
||||||
|
options={{ title: t("music.tabs.artists") }}
|
||||||
|
/>
|
||||||
|
<Tab.Screen
|
||||||
|
name='playlists'
|
||||||
|
initialParams={{ libraryId }}
|
||||||
|
options={{ title: t("music.tabs.playlists") }}
|
||||||
|
/>
|
||||||
|
</Tab>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Layout;
|
||||||
138
app/(auth)/(tabs)/(libraries)/music/[libraryId]/albums.tsx
Normal file
138
app/(auth)/(tabs)/(libraries)/music/[libraryId]/albums.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useRoute } from "@react-navigation/native";
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useCallback, useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Dimensions, RefreshControl, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { MusicAlbumCard } from "@/components/music/MusicAlbumCard";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
|
const ITEMS_PER_PAGE = 40;
|
||||||
|
|
||||||
|
export default function AlbumsScreen() {
|
||||||
|
const localParams = useLocalSearchParams<{ libraryId?: string | string[] }>();
|
||||||
|
const route = useRoute<any>();
|
||||||
|
const libraryId =
|
||||||
|
(Array.isArray(localParams.libraryId)
|
||||||
|
? localParams.libraryId[0]
|
||||||
|
: localParams.libraryId) ?? route?.params?.libraryId;
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
refetch,
|
||||||
|
} = useInfiniteQuery({
|
||||||
|
queryKey: ["music-albums", libraryId, user?.Id],
|
||||||
|
queryFn: async ({ pageParam = 0 }) => {
|
||||||
|
const response = await getItemsApi(api!).getItems({
|
||||||
|
userId: user?.Id,
|
||||||
|
parentId: libraryId,
|
||||||
|
includeItemTypes: ["MusicAlbum"],
|
||||||
|
sortBy: ["SortName"],
|
||||||
|
sortOrder: ["Ascending"],
|
||||||
|
limit: ITEMS_PER_PAGE,
|
||||||
|
startIndex: pageParam,
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
items: response.data.Items || [],
|
||||||
|
totalCount: response.data.TotalRecordCount || 0,
|
||||||
|
startIndex: pageParam,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getNextPageParam: (lastPage) => {
|
||||||
|
const nextStart = lastPage.startIndex + ITEMS_PER_PAGE;
|
||||||
|
return nextStart < lastPage.totalCount ? nextStart : undefined;
|
||||||
|
},
|
||||||
|
initialPageParam: 0,
|
||||||
|
enabled: !!api && !!user?.Id && !!libraryId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const albums = useMemo(() => {
|
||||||
|
return data?.pages.flatMap((page) => page.items) || [];
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const numColumns = 2;
|
||||||
|
const screenWidth = Dimensions.get("window").width;
|
||||||
|
const gap = 12;
|
||||||
|
const padding = 16;
|
||||||
|
const itemWidth =
|
||||||
|
(screenWidth - padding * 2 - gap * (numColumns - 1)) / numColumns;
|
||||||
|
|
||||||
|
const handleEndReached = useCallback(() => {
|
||||||
|
if (hasNextPage && !isFetchingNextPage) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black'>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (albums.length === 0) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black'>
|
||||||
|
<Text className='text-neutral-500'>{t("music.no_albums")}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='flex-1 bg-black'>
|
||||||
|
<FlashList
|
||||||
|
data={albums}
|
||||||
|
numColumns={numColumns}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingBottom: insets.bottom + 100,
|
||||||
|
paddingTop: 16,
|
||||||
|
paddingHorizontal: padding,
|
||||||
|
}}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={false}
|
||||||
|
onRefresh={refetch}
|
||||||
|
tintColor='#9334E9'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onEndReached={handleEndReached}
|
||||||
|
onEndReachedThreshold={0.5}
|
||||||
|
renderItem={({ item, index }) => (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: itemWidth,
|
||||||
|
marginRight: index % numColumns === 0 ? gap : 0,
|
||||||
|
marginBottom: gap,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MusicAlbumCard album={item} width={itemWidth} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
keyExtractor={(item) => item.Id!}
|
||||||
|
ListFooterComponent={
|
||||||
|
isFetchingNextPage ? (
|
||||||
|
<View className='py-4'>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
175
app/(auth)/(tabs)/(libraries)/music/[libraryId]/artists.tsx
Normal file
175
app/(auth)/(tabs)/(libraries)/music/[libraryId]/artists.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import { getArtistsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useRoute } from "@react-navigation/native";
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useCallback, useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Dimensions, RefreshControl, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { MusicArtistCard } from "@/components/music/MusicArtistCard";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
|
// Web uses Limit=100
|
||||||
|
const ITEMS_PER_PAGE = 100;
|
||||||
|
|
||||||
|
export default function ArtistsScreen() {
|
||||||
|
const localParams = useLocalSearchParams<{ libraryId?: string | string[] }>();
|
||||||
|
const route = useRoute<any>();
|
||||||
|
const libraryId =
|
||||||
|
(Array.isArray(localParams.libraryId)
|
||||||
|
? localParams.libraryId[0]
|
||||||
|
: localParams.libraryId) ?? route?.params?.libraryId;
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const isReady = Boolean(api && user?.Id && libraryId);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
refetch,
|
||||||
|
} = useInfiniteQuery({
|
||||||
|
queryKey: ["music-artists", libraryId, user?.Id],
|
||||||
|
queryFn: async ({ pageParam = 0 }) => {
|
||||||
|
const response = await getArtistsApi(api!).getArtists({
|
||||||
|
userId: user?.Id,
|
||||||
|
parentId: libraryId,
|
||||||
|
sortBy: ["SortName"],
|
||||||
|
sortOrder: ["Ascending"],
|
||||||
|
fields: ["PrimaryImageAspectRatio", "SortName"],
|
||||||
|
imageTypeLimit: 1,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
|
||||||
|
limit: ITEMS_PER_PAGE,
|
||||||
|
startIndex: pageParam,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
items: response.data.Items || [],
|
||||||
|
totalCount: response.data.TotalRecordCount || 0,
|
||||||
|
startIndex: pageParam,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getNextPageParam: (lastPage) => {
|
||||||
|
const nextStart = lastPage.startIndex + ITEMS_PER_PAGE;
|
||||||
|
return nextStart < lastPage.totalCount ? nextStart : undefined;
|
||||||
|
},
|
||||||
|
initialPageParam: 0,
|
||||||
|
enabled: isReady,
|
||||||
|
});
|
||||||
|
|
||||||
|
const artists = useMemo(() => {
|
||||||
|
return data?.pages.flatMap((page) => page.items) || [];
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const numColumns = 3;
|
||||||
|
const screenWidth = Dimensions.get("window").width;
|
||||||
|
const gap = 12;
|
||||||
|
const padding = 16;
|
||||||
|
const itemWidth =
|
||||||
|
(screenWidth - padding * 2 - gap * (numColumns - 1)) / numColumns;
|
||||||
|
|
||||||
|
const handleEndReached = useCallback(() => {
|
||||||
|
if (hasNextPage && !isFetchingNextPage) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||||
|
|
||||||
|
if (!api || !user?.Id) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black'>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!libraryId) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||||
|
<Text className='text-neutral-500 text-center'>
|
||||||
|
Missing music library id.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only show loading if we have no cached data to display
|
||||||
|
if (isLoading && artists.length === 0) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black'>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only show error if we have no cached data to display
|
||||||
|
// This allows offline access to previously cached artists
|
||||||
|
if (isError && artists.length === 0) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||||
|
<Text className='text-neutral-500 text-center'>
|
||||||
|
Failed to load artists: {(error as Error)?.message || "Unknown error"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (artists.length === 0) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black'>
|
||||||
|
<Text className='text-neutral-500'>{t("music.no_artists")}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='flex-1 bg-black'>
|
||||||
|
<FlashList
|
||||||
|
data={artists}
|
||||||
|
numColumns={numColumns}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingBottom: insets.bottom + 100,
|
||||||
|
paddingTop: 16,
|
||||||
|
paddingHorizontal: padding,
|
||||||
|
}}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={false}
|
||||||
|
onRefresh={refetch}
|
||||||
|
tintColor='#9334E9'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onEndReached={handleEndReached}
|
||||||
|
onEndReachedThreshold={0.5}
|
||||||
|
renderItem={({ item, index }) => (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: itemWidth,
|
||||||
|
marginRight: index % numColumns !== numColumns - 1 ? gap : 0,
|
||||||
|
marginBottom: gap,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MusicArtistCard artist={item} size={itemWidth} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
keyExtractor={(item) => item.Id!}
|
||||||
|
ListFooterComponent={
|
||||||
|
isFetchingNextPage ? (
|
||||||
|
<View className='py-4'>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
215
app/(auth)/(tabs)/(libraries)/music/[libraryId]/playlists.tsx
Normal file
215
app/(auth)/(tabs)/(libraries)/music/[libraryId]/playlists.tsx
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useNavigation, useRoute } from "@react-navigation/native";
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useCallback, useLayoutEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Dimensions,
|
||||||
|
RefreshControl,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
|
||||||
|
import { MusicPlaylistCard } from "@/components/music/MusicPlaylistCard";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
|
const ITEMS_PER_PAGE = 40;
|
||||||
|
|
||||||
|
export default function PlaylistsScreen() {
|
||||||
|
const localParams = useLocalSearchParams<{ libraryId?: string | string[] }>();
|
||||||
|
const route = useRoute<any>();
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const libraryId =
|
||||||
|
(Array.isArray(localParams.libraryId)
|
||||||
|
? localParams.libraryId[0]
|
||||||
|
: localParams.libraryId) ?? route?.params?.libraryId;
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const isReady = Boolean(api && user?.Id && libraryId);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
navigation.setOptions({
|
||||||
|
headerRight: () => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setCreateModalOpen(true)}
|
||||||
|
className='mr-4'
|
||||||
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||||
|
>
|
||||||
|
<Ionicons name='add' size={28} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}, [navigation]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
refetch,
|
||||||
|
} = useInfiniteQuery({
|
||||||
|
queryKey: ["music-playlists", libraryId, user?.Id],
|
||||||
|
queryFn: async ({ pageParam = 0 }) => {
|
||||||
|
const response = await getItemsApi(api!).getItems({
|
||||||
|
userId: user?.Id,
|
||||||
|
includeItemTypes: ["Playlist"],
|
||||||
|
sortBy: ["SortName"],
|
||||||
|
sortOrder: ["Ascending"],
|
||||||
|
limit: ITEMS_PER_PAGE,
|
||||||
|
startIndex: pageParam,
|
||||||
|
recursive: true,
|
||||||
|
mediaTypes: ["Audio"],
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
items: response.data.Items || [],
|
||||||
|
totalCount: response.data.TotalRecordCount || 0,
|
||||||
|
startIndex: pageParam,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getNextPageParam: (lastPage) => {
|
||||||
|
const nextStart = lastPage.startIndex + ITEMS_PER_PAGE;
|
||||||
|
return nextStart < lastPage.totalCount ? nextStart : undefined;
|
||||||
|
},
|
||||||
|
initialPageParam: 0,
|
||||||
|
enabled: isReady,
|
||||||
|
});
|
||||||
|
|
||||||
|
const playlists = useMemo(() => {
|
||||||
|
return data?.pages.flatMap((page) => page.items) || [];
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const numColumns = 2;
|
||||||
|
const screenWidth = Dimensions.get("window").width;
|
||||||
|
const gap = 12;
|
||||||
|
const padding = 16;
|
||||||
|
const itemWidth =
|
||||||
|
(screenWidth - padding * 2 - gap * (numColumns - 1)) / numColumns;
|
||||||
|
|
||||||
|
const handleEndReached = useCallback(() => {
|
||||||
|
if (hasNextPage && !isFetchingNextPage) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||||
|
|
||||||
|
if (!api || !user?.Id) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black'>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!libraryId) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||||
|
<Text className='text-neutral-500 text-center'>
|
||||||
|
Missing music library id.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only show loading if we have no cached data to display
|
||||||
|
if (isLoading && playlists.length === 0) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black'>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only show error if we have no cached data to display
|
||||||
|
// This allows offline access to previously cached playlists
|
||||||
|
if (isError && playlists.length === 0) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||||
|
<Text className='text-neutral-500 text-center'>
|
||||||
|
Failed to load playlists:{" "}
|
||||||
|
{(error as Error)?.message || "Unknown error"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playlists.length === 0) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black'>
|
||||||
|
<Text className='text-neutral-500 mb-4'>{t("music.no_playlists")}</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setCreateModalOpen(true)}
|
||||||
|
className='flex-row items-center bg-purple-600 px-6 py-3 rounded-full'
|
||||||
|
>
|
||||||
|
<Ionicons name='add' size={20} color='white' />
|
||||||
|
<Text className='text-white font-semibold ml-2'>
|
||||||
|
{t("music.playlists.create_playlist")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<CreatePlaylistModal
|
||||||
|
open={createModalOpen}
|
||||||
|
setOpen={setCreateModalOpen}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='flex-1 bg-black'>
|
||||||
|
<FlashList
|
||||||
|
data={playlists}
|
||||||
|
numColumns={numColumns}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingBottom: insets.bottom + 100,
|
||||||
|
paddingTop: 16,
|
||||||
|
paddingHorizontal: padding,
|
||||||
|
}}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={false}
|
||||||
|
onRefresh={refetch}
|
||||||
|
tintColor='#9334E9'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onEndReached={handleEndReached}
|
||||||
|
onEndReachedThreshold={0.5}
|
||||||
|
renderItem={({ item, index }) => (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: itemWidth,
|
||||||
|
marginRight: index % numColumns === 0 ? gap : 0,
|
||||||
|
marginBottom: gap,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MusicPlaylistCard playlist={item} width={itemWidth} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
keyExtractor={(item) => item.Id!}
|
||||||
|
ListFooterComponent={
|
||||||
|
isFetchingNextPage ? (
|
||||||
|
<View className='py-4'>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<CreatePlaylistModal
|
||||||
|
open={createModalOpen}
|
||||||
|
setOpen={setCreateModalOpen}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
333
app/(auth)/(tabs)/(libraries)/music/[libraryId]/suggestions.tsx
Normal file
333
app/(auth)/(tabs)/(libraries)/music/[libraryId]/suggestions.tsx
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useRoute } from "@react-navigation/native";
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { RefreshControl, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { HorizontalScroll } from "@/components/common/HorizontalScroll";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
|
||||||
|
import { MusicAlbumCard } from "@/components/music/MusicAlbumCard";
|
||||||
|
import { MusicTrackItem } from "@/components/music/MusicTrackItem";
|
||||||
|
import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet";
|
||||||
|
import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { writeDebugLog } from "@/utils/log";
|
||||||
|
|
||||||
|
export default function SuggestionsScreen() {
|
||||||
|
const localParams = useLocalSearchParams<{ libraryId?: string | string[] }>();
|
||||||
|
const route = useRoute<any>();
|
||||||
|
const libraryId =
|
||||||
|
(Array.isArray(localParams.libraryId)
|
||||||
|
? localParams.libraryId[0]
|
||||||
|
: localParams.libraryId) ?? route?.params?.libraryId;
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [selectedTrack, setSelectedTrack] = useState<BaseItemDto | null>(null);
|
||||||
|
const [trackOptionsOpen, setTrackOptionsOpen] = useState(false);
|
||||||
|
const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false);
|
||||||
|
const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleTrackOptionsPress = useCallback((track: BaseItemDto) => {
|
||||||
|
setSelectedTrack(track);
|
||||||
|
setTrackOptionsOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAddToPlaylist = useCallback(() => {
|
||||||
|
setPlaylistPickerOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCreateNewPlaylist = useCallback(() => {
|
||||||
|
setCreatePlaylistOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isReady = Boolean(api && user?.Id && libraryId);
|
||||||
|
|
||||||
|
writeDebugLog("Music suggestions params", {
|
||||||
|
libraryId,
|
||||||
|
localParams,
|
||||||
|
routeParams: route?.params,
|
||||||
|
isReady,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Latest audio - uses the same endpoint as web: /Users/{userId}/Items/Latest
|
||||||
|
// This returns the most recently added albums
|
||||||
|
const {
|
||||||
|
data: latestAlbums,
|
||||||
|
isLoading: loadingLatest,
|
||||||
|
isError: isLatestError,
|
||||||
|
error: latestError,
|
||||||
|
refetch: refetchLatest,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["music-latest", libraryId, user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
// Prefer the exact endpoint the Web client calls (HAR):
|
||||||
|
// /Users/{userId}/Items/Latest?IncludeItemTypes=Audio&ParentId=...
|
||||||
|
// IMPORTANT: must use api.get(...) (not axiosInstance.get(fullUrl)) so the auth header is attached.
|
||||||
|
const res = await api!.get<BaseItemDto[]>(
|
||||||
|
`/Users/${user!.Id}/Items/Latest`,
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
IncludeItemTypes: "Audio",
|
||||||
|
Limit: 20,
|
||||||
|
Fields: "PrimaryImageAspectRatio",
|
||||||
|
ParentId: libraryId,
|
||||||
|
ImageTypeLimit: 1,
|
||||||
|
EnableImageTypes: "Primary,Backdrop,Banner,Thumb",
|
||||||
|
EnableTotalRecordCount: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (Array.isArray(res.data) && res.data.length > 0) {
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: ask for albums directly via /Items (more reliable across server variants)
|
||||||
|
const fallback = await getItemsApi(api!).getItems({
|
||||||
|
userId: user!.Id,
|
||||||
|
parentId: libraryId,
|
||||||
|
includeItemTypes: ["MusicAlbum"],
|
||||||
|
sortBy: ["DateCreated"],
|
||||||
|
sortOrder: ["Descending"],
|
||||||
|
limit: 20,
|
||||||
|
recursive: true,
|
||||||
|
fields: ["PrimaryImageAspectRatio", "SortName"],
|
||||||
|
imageTypeLimit: 1,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
|
||||||
|
enableTotalRecordCount: false,
|
||||||
|
});
|
||||||
|
return fallback.data.Items || [];
|
||||||
|
},
|
||||||
|
enabled: isReady,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Recently played - matches web: SortBy=DatePlayed, Filters=IsPlayed
|
||||||
|
const {
|
||||||
|
data: recentlyPlayed,
|
||||||
|
isLoading: loadingRecentlyPlayed,
|
||||||
|
isError: isRecentlyPlayedError,
|
||||||
|
error: recentlyPlayedError,
|
||||||
|
refetch: refetchRecentlyPlayed,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["music-recently-played", libraryId, user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await getItemsApi(api!).getItems({
|
||||||
|
userId: user?.Id,
|
||||||
|
parentId: libraryId,
|
||||||
|
includeItemTypes: ["Audio"],
|
||||||
|
sortBy: ["DatePlayed"],
|
||||||
|
sortOrder: ["Descending"],
|
||||||
|
limit: 10,
|
||||||
|
recursive: true,
|
||||||
|
fields: ["PrimaryImageAspectRatio", "SortName"],
|
||||||
|
filters: ["IsPlayed"],
|
||||||
|
imageTypeLimit: 1,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
|
||||||
|
enableTotalRecordCount: false,
|
||||||
|
});
|
||||||
|
return response.data.Items || [];
|
||||||
|
},
|
||||||
|
enabled: isReady,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Frequently played - matches web: SortBy=PlayCount, Filters=IsPlayed
|
||||||
|
const {
|
||||||
|
data: frequentlyPlayed,
|
||||||
|
isLoading: loadingFrequent,
|
||||||
|
isError: isFrequentError,
|
||||||
|
error: frequentError,
|
||||||
|
refetch: refetchFrequent,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["music-frequently-played", libraryId, user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await getItemsApi(api!).getItems({
|
||||||
|
userId: user?.Id,
|
||||||
|
parentId: libraryId,
|
||||||
|
includeItemTypes: ["Audio"],
|
||||||
|
sortBy: ["PlayCount"],
|
||||||
|
sortOrder: ["Descending"],
|
||||||
|
limit: 10,
|
||||||
|
recursive: true,
|
||||||
|
fields: ["PrimaryImageAspectRatio", "SortName"],
|
||||||
|
filters: ["IsPlayed"],
|
||||||
|
imageTypeLimit: 1,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
|
||||||
|
enableTotalRecordCount: false,
|
||||||
|
});
|
||||||
|
return response.data.Items || [];
|
||||||
|
},
|
||||||
|
enabled: isReady,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isLoading = loadingLatest || loadingRecentlyPlayed || loadingFrequent;
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(() => {
|
||||||
|
refetchLatest();
|
||||||
|
refetchRecentlyPlayed();
|
||||||
|
refetchFrequent();
|
||||||
|
}, [refetchLatest, refetchRecentlyPlayed, refetchFrequent]);
|
||||||
|
|
||||||
|
const sections = useMemo(() => {
|
||||||
|
const result: {
|
||||||
|
title: string;
|
||||||
|
data: BaseItemDto[];
|
||||||
|
type: "albums" | "tracks";
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
// Latest albums section
|
||||||
|
if (latestAlbums && latestAlbums.length > 0) {
|
||||||
|
result.push({
|
||||||
|
title: t("music.recently_added"),
|
||||||
|
data: latestAlbums,
|
||||||
|
type: "albums",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recently played tracks
|
||||||
|
if (recentlyPlayed && recentlyPlayed.length > 0) {
|
||||||
|
result.push({
|
||||||
|
title: t("music.recently_played"),
|
||||||
|
data: recentlyPlayed,
|
||||||
|
type: "tracks",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frequently played tracks
|
||||||
|
if (frequentlyPlayed && frequentlyPlayed.length > 0) {
|
||||||
|
result.push({
|
||||||
|
title: t("music.frequently_played"),
|
||||||
|
data: frequentlyPlayed,
|
||||||
|
type: "tracks",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [latestAlbums, frequentlyPlayed, recentlyPlayed, t]);
|
||||||
|
|
||||||
|
if (!api || !user?.Id) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black'>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!libraryId) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||||
|
<Text className='text-neutral-500 text-center'>
|
||||||
|
Missing music library id.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only show loading if we have no cached data to display
|
||||||
|
if (isLoading && sections.length === 0) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black'>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only show error if we have no cached data to display
|
||||||
|
// This allows offline access to previously cached suggestions
|
||||||
|
if (
|
||||||
|
(isLatestError || isRecentlyPlayedError || isFrequentError) &&
|
||||||
|
sections.length === 0
|
||||||
|
) {
|
||||||
|
const msg =
|
||||||
|
(latestError as Error | undefined)?.message ||
|
||||||
|
(recentlyPlayedError as Error | undefined)?.message ||
|
||||||
|
(frequentError as Error | undefined)?.message ||
|
||||||
|
"Unknown error";
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||||
|
<Text className='text-neutral-500 text-center'>
|
||||||
|
Failed to load music: {msg}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sections.length === 0) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black'>
|
||||||
|
<Text className='text-neutral-500'>{t("music.no_suggestions")}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='flex-1 bg-black'>
|
||||||
|
<FlashList
|
||||||
|
data={sections}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingBottom: insets.bottom + 100,
|
||||||
|
paddingTop: 16,
|
||||||
|
}}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={false}
|
||||||
|
onRefresh={handleRefresh}
|
||||||
|
tintColor='#9334E9'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
renderItem={({ item: section }) => (
|
||||||
|
<View className='mb-6'>
|
||||||
|
<Text className='text-lg font-bold px-4 mb-3'>{section.title}</Text>
|
||||||
|
{section.type === "albums" ? (
|
||||||
|
<HorizontalScroll
|
||||||
|
data={section.data}
|
||||||
|
height={200}
|
||||||
|
keyExtractor={(item) => item.Id!}
|
||||||
|
renderItem={(item) => <MusicAlbumCard album={item} />}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
section.data
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((track, index, _tracks) => (
|
||||||
|
<MusicTrackItem
|
||||||
|
key={track.Id}
|
||||||
|
track={track}
|
||||||
|
index={index + 1}
|
||||||
|
queue={section.data}
|
||||||
|
onOptionsPress={handleTrackOptionsPress}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
keyExtractor={(item) => item.title}
|
||||||
|
/>
|
||||||
|
<TrackOptionsSheet
|
||||||
|
open={trackOptionsOpen}
|
||||||
|
setOpen={setTrackOptionsOpen}
|
||||||
|
track={selectedTrack}
|
||||||
|
onAddToPlaylist={handleAddToPlaylist}
|
||||||
|
/>
|
||||||
|
<PlaylistPickerSheet
|
||||||
|
open={playlistPickerOpen}
|
||||||
|
setOpen={setPlaylistPickerOpen}
|
||||||
|
trackToAdd={selectedTrack}
|
||||||
|
onCreateNew={handleCreateNewPlaylist}
|
||||||
|
/>
|
||||||
|
<CreatePlaylistModal
|
||||||
|
open={createPlaylistOpen}
|
||||||
|
setOpen={setCreatePlaylistOpen}
|
||||||
|
initialTrackId={selectedTrack?.Id}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,12 +31,15 @@ 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";
|
||||||
import { eventBus } from "@/utils/eventBus";
|
import { eventBus } from "@/utils/eventBus";
|
||||||
|
import { createStreamystatsApi } from "@/utils/streamystats";
|
||||||
|
|
||||||
type SearchType = "Library" | "Discover";
|
type SearchType = "Library" | "Discover";
|
||||||
|
|
||||||
@@ -117,6 +118,54 @@ export default function search() {
|
|||||||
|
|
||||||
return (searchApi.data.Items as BaseItemDto[]) || [];
|
return (searchApi.data.Items as BaseItemDto[]) || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (searchEngine === "Streamystats") {
|
||||||
|
if (!settings?.streamyStatsServerUrl || !api.accessToken) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const streamyStatsApi = createStreamystatsApi({
|
||||||
|
serverUrl: settings.streamyStatsServerUrl,
|
||||||
|
jellyfinToken: api.accessToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
const typeMap: Record<BaseItemKind, string> = {
|
||||||
|
Movie: "movies",
|
||||||
|
Series: "series",
|
||||||
|
Episode: "episodes",
|
||||||
|
Person: "actors",
|
||||||
|
BoxSet: "movies",
|
||||||
|
Audio: "audio",
|
||||||
|
} as Record<BaseItemKind, string>;
|
||||||
|
|
||||||
|
const searchType = types.length === 1 ? typeMap[types[0]] : "media";
|
||||||
|
const response = await streamyStatsApi.searchIds(
|
||||||
|
query,
|
||||||
|
searchType as "movies" | "series" | "episodes" | "actors" | "media",
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
|
||||||
|
const allIds: string[] = [
|
||||||
|
...(response.data.movies || []),
|
||||||
|
...(response.data.series || []),
|
||||||
|
...(response.data.episodes || []),
|
||||||
|
...(response.data.actors || []),
|
||||||
|
...(response.data.audio || []),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!allIds.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemsResponse = await getItemsApi(api).getItems({
|
||||||
|
ids: allIds,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
|
});
|
||||||
|
|
||||||
|
return (itemsResponse.data.Items as BaseItemDto[]) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marlin search
|
||||||
if (!settings?.marlinServerUrl) {
|
if (!settings?.marlinServerUrl) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -141,12 +190,11 @@ export default function search() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (response2.data.Items as BaseItemDto[]) || [];
|
return (response2.data.Items as BaseItemDto[]) || [];
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
console.error("Error during search:", error);
|
return [];
|
||||||
return []; // Ensure an empty array is returned in case of an error
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[api, searchEngine, settings],
|
[api, searchEngine, settings, user?.Id],
|
||||||
);
|
);
|
||||||
|
|
||||||
type HeaderSearchBarRef = {
|
type HeaderSearchBarRef = {
|
||||||
@@ -284,67 +332,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'>
|
||||||
|
|||||||
297
app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx
Normal file
297
app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
import { useLocalSearchParams, useNavigation, useRouter } from "expo-router";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
RefreshControl,
|
||||||
|
TouchableOpacity,
|
||||||
|
useWindowDimensions,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
|
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||||
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
|
import {
|
||||||
|
useDeleteWatchlist,
|
||||||
|
useRemoveFromWatchlist,
|
||||||
|
} from "@/hooks/useWatchlistMutations";
|
||||||
|
import {
|
||||||
|
useWatchlistDetailQuery,
|
||||||
|
useWatchlistItemsQuery,
|
||||||
|
} from "@/hooks/useWatchlists";
|
||||||
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
|
import { userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
|
export default function WatchlistDetailScreen() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { watchlistId } = useLocalSearchParams<{ watchlistId: string }>();
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
|
const { width: screenWidth } = useWindowDimensions();
|
||||||
|
const { orientation } = useOrientation();
|
||||||
|
|
||||||
|
const watchlistIdNum = watchlistId
|
||||||
|
? Number.parseInt(watchlistId, 10)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const nrOfCols = useMemo(() => {
|
||||||
|
if (screenWidth < 300) return 2;
|
||||||
|
if (screenWidth < 500) return 3;
|
||||||
|
if (screenWidth < 800) return 5;
|
||||||
|
if (screenWidth < 1000) return 6;
|
||||||
|
if (screenWidth < 1500) return 7;
|
||||||
|
return 6;
|
||||||
|
}, [screenWidth]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: watchlist,
|
||||||
|
isLoading: watchlistLoading,
|
||||||
|
refetch: refetchWatchlist,
|
||||||
|
} = useWatchlistDetailQuery(watchlistIdNum);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: items,
|
||||||
|
isLoading: itemsLoading,
|
||||||
|
refetch: refetchItems,
|
||||||
|
} = useWatchlistItemsQuery(watchlistIdNum);
|
||||||
|
|
||||||
|
const deleteWatchlist = useDeleteWatchlist();
|
||||||
|
const removeFromWatchlist = useRemoveFromWatchlist();
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
|
const isOwner = useMemo(
|
||||||
|
() => watchlist?.userId === user?.Id,
|
||||||
|
[watchlist?.userId, user?.Id],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set up header
|
||||||
|
useEffect(() => {
|
||||||
|
navigation.setOptions({
|
||||||
|
headerTitle: watchlist?.name || "",
|
||||||
|
headerLeft: () => <HeaderBackButton />,
|
||||||
|
headerRight: isOwner
|
||||||
|
? () => (
|
||||||
|
<View className='flex-row gap-2'>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() =>
|
||||||
|
router.push(`/(auth)/(tabs)/(watchlists)/edit/${watchlistId}`)
|
||||||
|
}
|
||||||
|
className='p-2'
|
||||||
|
>
|
||||||
|
<Ionicons name='pencil' size={20} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity onPress={handleDelete} className='p-2'>
|
||||||
|
<Ionicons name='trash-outline' size={20} color='#ef4444' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
}, [navigation, watchlist?.name, isOwner, watchlistId]);
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(async () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
await Promise.all([refetchWatchlist(), refetchItems()]);
|
||||||
|
setRefreshing(false);
|
||||||
|
}, [refetchWatchlist, refetchItems]);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(() => {
|
||||||
|
Alert.alert(
|
||||||
|
t("watchlists.delete_confirm_title"),
|
||||||
|
t("watchlists.delete_confirm_message", { name: watchlist?.name }),
|
||||||
|
[
|
||||||
|
{ text: t("watchlists.cancel_button"), style: "cancel" },
|
||||||
|
{
|
||||||
|
text: t("watchlists.delete_button"),
|
||||||
|
style: "destructive",
|
||||||
|
onPress: async () => {
|
||||||
|
if (watchlistIdNum) {
|
||||||
|
await deleteWatchlist.mutateAsync(watchlistIdNum);
|
||||||
|
router.back();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}, [deleteWatchlist, watchlistIdNum, watchlist?.name, router, t]);
|
||||||
|
|
||||||
|
const handleRemoveItem = useCallback(
|
||||||
|
(item: BaseItemDto) => {
|
||||||
|
if (!watchlistIdNum || !item.Id) return;
|
||||||
|
|
||||||
|
Alert.alert(
|
||||||
|
t("watchlists.remove_item_title"),
|
||||||
|
t("watchlists.remove_item_message", { name: item.Name }),
|
||||||
|
[
|
||||||
|
{ text: t("watchlists.cancel_button"), style: "cancel" },
|
||||||
|
{
|
||||||
|
text: t("watchlists.remove_button"),
|
||||||
|
style: "destructive",
|
||||||
|
onPress: async () => {
|
||||||
|
await removeFromWatchlist.mutateAsync({
|
||||||
|
watchlistId: watchlistIdNum,
|
||||||
|
itemId: item.Id!,
|
||||||
|
watchlistName: watchlist?.name,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[removeFromWatchlist, watchlistIdNum, watchlist?.name, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderItem = useCallback(
|
||||||
|
({ item, index }: { item: BaseItemDto; index: number }) => (
|
||||||
|
<TouchableItemRouter
|
||||||
|
key={item.Id}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
|
item={item}
|
||||||
|
onLongPress={isOwner ? () => handleRemoveItem(item) : undefined}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
alignSelf:
|
||||||
|
orientation === ScreenOrientation.OrientationLock.PORTRAIT_UP
|
||||||
|
? index % nrOfCols === 0
|
||||||
|
? "flex-end"
|
||||||
|
: (index + 1) % nrOfCols === 0
|
||||||
|
? "flex-start"
|
||||||
|
: "center"
|
||||||
|
: "center",
|
||||||
|
width: "89%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ItemPoster item={item} />
|
||||||
|
<ItemCardText item={item} />
|
||||||
|
</View>
|
||||||
|
</TouchableItemRouter>
|
||||||
|
),
|
||||||
|
[isOwner, handleRemoveItem, orientation, nrOfCols],
|
||||||
|
);
|
||||||
|
|
||||||
|
const ListHeader = useMemo(
|
||||||
|
() =>
|
||||||
|
watchlist ? (
|
||||||
|
<View className='px-4 pt-4 pb-6 mb-4 border-b border-neutral-800'>
|
||||||
|
{watchlist.description && (
|
||||||
|
<Text className='text-neutral-400 mb-2'>
|
||||||
|
{watchlist.description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<View className='flex-row items-center gap-4'>
|
||||||
|
<View className='flex-row items-center gap-1'>
|
||||||
|
<Ionicons name='film-outline' size={14} color='#9ca3af' />
|
||||||
|
<Text className='text-neutral-400 text-sm'>
|
||||||
|
{items?.length ?? 0}{" "}
|
||||||
|
{(items?.length ?? 0) === 1
|
||||||
|
? t("watchlists.item")
|
||||||
|
: t("watchlists.items")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className='flex-row items-center gap-1'>
|
||||||
|
<Ionicons
|
||||||
|
name={
|
||||||
|
watchlist.isPublic ? "globe-outline" : "lock-closed-outline"
|
||||||
|
}
|
||||||
|
size={14}
|
||||||
|
color='#9ca3af'
|
||||||
|
/>
|
||||||
|
<Text className='text-neutral-400 text-sm'>
|
||||||
|
{watchlist.isPublic
|
||||||
|
? t("watchlists.public")
|
||||||
|
: t("watchlists.private")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{!isOwner && (
|
||||||
|
<Text className='text-neutral-500 text-sm'>
|
||||||
|
{t("watchlists.by_owner")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : null,
|
||||||
|
[watchlist, items?.length, isOwner, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const EmptyComponent = useMemo(
|
||||||
|
() => (
|
||||||
|
<View className='flex-1 items-center justify-center px-8 py-16'>
|
||||||
|
<Ionicons name='film-outline' size={48} color='#4b5563' />
|
||||||
|
<Text className='text-neutral-400 text-center mt-4'>
|
||||||
|
{t("watchlists.empty_watchlist")}
|
||||||
|
</Text>
|
||||||
|
{isOwner && (
|
||||||
|
<Text className='text-neutral-500 text-center mt-2 text-sm'>
|
||||||
|
{t("watchlists.empty_watchlist_hint")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
[isOwner, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||||
|
|
||||||
|
if (watchlistLoading || itemsLoading) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 items-center justify-center'>
|
||||||
|
<ActivityIndicator size='large' />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!watchlist) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 items-center justify-center px-8'>
|
||||||
|
<Text className='text-lg text-neutral-400'>
|
||||||
|
{t("watchlists.not_found")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlashList
|
||||||
|
key={orientation}
|
||||||
|
data={items ?? []}
|
||||||
|
numColumns={nrOfCols}
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
|
ListHeaderComponent={ListHeader}
|
||||||
|
ListEmptyComponent={EmptyComponent}
|
||||||
|
extraData={[orientation, nrOfCols]}
|
||||||
|
keyExtractor={keyExtractor}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingBottom: 24,
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
|
||||||
|
}
|
||||||
|
renderItem={renderItem}
|
||||||
|
ItemSeparatorComponent={() => (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
app/(auth)/(tabs)/(watchlists)/_layout.tsx
Normal file
74
app/(auth)/(tabs)/(watchlists)/_layout.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Stack, useRouter } from "expo-router";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform, TouchableOpacity } from "react-native";
|
||||||
|
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||||
|
import { useStreamystatsEnabled } from "@/hooks/useWatchlists";
|
||||||
|
|
||||||
|
export default function WatchlistsLayout() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
const streamystatsEnabled = useStreamystatsEnabled();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Stack.Screen
|
||||||
|
name='index'
|
||||||
|
options={{
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
|
headerTitle: t("watchlists.title"),
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
headerShadowVisible: false,
|
||||||
|
headerRight: streamystatsEnabled
|
||||||
|
? () => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() =>
|
||||||
|
router.push("/(auth)/(tabs)/(watchlists)/create")
|
||||||
|
}
|
||||||
|
className='p-1.5'
|
||||||
|
>
|
||||||
|
<Ionicons name='add' size={24} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='[watchlistId]'
|
||||||
|
options={{
|
||||||
|
title: "",
|
||||||
|
headerShown: true,
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
headerShadowVisible: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='create'
|
||||||
|
options={{
|
||||||
|
title: t("watchlists.create_title"),
|
||||||
|
presentation: "modal",
|
||||||
|
headerShown: true,
|
||||||
|
headerStyle: { backgroundColor: "#171717" },
|
||||||
|
headerTintColor: "white",
|
||||||
|
contentStyle: { backgroundColor: "#171717" },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='edit/[watchlistId]'
|
||||||
|
options={{
|
||||||
|
title: t("watchlists.edit_title"),
|
||||||
|
presentation: "modal",
|
||||||
|
headerShown: true,
|
||||||
|
headerStyle: { backgroundColor: "#171717" },
|
||||||
|
headerTintColor: "white",
|
||||||
|
contentStyle: { backgroundColor: "#171717" },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||||
|
<Stack.Screen key={name} name={name} options={options} />
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
221
app/(auth)/(tabs)/(watchlists)/create.tsx
Normal file
221
app/(auth)/(tabs)/(watchlists)/create.tsx
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
ScrollView,
|
||||||
|
Switch,
|
||||||
|
TextInput,
|
||||||
|
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 { useCreateWatchlist } from "@/hooks/useWatchlistMutations";
|
||||||
|
import type {
|
||||||
|
StreamystatsWatchlistAllowedItemType,
|
||||||
|
StreamystatsWatchlistSortOrder,
|
||||||
|
} from "@/utils/streamystats/types";
|
||||||
|
|
||||||
|
const ITEM_TYPES: Array<{
|
||||||
|
value: StreamystatsWatchlistAllowedItemType;
|
||||||
|
label: string;
|
||||||
|
}> = [
|
||||||
|
{ value: null, label: "All Types" },
|
||||||
|
{ value: "Movie", label: "Movies Only" },
|
||||||
|
{ value: "Series", label: "Series Only" },
|
||||||
|
{ value: "Episode", label: "Episodes Only" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SORT_OPTIONS: Array<{
|
||||||
|
value: StreamystatsWatchlistSortOrder;
|
||||||
|
label: string;
|
||||||
|
}> = [
|
||||||
|
{ value: "custom", label: "Custom Order" },
|
||||||
|
{ value: "name", label: "Name" },
|
||||||
|
{ value: "dateAdded", label: "Date Added" },
|
||||||
|
{ value: "releaseDate", label: "Release Date" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function CreateWatchlistScreen() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const createWatchlist = useCreateWatchlist();
|
||||||
|
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [isPublic, setIsPublic] = useState(false);
|
||||||
|
const [allowedItemType, setAllowedItemType] =
|
||||||
|
useState<StreamystatsWatchlistAllowedItemType>(null);
|
||||||
|
const [defaultSortOrder, setDefaultSortOrder] =
|
||||||
|
useState<StreamystatsWatchlistSortOrder>("custom");
|
||||||
|
|
||||||
|
const handleCreate = useCallback(async () => {
|
||||||
|
if (!name.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createWatchlist.mutateAsync({
|
||||||
|
name: name.trim(),
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
isPublic,
|
||||||
|
allowedItemType,
|
||||||
|
defaultSortOrder,
|
||||||
|
});
|
||||||
|
router.back();
|
||||||
|
} catch {
|
||||||
|
// Error handled by mutation
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
isPublic,
|
||||||
|
allowedItemType,
|
||||||
|
defaultSortOrder,
|
||||||
|
createWatchlist,
|
||||||
|
router,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||||
|
className='flex-1'
|
||||||
|
style={{ backgroundColor: "#171717" }}
|
||||||
|
>
|
||||||
|
<ScrollView
|
||||||
|
className='flex-1'
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingBottom: insets.bottom + 20,
|
||||||
|
}}
|
||||||
|
keyboardShouldPersistTaps='handled'
|
||||||
|
>
|
||||||
|
{/* Name */}
|
||||||
|
<View className='px-4 py-4'>
|
||||||
|
<Text className='text-sm font-medium text-neutral-400 mb-2'>
|
||||||
|
{t("watchlists.name_label")} *
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
value={name}
|
||||||
|
onChangeText={setName}
|
||||||
|
placeholder={t("watchlists.name_placeholder")}
|
||||||
|
placeholderTextColor='#6b7280'
|
||||||
|
className='bg-neutral-800 text-white px-4 py-3 rounded-lg text-base'
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<View className='px-4 py-4'>
|
||||||
|
<Text className='text-sm font-medium text-neutral-400 mb-2'>
|
||||||
|
{t("watchlists.description_label")}
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
value={description}
|
||||||
|
onChangeText={setDescription}
|
||||||
|
placeholder={t("watchlists.description_placeholder")}
|
||||||
|
placeholderTextColor='#6b7280'
|
||||||
|
className='bg-neutral-800 text-white px-4 py-3 rounded-lg text-base'
|
||||||
|
multiline
|
||||||
|
numberOfLines={3}
|
||||||
|
textAlignVertical='top'
|
||||||
|
style={{ minHeight: 80 }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Public Toggle */}
|
||||||
|
<View className='px-4 py-4 flex-row items-center justify-between'>
|
||||||
|
<View className='flex-1 mr-4'>
|
||||||
|
<Text className='text-base font-medium text-white'>
|
||||||
|
{t("watchlists.is_public_label")}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-sm text-neutral-400 mt-1'>
|
||||||
|
{t("watchlists.is_public_description")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={isPublic}
|
||||||
|
onValueChange={setIsPublic}
|
||||||
|
trackColor={{ false: "#374151", true: "#7c3aed" }}
|
||||||
|
thumbColor={isPublic ? "#a78bfa" : "#9ca3af"}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Content Type */}
|
||||||
|
<View className='px-4 py-4'>
|
||||||
|
<Text className='text-sm font-medium text-neutral-400 mb-2'>
|
||||||
|
{t("watchlists.allowed_type_label")}
|
||||||
|
</Text>
|
||||||
|
<View className='flex-row flex-wrap gap-2'>
|
||||||
|
{ITEM_TYPES.map((type) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={type.value ?? "all"}
|
||||||
|
onPress={() => setAllowedItemType(type.value)}
|
||||||
|
className={`px-4 py-2 rounded-lg ${allowedItemType === type.value ? "bg-purple-600" : "bg-neutral-800"}`}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={
|
||||||
|
allowedItemType === type.value
|
||||||
|
? "text-white font-medium"
|
||||||
|
: "text-neutral-300"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{type.label}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Sort Order */}
|
||||||
|
<View className='px-4 py-4'>
|
||||||
|
<Text className='text-sm font-medium text-neutral-400 mb-2'>
|
||||||
|
{t("watchlists.sort_order_label")}
|
||||||
|
</Text>
|
||||||
|
<View className='flex-row flex-wrap gap-2'>
|
||||||
|
{SORT_OPTIONS.map((sort) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={sort.value}
|
||||||
|
onPress={() => setDefaultSortOrder(sort.value)}
|
||||||
|
className={`px-4 py-2 rounded-lg ${defaultSortOrder === sort.value ? "bg-purple-600" : "bg-neutral-800"}`}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={
|
||||||
|
defaultSortOrder === sort.value
|
||||||
|
? "text-white font-medium"
|
||||||
|
: "text-neutral-300"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{sort.label}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Create Button */}
|
||||||
|
<View className='px-4 pt-4'>
|
||||||
|
<Button
|
||||||
|
onPress={handleCreate}
|
||||||
|
disabled={!name.trim() || createWatchlist.isPending}
|
||||||
|
className={`py-3 ${!name.trim() ? "opacity-50" : ""}`}
|
||||||
|
>
|
||||||
|
{createWatchlist.isPending ? (
|
||||||
|
<ActivityIndicator color='white' />
|
||||||
|
) : (
|
||||||
|
<View className='flex-row items-center'>
|
||||||
|
<Ionicons name='add' size={20} color='white' />
|
||||||
|
<Text className='text-white font-semibold text-base'>
|
||||||
|
{t("watchlists.create_button")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
273
app/(auth)/(tabs)/(watchlists)/edit/[watchlistId].tsx
Normal file
273
app/(auth)/(tabs)/(watchlists)/edit/[watchlistId].tsx
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
ScrollView,
|
||||||
|
Switch,
|
||||||
|
TextInput,
|
||||||
|
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 { useUpdateWatchlist } from "@/hooks/useWatchlistMutations";
|
||||||
|
import { useWatchlistDetailQuery } from "@/hooks/useWatchlists";
|
||||||
|
import type {
|
||||||
|
StreamystatsWatchlistAllowedItemType,
|
||||||
|
StreamystatsWatchlistSortOrder,
|
||||||
|
} from "@/utils/streamystats/types";
|
||||||
|
|
||||||
|
const ITEM_TYPES: Array<{
|
||||||
|
value: StreamystatsWatchlistAllowedItemType;
|
||||||
|
label: string;
|
||||||
|
}> = [
|
||||||
|
{ value: null, label: "All Types" },
|
||||||
|
{ value: "Movie", label: "Movies Only" },
|
||||||
|
{ value: "Series", label: "Series Only" },
|
||||||
|
{ value: "Episode", label: "Episodes Only" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SORT_OPTIONS: Array<{
|
||||||
|
value: StreamystatsWatchlistSortOrder;
|
||||||
|
label: string;
|
||||||
|
}> = [
|
||||||
|
{ value: "custom", label: "Custom Order" },
|
||||||
|
{ value: "name", label: "Name" },
|
||||||
|
{ value: "dateAdded", label: "Date Added" },
|
||||||
|
{ value: "releaseDate", label: "Release Date" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function EditWatchlistScreen() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { watchlistId } = useLocalSearchParams<{ watchlistId: string }>();
|
||||||
|
const watchlistIdNum = watchlistId
|
||||||
|
? Number.parseInt(watchlistId, 10)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const { data: watchlist, isLoading } =
|
||||||
|
useWatchlistDetailQuery(watchlistIdNum);
|
||||||
|
const updateWatchlist = useUpdateWatchlist();
|
||||||
|
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [isPublic, setIsPublic] = useState(false);
|
||||||
|
const [allowedItemType, setAllowedItemType] =
|
||||||
|
useState<StreamystatsWatchlistAllowedItemType>(null);
|
||||||
|
const [defaultSortOrder, setDefaultSortOrder] =
|
||||||
|
useState<StreamystatsWatchlistSortOrder>("custom");
|
||||||
|
|
||||||
|
// Initialize form with watchlist data
|
||||||
|
useEffect(() => {
|
||||||
|
if (watchlist) {
|
||||||
|
setName(watchlist.name);
|
||||||
|
setDescription(watchlist.description ?? "");
|
||||||
|
setIsPublic(watchlist.isPublic);
|
||||||
|
setAllowedItemType(
|
||||||
|
(watchlist.allowedItemType as StreamystatsWatchlistAllowedItemType) ??
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
setDefaultSortOrder(
|
||||||
|
(watchlist.defaultSortOrder as StreamystatsWatchlistSortOrder) ??
|
||||||
|
"custom",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [watchlist]);
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
if (!name.trim() || !watchlistIdNum) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateWatchlist.mutateAsync({
|
||||||
|
watchlistId: watchlistIdNum,
|
||||||
|
data: {
|
||||||
|
name: name.trim(),
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
isPublic,
|
||||||
|
allowedItemType,
|
||||||
|
defaultSortOrder,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
router.back();
|
||||||
|
} catch {
|
||||||
|
// Error handled by mutation
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
isPublic,
|
||||||
|
allowedItemType,
|
||||||
|
defaultSortOrder,
|
||||||
|
watchlistIdNum,
|
||||||
|
updateWatchlist,
|
||||||
|
router,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className='flex-1 items-center justify-center'
|
||||||
|
style={{ backgroundColor: "#171717" }}
|
||||||
|
>
|
||||||
|
<ActivityIndicator size='large' />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!watchlist) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className='flex-1 items-center justify-center px-8'
|
||||||
|
style={{ backgroundColor: "#171717" }}
|
||||||
|
>
|
||||||
|
<Text className='text-lg text-neutral-400'>
|
||||||
|
{t("watchlists.not_found")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||||
|
className='flex-1'
|
||||||
|
style={{ backgroundColor: "#171717" }}
|
||||||
|
>
|
||||||
|
<ScrollView
|
||||||
|
className='flex-1'
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingBottom: insets.bottom + 20,
|
||||||
|
}}
|
||||||
|
keyboardShouldPersistTaps='handled'
|
||||||
|
>
|
||||||
|
{/* Name */}
|
||||||
|
<View className='px-4 py-4'>
|
||||||
|
<Text className='text-sm font-medium text-neutral-400 mb-2'>
|
||||||
|
{t("watchlists.name_label")} *
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
value={name}
|
||||||
|
onChangeText={setName}
|
||||||
|
placeholder={t("watchlists.name_placeholder")}
|
||||||
|
placeholderTextColor='#6b7280'
|
||||||
|
className='bg-neutral-800 text-white px-4 py-3 rounded-lg text-base'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<View className='px-4 py-4'>
|
||||||
|
<Text className='text-sm font-medium text-neutral-400 mb-2'>
|
||||||
|
{t("watchlists.description_label")}
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
value={description}
|
||||||
|
onChangeText={setDescription}
|
||||||
|
placeholder={t("watchlists.description_placeholder")}
|
||||||
|
placeholderTextColor='#6b7280'
|
||||||
|
className='bg-neutral-800 text-white px-4 py-3 rounded-lg text-base'
|
||||||
|
multiline
|
||||||
|
numberOfLines={3}
|
||||||
|
textAlignVertical='top'
|
||||||
|
style={{ minHeight: 80 }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Public Toggle */}
|
||||||
|
<View className='px-4 py-4 flex-row items-center justify-between'>
|
||||||
|
<View className='flex-1 mr-4'>
|
||||||
|
<Text className='text-base font-medium text-white'>
|
||||||
|
{t("watchlists.is_public_label")}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-sm text-neutral-400 mt-1'>
|
||||||
|
{t("watchlists.is_public_description")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={isPublic}
|
||||||
|
onValueChange={setIsPublic}
|
||||||
|
trackColor={{ false: "#374151", true: "#7c3aed" }}
|
||||||
|
thumbColor={isPublic ? "#a78bfa" : "#9ca3af"}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Content Type */}
|
||||||
|
<View className='px-4 py-4'>
|
||||||
|
<Text className='text-sm font-medium text-neutral-400 mb-2'>
|
||||||
|
{t("watchlists.allowed_type_label")}
|
||||||
|
</Text>
|
||||||
|
<View className='flex-row flex-wrap gap-2'>
|
||||||
|
{ITEM_TYPES.map((type) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={type.value ?? "all"}
|
||||||
|
onPress={() => setAllowedItemType(type.value)}
|
||||||
|
className={`px-4 py-2 rounded-lg ${allowedItemType === type.value ? "bg-purple-600" : "bg-neutral-800"}`}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={
|
||||||
|
allowedItemType === type.value
|
||||||
|
? "text-white font-medium"
|
||||||
|
: "text-neutral-300"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{type.label}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Sort Order */}
|
||||||
|
<View className='px-4 py-4'>
|
||||||
|
<Text className='text-sm font-medium text-neutral-400 mb-2'>
|
||||||
|
{t("watchlists.sort_order_label")}
|
||||||
|
</Text>
|
||||||
|
<View className='flex-row flex-wrap gap-2'>
|
||||||
|
{SORT_OPTIONS.map((sort) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={sort.value}
|
||||||
|
onPress={() => setDefaultSortOrder(sort.value)}
|
||||||
|
className={`px-4 py-2 rounded-lg ${defaultSortOrder === sort.value ? "bg-purple-600" : "bg-neutral-800"}`}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={
|
||||||
|
defaultSortOrder === sort.value
|
||||||
|
? "text-white font-medium"
|
||||||
|
: "text-neutral-300"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{sort.label}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Save Button */}
|
||||||
|
<View className='px-4 pt-4'>
|
||||||
|
<Button
|
||||||
|
onPress={handleSave}
|
||||||
|
disabled={!name.trim() || updateWatchlist.isPending}
|
||||||
|
className={`py-3 ${!name.trim() ? "opacity-50" : ""}`}
|
||||||
|
>
|
||||||
|
{updateWatchlist.isPending ? (
|
||||||
|
<ActivityIndicator color='white' />
|
||||||
|
) : (
|
||||||
|
<View className='flex-row items-center'>
|
||||||
|
<Ionicons name='checkmark' size={20} color='white' />
|
||||||
|
<Text className='text-white font-semibold text-base'>
|
||||||
|
{t("watchlists.save_button")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
239
app/(auth)/(tabs)/(watchlists)/index.tsx
Normal file
239
app/(auth)/(tabs)/(watchlists)/index.tsx
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform, RefreshControl, 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 {
|
||||||
|
useStreamystatsEnabled,
|
||||||
|
useWatchlistsQuery,
|
||||||
|
} from "@/hooks/useWatchlists";
|
||||||
|
import { userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import type { StreamystatsWatchlist } from "@/utils/streamystats/types";
|
||||||
|
|
||||||
|
interface WatchlistCardProps {
|
||||||
|
watchlist: StreamystatsWatchlist;
|
||||||
|
isOwner: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WatchlistCard: React.FC<WatchlistCardProps> = ({
|
||||||
|
watchlist,
|
||||||
|
isOwner,
|
||||||
|
onPress,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onPress}
|
||||||
|
className='bg-neutral-900 rounded-xl p-4 mx-4 mb-3'
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<View className='flex-row items-center justify-between mb-2'>
|
||||||
|
<Text className='text-lg font-semibold flex-1' numberOfLines={1}>
|
||||||
|
{watchlist.name}
|
||||||
|
</Text>
|
||||||
|
<View className='flex-row items-center gap-2'>
|
||||||
|
{isOwner && (
|
||||||
|
<View className='bg-purple-600/20 px-2 py-1 rounded'>
|
||||||
|
<Text className='text-purple-400 text-xs'>
|
||||||
|
{t("watchlists.you")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<Ionicons
|
||||||
|
name={watchlist.isPublic ? "globe-outline" : "lock-closed-outline"}
|
||||||
|
size={16}
|
||||||
|
color='#9ca3af'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{watchlist.description && (
|
||||||
|
<Text className='text-neutral-400 text-sm mb-2' numberOfLines={2}>
|
||||||
|
{watchlist.description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View className='flex-row items-center gap-4'>
|
||||||
|
<View className='flex-row items-center gap-1'>
|
||||||
|
<Ionicons name='film-outline' size={14} color='#9ca3af' />
|
||||||
|
<Text className='text-neutral-400 text-sm'>
|
||||||
|
{watchlist.itemCount ?? 0}{" "}
|
||||||
|
{(watchlist.itemCount ?? 0) === 1
|
||||||
|
? t("watchlists.item")
|
||||||
|
: t("watchlists.items")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{watchlist.allowedItemType && (
|
||||||
|
<View className='bg-neutral-800 px-2 py-0.5 rounded'>
|
||||||
|
<Text className='text-neutral-400 text-xs'>
|
||||||
|
{watchlist.allowedItemType}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const EmptyState: React.FC<{ onCreatePress: () => void }> = ({
|
||||||
|
onCreatePress: _onCreatePress,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='flex-1 items-center justify-center px-8'>
|
||||||
|
<Ionicons name='list-outline' size={64} color='#4b5563' />
|
||||||
|
<Text className='text-xl font-semibold mt-4 text-center'>
|
||||||
|
{t("watchlists.empty_title")}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-neutral-400 text-center mt-2 mb-6'>
|
||||||
|
{t("watchlists.empty_description")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const NotConfiguredState: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='flex-1 items-center justify-center px-8'>
|
||||||
|
<Ionicons name='settings-outline' size={64} color='#4b5563' />
|
||||||
|
<Text className='text-xl font-semibold mt-4 text-center'>
|
||||||
|
{t("watchlists.not_configured_title")}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-neutral-400 text-center mt-2 mb-6'>
|
||||||
|
{t("watchlists.not_configured_description")}
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
onPress={() =>
|
||||||
|
router.push(
|
||||||
|
"/(auth)/(tabs)/(home)/settings/plugins/streamystats/page",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className='px-6'
|
||||||
|
>
|
||||||
|
<Text className='font-semibold'>{t("watchlists.go_to_settings")}</Text>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function WatchlistsScreen() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
|
const streamystatsEnabled = useStreamystatsEnabled();
|
||||||
|
const { data: watchlists, isLoading, refetch } = useWatchlistsQuery();
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(async () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
await refetch();
|
||||||
|
setRefreshing(false);
|
||||||
|
}, [refetch]);
|
||||||
|
|
||||||
|
const handleCreatePress = useCallback(() => {
|
||||||
|
router.push("/(auth)/(tabs)/(watchlists)/create");
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const handleWatchlistPress = useCallback(
|
||||||
|
(watchlistId: number) => {
|
||||||
|
router.push(`/(auth)/(tabs)/(watchlists)/${watchlistId}`);
|
||||||
|
},
|
||||||
|
[router],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Separate watchlists into "mine" and "public"
|
||||||
|
const { myWatchlists, publicWatchlists } = useMemo(() => {
|
||||||
|
if (!watchlists) return { myWatchlists: [], publicWatchlists: [] };
|
||||||
|
|
||||||
|
const mine: StreamystatsWatchlist[] = [];
|
||||||
|
const pub: StreamystatsWatchlist[] = [];
|
||||||
|
|
||||||
|
for (const w of watchlists) {
|
||||||
|
if (w.userId === user?.Id) {
|
||||||
|
mine.push(w);
|
||||||
|
} else {
|
||||||
|
pub.push(w);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { myWatchlists: mine, publicWatchlists: pub };
|
||||||
|
}, [watchlists, user?.Id]);
|
||||||
|
|
||||||
|
// Combine into sections for FlashList
|
||||||
|
const sections = useMemo(() => {
|
||||||
|
const result: Array<
|
||||||
|
| { type: "header"; title: string }
|
||||||
|
| { type: "watchlist"; data: StreamystatsWatchlist; isOwner: boolean }
|
||||||
|
> = [];
|
||||||
|
|
||||||
|
if (myWatchlists.length > 0) {
|
||||||
|
result.push({ type: "header", title: t("watchlists.my_watchlists") });
|
||||||
|
for (const w of myWatchlists) {
|
||||||
|
result.push({ type: "watchlist", data: w, isOwner: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (publicWatchlists.length > 0) {
|
||||||
|
result.push({ type: "header", title: t("watchlists.public_watchlists") });
|
||||||
|
for (const w of publicWatchlists) {
|
||||||
|
result.push({ type: "watchlist", data: w, isOwner: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [myWatchlists, publicWatchlists, t]);
|
||||||
|
|
||||||
|
if (!streamystatsEnabled) {
|
||||||
|
return <NotConfiguredState />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoading && (!watchlists || watchlists.length === 0)) {
|
||||||
|
return <EmptyState onCreatePress={handleCreatePress} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlashList
|
||||||
|
data={sections}
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingTop: Platform.OS === "android" ? 10 : 0,
|
||||||
|
paddingBottom: 100,
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
|
||||||
|
}
|
||||||
|
renderItem={({ item }) => {
|
||||||
|
if (item.type === "header") {
|
||||||
|
return (
|
||||||
|
<Text className='text-lg font-bold px-4 pt-4 pb-2'>
|
||||||
|
{item.title}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WatchlistCard
|
||||||
|
watchlist={item.data}
|
||||||
|
isOwner={item.isOwner}
|
||||||
|
onPress={() => handleWatchlistPress(item.data.id)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
getItemType={(item) => item.type}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,8 +10,10 @@ import type {
|
|||||||
import { useFocusEffect, useRouter, withLayoutContext } from "expo-router";
|
import { useFocusEffect, useRouter, withLayoutContext } from "expo-router";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
|
import { MiniPlayerBar } from "@/components/music/MiniPlayerBar";
|
||||||
|
import { MusicPlaybackEngine } from "@/components/music/MusicPlaybackEngine";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { eventBus } from "@/utils/eventBus";
|
import { eventBus } from "@/utils/eventBus";
|
||||||
@@ -47,7 +49,7 @@ export default function TabLayout() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<View style={{ flex: 1 }}>
|
||||||
<SystemBars hidden={false} style='light' />
|
<SystemBars hidden={false} style='light' />
|
||||||
<NativeTabs
|
<NativeTabs
|
||||||
sidebarAdaptable={false}
|
sidebarAdaptable={false}
|
||||||
@@ -55,6 +57,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 +73,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 +84,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 +98,20 @@ 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 }) =>
|
<NativeTabs.Screen
|
||||||
focused
|
name='(watchlists)'
|
||||||
? { sfSymbol: "heart.fill" }
|
options={{
|
||||||
: { sfSymbol: "heart" },
|
title: t("watchlists.title"),
|
||||||
|
tabBarItemHidden:
|
||||||
|
!settings?.streamyStatsServerUrl || settings?.hideWatchlistsTab,
|
||||||
|
tabBarIcon:
|
||||||
|
Platform.OS === "android"
|
||||||
|
? (_e) => require("@/assets/icons/list.png")
|
||||||
|
: (_e) => ({ sfSymbol: "list.bullet.rectangle" }),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
@@ -117,10 +121,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,13 +132,12 @@ 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>
|
||||||
</>
|
<MiniPlayerBar />
|
||||||
|
<MusicPlaybackEngine />
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
633
app/(auth)/now-playing.tsx
Normal file
633
app/(auth)/now-playing.tsx
Normal file
@@ -0,0 +1,633 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type {
|
||||||
|
BaseItemDto,
|
||||||
|
MediaSourceInfo,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Dimensions,
|
||||||
|
Platform,
|
||||||
|
ScrollView,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { Slider } from "react-native-awesome-slider";
|
||||||
|
import DraggableFlatList, {
|
||||||
|
type RenderItemParams,
|
||||||
|
ScaleDecorator,
|
||||||
|
} from "react-native-draggable-flatlist";
|
||||||
|
import { useSharedValue } from "react-native-reanimated";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Badge } from "@/components/Badge";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import {
|
||||||
|
type RepeatMode,
|
||||||
|
useMusicPlayer,
|
||||||
|
} from "@/providers/MusicPlayerProvider";
|
||||||
|
import { formatBitrate } from "@/utils/bitrate";
|
||||||
|
import { formatDuration } from "@/utils/time";
|
||||||
|
|
||||||
|
const formatFileSize = (bytes?: number | null) => {
|
||||||
|
if (!bytes) return null;
|
||||||
|
const sizes = ["B", "KB", "MB", "GB"];
|
||||||
|
if (bytes === 0) return "0 B";
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||||
|
return `${Math.round((bytes / 1024 ** i) * 100) / 100} ${sizes[i]}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatSampleRate = (sampleRate?: number | null) => {
|
||||||
|
if (!sampleRate) return null;
|
||||||
|
return `${(sampleRate / 1000).toFixed(1)} kHz`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { width: SCREEN_WIDTH } = Dimensions.get("window");
|
||||||
|
const ARTWORK_SIZE = SCREEN_WIDTH - 80;
|
||||||
|
|
||||||
|
type ViewMode = "player" | "queue";
|
||||||
|
|
||||||
|
export default function NowPlayingScreen() {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const router = useRouter();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>("player");
|
||||||
|
|
||||||
|
const {
|
||||||
|
currentTrack,
|
||||||
|
queue,
|
||||||
|
queueIndex,
|
||||||
|
isPlaying,
|
||||||
|
isLoading,
|
||||||
|
progress,
|
||||||
|
duration,
|
||||||
|
repeatMode,
|
||||||
|
shuffleEnabled,
|
||||||
|
mediaSource,
|
||||||
|
isTranscoding,
|
||||||
|
togglePlayPause,
|
||||||
|
next,
|
||||||
|
previous,
|
||||||
|
seek,
|
||||||
|
setRepeatMode,
|
||||||
|
toggleShuffle,
|
||||||
|
jumpToIndex,
|
||||||
|
removeFromQueue,
|
||||||
|
reorderQueue,
|
||||||
|
stop,
|
||||||
|
} = useMusicPlayer();
|
||||||
|
|
||||||
|
const sliderProgress = useSharedValue(0);
|
||||||
|
const sliderMin = useSharedValue(0);
|
||||||
|
const sliderMax = useSharedValue(1);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
sliderProgress.value = progress;
|
||||||
|
}, [progress, sliderProgress]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
sliderMax.value = duration > 0 ? duration : 1;
|
||||||
|
}, [duration, sliderMax]);
|
||||||
|
|
||||||
|
const imageUrl = useMemo(() => {
|
||||||
|
if (!api || !currentTrack) return null;
|
||||||
|
const albumId = currentTrack.AlbumId || currentTrack.ParentId;
|
||||||
|
if (albumId) {
|
||||||
|
return `${api.basePath}/Items/${albumId}/Images/Primary?maxHeight=600&maxWidth=600`;
|
||||||
|
}
|
||||||
|
return `${api.basePath}/Items/${currentTrack.Id}/Images/Primary?maxHeight=600&maxWidth=600`;
|
||||||
|
}, [api, currentTrack]);
|
||||||
|
|
||||||
|
const progressText = useMemo(() => {
|
||||||
|
const progressTicks = progress * 10000000;
|
||||||
|
return formatDuration(progressTicks);
|
||||||
|
}, [progress]);
|
||||||
|
|
||||||
|
const durationText = useMemo(() => {
|
||||||
|
const durationTicks = duration * 10000000;
|
||||||
|
return formatDuration(durationTicks);
|
||||||
|
}, [duration]);
|
||||||
|
|
||||||
|
const handleSliderComplete = useCallback(
|
||||||
|
(value: number) => {
|
||||||
|
seek(value);
|
||||||
|
},
|
||||||
|
[seek],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
router.back();
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const _handleStop = useCallback(() => {
|
||||||
|
stop();
|
||||||
|
router.back();
|
||||||
|
}, [stop, router]);
|
||||||
|
|
||||||
|
const cycleRepeatMode = useCallback(() => {
|
||||||
|
const modes: RepeatMode[] = ["off", "all", "one"];
|
||||||
|
const currentIndex = modes.indexOf(repeatMode);
|
||||||
|
const nextMode = modes[(currentIndex + 1) % modes.length];
|
||||||
|
setRepeatMode(nextMode);
|
||||||
|
}, [repeatMode, setRepeatMode]);
|
||||||
|
|
||||||
|
const getRepeatIcon = (): string => {
|
||||||
|
switch (repeatMode) {
|
||||||
|
case "one":
|
||||||
|
return "repeat";
|
||||||
|
case "all":
|
||||||
|
return "repeat";
|
||||||
|
default:
|
||||||
|
return "repeat";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const canGoNext = queueIndex < queue.length - 1 || repeatMode === "all";
|
||||||
|
const canGoPrevious = queueIndex > 0 || progress > 3 || repeatMode === "all";
|
||||||
|
|
||||||
|
if (!currentTrack) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className='flex-1 bg-[#121212] items-center justify-center'
|
||||||
|
style={{
|
||||||
|
paddingTop: Platform.OS === "android" ? insets.top : 0,
|
||||||
|
paddingBottom: Platform.OS === "android" ? insets.bottom : 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className='text-neutral-500'>No track playing</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className='flex-1 bg-[#121212]'
|
||||||
|
style={{
|
||||||
|
paddingTop: Platform.OS === "android" ? insets.top : 0,
|
||||||
|
paddingBottom: Platform.OS === "android" ? insets.bottom : 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<View className='flex-row items-center justify-between px-4 pt-3 pb-2'>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleClose}
|
||||||
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||||
|
className='p-2'
|
||||||
|
>
|
||||||
|
<Ionicons name='chevron-down' size={28} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<View className='flex-row'>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setViewMode("player")}
|
||||||
|
className='px-3 py-1'
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={
|
||||||
|
viewMode === "player"
|
||||||
|
? "text-white font-semibold"
|
||||||
|
: "text-neutral-500"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Now Playing
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setViewMode("queue")}
|
||||||
|
className='px-3 py-1'
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={
|
||||||
|
viewMode === "queue"
|
||||||
|
? "text-white font-semibold"
|
||||||
|
: "text-neutral-500"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Queue ({queue.length})
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<View style={{ width: 16 }} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{viewMode === "player" ? (
|
||||||
|
<PlayerView
|
||||||
|
api={api}
|
||||||
|
currentTrack={currentTrack}
|
||||||
|
imageUrl={imageUrl}
|
||||||
|
sliderProgress={sliderProgress}
|
||||||
|
sliderMin={sliderMin}
|
||||||
|
sliderMax={sliderMax}
|
||||||
|
progressText={progressText}
|
||||||
|
durationText={durationText}
|
||||||
|
isPlaying={isPlaying}
|
||||||
|
isLoading={isLoading}
|
||||||
|
repeatMode={repeatMode}
|
||||||
|
shuffleEnabled={shuffleEnabled}
|
||||||
|
canGoNext={canGoNext}
|
||||||
|
canGoPrevious={canGoPrevious}
|
||||||
|
onSliderComplete={handleSliderComplete}
|
||||||
|
onTogglePlayPause={togglePlayPause}
|
||||||
|
onNext={next}
|
||||||
|
onPrevious={previous}
|
||||||
|
onCycleRepeat={cycleRepeatMode}
|
||||||
|
onToggleShuffle={toggleShuffle}
|
||||||
|
getRepeatIcon={getRepeatIcon}
|
||||||
|
queue={queue}
|
||||||
|
queueIndex={queueIndex}
|
||||||
|
mediaSource={mediaSource}
|
||||||
|
isTranscoding={isTranscoding}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<QueueView
|
||||||
|
api={api}
|
||||||
|
queue={queue}
|
||||||
|
queueIndex={queueIndex}
|
||||||
|
onJumpToIndex={jumpToIndex}
|
||||||
|
onRemoveFromQueue={removeFromQueue}
|
||||||
|
onReorderQueue={reorderQueue}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlayerViewProps {
|
||||||
|
api: any;
|
||||||
|
currentTrack: BaseItemDto;
|
||||||
|
imageUrl: string | null;
|
||||||
|
sliderProgress: any;
|
||||||
|
sliderMin: any;
|
||||||
|
sliderMax: any;
|
||||||
|
progressText: string;
|
||||||
|
durationText: string;
|
||||||
|
isPlaying: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
repeatMode: RepeatMode;
|
||||||
|
shuffleEnabled: boolean;
|
||||||
|
canGoNext: boolean;
|
||||||
|
canGoPrevious: boolean;
|
||||||
|
onSliderComplete: (value: number) => void;
|
||||||
|
onTogglePlayPause: () => void;
|
||||||
|
onNext: () => void;
|
||||||
|
onPrevious: () => void;
|
||||||
|
onCycleRepeat: () => void;
|
||||||
|
onToggleShuffle: () => void;
|
||||||
|
getRepeatIcon: () => string;
|
||||||
|
queue: BaseItemDto[];
|
||||||
|
queueIndex: number;
|
||||||
|
mediaSource: MediaSourceInfo | null;
|
||||||
|
isTranscoding: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PlayerView: React.FC<PlayerViewProps> = ({
|
||||||
|
currentTrack,
|
||||||
|
imageUrl,
|
||||||
|
sliderProgress,
|
||||||
|
sliderMin,
|
||||||
|
sliderMax,
|
||||||
|
progressText,
|
||||||
|
durationText,
|
||||||
|
isPlaying,
|
||||||
|
isLoading,
|
||||||
|
repeatMode,
|
||||||
|
shuffleEnabled,
|
||||||
|
canGoNext,
|
||||||
|
canGoPrevious,
|
||||||
|
onSliderComplete,
|
||||||
|
onTogglePlayPause,
|
||||||
|
onNext,
|
||||||
|
onPrevious,
|
||||||
|
onCycleRepeat,
|
||||||
|
onToggleShuffle,
|
||||||
|
getRepeatIcon,
|
||||||
|
queue,
|
||||||
|
queueIndex,
|
||||||
|
mediaSource,
|
||||||
|
isTranscoding,
|
||||||
|
}) => {
|
||||||
|
const audioStream = useMemo(() => {
|
||||||
|
return mediaSource?.MediaStreams?.find((stream) => stream.Type === "Audio");
|
||||||
|
}, [mediaSource]);
|
||||||
|
|
||||||
|
const fileSize = formatFileSize(mediaSource?.Size);
|
||||||
|
const codec = audioStream?.Codec?.toUpperCase();
|
||||||
|
const bitrate = formatBitrate(audioStream?.BitRate);
|
||||||
|
const sampleRate = formatSampleRate(audioStream?.SampleRate);
|
||||||
|
const playbackMethod = isTranscoding ? "Transcoding" : "Direct";
|
||||||
|
|
||||||
|
const hasAudioStats =
|
||||||
|
mediaSource && (fileSize || codec || bitrate || sampleRate);
|
||||||
|
return (
|
||||||
|
<ScrollView className='flex-1 px-6' showsVerticalScrollIndicator={false}>
|
||||||
|
{/* Album artwork */}
|
||||||
|
<View
|
||||||
|
className='self-center mb-8 mt-4'
|
||||||
|
style={{
|
||||||
|
width: ARTWORK_SIZE,
|
||||||
|
height: ARTWORK_SIZE,
|
||||||
|
borderRadius: 12,
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 8 },
|
||||||
|
shadowOpacity: 0.4,
|
||||||
|
shadowRadius: 16,
|
||||||
|
elevation: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{imageUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: imageUrl }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
cachePolicy='memory-disk'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View className='flex-1 items-center justify-center bg-neutral-800'>
|
||||||
|
<Ionicons name='musical-note' size={80} color='#666' />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Track info */}
|
||||||
|
<View className='mb-6'>
|
||||||
|
<Text numberOfLines={1} className='text-white text-2xl font-bold'>
|
||||||
|
{currentTrack.Name}
|
||||||
|
</Text>
|
||||||
|
<Text numberOfLines={1} className='text-purple-400 text-lg mt-1'>
|
||||||
|
{currentTrack.Artists?.join(", ") || currentTrack.AlbumArtist}
|
||||||
|
</Text>
|
||||||
|
{currentTrack.Album && (
|
||||||
|
<Text numberOfLines={1} className='text-neutral-500 text-sm mt-1'>
|
||||||
|
{currentTrack.Album}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Audio Stats */}
|
||||||
|
{hasAudioStats && (
|
||||||
|
<View className='flex-row flex-wrap gap-1.5 mt-3'>
|
||||||
|
{fileSize && <Badge variant='gray' text={fileSize} />}
|
||||||
|
{codec && <Badge variant='gray' text={codec} />}
|
||||||
|
<Badge
|
||||||
|
variant='gray'
|
||||||
|
text={playbackMethod}
|
||||||
|
iconLeft={
|
||||||
|
<Ionicons
|
||||||
|
name={isTranscoding ? "swap-horizontal" : "play"}
|
||||||
|
size={12}
|
||||||
|
color='white'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{bitrate && bitrate !== "N/A" && (
|
||||||
|
<Badge variant='gray' text={bitrate} />
|
||||||
|
)}
|
||||||
|
{sampleRate && <Badge variant='gray' text={sampleRate} />}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Progress slider */}
|
||||||
|
<View className='mb-4'>
|
||||||
|
<Slider
|
||||||
|
theme={{
|
||||||
|
maximumTrackTintColor: "#333",
|
||||||
|
minimumTrackTintColor: "#9334E9",
|
||||||
|
bubbleBackgroundColor: "#9334E9",
|
||||||
|
bubbleTextColor: "#fff",
|
||||||
|
}}
|
||||||
|
progress={sliderProgress}
|
||||||
|
minimumValue={sliderMin}
|
||||||
|
maximumValue={sliderMax}
|
||||||
|
onSlidingComplete={onSliderComplete}
|
||||||
|
thumbWidth={16}
|
||||||
|
sliderHeight={6}
|
||||||
|
containerStyle={{ borderRadius: 10 }}
|
||||||
|
renderBubble={() => null}
|
||||||
|
/>
|
||||||
|
<View className='flex flex-row justify-between px-1 mt-2'>
|
||||||
|
<Text className='text-neutral-500 text-xs'>{progressText}</Text>
|
||||||
|
<Text className='text-neutral-500 text-xs'>{durationText}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Main Controls */}
|
||||||
|
<View className='flex flex-row items-center justify-center mb-2'>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onPrevious}
|
||||||
|
disabled={!canGoPrevious || isLoading}
|
||||||
|
className='p-4'
|
||||||
|
style={{ opacity: canGoPrevious && !isLoading ? 1 : 0.3 }}
|
||||||
|
>
|
||||||
|
<Ionicons name='play-skip-back' size={32} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onTogglePlayPause}
|
||||||
|
disabled={isLoading}
|
||||||
|
className='mx-8 bg-white rounded-full p-4'
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<ActivityIndicator size={36} color='#121212' />
|
||||||
|
) : (
|
||||||
|
<Ionicons
|
||||||
|
name={isPlaying ? "pause" : "play"}
|
||||||
|
size={36}
|
||||||
|
color='#121212'
|
||||||
|
style={isPlaying ? {} : { marginLeft: 4 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onNext}
|
||||||
|
disabled={!canGoNext || isLoading}
|
||||||
|
className='p-4'
|
||||||
|
style={{ opacity: canGoNext && !isLoading ? 1 : 0.3 }}
|
||||||
|
>
|
||||||
|
<Ionicons name='play-skip-forward' size={32} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Shuffle & Repeat Controls */}
|
||||||
|
<View className='flex flex-row items-center justify-center mb-2'>
|
||||||
|
<TouchableOpacity onPress={onToggleShuffle} className='p-3 mx-4'>
|
||||||
|
<Ionicons
|
||||||
|
name='shuffle'
|
||||||
|
size={24}
|
||||||
|
color={shuffleEnabled ? "#9334E9" : "#666"}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity onPress={onCycleRepeat} className='p-3 mx-4 relative'>
|
||||||
|
<Ionicons
|
||||||
|
name={getRepeatIcon() as any}
|
||||||
|
size={24}
|
||||||
|
color={repeatMode !== "off" ? "#9334E9" : "#666"}
|
||||||
|
/>
|
||||||
|
{repeatMode === "one" && (
|
||||||
|
<View className='absolute right-0 bg-purple-600 rounded-full w-4 h-4 items-center justify-center'>
|
||||||
|
<Text className='text-white text-[10px] font-bold'>1</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Queue info */}
|
||||||
|
{queue.length > 1 && (
|
||||||
|
<View className='items-center mb-4'>
|
||||||
|
<Text className='text-neutral-500 text-sm'>
|
||||||
|
{queueIndex + 1} of {queue.length}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface QueueViewProps {
|
||||||
|
api: any;
|
||||||
|
queue: BaseItemDto[];
|
||||||
|
queueIndex: number;
|
||||||
|
onJumpToIndex: (index: number) => void;
|
||||||
|
onRemoveFromQueue: (index: number) => void;
|
||||||
|
onReorderQueue: (newQueue: BaseItemDto[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QueueView: React.FC<QueueViewProps> = ({
|
||||||
|
api,
|
||||||
|
queue,
|
||||||
|
queueIndex,
|
||||||
|
onJumpToIndex,
|
||||||
|
onRemoveFromQueue,
|
||||||
|
onReorderQueue,
|
||||||
|
}) => {
|
||||||
|
const renderQueueItem = useCallback(
|
||||||
|
({ item, drag, isActive, getIndex }: RenderItemParams<BaseItemDto>) => {
|
||||||
|
const index = getIndex() ?? 0;
|
||||||
|
const isCurrentTrack = index === queueIndex;
|
||||||
|
const isPast = index < queueIndex;
|
||||||
|
|
||||||
|
const albumId = item.AlbumId || item.ParentId;
|
||||||
|
const imageUrl = api
|
||||||
|
? albumId
|
||||||
|
? `${api.basePath}/Items/${albumId}/Images/Primary?maxHeight=80&maxWidth=80`
|
||||||
|
: `${api.basePath}/Items/${item.Id}/Images/Primary?maxHeight=80&maxWidth=80`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScaleDecorator>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => onJumpToIndex(index)}
|
||||||
|
onLongPress={drag}
|
||||||
|
disabled={isActive}
|
||||||
|
className='flex-row items-center px-4 py-3'
|
||||||
|
style={{
|
||||||
|
opacity: isPast && !isActive ? 0.5 : 1,
|
||||||
|
backgroundColor: isActive
|
||||||
|
? "#2a2a2a"
|
||||||
|
: isCurrentTrack
|
||||||
|
? "rgba(147, 52, 233, 0.3)"
|
||||||
|
: "#121212",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Drag handle */}
|
||||||
|
<TouchableOpacity
|
||||||
|
onPressIn={drag}
|
||||||
|
disabled={isActive}
|
||||||
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||||
|
className='pr-2'
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name='reorder-three'
|
||||||
|
size={20}
|
||||||
|
color={isActive ? "#9334E9" : "#666"}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* Album art */}
|
||||||
|
<View className='w-12 h-12 rounded overflow-hidden bg-neutral-800 mr-3'>
|
||||||
|
{imageUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: imageUrl }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
cachePolicy='memory-disk'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View className='flex-1 items-center justify-center'>
|
||||||
|
<Ionicons name='musical-note' size={16} color='#666' />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Track info */}
|
||||||
|
<View className='flex-1 mr-2'>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
className={`text-base ${isCurrentTrack ? "text-purple-400 font-semibold" : "text-white"}`}
|
||||||
|
>
|
||||||
|
{item.Name}
|
||||||
|
</Text>
|
||||||
|
<Text numberOfLines={1} className='text-neutral-500 text-sm'>
|
||||||
|
{item.Artists?.join(", ") || item.AlbumArtist}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Now playing indicator */}
|
||||||
|
{isCurrentTrack && (
|
||||||
|
<Ionicons name='musical-note' size={16} color='#9334E9' />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Remove button (not for current track) */}
|
||||||
|
{!isCurrentTrack && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => onRemoveFromQueue(index)}
|
||||||
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||||
|
className='p-2'
|
||||||
|
>
|
||||||
|
<Ionicons name='close' size={20} color='#666' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</ScaleDecorator>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[api, queueIndex, onJumpToIndex, onRemoveFromQueue],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback(
|
||||||
|
({ data }: { data: BaseItemDto[] }) => {
|
||||||
|
onReorderQueue(data);
|
||||||
|
},
|
||||||
|
[onReorderQueue],
|
||||||
|
);
|
||||||
|
|
||||||
|
const history = queue.slice(0, queueIndex);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DraggableFlatList
|
||||||
|
data={queue}
|
||||||
|
keyExtractor={(item, index) => `${item.Id}-${index}`}
|
||||||
|
renderItem={renderQueueItem}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
ListHeaderComponent={
|
||||||
|
<View className='px-4 py-2'>
|
||||||
|
<Text className='text-neutral-400 text-xs uppercase tracking-wider'>
|
||||||
|
{history.length > 0 ? "Playing from queue" : "Up next"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View className='flex-1 items-center justify-center py-20'>
|
||||||
|
<Text className='text-neutral-500'>Queue is empty</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,7 +1,33 @@
|
|||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { AppState } from "react-native";
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
|
|
||||||
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
|
const { settings } = useSettings();
|
||||||
|
const { lockOrientation, unlockOrientation } = useOrientation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (settings?.defaultVideoOrientation) {
|
||||||
|
lockOrientation(settings.defaultVideoOrientation);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-apply orientation lock when app returns to foreground (iOS resets it)
|
||||||
|
const subscription = AppState.addEventListener("change", (nextAppState) => {
|
||||||
|
if (nextAppState === "active" && settings?.defaultVideoOrientation) {
|
||||||
|
lockOrientation(settings.defaultVideoOrientation);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
subscription.remove();
|
||||||
|
unlockOrientation();
|
||||||
|
};
|
||||||
|
}, [settings?.defaultVideoOrientation, lockOrientation, unlockOrientation]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SystemBars hidden />
|
<SystemBars hidden />
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
305
app/_layout.tsx
305
app/_layout.tsx
@@ -1,18 +1,27 @@
|
|||||||
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 { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
|
||||||
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
|
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||||
|
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 { MusicPlayerProvider } from "@/providers/MusicPlayerProvider";
|
||||||
|
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 +35,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 +125,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,21 +190,26 @@ export default function RootLayout() {
|
|||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
staleTime: 0,
|
staleTime: 30000, // 30 seconds - data is fresh
|
||||||
refetchOnMount: true,
|
gcTime: 1000 * 60 * 60 * 24, // 24 hours - keep in cache for persistence
|
||||||
refetchOnReconnect: true,
|
|
||||||
refetchOnWindowFocus: true,
|
|
||||||
retryOnMount: true,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Create MMKV-based persister for offline support
|
||||||
|
const mmkvPersister = createSyncStoragePersister({
|
||||||
|
storage: {
|
||||||
|
getItem: (key) => storage.getString(key) ?? null,
|
||||||
|
setItem: (key, value) => storage.set(key, value),
|
||||||
|
removeItem: (key) => storage.remove(key),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
function Layout() {
|
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 +238,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 +269,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,120 +347,93 @@ 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}>
|
<PersistQueryClientProvider
|
||||||
|
client={queryClient}
|
||||||
|
persistOptions={{
|
||||||
|
persister: mmkvPersister,
|
||||||
|
maxAge: 1000 * 60 * 60 * 24, // 24 hours max cache age
|
||||||
|
dehydrateOptions: {
|
||||||
|
shouldDehydrateQuery: (query) => {
|
||||||
|
// Only persist successful queries
|
||||||
|
return query.state.status === "success";
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
<JellyfinProvider>
|
<JellyfinProvider>
|
||||||
<PlaySettingsProvider>
|
<NetworkStatusProvider>
|
||||||
<LogProvider>
|
<PlaySettingsProvider>
|
||||||
<WebSocketProvider>
|
<LogProvider>
|
||||||
<DownloadProvider>
|
<WebSocketProvider>
|
||||||
<BottomSheetModalProvider>
|
<DownloadProvider>
|
||||||
<SystemBars style='light' hidden={false} />
|
<MusicPlayerProvider>
|
||||||
<ThemeProvider value={DarkTheme}>
|
<GlobalModalProvider>
|
||||||
<Stack initialRouteName='(auth)/(tabs)'>
|
<BottomSheetModalProvider>
|
||||||
<Stack.Screen
|
<ThemeProvider value={DarkTheme}>
|
||||||
name='(auth)/(tabs)'
|
<SystemBars style='light' hidden={false} />
|
||||||
options={{
|
<Stack initialRouteName='(auth)/(tabs)'>
|
||||||
headerShown: false,
|
<Stack.Screen
|
||||||
title: "",
|
name='(auth)/(tabs)'
|
||||||
header: () => null,
|
options={{
|
||||||
}}
|
headerShown: false,
|
||||||
/>
|
title: "",
|
||||||
<Stack.Screen
|
header: () => null,
|
||||||
name='(auth)/player'
|
}}
|
||||||
options={{
|
/>
|
||||||
headerShown: false,
|
<Stack.Screen
|
||||||
title: "",
|
name='(auth)/player'
|
||||||
header: () => null,
|
options={{
|
||||||
}}
|
headerShown: false,
|
||||||
/>
|
title: "",
|
||||||
<Stack.Screen
|
header: () => null,
|
||||||
name='login'
|
}}
|
||||||
options={{
|
/>
|
||||||
headerShown: true,
|
<Stack.Screen
|
||||||
title: "",
|
name='(auth)/now-playing'
|
||||||
headerTransparent: true,
|
options={{
|
||||||
}}
|
headerShown: false,
|
||||||
/>
|
presentation: "modal",
|
||||||
<Stack.Screen name='+not-found' />
|
gestureEnabled: true,
|
||||||
</Stack>
|
}}
|
||||||
<Toaster
|
/>
|
||||||
duration={4000}
|
<Stack.Screen
|
||||||
toastOptions={{
|
name='login'
|
||||||
style: {
|
options={{
|
||||||
backgroundColor: "#262626",
|
headerShown: true,
|
||||||
borderColor: "#363639",
|
title: "",
|
||||||
borderWidth: 1,
|
headerTransparent: Platform.OS === "ios",
|
||||||
},
|
}}
|
||||||
titleStyle: {
|
/>
|
||||||
color: "white",
|
<Stack.Screen name='+not-found' />
|
||||||
},
|
</Stack>
|
||||||
}}
|
<Toaster
|
||||||
closeButton
|
duration={4000}
|
||||||
/>
|
toastOptions={{
|
||||||
</ThemeProvider>
|
style: {
|
||||||
</BottomSheetModalProvider>
|
backgroundColor: "#262626",
|
||||||
</DownloadProvider>
|
borderColor: "#363639",
|
||||||
</WebSocketProvider>
|
borderWidth: 1,
|
||||||
</LogProvider>
|
},
|
||||||
</PlaySettingsProvider>
|
titleStyle: {
|
||||||
|
color: "white",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
closeButton
|
||||||
|
/>
|
||||||
|
<GlobalModal />
|
||||||
|
</ThemeProvider>
|
||||||
|
</BottomSheetModalProvider>
|
||||||
|
</GlobalModalProvider>
|
||||||
|
</MusicPlayerProvider>
|
||||||
|
</DownloadProvider>
|
||||||
|
</WebSocketProvider>
|
||||||
|
</LogProvider>
|
||||||
|
</PlaySettingsProvider>
|
||||||
|
</NetworkStatusProvider>
|
||||||
</JellyfinProvider>
|
</JellyfinProvider>
|
||||||
</QueryClientProvider>
|
</PersistQueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -262,8 +262,14 @@ const Login: React.FC = () => {
|
|||||||
<Input
|
<Input
|
||||||
placeholder={t("login.username_placeholder")}
|
placeholder={t("login.username_placeholder")}
|
||||||
onChangeText={(text: string) =>
|
onChangeText={(text: string) =>
|
||||||
setCredentials({ ...credentials, username: text })
|
setCredentials((prev) => ({ ...prev, username: text }))
|
||||||
}
|
}
|
||||||
|
onEndEditing={(e) => {
|
||||||
|
const newValue = e.nativeEvent.text;
|
||||||
|
if (newValue && newValue !== credentials.username) {
|
||||||
|
setCredentials((prev) => ({ ...prev, username: newValue }));
|
||||||
|
}
|
||||||
|
}}
|
||||||
value={credentials.username}
|
value={credentials.username}
|
||||||
keyboardType='default'
|
keyboardType='default'
|
||||||
returnKeyType='done'
|
returnKeyType='done'
|
||||||
@@ -272,14 +278,22 @@ 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 */}
|
||||||
<Input
|
<Input
|
||||||
placeholder={t("login.password_placeholder")}
|
placeholder={t("login.password_placeholder")}
|
||||||
onChangeText={(text: string) =>
|
onChangeText={(text: string) =>
|
||||||
setCredentials({ ...credentials, password: text })
|
setCredentials((prev) => ({ ...prev, password: text }))
|
||||||
}
|
}
|
||||||
|
onEndEditing={(e) => {
|
||||||
|
const newValue = e.nativeEvent.text;
|
||||||
|
if (newValue && newValue !== credentials.password) {
|
||||||
|
setCredentials((prev) => ({ ...prev, 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,11 +394,12 @@ 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 justify-center'>
|
||||||
<View className='px-4 -mt-20 w-full'>
|
<View className='px-4 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'>
|
||||||
{serverName ? (
|
{serverName ? (
|
||||||
@@ -391,8 +415,17 @@ const Login: React.FC = () => {
|
|||||||
<Input
|
<Input
|
||||||
placeholder={t("login.username_placeholder")}
|
placeholder={t("login.username_placeholder")}
|
||||||
onChangeText={(text) =>
|
onChangeText={(text) =>
|
||||||
setCredentials({ ...credentials, username: text })
|
setCredentials((prev) => ({ ...prev, username: text }))
|
||||||
}
|
}
|
||||||
|
onEndEditing={(e) => {
|
||||||
|
const newValue = e.nativeEvent.text;
|
||||||
|
if (newValue && newValue !== credentials.username) {
|
||||||
|
setCredentials((prev) => ({
|
||||||
|
...prev,
|
||||||
|
username: newValue,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}}
|
||||||
value={credentials.username}
|
value={credentials.username}
|
||||||
keyboardType='default'
|
keyboardType='default'
|
||||||
returnKeyType='done'
|
returnKeyType='done'
|
||||||
@@ -407,8 +440,17 @@ const Login: React.FC = () => {
|
|||||||
<Input
|
<Input
|
||||||
placeholder={t("login.password_placeholder")}
|
placeholder={t("login.password_placeholder")}
|
||||||
onChangeText={(text) =>
|
onChangeText={(text) =>
|
||||||
setCredentials({ ...credentials, password: text })
|
setCredentials((prev) => ({ ...prev, password: text }))
|
||||||
}
|
}
|
||||||
|
onEndEditing={(e) => {
|
||||||
|
const newValue = e.nativeEvent.text;
|
||||||
|
if (newValue && newValue !== credentials.password) {
|
||||||
|
setCredentials((prev) => ({
|
||||||
|
...prev,
|
||||||
|
password: newValue,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}}
|
||||||
value={credentials.password}
|
value={credentials.password}
|
||||||
secureTextEntry
|
secureTextEntry
|
||||||
keyboardType='default'
|
keyboardType='default'
|
||||||
@@ -422,6 +464,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 +486,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_with_Obtainium.png
Normal file
BIN
assets/Download_with_Obtainium.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 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.7/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.3.5/schema.json",
|
||||||
"files": {
|
"files": {
|
||||||
"includes": [
|
"includes": [
|
||||||
"**/*",
|
"**/*",
|
||||||
@@ -8,7 +8,8 @@
|
|||||||
"!android",
|
"!android",
|
||||||
"!Streamyfin.app",
|
"!Streamyfin.app",
|
||||||
"!utils/jellyseerr",
|
"!utils/jellyseerr",
|
||||||
"!.expo"
|
"!.expo",
|
||||||
|
"!docs/jellyfin-openapi-stable.json"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"linter": {
|
"linter": {
|
||||||
|
|||||||
@@ -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,12 @@ 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}
|
color={isFavorite ? "purple" : "white"}
|
||||||
onPress={toggleFavorite}
|
onPress={toggleFavorite}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
43
components/AddToWatchlist.tsx
Normal file
43
components/AddToWatchlist.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import type { FC } from "react";
|
||||||
|
import { useCallback, useRef } from "react";
|
||||||
|
import { View, type ViewProps } from "react-native";
|
||||||
|
import { RoundButton } from "@/components/RoundButton";
|
||||||
|
import {
|
||||||
|
WatchlistSheet,
|
||||||
|
type WatchlistSheetRef,
|
||||||
|
} from "@/components/watchlists/WatchlistSheet";
|
||||||
|
import {
|
||||||
|
useItemInWatchlists,
|
||||||
|
useStreamystatsEnabled,
|
||||||
|
} from "@/hooks/useWatchlists";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {
|
||||||
|
item: BaseItemDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AddToWatchlist: FC<Props> = ({ item, ...props }) => {
|
||||||
|
const streamystatsEnabled = useStreamystatsEnabled();
|
||||||
|
const sheetRef = useRef<WatchlistSheetRef>(null);
|
||||||
|
|
||||||
|
const { data: watchlistsContainingItem } = useItemInWatchlists(item.Id);
|
||||||
|
const isInAnyWatchlist = (watchlistsContainingItem?.length ?? 0) > 0;
|
||||||
|
|
||||||
|
const handlePress = useCallback(() => {
|
||||||
|
sheetRef.current?.open(item);
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
// Don't render if Streamystats is not enabled
|
||||||
|
if (!streamystatsEnabled) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View {...props}>
|
||||||
|
<RoundButton
|
||||||
|
size='large'
|
||||||
|
icon={isInAnyWatchlist ? "list" : "list-outline"}
|
||||||
|
onPress={handlePress}
|
||||||
|
/>
|
||||||
|
<WatchlistSheet ref={sheetRef} />
|
||||||
|
</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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type React from "react";
|
|||||||
import {
|
import {
|
||||||
type PropsWithChildren,
|
type PropsWithChildren,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
useMemo,
|
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
@@ -18,6 +17,58 @@ import {
|
|||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { Loader } from "./Loader";
|
import { Loader } from "./Loader";
|
||||||
|
|
||||||
|
const getColorClasses = (
|
||||||
|
color: "purple" | "red" | "black" | "transparent" | "white",
|
||||||
|
variant: "solid" | "border",
|
||||||
|
focused: boolean,
|
||||||
|
): string => {
|
||||||
|
if (variant === "border") {
|
||||||
|
switch (color) {
|
||||||
|
case "purple":
|
||||||
|
return focused
|
||||||
|
? "bg-transparent border-2 border-purple-400"
|
||||||
|
: "bg-transparent border-2 border-purple-600";
|
||||||
|
case "red":
|
||||||
|
return focused
|
||||||
|
? "bg-transparent border-2 border-red-400"
|
||||||
|
: "bg-transparent border-2 border-red-600";
|
||||||
|
case "black":
|
||||||
|
return focused
|
||||||
|
? "bg-transparent border-2 border-neutral-700"
|
||||||
|
: "bg-transparent border-2 border-neutral-900";
|
||||||
|
case "white":
|
||||||
|
return focused
|
||||||
|
? "bg-transparent border-2 border-gray-100"
|
||||||
|
: "bg-transparent border-2 border-white";
|
||||||
|
case "transparent":
|
||||||
|
return focused
|
||||||
|
? "bg-transparent border-2 border-gray-400"
|
||||||
|
: "bg-transparent border-2 border-gray-600";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
switch (color) {
|
||||||
|
case "purple":
|
||||||
|
return focused
|
||||||
|
? "bg-purple-500 border-2 border-white"
|
||||||
|
: "bg-purple-600 border border-purple-700";
|
||||||
|
case "red":
|
||||||
|
return "bg-red-600";
|
||||||
|
case "black":
|
||||||
|
return "bg-neutral-900";
|
||||||
|
case "white":
|
||||||
|
return focused
|
||||||
|
? "bg-gray-100 border-2 border-gray-300"
|
||||||
|
: "bg-white border border-gray-200";
|
||||||
|
case "transparent":
|
||||||
|
return "bg-transparent";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export interface ButtonProps
|
export interface ButtonProps
|
||||||
extends React.ComponentProps<typeof TouchableOpacity> {
|
extends React.ComponentProps<typeof TouchableOpacity> {
|
||||||
onPress?: () => void;
|
onPress?: () => void;
|
||||||
@@ -26,7 +77,8 @@ export interface ButtonProps
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
children?: string | ReactNode;
|
children?: string | ReactNode;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
color?: "purple" | "red" | "black" | "transparent";
|
color?: "purple" | "red" | "black" | "transparent" | "white";
|
||||||
|
variant?: "solid" | "border";
|
||||||
iconRight?: ReactNode;
|
iconRight?: ReactNode;
|
||||||
iconLeft?: ReactNode;
|
iconLeft?: ReactNode;
|
||||||
justify?: "center" | "between";
|
justify?: "center" | "between";
|
||||||
@@ -39,6 +91,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
loading = false,
|
loading = false,
|
||||||
color = "purple",
|
color = "purple",
|
||||||
|
variant = "solid",
|
||||||
iconRight,
|
iconRight,
|
||||||
iconLeft,
|
iconLeft,
|
||||||
children,
|
children,
|
||||||
@@ -56,23 +109,13 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
|||||||
useNativeDriver: true,
|
useNativeDriver: true,
|
||||||
}).start();
|
}).start();
|
||||||
|
|
||||||
const colorClasses = useMemo(() => {
|
const colorClasses = getColorClasses(color, variant, focused);
|
||||||
switch (color) {
|
|
||||||
case "purple":
|
|
||||||
return focused
|
|
||||||
? "bg-purple-500 border-2 border-white"
|
|
||||||
: "bg-purple-600 border border-purple-700";
|
|
||||||
case "red":
|
|
||||||
return "bg-red-600";
|
|
||||||
case "black":
|
|
||||||
return "bg-neutral-900";
|
|
||||||
case "transparent":
|
|
||||||
return "bg-transparent";
|
|
||||||
}
|
|
||||||
}, [color, focused]);
|
|
||||||
|
|
||||||
const lightHapticFeedback = useHaptic("light");
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
|
const textColorClass =
|
||||||
|
color === "white" && variant === "solid" ? "text-black" : "text-white";
|
||||||
|
|
||||||
return Platform.isTV ? (
|
return Platform.isTV ? (
|
||||||
<Pressable
|
<Pressable
|
||||||
className='w-full'
|
className='w-full'
|
||||||
@@ -98,10 +141,12 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
|||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
className={`rounded-2xl py-5 items-center justify-center
|
className={`rounded-2xl py-5 items-center justify-center
|
||||||
${focused ? "bg-purple-500 border-2 border-white" : "bg-purple-600 border border-purple-700"}
|
${colorClasses}
|
||||||
${className}`}
|
${className}`}
|
||||||
>
|
>
|
||||||
<Text className='text-white text-xl font-bold'>{children}</Text>
|
<Text className={`${textColorClass} text-xl font-bold`}>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -135,7 +180,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
|||||||
{iconLeft ? iconLeft : <View className='w-4' />}
|
{iconLeft ? iconLeft : <View className='w-4' />}
|
||||||
<Text
|
<Text
|
||||||
className={`
|
className={`
|
||||||
text-white font-bold text-base
|
${textColorClass} font-bold text-base
|
||||||
${disabled ? "text-gray-300" : ""}
|
${disabled ? "text-gray-300" : ""}
|
||||||
${textClassName}
|
${textClassName}
|
||||||
${iconRight ? "mr-2" : ""}
|
${iconRight ? "mr-2" : ""}
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
@@ -113,7 +109,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedOptions(() => ({
|
setSelectedOptions(() => ({
|
||||||
bitrate: defaultBitrate,
|
bitrate: defaultBitrate,
|
||||||
mediaSource: defaultMediaSource,
|
mediaSource: defaultMediaSource ?? undefined,
|
||||||
subtitleIndex: defaultSubtitleIndex ?? -1,
|
subtitleIndex: defaultSubtitleIndex ?? -1,
|
||||||
audioIndex: defaultAudioIndex,
|
audioIndex: defaultAudioIndex,
|
||||||
}));
|
}));
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
79
components/GlobalModal.tsx
Normal file
79
components/GlobalModal.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import {
|
||||||
|
BottomSheetBackdrop,
|
||||||
|
type BottomSheetBackdropProps,
|
||||||
|
BottomSheetModal,
|
||||||
|
} from "@gorhom/bottom-sheet";
|
||||||
|
import { useCallback, useEffect } 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, isVisible } = useGlobalModal();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isVisible && modalState.content) {
|
||||||
|
modalRef.current?.present();
|
||||||
|
}
|
||||||
|
}, [isVisible, modalState.content, modalRef]);
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -6,19 +6,19 @@ import { Image } from "expo-image";
|
|||||||
import { useNavigation } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { type Bitrate } from "@/components/BitrateSelector";
|
import { type Bitrate } from "@/components/BitrateSelector";
|
||||||
import { ItemImage } from "@/components/common/ItemImage";
|
import { ItemImage } from "@/components/common/ItemImage";
|
||||||
import { DownloadSingleItem } from "@/components/DownloadItem";
|
import { DownloadSingleItem } from "@/components/DownloadItem";
|
||||||
|
import { ItemPeopleSections } from "@/components/item/ItemPeopleSections";
|
||||||
|
import { MediaSourceButton } from "@/components/MediaSourceButton";
|
||||||
import { OverviewText } from "@/components/OverviewText";
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
// const PlayButton = !Platform.isTV ? require("@/components/PlayButton") : null;
|
// const PlayButton = !Platform.isTV ? require("@/components/PlayButton") : null;
|
||||||
import { PlayButton } from "@/components/PlayButton";
|
import { PlayButton } from "@/components/PlayButton";
|
||||||
import { PlayedStatus } from "@/components/PlayedStatus";
|
import { PlayedStatus } from "@/components/PlayedStatus";
|
||||||
import { SimilarItems } from "@/components/SimilarItems";
|
import { SimilarItems } from "@/components/SimilarItems";
|
||||||
import { CastAndCrew } from "@/components/series/CastAndCrew";
|
|
||||||
import { CurrentSeries } from "@/components/series/CurrentSeries";
|
import { CurrentSeries } from "@/components/series/CurrentSeries";
|
||||||
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
|
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
|
||||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||||
@@ -29,13 +29,10 @@ 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 { AddToFavorites } from "./AddToFavorites";
|
import { AddToFavorites } from "./AddToFavorites";
|
||||||
import { BitrateSheet } from "./BitRateSheet";
|
import { AddToWatchlist } from "./AddToWatchlist";
|
||||||
import { ItemHeader } from "./ItemHeader";
|
import { ItemHeader } from "./ItemHeader";
|
||||||
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
||||||
import { MediaSourceSheet } from "./MediaSourceSheet";
|
|
||||||
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
|
||||||
import { PlayInRemoteSessionButton } from "./PlayInRemoteSession";
|
import { PlayInRemoteSessionButton } from "./PlayInRemoteSession";
|
||||||
import { TrackSheet } from "./TrackSheet";
|
|
||||||
|
|
||||||
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
|
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
|
||||||
|
|
||||||
@@ -49,17 +46,17 @@ export type SelectedOptions = {
|
|||||||
interface ItemContentProps {
|
interface ItemContentProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
isOffline: boolean;
|
isOffline: boolean;
|
||||||
|
itemWithSources?: BaseItemDto | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||||
({ item, isOffline }) => {
|
({ item, isOffline, itemWithSources }) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const { orientation } = useOrientation();
|
const { orientation } = useOrientation();
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const itemColors = useImageColorsReturn({ item });
|
const itemColors = useImageColorsReturn({ item });
|
||||||
|
|
||||||
@@ -70,18 +67,23 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
SelectedOptions | undefined
|
SelectedOptions | undefined
|
||||||
>(undefined);
|
>(undefined);
|
||||||
|
|
||||||
|
// Use itemWithSources for play settings since it has MediaSources data
|
||||||
const {
|
const {
|
||||||
defaultAudioIndex,
|
defaultAudioIndex,
|
||||||
defaultBitrate,
|
defaultBitrate,
|
||||||
defaultMediaSource,
|
defaultMediaSource,
|
||||||
defaultSubtitleIndex,
|
defaultSubtitleIndex,
|
||||||
} = useDefaultPlaySettings(item!, settings);
|
} = useDefaultPlaySettings(itemWithSources ?? item, settings);
|
||||||
|
|
||||||
const logoUrl = useMemo(
|
const logoUrl = useMemo(
|
||||||
() => (item ? getLogoImageUrlById({ api, item }) : null),
|
() => (item ? getLogoImageUrlById({ api, item }) : null),
|
||||||
[api, item],
|
[api, item],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const onLogoLoad = React.useCallback(() => {
|
||||||
|
setLoadingLogo(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const loading = useMemo(() => {
|
const loading = useMemo(() => {
|
||||||
return Boolean(logoUrl && loadingLogo);
|
return Boolean(logoUrl && loadingLogo);
|
||||||
}, [loadingLogo, logoUrl]);
|
}, [loadingLogo, logoUrl]);
|
||||||
@@ -90,7 +92,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedOptions(() => ({
|
setSelectedOptions(() => ({
|
||||||
bitrate: defaultBitrate,
|
bitrate: defaultBitrate,
|
||||||
mediaSource: defaultMediaSource,
|
mediaSource: defaultMediaSource ?? undefined,
|
||||||
subtitleIndex: defaultSubtitleIndex ?? -1,
|
subtitleIndex: defaultSubtitleIndex ?? -1,
|
||||||
audioIndex: defaultAudioIndex,
|
audioIndex: defaultAudioIndex,
|
||||||
}));
|
}));
|
||||||
@@ -102,7 +104,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!Platform.isTV) {
|
if (!Platform.isTV && itemWithSources) {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () =>
|
headerRight: () =>
|
||||||
item &&
|
item &&
|
||||||
@@ -112,14 +114,19 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
{item.Type !== "Program" && (
|
{item.Type !== "Program" && (
|
||||||
<View className='flex flex-row items-center'>
|
<View className='flex flex-row items-center'>
|
||||||
{!Platform.isTV && (
|
{!Platform.isTV && (
|
||||||
<DownloadSingleItem item={item} size='large' />
|
<DownloadSingleItem item={itemWithSources} size='large' />
|
||||||
)}
|
|
||||||
{user?.Policy?.IsAdministrator && (
|
|
||||||
<PlayInRemoteSessionButton item={item} size='large' />
|
|
||||||
)}
|
)}
|
||||||
|
{user?.Policy?.IsAdministrator &&
|
||||||
|
!settings.hideRemoteSessionButton && (
|
||||||
|
<PlayInRemoteSessionButton item={item} size='large' />
|
||||||
|
)}
|
||||||
|
|
||||||
<PlayedStatus items={[item]} size='large' />
|
<PlayedStatus items={[item]} size='large' />
|
||||||
<AddToFavorites item={item} />
|
<AddToFavorites item={item} />
|
||||||
|
{settings.streamyStatsServerUrl &&
|
||||||
|
!settings.hideWatchlistsTab && (
|
||||||
|
<AddToWatchlist item={item} />
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -129,21 +136,34 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
{item.Type !== "Program" && (
|
{item.Type !== "Program" && (
|
||||||
<View className='flex flex-row items-center space-x-2'>
|
<View className='flex flex-row items-center space-x-2'>
|
||||||
{!Platform.isTV && (
|
{!Platform.isTV && (
|
||||||
<DownloadSingleItem item={item} size='large' />
|
<DownloadSingleItem item={itemWithSources} size='large' />
|
||||||
)}
|
|
||||||
{user?.Policy?.IsAdministrator && (
|
|
||||||
<PlayInRemoteSessionButton item={item} size='large' />
|
|
||||||
)}
|
)}
|
||||||
|
{user?.Policy?.IsAdministrator &&
|
||||||
|
!settings.hideRemoteSessionButton && (
|
||||||
|
<PlayInRemoteSessionButton item={item} size='large' />
|
||||||
|
)}
|
||||||
|
|
||||||
<PlayedStatus items={[item]} size='large' />
|
<PlayedStatus items={[item]} size='large' />
|
||||||
<AddToFavorites item={item} />
|
<AddToFavorites item={item} />
|
||||||
|
{settings.streamyStatsServerUrl &&
|
||||||
|
!settings.hideWatchlistsTab && (
|
||||||
|
<AddToWatchlist item={item} />
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
)),
|
)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [item, navigation, user]);
|
}, [
|
||||||
|
item,
|
||||||
|
navigation,
|
||||||
|
user,
|
||||||
|
itemWithSources,
|
||||||
|
settings.hideRemoteSessionButton,
|
||||||
|
settings.streamyStatsServerUrl,
|
||||||
|
settings.hideWatchlistsTab,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (item) {
|
if (item) {
|
||||||
@@ -165,7 +185,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ParallaxScrollView
|
<ParallaxScrollView
|
||||||
className={`flex-1 ${loading ? "opacity-0" : "opacity-100"}`}
|
className='flex-1'
|
||||||
headerHeight={headerHeight}
|
headerHeight={headerHeight}
|
||||||
headerImage={
|
headerImage={
|
||||||
<View style={[{ flex: 1 }]}>
|
<View style={[{ flex: 1 }]}>
|
||||||
@@ -192,8 +212,8 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
width: "100%",
|
width: "100%",
|
||||||
}}
|
}}
|
||||||
contentFit='contain'
|
contentFit='contain'
|
||||||
onLoad={() => setLoadingLogo(false)}
|
onLoad={onLogoLoad}
|
||||||
onError={() => setLoadingLogo(false)}
|
onError={onLogoLoad}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<View />
|
<View />
|
||||||
@@ -201,76 +221,27 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<View className='flex flex-col bg-transparent shrink'>
|
<View className='flex flex-col bg-transparent shrink'>
|
||||||
<View className='flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink'>
|
<View className='flex flex-col px-4 w-full pt-2 mb-2 shrink'>
|
||||||
<ItemHeader item={item} className='mb-2' />
|
<ItemHeader item={item} className='mb-2' />
|
||||||
{item.Type !== "Program" && !Platform.isTV && !isOffline && (
|
|
||||||
<View className='flex flex-row items-center justify-start w-full h-16'>
|
|
||||||
<BitrateSheet
|
|
||||||
className='mr-1'
|
|
||||||
onChange={(val) =>
|
|
||||||
setSelectedOptions(
|
|
||||||
(prev) => prev && { ...prev, bitrate: val },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
selected={selectedOptions.bitrate}
|
|
||||||
/>
|
|
||||||
<MediaSourceSheet
|
|
||||||
className='mr-1'
|
|
||||||
item={item}
|
|
||||||
onChange={(val) =>
|
|
||||||
setSelectedOptions(
|
|
||||||
(prev) =>
|
|
||||||
prev && {
|
|
||||||
...prev,
|
|
||||||
mediaSource: val,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
selected={selectedOptions.mediaSource}
|
|
||||||
/>
|
|
||||||
<TrackSheet
|
|
||||||
className='mr-1'
|
|
||||||
streamType='Audio'
|
|
||||||
title={t("item_card.audio")}
|
|
||||||
source={selectedOptions.mediaSource}
|
|
||||||
onChange={(val) => {
|
|
||||||
setSelectedOptions(
|
|
||||||
(prev) =>
|
|
||||||
prev && {
|
|
||||||
...prev,
|
|
||||||
audioIndex: val,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
selected={selectedOptions.audioIndex}
|
|
||||||
/>
|
|
||||||
<TrackSheet
|
|
||||||
source={selectedOptions.mediaSource}
|
|
||||||
streamType='Subtitle'
|
|
||||||
title={t("item_card.subtitles")}
|
|
||||||
onChange={(val) =>
|
|
||||||
setSelectedOptions(
|
|
||||||
(prev) =>
|
|
||||||
prev && {
|
|
||||||
...prev,
|
|
||||||
subtitleIndex: val,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
selected={selectedOptions.subtitleIndex}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<PlayButton
|
<View className='flex flex-row px-0 mb-2 justify-between space-x-2'>
|
||||||
className='grow'
|
<PlayButton
|
||||||
selectedOptions={selectedOptions}
|
selectedOptions={selectedOptions}
|
||||||
item={item}
|
item={item}
|
||||||
isOffline={isOffline}
|
isOffline={isOffline}
|
||||||
colors={itemColors}
|
colors={itemColors}
|
||||||
/>
|
/>
|
||||||
|
<View className='w-1' />
|
||||||
|
{!isOffline && (
|
||||||
|
<MediaSourceButton
|
||||||
|
selectedOptions={selectedOptions}
|
||||||
|
setSelectedOptions={setSelectedOptions}
|
||||||
|
item={itemWithSources}
|
||||||
|
colors={itemColors}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{item.Type === "Episode" && (
|
{item.Type === "Episode" && (
|
||||||
<SeasonEpisodesCarousel
|
<SeasonEpisodesCarousel
|
||||||
item={item}
|
item={item}
|
||||||
@@ -279,33 +250,21 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isOffline && (
|
{!isOffline &&
|
||||||
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
|
selectedOptions.mediaSource?.MediaStreams &&
|
||||||
)}
|
selectedOptions.mediaSource.MediaStreams.length > 0 && (
|
||||||
|
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
|
||||||
|
)}
|
||||||
|
|
||||||
<OverviewText text={item.Overview} className='px-4 mb-4' />
|
<OverviewText text={item.Overview} className='px-4 mb-4' />
|
||||||
|
|
||||||
{item.Type !== "Program" && (
|
{item.Type !== "Program" && (
|
||||||
<>
|
<>
|
||||||
{item.Type === "Episode" && !isOffline && (
|
{item.Type === "Episode" && !isOffline && (
|
||||||
<CurrentSeries item={item} className='mb-4' />
|
<CurrentSeries item={item} className='mb-2' />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isOffline && (
|
<ItemPeopleSections item={item} isOffline={isOffline} />
|
||||||
<CastAndCrew item={item} className='mb-4' loading={loading} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{item.People && item.People.length > 0 && !isOffline && (
|
|
||||||
<View className='mb-4'>
|
|
||||||
{item.People.slice(0, 3).map((person, idx) => (
|
|
||||||
<MoreMoviesWithActor
|
|
||||||
currentItem={item}
|
|
||||||
key={idx}
|
|
||||||
actorId={person.Id!}
|
|
||||||
className='mb-4'
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isOffline && <SimilarItems itemId={item.Id} />}
|
{!isOffline && <SimilarItems itemId={item.Id} />}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -183,6 +183,12 @@ const VideoStreamInfo = ({ source }: { source?: MediaSourceInfo }) => {
|
|||||||
|
|
||||||
if (!source || !videoStream) return null;
|
if (!source || !videoStream) return null;
|
||||||
|
|
||||||
|
// Dolby Vision video check
|
||||||
|
const isDolbyVision =
|
||||||
|
videoStream.VideoRangeType === "DOVI" ||
|
||||||
|
videoStream.DvVersionMajor != null ||
|
||||||
|
videoStream.DvVersionMinor != null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='flex-row flex-wrap gap-2'>
|
<View className='flex-row flex-wrap gap-2'>
|
||||||
<Badge
|
<Badge
|
||||||
@@ -195,6 +201,15 @@ const VideoStreamInfo = ({ source }: { source?: MediaSourceInfo }) => {
|
|||||||
iconLeft={<Ionicons name='film-outline' size={16} color='white' />}
|
iconLeft={<Ionicons name='film-outline' size={16} color='white' />}
|
||||||
text={`${videoStream.Width}x${videoStream.Height}`}
|
text={`${videoStream.Width}x${videoStream.Height}`}
|
||||||
/>
|
/>
|
||||||
|
{isDolbyVision && (
|
||||||
|
<Badge
|
||||||
|
variant='gray'
|
||||||
|
iconLeft={
|
||||||
|
<Ionicons name='sparkles-outline' size={16} color='white' />
|
||||||
|
}
|
||||||
|
text={"DV"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Badge
|
<Badge
|
||||||
variant='gray'
|
variant='gray'
|
||||||
iconLeft={
|
iconLeft={
|
||||||
|
|||||||
193
components/MediaSourceButton.tsx
Normal file
193
components/MediaSourceButton.tsx
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type {
|
||||||
|
BaseItemDto,
|
||||||
|
MediaSourceInfo,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
||||||
|
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
||||||
|
import { BITRATES } from "./BitRateSheet";
|
||||||
|
import type { SelectedOptions } from "./ItemContent";
|
||||||
|
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||||
|
|
||||||
|
interface Props extends React.ComponentProps<typeof TouchableOpacity> {
|
||||||
|
item?: BaseItemDto | null;
|
||||||
|
selectedOptions: SelectedOptions;
|
||||||
|
setSelectedOptions: React.Dispatch<
|
||||||
|
React.SetStateAction<SelectedOptions | undefined>
|
||||||
|
>;
|
||||||
|
colors?: ThemeColors;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MediaSourceButton: React.FC<Props> = ({
|
||||||
|
item,
|
||||||
|
selectedOptions,
|
||||||
|
setSelectedOptions,
|
||||||
|
colors,
|
||||||
|
}: Props) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const effectiveColors = colors || {
|
||||||
|
primary: "#7c3aed",
|
||||||
|
text: "#000000",
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const firstMediaSource = item?.MediaSources?.[0];
|
||||||
|
if (!firstMediaSource) return;
|
||||||
|
setSelectedOptions((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
mediaSource: firstMediaSource,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [item, setSelectedOptions]);
|
||||||
|
|
||||||
|
const getMediaSourceDisplayName = useCallback((source: MediaSourceInfo) => {
|
||||||
|
const videoStream = source.MediaStreams?.find((x) => x.Type === "Video");
|
||||||
|
if (source.Name) return source.Name;
|
||||||
|
if (videoStream?.DisplayTitle) return videoStream.DisplayTitle;
|
||||||
|
return `Source ${source.Id}`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const audioStreams = useMemo(
|
||||||
|
() =>
|
||||||
|
selectedOptions.mediaSource?.MediaStreams?.filter(
|
||||||
|
(x) => x.Type === "Audio",
|
||||||
|
) || [],
|
||||||
|
[selectedOptions.mediaSource],
|
||||||
|
);
|
||||||
|
|
||||||
|
const subtitleStreams = useMemo(
|
||||||
|
() =>
|
||||||
|
selectedOptions.mediaSource?.MediaStreams?.filter(
|
||||||
|
(x) => x.Type === "Subtitle",
|
||||||
|
) || [],
|
||||||
|
[selectedOptions.mediaSource],
|
||||||
|
);
|
||||||
|
|
||||||
|
const optionGroups: OptionGroup[] = useMemo(() => {
|
||||||
|
const groups: OptionGroup[] = [];
|
||||||
|
|
||||||
|
// Bitrate group
|
||||||
|
groups.push({
|
||||||
|
title: t("item_card.quality"),
|
||||||
|
options: BITRATES.map((bitrate) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: bitrate.key,
|
||||||
|
value: bitrate,
|
||||||
|
selected: bitrate.value === selectedOptions.bitrate?.value,
|
||||||
|
onPress: () =>
|
||||||
|
setSelectedOptions((prev) => prev && { ...prev, bitrate }),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Media Source group (only if multiple sources)
|
||||||
|
if (item?.MediaSources && item.MediaSources.length > 1) {
|
||||||
|
groups.push({
|
||||||
|
title: t("item_card.video"),
|
||||||
|
options: item.MediaSources.map((source) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: getMediaSourceDisplayName(source),
|
||||||
|
value: source,
|
||||||
|
selected: source.Id === selectedOptions.mediaSource?.Id,
|
||||||
|
onPress: () =>
|
||||||
|
setSelectedOptions(
|
||||||
|
(prev) => prev && { ...prev, mediaSource: source },
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audio track group
|
||||||
|
if (audioStreams.length > 0) {
|
||||||
|
groups.push({
|
||||||
|
title: t("item_card.audio"),
|
||||||
|
options: audioStreams.map((stream) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: stream.DisplayTitle || `${t("common.track")} ${stream.Index}`,
|
||||||
|
value: stream.Index,
|
||||||
|
selected: stream.Index === selectedOptions.audioIndex,
|
||||||
|
onPress: () =>
|
||||||
|
setSelectedOptions(
|
||||||
|
(prev) => prev && { ...prev, audioIndex: stream.Index ?? 0 },
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subtitle track group (with None option)
|
||||||
|
if (subtitleStreams.length > 0) {
|
||||||
|
const noneOption = {
|
||||||
|
type: "radio" as const,
|
||||||
|
label: t("common.none"),
|
||||||
|
value: -1,
|
||||||
|
selected: selectedOptions.subtitleIndex === -1,
|
||||||
|
onPress: () =>
|
||||||
|
setSelectedOptions((prev) => prev && { ...prev, subtitleIndex: -1 }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const subtitleOptions = subtitleStreams.map((stream) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: stream.DisplayTitle || `${t("common.track")} ${stream.Index}`,
|
||||||
|
value: stream.Index,
|
||||||
|
selected: stream.Index === selectedOptions.subtitleIndex,
|
||||||
|
onPress: () =>
|
||||||
|
setSelectedOptions(
|
||||||
|
(prev) => prev && { ...prev, subtitleIndex: stream.Index ?? -1 },
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
groups.push({
|
||||||
|
title: t("item_card.subtitles"),
|
||||||
|
options: [noneOption, ...subtitleOptions],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}, [
|
||||||
|
item,
|
||||||
|
selectedOptions,
|
||||||
|
audioStreams,
|
||||||
|
subtitleStreams,
|
||||||
|
getMediaSourceDisplayName,
|
||||||
|
t,
|
||||||
|
setSelectedOptions,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const trigger = (
|
||||||
|
<TouchableOpacity
|
||||||
|
disabled={!item}
|
||||||
|
onPress={() => setOpen(true)}
|
||||||
|
className='relative'
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{ backgroundColor: effectiveColors.primary, opacity: 0.7 }}
|
||||||
|
className='absolute w-12 h-12 rounded-full'
|
||||||
|
/>
|
||||||
|
<View className='w-12 h-12 rounded-full z-10 items-center justify-center'>
|
||||||
|
{!item ? (
|
||||||
|
<ActivityIndicator size='small' color={effectiveColors.text} />
|
||||||
|
) : (
|
||||||
|
<Ionicons name='list' size={24} color={effectiveColors.text} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PlatformDropdown
|
||||||
|
groups={optionGroups}
|
||||||
|
trigger={trigger}
|
||||||
|
title={t("item_card.media_options")}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
bottomSheetConfig={{
|
||||||
|
enablePanDownToClose: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
|
import { useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { View, type ViewProps } from "react-native";
|
import { View, type ViewProps } from "react-native";
|
||||||
import { HorizontalScroll } from "@/components/common/HorizontalScroll";
|
import { HorizontalScroll } from "@/components/common/HorizontalScroll";
|
||||||
@@ -10,16 +11,18 @@ import { Text } from "@/components/common/Text";
|
|||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
|
import { POSTER_CAROUSEL_HEIGHT } from "@/constants/Values";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
actorId: string;
|
actorId: string;
|
||||||
|
actorName?: string | null;
|
||||||
currentItem: BaseItemDto;
|
currentItem: BaseItemDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MoreMoviesWithActor: React.FC<Props> = ({
|
export const MoreMoviesWithActor: React.FC<Props> = ({
|
||||||
actorId,
|
actorId,
|
||||||
|
actorName,
|
||||||
currentItem,
|
currentItem,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
@@ -27,19 +30,6 @@ export const MoreMoviesWithActor: React.FC<Props> = ({
|
|||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { data: actor } = useQuery({
|
|
||||||
queryKey: ["actor", actorId],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api || !user?.Id) return null;
|
|
||||||
return await getUserItemData({
|
|
||||||
api,
|
|
||||||
userId: user.Id,
|
|
||||||
itemId: actorId,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
enabled: !!api && !!user?.Id && !!actorId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: items, isLoading } = useQuery({
|
const { data: items, isLoading } = useQuery({
|
||||||
queryKey: ["actor", "movies", actorId, currentItem.Id],
|
queryKey: ["actor", "movies", actorId, currentItem.Id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -72,29 +62,34 @@ export const MoreMoviesWithActor: React.FC<Props> = ({
|
|||||||
enabled: !!api && !!user?.Id && !!actorId,
|
enabled: !!api && !!user?.Id && !!actorId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const renderItem = useCallback(
|
||||||
|
(item: BaseItemDto, idx: number) => (
|
||||||
|
<TouchableItemRouter
|
||||||
|
key={item.Id ?? idx}
|
||||||
|
item={item}
|
||||||
|
className='flex flex-col w-28'
|
||||||
|
>
|
||||||
|
<View>
|
||||||
|
<MoviePoster item={item} />
|
||||||
|
<ItemCardText item={item} />
|
||||||
|
</View>
|
||||||
|
</TouchableItemRouter>
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
if (items?.length === 0) return null;
|
if (items?.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<Text className='text-lg font-bold mb-2 px-4'>
|
<Text className='text-lg font-bold mb-2 px-4'>
|
||||||
{t("item_card.more_with", { name: actor?.Name })}
|
{t("item_card.more_with", { name: actorName ?? "" })}
|
||||||
</Text>
|
</Text>
|
||||||
<HorizontalScroll
|
<HorizontalScroll
|
||||||
data={items}
|
data={items}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
height={247}
|
height={POSTER_CAROUSEL_HEIGHT}
|
||||||
renderItem={(item: BaseItemDto, idx: number) => (
|
renderItem={renderItem}
|
||||||
<TouchableItemRouter
|
|
||||||
key={idx}
|
|
||||||
item={item}
|
|
||||||
className='flex flex-col w-28'
|
|
||||||
>
|
|
||||||
<View>
|
|
||||||
<MoviePoster item={item} />
|
|
||||||
<ItemCardText item={item} />
|
|
||||||
</View>
|
|
||||||
</TouchableItemRouter>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</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, isVisible } = 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]);
|
||||||
|
|
||||||
|
// Watch for modal dismissal on Android (e.g., swipe down, backdrop tap)
|
||||||
|
// and sync the controlled open state
|
||||||
|
useEffect(() => {
|
||||||
|
if (Platform.OS === "android" && controlledOpen === true && !isVisible) {
|
||||||
|
controlledOnOpenChange?.(false);
|
||||||
|
}
|
||||||
|
}, [isVisible, controlledOpen, controlledOnOpenChange]);
|
||||||
|
|
||||||
|
if (Platform.OS === "ios") {
|
||||||
|
return (
|
||||||
|
<Host style={expoUIConfig?.hostStyle}>
|
||||||
|
<ContextMenu>
|
||||||
|
<ContextMenu.Trigger>{trigger}</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
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||||
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||||
|
import { BottomSheetView } from "@gorhom/bottom-sheet";
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Alert, TouchableOpacity, View } from "react-native";
|
import { Alert, Platform, TouchableOpacity, View } from "react-native";
|
||||||
import CastContext, {
|
import CastContext, {
|
||||||
CastButton,
|
CastButton,
|
||||||
PlayServicesState,
|
PlayServicesState,
|
||||||
@@ -24,6 +25,8 @@ import Animated, {
|
|||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
||||||
|
import { getDownloadedItemById } from "@/providers/Downloads/database";
|
||||||
|
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
@@ -33,10 +36,11 @@ 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 { Button } from "./Button";
|
||||||
|
import { Text } from "./common/Text";
|
||||||
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,12 +55,12 @@ 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();
|
||||||
const mediaStatus = useMediaStatus();
|
const mediaStatus = useMediaStatus();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { showModal, hideModal } = useGlobalModal();
|
||||||
|
|
||||||
const [globalColorAtom] = useAtom(itemThemeColorAtom);
|
const [globalColorAtom] = useAtom(itemThemeColorAtom);
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
@@ -86,12 +90,9 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
[router, isOffline],
|
[router, isOffline],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onPress = useCallback(async () => {
|
const handleNormalPlayFlow = useCallback(async () => {
|
||||||
console.log("onPress");
|
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
|
|
||||||
lightHapticFeedback();
|
|
||||||
|
|
||||||
const queryParams = new URLSearchParams({
|
const queryParams = new URLSearchParams({
|
||||||
itemId: item.Id!,
|
itemId: item.Id!,
|
||||||
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
|
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
|
||||||
@@ -165,7 +166,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,
|
||||||
@@ -273,6 +274,117 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
showActionSheetWithOptions,
|
showActionSheetWithOptions,
|
||||||
mediaStatus,
|
mediaStatus,
|
||||||
selectedOptions,
|
selectedOptions,
|
||||||
|
goToPlayer,
|
||||||
|
isOffline,
|
||||||
|
t,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const onPress = useCallback(async () => {
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
lightHapticFeedback();
|
||||||
|
|
||||||
|
// Check if item is downloaded
|
||||||
|
const downloadedItem = item.Id ? getDownloadedItemById(item.Id) : undefined;
|
||||||
|
|
||||||
|
if (downloadedItem) {
|
||||||
|
if (Platform.OS === "android") {
|
||||||
|
// Show bottom sheet for Android
|
||||||
|
showModal(
|
||||||
|
<BottomSheetView>
|
||||||
|
<View className='px-4 mt-4 mb-12'>
|
||||||
|
<View className='pb-6'>
|
||||||
|
<Text className='text-2xl font-bold mb-2'>
|
||||||
|
{t("player.downloaded_file_title")}
|
||||||
|
</Text>
|
||||||
|
<Text className='opacity-70 text-base'>
|
||||||
|
{t("player.downloaded_file_message")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className='space-y-3'>
|
||||||
|
<Button
|
||||||
|
onPress={() => {
|
||||||
|
hideModal();
|
||||||
|
const queryParams = new URLSearchParams({
|
||||||
|
itemId: item.Id!,
|
||||||
|
offline: "true",
|
||||||
|
playbackPosition:
|
||||||
|
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
||||||
|
});
|
||||||
|
goToPlayer(queryParams.toString());
|
||||||
|
}}
|
||||||
|
color='purple'
|
||||||
|
>
|
||||||
|
{Platform.OS === "android"
|
||||||
|
? "Play downloaded file"
|
||||||
|
: t("player.downloaded_file_yes")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onPress={() => {
|
||||||
|
hideModal();
|
||||||
|
handleNormalPlayFlow();
|
||||||
|
}}
|
||||||
|
color='white'
|
||||||
|
variant='border'
|
||||||
|
>
|
||||||
|
{Platform.OS === "android"
|
||||||
|
? "Stream file"
|
||||||
|
: t("player.downloaded_file_no")}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</BottomSheetView>,
|
||||||
|
{
|
||||||
|
snapPoints: ["35%"],
|
||||||
|
enablePanDownToClose: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Show alert for iOS
|
||||||
|
Alert.alert(
|
||||||
|
t("player.downloaded_file_title"),
|
||||||
|
t("player.downloaded_file_message"),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: t("player.downloaded_file_yes"),
|
||||||
|
onPress: () => {
|
||||||
|
const queryParams = new URLSearchParams({
|
||||||
|
itemId: item.Id!,
|
||||||
|
offline: "true",
|
||||||
|
playbackPosition:
|
||||||
|
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
||||||
|
});
|
||||||
|
goToPlayer(queryParams.toString());
|
||||||
|
},
|
||||||
|
isPreferred: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: t("player.downloaded_file_no"),
|
||||||
|
onPress: () => {
|
||||||
|
handleNormalPlayFlow();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: t("player.downloaded_file_cancel"),
|
||||||
|
style: "cancel",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not downloaded, proceed with normal flow
|
||||||
|
handleNormalPlayFlow();
|
||||||
|
}, [
|
||||||
|
item,
|
||||||
|
lightHapticFeedback,
|
||||||
|
handleNormalPlayFlow,
|
||||||
|
goToPlayer,
|
||||||
|
t,
|
||||||
|
showModal,
|
||||||
|
hideModal,
|
||||||
|
effectiveColors,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const derivedTargetWidth = useDerivedValue(() => {
|
const derivedTargetWidth = useDerivedValue(() => {
|
||||||
@@ -360,9 +472,6 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
[startColor.value.text, endColor.value.text],
|
[startColor.value.text, endColor.value.text],
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
/**
|
|
||||||
* *********************
|
|
||||||
*/
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -370,8 +479,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
accessibilityLabel='Play button'
|
accessibilityLabel='Play button'
|
||||||
accessibilityHint='Tap to play the media'
|
accessibilityHint='Tap to play the media'
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
className={"relative"}
|
className={"relative flex-1"}
|
||||||
{...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 +507,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} />
|
||||||
@@ -410,15 +522,6 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
<CastButton tintColor='transparent' />
|
<CastButton tintColor='transparent' />
|
||||||
</Animated.Text>
|
</Animated.Text>
|
||||||
)}
|
)}
|
||||||
{!client && settings?.openInVLC && (
|
|
||||||
<Animated.Text style={animatedTextStyle}>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name='vlc'
|
|
||||||
size={18}
|
|
||||||
color={animatedTextStyle.color}
|
|
||||||
/>
|
|
||||||
</Animated.Text>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
@@ -17,7 +17,6 @@ import Animated, {
|
|||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
||||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
import type { Button } from "./Button";
|
import type { Button } from "./Button";
|
||||||
import type { SelectedOptions } from "./ItemContent";
|
import type { SelectedOptions } from "./ItemContent";
|
||||||
@@ -50,7 +49,6 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
const startColor = useSharedValue(effectiveColors);
|
const startColor = useSharedValue(effectiveColors);
|
||||||
const widthProgress = useSharedValue(0);
|
const widthProgress = useSharedValue(0);
|
||||||
const colorChangeProgress = useSharedValue(0);
|
const colorChangeProgress = useSharedValue(0);
|
||||||
const { settings } = useSettings();
|
|
||||||
const lightHapticFeedback = useHaptic("light");
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
const goToPlayer = useCallback(
|
const goToPlayer = useCallback(
|
||||||
@@ -61,7 +59,6 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const onPress = () => {
|
const onPress = () => {
|
||||||
console.log("onpress");
|
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
|
|
||||||
lightHapticFeedback();
|
lightHapticFeedback();
|
||||||
@@ -207,15 +204,6 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
<Animated.Text style={animatedTextStyle}>
|
<Animated.Text style={animatedTextStyle}>
|
||||||
<Ionicons name='play-circle' size={24} />
|
<Ionicons name='play-circle' size={24} />
|
||||||
</Animated.Text>
|
</Animated.Text>
|
||||||
{settings?.openInVLC && (
|
|
||||||
<Animated.Text style={animatedTextStyle}>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name='vlc'
|
|
||||||
size={18}
|
|
||||||
color={animatedTextStyle.color}
|
|
||||||
/>
|
|
||||||
</Animated.Text>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
180
components/PlaybackSpeedSelector.tsx
Normal file
180
components/PlaybackSpeedSelector.tsx
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform, View } from "react-native";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||||
|
import { PlaybackSpeedScope } from "./video-player/controls/utils/playback-speed-settings";
|
||||||
|
|
||||||
|
export const PLAYBACK_SPEEDS = [
|
||||||
|
{ label: "0.25x", value: 0.25 },
|
||||||
|
{ label: "0.5x", value: 0.5 },
|
||||||
|
{ label: "0.75x", value: 0.75 },
|
||||||
|
{ label: "1x", value: 1.0 },
|
||||||
|
{ label: "1.25x", value: 1.25 },
|
||||||
|
{ label: "1.5x", value: 1.5 },
|
||||||
|
{ label: "1.75x", value: 1.75 },
|
||||||
|
{ label: "2x", value: 2.0 },
|
||||||
|
{ label: "2.25x", value: 2.25 },
|
||||||
|
{ label: "2.5x", value: 2.5 },
|
||||||
|
{ label: "2.75x", value: 2.75 },
|
||||||
|
{ label: "3x", value: 3.0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Props extends React.ComponentProps<typeof View> {
|
||||||
|
onChange: (value: number, scope: PlaybackSpeedScope) => void;
|
||||||
|
selected: number;
|
||||||
|
item?: BaseItemDto;
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PlaybackSpeedSelector: React.FC<Props> = ({
|
||||||
|
onChange,
|
||||||
|
selected,
|
||||||
|
item,
|
||||||
|
open: controlledOpen,
|
||||||
|
onOpenChange,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const isTv = Platform.isTV;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { settings } = useSettings();
|
||||||
|
const [internalOpen, setInternalOpen] = useState(false);
|
||||||
|
|
||||||
|
// Determine initial scope based on existing settings
|
||||||
|
const initialScope = useMemo<PlaybackSpeedScope>(() => {
|
||||||
|
if (!item || !settings) return PlaybackSpeedScope.All;
|
||||||
|
|
||||||
|
const itemId = item?.Id;
|
||||||
|
if (!itemId) return PlaybackSpeedScope.All;
|
||||||
|
|
||||||
|
// Check for media-specific speed preference
|
||||||
|
if (settings?.playbackSpeedPerMedia?.[itemId] !== undefined) {
|
||||||
|
return PlaybackSpeedScope.Media;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for show-specific speed preference (only for episodes)
|
||||||
|
const seriesId = item?.SeriesId;
|
||||||
|
const perShowSettings = settings?.playbackSpeedPerShow;
|
||||||
|
if (
|
||||||
|
seriesId &&
|
||||||
|
perShowSettings &&
|
||||||
|
perShowSettings[seriesId] !== undefined
|
||||||
|
) {
|
||||||
|
return PlaybackSpeedScope.Show;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no custom setting exists, check default playback speed
|
||||||
|
// Show "All" if speed is not 1x, otherwise show "Media"
|
||||||
|
return (settings?.defaultPlaybackSpeed ?? 1.0) !== 1.0
|
||||||
|
? PlaybackSpeedScope.All
|
||||||
|
: PlaybackSpeedScope.Media;
|
||||||
|
}, [item?.Id, item?.SeriesId, settings]);
|
||||||
|
|
||||||
|
const [selectedScope, setSelectedScope] =
|
||||||
|
useState<PlaybackSpeedScope>(initialScope);
|
||||||
|
|
||||||
|
// Update selectedScope when initialScope changes
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedScope(initialScope);
|
||||||
|
}, [initialScope]);
|
||||||
|
|
||||||
|
const open = controlledOpen !== undefined ? controlledOpen : internalOpen;
|
||||||
|
const setOpen = onOpenChange || setInternalOpen;
|
||||||
|
|
||||||
|
const scopeLabels = useMemo<Record<PlaybackSpeedScope, string>>(() => {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
[PlaybackSpeedScope.Media]: t("playback_speed.scope.media"),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (item?.SeriesId) {
|
||||||
|
labels[PlaybackSpeedScope.Show] = t("playback_speed.scope.show");
|
||||||
|
}
|
||||||
|
|
||||||
|
labels[PlaybackSpeedScope.All] = t("playback_speed.scope.all");
|
||||||
|
|
||||||
|
return labels as Record<PlaybackSpeedScope, string>;
|
||||||
|
}, [item?.SeriesId, t]);
|
||||||
|
|
||||||
|
const availableScopes = useMemo<PlaybackSpeedScope[]>(() => {
|
||||||
|
const scopes = [PlaybackSpeedScope.Media];
|
||||||
|
if (item?.SeriesId) {
|
||||||
|
scopes.push(PlaybackSpeedScope.Show);
|
||||||
|
}
|
||||||
|
scopes.push(PlaybackSpeedScope.All);
|
||||||
|
return scopes;
|
||||||
|
}, [item?.SeriesId]);
|
||||||
|
|
||||||
|
const handleSpeedSelect = useCallback(
|
||||||
|
(speed: number) => {
|
||||||
|
onChange(speed, selectedScope);
|
||||||
|
setOpen(false);
|
||||||
|
},
|
||||||
|
[onChange, selectedScope, setOpen],
|
||||||
|
);
|
||||||
|
|
||||||
|
const optionGroups = useMemo<OptionGroup[]>(() => {
|
||||||
|
const groups: OptionGroup[] = [];
|
||||||
|
|
||||||
|
// Scope selection group
|
||||||
|
groups.push({
|
||||||
|
title: t("playback_speed.apply_to"),
|
||||||
|
options: availableScopes.map((scope) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: scopeLabels[scope],
|
||||||
|
value: scope,
|
||||||
|
selected: selectedScope === scope,
|
||||||
|
onPress: () => setSelectedScope(scope),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Speed selection group
|
||||||
|
groups.push({
|
||||||
|
title: t("playback_speed.speed"),
|
||||||
|
options: PLAYBACK_SPEEDS.map((speed) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: speed.label,
|
||||||
|
value: speed.value,
|
||||||
|
selected: selected === speed.value,
|
||||||
|
onPress: () => handleSpeedSelect(speed.value),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}, [
|
||||||
|
t,
|
||||||
|
availableScopes,
|
||||||
|
scopeLabels,
|
||||||
|
selectedScope,
|
||||||
|
selected,
|
||||||
|
handleSpeedSelect,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const trigger = useMemo(
|
||||||
|
() => (
|
||||||
|
<View className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'>
|
||||||
|
<Ionicons name='speedometer' size={24} color='white' />
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isTv) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='flex shrink' style={{ minWidth: 60 }} {...props}>
|
||||||
|
<PlatformDropdown
|
||||||
|
title={t("playback_speed.title")}
|
||||||
|
groups={optionGroups}
|
||||||
|
trigger={trigger}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
bottomSheetConfig={{
|
||||||
|
enablePanDownToClose: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user