mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-16 16:18:09 +00:00
Compare commits
2 Commits
v0.51.0
...
codeql-fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c0ed076d5 | ||
|
|
118c24ee05 |
9
.github/ISSUE_TEMPLATE/issue_report.yml
vendored
9
.github/ISSUE_TEMPLATE/issue_report.yml
vendored
@@ -77,8 +77,13 @@ 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:
|
||||||
@@ -111,4 +116,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-26
|
runs-on: macos-15
|
||||||
name: 🍎 Build iOS IPA (Phone)
|
name: 🍎 Build iOS IPA (Phone)
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -191,11 +191,6 @@ 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:
|
||||||
@@ -224,7 +219,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-26
|
# runs-on: macos-15
|
||||||
# name: 🍎 Build iOS IPA (TV)
|
# name: 🍎 Build iOS IPA (TV)
|
||||||
# permissions:
|
# permissions:
|
||||||
# contents: read
|
# contents: read
|
||||||
@@ -259,11 +254,6 @@ 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,15 +25,19 @@ 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@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
|
uses: github/codeql-action/init@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
queries: +security-extended,security-and-quality
|
queries: +security-extended,security-and-quality
|
||||||
|
|
||||||
- name: 🛠️ Autobuild
|
- name: 🛠️ Autobuild
|
||||||
uses: github/codeql-action/autobuild@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
|
uses: github/codeql-action/autobuild@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0
|
||||||
|
|
||||||
- name: 🧪 Perform CodeQL Analysis
|
- name: 🧪 Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
|
uses: github/codeql-action/analyze@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0
|
||||||
|
|||||||
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@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2
|
uses: actions/dependency-review-action@40c09b7dc99638e5ddb0bfd91c1673effc064d8a # v4.8.1
|
||||||
with:
|
with:
|
||||||
fail-on-severity: high
|
fail-on-severity: high
|
||||||
base-ref: ${{ github.event.pull_request.base.sha || 'develop' }}
|
base-ref: ${{ github.event.pull_request.base.sha || 'develop' }}
|
||||||
@@ -107,7 +107,7 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: "🟢 Setup Node.js"
|
- name: "🟢 Setup Node.js"
|
||||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||||
with:
|
with:
|
||||||
node-version: '24.x'
|
node-version: '24.x'
|
||||||
|
|
||||||
|
|||||||
1
.github/workflows/notification.yml
vendored
1
.github/workflows/notification.yml
vendored
@@ -1,4 +1,5 @@
|
|||||||
name: 🛎️ Discord Notification
|
name: 🛎️ Discord Notification
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|||||||
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@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||||
with:
|
with:
|
||||||
node-version: '24.x'
|
node-version: '24.x'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -51,7 +51,6 @@ 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
|
||||||
@@ -65,8 +64,4 @@ 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,25 +1,178 @@
|
|||||||
{
|
{
|
||||||
|
// ==========================================
|
||||||
|
// FORMATTING & LINTING
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
// Biome as default formatter
|
||||||
"editor.defaultFormatter": "biomejs.biome",
|
"editor.defaultFormatter": "biomejs.biome",
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
"editor.codeActionsOnSave": {
|
"editor.formatOnPaste": true,
|
||||||
"source.fixAll.biome": "explicit"
|
"editor.formatOnType": false,
|
||||||
|
|
||||||
|
// 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
|
||||||
},
|
},
|
||||||
"typescript.tsdk": "node_modules/typescript/lib",
|
"[jsonc]": {
|
||||||
"typescript.enablePromptUseWorkspaceTsdk": true,
|
"editor.defaultFormatter": "biomejs.biome",
|
||||||
"editor.formatOnSaveMode": "file"
|
"editor.formatOnSave": true
|
||||||
|
},
|
||||||
|
"[swift]": {
|
||||||
|
"editor.insertSpaces": true,
|
||||||
|
"editor.tabSize": 2
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// TYPESCRIPT & JAVASCRIPT
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
// TypeScript performance optimizations
|
||||||
|
"typescript.preferences.includePackageJsonAutoImports": "auto",
|
||||||
|
"typescript.suggest.autoImports": true,
|
||||||
|
"typescript.updateImportsOnFileMove.enabled": "always",
|
||||||
|
"typescript.preferences.preferTypeOnlyAutoImports": true,
|
||||||
|
"typescript.preferences.importModuleSpecifier": "relative",
|
||||||
|
"typescript.preferences.includeCompletionsForImportStatements": true,
|
||||||
|
"typescript.preferences.includeCompletionsWithSnippetText": true,
|
||||||
|
|
||||||
|
// JavaScript settings
|
||||||
|
"javascript.preferences.importModuleSpecifier": "relative",
|
||||||
|
"javascript.suggest.autoImports": true,
|
||||||
|
"javascript.updateImportsOnFileMove.enabled": "always",
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// REACT NATIVE & EXPO
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
// File associations for React Native
|
||||||
|
"files.associations": {
|
||||||
|
"*.expo.ts": "typescript",
|
||||||
|
"*.expo.tsx": "typescriptreact",
|
||||||
|
"*.expo.js": "javascript",
|
||||||
|
"*.expo.jsx": "javascriptreact",
|
||||||
|
"metro.config.js": "javascript",
|
||||||
|
"babel.config.js": "javascript",
|
||||||
|
"app.config.js": "javascript",
|
||||||
|
"eas.json": "jsonc"
|
||||||
|
},
|
||||||
|
|
||||||
|
// React Native specific settings
|
||||||
|
"emmet.includeLanguages": {
|
||||||
|
"typescriptreact": "html",
|
||||||
|
"javascriptreact": "html"
|
||||||
|
},
|
||||||
|
"emmet.triggerExpansionOnTab": true,
|
||||||
|
|
||||||
|
// Exclude build directories from search
|
||||||
|
"search.exclude": {
|
||||||
|
"**/node_modules": true
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// EDITOR PERFORMANCE & UX
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
// Performance optimizations
|
||||||
|
"editor.largeFileOptimizations": true,
|
||||||
|
"files.watcherExclude": {
|
||||||
|
"**/.git/objects/**": true,
|
||||||
|
"**/.git/subtree-cache/**": true,
|
||||||
|
"**/node_modules/**": true,
|
||||||
|
"**/.expo/**": true,
|
||||||
|
"**/ios/**": true,
|
||||||
|
"**/android/**": true,
|
||||||
|
"**/build/**": true,
|
||||||
|
"**/dist/**": true
|
||||||
|
},
|
||||||
|
|
||||||
|
// Better editor behavior
|
||||||
|
"editor.suggestSelection": "first",
|
||||||
|
"editor.quickSuggestions": {
|
||||||
|
"strings": true,
|
||||||
|
"comments": true,
|
||||||
|
"other": true
|
||||||
|
},
|
||||||
|
"editor.snippetSuggestions": "top",
|
||||||
|
"editor.tabCompletion": "on",
|
||||||
|
"editor.wordBasedSuggestions": "off",
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// TERMINAL & DEVELOPMENT
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
// Terminal settings for Bun (Windows-specific)
|
||||||
|
"terminal.integrated.profiles.windows": {
|
||||||
|
"Command Prompt": {
|
||||||
|
"path": "C:\\Windows\\System32\\cmd.exe",
|
||||||
|
"env": {
|
||||||
|
"PATH": "${env:PATH};./node_modules/.bin"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// WORKSPACE & NAVIGATION
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
// Better workspace navigation
|
||||||
|
"explorer.fileNesting.enabled": true,
|
||||||
|
"explorer.fileNesting.expand": false,
|
||||||
|
"explorer.fileNesting.patterns": {
|
||||||
|
"*.ts": "${capture}.js",
|
||||||
|
"*.tsx": "${capture}.js",
|
||||||
|
"*.js": "${capture}.js,${capture}.js.map,${capture}.min.js,${capture}.d.ts",
|
||||||
|
"*.jsx": "${capture}.js",
|
||||||
|
"package.json": "package-lock.json,yarn.lock,bun.lock,bun.lockb,.yarnrc,.yarnrc.yml",
|
||||||
|
"tsconfig.json": "tsconfig.*.json",
|
||||||
|
".env": ".env.*",
|
||||||
|
"app.json": "app.config.js,eas.json,expo-env.d.ts",
|
||||||
|
"README.md": "LICENSE.txt,SECURITY.md,CODE_OF_CONDUCT.md,CONTRIBUTING.md"
|
||||||
|
},
|
||||||
|
|
||||||
|
// Better breadcrumbs and navigation
|
||||||
|
"breadcrumbs.enabled": true,
|
||||||
|
"outline.showVariables": true,
|
||||||
|
"outline.showConstants": true,
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// GIT & VERSION CONTROL
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
// Git integration
|
||||||
|
"git.autofetch": true,
|
||||||
|
"git.enableSmartCommit": true,
|
||||||
|
"git.confirmSync": false,
|
||||||
|
"git.ignoreLimitWarning": true,
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// CODE QUALITY & ERRORS
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
// Better error detection
|
||||||
|
"typescript.validate.enable": true,
|
||||||
|
"javascript.validate.enable": true,
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll": "explicit",
|
||||||
|
"source.organizeImports": "explicit"
|
||||||
|
},
|
||||||
|
|
||||||
|
// Problem matcher for better error display
|
||||||
|
"typescript.tsc.autoDetect": "on"
|
||||||
}
|
}
|
||||||
|
|||||||
119
CLAUDE.md
119
CLAUDE.md
@@ -1,119 +0,0 @@
|
|||||||
# 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)
|
|
||||||
@@ -1,232 +0,0 @@
|
|||||||
# 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,7 +70,6 @@ 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
|
||||||
@@ -105,7 +104,6 @@ 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,18 +7,13 @@ module.exports = ({ config }) => {
|
|||||||
{ useDefaultExpandedMediaControls: true },
|
{ useDefaultExpandedMediaControls: true },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// KSPlayer for iOS (GPU acceleration + native PiP)
|
// Add the background downloader plugin only for non-TV builds
|
||||||
config.plugins.push("./plugins/withKSPlayer.js");
|
config.plugins.push("./plugins/withRNBackgroundDownloader.js");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only override googleServicesFile if env var is set
|
|
||||||
const androidConfig = {};
|
|
||||||
if (process.env.GOOGLE_SERVICES_JSON) {
|
|
||||||
androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...(Object.keys(androidConfig).length > 0 && { android: androidConfig }),
|
android: {
|
||||||
|
googleServicesFile: process.env.GOOGLE_SERVICES_JSON,
|
||||||
|
},
|
||||||
...config,
|
...config,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
46
app.json
46
app.json
@@ -2,13 +2,12 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.51.0",
|
"version": "0.39.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,
|
||||||
@@ -29,12 +28,16 @@
|
|||||||
},
|
},
|
||||||
"supportsTablet": true,
|
"supportsTablet": true,
|
||||||
"bundleIdentifier": "com.fredrikburmester.streamyfin",
|
"bundleIdentifier": "com.fredrikburmester.streamyfin",
|
||||||
"icon": "./assets/images/icon-ios-liquid-glass.icon",
|
"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": 91,
|
"versionCode": 71,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/images/icon-android-plain.png",
|
"foregroundImage": "./assets/images/icon-android-plain.png",
|
||||||
"monochromeImage": "./assets/images/icon-android-themed.png",
|
"monochromeImage": "./assets/images/icon-android-themed.png",
|
||||||
@@ -53,16 +56,28 @@
|
|||||||
"@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": {
|
||||||
"buildArchs": ["arm64-v8a", "x86_64"],
|
"compileSdkVersion": 35,
|
||||||
"compileSdkVersion": 36,
|
|
||||||
"targetSdkVersion": 35,
|
"targetSdkVersion": 35,
|
||||||
"buildToolsVersion": "35.0.0",
|
"buildToolsVersion": "35.0.0",
|
||||||
"kotlinVersion": "2.0.21",
|
"kotlinVersion": "2.0.21",
|
||||||
@@ -100,6 +115,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
["./plugins/withChangeNativeAndroidTextToWhite.js"],
|
||||||
|
["./plugins/withAndroidManifest.js"],
|
||||||
|
["./plugins/withTrustLocalCerts.js"],
|
||||||
|
["./plugins/withGradleProperties.js"],
|
||||||
[
|
[
|
||||||
"expo-splash-screen",
|
"expo-splash-screen",
|
||||||
{
|
{
|
||||||
@@ -115,12 +134,8 @@
|
|||||||
"color": "#9333EA"
|
"color": "#9333EA"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"expo-web-browser",
|
"./plugins/with-runtime-framework-headers.js",
|
||||||
["./plugins/with-runtime-framework-headers.js"],
|
"react-native-bottom-tabs"
|
||||||
["./plugins/withChangeNativeAndroidTextToWhite.js"],
|
|
||||||
["./plugins/withAndroidManifest.js"],
|
|
||||||
["./plugins/withTrustLocalCerts.js"],
|
|
||||||
["./plugins/withGradleProperties.js"]
|
|
||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
"typedRoutes": true
|
"typedRoutes": true
|
||||||
@@ -139,6 +154,7 @@
|
|||||||
},
|
},
|
||||||
"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: Platform.OS !== "ios",
|
headerShown: true,
|
||||||
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 { Platform, RefreshControl, ScrollView, View } from "react-native";
|
import { 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 style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}>
|
<View className='my-4'>
|
||||||
<Favorites />
|
<Favorites />
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|||||||
@@ -1,212 +0,0 @@
|
|||||||
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,6 +30,7 @@ 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 />
|
||||||
</>
|
</>
|
||||||
@@ -41,291 +42,49 @@ 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/playback-controls/page'
|
name='settings/marlin-search/page'
|
||||||
options={{
|
options={{
|
||||||
title: t("home.settings.playback_controls.title"),
|
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/audio-subtitles/page'
|
name='settings/jellyseerr/page'
|
||||||
options={{
|
options={{
|
||||||
title: t("home.settings.audio_subtitles.title"),
|
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/appearance/page'
|
name='settings/hide-libraries/page'
|
||||||
options={{
|
options={{
|
||||||
title: t("home.settings.appearance.title"),
|
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: t("home.settings.logs.logs_title"),
|
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
|
||||||
@@ -333,11 +92,6 @@ 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",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -348,11 +102,6 @@ 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,10 +1,8 @@
|
|||||||
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, Platform, TouchableOpacity, View } from "react-native";
|
import { Alert, ScrollView, 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 {
|
||||||
@@ -25,23 +23,21 @@ export default function page() {
|
|||||||
const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>(
|
const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>(
|
||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
const { downloadedItems, deleteItems } = useDownload();
|
const { getDownloadedItems, deleteItems } = useDownload();
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
const series = useMemo(() => {
|
const series = useMemo(() => {
|
||||||
try {
|
try {
|
||||||
return (
|
return (
|
||||||
downloadedItems
|
getDownloadedItems()
|
||||||
?.filter((f) => f.item.SeriesId === seriesId)
|
?.filter((f) => f.item.SeriesId === seriesId)
|
||||||
?.sort(
|
?.sort(
|
||||||
(a, b) =>
|
(a, b) => a?.item.ParentIndexNumber! - b.item.ParentIndexNumber!,
|
||||||
(a.item.ParentIndexNumber ?? 0) - (b.item.ParentIndexNumber ?? 0),
|
|
||||||
) || []
|
) || []
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}, [downloadedItems, seriesId]);
|
}, [getDownloadedItems]);
|
||||||
|
|
||||||
// Group episodes by season in a single pass
|
// Group episodes by season in a single pass
|
||||||
const seasonGroups = useMemo(() => {
|
const seasonGroups = useMemo(() => {
|
||||||
@@ -74,9 +70,8 @@ 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[]>(() => {
|
||||||
@@ -85,9 +80,9 @@ export default function page() {
|
|||||||
|
|
||||||
const initialSeasonIndex = useMemo(
|
const initialSeasonIndex = useMemo(
|
||||||
() =>
|
() =>
|
||||||
groupBySeason?.[0]?.ParentIndexNumber ??
|
Object.values(groupBySeason)?.[0]?.ParentIndexNumber ??
|
||||||
series?.[0]?.item?.ParentIndexNumber,
|
series?.[0]?.item?.ParentIndexNumber,
|
||||||
[groupBySeason, series],
|
[groupBySeason],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -96,7 +91,7 @@ export default function page() {
|
|||||||
title: series[0].item.SeriesName,
|
title: series[0].item.SeriesName,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
storage.remove(seriesId);
|
storage.delete(seriesId);
|
||||||
router.back();
|
router.back();
|
||||||
}
|
}
|
||||||
}, [series]);
|
}, [series]);
|
||||||
@@ -112,70 +107,44 @@ export default function page() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: "Delete",
|
text: "Delete",
|
||||||
onPress: () =>
|
onPress: () => deleteItems(groupBySeason),
|
||||||
deleteItems(
|
|
||||||
groupBySeason
|
|
||||||
.map((item) => item.Id)
|
|
||||||
.filter((id) => id !== undefined),
|
|
||||||
),
|
|
||||||
style: "destructive",
|
style: "destructive",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}, [groupBySeason, deleteItems]);
|
}, [groupBySeason]);
|
||||||
|
|
||||||
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'>
|
||||||
<FlashList
|
{series.length > 0 && (
|
||||||
key={seasonIndex}
|
<View className='flex flex-row items-center justify-start my-2 px-4'>
|
||||||
data={groupBySeason}
|
<SeasonDropdown
|
||||||
renderItem={({ item }) => <EpisodeCard item={item} />}
|
item={series[0].item}
|
||||||
keyExtractor={(item, index) => item.Id ?? `episode-${index}`}
|
seasons={uniqueSeasons}
|
||||||
ListHeaderComponent={ListHeaderComponent}
|
state={seasonIndexState}
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
initialSeasonIndex={initialSeasonIndex!}
|
||||||
contentContainerStyle={{
|
onSelect={(season) => {
|
||||||
paddingHorizontal: 16,
|
setSeasonIndexState((prev) => ({
|
||||||
paddingLeft: insets.left + 16,
|
...prev,
|
||||||
paddingRight: insets.right + 16,
|
[series[0].item.ParentId ?? ""]: season.ParentIndexNumber,
|
||||||
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 { BottomSheetModal } from "@gorhom/bottom-sheet";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
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 {
|
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
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,15 +26,18 @@ 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 { downloadedItems, deleteFileByType, deleteAllFiles } = useDownload();
|
const {
|
||||||
|
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"),
|
||||||
@@ -59,7 +62,7 @@ export default function page() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const downloadedFiles = useMemo(() => downloadedItems, [downloadedItems]);
|
const downloadedFiles = getDownloadedItems();
|
||||||
|
|
||||||
const movies = useMemo(() => {
|
const movies = useMemo(() => {
|
||||||
try {
|
try {
|
||||||
@@ -103,10 +106,7 @@ export default function page() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity onPress={bottomSheetModalRef.current?.present}>
|
||||||
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,39 +141,38 @@ 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
|
otherMedia.map((item) =>
|
||||||
.filter((item) => item.item.Type)
|
deleteFileByType(item.item.Type)
|
||||||
.map((item) =>
|
.then(() =>
|
||||||
deleteFileByType(item.item.Type!)
|
toast.success(
|
||||||
.then(() =>
|
t("home.downloads.toasts.deleted_media_successfully", {
|
||||||
toast.success(
|
type: item.item.Type,
|
||||||
t("home.downloads.toasts.deleted_media_successfully", {
|
}),
|
||||||
type: item.item.Type,
|
),
|
||||||
}),
|
)
|
||||||
),
|
.catch((reason) => {
|
||||||
)
|
writeToLog("ERROR", reason);
|
||||||
.catch((reason) => {
|
toast.error(
|
||||||
writeToLog("ERROR", reason);
|
t("home.downloads.toasts.failed_to_delete_media", {
|
||||||
toast.error(
|
type: item.item.Type,
|
||||||
t("home.downloads.toasts.failed_to_delete_media", {
|
}),
|
||||||
type: item.item.Type,
|
);
|
||||||
}),
|
}),
|
||||||
);
|
),
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const deleteAllMedia = async () =>
|
||||||
|
await Promise.all([deleteMovies(), deleteShows(), deleteOtherMedia()]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<>
|
||||||
showsVerticalScrollIndicator={false}
|
<View style={{ flex: 1 }}>
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
<ScrollView showsVerticalScrollIndicator={false} className='flex-1'>
|
||||||
>
|
<View className='py-4'>
|
||||||
<View style={{ paddingTop: Platform.OS === "android" ? 17 : 0 }}>
|
<View className='mb-4 flex flex-col space-y-4 px-4'>
|
||||||
<View className='mb-4 flex flex-col space-y-4 px-4'>
|
<View className='bg-neutral-900 p-4 rounded-2xl'>
|
||||||
{/* 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>
|
||||||
@@ -215,96 +214,139 @@ export default function page() {
|
|||||||
{t("home.downloads.no_items_in_queue")}
|
{t("home.downloads.no_items_in_queue")}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View> */}
|
|
||||||
|
|
||||||
<ActiveDownloads />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{movies.length > 0 && (
|
|
||||||
<View className='mb-4'>
|
|
||||||
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
|
||||||
<Text className='text-lg font-bold'>
|
|
||||||
{t("home.downloads.movies")}
|
|
||||||
</Text>
|
|
||||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
|
||||||
<Text className='text-xs font-bold'>{movies?.length}</Text>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<ActiveDownloads />
|
||||||
</View>
|
</View>
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
|
||||||
<View className='px-4 flex flex-row'>
|
{movies.length > 0 && (
|
||||||
{movies?.map((item) => (
|
<View className='mb-4'>
|
||||||
<TouchableItemRouter
|
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
||||||
item={item.item}
|
<Text className='text-lg font-bold'>
|
||||||
isOffline
|
{t("home.downloads.movies")}
|
||||||
key={item.item.Id}
|
</Text>
|
||||||
>
|
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
||||||
<MovieCard item={item.item} />
|
<Text className='text-xs font-bold'>{movies?.length}</Text>
|
||||||
</TouchableItemRouter>
|
</View>
|
||||||
))}
|
</View>
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
|
<View className='px-4 flex flex-row'>
|
||||||
|
{movies?.map((item) => (
|
||||||
|
<TouchableItemRouter
|
||||||
|
item={item.item}
|
||||||
|
isOffline
|
||||||
|
key={item.item.Id}
|
||||||
|
>
|
||||||
|
<MovieCard item={item.item} />
|
||||||
|
</TouchableItemRouter>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
)}
|
||||||
</View>
|
{groupedBySeries.length > 0 && (
|
||||||
)}
|
<View className='mb-4'>
|
||||||
{groupedBySeries.length > 0 && (
|
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
||||||
<View className='mb-4'>
|
<Text className='text-lg font-bold'>
|
||||||
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
{t("home.downloads.tvseries")}
|
||||||
<Text className='text-lg font-bold'>
|
</Text>
|
||||||
{t("home.downloads.tvseries")}
|
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
||||||
</Text>
|
<Text className='text-xs font-bold'>
|
||||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
{groupedBySeries?.length}
|
||||||
<Text className='text-xs font-bold'>
|
</Text>
|
||||||
{groupedBySeries?.length}
|
</View>
|
||||||
|
</View>
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
|
<View className='px-4 flex flex-row'>
|
||||||
|
{groupedBySeries?.map((items) => (
|
||||||
|
<View
|
||||||
|
className='mb-2 last:mb-0'
|
||||||
|
key={items[0].item.SeriesId}
|
||||||
|
>
|
||||||
|
<SeriesCard
|
||||||
|
items={items.map((i) => i.item)}
|
||||||
|
key={items[0].item.SeriesId}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{otherMedia.length > 0 && (
|
||||||
|
<View className='mb-4'>
|
||||||
|
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
||||||
|
<Text className='text-lg font-bold'>
|
||||||
|
{t("home.downloads.other_media")}
|
||||||
|
</Text>
|
||||||
|
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
||||||
|
<Text className='text-xs font-bold'>
|
||||||
|
{otherMedia?.length}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
|
<View className='px-4 flex flex-row'>
|
||||||
|
{otherMedia?.map((item) => (
|
||||||
|
<TouchableItemRouter
|
||||||
|
item={item.item}
|
||||||
|
isOffline
|
||||||
|
key={item.item.Id}
|
||||||
|
>
|
||||||
|
<MovieCard item={item.item} />
|
||||||
|
</TouchableItemRouter>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{downloadedFiles?.length === 0 && (
|
||||||
|
<View className='flex px-4'>
|
||||||
|
<Text className='opacity-50'>
|
||||||
|
{t("home.downloads.no_downloaded_items")}
|
||||||
</Text>
|
</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>
|
||||||
|
|
||||||
{otherMedia.length > 0 && (
|
|
||||||
<View className='mb-4'>
|
|
||||||
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
|
||||||
<Text className='text-lg font-bold'>
|
|
||||||
{t("home.downloads.other_media")}
|
|
||||||
</Text>
|
|
||||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
|
||||||
<Text className='text-xs font-bold'>{otherMedia?.length}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
|
||||||
<View className='px-4 flex flex-row'>
|
|
||||||
{otherMedia?.map((item) => (
|
|
||||||
<TouchableItemRouter
|
|
||||||
item={item.item}
|
|
||||||
isOffline
|
|
||||||
key={item.item.Id}
|
|
||||||
>
|
|
||||||
<MovieCard item={item.item} />
|
|
||||||
</TouchableItemRouter>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{downloadedFiles?.length === 0 && (
|
|
||||||
<View className='flex px-4'>
|
|
||||||
<Text className='opacity-50'>
|
|
||||||
{t("home.downloads.no_downloaded_items")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
<BottomSheetModal
|
||||||
|
ref={bottomSheetModalRef}
|
||||||
|
enableDynamicSizing
|
||||||
|
handleIndicatorStyle={{
|
||||||
|
backgroundColor: "white",
|
||||||
|
}}
|
||||||
|
backgroundStyle={{
|
||||||
|
backgroundColor: "#171717",
|
||||||
|
}}
|
||||||
|
backdropComponent={(props: BottomSheetBackdropProps) => (
|
||||||
|
<BottomSheetBackdrop
|
||||||
|
{...props}
|
||||||
|
disappearsOnIndex={-1}
|
||||||
|
appearsOnIndex={0}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<BottomSheetView>
|
||||||
|
<View className='p-4 space-y-4 mb-4'>
|
||||||
|
<Button color='purple' onPress={deleteMovies}>
|
||||||
|
{t("home.downloads.delete_all_movies_button")}
|
||||||
|
</Button>
|
||||||
|
<Button color='purple' onPress={deleteShows}>
|
||||||
|
{t("home.downloads.delete_all_tvseries_button")}
|
||||||
|
</Button>
|
||||||
|
{otherMedia.length > 0 && (
|
||||||
|
<Button color='purple' onPress={deleteOtherMedia}>
|
||||||
|
{t("home.downloads.delete_all_other_media_button")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button color='red' onPress={deleteAllMedia}>
|
||||||
|
{t("home.downloads.delete_all_button")}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</BottomSheetView>
|
||||||
|
</BottomSheetModal>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,5 @@
|
|||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { HomeIndex } from "@/components/settings/HomeIndex";
|
||||||
import { Home } from "../../../../components/home/Home";
|
|
||||||
import { HomeWithCarousel } from "../../../../components/home/HomeWithCarousel";
|
|
||||||
|
|
||||||
const Index = () => {
|
export default function page() {
|
||||||
const { settings } = useSettings();
|
return <HomeIndex />;
|
||||||
const showLargeHomeCarousel = settings.showLargeHomeCarousel ?? false;
|
}
|
||||||
|
|
||||||
if (showLargeHomeCarousel) {
|
|
||||||
return <HomeWithCarousel />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <Home />;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Index;
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
import { HardwareAccelerationType } from "@jellyfin/sdk/lib/generated-client";
|
import {
|
||||||
|
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";
|
||||||
@@ -11,7 +13,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 { Platform, TouchableOpacity, View } from "react-native";
|
import { 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";
|
||||||
@@ -47,13 +49,14 @@ export default function page() {
|
|||||||
<FlashList
|
<FlashList
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingTop: Platform.OS === "android" ? 17 : 0,
|
paddingTop: 17,
|
||||||
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,16 +8,34 @@ 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(() => {
|
||||||
@@ -28,7 +46,7 @@ export default function settings() {
|
|||||||
logout();
|
logout();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text className='text-red-600 px-2'>
|
<Text className='text-red-600'>
|
||||||
{t("home.settings.log_out_button")}
|
{t("home.settings.log_out_button")}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -38,63 +56,61 @@ 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
|
<View className='p-4 flex flex-col gap-y-4'>
|
||||||
className='p-4 flex flex-col'
|
<UserInfo />
|
||||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
|
||||||
>
|
|
||||||
<View className='mb-4'>
|
|
||||||
<UserInfo />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<QuickConnect className='mb-4' />
|
<QuickConnect className='mb-4' />
|
||||||
|
|
||||||
<View className='mb-4'>
|
<MediaProvider>
|
||||||
<AppLanguageSelector />
|
<MediaToggles className='mb-4' />
|
||||||
</View>
|
<GestureControls className='mb-4' />
|
||||||
|
<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.categories.title")}>
|
<ListGroup title={t("home.settings.logs.logs_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>
|
||||||
|
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
16
app/(auth)/(tabs)/(home)/settings/jellyseerr/page.tsx
Normal file
16
app/(auth)/(tabs)/(home)/settings/jellyseerr/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
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,21 +1,15 @@
|
|||||||
import { File, Paths } from "expo-file-system";
|
import * as FileSystem from "expo-file-system";
|
||||||
import { useNavigation } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
import type * as SharingType from "expo-sharing";
|
import * as Sharing 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 { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
import { 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();
|
||||||
@@ -39,7 +33,6 @@ 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(
|
||||||
() =>
|
() =>
|
||||||
@@ -54,30 +47,27 @@ 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 () => {
|
||||||
if (!Sharing) return;
|
const uri = `${FileSystem.documentDirectory}logs.txt`;
|
||||||
|
|
||||||
const logsFile = new File(Paths.document, "logs.txt");
|
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
FileSystem.writeAsStringAsync(uri, JSON.stringify(filteredLogs))
|
||||||
logsFile.write(JSON.stringify(filteredLogs));
|
.then(() => {
|
||||||
await Sharing.shareAsync(logsFile.uri, { mimeType: "txt", UTI: "txt" });
|
setLoading(false);
|
||||||
} catch (e: any) {
|
Sharing.shareAsync(uri, { mimeType: "txt", UTI: "txt" });
|
||||||
writeErrorLog("Something went wrong attempting to export", e);
|
})
|
||||||
} finally {
|
.catch((e) =>
|
||||||
setLoading(false);
|
writeErrorLog("Something went wrong attempting to export", e),
|
||||||
}
|
)
|
||||||
}, [filteredLogs, Sharing]);
|
.finally(() => setLoading(false));
|
||||||
|
}, [filteredLogs]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.isTV) return;
|
|
||||||
|
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () =>
|
headerRight: () =>
|
||||||
loading ? (
|
loading ? (
|
||||||
<Loader />
|
<Loader />
|
||||||
) : (
|
) : (
|
||||||
<TouchableOpacity onPress={share} className='px-2'>
|
<TouchableOpacity onPress={share}>
|
||||||
<Text>{t("home.settings.logs.export_logs")}</Text>
|
<Text>{t("home.settings.logs.export_logs")}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
),
|
),
|
||||||
@@ -85,12 +75,7 @@ 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}
|
||||||
@@ -172,6 +157,6 @@ export default function Page() {
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
122
app/(auth)/(tabs)/(home)/settings/marlin-search/page.tsx
Normal file
122
app/(auth)/(tabs)/(home)/settings/marlin-search/page.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,142 +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,
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,262 +0,0 @@
|
|||||||
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,7 +16,6 @@ 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";
|
||||||
@@ -205,154 +204,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(
|
||||||
() => (
|
() => (
|
||||||
<FlatList
|
<View className=''>
|
||||||
horizontal
|
<FlatList
|
||||||
showsHorizontalScrollIndicator={false}
|
horizontal
|
||||||
contentContainerStyle={{
|
showsHorizontalScrollIndicator={false}
|
||||||
display: "flex",
|
contentContainerStyle={{
|
||||||
paddingHorizontal: 15,
|
display: "flex",
|
||||||
paddingVertical: 16,
|
paddingHorizontal: 15,
|
||||||
flexDirection: "row",
|
paddingVertical: 16,
|
||||||
}}
|
flexDirection: "row",
|
||||||
extraData={[
|
}}
|
||||||
selectedGenres,
|
extraData={[
|
||||||
selectedYears,
|
selectedGenres,
|
||||||
selectedTags,
|
selectedYears,
|
||||||
sortBy,
|
selectedTags,
|
||||||
sortOrder,
|
sortBy,
|
||||||
]}
|
sortOrder,
|
||||||
data={[
|
]}
|
||||||
{
|
data={[
|
||||||
key: "reset",
|
{
|
||||||
component: <ResetFiltersButton />,
|
key: "reset",
|
||||||
},
|
component: <ResetFiltersButton />,
|
||||||
{
|
},
|
||||||
key: "genre",
|
{
|
||||||
component: (
|
key: "genre",
|
||||||
<FilterButton
|
component: (
|
||||||
className='mr-1'
|
<FilterButton
|
||||||
id={collectionId}
|
className='mr-1'
|
||||||
queryKey='genreFilter'
|
id={collectionId}
|
||||||
queryFn={async () => {
|
queryKey='genreFilter'
|
||||||
if (!api) return null;
|
queryFn={async () => {
|
||||||
const response = await getFilterApi(
|
if (!api) return null;
|
||||||
api,
|
const response = await getFilterApi(
|
||||||
).getQueryFiltersLegacy({
|
api,
|
||||||
userId: user?.Id,
|
).getQueryFiltersLegacy({
|
||||||
parentId: collectionId,
|
userId: user?.Id,
|
||||||
});
|
parentId: collectionId,
|
||||||
return response.data.Genres || [];
|
});
|
||||||
}}
|
return response.data.Genres || [];
|
||||||
set={setSelectedGenres}
|
}}
|
||||||
values={selectedGenres}
|
set={setSelectedGenres}
|
||||||
title={t("library.filters.genres")}
|
values={selectedGenres}
|
||||||
renderItemLabel={(item) => item.toString()}
|
title={t("library.filters.genres")}
|
||||||
searchFilter={(item, search) =>
|
renderItemLabel={(item) => item.toString()}
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
searchFilter={(item, search) =>
|
||||||
}
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
/>
|
}
|
||||||
),
|
/>
|
||||||
},
|
),
|
||||||
{
|
},
|
||||||
key: "year",
|
{
|
||||||
component: (
|
key: "year",
|
||||||
<FilterButton
|
component: (
|
||||||
className='mr-1'
|
<FilterButton
|
||||||
id={collectionId}
|
className='mr-1'
|
||||||
queryKey='yearFilter'
|
id={collectionId}
|
||||||
queryFn={async () => {
|
queryKey='yearFilter'
|
||||||
if (!api) return null;
|
queryFn={async () => {
|
||||||
const response = await getFilterApi(
|
if (!api) return null;
|
||||||
api,
|
const response = await getFilterApi(
|
||||||
).getQueryFiltersLegacy({
|
api,
|
||||||
userId: user?.Id,
|
).getQueryFiltersLegacy({
|
||||||
parentId: collectionId,
|
userId: user?.Id,
|
||||||
});
|
parentId: collectionId,
|
||||||
return response.data.Years || [];
|
});
|
||||||
}}
|
return response.data.Years || [];
|
||||||
set={setSelectedYears}
|
}}
|
||||||
values={selectedYears}
|
set={setSelectedYears}
|
||||||
title={t("library.filters.years")}
|
values={selectedYears}
|
||||||
renderItemLabel={(item) => item.toString()}
|
title={t("library.filters.years")}
|
||||||
searchFilter={(item, search) => item.includes(search)}
|
renderItemLabel={(item) => item.toString()}
|
||||||
/>
|
searchFilter={(item, search) => item.includes(search)}
|
||||||
),
|
/>
|
||||||
},
|
),
|
||||||
{
|
},
|
||||||
key: "tags",
|
{
|
||||||
component: (
|
key: "tags",
|
||||||
<FilterButton
|
component: (
|
||||||
className='mr-1'
|
<FilterButton
|
||||||
id={collectionId}
|
className='mr-1'
|
||||||
queryKey='tagsFilter'
|
id={collectionId}
|
||||||
queryFn={async () => {
|
queryKey='tagsFilter'
|
||||||
if (!api) return null;
|
queryFn={async () => {
|
||||||
const response = await getFilterApi(
|
if (!api) return null;
|
||||||
api,
|
const response = await getFilterApi(
|
||||||
).getQueryFiltersLegacy({
|
api,
|
||||||
userId: user?.Id,
|
).getQueryFiltersLegacy({
|
||||||
parentId: collectionId,
|
userId: user?.Id,
|
||||||
});
|
parentId: collectionId,
|
||||||
return response.data.Tags || [];
|
});
|
||||||
}}
|
return response.data.Tags || [];
|
||||||
set={setSelectedTags}
|
}}
|
||||||
values={selectedTags}
|
set={setSelectedTags}
|
||||||
title={t("library.filters.tags")}
|
values={selectedTags}
|
||||||
renderItemLabel={(item) => item.toString()}
|
title={t("library.filters.tags")}
|
||||||
searchFilter={(item, search) =>
|
renderItemLabel={(item) => item.toString()}
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
searchFilter={(item, search) =>
|
||||||
}
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
/>
|
}
|
||||||
),
|
/>
|
||||||
},
|
),
|
||||||
{
|
},
|
||||||
key: "sortBy",
|
{
|
||||||
component: (
|
key: "sortBy",
|
||||||
<FilterButton
|
component: (
|
||||||
className='mr-1'
|
<FilterButton
|
||||||
id={collectionId}
|
className='mr-1'
|
||||||
queryKey='sortBy'
|
id={collectionId}
|
||||||
queryFn={async () => sortOptions.map((s) => s.key)}
|
queryKey='sortBy'
|
||||||
set={setSortBy}
|
queryFn={async () => sortOptions.map((s) => s.key)}
|
||||||
values={sortBy}
|
set={setSortBy}
|
||||||
title={t("library.filters.sort_by")}
|
values={sortBy}
|
||||||
renderItemLabel={(item) =>
|
title={t("library.filters.sort_by")}
|
||||||
sortOptions.find((i) => i.key === item)?.value || ""
|
renderItemLabel={(item) =>
|
||||||
}
|
sortOptions.find((i) => i.key === item)?.value || ""
|
||||||
searchFilter={(item, search) =>
|
}
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
searchFilter={(item, search) =>
|
||||||
}
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
/>
|
}
|
||||||
),
|
/>
|
||||||
},
|
),
|
||||||
{
|
},
|
||||||
key: "sortOrder",
|
{
|
||||||
component: (
|
key: "sortOrder",
|
||||||
<FilterButton
|
component: (
|
||||||
className='mr-1'
|
<FilterButton
|
||||||
id={collectionId}
|
className='mr-1'
|
||||||
queryKey='sortOrder'
|
id={collectionId}
|
||||||
queryFn={async () => sortOrderOptions.map((s) => s.key)}
|
queryKey='sortOrder'
|
||||||
set={setSortOrder}
|
queryFn={async () => sortOrderOptions.map((s) => s.key)}
|
||||||
values={sortOrder}
|
set={setSortOrder}
|
||||||
title={t("library.filters.sort_order")}
|
values={sortOrder}
|
||||||
renderItemLabel={(item) =>
|
title={t("library.filters.sort_order")}
|
||||||
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
renderItemLabel={(item) =>
|
||||||
}
|
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
||||||
searchFilter={(item, search) =>
|
}
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
searchFilter={(item, search) =>
|
||||||
}
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
/>
|
}
|
||||||
),
|
/>
|
||||||
},
|
),
|
||||||
]}
|
},
|
||||||
renderItem={({ item }) => item.component}
|
]}
|
||||||
keyExtractor={(item) => item.key}
|
renderItem={({ item }) => item.component}
|
||||||
/>
|
keyExtractor={(item) => item.key}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
),
|
),
|
||||||
[
|
[
|
||||||
collectionId,
|
collectionId,
|
||||||
@@ -394,6 +393,7 @@ 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,4 +1,3 @@
|
|||||||
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";
|
||||||
@@ -21,16 +20,7 @@ const Page: React.FC = () => {
|
|||||||
const { offline } = useLocalSearchParams() as { offline?: string };
|
const { offline } = useLocalSearchParams() as { offline?: string };
|
||||||
const isOffline = offline === "true";
|
const isOffline = offline === "true";
|
||||||
|
|
||||||
// Exclude MediaSources/MediaStreams from initial fetch for faster loading
|
const { data: item, isError } = useItemQuery(id, isOffline);
|
||||||
// (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(() => {
|
||||||
@@ -100,13 +90,7 @@ 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 && (
|
{item && <ItemContent item={item} isOffline={isOffline} />}
|
||||||
<ItemContent
|
|
||||||
item={item}
|
|
||||||
isOffline={isOffline}
|
|
||||||
itemWithSources={itemWithSources}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -21,18 +21,19 @@ export default function page() {
|
|||||||
companyId: string;
|
companyId: string;
|
||||||
name: string;
|
name: string;
|
||||||
image: string;
|
image: string;
|
||||||
type: DiscoverSliderType; //This gets converted to a string because it's a url param
|
type: DiscoverSliderType;
|
||||||
};
|
};
|
||||||
|
|
||||||
const { data, fetchNextPage, hasNextPage, isLoading } = useInfiniteQuery({
|
const { data, fetchNextPage, hasNextPage } = 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(
|
||||||
`${
|
`${
|
||||||
Number(type) === DiscoverSliderType.NETWORKS
|
type === DiscoverSliderType.NETWORKS
|
||||||
? Endpoints.DISCOVER_TV_NETWORK
|
? Endpoints.DISCOVER_TV_NETWORK
|
||||||
: Endpoints.DISCOVER_MOVIES_STUDIO
|
: Endpoints.DISCOVER_MOVIES_STUDIO
|
||||||
}/${companyId}`,
|
}/${companyId}`,
|
||||||
@@ -85,7 +86,6 @@ export default function page() {
|
|||||||
fetchNextPage();
|
fetchNextPage();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
isLoading={isLoading}
|
|
||||||
logo={
|
logo={
|
||||||
<Image
|
<Image
|
||||||
id={companyId}
|
id={companyId}
|
||||||
@@ -14,43 +14,36 @@ 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 {
|
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||||
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();
|
||||||
@@ -67,12 +60,11 @@ const Page: React.FC = () => {
|
|||||||
} & Partial<MovieResult | TvResult | MovieDetails | TvDetails>;
|
} & Partial<MovieResult | TvResult | MovieDetails | TvDetails>;
|
||||||
|
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
|
const { jellyseerrApi, 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);
|
||||||
|
|
||||||
@@ -100,46 +92,6 @@ 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
|
||||||
@@ -163,10 +115,6 @@ 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);
|
||||||
@@ -180,11 +128,9 @@ const Page: React.FC = () => {
|
|||||||
mediaId: Number(result.id!),
|
mediaId: Number(result.id!),
|
||||||
mediaType: mediaType!,
|
mediaType: mediaType!,
|
||||||
tvdbId: details?.externalIds?.tvdbId,
|
tvdbId: details?.externalIds?.tvdbId,
|
||||||
...(mediaType === MediaType.TV && {
|
seasons: (details as TvDetails)?.seasons
|
||||||
seasons: (details as TvDetails)?.seasons
|
?.filter?.((s) => s.seasonNumber !== 0)
|
||||||
?.filter?.((s) => s.seasonNumber !== 0)
|
?.map?.((s) => s.seasonNumber),
|
||||||
?.map?.((s) => s.seasonNumber),
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (hasAdvancedRequestPermission) {
|
if (hasAdvancedRequestPermission) {
|
||||||
@@ -210,31 +156,11 @@ 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
|
<TouchableOpacity className='rounded-full p-2 bg-neutral-800/80'>
|
||||||
className={`rounded-full pl-1.5 ${Platform.OS === "android" ? "" : "bg-neutral-800/80"}`}
|
|
||||||
>
|
|
||||||
<ItemActions item={details} />
|
<ItemActions item={details} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
),
|
),
|
||||||
@@ -383,60 +309,6 @@ 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>
|
||||||
|
|
||||||
@@ -483,8 +355,6 @@ 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'>
|
||||||
@@ -494,25 +364,50 @@ 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 w-full'>
|
<View className='flex flex-col'>
|
||||||
<Text className='opacity-50 mb-1 text-xs'>
|
<DropdownMenu.Root>
|
||||||
{t("jellyseerr.issue_type")}
|
<DropdownMenu.Trigger>
|
||||||
</Text>
|
<View className='flex flex-col'>
|
||||||
<PlatformDropdown
|
<Text className='opacity-50 mb-1 text-xs'>
|
||||||
groups={issueTypeOptionGroups}
|
{t("jellyseerr.issue_type")}
|
||||||
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>
|
||||||
title={t("jellyseerr.types")}
|
<DropdownMenu.Content
|
||||||
open={issueTypeDropdownOpen}
|
loop={false}
|
||||||
onOpenChange={setIssueTypeDropdownOpen}
|
side='bottom'
|
||||||
/>
|
align='center'
|
||||||
|
alignOffset={0}
|
||||||
|
avoidCollisions={true}
|
||||||
|
collisionPadding={0}
|
||||||
|
sideOffset={0}
|
||||||
|
>
|
||||||
|
<DropdownMenu.Label>
|
||||||
|
{t("jellyseerr.types")}
|
||||||
|
</DropdownMenu.Label>
|
||||||
|
{Object.entries(IssueTypeName)
|
||||||
|
.reverse()
|
||||||
|
.map(([key, value], _idx) => (
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key={value}
|
||||||
|
onSelect={() =>
|
||||||
|
setIssueType(key as unknown as IssueType)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemTitle>
|
||||||
|
{value}
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
))}
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<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,15 +87,14 @@ 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")}{" "}
|
||||||
{data?.details?.birthday &&
|
{new Date(data?.details?.birthday!).toLocaleDateString(
|
||||||
new Date(data.details.birthday).toLocaleDateString(
|
`${locale}-${region}`,
|
||||||
`${locale}-${region}`,
|
{
|
||||||
{
|
year: "numeric",
|
||||||
year: "numeric",
|
month: "long",
|
||||||
month: "long",
|
day: "numeric",
|
||||||
day: "numeric",
|
},
|
||||||
},
|
)}{" "}
|
||||||
)}{" "}
|
|
||||||
| {data?.details?.placeOfBirth}
|
| {data?.details?.placeOfBirth}
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
@@ -33,6 +33,7 @@ export default function page() {
|
|||||||
<View className='flex flex-1'>
|
<View className='flex flex-1'>
|
||||||
<FlashList
|
<FlashList
|
||||||
data={channels?.Items}
|
data={channels?.Items}
|
||||||
|
estimatedItemSize={76}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
<View className='flex flex-row items-center px-4 mb-2'>
|
<View className='flex flex-row items-center px-4 mb-2'>
|
||||||
<View className='w-22 mr-4 rounded-lg overflow-hidden'>
|
<View className='w-22 mr-4 rounded-lg overflow-hidden'>
|
||||||
@@ -65,11 +65,9 @@ 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 () => {
|
||||||
if (!api || !user?.Id || !item?.Id) return [];
|
const res = await getTvShowsApi(api!).getEpisodes({
|
||||||
|
seriesId: item?.Id!,
|
||||||
const res = await getTvShowsApi(api).getEpisodes({
|
userId: user?.Id!,
|
||||||
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"],
|
||||||
@@ -1,300 +0,0 @@
|
|||||||
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}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,273 +0,0 @@
|
|||||||
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}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,321 +0,0 @@
|
|||||||
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}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ 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,
|
||||||
@@ -28,11 +27,7 @@ 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,
|
||||||
@@ -44,10 +39,8 @@ 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();
|
||||||
@@ -61,13 +54,9 @@ 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 [filterByPreference, setFilterByPreference] = useAtom(
|
const [sortOrderPreference, setOderByPreference] = useAtom(
|
||||||
FilterByPreferenceAtom,
|
|
||||||
);
|
|
||||||
const [sortOrderPreference, setOrderByPreference] = useAtom(
|
|
||||||
sortOrderPreferenceAtom,
|
sortOrderPreferenceAtom,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -88,20 +77,12 @@ 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(
|
||||||
@@ -119,28 +100,14 @@ const Page = () => {
|
|||||||
(sortOrder: SortOrderOption[]) => {
|
(sortOrder: SortOrderOption[]) => {
|
||||||
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
|
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
|
||||||
if (sortOrder[0] !== sop) {
|
if (sortOrder[0] !== sop) {
|
||||||
setOrderByPreference({
|
setOderByPreference({
|
||||||
...sortOrderPreference,
|
...sortOrderPreference,
|
||||||
[libraryId]: sortOrder[0],
|
[libraryId]: sortOrder[0],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
_setSortOrder(sortOrder);
|
_setSortOrder(sortOrder);
|
||||||
},
|
},
|
||||||
[libraryId, sortOrderPreference, setOrderByPreference, _setSortOrder],
|
[libraryId, sortOrderPreference, setOderByPreference, _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(() => {
|
||||||
@@ -201,7 +168,6 @@ 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,
|
||||||
@@ -224,7 +190,6 @@ const Page = () => {
|
|||||||
selectedTags,
|
selectedTags,
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
filterBy,
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -238,7 +203,6 @@ const Page = () => {
|
|||||||
selectedTags,
|
selectedTags,
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
filterBy,
|
|
||||||
],
|
],
|
||||||
queryFn: fetchItems,
|
queryFn: fetchItems,
|
||||||
getNextPageParam: (lastPage, pages) => {
|
getNextPageParam: (lastPage, pages) => {
|
||||||
@@ -304,167 +268,148 @@ 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(
|
||||||
() => (
|
() => (
|
||||||
<FlatList
|
<View className=''>
|
||||||
horizontal
|
<FlatList
|
||||||
showsHorizontalScrollIndicator={false}
|
horizontal
|
||||||
contentContainerStyle={{
|
showsHorizontalScrollIndicator={false}
|
||||||
display: "flex",
|
contentContainerStyle={{
|
||||||
paddingHorizontal: 15,
|
display: "flex",
|
||||||
paddingVertical: 16,
|
paddingHorizontal: 15,
|
||||||
flexDirection: "row",
|
paddingVertical: 16,
|
||||||
}}
|
flexDirection: "row",
|
||||||
data={[
|
}}
|
||||||
{
|
data={[
|
||||||
key: "reset",
|
{
|
||||||
component: <ResetFiltersButton />,
|
key: "reset",
|
||||||
},
|
component: <ResetFiltersButton />,
|
||||||
{
|
},
|
||||||
key: "genre",
|
{
|
||||||
component: (
|
key: "genre",
|
||||||
<FilterButton
|
component: (
|
||||||
className='mr-1'
|
<FilterButton
|
||||||
id={libraryId}
|
className='mr-1'
|
||||||
queryKey='genreFilter'
|
id={libraryId}
|
||||||
queryFn={async () => {
|
queryKey='genreFilter'
|
||||||
if (!api) return null;
|
queryFn={async () => {
|
||||||
const response = await getFilterApi(
|
if (!api) return null;
|
||||||
api,
|
const response = await getFilterApi(
|
||||||
).getQueryFiltersLegacy({
|
api,
|
||||||
userId: user?.Id,
|
).getQueryFiltersLegacy({
|
||||||
parentId: libraryId,
|
userId: user?.Id,
|
||||||
});
|
parentId: libraryId,
|
||||||
return response.data.Genres || [];
|
});
|
||||||
}}
|
return response.data.Genres || [];
|
||||||
set={setSelectedGenres}
|
}}
|
||||||
values={selectedGenres}
|
set={setSelectedGenres}
|
||||||
title={t("library.filters.genres")}
|
values={selectedGenres}
|
||||||
renderItemLabel={(item) => item.toString()}
|
title={t("library.filters.genres")}
|
||||||
searchFilter={(item, search) =>
|
renderItemLabel={(item) => item.toString()}
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
searchFilter={(item, search) =>
|
||||||
}
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
/>
|
}
|
||||||
),
|
/>
|
||||||
},
|
),
|
||||||
{
|
},
|
||||||
key: "year",
|
{
|
||||||
component: (
|
key: "year",
|
||||||
<FilterButton
|
component: (
|
||||||
className='mr-1'
|
<FilterButton
|
||||||
id={libraryId}
|
className='mr-1'
|
||||||
queryKey='yearFilter'
|
id={libraryId}
|
||||||
queryFn={async () => {
|
queryKey='yearFilter'
|
||||||
if (!api) return null;
|
queryFn={async () => {
|
||||||
const response = await getFilterApi(
|
if (!api) return null;
|
||||||
api,
|
const response = await getFilterApi(
|
||||||
).getQueryFiltersLegacy({
|
api,
|
||||||
userId: user?.Id,
|
).getQueryFiltersLegacy({
|
||||||
parentId: libraryId,
|
userId: user?.Id,
|
||||||
});
|
parentId: libraryId,
|
||||||
return response.data.Years || [];
|
});
|
||||||
}}
|
return response.data.Years || [];
|
||||||
set={setSelectedYears}
|
}}
|
||||||
values={selectedYears}
|
set={setSelectedYears}
|
||||||
title={t("library.filters.years")}
|
values={selectedYears}
|
||||||
renderItemLabel={(item) => item.toString()}
|
title={t("library.filters.years")}
|
||||||
searchFilter={(item, search) => item.includes(search)}
|
renderItemLabel={(item) => item.toString()}
|
||||||
/>
|
searchFilter={(item, search) => item.includes(search)}
|
||||||
),
|
/>
|
||||||
},
|
),
|
||||||
{
|
},
|
||||||
key: "tags",
|
{
|
||||||
component: (
|
key: "tags",
|
||||||
<FilterButton
|
component: (
|
||||||
className='mr-1'
|
<FilterButton
|
||||||
id={libraryId}
|
className='mr-1'
|
||||||
queryKey='tagsFilter'
|
id={libraryId}
|
||||||
queryFn={async () => {
|
queryKey='tagsFilter'
|
||||||
if (!api) return null;
|
queryFn={async () => {
|
||||||
const response = await getFilterApi(
|
if (!api) return null;
|
||||||
api,
|
const response = await getFilterApi(
|
||||||
).getQueryFiltersLegacy({
|
api,
|
||||||
userId: user?.Id,
|
).getQueryFiltersLegacy({
|
||||||
parentId: libraryId,
|
userId: user?.Id,
|
||||||
});
|
parentId: libraryId,
|
||||||
return response.data.Tags || [];
|
});
|
||||||
}}
|
return response.data.Tags || [];
|
||||||
set={setSelectedTags}
|
}}
|
||||||
values={selectedTags}
|
set={setSelectedTags}
|
||||||
title={t("library.filters.tags")}
|
values={selectedTags}
|
||||||
renderItemLabel={(item) => item.toString()}
|
title={t("library.filters.tags")}
|
||||||
searchFilter={(item, search) =>
|
renderItemLabel={(item) => item.toString()}
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
searchFilter={(item, search) =>
|
||||||
}
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
/>
|
}
|
||||||
),
|
/>
|
||||||
},
|
),
|
||||||
{
|
},
|
||||||
key: "sortBy",
|
{
|
||||||
component: (
|
key: "sortBy",
|
||||||
<FilterButton
|
component: (
|
||||||
className='mr-1'
|
<FilterButton
|
||||||
id={libraryId}
|
className='mr-1'
|
||||||
queryKey='sortBy'
|
id={libraryId}
|
||||||
queryFn={async () => sortOptions.map((s) => s.key)}
|
queryKey='sortBy'
|
||||||
set={setSortBy}
|
queryFn={async () => sortOptions.map((s) => s.key)}
|
||||||
values={sortBy}
|
set={setSortBy}
|
||||||
title={t("library.filters.sort_by")}
|
values={sortBy}
|
||||||
renderItemLabel={(item) =>
|
title={t("library.filters.sort_by")}
|
||||||
sortOptions.find((i) => i.key === item)?.value || ""
|
renderItemLabel={(item) =>
|
||||||
}
|
sortOptions.find((i) => i.key === item)?.value || ""
|
||||||
searchFilter={(item, search) =>
|
}
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
searchFilter={(item, search) =>
|
||||||
}
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
/>
|
}
|
||||||
),
|
/>
|
||||||
},
|
),
|
||||||
{
|
},
|
||||||
key: "sortOrder",
|
{
|
||||||
component: (
|
key: "sortOrder",
|
||||||
<FilterButton
|
component: (
|
||||||
className='mr-1'
|
<FilterButton
|
||||||
id={libraryId}
|
className='mr-1'
|
||||||
queryKey='sortOrder'
|
id={libraryId}
|
||||||
queryFn={async () => sortOrderOptions.map((s) => s.key)}
|
queryKey='sortOrder'
|
||||||
set={setSortOrder}
|
queryFn={async () => sortOrderOptions.map((s) => s.key)}
|
||||||
values={sortOrder}
|
set={setSortOrder}
|
||||||
title={t("library.filters.sort_order")}
|
values={sortOrder}
|
||||||
renderItemLabel={(item) =>
|
title={t("library.filters.sort_order")}
|
||||||
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
renderItemLabel={(item) =>
|
||||||
}
|
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
||||||
searchFilter={(item, search) =>
|
}
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
searchFilter={(item, search) =>
|
||||||
}
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
/>
|
}
|
||||||
),
|
/>
|
||||||
},
|
),
|
||||||
{
|
},
|
||||||
key: "filterOptions",
|
]}
|
||||||
component: (
|
renderItem={({ item }) => item.component}
|
||||||
<FilterButton
|
keyExtractor={(item) => item.key}
|
||||||
className='mr-1'
|
/>
|
||||||
id={libraryId}
|
</View>
|
||||||
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,
|
||||||
@@ -481,9 +426,6 @@ const Page = () => {
|
|||||||
sortOrder,
|
sortOrder,
|
||||||
setSortOrder,
|
setSortOrder,
|
||||||
isFetching,
|
isFetching,
|
||||||
filterBy,
|
|
||||||
setFilter,
|
|
||||||
settings,
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -511,6 +453,7 @@ 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,208 +1,85 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, View } from "react-native";
|
import { Platform, TouchableOpacity } from "react-native";
|
||||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
import { LibraryOptionsSheet } from "@/components/settings/LibraryOptionsSheet";
|
||||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function IndexLayout() {
|
export default function IndexLayout() {
|
||||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
const [optionsSheetOpen, setOptionsSheetOpen] = 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.Screen
|
<Stack>
|
||||||
name='index'
|
<Stack.Screen
|
||||||
options={{
|
name='index'
|
||||||
headerShown: !Platform.isTV,
|
options={{
|
||||||
headerTitle: t("tabs.library"),
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "none",
|
headerTitle: t("tabs.library"),
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerBlurEffect: "none",
|
||||||
headerShadowVisible: false,
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerRight: () =>
|
headerShadowVisible: false,
|
||||||
!pluginSettings?.libraryOptions?.locked &&
|
headerRight: () =>
|
||||||
!Platform.isTV && (
|
!pluginSettings?.libraryOptions?.locked &&
|
||||||
<PlatformDropdown
|
!Platform.isTV && (
|
||||||
open={dropdownOpen}
|
<TouchableOpacity
|
||||||
onOpenChange={setDropdownOpen}
|
onPress={() => setOptionsSheetOpen(true)}
|
||||||
trigger={
|
className='flex flex-row items-center justify-center w-9 h-9'
|
||||||
<View className='pl-1.5'>
|
>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name='ellipsis-horizontal-outline'
|
name='ellipsis-horizontal-outline'
|
||||||
size={24}
|
size={24}
|
||||||
color='white'
|
color='white'
|
||||||
/>
|
/>
|
||||||
</View>
|
</TouchableOpacity>
|
||||||
}
|
),
|
||||||
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 { Platform, StyleSheet, View } from "react-native";
|
import { 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,6 +39,7 @@ 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],
|
||||||
);
|
);
|
||||||
@@ -83,11 +84,11 @@ export default function index() {
|
|||||||
extraData={settings}
|
extraData={settings}
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingTop: Platform.OS === "android" ? 17 : 0,
|
paddingTop: 17,
|
||||||
paddingHorizontal: settings?.libraryOptions?.display === "row" ? 0 : 17,
|
paddingHorizontal: settings?.libraryOptions?.display === "row" ? 0 : 17,
|
||||||
paddingBottom: 150,
|
paddingBottom: 150,
|
||||||
paddingLeft: insets.left + 17,
|
paddingLeft: insets.left,
|
||||||
paddingRight: insets.right + 17,
|
paddingRight: insets.right,
|
||||||
}}
|
}}
|
||||||
data={libraries}
|
data={libraries}
|
||||||
renderItem={({ item }) => <LibraryItemCard library={item} />}
|
renderItem={({ item }) => <LibraryItemCard library={item} />}
|
||||||
@@ -104,6 +105,7 @@ export default function index() {
|
|||||||
<View className='h-4' />
|
<View className='h-4' />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
estimatedItemSize={200}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,333 +0,0 @@
|
|||||||
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: "none",
|
headerBlurEffect: "prominent",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ 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,
|
||||||
@@ -31,15 +33,12 @@ 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";
|
||||||
|
|
||||||
@@ -118,54 +117,6 @@ 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 [];
|
||||||
}
|
}
|
||||||
@@ -190,11 +141,12 @@ export default function search() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (response2.data.Items as BaseItemDto[]) || [];
|
return (response2.data.Items as BaseItemDto[]) || [];
|
||||||
} catch (_error) {
|
} catch (error) {
|
||||||
return [];
|
console.error("Error during search:", error);
|
||||||
|
return []; // Ensure an empty array is returned in case of an error
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[api, searchEngine, settings, user?.Id],
|
[api, searchEngine, settings],
|
||||||
);
|
);
|
||||||
|
|
||||||
type HeaderSearchBarRef = {
|
type HeaderSearchBarRef = {
|
||||||
@@ -332,30 +284,67 @@ export default function search() {
|
|||||||
)}
|
)}
|
||||||
<View
|
<View
|
||||||
className='flex flex-col'
|
className='flex flex-col'
|
||||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
style={{
|
||||||
|
marginTop: Platform.OS === "android" ? 16 : 0,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{jellyseerrApi && (
|
{jellyseerrApi && (
|
||||||
<View className='pl-4 pr-4 flex flex-row'>
|
<ScrollView
|
||||||
<SearchTabButtons
|
horizontal
|
||||||
searchType={searchType}
|
className='flex flex-row flex-wrap space-x-2 px-4 mb-2'
|
||||||
setSearchType={setSearchType}
|
>
|
||||||
t={t}
|
<TouchableOpacity onPress={() => setSearchType("Library")}>
|
||||||
/>
|
<Tag
|
||||||
|
text={t("search.library")}
|
||||||
|
textClass='p-1'
|
||||||
|
className={
|
||||||
|
searchType === "Library" ? "bg-purple-600" : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity onPress={() => setSearchType("Discover")}>
|
||||||
|
<Tag
|
||||||
|
text={t("search.discover")}
|
||||||
|
textClass='p-1'
|
||||||
|
className={
|
||||||
|
searchType === "Discover" ? "bg-purple-600" : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
{searchType === "Discover" &&
|
{searchType === "Discover" &&
|
||||||
!loading &&
|
!loading &&
|
||||||
noResults &&
|
noResults &&
|
||||||
debouncedSearch.length > 0 && (
|
debouncedSearch.length > 0 && (
|
||||||
<DiscoverFilters
|
<View className='flex flex-row justify-end items-center space-x-1'>
|
||||||
searchFilterId={searchFilterId}
|
<FilterButton
|
||||||
orderFilterId={orderFilterId}
|
id={searchFilterId}
|
||||||
jellyseerrOrderBy={jellyseerrOrderBy}
|
queryKey='jellyseerr_search'
|
||||||
setJellyseerrOrderBy={setJellyseerrOrderBy}
|
queryFn={async () =>
|
||||||
jellyseerrSortOrder={jellyseerrSortOrder}
|
Object.keys(JellyseerrSearchSort).filter((v) =>
|
||||||
setJellyseerrSortOrder={setJellyseerrSortOrder}
|
Number.isNaN(Number(v)),
|
||||||
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>
|
||||||
)}
|
)}
|
||||||
</View>
|
</ScrollView>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<View className='mt-2'>
|
<View className='mt-2'>
|
||||||
|
|||||||
@@ -1,297 +0,0 @@
|
|||||||
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,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,221 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,273 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
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,10 +10,8 @@ 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, View } from "react-native";
|
import { Platform } 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";
|
||||||
@@ -49,7 +47,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}
|
||||||
@@ -57,7 +55,6 @@ 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' />
|
||||||
@@ -73,7 +70,10 @@ 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")
|
||||||
: (_e) => ({ sfSymbol: "house.fill" }),
|
: ({ focused }) =>
|
||||||
|
focused
|
||||||
|
? { sfSymbol: "house.fill" }
|
||||||
|
: { sfSymbol: "house" },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
@@ -84,12 +84,14 @@ 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")
|
||||||
: (_e) => ({ sfSymbol: "magnifyingglass" }),
|
: ({ focused }) =>
|
||||||
|
focused
|
||||||
|
? { sfSymbol: "magnifyingglass" }
|
||||||
|
: { sfSymbol: "magnifyingglass" },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
@@ -98,20 +100,14 @@ export default function TabLayout() {
|
|||||||
title: t("tabs.favorites"),
|
title: t("tabs.favorites"),
|
||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS === "android"
|
Platform.OS === "android"
|
||||||
? (_e) => require("@/assets/icons/heart.fill.png")
|
? ({ focused }) =>
|
||||||
: (_e) => ({ sfSymbol: "heart.fill" }),
|
focused
|
||||||
}}
|
? require("@/assets/icons/heart.fill.png")
|
||||||
/>
|
: require("@/assets/icons/heart.png")
|
||||||
<NativeTabs.Screen
|
: ({ focused }) =>
|
||||||
name='(watchlists)'
|
focused
|
||||||
options={{
|
? { sfSymbol: "heart.fill" }
|
||||||
title: t("watchlists.title"),
|
: { sfSymbol: "heart" },
|
||||||
tabBarItemHidden:
|
|
||||||
!settings?.streamyStatsServerUrl || settings?.hideWatchlistsTab,
|
|
||||||
tabBarIcon:
|
|
||||||
Platform.OS === "android"
|
|
||||||
? (_e) => require("@/assets/icons/list.png")
|
|
||||||
: (_e) => ({ sfSymbol: "list.bullet.rectangle" }),
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
@@ -121,7 +117,10 @@ 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")
|
||||||
: (_e) => ({ sfSymbol: "rectangle.stack.fill" }),
|
: ({ focused }) =>
|
||||||
|
focused
|
||||||
|
? { sfSymbol: "rectangle.stack.fill" }
|
||||||
|
: { sfSymbol: "rectangle.stack" },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
@@ -132,12 +131,13 @@ export default function TabLayout() {
|
|||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS === "android"
|
Platform.OS === "android"
|
||||||
? (_e) => require("@/assets/icons/list.png")
|
? (_e) => require("@/assets/icons/list.png")
|
||||||
: (_e) => ({ sfSymbol: "list.dash.fill" }),
|
: ({ focused }) =>
|
||||||
|
focused
|
||||||
|
? { sfSymbol: "list.dash.fill" }
|
||||||
|
: { sfSymbol: "list.dash" },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</NativeTabs>
|
</NativeTabs>
|
||||||
<MiniPlayerBar />
|
</>
|
||||||
<MusicPlaybackEngine />
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,633 +0,0 @@
|
|||||||
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,33 +1,7 @@
|
|||||||
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,27 +1,18 @@
|
|||||||
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 { useSettings } from "@/utils/atoms/settings";
|
import { type Settings, useSettings } from "@/utils/atoms/settings";
|
||||||
import {
|
import {
|
||||||
BACKGROUND_FETCH_TASK,
|
BACKGROUND_FETCH_TASK,
|
||||||
BACKGROUND_FETCH_TASK_SESSIONS,
|
BACKGROUND_FETCH_TASK_SESSIONS,
|
||||||
@@ -35,29 +26,44 @@ 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 { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
|
||||||
import { getLocales } from "expo-localization";
|
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 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 { router, Stack, useSegments } from "expo-router";
|
import { useAtom } from "jotai";
|
||||||
import * as SplashScreen from "expo-splash-screen";
|
import { Toaster } from "sonner-native";
|
||||||
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({
|
||||||
@@ -125,7 +131,24 @@ 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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -190,26 +213,21 @@ export default function RootLayout() {
|
|||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
staleTime: 30000, // 30 seconds - data is fresh
|
staleTime: 0,
|
||||||
gcTime: 1000 * 60 * 60 * 24, // 24 hours - keep in cache for persistence
|
refetchOnMount: true,
|
||||||
|
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 _segments = useSegments();
|
const appState = useRef(AppState.currentState);
|
||||||
|
const segments = useSegments();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
i18n.changeLanguage(
|
i18n.changeLanguage(
|
||||||
@@ -238,7 +256,7 @@ function Layout() {
|
|||||||
} else console.log("No token available");
|
} else console.log("No token available");
|
||||||
}, [api, expoPushToken, user]);
|
}, [api, expoPushToken, user]);
|
||||||
|
|
||||||
const registerNotifications = useCallback(async () => {
|
async function registerNotifications() {
|
||||||
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", {
|
||||||
@@ -269,21 +287,11 @@ 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()
|
||||||
projectId: "e79219d1-797f-4fbe-9fa1-cfd360690a68",
|
.then((token: ExpoPushToken) => token && setExpoPushToken(token))
|
||||||
})
|
.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) {
|
||||||
@@ -347,93 +355,120 @@ function Layout() {
|
|||||||
responseListener.current?.remove();
|
responseListener.current?.remove();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [user]);
|
}, [user, api]);
|
||||||
|
|
||||||
|
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 (
|
||||||
<PersistQueryClientProvider
|
<QueryClientProvider client={queryClient}>
|
||||||
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>
|
||||||
<NetworkStatusProvider>
|
<PlaySettingsProvider>
|
||||||
<PlaySettingsProvider>
|
<LogProvider>
|
||||||
<LogProvider>
|
<WebSocketProvider>
|
||||||
<WebSocketProvider>
|
<DownloadProvider>
|
||||||
<DownloadProvider>
|
<BottomSheetModalProvider>
|
||||||
<MusicPlayerProvider>
|
<SystemBars style='light' hidden={false} />
|
||||||
<GlobalModalProvider>
|
<ThemeProvider value={DarkTheme}>
|
||||||
<BottomSheetModalProvider>
|
<Stack initialRouteName='(auth)/(tabs)'>
|
||||||
<ThemeProvider value={DarkTheme}>
|
<Stack.Screen
|
||||||
<SystemBars style='light' hidden={false} />
|
name='(auth)/(tabs)'
|
||||||
<Stack initialRouteName='(auth)/(tabs)'>
|
options={{
|
||||||
<Stack.Screen
|
headerShown: false,
|
||||||
name='(auth)/(tabs)'
|
title: "",
|
||||||
options={{
|
header: () => null,
|
||||||
headerShown: false,
|
}}
|
||||||
title: "",
|
/>
|
||||||
header: () => null,
|
<Stack.Screen
|
||||||
}}
|
name='(auth)/player'
|
||||||
/>
|
options={{
|
||||||
<Stack.Screen
|
headerShown: false,
|
||||||
name='(auth)/player'
|
title: "",
|
||||||
options={{
|
header: () => null,
|
||||||
headerShown: false,
|
}}
|
||||||
title: "",
|
/>
|
||||||
header: () => null,
|
<Stack.Screen
|
||||||
}}
|
name='login'
|
||||||
/>
|
options={{
|
||||||
<Stack.Screen
|
headerShown: true,
|
||||||
name='(auth)/now-playing'
|
title: "",
|
||||||
options={{
|
headerTransparent: true,
|
||||||
headerShown: false,
|
}}
|
||||||
presentation: "modal",
|
/>
|
||||||
gestureEnabled: true,
|
<Stack.Screen name='+not-found' />
|
||||||
}}
|
</Stack>
|
||||||
/>
|
<Toaster
|
||||||
<Stack.Screen
|
duration={4000}
|
||||||
name='login'
|
toastOptions={{
|
||||||
options={{
|
style: {
|
||||||
headerShown: true,
|
backgroundColor: "#262626",
|
||||||
title: "",
|
borderColor: "#363639",
|
||||||
headerTransparent: Platform.OS === "ios",
|
borderWidth: 1,
|
||||||
}}
|
},
|
||||||
/>
|
titleStyle: {
|
||||||
<Stack.Screen name='+not-found' />
|
color: "white",
|
||||||
</Stack>
|
},
|
||||||
<Toaster
|
}}
|
||||||
duration={4000}
|
closeButton
|
||||||
toastOptions={{
|
/>
|
||||||
style: {
|
</ThemeProvider>
|
||||||
backgroundColor: "#262626",
|
</BottomSheetModalProvider>
|
||||||
borderColor: "#363639",
|
</DownloadProvider>
|
||||||
borderWidth: 1,
|
</WebSocketProvider>
|
||||||
},
|
</LogProvider>
|
||||||
titleStyle: {
|
</PlaySettingsProvider>
|
||||||
color: "white",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
closeButton
|
|
||||||
/>
|
|
||||||
<GlobalModal />
|
|
||||||
</ThemeProvider>
|
|
||||||
</BottomSheetModalProvider>
|
|
||||||
</GlobalModalProvider>
|
|
||||||
</MusicPlayerProvider>
|
|
||||||
</DownloadProvider>
|
|
||||||
</WebSocketProvider>
|
|
||||||
</LogProvider>
|
|
||||||
</PlaySettingsProvider>
|
|
||||||
</NetworkStatusProvider>
|
|
||||||
</JellyfinProvider>
|
</JellyfinProvider>
|
||||||
</PersistQueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,16 +4,17 @@ 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";
|
||||||
@@ -42,14 +43,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,
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -62,13 +63,12 @@ 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);
|
||||||
}
|
}
|
||||||
}, 0);
|
}, 300);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [_apiUrl, _username, _password]);
|
}, [_apiUrl, _username, _password]);
|
||||||
@@ -82,10 +82,10 @@ const Login: React.FC = () => {
|
|||||||
onPress={() => {
|
onPress={() => {
|
||||||
removeServer();
|
removeServer();
|
||||||
}}
|
}}
|
||||||
className='flex flex-row items-center pr-2 pl-1'
|
className='flex flex-row items-center'
|
||||||
>
|
>
|
||||||
<Ionicons name='chevron-back' size={18} color={Colors.primary} />
|
<Ionicons name='chevron-back' size={18} color={Colors.primary} />
|
||||||
<Text className=' ml-1 text-purple-600'>
|
<Text className='ml-2 text-purple-600'>
|
||||||
{t("login.change_server")}
|
{t("login.change_server")}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -262,14 +262,8 @@ const Login: React.FC = () => {
|
|||||||
<Input
|
<Input
|
||||||
placeholder={t("login.username_placeholder")}
|
placeholder={t("login.username_placeholder")}
|
||||||
onChangeText={(text: string) =>
|
onChangeText={(text: string) =>
|
||||||
setCredentials((prev) => ({ ...prev, username: text }))
|
setCredentials({ ...credentials, 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'
|
||||||
@@ -278,22 +272,14 @@ 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((prev) => ({ ...prev, password: text }))
|
setCredentials({ ...credentials, 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'
|
||||||
@@ -303,17 +289,10 @@ 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
|
<Button onPress={handleLogin}>{t("login.login_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
|
||||||
@@ -355,8 +334,6 @@ 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 */}
|
||||||
@@ -394,12 +371,11 @@ const Login: React.FC = () => {
|
|||||||
// Mobile layout
|
// Mobile layout
|
||||||
<SafeAreaView style={{ flex: 1, paddingBottom: 16 }}>
|
<SafeAreaView style={{ flex: 1, paddingBottom: 16 }}>
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||||
style={{ flex: 1 }}
|
|
||||||
>
|
>
|
||||||
{api?.basePath ? (
|
{api?.basePath ? (
|
||||||
<View className='flex flex-col flex-1 justify-center'>
|
<View className='flex flex-col h-full relative items-center justify-center'>
|
||||||
<View className='px-4 w-full'>
|
<View className='px-4 -mt-20 w-full'>
|
||||||
<View className='flex flex-col space-y-2'>
|
<View className='flex flex-col space-y-2'>
|
||||||
<Text className='text-2xl font-bold -mb-2'>
|
<Text className='text-2xl font-bold -mb-2'>
|
||||||
{serverName ? (
|
{serverName ? (
|
||||||
@@ -415,17 +391,8 @@ const Login: React.FC = () => {
|
|||||||
<Input
|
<Input
|
||||||
placeholder={t("login.username_placeholder")}
|
placeholder={t("login.username_placeholder")}
|
||||||
onChangeText={(text) =>
|
onChangeText={(text) =>
|
||||||
setCredentials((prev) => ({ ...prev, username: text }))
|
setCredentials({ ...credentials, 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'
|
||||||
@@ -440,17 +407,8 @@ const Login: React.FC = () => {
|
|||||||
<Input
|
<Input
|
||||||
placeholder={t("login.password_placeholder")}
|
placeholder={t("login.password_placeholder")}
|
||||||
onChangeText={(text) =>
|
onChangeText={(text) =>
|
||||||
setCredentials((prev) => ({ ...prev, password: text }))
|
setCredentials({ ...credentials, 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'
|
||||||
@@ -464,7 +422,6 @@ 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")}
|
||||||
@@ -486,7 +443,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 flex-1 items-center justify-center w-full'>
|
<View className='flex flex-col h-full items-center justify-center w-full'>
|
||||||
<View className='flex flex-col gap-y-2 px-4 w-full -mt-36'>
|
<View className='flex flex-col gap-y-2 px-4 w-full -mt-36'>
|
||||||
<Image
|
<Image
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 18 KiB |
@@ -1,12 +0,0 @@
|
|||||||
<?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>
|
|
||||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1,12 +0,0 @@
|
|||||||
<?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>
|
|
||||||
|
Before Width: | Height: | Size: 1.6 KiB |
@@ -1,12 +0,0 @@
|
|||||||
<?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>
|
|
||||||
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -1,12 +0,0 @@
|
|||||||
<?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>
|
|
||||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1,184 +0,0 @@
|
|||||||
{
|
|
||||||
"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 { storage } from "@/utils/mmkv";
|
import { MMKV } from "react-native-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
|
||||||
(storage as any).get = function <T>(key: string): T | undefined {
|
MMKV.prototype.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 @@ declare module "react-native-mmkv" {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
(storage as any).setAny = function (key: string, value: any | undefined): void {
|
MMKV.prototype.setAny = function (key: string, value: any | undefined): void {
|
||||||
try {
|
try {
|
||||||
if (value === undefined) {
|
if (value === undefined) {
|
||||||
this.remove(key);
|
this.delete(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-worklets/plugin"],
|
plugins: ["nativewind/babel", "react-native-reanimated/plugin"],
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.3.5/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.2.7/schema.json",
|
||||||
"files": {
|
"files": {
|
||||||
"includes": [
|
"includes": [
|
||||||
"**/*",
|
"**/*",
|
||||||
@@ -8,8 +8,7 @@
|
|||||||
"!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 { View, type ViewProps } from "react-native";
|
import { Platform, View, type ViewProps } from "react-native";
|
||||||
import { RoundButton } from "@/components/RoundButton";
|
import { RoundButton } from "@/components/RoundButton";
|
||||||
import { useFavorite } from "@/hooks/useFavorite";
|
import { useFavorite } from "@/hooks/useFavorite";
|
||||||
|
|
||||||
@@ -11,12 +11,24 @@ 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"}
|
||||||
color={isFavorite ? "purple" : "white"}
|
fillColor={isFavorite ? "primary" : undefined}
|
||||||
onPress={toggleFavorite}
|
onPress={toggleFavorite}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -10,52 +10,45 @@ import { LinearGradient } from "expo-linear-gradient";
|
|||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import { Dimensions, Pressable, TouchableOpacity, View } from "react-native";
|
||||||
Pressable,
|
|
||||||
TouchableOpacity,
|
|
||||||
useWindowDimensions,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
Easing,
|
Easing,
|
||||||
interpolate,
|
|
||||||
runOnJS,
|
runOnJS,
|
||||||
type SharedValue,
|
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
useSharedValue,
|
useSharedValue,
|
||||||
withTiming,
|
withTiming,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||||
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
|
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
|
||||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
|
||||||
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
import { ItemImage } from "../common/ItemImage";
|
import { ItemImage } from "./common/ItemImage";
|
||||||
import { getItemNavigation } from "../common/TouchableItemRouter";
|
import { getItemNavigation } from "./common/TouchableItemRouter";
|
||||||
import type { SelectedOptions } from "../ItemContent";
|
import type { SelectedOptions } from "./ItemContent";
|
||||||
import { PlayButton } from "../PlayButton";
|
import { PlayButton } from "./PlayButton";
|
||||||
import { MarkAsPlayedLargeButton } from "./MarkAsPlayedLargeButton";
|
import { PlayedStatus } from "./PlayedStatus";
|
||||||
|
|
||||||
interface AppleTVCarouselProps {
|
interface AppleTVCarouselProps {
|
||||||
initialIndex?: number;
|
initialIndex?: number;
|
||||||
onItemChange?: (index: number) => void;
|
onItemChange?: (index: number) => void;
|
||||||
scrollOffset?: SharedValue<number>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { width: screenWidth, height: screenHeight } = Dimensions.get("window");
|
||||||
|
|
||||||
// Layout Constants
|
// Layout Constants
|
||||||
|
const CAROUSEL_HEIGHT = screenHeight / 1.45;
|
||||||
const GRADIENT_HEIGHT_TOP = 150;
|
const GRADIENT_HEIGHT_TOP = 150;
|
||||||
const GRADIENT_HEIGHT_BOTTOM = 150;
|
const GRADIENT_HEIGHT_BOTTOM = 150;
|
||||||
const LOGO_HEIGHT = 80;
|
const LOGO_HEIGHT = 80;
|
||||||
|
|
||||||
// Position Constants
|
// Position Constants
|
||||||
const LOGO_BOTTOM_POSITION = 260;
|
const LOGO_BOTTOM_POSITION = 210;
|
||||||
const GENRES_BOTTOM_POSITION = 220;
|
const GENRES_BOTTOM_POSITION = 170;
|
||||||
const OVERVIEW_BOTTOM_POSITION = 165;
|
const CONTROLS_BOTTOM_POSITION = 100;
|
||||||
const CONTROLS_BOTTOM_POSITION = 80;
|
const DOTS_BOTTOM_POSITION = 60;
|
||||||
const DOTS_BOTTOM_POSITION = 40;
|
|
||||||
|
|
||||||
// Size Constants
|
// Size Constants
|
||||||
const DOT_HEIGHT = 6;
|
const DOT_HEIGHT = 6;
|
||||||
@@ -65,15 +58,13 @@ const PLAY_BUTTON_SKELETON_HEIGHT = 50;
|
|||||||
const PLAYED_STATUS_SKELETON_SIZE = 40;
|
const PLAYED_STATUS_SKELETON_SIZE = 40;
|
||||||
const TEXT_SKELETON_HEIGHT = 20;
|
const TEXT_SKELETON_HEIGHT = 20;
|
||||||
const TEXT_SKELETON_WIDTH = 250;
|
const TEXT_SKELETON_WIDTH = 250;
|
||||||
const OVERVIEW_SKELETON_HEIGHT = 16;
|
|
||||||
const OVERVIEW_SKELETON_WIDTH = 400;
|
|
||||||
const _EMPTY_STATE_ICON_SIZE = 64;
|
const _EMPTY_STATE_ICON_SIZE = 64;
|
||||||
|
|
||||||
// Spacing Constants
|
// Spacing Constants
|
||||||
const HORIZONTAL_PADDING = 40;
|
const HORIZONTAL_PADDING = 40;
|
||||||
const DOT_PADDING = 2;
|
const DOT_PADDING = 2;
|
||||||
const DOT_GAP = 4;
|
const DOT_GAP = 4;
|
||||||
const CONTROLS_GAP = 10;
|
const CONTROLS_GAP = 20;
|
||||||
const _TEXT_MARGIN_TOP = 16;
|
const _TEXT_MARGIN_TOP = 16;
|
||||||
|
|
||||||
// Border Radius Constants
|
// Border Radius Constants
|
||||||
@@ -92,16 +83,13 @@ const VELOCITY_THRESHOLD = 400;
|
|||||||
|
|
||||||
// Text Constants
|
// Text Constants
|
||||||
const GENRES_FONT_SIZE = 16;
|
const GENRES_FONT_SIZE = 16;
|
||||||
const OVERVIEW_FONT_SIZE = 14;
|
|
||||||
const _EMPTY_STATE_FONT_SIZE = 18;
|
const _EMPTY_STATE_FONT_SIZE = 18;
|
||||||
const TEXT_SHADOW_RADIUS = 2;
|
const TEXT_SHADOW_RADIUS = 2;
|
||||||
const MAX_GENRES_COUNT = 2;
|
const MAX_GENRES_COUNT = 2;
|
||||||
const MAX_BUTTON_WIDTH = 300;
|
const MAX_BUTTON_WIDTH = 300;
|
||||||
const OVERVIEW_MAX_LINES = 2;
|
|
||||||
const OVERVIEW_MAX_WIDTH = "80%";
|
|
||||||
|
|
||||||
// Opacity Constants
|
// Opacity Constants
|
||||||
const OVERLAY_OPACITY = 0.3;
|
const OVERLAY_OPACITY = 0.4;
|
||||||
const DOT_INACTIVE_OPACITY = 0.6;
|
const DOT_INACTIVE_OPACITY = 0.6;
|
||||||
const TEXT_OPACITY = 0.9;
|
const TEXT_OPACITY = 0.9;
|
||||||
|
|
||||||
@@ -159,21 +147,14 @@ const DotIndicator = ({
|
|||||||
export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
||||||
initialIndex = 0,
|
initialIndex = 0,
|
||||||
onItemChange,
|
onItemChange,
|
||||||
scrollOffset,
|
|
||||||
}) => {
|
}) => {
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const user = useAtomValue(userAtom);
|
const user = useAtomValue(userAtom);
|
||||||
const { isConnected, serverConnected } = useNetworkStatus();
|
const { isConnected, serverConnected } = useNetworkStatus();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { width: screenWidth, height: screenHeight } = useWindowDimensions();
|
|
||||||
const isLandscape = screenWidth >= screenHeight;
|
|
||||||
const carouselHeight = useMemo(
|
|
||||||
() => (isLandscape ? screenHeight * 0.9 : screenHeight / 1.45),
|
|
||||||
[isLandscape, screenHeight],
|
|
||||||
);
|
|
||||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||||
const translateX = useSharedValue(-initialIndex * screenWidth);
|
const translateX = useSharedValue(-currentIndex * screenWidth);
|
||||||
|
|
||||||
const isQueryEnabled =
|
const isQueryEnabled =
|
||||||
!!api && !!user?.Id && isConnected && serverConnected === true;
|
!!api && !!user?.Id && isConnected && serverConnected === true;
|
||||||
@@ -187,7 +168,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
userId: user.Id,
|
userId: user.Id,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||||
fields: ["Genres", "Overview"],
|
fields: ["Genres"],
|
||||||
limit: 2,
|
limit: 2,
|
||||||
});
|
});
|
||||||
return response.data.Items || [];
|
return response.data.Items || [];
|
||||||
@@ -202,7 +183,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
if (!api || !user?.Id) return [];
|
if (!api || !user?.Id) return [];
|
||||||
const response = await getTvShowsApi(api).getNextUp({
|
const response = await getTvShowsApi(api).getNextUp({
|
||||||
userId: user.Id,
|
userId: user.Id,
|
||||||
fields: ["MediaSourceCount", "Genres", "Overview"],
|
fields: ["MediaSourceCount", "Genres"],
|
||||||
limit: 2,
|
limit: 2,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||||
enableResumable: false,
|
enableResumable: false,
|
||||||
@@ -221,7 +202,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
const response = await getUserLibraryApi(api).getLatestMedia({
|
const response = await getUserLibraryApi(api).getLatestMedia({
|
||||||
userId: user.Id,
|
userId: user.Id,
|
||||||
limit: 2,
|
limit: 2,
|
||||||
fields: ["PrimaryImageAspectRatio", "Path", "Genres", "Overview"],
|
fields: ["PrimaryImageAspectRatio", "Path", "Genres"],
|
||||||
imageTypeLimit: 1,
|
imageTypeLimit: 1,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||||
});
|
});
|
||||||
@@ -237,21 +218,11 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
const nextItems = nextUpData ?? [];
|
const nextItems = nextUpData ?? [];
|
||||||
const recentItems = recentlyAddedData ?? [];
|
const recentItems = recentlyAddedData ?? [];
|
||||||
|
|
||||||
const allItems = [
|
return [
|
||||||
...continueItems.slice(0, 2),
|
...continueItems.slice(0, 2),
|
||||||
...nextItems.slice(0, 2),
|
...nextItems.slice(0, 2),
|
||||||
...recentItems.slice(0, 2),
|
...recentItems.slice(0, 2),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Deduplicate by item ID to prevent duplicate keys
|
|
||||||
const seen = new Set<string>();
|
|
||||||
return allItems.filter((item) => {
|
|
||||||
if (item.Id && !seen.has(item.Id)) {
|
|
||||||
seen.add(item.Id);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
}, [continueWatchingData, nextUpData, recentlyAddedData]);
|
}, [continueWatchingData, nextUpData, recentlyAddedData]);
|
||||||
|
|
||||||
const isLoading =
|
const isLoading =
|
||||||
@@ -282,7 +253,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
if (currentItem) {
|
if (currentItem) {
|
||||||
setSelectedOptions({
|
setSelectedOptions({
|
||||||
bitrate: defaultBitrate,
|
bitrate: defaultBitrate,
|
||||||
mediaSource: defaultMediaSource ?? undefined,
|
mediaSource: defaultMediaSource,
|
||||||
subtitleIndex: defaultSubtitleIndex ?? -1,
|
subtitleIndex: defaultSubtitleIndex ?? -1,
|
||||||
audioIndex: defaultAudioIndex,
|
audioIndex: defaultAudioIndex,
|
||||||
});
|
});
|
||||||
@@ -310,11 +281,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
translateX.value = -newIndex * screenWidth;
|
translateX.value = -newIndex * screenWidth;
|
||||||
return newIndex;
|
return newIndex;
|
||||||
});
|
});
|
||||||
}, [hasItems, items, initialIndex, screenWidth, translateX]);
|
}, [hasItems, items, initialIndex, translateX]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
translateX.value = -currentIndex * screenWidth;
|
|
||||||
}, [currentIndex, screenWidth, translateX]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasItems) {
|
if (hasItems) {
|
||||||
@@ -334,7 +301,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
setCurrentIndex(index);
|
setCurrentIndex(index);
|
||||||
onItemChange?.(index);
|
onItemChange?.(index);
|
||||||
},
|
},
|
||||||
[hasItems, items, onItemChange, screenWidth, translateX],
|
[hasItems, items, onItemChange, translateX],
|
||||||
);
|
);
|
||||||
|
|
||||||
const navigateToItem = useCallback(
|
const navigateToItem = useCallback(
|
||||||
@@ -381,30 +348,6 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const togglePlayedStatus = useMarkAsPlayed(items);
|
|
||||||
|
|
||||||
const headerAnimatedStyle = useAnimatedStyle(() => {
|
|
||||||
if (!scrollOffset) return {};
|
|
||||||
return {
|
|
||||||
transform: [
|
|
||||||
{
|
|
||||||
translateY: interpolate(
|
|
||||||
scrollOffset.value,
|
|
||||||
[-carouselHeight, 0, carouselHeight],
|
|
||||||
[-carouselHeight / 2, 0, carouselHeight * 0.75],
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
scale: interpolate(
|
|
||||||
scrollOffset.value,
|
|
||||||
[-carouselHeight, 0, carouselHeight],
|
|
||||||
[2, 1, 1],
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const renderDots = () => {
|
const renderDots = () => {
|
||||||
if (!hasItems || items.length <= 1) return null;
|
if (!hasItems || items.length <= 1) return null;
|
||||||
|
|
||||||
@@ -438,7 +381,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: screenWidth,
|
width: screenWidth,
|
||||||
height: carouselHeight,
|
height: CAROUSEL_HEIGHT,
|
||||||
backgroundColor: "#000",
|
backgroundColor: "#000",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -530,36 +473,6 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Overview Skeleton */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: OVERVIEW_BOTTOM_POSITION,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
paddingHorizontal: HORIZONTAL_PADDING,
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: OVERVIEW_SKELETON_HEIGHT,
|
|
||||||
width: OVERVIEW_SKELETON_WIDTH,
|
|
||||||
backgroundColor: SKELETON_ELEMENT_COLOR,
|
|
||||||
borderRadius: TEXT_SKELETON_BORDER_RADIUS,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: OVERVIEW_SKELETON_HEIGHT,
|
|
||||||
width: OVERVIEW_SKELETON_WIDTH * 0.7,
|
|
||||||
backgroundColor: SKELETON_ELEMENT_COLOR,
|
|
||||||
borderRadius: TEXT_SKELETON_BORDER_RADIUS,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Controls Skeleton */}
|
{/* Controls Skeleton */}
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
@@ -636,30 +549,20 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
key={item.Id}
|
key={item.Id}
|
||||||
style={{
|
style={{
|
||||||
width: screenWidth,
|
width: screenWidth,
|
||||||
height: carouselHeight,
|
height: CAROUSEL_HEIGHT,
|
||||||
position: "relative",
|
position: "relative",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Background Backdrop */}
|
{/* Background Backdrop */}
|
||||||
<Animated.View
|
<ItemImage
|
||||||
style={[
|
item={item}
|
||||||
{
|
variant='Backdrop'
|
||||||
width: "100%",
|
style={{
|
||||||
height: "100%",
|
width: "100%",
|
||||||
position: "absolute",
|
height: "100%",
|
||||||
},
|
position: "absolute",
|
||||||
headerAnimatedStyle,
|
}}
|
||||||
]}
|
/>
|
||||||
>
|
|
||||||
<ItemImage
|
|
||||||
item={item}
|
|
||||||
variant='Backdrop'
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Animated.View>
|
|
||||||
|
|
||||||
{/* Dark Overlay */}
|
{/* Dark Overlay */}
|
||||||
<View
|
<View
|
||||||
@@ -786,39 +689,6 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Overview Section - for Episodes and Movies */}
|
|
||||||
{(item.Type === "Episode" || item.Type === "Movie") &&
|
|
||||||
item.Overview && (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: OVERVIEW_BOTTOM_POSITION,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
paddingHorizontal: HORIZONTAL_PADDING,
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TouchableOpacity onPress={() => navigateToItem(item)}>
|
|
||||||
<Animated.Text
|
|
||||||
numberOfLines={OVERVIEW_MAX_LINES}
|
|
||||||
style={{
|
|
||||||
color: `rgba(255, 255, 255, ${TEXT_OPACITY * 0.85})`,
|
|
||||||
fontSize: OVERVIEW_FONT_SIZE,
|
|
||||||
fontWeight: "400",
|
|
||||||
textAlign: "center",
|
|
||||||
maxWidth: OVERVIEW_MAX_WIDTH,
|
|
||||||
textShadowColor: TEXT_SHADOW_COLOR,
|
|
||||||
textShadowOffset: { width: 0, height: 1 },
|
|
||||||
textShadowRadius: TEXT_SHADOW_RADIUS,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.Overview}
|
|
||||||
</Animated.Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Controls Section */}
|
{/* Controls Section */}
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
@@ -849,10 +719,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Mark as Played */}
|
{/* Mark as Played */}
|
||||||
<MarkAsPlayedLargeButton
|
<PlayedStatus items={[item]} size='large' />
|
||||||
isPlayed={item.UserData?.Played ?? false}
|
|
||||||
onToggle={togglePlayedStatus}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -864,7 +731,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
height: carouselHeight,
|
height: CAROUSEL_HEIGHT,
|
||||||
backgroundColor: "#000",
|
backgroundColor: "#000",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
}}
|
}}
|
||||||
@@ -882,7 +749,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
height: carouselHeight, // Fixed height instead of flex: 1
|
height: CAROUSEL_HEIGHT, // Fixed height instead of flex: 1
|
||||||
backgroundColor: "#000",
|
backgroundColor: "#000",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
}}
|
}}
|
||||||
@@ -891,7 +758,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
<Animated.View
|
<Animated.View
|
||||||
style={[
|
style={[
|
||||||
{
|
{
|
||||||
height: carouselHeight, // Fixed height instead of flex: 1
|
height: CAROUSEL_HEIGHT, // Fixed height instead of flex: 1
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
width: screenWidth * items.length,
|
width: screenWidth * items.length,
|
||||||
},
|
},
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
|
||||||
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
|
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Text } from "./common/Text";
|
import { 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;
|
||||||
@@ -18,8 +20,6 @@ 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,58 +31,55 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
|||||||
[audioStreams, selected],
|
[audioStreams, selected],
|
||||||
);
|
);
|
||||||
|
|
||||||
const optionGroups: OptionGroup[] = useMemo(
|
const { t } = useTranslation();
|
||||||
() => [
|
|
||||||
{
|
|
||||||
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 (
|
||||||
<PlatformDropdown
|
<View
|
||||||
groups={optionGroups}
|
className='flex shrink'
|
||||||
trigger={trigger}
|
style={{
|
||||||
title={t("item_card.audio")}
|
minWidth: 50,
|
||||||
open={open}
|
|
||||||
onOpenChange={setOpen}
|
|
||||||
onOptionSelect={handleOptionSelect}
|
|
||||||
expoUIConfig={{
|
|
||||||
hostStyle: { flex: 1 },
|
|
||||||
}}
|
}}
|
||||||
bottomSheetConfig={{
|
>
|
||||||
enablePanDownToClose: true,
|
<DropdownMenu.Root>
|
||||||
}}
|
<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,8 +1,10 @@
|
|||||||
import { useMemo, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
|
||||||
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
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;
|
||||||
@@ -59,8 +61,6 @@ 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,59 +76,53 @@ export const BitrateSelector: React.FC<Props> = ({
|
|||||||
);
|
);
|
||||||
}, [inverted]);
|
}, [inverted]);
|
||||||
|
|
||||||
const optionGroups: OptionGroup[] = useMemo(
|
const { t } = useTranslation();
|
||||||
() => [
|
|
||||||
{
|
|
||||||
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 (
|
||||||
<PlatformDropdown
|
<View
|
||||||
groups={optionGroups}
|
className='flex shrink'
|
||||||
trigger={trigger}
|
style={{
|
||||||
title={t("item_card.quality")}
|
minWidth: 60,
|
||||||
open={open}
|
maxWidth: 200,
|
||||||
onOpenChange={setOpen}
|
|
||||||
onOptionSelect={handleOptionSelect}
|
|
||||||
expoUIConfig={{
|
|
||||||
hostStyle: { flex: 1 },
|
|
||||||
}}
|
}}
|
||||||
bottomSheetConfig={{
|
>
|
||||||
enablePanDownToClose: true,
|
<DropdownMenu.Root>
|
||||||
}}
|
<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,6 +2,7 @@ import type React from "react";
|
|||||||
import {
|
import {
|
||||||
type PropsWithChildren,
|
type PropsWithChildren,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
@@ -17,58 +18,6 @@ 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;
|
||||||
@@ -77,8 +26,7 @@ export interface ButtonProps
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
children?: string | ReactNode;
|
children?: string | ReactNode;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
color?: "purple" | "red" | "black" | "transparent" | "white";
|
color?: "purple" | "red" | "black" | "transparent";
|
||||||
variant?: "solid" | "border";
|
|
||||||
iconRight?: ReactNode;
|
iconRight?: ReactNode;
|
||||||
iconLeft?: ReactNode;
|
iconLeft?: ReactNode;
|
||||||
justify?: "center" | "between";
|
justify?: "center" | "between";
|
||||||
@@ -91,7 +39,6 @@ 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,
|
||||||
@@ -109,13 +56,23 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
|||||||
useNativeDriver: true,
|
useNativeDriver: true,
|
||||||
}).start();
|
}).start();
|
||||||
|
|
||||||
const colorClasses = getColorClasses(color, variant, focused);
|
const colorClasses = useMemo(() => {
|
||||||
|
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'
|
||||||
@@ -141,12 +98,10 @@ 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
|
||||||
${colorClasses}
|
${focused ? "bg-purple-500 border-2 border-white" : "bg-purple-600 border border-purple-700"}
|
||||||
${className}`}
|
${className}`}
|
||||||
>
|
>
|
||||||
<Text className={`${textColorClass} text-xl font-bold`}>
|
<Text className='text-white text-xl font-bold'>{children}</Text>
|
||||||
{children}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -180,7 +135,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
|||||||
{iconLeft ? iconLeft : <View className='w-4' />}
|
{iconLeft ? iconLeft : <View className='w-4' />}
|
||||||
<Text
|
<Text
|
||||||
className={`
|
className={`
|
||||||
${textColorClass} font-bold text-base
|
text-white font-bold text-base
|
||||||
${disabled ? "text-gray-300" : ""}
|
${disabled ? "text-gray-300" : ""}
|
||||||
${textClassName}
|
${textClassName}
|
||||||
${iconRight ? "mr-2" : ""}
|
${iconRight ? "mr-2" : ""}
|
||||||
|
|||||||
@@ -64,8 +64,9 @@ 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, downloadedItems } = useDownload();
|
const { processes, startBackgroundDownload, getDownloadedItems } =
|
||||||
const downloadedFiles = downloadedItems;
|
useDownload();
|
||||||
|
const downloadedFiles = getDownloadedItems();
|
||||||
|
|
||||||
const [selectedOptions, setSelectedOptions] = useState<
|
const [selectedOptions, setSelectedOptions] = useState<
|
||||||
SelectedOptions | undefined
|
SelectedOptions | undefined
|
||||||
@@ -89,8 +90,11 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
bottomSheetModalRef.current?.present();
|
bottomSheetModalRef.current?.present();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSheetChanges = useCallback((_index: number) => {
|
const handleSheetChanges = useCallback((index: number) => {
|
||||||
// Modal state tracking handled by BottomSheetModal
|
// Ensure modal is fully dismissed when index is -1
|
||||||
|
if (index === -1) {
|
||||||
|
// Modal is fully closed
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const closeModal = useCallback(() => {
|
const closeModal = useCallback(() => {
|
||||||
@@ -109,7 +113,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedOptions(() => ({
|
setSelectedOptions(() => ({
|
||||||
bitrate: defaultBitrate,
|
bitrate: defaultBitrate,
|
||||||
mediaSource: defaultMediaSource ?? undefined,
|
mediaSource: defaultMediaSource,
|
||||||
subtitleIndex: defaultSubtitleIndex ?? -1,
|
subtitleIndex: defaultSubtitleIndex ?? -1,
|
||||||
audioIndex: defaultAudioIndex,
|
audioIndex: defaultAudioIndex,
|
||||||
}));
|
}));
|
||||||
@@ -132,15 +136,13 @@ 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), 0);
|
return itemsProcesses.reduce((acc, p) => acc + p.progress, 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) /
|
||||||
@@ -155,13 +157,6 @@ 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 = () => {
|
||||||
@@ -261,12 +256,13 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
throw new Error("No item id");
|
throw new Error("No item id");
|
||||||
}
|
}
|
||||||
|
|
||||||
closeModal();
|
// Ensure modal is dismissed before starting download
|
||||||
|
await closeModal();
|
||||||
|
|
||||||
// Wait for modal dismiss animation to complete
|
// Small delay to ensure modal is fully dismissed
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
initiateDownload(...itemsToDownload);
|
initiateDownload(...itemsToDownload);
|
||||||
}, 300);
|
}, 100);
|
||||||
} 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"),
|
||||||
@@ -286,14 +282,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const renderButtonContent = () => {
|
const renderButtonContent = () => {
|
||||||
// For single item downloads, show progress if item is being processed
|
if (processes.length > 0 && itemsProcesses.length > 0) {
|
||||||
// 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 />
|
||||||
) : (
|
) : (
|
||||||
@@ -347,6 +336,9 @@ 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
|
||||||
@@ -367,18 +359,16 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className='flex flex-col space-y-2 w-full'>
|
<View className='flex flex-col space-y-2 w-full items-start'>
|
||||||
<View className='items-start'>
|
<BitrateSelector
|
||||||
<BitrateSelector
|
inverted
|
||||||
inverted
|
onChange={(val) =>
|
||||||
onChange={(val) =>
|
setSelectedOptions(
|
||||||
setSelectedOptions(
|
(prev) => prev && { ...prev, bitrate: val },
|
||||||
(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>
|
||||||
@@ -390,23 +380,21 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
)}
|
)}
|
||||||
{itemsNotDownloaded.length === 1 && (
|
{itemsNotDownloaded.length === 1 && (
|
||||||
<View>
|
<View>
|
||||||
<View className='items-start'>
|
<MediaSourceSelector
|
||||||
<MediaSourceSelector
|
item={items[0]}
|
||||||
item={items[0]}
|
onChange={(val) =>
|
||||||
onChange={(val) =>
|
setSelectedOptions(
|
||||||
setSelectedOptions(
|
(prev) =>
|
||||||
(prev) =>
|
prev && {
|
||||||
prev && {
|
...prev,
|
||||||
...prev,
|
mediaSource: val,
|
||||||
mediaSource: val,
|
},
|
||||||
},
|
)
|
||||||
)
|
}
|
||||||
}
|
selected={selectedOptions?.mediaSource}
|
||||||
selected={selectedOptions?.mediaSource}
|
/>
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
{selectedOptions?.mediaSource && (
|
{selectedOptions?.mediaSource && (
|
||||||
<View className='flex flex-col space-y-2 items-start'>
|
<View className='flex flex-col space-y-2'>
|
||||||
<AudioTrackSelector
|
<AudioTrackSelector
|
||||||
source={selectedOptions.mediaSource}
|
source={selectedOptions.mediaSource}
|
||||||
onChange={(val) => {
|
onChange={(val) => {
|
||||||
@@ -439,7 +427,11 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Button onPress={acceptDownloadOptions} color='purple'>
|
<Button
|
||||||
|
className='mt-auto'
|
||||||
|
onPress={acceptDownloadOptions}
|
||||||
|
color='purple'
|
||||||
|
>
|
||||||
{t("item_card.download.download_button")}
|
{t("item_card.download.download_button")}
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,203 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
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,10 +29,13 @@ 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 { AddToWatchlist } from "./AddToWatchlist";
|
import { BitrateSheet } from "./BitRateSheet";
|
||||||
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;
|
||||||
|
|
||||||
@@ -46,17 +49,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, itemWithSources }) => {
|
({ item, isOffline }) => {
|
||||||
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 });
|
||||||
|
|
||||||
@@ -67,23 +70,18 @@ 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(itemWithSources ?? item, settings);
|
} = useDefaultPlaySettings(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]);
|
||||||
@@ -92,7 +90,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedOptions(() => ({
|
setSelectedOptions(() => ({
|
||||||
bitrate: defaultBitrate,
|
bitrate: defaultBitrate,
|
||||||
mediaSource: defaultMediaSource ?? undefined,
|
mediaSource: defaultMediaSource,
|
||||||
subtitleIndex: defaultSubtitleIndex ?? -1,
|
subtitleIndex: defaultSubtitleIndex ?? -1,
|
||||||
audioIndex: defaultAudioIndex,
|
audioIndex: defaultAudioIndex,
|
||||||
}));
|
}));
|
||||||
@@ -104,7 +102,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!Platform.isTV && itemWithSources) {
|
if (!Platform.isTV) {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () =>
|
headerRight: () =>
|
||||||
item &&
|
item &&
|
||||||
@@ -114,19 +112,14 @@ 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={itemWithSources} size='large' />
|
<DownloadSingleItem item={item} 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>
|
||||||
@@ -136,34 +129,21 @@ 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={itemWithSources} size='large' />
|
<DownloadSingleItem item={item} 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) {
|
||||||
@@ -185,7 +165,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ParallaxScrollView
|
<ParallaxScrollView
|
||||||
className='flex-1'
|
className={`flex-1 ${loading ? "opacity-0" : "opacity-100"}`}
|
||||||
headerHeight={headerHeight}
|
headerHeight={headerHeight}
|
||||||
headerImage={
|
headerImage={
|
||||||
<View style={[{ flex: 1 }]}>
|
<View style={[{ flex: 1 }]}>
|
||||||
@@ -212,8 +192,8 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
width: "100%",
|
width: "100%",
|
||||||
}}
|
}}
|
||||||
contentFit='contain'
|
contentFit='contain'
|
||||||
onLoad={onLogoLoad}
|
onLoad={() => setLoadingLogo(false)}
|
||||||
onError={onLogoLoad}
|
onError={() => setLoadingLogo(false)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<View />
|
<View />
|
||||||
@@ -221,27 +201,76 @@ 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 pt-2 mb-2 shrink'>
|
<View className='flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink'>
|
||||||
<ItemHeader item={item} className='mb-2' />
|
<ItemHeader item={item} className='mb-2' />
|
||||||
|
{item.Type !== "Program" && !Platform.isTV && !isOffline && (
|
||||||
<View className='flex flex-row px-0 mb-2 justify-between space-x-2'>
|
<View className='flex flex-row items-center justify-start w-full h-16'>
|
||||||
<PlayButton
|
<BitrateSheet
|
||||||
selectedOptions={selectedOptions}
|
className='mr-1'
|
||||||
item={item}
|
onChange={(val) =>
|
||||||
isOffline={isOffline}
|
setSelectedOptions(
|
||||||
colors={itemColors}
|
(prev) => prev && { ...prev, bitrate: val },
|
||||||
/>
|
)
|
||||||
<View className='w-1' />
|
}
|
||||||
{!isOffline && (
|
selected={selectedOptions.bitrate}
|
||||||
<MediaSourceButton
|
|
||||||
selectedOptions={selectedOptions}
|
|
||||||
setSelectedOptions={setSelectedOptions}
|
|
||||||
item={itemWithSources}
|
|
||||||
colors={itemColors}
|
|
||||||
/>
|
/>
|
||||||
)}
|
<MediaSourceSheet
|
||||||
</View>
|
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
|
||||||
|
className='grow'
|
||||||
|
selectedOptions={selectedOptions}
|
||||||
|
item={item}
|
||||||
|
isOffline={isOffline}
|
||||||
|
colors={itemColors}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{item.Type === "Episode" && (
|
{item.Type === "Episode" && (
|
||||||
<SeasonEpisodesCarousel
|
<SeasonEpisodesCarousel
|
||||||
item={item}
|
item={item}
|
||||||
@@ -250,21 +279,33 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isOffline &&
|
{!isOffline && (
|
||||||
selectedOptions.mediaSource?.MediaStreams &&
|
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
|
||||||
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-2' />
|
<CurrentSeries item={item} className='mb-4' />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ItemPeopleSections item={item} isOffline={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,12 +183,6 @@ 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
|
||||||
@@ -201,15 +195,6 @@ 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={
|
||||||
|
|||||||
@@ -1,193 +0,0 @@
|
|||||||
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,11 +2,13 @@ import type {
|
|||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
|
||||||
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
|
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Text } from "./common/Text";
|
import { 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;
|
||||||
@@ -21,7 +23,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) => {
|
||||||
@@ -44,60 +46,50 @@ 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 (
|
||||||
<PlatformDropdown
|
<View
|
||||||
groups={optionGroups}
|
className='flex shrink'
|
||||||
trigger={trigger}
|
style={{
|
||||||
title={t("item_card.video")}
|
minWidth: 50,
|
||||||
open={open}
|
|
||||||
onOpenChange={setOpen}
|
|
||||||
onOptionSelect={handleOptionSelect}
|
|
||||||
expoUIConfig={{
|
|
||||||
hostStyle: { flex: 1 },
|
|
||||||
}}
|
}}
|
||||||
bottomSheetConfig={{
|
>
|
||||||
enablePanDownToClose: true,
|
<DropdownMenu.Root>
|
||||||
}}
|
<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,7 +3,6 @@ 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";
|
||||||
@@ -11,18 +10,16 @@ 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
|
||||||
}) => {
|
}) => {
|
||||||
@@ -30,6 +27,19 @@ 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 () => {
|
||||||
@@ -62,34 +72,29 @@ 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: actorName ?? "" })}
|
{t("item_card.more_with", { name: actor?.Name })}
|
||||||
</Text>
|
</Text>
|
||||||
<HorizontalScroll
|
<HorizontalScroll
|
||||||
data={items}
|
data={items}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
height={POSTER_CAROUSEL_HEIGHT}
|
height={247}
|
||||||
renderItem={renderItem}
|
renderItem={(item: BaseItemDto, idx: number) => (
|
||||||
|
<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-4'
|
className='relative flex-1 bg-transparent pb-24'
|
||||||
>
|
>
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
// Background Linear Gradient
|
// Background Linear Gradient
|
||||||
|
|||||||
@@ -1,337 +0,0 @@
|
|||||||
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,12 +1,11 @@
|
|||||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
import { Feather, Ionicons, MaterialCommunityIcons } 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, Platform, TouchableOpacity, View } from "react-native";
|
import { Alert, TouchableOpacity, View } from "react-native";
|
||||||
import CastContext, {
|
import CastContext, {
|
||||||
CastButton,
|
CastButton,
|
||||||
PlayServicesState,
|
PlayServicesState,
|
||||||
@@ -25,8 +24,6 @@ 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";
|
||||||
@@ -36,11 +33,10 @@ 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 { Button } from "./Button";
|
import type { Button } from "./Button";
|
||||||
import { Text } from "./common/Text";
|
|
||||||
import type { SelectedOptions } from "./ItemContent";
|
import type { SelectedOptions } from "./ItemContent";
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof TouchableOpacity> {
|
interface Props extends React.ComponentProps<typeof Button> {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
selectedOptions: SelectedOptions;
|
selectedOptions: SelectedOptions;
|
||||||
isOffline?: boolean;
|
isOffline?: boolean;
|
||||||
@@ -55,12 +51,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);
|
||||||
@@ -90,9 +86,12 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
[router, isOffline],
|
[router, isOffline],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleNormalPlayFlow = useCallback(async () => {
|
const onPress = 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() ?? "",
|
||||||
@@ -166,7 +165,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
api,
|
api,
|
||||||
item,
|
item,
|
||||||
deviceProfile: enableH265 ? chromecasth265 : chromecast,
|
deviceProfile: enableH265 ? chromecasth265 : chromecast,
|
||||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks ?? 0,
|
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
||||||
userId: user.Id,
|
userId: user.Id,
|
||||||
audioStreamIndex: selectedOptions.audioIndex,
|
audioStreamIndex: selectedOptions.audioIndex,
|
||||||
maxStreamingBitrate: selectedOptions.bitrate?.value,
|
maxStreamingBitrate: selectedOptions.bitrate?.value,
|
||||||
@@ -274,117 +273,6 @@ 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(() => {
|
||||||
@@ -472,6 +360,9 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
[startColor.value.text, endColor.value.text],
|
[startColor.value.text, endColor.value.text],
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
/**
|
||||||
|
* *********************
|
||||||
|
*/
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -479,7 +370,8 @@ 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 flex-1"}
|
className={"relative"}
|
||||||
|
{...props}
|
||||||
>
|
>
|
||||||
<View className='absolute w-full h-full top-0 left-0 rounded-full z-10 overflow-hidden'>
|
<View className='absolute w-full h-full top-0 left-0 rounded-full z-10 overflow-hidden'>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
@@ -507,11 +399,7 @@ 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(
|
{runtimeTicksToMinutes(item?.RunTimeTicks)}
|
||||||
(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} />
|
||||||
@@ -522,6 +410,15 @@ 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>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user