mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-18 17:18:11 +00:00
Compare commits
55 Commits
fix/github
...
0.51.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24d04c1003 | ||
|
|
7da52441ab | ||
|
|
70268e6120 | ||
|
|
96fbb9fe1f | ||
|
|
3b104b91fc | ||
|
|
e4134d6f9a | ||
|
|
5b2e7b3883 | ||
|
|
1fde3c82a3 | ||
|
|
054fb05651 | ||
|
|
a2058a8009 | ||
|
|
d22827bc9b | ||
|
|
4121502bfe | ||
|
|
b6e59aab01 | ||
|
|
ab3465aec5 | ||
|
|
b1da9f8777 | ||
|
|
36d24176ae | ||
|
|
bfdc2c053b | ||
|
|
245c9597c4 | ||
|
|
966a8e8f24 | ||
|
|
f941c88457 | ||
|
|
bd4e5bb70a | ||
|
|
9334263414 | ||
|
|
4ae3c44d02 | ||
|
|
4fb3fb195c | ||
|
|
e8089cfd20 | ||
|
|
039bf9729a | ||
|
|
3ff7c47b7f | ||
|
|
1d8d92175a | ||
|
|
60b0040681 | ||
|
|
9cd55cf544 | ||
|
|
090e0cb170 | ||
|
|
85d707ef45 | ||
|
|
792eef20a9 | ||
|
|
6487c8b5a1 | ||
|
|
baa96d222f | ||
|
|
74d86b5d12 | ||
|
|
d1795c9df8 | ||
|
|
149609f46e | ||
|
|
cf269ba83e | ||
|
|
24d5fdefdf | ||
|
|
c05cef295e | ||
|
|
3c57829360 | ||
|
|
06349a4319 | ||
|
|
55ac9ae9d4 | ||
|
|
c8bdcc4df0 | ||
|
|
e7013edd84 | ||
|
|
991b45de06 | ||
|
|
97fe899cb0 | ||
|
|
86d7642dca | ||
|
|
631a5ef94e | ||
|
|
8b8b928837 | ||
|
|
56a3c62ed2 | ||
|
|
82683407da | ||
|
|
7b146e30bd | ||
|
|
5f48bec0f2 |
9
.github/ISSUE_TEMPLATE/issue_report.yml
vendored
9
.github/ISSUE_TEMPLATE/issue_report.yml
vendored
@@ -77,13 +77,8 @@ body:
|
|||||||
label: Streamyfin Version
|
label: Streamyfin Version
|
||||||
description: What version of Streamyfin are you running?
|
description: What version of Streamyfin are you running?
|
||||||
options:
|
options:
|
||||||
|
- 0.47.1
|
||||||
- 0.30.2
|
- 0.30.2
|
||||||
- 0.29.0
|
|
||||||
- 0.28.0
|
|
||||||
- 0.27.0
|
|
||||||
- 0.26.1
|
|
||||||
- 0.26.0
|
|
||||||
- 0.25.0
|
|
||||||
- older
|
- older
|
||||||
- TestFlight/Development build
|
- TestFlight/Development build
|
||||||
validations:
|
validations:
|
||||||
@@ -116,4 +111,4 @@ body:
|
|||||||
id: additional-info
|
id: additional-info
|
||||||
attributes:
|
attributes:
|
||||||
label: Additional information
|
label: Additional information
|
||||||
description: Any additional context that might help us understand and reproduce the issue.
|
description: Any additional context that might help us understand and reproduce the issue.
|
||||||
|
|||||||
2
.github/workflows/build-apps.yml
vendored
2
.github/workflows/build-apps.yml
vendored
@@ -192,7 +192,7 @@ jobs:
|
|||||||
run: bun run prebuild
|
run: bun run prebuild
|
||||||
|
|
||||||
- name: 🔧 Setup Xcode
|
- name: 🔧 Setup Xcode
|
||||||
uses: maxim-lobanov/setup-xcode@v1
|
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1
|
||||||
with:
|
with:
|
||||||
xcode-version: "26.0.1"
|
xcode-version: "26.0.1"
|
||||||
|
|
||||||
|
|||||||
4
.github/workflows/ci-codeql.yml
vendored
4
.github/workflows/ci-codeql.yml
vendored
@@ -25,10 +25,6 @@ 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@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
|
||||||
|
|||||||
2
.github/workflows/linting.yml
vendored
2
.github/workflows/linting.yml
vendored
@@ -107,7 +107,7 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: "🟢 Setup Node.js"
|
- name: "🟢 Setup Node.js"
|
||||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||||
with:
|
with:
|
||||||
node-version: '24.x'
|
node-version: '24.x'
|
||||||
|
|
||||||
|
|||||||
2
.github/workflows/update-issue-form.yml
vendored
2
.github/workflows/update-issue-form.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
|||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
|
|
||||||
- name: "🟢 Setup Node.js"
|
- name: "🟢 Setup Node.js"
|
||||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||||
with:
|
with:
|
||||||
node-version: '24.x'
|
node-version: '24.x'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -51,6 +51,7 @@ npm-debug.*
|
|||||||
.ruby-lsp
|
.ruby-lsp
|
||||||
.cursor/
|
.cursor/
|
||||||
.claude/
|
.claude/
|
||||||
|
CLAUDE.md
|
||||||
|
|
||||||
# Environment and Configuration
|
# Environment and Configuration
|
||||||
expo-env.d.ts
|
expo-env.d.ts
|
||||||
@@ -66,3 +67,6 @@ streamyfin-4fec1-firebase-adminsdk.json
|
|||||||
# Version and Backup Files
|
# Version and Backup Files
|
||||||
/version-backup-*
|
/version-backup-*
|
||||||
modules/background-downloader/android/build/*
|
modules/background-downloader/android/build/*
|
||||||
|
/modules/sf-player/android/build
|
||||||
|
/modules/music-controls/android/build
|
||||||
|
/modules/mpv-player/android/build
|
||||||
|
|||||||
119
CLAUDE.md
Normal file
119
CLAUDE.md
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native). It supports mobile (iOS/Android) and TV platforms, with features including offline downloads, Chromecast support, and Jellyseerr integration.
|
||||||
|
|
||||||
|
## Development Commands
|
||||||
|
|
||||||
|
**CRITICAL: Always use `bun` for package management. Never use `npm`, `yarn`, or `npx`.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Setup
|
||||||
|
bun i && bun run submodule-reload
|
||||||
|
|
||||||
|
# Development builds
|
||||||
|
bun run prebuild # Mobile prebuild
|
||||||
|
bun run ios # Run iOS
|
||||||
|
bun run android # Run Android
|
||||||
|
|
||||||
|
# TV builds (suffix with :tv)
|
||||||
|
bun run prebuild:tv
|
||||||
|
bun run ios:tv
|
||||||
|
bun run android:tv
|
||||||
|
|
||||||
|
# Code quality
|
||||||
|
bun run typecheck # TypeScript check
|
||||||
|
bun run check # BiomeJS check
|
||||||
|
bun run lint # BiomeJS lint + fix
|
||||||
|
bun run format # BiomeJS format
|
||||||
|
bun run test # Run all checks (typecheck, lint, format, doctor)
|
||||||
|
|
||||||
|
# iOS-specific
|
||||||
|
bun run ios:install-metal-toolchain # Fix "missing Metal Toolchain" build errors
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Runtime**: Bun
|
||||||
|
- **Framework**: React Native (Expo SDK 54)
|
||||||
|
- **Language**: TypeScript (strict mode)
|
||||||
|
- **State Management**: Jotai (global state atoms) + React Query (server state)
|
||||||
|
- **API**: Jellyfin SDK (`@jellyfin/sdk`)
|
||||||
|
- **Navigation**: Expo Router (file-based)
|
||||||
|
- **Linting/Formatting**: BiomeJS
|
||||||
|
- **Storage**: react-native-mmkv
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### File Structure
|
||||||
|
|
||||||
|
- `app/` - Expo Router screens with file-based routing
|
||||||
|
- `components/` - Reusable UI components
|
||||||
|
- `providers/` - React Context providers
|
||||||
|
- `hooks/` - Custom React hooks
|
||||||
|
- `utils/` - Utilities including Jotai atoms
|
||||||
|
- `modules/` - Native modules (vlc-player, mpv-player, background-downloader)
|
||||||
|
- `translations/` - i18n translation files
|
||||||
|
|
||||||
|
### Key Patterns
|
||||||
|
|
||||||
|
**State Management**:
|
||||||
|
- Global state uses Jotai atoms in `utils/atoms/`
|
||||||
|
- `settingsAtom` in `utils/atoms/settings.ts` for app settings
|
||||||
|
- `apiAtom` and `userAtom` in `providers/JellyfinProvider.tsx` for auth state
|
||||||
|
- Server state uses React Query with `@tanstack/react-query`
|
||||||
|
|
||||||
|
**Jellyfin API Access**:
|
||||||
|
- Use `apiAtom` from `JellyfinProvider` for authenticated API calls
|
||||||
|
- Access user via `userAtom`
|
||||||
|
- Use Jellyfin SDK utilities from `@jellyfin/sdk/lib/utils/api`
|
||||||
|
|
||||||
|
**Navigation**:
|
||||||
|
- File-based routing in `app/` directory
|
||||||
|
- Tab navigation: `(home)`, `(search)`, `(favorites)`, `(libraries)`, `(watchlists)`
|
||||||
|
- Shared routes use parenthesized groups like `(home,libraries,search,favorites,watchlists)`
|
||||||
|
|
||||||
|
**Providers** (wrapping order in `app/_layout.tsx`):
|
||||||
|
1. JotaiProvider
|
||||||
|
2. QueryClientProvider
|
||||||
|
3. JellyfinProvider (auth, API)
|
||||||
|
4. NetworkStatusProvider
|
||||||
|
5. PlaySettingsProvider
|
||||||
|
6. WebSocketProvider
|
||||||
|
7. DownloadProvider
|
||||||
|
8. MusicPlayerProvider
|
||||||
|
|
||||||
|
### Native Modules
|
||||||
|
|
||||||
|
Located in `modules/`:
|
||||||
|
- `vlc-player` - VLC video player integration
|
||||||
|
- `mpv-player` - MPV video player integration (iOS)
|
||||||
|
- `background-downloader` - Background download functionality
|
||||||
|
- `sf-player` - Swift player module
|
||||||
|
|
||||||
|
### Path Aliases
|
||||||
|
|
||||||
|
Use `@/` prefix for imports (configured in `tsconfig.json`):
|
||||||
|
```typescript
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
```
|
||||||
|
|
||||||
|
## Coding Standards
|
||||||
|
|
||||||
|
- Use TypeScript for all files (no .js)
|
||||||
|
- Use functional React components with hooks
|
||||||
|
- Use Jotai atoms for global state, React Query for server state
|
||||||
|
- Follow BiomeJS formatting rules (2-space indent, semicolons, LF line endings)
|
||||||
|
- Handle both mobile and TV navigation patterns
|
||||||
|
- Use existing atoms, hooks, and utilities before creating new ones
|
||||||
|
- Use Conventional Commits: `feat(scope):`, `fix(scope):`, `chore(scope):`
|
||||||
|
|
||||||
|
## Platform Considerations
|
||||||
|
|
||||||
|
- TV version uses `:tv` suffix for scripts
|
||||||
|
- Platform checks: `Platform.isTV`, `Platform.OS === "android"` or `"ios"`
|
||||||
|
- Some features disabled on TV (e.g., notifications, Chromecast)
|
||||||
@@ -70,6 +70,7 @@ Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) To
|
|||||||
<a href="https://apps.apple.com/app/streamyfin/id6593660679?l=en-GB"><img height=50 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/></a>
|
<a href="https://apps.apple.com/app/streamyfin/id6593660679?l=en-GB"><img height=50 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/></a>
|
||||||
<a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin"><img height=50 alt="Get Streamyfin on Google Play Store" src="./assets/Google_Play_Store_badge_EN.svg"/></a>
|
<a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin"><img height=50 alt="Get Streamyfin on Google Play Store" src="./assets/Google_Play_Store_badge_EN.svg"/></a>
|
||||||
<a href="https://github.com/streamyfin/streamyfin/releases/latest"><img height=50 alt="Get Streamyfin on Github" src="./assets/Download_on_Github_.png"/></a>
|
<a href="https://github.com/streamyfin/streamyfin/releases/latest"><img height=50 alt="Get Streamyfin on Github" src="./assets/Download_on_Github_.png"/></a>
|
||||||
|
<a href="https://apps.obtainium.imranr.dev/redirect.html?r=obtainium://add/https://github.com/streamyfin/streamyfin"><img height=50 alt="Add Streamyfin to Obtainium" src="./assets/Download_with_Obtainium.png"/></a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
### 🧪 Beta Testing
|
### 🧪 Beta Testing
|
||||||
@@ -104,6 +105,7 @@ You can contribute translations directly on our [Crowdin project page](https://c
|
|||||||
1. Use node `>20`
|
1. Use node `>20`
|
||||||
2. Install dependencies `bun i && bun run submodule-reload`
|
2. Install dependencies `bun i && bun run submodule-reload`
|
||||||
3. Make sure you have xcode and/or android studio installed. (follow the guides for expo: https://docs.expo.dev/workflow/android-studio-emulator/)
|
3. Make sure you have xcode and/or android studio installed. (follow the guides for expo: https://docs.expo.dev/workflow/android-studio-emulator/)
|
||||||
|
- If iOS builds fail with `missing Metal Toolchain` (KSPlayer shaders), run `npm run ios:install-metal-toolchain` once
|
||||||
4. Install BiomeJS extension in VSCode/Your IDE (https://biomejs.dev/)
|
4. Install BiomeJS extension in VSCode/Your IDE (https://biomejs.dev/)
|
||||||
4. run `npm run prebuild`
|
4. run `npm run prebuild`
|
||||||
5. Create an expo dev build by running `npm run ios` or `npm run android`. This will open a simulator on your computer and run the app
|
5. Create an expo dev build by running `npm run ios` or `npm run android`. This will open a simulator on your computer and run the app
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ module.exports = ({ config }) => {
|
|||||||
"react-native-google-cast",
|
"react-native-google-cast",
|
||||||
{ useDefaultExpandedMediaControls: true },
|
{ useDefaultExpandedMediaControls: true },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// KSPlayer for iOS (GPU acceleration + native PiP)
|
||||||
|
config.plugins.push("./plugins/withKSPlayer.js");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only override googleServicesFile if env var is set
|
// Only override googleServicesFile if env var is set
|
||||||
|
|||||||
23
app.json
23
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.47.1",
|
"version": "0.51.0",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "streamyfin",
|
"scheme": "streamyfin",
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"jsEngine": "hermes",
|
"jsEngine": "hermes",
|
||||||
"versionCode": 84,
|
"versionCode": 91,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/images/icon-android-plain.png",
|
"foregroundImage": "./assets/images/icon-android-plain.png",
|
||||||
"monochromeImage": "./assets/images/icon-android-themed.png",
|
"monochromeImage": "./assets/images/icon-android-themed.png",
|
||||||
@@ -53,29 +53,16 @@
|
|||||||
"@react-native-tvos/config-tv",
|
"@react-native-tvos/config-tv",
|
||||||
"expo-router",
|
"expo-router",
|
||||||
"expo-font",
|
"expo-font",
|
||||||
[
|
"./plugins/withExcludeMedia3Dash.js",
|
||||||
"react-native-video",
|
|
||||||
{
|
|
||||||
"enableNotificationControls": true,
|
|
||||||
"enableBackgroundAudio": true,
|
|
||||||
"androidExtensions": {
|
|
||||||
"useExoplayerRtsp": false,
|
|
||||||
"useExoplayerSmoothStreaming": false,
|
|
||||||
"useExoplayerHls": true,
|
|
||||||
"useExoplayerDash": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[
|
[
|
||||||
"expo-build-properties",
|
"expo-build-properties",
|
||||||
{
|
{
|
||||||
"ios": {
|
"ios": {
|
||||||
"deploymentTarget": "15.6",
|
"deploymentTarget": "15.6"
|
||||||
"useFrameworks": "static"
|
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"buildArchs": ["arm64-v8a", "x86_64"],
|
"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",
|
||||||
|
|||||||
212
app/(auth)/(tabs)/(favorites)/see-all.tsx
Normal file
212
app/(auth)/(tabs)/(favorites)/see-all.tsx
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import type { Api } from "@jellyfin/sdk";
|
||||||
|
import type {
|
||||||
|
BaseItemDto,
|
||||||
|
BaseItemKind,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
|
import { Stack, useLocalSearchParams } from "expo-router";
|
||||||
|
import { t } from "i18next";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useCallback, useMemo } from "react";
|
||||||
|
import { useWindowDimensions, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
|
type FavoriteTypes =
|
||||||
|
| "Series"
|
||||||
|
| "Movie"
|
||||||
|
| "Episode"
|
||||||
|
| "Video"
|
||||||
|
| "BoxSet"
|
||||||
|
| "Playlist";
|
||||||
|
|
||||||
|
const favoriteTypes: readonly FavoriteTypes[] = [
|
||||||
|
"Series",
|
||||||
|
"Movie",
|
||||||
|
"Episode",
|
||||||
|
"Video",
|
||||||
|
"BoxSet",
|
||||||
|
"Playlist",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function isFavoriteType(value: unknown): value is FavoriteTypes {
|
||||||
|
return (
|
||||||
|
typeof value === "string" &&
|
||||||
|
(favoriteTypes as readonly string[]).includes(value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FavoritesSeeAllScreen() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { width: screenWidth } = useWindowDimensions();
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
|
const searchParams = useLocalSearchParams<{
|
||||||
|
type?: string;
|
||||||
|
title?: string;
|
||||||
|
}>();
|
||||||
|
const typeParam = searchParams.type;
|
||||||
|
const titleParam = searchParams.title;
|
||||||
|
|
||||||
|
const itemType = useMemo(() => {
|
||||||
|
if (!isFavoriteType(typeParam)) return null;
|
||||||
|
return typeParam as BaseItemKind;
|
||||||
|
}, [typeParam]);
|
||||||
|
|
||||||
|
const headerTitle = useMemo(() => {
|
||||||
|
if (typeof titleParam === "string" && titleParam.trim().length > 0)
|
||||||
|
return titleParam;
|
||||||
|
return "";
|
||||||
|
}, [titleParam]);
|
||||||
|
|
||||||
|
const pageSize = 50;
|
||||||
|
|
||||||
|
const fetchItems = useCallback(
|
||||||
|
async ({ pageParam }: { pageParam: number }): Promise<BaseItemDto[]> => {
|
||||||
|
if (!api || !user?.Id || !itemType) return [];
|
||||||
|
|
||||||
|
const response = await getItemsApi(api as Api).getItems({
|
||||||
|
userId: user.Id,
|
||||||
|
sortBy: ["SeriesSortName", "SortName"],
|
||||||
|
sortOrder: ["Ascending"],
|
||||||
|
filters: ["IsFavorite"],
|
||||||
|
recursive: true,
|
||||||
|
fields: ["PrimaryImageAspectRatio"],
|
||||||
|
collapseBoxSetItems: false,
|
||||||
|
excludeLocationTypes: ["Virtual"],
|
||||||
|
enableTotalRecordCount: true,
|
||||||
|
startIndex: pageParam,
|
||||||
|
limit: pageSize,
|
||||||
|
includeItemTypes: [itemType],
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data.Items || [];
|
||||||
|
},
|
||||||
|
[api, itemType, user?.Id],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
|
||||||
|
useInfiniteQuery({
|
||||||
|
queryKey: ["favorites", "see-all", itemType],
|
||||||
|
queryFn: ({ pageParam = 0 }) => fetchItems({ pageParam }),
|
||||||
|
getNextPageParam: (lastPage, pages) => {
|
||||||
|
if (!lastPage || lastPage.length < pageSize) return undefined;
|
||||||
|
return pages.reduce((acc, page) => acc + page.length, 0);
|
||||||
|
},
|
||||||
|
initialPageParam: 0,
|
||||||
|
enabled: !!api && !!user?.Id && !!itemType,
|
||||||
|
});
|
||||||
|
|
||||||
|
const flatData = useMemo(() => data?.pages.flat() ?? [], [data]);
|
||||||
|
|
||||||
|
const nrOfCols = useMemo(() => {
|
||||||
|
if (screenWidth < 350) return 2;
|
||||||
|
if (screenWidth < 600) return 3;
|
||||||
|
if (screenWidth < 900) return 5;
|
||||||
|
return 6;
|
||||||
|
}, [screenWidth]);
|
||||||
|
|
||||||
|
const renderItem = useCallback(
|
||||||
|
({ item, index }: { item: BaseItemDto; index: number }) => (
|
||||||
|
<TouchableItemRouter
|
||||||
|
item={item}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
alignSelf:
|
||||||
|
index % nrOfCols === 0
|
||||||
|
? "flex-end"
|
||||||
|
: (index + 1) % nrOfCols === 0
|
||||||
|
? "flex-start"
|
||||||
|
: "center",
|
||||||
|
width: "89%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ItemPoster item={item} />
|
||||||
|
<ItemCardText item={item} />
|
||||||
|
</View>
|
||||||
|
</TouchableItemRouter>
|
||||||
|
),
|
||||||
|
[nrOfCols],
|
||||||
|
);
|
||||||
|
|
||||||
|
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||||
|
|
||||||
|
const handleEndReached = useCallback(() => {
|
||||||
|
if (hasNextPage) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
}, [fetchNextPage, hasNextPage]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen
|
||||||
|
options={{
|
||||||
|
headerTitle: headerTitle,
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTransparent: true,
|
||||||
|
headerShadowVisible: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{!itemType ? (
|
||||||
|
<View className='flex-1 items-center justify-center px-6'>
|
||||||
|
<Text className='text-neutral-500'>
|
||||||
|
{t("favorites.noData", { defaultValue: "No items found." })}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : isLoading ? (
|
||||||
|
<View className='justify-center items-center h-full'>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<FlashList
|
||||||
|
data={flatData}
|
||||||
|
renderItem={renderItem}
|
||||||
|
keyExtractor={keyExtractor}
|
||||||
|
numColumns={nrOfCols}
|
||||||
|
onEndReached={handleEndReached}
|
||||||
|
onEndReachedThreshold={0.8}
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingBottom: 24,
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}}
|
||||||
|
ItemSeparatorComponent={() => (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View className='flex flex-col items-center justify-center h-full py-12'>
|
||||||
|
<Text className='font-bold text-xl text-neutral-500'>
|
||||||
|
{t("home.no_items", { defaultValue: "No items" })}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
ListFooterComponent={
|
||||||
|
isFetching ? (
|
||||||
|
<View style={{ paddingVertical: 16 }}>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -166,6 +166,24 @@ export default function IndexLayout() {
|
|||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<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
|
<Stack.Screen
|
||||||
name='settings/appearance/hide-libraries/page'
|
name='settings/appearance/hide-libraries/page'
|
||||||
options={{
|
options={{
|
||||||
@@ -238,6 +256,42 @@ export default function IndexLayout() {
|
|||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<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
|
<Stack.Screen
|
||||||
name='settings/intro/page'
|
name='settings/intro/page'
|
||||||
options={{
|
options={{
|
||||||
|
|||||||
@@ -70,6 +70,11 @@ export default function settings() {
|
|||||||
showArrow
|
showArrow
|
||||||
title={t("home.settings.audio_subtitles.title")}
|
title={t("home.settings.audio_subtitles.title")}
|
||||||
/>
|
/>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => router.push("/settings/music/page")}
|
||||||
|
showArrow
|
||||||
|
title={t("home.settings.music.title")}
|
||||||
|
/>
|
||||||
<ListItem
|
<ListItem
|
||||||
onPress={() => router.push("/settings/appearance/page")}
|
onPress={() => router.push("/settings/appearance/page")}
|
||||||
showArrow
|
showArrow
|
||||||
|
|||||||
177
app/(auth)/(tabs)/(home)/settings/music/page.tsx
Normal file
177
app/(auth)/(tabs)/(home)/settings/music/page.tsx
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform, ScrollView, View } from "react-native";
|
||||||
|
import { Switch } from "react-native-gesture-handler";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
|
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
|
const CACHE_SIZE_OPTIONS = [
|
||||||
|
{ label: "100 MB", value: 100 },
|
||||||
|
{ label: "250 MB", value: 250 },
|
||||||
|
{ label: "500 MB", value: 500 },
|
||||||
|
{ label: "1 GB", value: 1024 },
|
||||||
|
{ label: "2 GB", value: 2048 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const LOOKAHEAD_COUNT_OPTIONS = [
|
||||||
|
{ label: "1 song", value: 1 },
|
||||||
|
{ label: "2 songs", value: 2 },
|
||||||
|
{ label: "3 songs", value: 3 },
|
||||||
|
{ label: "5 songs", value: 5 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function MusicSettingsPage() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const cacheSizeOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
options: CACHE_SIZE_OPTIONS.map((option) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: option.label,
|
||||||
|
value: String(option.value),
|
||||||
|
selected: option.value === settings?.audioMaxCacheSizeMB,
|
||||||
|
onPress: () => updateSettings({ audioMaxCacheSizeMB: option.value }),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[settings?.audioMaxCacheSizeMB, updateSettings],
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentCacheSizeLabel =
|
||||||
|
CACHE_SIZE_OPTIONS.find((o) => o.value === settings?.audioMaxCacheSizeMB)
|
||||||
|
?.label ?? `${settings?.audioMaxCacheSizeMB} MB`;
|
||||||
|
|
||||||
|
const lookaheadCountOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
options: LOOKAHEAD_COUNT_OPTIONS.map((option) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: option.label,
|
||||||
|
value: String(option.value),
|
||||||
|
selected: option.value === settings?.audioLookaheadCount,
|
||||||
|
onPress: () => updateSettings({ audioLookaheadCount: option.value }),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[settings?.audioLookaheadCount, updateSettings],
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentLookaheadLabel =
|
||||||
|
LOOKAHEAD_COUNT_OPTIONS.find(
|
||||||
|
(o) => o.value === settings?.audioLookaheadCount,
|
||||||
|
)?.label ?? `${settings?.audioLookaheadCount} songs`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className='p-4 flex flex-col'
|
||||||
|
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||||
|
>
|
||||||
|
<ListGroup
|
||||||
|
title={t("home.settings.music.playback_title")}
|
||||||
|
description={
|
||||||
|
<Text className='text-[#8E8D91] text-xs'>
|
||||||
|
{t("home.settings.music.playback_description")}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.music.prefer_downloaded")}
|
||||||
|
disabled={pluginSettings?.preferLocalAudio?.locked}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
value={settings.preferLocalAudio}
|
||||||
|
disabled={pluginSettings?.preferLocalAudio?.locked}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateSettings({ preferLocalAudio: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
</ListGroup>
|
||||||
|
|
||||||
|
<View className='mt-4'>
|
||||||
|
<ListGroup
|
||||||
|
title={t("home.settings.music.caching_title")}
|
||||||
|
description={
|
||||||
|
<Text className='text-[#8E8D91] text-xs'>
|
||||||
|
{t("home.settings.music.caching_description")}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.music.lookahead_enabled")}
|
||||||
|
disabled={pluginSettings?.audioLookaheadEnabled?.locked}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
value={settings.audioLookaheadEnabled}
|
||||||
|
disabled={pluginSettings?.audioLookaheadEnabled?.locked}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateSettings({ audioLookaheadEnabled: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.music.lookahead_count")}
|
||||||
|
disabled={
|
||||||
|
pluginSettings?.audioLookaheadCount?.locked ||
|
||||||
|
!settings.audioLookaheadEnabled
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PlatformDropdown
|
||||||
|
groups={lookaheadCountOptions}
|
||||||
|
trigger={
|
||||||
|
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||||
|
<Text className='mr-1 text-[#8E8D91]'>
|
||||||
|
{currentLookaheadLabel}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name='chevron-expand-sharp'
|
||||||
|
size={18}
|
||||||
|
color='#5A5960'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
title={t("home.settings.music.lookahead_count")}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.music.max_cache_size")}
|
||||||
|
disabled={pluginSettings?.audioMaxCacheSizeMB?.locked}
|
||||||
|
>
|
||||||
|
<PlatformDropdown
|
||||||
|
groups={cacheSizeOptions}
|
||||||
|
trigger={
|
||||||
|
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||||
|
<Text className='mr-1 text-[#8E8D91]'>
|
||||||
|
{currentCacheSizeLabel}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name='chevron-expand-sharp'
|
||||||
|
size={18}
|
||||||
|
color='#5A5960'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
title={t("home.settings.music.max_cache_size")}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
</ListGroup>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { ScrollView } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
|
import { KefinTweaksSettings } from "@/components/settings/KefinTweaks";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const { pluginSettings } = useSettings();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DisabledSetting
|
||||||
|
disabled={pluginSettings?.useKefinTweaks?.locked === true}
|
||||||
|
className='px-4'
|
||||||
|
>
|
||||||
|
<KefinTweaksSettings />
|
||||||
|
</DisabledSetting>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -60,7 +60,7 @@ export default function page() {
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [navigation, value]);
|
}, [navigation, value, pluginSettings?.marlinServerUrl?.locked, t]);
|
||||||
|
|
||||||
if (!settings) return null;
|
if (!settings) return null;
|
||||||
|
|
||||||
@@ -75,7 +75,10 @@ export default function page() {
|
|||||||
<DisabledSetting disabled={disabled} className='px-4'>
|
<DisabledSetting disabled={disabled} className='px-4'>
|
||||||
<ListGroup>
|
<ListGroup>
|
||||||
<DisabledSetting
|
<DisabledSetting
|
||||||
disabled={pluginSettings?.searchEngine?.locked === true}
|
disabled={
|
||||||
|
pluginSettings?.searchEngine?.locked === true ||
|
||||||
|
!!pluginSettings?.streamyStatsServerUrl?.value
|
||||||
|
}
|
||||||
showText={!pluginSettings?.marlinServerUrl?.locked}
|
showText={!pluginSettings?.marlinServerUrl?.locked}
|
||||||
>
|
>
|
||||||
<ListItem
|
<ListItem
|
||||||
@@ -89,6 +92,7 @@ export default function page() {
|
|||||||
>
|
>
|
||||||
<Switch
|
<Switch
|
||||||
value={settings.searchEngine === "Marlin"}
|
value={settings.searchEngine === "Marlin"}
|
||||||
|
disabled={!!pluginSettings?.streamyStatsServerUrl?.value}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
updateSettings({
|
updateSettings({
|
||||||
searchEngine: value ? "Marlin" : "Jellyfin",
|
searchEngine: value ? "Marlin" : "Jellyfin",
|
||||||
|
|||||||
262
app/(auth)/(tabs)/(home)/settings/plugins/streamystats/page.tsx
Normal file
262
app/(auth)/(tabs)/(home)/settings/plugins/streamystats/page.tsx
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useNavigation } from "expo-router";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Linking,
|
||||||
|
ScrollView,
|
||||||
|
Switch,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const {
|
||||||
|
settings,
|
||||||
|
updateSettings,
|
||||||
|
pluginSettings,
|
||||||
|
refreshStreamyfinPluginSettings,
|
||||||
|
} = useSettings();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Local state for all editable fields
|
||||||
|
const [url, setUrl] = useState<string>(settings?.streamyStatsServerUrl || "");
|
||||||
|
const [useForSearch, setUseForSearch] = useState<boolean>(
|
||||||
|
settings?.searchEngine === "Streamystats",
|
||||||
|
);
|
||||||
|
const [movieRecs, setMovieRecs] = useState<boolean>(
|
||||||
|
settings?.streamyStatsMovieRecommendations ?? false,
|
||||||
|
);
|
||||||
|
const [seriesRecs, setSeriesRecs] = useState<boolean>(
|
||||||
|
settings?.streamyStatsSeriesRecommendations ?? false,
|
||||||
|
);
|
||||||
|
const [promotedWatchlists, setPromotedWatchlists] = useState<boolean>(
|
||||||
|
settings?.streamyStatsPromotedWatchlists ?? false,
|
||||||
|
);
|
||||||
|
const [hideWatchlistsTab, setHideWatchlistsTab] = useState<boolean>(
|
||||||
|
settings?.hideWatchlistsTab ?? false,
|
||||||
|
);
|
||||||
|
|
||||||
|
const isUrlLocked = pluginSettings?.streamyStatsServerUrl?.locked === true;
|
||||||
|
const isStreamystatsEnabled = !!url;
|
||||||
|
|
||||||
|
const onSave = useCallback(() => {
|
||||||
|
const cleanUrl = url.endsWith("/") ? url.slice(0, -1) : url;
|
||||||
|
updateSettings({
|
||||||
|
streamyStatsServerUrl: cleanUrl,
|
||||||
|
searchEngine: useForSearch ? "Streamystats" : "Jellyfin",
|
||||||
|
streamyStatsMovieRecommendations: movieRecs,
|
||||||
|
streamyStatsSeriesRecommendations: seriesRecs,
|
||||||
|
streamyStatsPromotedWatchlists: promotedWatchlists,
|
||||||
|
hideWatchlistsTab: hideWatchlistsTab,
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["streamystats"] });
|
||||||
|
toast.success(t("home.settings.plugins.streamystats.toasts.saved"));
|
||||||
|
}, [
|
||||||
|
url,
|
||||||
|
useForSearch,
|
||||||
|
movieRecs,
|
||||||
|
seriesRecs,
|
||||||
|
promotedWatchlists,
|
||||||
|
hideWatchlistsTab,
|
||||||
|
updateSettings,
|
||||||
|
queryClient,
|
||||||
|
t,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Set up header save button
|
||||||
|
useEffect(() => {
|
||||||
|
navigation.setOptions({
|
||||||
|
headerRight: () => (
|
||||||
|
<TouchableOpacity onPress={onSave}>
|
||||||
|
<Text className='text-blue-500 font-medium'>
|
||||||
|
{t("home.settings.plugins.streamystats.save")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}, [navigation, onSave, t]);
|
||||||
|
|
||||||
|
const handleClearStreamystats = useCallback(() => {
|
||||||
|
setUrl("");
|
||||||
|
setUseForSearch(false);
|
||||||
|
setMovieRecs(false);
|
||||||
|
setSeriesRecs(false);
|
||||||
|
setPromotedWatchlists(false);
|
||||||
|
setHideWatchlistsTab(false);
|
||||||
|
updateSettings({
|
||||||
|
streamyStatsServerUrl: "",
|
||||||
|
searchEngine: "Jellyfin",
|
||||||
|
streamyStatsMovieRecommendations: false,
|
||||||
|
streamyStatsSeriesRecommendations: false,
|
||||||
|
streamyStatsPromotedWatchlists: false,
|
||||||
|
hideWatchlistsTab: false,
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["streamystats"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||||
|
toast.success(t("home.settings.plugins.streamystats.toasts.disabled"));
|
||||||
|
}, [updateSettings, queryClient, t]);
|
||||||
|
|
||||||
|
const handleOpenLink = () => {
|
||||||
|
Linking.openURL("https://github.com/fredrikburmester/streamystats");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRefreshFromServer = useCallback(async () => {
|
||||||
|
const newPluginSettings = await refreshStreamyfinPluginSettings(true);
|
||||||
|
// Update local state with new values
|
||||||
|
const newUrl = newPluginSettings?.streamyStatsServerUrl?.value || "";
|
||||||
|
setUrl(newUrl);
|
||||||
|
if (newUrl) {
|
||||||
|
setUseForSearch(true);
|
||||||
|
}
|
||||||
|
toast.success(t("home.settings.plugins.streamystats.toasts.refreshed"));
|
||||||
|
}, [refreshStreamyfinPluginSettings, t]);
|
||||||
|
|
||||||
|
if (!settings) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className='px-4'>
|
||||||
|
<ListGroup className='flex-1'>
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.plugins.streamystats.url")}
|
||||||
|
disabledByAdmin={isUrlLocked}
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
editable={!isUrlLocked}
|
||||||
|
className='text-white text-right flex-1'
|
||||||
|
placeholder={t(
|
||||||
|
"home.settings.plugins.streamystats.server_url_placeholder",
|
||||||
|
)}
|
||||||
|
value={url}
|
||||||
|
keyboardType='url'
|
||||||
|
returnKeyType='done'
|
||||||
|
autoCapitalize='none'
|
||||||
|
textContentType='URL'
|
||||||
|
onChangeText={setUrl}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
</ListGroup>
|
||||||
|
|
||||||
|
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||||
|
{t("home.settings.plugins.streamystats.streamystats_search_hint")}{" "}
|
||||||
|
<Text className='text-blue-500' onPress={handleOpenLink}>
|
||||||
|
{t(
|
||||||
|
"home.settings.plugins.streamystats.read_more_about_streamystats",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<ListGroup
|
||||||
|
title={t("home.settings.plugins.streamystats.features_title")}
|
||||||
|
className='mt-4'
|
||||||
|
>
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.plugins.streamystats.enable_search")}
|
||||||
|
disabledByAdmin={pluginSettings?.searchEngine?.locked === true}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
value={useForSearch}
|
||||||
|
disabled={!isStreamystatsEnabled}
|
||||||
|
onValueChange={setUseForSearch}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem
|
||||||
|
title={t(
|
||||||
|
"home.settings.plugins.streamystats.enable_movie_recommendations",
|
||||||
|
)}
|
||||||
|
disabledByAdmin={
|
||||||
|
pluginSettings?.streamyStatsMovieRecommendations?.locked === true
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
value={movieRecs}
|
||||||
|
onValueChange={setMovieRecs}
|
||||||
|
disabled={!isStreamystatsEnabled}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem
|
||||||
|
title={t(
|
||||||
|
"home.settings.plugins.streamystats.enable_series_recommendations",
|
||||||
|
)}
|
||||||
|
disabledByAdmin={
|
||||||
|
pluginSettings?.streamyStatsSeriesRecommendations?.locked === true
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
value={seriesRecs}
|
||||||
|
onValueChange={setSeriesRecs}
|
||||||
|
disabled={!isStreamystatsEnabled}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem
|
||||||
|
title={t(
|
||||||
|
"home.settings.plugins.streamystats.enable_promoted_watchlists",
|
||||||
|
)}
|
||||||
|
disabledByAdmin={
|
||||||
|
pluginSettings?.streamyStatsPromotedWatchlists?.locked === true
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
value={promotedWatchlists}
|
||||||
|
onValueChange={setPromotedWatchlists}
|
||||||
|
disabled={!isStreamystatsEnabled}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<ListItem
|
||||||
|
title={t("home.settings.plugins.streamystats.hide_watchlists_tab")}
|
||||||
|
disabledByAdmin={pluginSettings?.hideWatchlistsTab?.locked === true}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
value={hideWatchlistsTab}
|
||||||
|
onValueChange={setHideWatchlistsTab}
|
||||||
|
disabled={!isStreamystatsEnabled}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
</ListGroup>
|
||||||
|
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||||
|
{t("home.settings.plugins.streamystats.home_sections_hint")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleRefreshFromServer}
|
||||||
|
className='mt-6 py-3 rounded-xl bg-neutral-800'
|
||||||
|
>
|
||||||
|
<Text className='text-center text-blue-500'>
|
||||||
|
{t("home.settings.plugins.streamystats.refresh_from_server")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* Disable button - only show if URL is not locked and Streamystats is enabled */}
|
||||||
|
{!isUrlLocked && isStreamystatsEnabled && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleClearStreamystats}
|
||||||
|
className='mt-3 mb-4 py-3 rounded-xl bg-neutral-800'
|
||||||
|
>
|
||||||
|
<Text className='text-center text-red-500'>
|
||||||
|
{t("home.settings.plugins.streamystats.disable_streamystats")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ItemFields } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
@@ -20,7 +21,16 @@ const Page: React.FC = () => {
|
|||||||
const { offline } = useLocalSearchParams() as { offline?: string };
|
const { offline } = useLocalSearchParams() as { offline?: string };
|
||||||
const isOffline = offline === "true";
|
const isOffline = offline === "true";
|
||||||
|
|
||||||
const { data: item, isError } = useItemQuery(id, isOffline);
|
// Exclude MediaSources/MediaStreams from initial fetch for faster loading
|
||||||
|
// (especially important for plugins like Gelato)
|
||||||
|
const { data: item, isError } = useItemQuery(id, isOffline, undefined, [
|
||||||
|
ItemFields.MediaSources,
|
||||||
|
ItemFields.MediaSourceCount,
|
||||||
|
ItemFields.MediaStreams,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Lazily preload item with full media sources in background
|
||||||
|
const { data: itemWithSources } = useItemQuery(id, isOffline, undefined, []);
|
||||||
|
|
||||||
const opacity = useSharedValue(1);
|
const opacity = useSharedValue(1);
|
||||||
const animatedStyle = useAnimatedStyle(() => {
|
const animatedStyle = useAnimatedStyle(() => {
|
||||||
@@ -90,7 +100,13 @@ const Page: React.FC = () => {
|
|||||||
<View className='h-12 bg-neutral-900 rounded-lg w-full mb-2' />
|
<View className='h-12 bg-neutral-900 rounded-lg w-full mb-2' />
|
||||||
<View className='h-24 bg-neutral-900 rounded-lg mb-1 w-full' />
|
<View className='h-24 bg-neutral-900 rounded-lg mb-1 w-full' />
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
{item && <ItemContent item={item} isOffline={isOffline} />}
|
{item && (
|
||||||
|
<ItemContent
|
||||||
|
item={item}
|
||||||
|
isOffline={isOffline}
|
||||||
|
itemWithSources={itemWithSources}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -21,19 +21,18 @@ export default function page() {
|
|||||||
companyId: string;
|
companyId: string;
|
||||||
name: string;
|
name: string;
|
||||||
image: string;
|
image: string;
|
||||||
type: DiscoverSliderType;
|
type: DiscoverSliderType; //This gets converted to a string because it's a url param
|
||||||
};
|
};
|
||||||
|
|
||||||
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
const { data, fetchNextPage, hasNextPage, isLoading } = useInfiniteQuery({
|
||||||
queryKey: ["jellyseerr", "company", type, companyId],
|
queryKey: ["jellyseerr", "company", type, companyId],
|
||||||
queryFn: async ({ pageParam }) => {
|
queryFn: async ({ pageParam }) => {
|
||||||
const params: any = {
|
const params: any = {
|
||||||
page: Number(pageParam),
|
page: Number(pageParam),
|
||||||
};
|
};
|
||||||
|
|
||||||
return jellyseerrApi?.discover(
|
return jellyseerrApi?.discover(
|
||||||
`${
|
`${
|
||||||
type === DiscoverSliderType.NETWORKS
|
Number(type) === DiscoverSliderType.NETWORKS
|
||||||
? Endpoints.DISCOVER_TV_NETWORK
|
? Endpoints.DISCOVER_TV_NETWORK
|
||||||
: Endpoints.DISCOVER_MOVIES_STUDIO
|
: Endpoints.DISCOVER_MOVIES_STUDIO
|
||||||
}/${companyId}`,
|
}/${companyId}`,
|
||||||
@@ -86,6 +85,7 @@ export default function page() {
|
|||||||
fetchNextPage();
|
fetchNextPage();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
isLoading={isLoading}
|
||||||
logo={
|
logo={
|
||||||
<Image
|
<Image
|
||||||
id={companyId}
|
id={companyId}
|
||||||
@@ -14,6 +14,7 @@ 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";
|
||||||
@@ -33,8 +34,16 @@ import {
|
|||||||
type IssueType,
|
type IssueType,
|
||||||
IssueTypeName,
|
IssueTypeName,
|
||||||
} from "@/utils/jellyseerr/server/constants/issue";
|
} from "@/utils/jellyseerr/server/constants/issue";
|
||||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
import {
|
||||||
|
MediaRequestStatus,
|
||||||
|
MediaType,
|
||||||
|
} from "@/utils/jellyseerr/server/constants/media";
|
||||||
|
import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
|
||||||
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
import 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 { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||||
import type {
|
import type {
|
||||||
MovieResult,
|
MovieResult,
|
||||||
@@ -58,7 +67,7 @@ const Page: React.FC = () => {
|
|||||||
} & Partial<MovieResult | TvResult | MovieDetails | TvDetails>;
|
} & Partial<MovieResult | TvResult | MovieDetails | TvDetails>;
|
||||||
|
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const { jellyseerrApi, requestMedia } = useJellyseerr();
|
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
|
||||||
|
|
||||||
const [issueType, setIssueType] = useState<IssueType>();
|
const [issueType, setIssueType] = useState<IssueType>();
|
||||||
const [issueMessage, setIssueMessage] = useState<string>();
|
const [issueMessage, setIssueMessage] = useState<string>();
|
||||||
@@ -91,6 +100,46 @@ const Page: React.FC = () => {
|
|||||||
const [canRequest, hasAdvancedRequestPermission] =
|
const [canRequest, hasAdvancedRequestPermission] =
|
||||||
useJellyseerrCanRequest(details);
|
useJellyseerrCanRequest(details);
|
||||||
|
|
||||||
|
const canManageRequests = useMemo(() => {
|
||||||
|
if (!jellyseerrUser) return false;
|
||||||
|
return hasPermission(
|
||||||
|
Permission.MANAGE_REQUESTS,
|
||||||
|
jellyseerrUser.permissions,
|
||||||
|
);
|
||||||
|
}, [jellyseerrUser]);
|
||||||
|
|
||||||
|
const pendingRequest = useMemo(() => {
|
||||||
|
return details?.mediaInfo?.requests?.find(
|
||||||
|
(r: MediaRequest) => r.status === MediaRequestStatus.PENDING,
|
||||||
|
);
|
||||||
|
}, [details]);
|
||||||
|
|
||||||
|
const handleApproveRequest = useCallback(async () => {
|
||||||
|
if (!pendingRequest?.id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await jellyseerrApi?.approveRequest(pendingRequest.id);
|
||||||
|
toast.success(t("jellyseerr.toasts.request_approved"));
|
||||||
|
refetch();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(t("jellyseerr.toasts.failed_to_approve_request"));
|
||||||
|
console.error("Failed to approve request:", error);
|
||||||
|
}
|
||||||
|
}, [jellyseerrApi, pendingRequest, refetch, t]);
|
||||||
|
|
||||||
|
const handleDeclineRequest = useCallback(async () => {
|
||||||
|
if (!pendingRequest?.id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await jellyseerrApi?.declineRequest(pendingRequest.id);
|
||||||
|
toast.success(t("jellyseerr.toasts.request_declined"));
|
||||||
|
refetch();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(t("jellyseerr.toasts.failed_to_decline_request"));
|
||||||
|
console.error("Failed to decline request:", error);
|
||||||
|
}
|
||||||
|
}, [jellyseerrApi, pendingRequest, refetch, t]);
|
||||||
|
|
||||||
const renderBackdrop = useCallback(
|
const renderBackdrop = useCallback(
|
||||||
(props: BottomSheetBackdropProps) => (
|
(props: BottomSheetBackdropProps) => (
|
||||||
<BottomSheetBackdrop
|
<BottomSheetBackdrop
|
||||||
@@ -334,6 +383,60 @@ const Page: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
{canManageRequests && pendingRequest && (
|
||||||
|
<View className='flex flex-col space-y-2 mt-4'>
|
||||||
|
<View className='flex flex-row items-center space-x-2'>
|
||||||
|
<Ionicons name='person-outline' size={16} color='#9CA3AF' />
|
||||||
|
<Text className='text-sm text-neutral-400'>
|
||||||
|
{t("jellyseerr.requested_by", {
|
||||||
|
user:
|
||||||
|
pendingRequest.requestedBy?.displayName ||
|
||||||
|
pendingRequest.requestedBy?.username ||
|
||||||
|
pendingRequest.requestedBy?.jellyfinUsername ||
|
||||||
|
t("jellyseerr.unknown_user"),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className='flex flex-row space-x-2'>
|
||||||
|
<Button
|
||||||
|
className='flex-1 bg-green-600/50 border-green-400 ring-green-400 text-green-100'
|
||||||
|
color='transparent'
|
||||||
|
onPress={handleApproveRequest}
|
||||||
|
iconLeft={
|
||||||
|
<Ionicons
|
||||||
|
name='checkmark-outline'
|
||||||
|
size={20}
|
||||||
|
color='white'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className='text-sm'>{t("jellyseerr.approve")}</Text>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className='flex-1 bg-red-600/50 border-red-400 ring-red-400 text-red-100'
|
||||||
|
color='transparent'
|
||||||
|
onPress={handleDeclineRequest}
|
||||||
|
iconLeft={
|
||||||
|
<Ionicons
|
||||||
|
name='close-outline'
|
||||||
|
size={20}
|
||||||
|
color='white'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className='text-sm'>{t("jellyseerr.decline")}</Text>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
<OverviewText text={result.overview} className='mt-4' />
|
<OverviewText text={result.overview} className='mt-4' />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getItemsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Dimensions,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
|
||||||
|
import { MusicTrackItem } from "@/components/music/MusicTrackItem";
|
||||||
|
import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet";
|
||||||
|
import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet";
|
||||||
|
import {
|
||||||
|
downloadTrack,
|
||||||
|
isPermanentlyDownloaded,
|
||||||
|
} from "@/providers/AudioStorage";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
|
||||||
|
import { getAudioStreamUrl } from "@/utils/jellyfin/audio/getAudioStreamUrl";
|
||||||
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
|
|
||||||
|
const { width: SCREEN_WIDTH } = Dimensions.get("window");
|
||||||
|
const ARTWORK_SIZE = SCREEN_WIDTH * 0.5;
|
||||||
|
|
||||||
|
export default function AlbumDetailScreen() {
|
||||||
|
const { albumId } = useLocalSearchParams<{ albumId: string }>();
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { playQueue } = useMusicPlayer();
|
||||||
|
|
||||||
|
const [selectedTrack, setSelectedTrack] = useState<BaseItemDto | null>(null);
|
||||||
|
const [trackOptionsOpen, setTrackOptionsOpen] = useState(false);
|
||||||
|
const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false);
|
||||||
|
const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false);
|
||||||
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
|
|
||||||
|
const handleTrackOptionsPress = useCallback((track: BaseItemDto) => {
|
||||||
|
setSelectedTrack(track);
|
||||||
|
setTrackOptionsOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAddToPlaylist = useCallback(() => {
|
||||||
|
setPlaylistPickerOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCreateNewPlaylist = useCallback(() => {
|
||||||
|
setCreatePlaylistOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { data: album, isLoading: loadingAlbum } = useQuery({
|
||||||
|
queryKey: ["music-album", albumId, user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await getUserLibraryApi(api!).getItem({
|
||||||
|
userId: user?.Id,
|
||||||
|
itemId: albumId!,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id && !!albumId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: tracks, isLoading: loadingTracks } = useQuery({
|
||||||
|
queryKey: ["music-album-tracks", albumId, user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await getItemsApi(api!).getItems({
|
||||||
|
userId: user?.Id,
|
||||||
|
parentId: albumId,
|
||||||
|
sortBy: ["IndexNumber"],
|
||||||
|
sortOrder: ["Ascending"],
|
||||||
|
});
|
||||||
|
return response.data.Items || [];
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id && !!albumId,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
navigation.setOptions({
|
||||||
|
title: album?.Name ?? "",
|
||||||
|
headerTransparent: true,
|
||||||
|
headerStyle: { backgroundColor: "transparent" },
|
||||||
|
headerShadowVisible: false,
|
||||||
|
});
|
||||||
|
}, [album?.Name, navigation]);
|
||||||
|
|
||||||
|
const imageUrl = useMemo(
|
||||||
|
() => (album ? getPrimaryImageUrl({ api, item: album }) : null),
|
||||||
|
[api, album],
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalDuration = useMemo(() => {
|
||||||
|
if (!tracks) return "";
|
||||||
|
const totalTicks = tracks.reduce(
|
||||||
|
(acc, track) => acc + (track.RunTimeTicks || 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
return runtimeTicksToMinutes(totalTicks);
|
||||||
|
}, [tracks]);
|
||||||
|
|
||||||
|
const handlePlayAll = useCallback(() => {
|
||||||
|
if (tracks && tracks.length > 0) {
|
||||||
|
playQueue(tracks, 0);
|
||||||
|
}
|
||||||
|
}, [playQueue, tracks]);
|
||||||
|
|
||||||
|
const handleShuffle = useCallback(() => {
|
||||||
|
if (tracks && tracks.length > 0) {
|
||||||
|
const shuffled = [...tracks].sort(() => Math.random() - 0.5);
|
||||||
|
playQueue(shuffled, 0);
|
||||||
|
}
|
||||||
|
}, [playQueue, tracks]);
|
||||||
|
|
||||||
|
// Check if all tracks are already permanently downloaded
|
||||||
|
const allTracksDownloaded = useMemo(() => {
|
||||||
|
if (!tracks || tracks.length === 0) return false;
|
||||||
|
return tracks.every((track) => isPermanentlyDownloaded(track.Id));
|
||||||
|
}, [tracks]);
|
||||||
|
|
||||||
|
const handleDownloadAlbum = useCallback(async () => {
|
||||||
|
if (!tracks || !api || !user?.Id || isDownloading) return;
|
||||||
|
|
||||||
|
setIsDownloading(true);
|
||||||
|
try {
|
||||||
|
for (const track of tracks) {
|
||||||
|
if (!track.Id || isPermanentlyDownloaded(track.Id)) continue;
|
||||||
|
const result = await getAudioStreamUrl(api, user.Id, track.Id);
|
||||||
|
if (result?.url && !result.isTranscoding) {
|
||||||
|
await downloadTrack(track.Id, result.url, {
|
||||||
|
permanent: true,
|
||||||
|
container: result.mediaSource?.Container || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silent fail
|
||||||
|
}
|
||||||
|
setIsDownloading(false);
|
||||||
|
}, [tracks, api, user?.Id, isDownloading]);
|
||||||
|
|
||||||
|
const isLoading = loadingAlbum || loadingTracks;
|
||||||
|
|
||||||
|
// Only show loading if we have no cached data to display
|
||||||
|
if (isLoading && !album) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black'>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!album) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black'>
|
||||||
|
<Text className='text-neutral-500'>{t("music.album_not_found")}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlashList
|
||||||
|
data={tracks || []}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingBottom: insets.bottom + 100,
|
||||||
|
}}
|
||||||
|
ListHeaderComponent={
|
||||||
|
<View
|
||||||
|
className='items-center px-4 pb-6 bg-black'
|
||||||
|
style={{ paddingTop: insets.top + 60 }}
|
||||||
|
>
|
||||||
|
{/* Album artwork */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: ARTWORK_SIZE,
|
||||||
|
height: ARTWORK_SIZE,
|
||||||
|
borderRadius: 8,
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 8 },
|
||||||
|
shadowOpacity: 0.3,
|
||||||
|
shadowRadius: 12,
|
||||||
|
elevation: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{imageUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: imageUrl }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
cachePolicy='memory-disk'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View className='flex-1 items-center justify-center bg-neutral-800'>
|
||||||
|
<Ionicons name='disc' size={60} color='#666' />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Album info */}
|
||||||
|
<Text className='text-white text-xl font-bold mt-4 text-center'>
|
||||||
|
{album.Name}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-purple-400 text-base mt-1'>
|
||||||
|
{album.AlbumArtist || album.Artists?.join(", ")}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-neutral-500 text-sm mt-1'>
|
||||||
|
{album.ProductionYear && `${album.ProductionYear} • `}
|
||||||
|
{tracks?.length} tracks • {totalDuration}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Play buttons */}
|
||||||
|
<View className='flex flex-row mt-4 items-center'>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handlePlayAll}
|
||||||
|
className='flex flex-row items-center bg-purple-600 px-6 py-3 rounded-full mr-3'
|
||||||
|
>
|
||||||
|
<Ionicons name='play' size={20} color='white' />
|
||||||
|
<Text className='text-white font-medium ml-2'>
|
||||||
|
{t("music.play")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleShuffle}
|
||||||
|
className='flex flex-row items-center bg-neutral-800 px-6 py-3 rounded-full mr-3'
|
||||||
|
>
|
||||||
|
<Ionicons name='shuffle' size={20} color='white' />
|
||||||
|
<Text className='text-white font-medium ml-2'>
|
||||||
|
{t("music.shuffle")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleDownloadAlbum}
|
||||||
|
disabled={allTracksDownloaded || isDownloading}
|
||||||
|
className='flex items-center justify-center bg-neutral-800 p-3 rounded-full'
|
||||||
|
>
|
||||||
|
{isDownloading ? (
|
||||||
|
<ActivityIndicator size={20} color='white' />
|
||||||
|
) : (
|
||||||
|
<Ionicons
|
||||||
|
name={
|
||||||
|
allTracksDownloaded
|
||||||
|
? "checkmark-circle"
|
||||||
|
: "download-outline"
|
||||||
|
}
|
||||||
|
size={20}
|
||||||
|
color={allTracksDownloaded ? "#22c55e" : "white"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
renderItem={({ item, index }) => (
|
||||||
|
<MusicTrackItem
|
||||||
|
track={item}
|
||||||
|
index={index + 1}
|
||||||
|
queue={tracks}
|
||||||
|
showArtwork={false}
|
||||||
|
onOptionsPress={handleTrackOptionsPress}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
keyExtractor={(item) => item.Id!}
|
||||||
|
ListFooterComponent={
|
||||||
|
<>
|
||||||
|
<TrackOptionsSheet
|
||||||
|
open={trackOptionsOpen}
|
||||||
|
setOpen={setTrackOptionsOpen}
|
||||||
|
track={selectedTrack}
|
||||||
|
onAddToPlaylist={handleAddToPlaylist}
|
||||||
|
/>
|
||||||
|
<PlaylistPickerSheet
|
||||||
|
open={playlistPickerOpen}
|
||||||
|
setOpen={setPlaylistPickerOpen}
|
||||||
|
trackToAdd={selectedTrack}
|
||||||
|
onCreateNew={handleCreateNewPlaylist}
|
||||||
|
/>
|
||||||
|
<CreatePlaylistModal
|
||||||
|
open={createPlaylistOpen}
|
||||||
|
setOpen={setCreatePlaylistOpen}
|
||||||
|
initialTrackId={selectedTrack?.Id}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getItemsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Dimensions, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { HorizontalScroll } from "@/components/common/HorizontalScroll";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
|
||||||
|
import { MusicAlbumCard } from "@/components/music/MusicAlbumCard";
|
||||||
|
import { MusicTrackItem } from "@/components/music/MusicTrackItem";
|
||||||
|
import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet";
|
||||||
|
import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
|
||||||
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
|
||||||
|
const { width: SCREEN_WIDTH } = Dimensions.get("window");
|
||||||
|
const ARTWORK_SIZE = SCREEN_WIDTH * 0.4;
|
||||||
|
|
||||||
|
export default function ArtistDetailScreen() {
|
||||||
|
const { artistId } = useLocalSearchParams<{ artistId: string }>();
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { playQueue } = useMusicPlayer();
|
||||||
|
|
||||||
|
const [selectedTrack, setSelectedTrack] = useState<BaseItemDto | null>(null);
|
||||||
|
const [trackOptionsOpen, setTrackOptionsOpen] = useState(false);
|
||||||
|
const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false);
|
||||||
|
const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleTrackOptionsPress = useCallback((track: BaseItemDto) => {
|
||||||
|
setSelectedTrack(track);
|
||||||
|
setTrackOptionsOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAddToPlaylist = useCallback(() => {
|
||||||
|
setPlaylistPickerOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCreateNewPlaylist = useCallback(() => {
|
||||||
|
setCreatePlaylistOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { data: artist, isLoading: loadingArtist } = useQuery({
|
||||||
|
queryKey: ["music-artist", artistId, user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await getUserLibraryApi(api!).getItem({
|
||||||
|
userId: user?.Id,
|
||||||
|
itemId: artistId!,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id && !!artistId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: albums, isLoading: loadingAlbums } = useQuery({
|
||||||
|
queryKey: ["music-artist-albums", artistId, user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await getItemsApi(api!).getItems({
|
||||||
|
userId: user?.Id,
|
||||||
|
artistIds: [artistId!],
|
||||||
|
includeItemTypes: ["MusicAlbum"],
|
||||||
|
sortBy: ["ProductionYear", "SortName"],
|
||||||
|
sortOrder: ["Descending", "Ascending"],
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
return response.data.Items || [];
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id && !!artistId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: topTracks, isLoading: loadingTracks } = useQuery({
|
||||||
|
queryKey: ["music-artist-top-tracks", artistId, user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await getItemsApi(api!).getItems({
|
||||||
|
userId: user?.Id,
|
||||||
|
artistIds: [artistId!],
|
||||||
|
includeItemTypes: ["Audio"],
|
||||||
|
sortBy: ["PlayCount"],
|
||||||
|
sortOrder: ["Descending"],
|
||||||
|
limit: 10,
|
||||||
|
recursive: true,
|
||||||
|
filters: ["IsPlayed"],
|
||||||
|
});
|
||||||
|
return response.data.Items || [];
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id && !!artistId,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
navigation.setOptions({
|
||||||
|
title: artist?.Name ?? "",
|
||||||
|
headerTransparent: true,
|
||||||
|
headerStyle: { backgroundColor: "transparent" },
|
||||||
|
headerShadowVisible: false,
|
||||||
|
});
|
||||||
|
}, [artist?.Name, navigation]);
|
||||||
|
|
||||||
|
const imageUrl = useMemo(
|
||||||
|
() => (artist ? getPrimaryImageUrl({ api, item: artist }) : null),
|
||||||
|
[api, artist],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePlayAllTracks = useCallback(() => {
|
||||||
|
if (topTracks && topTracks.length > 0) {
|
||||||
|
playQueue(topTracks, 0);
|
||||||
|
}
|
||||||
|
}, [playQueue, topTracks]);
|
||||||
|
|
||||||
|
const isLoading = loadingArtist || loadingAlbums || loadingTracks;
|
||||||
|
|
||||||
|
// Only show loading if we have no cached data to display
|
||||||
|
if (isLoading && !artist) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black'>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!artist) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black'>
|
||||||
|
<Text className='text-neutral-500'>{t("music.artist_not_found")}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sections = [];
|
||||||
|
|
||||||
|
// Top tracks section
|
||||||
|
if (topTracks && topTracks.length > 0) {
|
||||||
|
sections.push({
|
||||||
|
id: "top-tracks",
|
||||||
|
title: t("music.top_tracks"),
|
||||||
|
type: "tracks" as const,
|
||||||
|
data: topTracks,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Albums section
|
||||||
|
if (albums && albums.length > 0) {
|
||||||
|
sections.push({
|
||||||
|
id: "albums",
|
||||||
|
title: t("music.tabs.albums"),
|
||||||
|
type: "albums" as const,
|
||||||
|
data: albums,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlashList
|
||||||
|
data={sections}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingBottom: insets.bottom + 100,
|
||||||
|
}}
|
||||||
|
ListHeaderComponent={
|
||||||
|
<View
|
||||||
|
className='items-center px-4 pb-6 bg-black'
|
||||||
|
style={{ paddingTop: insets.top + 50 }}
|
||||||
|
>
|
||||||
|
{/* Artist image */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: ARTWORK_SIZE,
|
||||||
|
height: ARTWORK_SIZE,
|
||||||
|
borderRadius: ARTWORK_SIZE / 2,
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 8 },
|
||||||
|
shadowOpacity: 0.3,
|
||||||
|
shadowRadius: 12,
|
||||||
|
elevation: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{imageUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: imageUrl }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
cachePolicy='memory-disk'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View className='flex-1 items-center justify-center bg-neutral-800'>
|
||||||
|
<Ionicons name='person' size={60} color='#666' />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Artist info */}
|
||||||
|
<Text className='text-white text-2xl font-bold mt-4 text-center'>
|
||||||
|
{artist.Name}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-neutral-500 text-sm mt-1'>
|
||||||
|
{albums?.length || 0} {t("music.tabs.albums").toLowerCase()}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Play button */}
|
||||||
|
{topTracks && topTracks.length > 0 && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handlePlayAllTracks}
|
||||||
|
className='flex flex-row items-center bg-purple-600 px-6 py-3 rounded-full mt-4'
|
||||||
|
>
|
||||||
|
<Ionicons name='play' size={20} color='white' />
|
||||||
|
<Text className='text-white font-medium ml-2'>
|
||||||
|
{t("music.play_top_tracks")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
renderItem={({ item: section }) => (
|
||||||
|
<View className='mb-6'>
|
||||||
|
<Text className='text-lg font-bold px-4 mb-3'>{section.title}</Text>
|
||||||
|
{section.type === "albums" ? (
|
||||||
|
<HorizontalScroll
|
||||||
|
data={section.data}
|
||||||
|
height={200}
|
||||||
|
keyExtractor={(item) => item.Id!}
|
||||||
|
renderItem={(item) => <MusicAlbumCard album={item} />}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
section.data
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((track, index) => (
|
||||||
|
<MusicTrackItem
|
||||||
|
key={track.Id}
|
||||||
|
track={track}
|
||||||
|
index={index + 1}
|
||||||
|
queue={section.data}
|
||||||
|
onOptionsPress={handleTrackOptionsPress}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
ListFooterComponent={
|
||||||
|
<>
|
||||||
|
<TrackOptionsSheet
|
||||||
|
open={trackOptionsOpen}
|
||||||
|
setOpen={setTrackOptionsOpen}
|
||||||
|
track={selectedTrack}
|
||||||
|
onAddToPlaylist={handleAddToPlaylist}
|
||||||
|
/>
|
||||||
|
<PlaylistPickerSheet
|
||||||
|
open={playlistPickerOpen}
|
||||||
|
setOpen={setPlaylistPickerOpen}
|
||||||
|
trackToAdd={selectedTrack}
|
||||||
|
onCreateNew={handleCreateNewPlaylist}
|
||||||
|
/>
|
||||||
|
<CreatePlaylistModal
|
||||||
|
open={createPlaylistOpen}
|
||||||
|
setOpen={setCreatePlaylistOpen}
|
||||||
|
initialTrackId={selectedTrack?.Id}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getItemsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Dimensions,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
|
||||||
|
import { MusicTrackItem } from "@/components/music/MusicTrackItem";
|
||||||
|
import { PlaylistOptionsSheet } from "@/components/music/PlaylistOptionsSheet";
|
||||||
|
import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet";
|
||||||
|
import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet";
|
||||||
|
import { useRemoveFromPlaylist } from "@/hooks/usePlaylistMutations";
|
||||||
|
import { downloadTrack, getLocalPath } from "@/providers/AudioStorage";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
|
||||||
|
import { getAudioStreamUrl } from "@/utils/jellyfin/audio/getAudioStreamUrl";
|
||||||
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
|
|
||||||
|
const { width: SCREEN_WIDTH } = Dimensions.get("window");
|
||||||
|
const ARTWORK_SIZE = SCREEN_WIDTH * 0.5;
|
||||||
|
|
||||||
|
export default function PlaylistDetailScreen() {
|
||||||
|
const { playlistId } = useLocalSearchParams<{ playlistId: string }>();
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { playQueue } = useMusicPlayer();
|
||||||
|
|
||||||
|
const [selectedTrack, setSelectedTrack] = useState<BaseItemDto | null>(null);
|
||||||
|
const [trackOptionsOpen, setTrackOptionsOpen] = useState(false);
|
||||||
|
const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false);
|
||||||
|
const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false);
|
||||||
|
const [playlistOptionsOpen, setPlaylistOptionsOpen] = useState(false);
|
||||||
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
|
|
||||||
|
const removeFromPlaylist = useRemoveFromPlaylist();
|
||||||
|
|
||||||
|
const handleTrackOptionsPress = useCallback((track: BaseItemDto) => {
|
||||||
|
setSelectedTrack(track);
|
||||||
|
setTrackOptionsOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAddToPlaylist = useCallback(() => {
|
||||||
|
setPlaylistPickerOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCreateNewPlaylist = useCallback(() => {
|
||||||
|
setCreatePlaylistOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRemoveFromPlaylist = useCallback(() => {
|
||||||
|
if (selectedTrack?.Id && playlistId) {
|
||||||
|
removeFromPlaylist.mutate({
|
||||||
|
playlistId,
|
||||||
|
entryIds: [selectedTrack.PlaylistItemId ?? selectedTrack.Id],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [selectedTrack, playlistId, removeFromPlaylist]);
|
||||||
|
|
||||||
|
const { data: playlist, isLoading: loadingPlaylist } = useQuery({
|
||||||
|
queryKey: ["music-playlist", playlistId, user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await getUserLibraryApi(api!).getItem({
|
||||||
|
userId: user?.Id,
|
||||||
|
itemId: playlistId!,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id && !!playlistId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: tracks, isLoading: loadingTracks } = useQuery({
|
||||||
|
queryKey: ["music-playlist-tracks", playlistId, user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await getItemsApi(api!).getItems({
|
||||||
|
userId: user?.Id,
|
||||||
|
parentId: playlistId,
|
||||||
|
});
|
||||||
|
return response.data.Items || [];
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id && !!playlistId,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
navigation.setOptions({
|
||||||
|
title: playlist?.Name ?? "",
|
||||||
|
headerTransparent: true,
|
||||||
|
headerStyle: { backgroundColor: "transparent" },
|
||||||
|
headerShadowVisible: false,
|
||||||
|
headerRight: () => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setPlaylistOptionsOpen(true)}
|
||||||
|
className='p-1.5'
|
||||||
|
>
|
||||||
|
<Ionicons name='ellipsis-horizontal' size={24} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}, [playlist?.Name, navigation]);
|
||||||
|
|
||||||
|
const imageUrl = useMemo(
|
||||||
|
() => (playlist ? getPrimaryImageUrl({ api, item: playlist }) : null),
|
||||||
|
[api, playlist],
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalDuration = useMemo(() => {
|
||||||
|
if (!tracks) return "";
|
||||||
|
const totalTicks = tracks.reduce(
|
||||||
|
(acc, track) => acc + (track.RunTimeTicks || 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
return runtimeTicksToMinutes(totalTicks);
|
||||||
|
}, [tracks]);
|
||||||
|
|
||||||
|
const handlePlayAll = useCallback(() => {
|
||||||
|
if (tracks && tracks.length > 0) {
|
||||||
|
playQueue(tracks, 0);
|
||||||
|
}
|
||||||
|
}, [playQueue, tracks]);
|
||||||
|
|
||||||
|
const handleShuffle = useCallback(() => {
|
||||||
|
if (tracks && tracks.length > 0) {
|
||||||
|
const shuffled = [...tracks].sort(() => Math.random() - 0.5);
|
||||||
|
playQueue(shuffled, 0);
|
||||||
|
}
|
||||||
|
}, [playQueue, tracks]);
|
||||||
|
|
||||||
|
// Check if all tracks are already downloaded
|
||||||
|
const allTracksDownloaded = useMemo(() => {
|
||||||
|
if (!tracks || tracks.length === 0) return false;
|
||||||
|
return tracks.every((track) => !!getLocalPath(track.Id));
|
||||||
|
}, [tracks]);
|
||||||
|
|
||||||
|
const handleDownloadPlaylist = useCallback(async () => {
|
||||||
|
if (!tracks || !api || !user?.Id || isDownloading) return;
|
||||||
|
|
||||||
|
setIsDownloading(true);
|
||||||
|
try {
|
||||||
|
for (const track of tracks) {
|
||||||
|
if (!track.Id || getLocalPath(track.Id)) continue;
|
||||||
|
const result = await getAudioStreamUrl(api, user.Id, track.Id);
|
||||||
|
if (result?.url && !result.isTranscoding) {
|
||||||
|
await downloadTrack(track.Id, result.url, {
|
||||||
|
permanent: true,
|
||||||
|
container: result.mediaSource?.Container || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silent fail
|
||||||
|
}
|
||||||
|
setIsDownloading(false);
|
||||||
|
}, [tracks, api, user?.Id, isDownloading]);
|
||||||
|
|
||||||
|
const isLoading = loadingPlaylist || loadingTracks;
|
||||||
|
|
||||||
|
// Only show loading if we have no cached data to display
|
||||||
|
if (isLoading && !playlist) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black'>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!playlist) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black'>
|
||||||
|
<Text className='text-neutral-500'>
|
||||||
|
{t("music.playlist_not_found")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlashList
|
||||||
|
data={tracks || []}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingBottom: insets.bottom + 100,
|
||||||
|
}}
|
||||||
|
ListHeaderComponent={
|
||||||
|
<View
|
||||||
|
className='items-center px-4 pb-6 bg-black'
|
||||||
|
style={{ paddingTop: insets.top + 50 }}
|
||||||
|
>
|
||||||
|
{/* Playlist artwork */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: ARTWORK_SIZE,
|
||||||
|
height: ARTWORK_SIZE,
|
||||||
|
borderRadius: 8,
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 8 },
|
||||||
|
shadowOpacity: 0.3,
|
||||||
|
shadowRadius: 12,
|
||||||
|
elevation: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{imageUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: imageUrl }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
cachePolicy='memory-disk'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View className='flex-1 items-center justify-center bg-neutral-800'>
|
||||||
|
<Ionicons name='list' size={60} color='#666' />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Playlist info */}
|
||||||
|
<Text className='text-white text-xl font-bold mt-4 text-center'>
|
||||||
|
{playlist.Name}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-neutral-500 text-sm mt-1'>
|
||||||
|
{tracks?.length} tracks • {totalDuration}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Play buttons */}
|
||||||
|
<View className='flex flex-row mt-4 items-center'>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handlePlayAll}
|
||||||
|
className='flex flex-row items-center bg-purple-600 px-6 py-3 rounded-full mr-3'
|
||||||
|
>
|
||||||
|
<Ionicons name='play' size={20} color='white' />
|
||||||
|
<Text className='text-white font-medium ml-2'>
|
||||||
|
{t("music.play")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleShuffle}
|
||||||
|
className='flex flex-row items-center bg-neutral-800 px-6 py-3 rounded-full mr-3'
|
||||||
|
>
|
||||||
|
<Ionicons name='shuffle' size={20} color='white' />
|
||||||
|
<Text className='text-white font-medium ml-2'>
|
||||||
|
{t("music.shuffle")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleDownloadPlaylist}
|
||||||
|
disabled={allTracksDownloaded || isDownloading}
|
||||||
|
className='flex items-center justify-center bg-neutral-800 p-3 rounded-full'
|
||||||
|
>
|
||||||
|
{isDownloading ? (
|
||||||
|
<ActivityIndicator size={20} color='white' />
|
||||||
|
) : (
|
||||||
|
<Ionicons
|
||||||
|
name={
|
||||||
|
allTracksDownloaded
|
||||||
|
? "checkmark-circle"
|
||||||
|
: "download-outline"
|
||||||
|
}
|
||||||
|
size={20}
|
||||||
|
color={allTracksDownloaded ? "#22c55e" : "white"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
renderItem={({ item, index }) => (
|
||||||
|
<MusicTrackItem
|
||||||
|
track={item}
|
||||||
|
index={index + 1}
|
||||||
|
queue={tracks}
|
||||||
|
onOptionsPress={handleTrackOptionsPress}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
keyExtractor={(item) => item.Id!}
|
||||||
|
ListFooterComponent={
|
||||||
|
<>
|
||||||
|
<TrackOptionsSheet
|
||||||
|
open={trackOptionsOpen}
|
||||||
|
setOpen={setTrackOptionsOpen}
|
||||||
|
track={selectedTrack}
|
||||||
|
onAddToPlaylist={handleAddToPlaylist}
|
||||||
|
playlistId={playlistId}
|
||||||
|
onRemoveFromPlaylist={handleRemoveFromPlaylist}
|
||||||
|
/>
|
||||||
|
<PlaylistPickerSheet
|
||||||
|
open={playlistPickerOpen}
|
||||||
|
setOpen={setPlaylistPickerOpen}
|
||||||
|
trackToAdd={selectedTrack}
|
||||||
|
onCreateNew={handleCreateNewPlaylist}
|
||||||
|
/>
|
||||||
|
<CreatePlaylistModal
|
||||||
|
open={createPlaylistOpen}
|
||||||
|
setOpen={setCreatePlaylistOpen}
|
||||||
|
initialTrackId={selectedTrack?.Id}
|
||||||
|
/>
|
||||||
|
<PlaylistOptionsSheet
|
||||||
|
open={playlistOptionsOpen}
|
||||||
|
setOpen={setPlaylistOptionsOpen}
|
||||||
|
playlist={playlist}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import type {
|
|||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
BaseItemDtoQueryResult,
|
BaseItemDtoQueryResult,
|
||||||
BaseItemKind,
|
BaseItemKind,
|
||||||
|
ItemFilter,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import {
|
import {
|
||||||
getFilterApi,
|
getFilterApi,
|
||||||
@@ -27,7 +28,11 @@ import { useOrientation } from "@/hooks/useOrientation";
|
|||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import {
|
import {
|
||||||
|
FilterByOption,
|
||||||
|
FilterByPreferenceAtom,
|
||||||
|
filterByAtom,
|
||||||
genreFilterAtom,
|
genreFilterAtom,
|
||||||
|
getFilterByPreference,
|
||||||
getSortByPreference,
|
getSortByPreference,
|
||||||
getSortOrderPreference,
|
getSortOrderPreference,
|
||||||
SortByOption,
|
SortByOption,
|
||||||
@@ -39,8 +44,10 @@ import {
|
|||||||
sortOrderOptions,
|
sortOrderOptions,
|
||||||
sortOrderPreferenceAtom,
|
sortOrderPreferenceAtom,
|
||||||
tagsFilterAtom,
|
tagsFilterAtom,
|
||||||
|
useFilterOptions,
|
||||||
yearFilterAtom,
|
yearFilterAtom,
|
||||||
} from "@/utils/atoms/filters";
|
} from "@/utils/atoms/filters";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
const Page = () => {
|
const Page = () => {
|
||||||
const searchParams = useLocalSearchParams();
|
const searchParams = useLocalSearchParams();
|
||||||
@@ -54,9 +61,13 @@ const Page = () => {
|
|||||||
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
||||||
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
|
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
|
||||||
const [sortBy, _setSortBy] = useAtom(sortByAtom);
|
const [sortBy, _setSortBy] = useAtom(sortByAtom);
|
||||||
|
const [filterBy, _setFilterBy] = useAtom(filterByAtom);
|
||||||
const [sortOrder, _setSortOrder] = useAtom(sortOrderAtom);
|
const [sortOrder, _setSortOrder] = useAtom(sortOrderAtom);
|
||||||
const [sortByPreference, setSortByPreference] = useAtom(sortByPreferenceAtom);
|
const [sortByPreference, setSortByPreference] = useAtom(sortByPreferenceAtom);
|
||||||
const [sortOrderPreference, setOderByPreference] = useAtom(
|
const [filterByPreference, setFilterByPreference] = useAtom(
|
||||||
|
FilterByPreferenceAtom,
|
||||||
|
);
|
||||||
|
const [sortOrderPreference, setOrderByPreference] = useAtom(
|
||||||
sortOrderPreferenceAtom,
|
sortOrderPreferenceAtom,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -77,12 +88,20 @@ const Page = () => {
|
|||||||
} else {
|
} else {
|
||||||
_setSortBy([SortByOption.SortName]);
|
_setSortBy([SortByOption.SortName]);
|
||||||
}
|
}
|
||||||
|
const fp = getFilterByPreference(libraryId, filterByPreference);
|
||||||
|
if (fp) {
|
||||||
|
_setFilterBy([fp]);
|
||||||
|
} else {
|
||||||
|
_setFilterBy([]);
|
||||||
|
}
|
||||||
}, [
|
}, [
|
||||||
libraryId,
|
libraryId,
|
||||||
sortOrderPreference,
|
sortOrderPreference,
|
||||||
sortByPreference,
|
sortByPreference,
|
||||||
_setSortOrder,
|
_setSortOrder,
|
||||||
_setSortBy,
|
_setSortBy,
|
||||||
|
filterByPreference,
|
||||||
|
_setFilterBy,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const setSortBy = useCallback(
|
const setSortBy = useCallback(
|
||||||
@@ -100,14 +119,28 @@ const Page = () => {
|
|||||||
(sortOrder: SortOrderOption[]) => {
|
(sortOrder: SortOrderOption[]) => {
|
||||||
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
|
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
|
||||||
if (sortOrder[0] !== sop) {
|
if (sortOrder[0] !== sop) {
|
||||||
setOderByPreference({
|
setOrderByPreference({
|
||||||
...sortOrderPreference,
|
...sortOrderPreference,
|
||||||
[libraryId]: sortOrder[0],
|
[libraryId]: sortOrder[0],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
_setSortOrder(sortOrder);
|
_setSortOrder(sortOrder);
|
||||||
},
|
},
|
||||||
[libraryId, sortOrderPreference, setOderByPreference, _setSortOrder],
|
[libraryId, sortOrderPreference, setOrderByPreference, _setSortOrder],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setFilter = useCallback(
|
||||||
|
(filterBy: FilterByOption[]) => {
|
||||||
|
const fp = getFilterByPreference(libraryId, filterByPreference);
|
||||||
|
if (filterBy[0] !== fp) {
|
||||||
|
setFilterByPreference({
|
||||||
|
...filterByPreference,
|
||||||
|
[libraryId]: filterBy[0],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_setFilterBy(filterBy);
|
||||||
|
},
|
||||||
|
[libraryId, filterByPreference, setFilterByPreference, _setFilterBy],
|
||||||
);
|
);
|
||||||
|
|
||||||
const nrOfCols = useMemo(() => {
|
const nrOfCols = useMemo(() => {
|
||||||
@@ -168,6 +201,7 @@ const Page = () => {
|
|||||||
sortBy: [sortBy[0], "SortName", "ProductionYear"],
|
sortBy: [sortBy[0], "SortName", "ProductionYear"],
|
||||||
sortOrder: [sortOrder[0]],
|
sortOrder: [sortOrder[0]],
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
|
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
|
||||||
|
filters: filterBy as ItemFilter[],
|
||||||
// true is needed for merged versions
|
// true is needed for merged versions
|
||||||
recursive: true,
|
recursive: true,
|
||||||
imageTypeLimit: 1,
|
imageTypeLimit: 1,
|
||||||
@@ -190,6 +224,7 @@ const Page = () => {
|
|||||||
selectedTags,
|
selectedTags,
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
|
filterBy,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -203,6 +238,7 @@ const Page = () => {
|
|||||||
selectedTags,
|
selectedTags,
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
|
filterBy,
|
||||||
],
|
],
|
||||||
queryFn: fetchItems,
|
queryFn: fetchItems,
|
||||||
getNextPageParam: (lastPage, pages) => {
|
getNextPageParam: (lastPage, pages) => {
|
||||||
@@ -268,7 +304,8 @@ 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
|
<FlatList
|
||||||
@@ -404,6 +441,26 @@ const Page = () => {
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "filterOptions",
|
||||||
|
component: (
|
||||||
|
<FilterButton
|
||||||
|
className='mr-1'
|
||||||
|
id={libraryId}
|
||||||
|
queryKey='filters'
|
||||||
|
queryFn={async () => generalFilters.map((s) => s.key)}
|
||||||
|
set={setFilter}
|
||||||
|
values={filterBy}
|
||||||
|
title={t("library.filters.filter_by")}
|
||||||
|
renderItemLabel={(item) =>
|
||||||
|
generalFilters.find((i) => i.key === item)?.value || ""
|
||||||
|
}
|
||||||
|
searchFilter={(item, search) =>
|
||||||
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
renderItem={({ item }) => item.component}
|
renderItem={({ item }) => item.component}
|
||||||
keyExtractor={(item) => item.key}
|
keyExtractor={(item) => item.key}
|
||||||
@@ -424,6 +481,9 @@ const Page = () => {
|
|||||||
sortOrder,
|
sortOrder,
|
||||||
setSortOrder,
|
setSortOrder,
|
||||||
isFetching,
|
isFetching,
|
||||||
|
filterBy,
|
||||||
|
setFilter,
|
||||||
|
settings,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ export default function index() {
|
|||||||
() =>
|
() =>
|
||||||
data
|
data
|
||||||
?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!))
|
?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!))
|
||||||
.filter((l) => l.CollectionType !== "music")
|
|
||||||
.filter((l) => l.CollectionType !== "books") || [],
|
.filter((l) => l.CollectionType !== "books") || [],
|
||||||
[data, settings?.hiddenLibraries],
|
[data, settings?.hiddenLibraries],
|
||||||
);
|
);
|
||||||
|
|||||||
87
app/(auth)/(tabs)/(libraries)/music/[libraryId]/_layout.tsx
Normal file
87
app/(auth)/(tabs)/(libraries)/music/[libraryId]/_layout.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import {
|
||||||
|
createMaterialTopTabNavigator,
|
||||||
|
MaterialTopTabNavigationEventMap,
|
||||||
|
MaterialTopTabNavigationOptions,
|
||||||
|
} from "@react-navigation/material-top-tabs";
|
||||||
|
import type {
|
||||||
|
ParamListBase,
|
||||||
|
TabNavigationState,
|
||||||
|
} from "@react-navigation/native";
|
||||||
|
import { Stack, useLocalSearchParams, withLayoutContext } from "expo-router";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
const { Navigator } = createMaterialTopTabNavigator();
|
||||||
|
|
||||||
|
const TAB_LABEL_FONT_SIZE = 13;
|
||||||
|
const TAB_ITEM_HORIZONTAL_PADDING = 18;
|
||||||
|
const TAB_ITEM_MIN_WIDTH = 110;
|
||||||
|
|
||||||
|
export const Tab = withLayoutContext<
|
||||||
|
MaterialTopTabNavigationOptions,
|
||||||
|
typeof Navigator,
|
||||||
|
TabNavigationState<ParamListBase>,
|
||||||
|
MaterialTopTabNavigationEventMap
|
||||||
|
>(Navigator);
|
||||||
|
|
||||||
|
const Layout = () => {
|
||||||
|
const { libraryId } = useLocalSearchParams<{ libraryId: string }>();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen
|
||||||
|
options={{
|
||||||
|
title: t("music.title"),
|
||||||
|
headerStyle: { backgroundColor: "black" },
|
||||||
|
headerShadowVisible: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tab
|
||||||
|
initialRouteName='suggestions'
|
||||||
|
keyboardDismissMode='none'
|
||||||
|
screenOptions={{
|
||||||
|
tabBarBounces: true,
|
||||||
|
tabBarLabelStyle: {
|
||||||
|
fontSize: TAB_LABEL_FONT_SIZE,
|
||||||
|
fontWeight: "600",
|
||||||
|
flexWrap: "nowrap",
|
||||||
|
},
|
||||||
|
tabBarItemStyle: {
|
||||||
|
width: "auto",
|
||||||
|
minWidth: TAB_ITEM_MIN_WIDTH,
|
||||||
|
paddingHorizontal: TAB_ITEM_HORIZONTAL_PADDING,
|
||||||
|
},
|
||||||
|
tabBarStyle: { backgroundColor: "black" },
|
||||||
|
animationEnabled: true,
|
||||||
|
lazy: true,
|
||||||
|
swipeEnabled: true,
|
||||||
|
tabBarIndicatorStyle: { backgroundColor: "#9334E9" },
|
||||||
|
tabBarScrollEnabled: true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tab.Screen
|
||||||
|
name='suggestions'
|
||||||
|
initialParams={{ libraryId }}
|
||||||
|
options={{ title: t("music.tabs.suggestions") }}
|
||||||
|
/>
|
||||||
|
<Tab.Screen
|
||||||
|
name='albums'
|
||||||
|
initialParams={{ libraryId }}
|
||||||
|
options={{ title: t("music.tabs.albums") }}
|
||||||
|
/>
|
||||||
|
<Tab.Screen
|
||||||
|
name='artists'
|
||||||
|
initialParams={{ libraryId }}
|
||||||
|
options={{ title: t("music.tabs.artists") }}
|
||||||
|
/>
|
||||||
|
<Tab.Screen
|
||||||
|
name='playlists'
|
||||||
|
initialParams={{ libraryId }}
|
||||||
|
options={{ title: t("music.tabs.playlists") }}
|
||||||
|
/>
|
||||||
|
</Tab>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Layout;
|
||||||
138
app/(auth)/(tabs)/(libraries)/music/[libraryId]/albums.tsx
Normal file
138
app/(auth)/(tabs)/(libraries)/music/[libraryId]/albums.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useRoute } from "@react-navigation/native";
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useCallback, useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Dimensions, RefreshControl, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { MusicAlbumCard } from "@/components/music/MusicAlbumCard";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
|
const ITEMS_PER_PAGE = 40;
|
||||||
|
|
||||||
|
export default function AlbumsScreen() {
|
||||||
|
const localParams = useLocalSearchParams<{ libraryId?: string | string[] }>();
|
||||||
|
const route = useRoute<any>();
|
||||||
|
const libraryId =
|
||||||
|
(Array.isArray(localParams.libraryId)
|
||||||
|
? localParams.libraryId[0]
|
||||||
|
: localParams.libraryId) ?? route?.params?.libraryId;
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
refetch,
|
||||||
|
} = useInfiniteQuery({
|
||||||
|
queryKey: ["music-albums", libraryId, user?.Id],
|
||||||
|
queryFn: async ({ pageParam = 0 }) => {
|
||||||
|
const response = await getItemsApi(api!).getItems({
|
||||||
|
userId: user?.Id,
|
||||||
|
parentId: libraryId,
|
||||||
|
includeItemTypes: ["MusicAlbum"],
|
||||||
|
sortBy: ["SortName"],
|
||||||
|
sortOrder: ["Ascending"],
|
||||||
|
limit: ITEMS_PER_PAGE,
|
||||||
|
startIndex: pageParam,
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
items: response.data.Items || [],
|
||||||
|
totalCount: response.data.TotalRecordCount || 0,
|
||||||
|
startIndex: pageParam,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getNextPageParam: (lastPage) => {
|
||||||
|
const nextStart = lastPage.startIndex + ITEMS_PER_PAGE;
|
||||||
|
return nextStart < lastPage.totalCount ? nextStart : undefined;
|
||||||
|
},
|
||||||
|
initialPageParam: 0,
|
||||||
|
enabled: !!api && !!user?.Id && !!libraryId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const albums = useMemo(() => {
|
||||||
|
return data?.pages.flatMap((page) => page.items) || [];
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const numColumns = 2;
|
||||||
|
const screenWidth = Dimensions.get("window").width;
|
||||||
|
const gap = 12;
|
||||||
|
const padding = 16;
|
||||||
|
const itemWidth =
|
||||||
|
(screenWidth - padding * 2 - gap * (numColumns - 1)) / numColumns;
|
||||||
|
|
||||||
|
const handleEndReached = useCallback(() => {
|
||||||
|
if (hasNextPage && !isFetchingNextPage) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black'>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (albums.length === 0) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black'>
|
||||||
|
<Text className='text-neutral-500'>{t("music.no_albums")}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='flex-1 bg-black'>
|
||||||
|
<FlashList
|
||||||
|
data={albums}
|
||||||
|
numColumns={numColumns}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingBottom: insets.bottom + 100,
|
||||||
|
paddingTop: 16,
|
||||||
|
paddingHorizontal: padding,
|
||||||
|
}}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={false}
|
||||||
|
onRefresh={refetch}
|
||||||
|
tintColor='#9334E9'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onEndReached={handleEndReached}
|
||||||
|
onEndReachedThreshold={0.5}
|
||||||
|
renderItem={({ item, index }) => (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: itemWidth,
|
||||||
|
marginRight: index % numColumns === 0 ? gap : 0,
|
||||||
|
marginBottom: gap,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MusicAlbumCard album={item} width={itemWidth} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
keyExtractor={(item) => item.Id!}
|
||||||
|
ListFooterComponent={
|
||||||
|
isFetchingNextPage ? (
|
||||||
|
<View className='py-4'>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
175
app/(auth)/(tabs)/(libraries)/music/[libraryId]/artists.tsx
Normal file
175
app/(auth)/(tabs)/(libraries)/music/[libraryId]/artists.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import { getArtistsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useRoute } from "@react-navigation/native";
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useCallback, useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Dimensions, RefreshControl, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { MusicArtistCard } from "@/components/music/MusicArtistCard";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
|
// Web uses Limit=100
|
||||||
|
const ITEMS_PER_PAGE = 100;
|
||||||
|
|
||||||
|
export default function ArtistsScreen() {
|
||||||
|
const localParams = useLocalSearchParams<{ libraryId?: string | string[] }>();
|
||||||
|
const route = useRoute<any>();
|
||||||
|
const libraryId =
|
||||||
|
(Array.isArray(localParams.libraryId)
|
||||||
|
? localParams.libraryId[0]
|
||||||
|
: localParams.libraryId) ?? route?.params?.libraryId;
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const isReady = Boolean(api && user?.Id && libraryId);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
refetch,
|
||||||
|
} = useInfiniteQuery({
|
||||||
|
queryKey: ["music-artists", libraryId, user?.Id],
|
||||||
|
queryFn: async ({ pageParam = 0 }) => {
|
||||||
|
const response = await getArtistsApi(api!).getArtists({
|
||||||
|
userId: user?.Id,
|
||||||
|
parentId: libraryId,
|
||||||
|
sortBy: ["SortName"],
|
||||||
|
sortOrder: ["Ascending"],
|
||||||
|
fields: ["PrimaryImageAspectRatio", "SortName"],
|
||||||
|
imageTypeLimit: 1,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
|
||||||
|
limit: ITEMS_PER_PAGE,
|
||||||
|
startIndex: pageParam,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
items: response.data.Items || [],
|
||||||
|
totalCount: response.data.TotalRecordCount || 0,
|
||||||
|
startIndex: pageParam,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getNextPageParam: (lastPage) => {
|
||||||
|
const nextStart = lastPage.startIndex + ITEMS_PER_PAGE;
|
||||||
|
return nextStart < lastPage.totalCount ? nextStart : undefined;
|
||||||
|
},
|
||||||
|
initialPageParam: 0,
|
||||||
|
enabled: isReady,
|
||||||
|
});
|
||||||
|
|
||||||
|
const artists = useMemo(() => {
|
||||||
|
return data?.pages.flatMap((page) => page.items) || [];
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const numColumns = 3;
|
||||||
|
const screenWidth = Dimensions.get("window").width;
|
||||||
|
const gap = 12;
|
||||||
|
const padding = 16;
|
||||||
|
const itemWidth =
|
||||||
|
(screenWidth - padding * 2 - gap * (numColumns - 1)) / numColumns;
|
||||||
|
|
||||||
|
const handleEndReached = useCallback(() => {
|
||||||
|
if (hasNextPage && !isFetchingNextPage) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||||
|
|
||||||
|
if (!api || !user?.Id) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black'>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!libraryId) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||||
|
<Text className='text-neutral-500 text-center'>
|
||||||
|
Missing music library id.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only show loading if we have no cached data to display
|
||||||
|
if (isLoading && artists.length === 0) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black'>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only show error if we have no cached data to display
|
||||||
|
// This allows offline access to previously cached artists
|
||||||
|
if (isError && artists.length === 0) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||||
|
<Text className='text-neutral-500 text-center'>
|
||||||
|
Failed to load artists: {(error as Error)?.message || "Unknown error"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (artists.length === 0) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black'>
|
||||||
|
<Text className='text-neutral-500'>{t("music.no_artists")}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='flex-1 bg-black'>
|
||||||
|
<FlashList
|
||||||
|
data={artists}
|
||||||
|
numColumns={numColumns}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingBottom: insets.bottom + 100,
|
||||||
|
paddingTop: 16,
|
||||||
|
paddingHorizontal: padding,
|
||||||
|
}}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={false}
|
||||||
|
onRefresh={refetch}
|
||||||
|
tintColor='#9334E9'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onEndReached={handleEndReached}
|
||||||
|
onEndReachedThreshold={0.5}
|
||||||
|
renderItem={({ item, index }) => (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: itemWidth,
|
||||||
|
marginRight: index % numColumns !== numColumns - 1 ? gap : 0,
|
||||||
|
marginBottom: gap,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MusicArtistCard artist={item} size={itemWidth} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
keyExtractor={(item) => item.Id!}
|
||||||
|
ListFooterComponent={
|
||||||
|
isFetchingNextPage ? (
|
||||||
|
<View className='py-4'>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
215
app/(auth)/(tabs)/(libraries)/music/[libraryId]/playlists.tsx
Normal file
215
app/(auth)/(tabs)/(libraries)/music/[libraryId]/playlists.tsx
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useNavigation, useRoute } from "@react-navigation/native";
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useCallback, useLayoutEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Dimensions,
|
||||||
|
RefreshControl,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
|
||||||
|
import { MusicPlaylistCard } from "@/components/music/MusicPlaylistCard";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
|
const ITEMS_PER_PAGE = 40;
|
||||||
|
|
||||||
|
export default function PlaylistsScreen() {
|
||||||
|
const localParams = useLocalSearchParams<{ libraryId?: string | string[] }>();
|
||||||
|
const route = useRoute<any>();
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const libraryId =
|
||||||
|
(Array.isArray(localParams.libraryId)
|
||||||
|
? localParams.libraryId[0]
|
||||||
|
: localParams.libraryId) ?? route?.params?.libraryId;
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const isReady = Boolean(api && user?.Id && libraryId);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
navigation.setOptions({
|
||||||
|
headerRight: () => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setCreateModalOpen(true)}
|
||||||
|
className='mr-4'
|
||||||
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||||
|
>
|
||||||
|
<Ionicons name='add' size={28} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}, [navigation]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
refetch,
|
||||||
|
} = useInfiniteQuery({
|
||||||
|
queryKey: ["music-playlists", libraryId, user?.Id],
|
||||||
|
queryFn: async ({ pageParam = 0 }) => {
|
||||||
|
const response = await getItemsApi(api!).getItems({
|
||||||
|
userId: user?.Id,
|
||||||
|
includeItemTypes: ["Playlist"],
|
||||||
|
sortBy: ["SortName"],
|
||||||
|
sortOrder: ["Ascending"],
|
||||||
|
limit: ITEMS_PER_PAGE,
|
||||||
|
startIndex: pageParam,
|
||||||
|
recursive: true,
|
||||||
|
mediaTypes: ["Audio"],
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
items: response.data.Items || [],
|
||||||
|
totalCount: response.data.TotalRecordCount || 0,
|
||||||
|
startIndex: pageParam,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getNextPageParam: (lastPage) => {
|
||||||
|
const nextStart = lastPage.startIndex + ITEMS_PER_PAGE;
|
||||||
|
return nextStart < lastPage.totalCount ? nextStart : undefined;
|
||||||
|
},
|
||||||
|
initialPageParam: 0,
|
||||||
|
enabled: isReady,
|
||||||
|
});
|
||||||
|
|
||||||
|
const playlists = useMemo(() => {
|
||||||
|
return data?.pages.flatMap((page) => page.items) || [];
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const numColumns = 2;
|
||||||
|
const screenWidth = Dimensions.get("window").width;
|
||||||
|
const gap = 12;
|
||||||
|
const padding = 16;
|
||||||
|
const itemWidth =
|
||||||
|
(screenWidth - padding * 2 - gap * (numColumns - 1)) / numColumns;
|
||||||
|
|
||||||
|
const handleEndReached = useCallback(() => {
|
||||||
|
if (hasNextPage && !isFetchingNextPage) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||||
|
|
||||||
|
if (!api || !user?.Id) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black'>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!libraryId) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||||
|
<Text className='text-neutral-500 text-center'>
|
||||||
|
Missing music library id.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only show loading if we have no cached data to display
|
||||||
|
if (isLoading && playlists.length === 0) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black'>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only show error if we have no cached data to display
|
||||||
|
// This allows offline access to previously cached playlists
|
||||||
|
if (isError && playlists.length === 0) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||||
|
<Text className='text-neutral-500 text-center'>
|
||||||
|
Failed to load playlists:{" "}
|
||||||
|
{(error as Error)?.message || "Unknown error"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playlists.length === 0) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black'>
|
||||||
|
<Text className='text-neutral-500 mb-4'>{t("music.no_playlists")}</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setCreateModalOpen(true)}
|
||||||
|
className='flex-row items-center bg-purple-600 px-6 py-3 rounded-full'
|
||||||
|
>
|
||||||
|
<Ionicons name='add' size={20} color='white' />
|
||||||
|
<Text className='text-white font-semibold ml-2'>
|
||||||
|
{t("music.playlists.create_playlist")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<CreatePlaylistModal
|
||||||
|
open={createModalOpen}
|
||||||
|
setOpen={setCreateModalOpen}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='flex-1 bg-black'>
|
||||||
|
<FlashList
|
||||||
|
data={playlists}
|
||||||
|
numColumns={numColumns}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingBottom: insets.bottom + 100,
|
||||||
|
paddingTop: 16,
|
||||||
|
paddingHorizontal: padding,
|
||||||
|
}}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={false}
|
||||||
|
onRefresh={refetch}
|
||||||
|
tintColor='#9334E9'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onEndReached={handleEndReached}
|
||||||
|
onEndReachedThreshold={0.5}
|
||||||
|
renderItem={({ item, index }) => (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: itemWidth,
|
||||||
|
marginRight: index % numColumns === 0 ? gap : 0,
|
||||||
|
marginBottom: gap,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MusicPlaylistCard playlist={item} width={itemWidth} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
keyExtractor={(item) => item.Id!}
|
||||||
|
ListFooterComponent={
|
||||||
|
isFetchingNextPage ? (
|
||||||
|
<View className='py-4'>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<CreatePlaylistModal
|
||||||
|
open={createModalOpen}
|
||||||
|
setOpen={setCreateModalOpen}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
333
app/(auth)/(tabs)/(libraries)/music/[libraryId]/suggestions.tsx
Normal file
333
app/(auth)/(tabs)/(libraries)/music/[libraryId]/suggestions.tsx
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useRoute } from "@react-navigation/native";
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { RefreshControl, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { HorizontalScroll } from "@/components/common/HorizontalScroll";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
|
||||||
|
import { MusicAlbumCard } from "@/components/music/MusicAlbumCard";
|
||||||
|
import { MusicTrackItem } from "@/components/music/MusicTrackItem";
|
||||||
|
import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet";
|
||||||
|
import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { writeDebugLog } from "@/utils/log";
|
||||||
|
|
||||||
|
export default function SuggestionsScreen() {
|
||||||
|
const localParams = useLocalSearchParams<{ libraryId?: string | string[] }>();
|
||||||
|
const route = useRoute<any>();
|
||||||
|
const libraryId =
|
||||||
|
(Array.isArray(localParams.libraryId)
|
||||||
|
? localParams.libraryId[0]
|
||||||
|
: localParams.libraryId) ?? route?.params?.libraryId;
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [selectedTrack, setSelectedTrack] = useState<BaseItemDto | null>(null);
|
||||||
|
const [trackOptionsOpen, setTrackOptionsOpen] = useState(false);
|
||||||
|
const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false);
|
||||||
|
const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleTrackOptionsPress = useCallback((track: BaseItemDto) => {
|
||||||
|
setSelectedTrack(track);
|
||||||
|
setTrackOptionsOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAddToPlaylist = useCallback(() => {
|
||||||
|
setPlaylistPickerOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCreateNewPlaylist = useCallback(() => {
|
||||||
|
setCreatePlaylistOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isReady = Boolean(api && user?.Id && libraryId);
|
||||||
|
|
||||||
|
writeDebugLog("Music suggestions params", {
|
||||||
|
libraryId,
|
||||||
|
localParams,
|
||||||
|
routeParams: route?.params,
|
||||||
|
isReady,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Latest audio - uses the same endpoint as web: /Users/{userId}/Items/Latest
|
||||||
|
// This returns the most recently added albums
|
||||||
|
const {
|
||||||
|
data: latestAlbums,
|
||||||
|
isLoading: loadingLatest,
|
||||||
|
isError: isLatestError,
|
||||||
|
error: latestError,
|
||||||
|
refetch: refetchLatest,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["music-latest", libraryId, user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
// Prefer the exact endpoint the Web client calls (HAR):
|
||||||
|
// /Users/{userId}/Items/Latest?IncludeItemTypes=Audio&ParentId=...
|
||||||
|
// IMPORTANT: must use api.get(...) (not axiosInstance.get(fullUrl)) so the auth header is attached.
|
||||||
|
const res = await api!.get<BaseItemDto[]>(
|
||||||
|
`/Users/${user!.Id}/Items/Latest`,
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
IncludeItemTypes: "Audio",
|
||||||
|
Limit: 20,
|
||||||
|
Fields: "PrimaryImageAspectRatio",
|
||||||
|
ParentId: libraryId,
|
||||||
|
ImageTypeLimit: 1,
|
||||||
|
EnableImageTypes: "Primary,Backdrop,Banner,Thumb",
|
||||||
|
EnableTotalRecordCount: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (Array.isArray(res.data) && res.data.length > 0) {
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: ask for albums directly via /Items (more reliable across server variants)
|
||||||
|
const fallback = await getItemsApi(api!).getItems({
|
||||||
|
userId: user!.Id,
|
||||||
|
parentId: libraryId,
|
||||||
|
includeItemTypes: ["MusicAlbum"],
|
||||||
|
sortBy: ["DateCreated"],
|
||||||
|
sortOrder: ["Descending"],
|
||||||
|
limit: 20,
|
||||||
|
recursive: true,
|
||||||
|
fields: ["PrimaryImageAspectRatio", "SortName"],
|
||||||
|
imageTypeLimit: 1,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
|
||||||
|
enableTotalRecordCount: false,
|
||||||
|
});
|
||||||
|
return fallback.data.Items || [];
|
||||||
|
},
|
||||||
|
enabled: isReady,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Recently played - matches web: SortBy=DatePlayed, Filters=IsPlayed
|
||||||
|
const {
|
||||||
|
data: recentlyPlayed,
|
||||||
|
isLoading: loadingRecentlyPlayed,
|
||||||
|
isError: isRecentlyPlayedError,
|
||||||
|
error: recentlyPlayedError,
|
||||||
|
refetch: refetchRecentlyPlayed,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["music-recently-played", libraryId, user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await getItemsApi(api!).getItems({
|
||||||
|
userId: user?.Id,
|
||||||
|
parentId: libraryId,
|
||||||
|
includeItemTypes: ["Audio"],
|
||||||
|
sortBy: ["DatePlayed"],
|
||||||
|
sortOrder: ["Descending"],
|
||||||
|
limit: 10,
|
||||||
|
recursive: true,
|
||||||
|
fields: ["PrimaryImageAspectRatio", "SortName"],
|
||||||
|
filters: ["IsPlayed"],
|
||||||
|
imageTypeLimit: 1,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
|
||||||
|
enableTotalRecordCount: false,
|
||||||
|
});
|
||||||
|
return response.data.Items || [];
|
||||||
|
},
|
||||||
|
enabled: isReady,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Frequently played - matches web: SortBy=PlayCount, Filters=IsPlayed
|
||||||
|
const {
|
||||||
|
data: frequentlyPlayed,
|
||||||
|
isLoading: loadingFrequent,
|
||||||
|
isError: isFrequentError,
|
||||||
|
error: frequentError,
|
||||||
|
refetch: refetchFrequent,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["music-frequently-played", libraryId, user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await getItemsApi(api!).getItems({
|
||||||
|
userId: user?.Id,
|
||||||
|
parentId: libraryId,
|
||||||
|
includeItemTypes: ["Audio"],
|
||||||
|
sortBy: ["PlayCount"],
|
||||||
|
sortOrder: ["Descending"],
|
||||||
|
limit: 10,
|
||||||
|
recursive: true,
|
||||||
|
fields: ["PrimaryImageAspectRatio", "SortName"],
|
||||||
|
filters: ["IsPlayed"],
|
||||||
|
imageTypeLimit: 1,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
|
||||||
|
enableTotalRecordCount: false,
|
||||||
|
});
|
||||||
|
return response.data.Items || [];
|
||||||
|
},
|
||||||
|
enabled: isReady,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isLoading = loadingLatest || loadingRecentlyPlayed || loadingFrequent;
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(() => {
|
||||||
|
refetchLatest();
|
||||||
|
refetchRecentlyPlayed();
|
||||||
|
refetchFrequent();
|
||||||
|
}, [refetchLatest, refetchRecentlyPlayed, refetchFrequent]);
|
||||||
|
|
||||||
|
const sections = useMemo(() => {
|
||||||
|
const result: {
|
||||||
|
title: string;
|
||||||
|
data: BaseItemDto[];
|
||||||
|
type: "albums" | "tracks";
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
// Latest albums section
|
||||||
|
if (latestAlbums && latestAlbums.length > 0) {
|
||||||
|
result.push({
|
||||||
|
title: t("music.recently_added"),
|
||||||
|
data: latestAlbums,
|
||||||
|
type: "albums",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recently played tracks
|
||||||
|
if (recentlyPlayed && recentlyPlayed.length > 0) {
|
||||||
|
result.push({
|
||||||
|
title: t("music.recently_played"),
|
||||||
|
data: recentlyPlayed,
|
||||||
|
type: "tracks",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frequently played tracks
|
||||||
|
if (frequentlyPlayed && frequentlyPlayed.length > 0) {
|
||||||
|
result.push({
|
||||||
|
title: t("music.frequently_played"),
|
||||||
|
data: frequentlyPlayed,
|
||||||
|
type: "tracks",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [latestAlbums, frequentlyPlayed, recentlyPlayed, t]);
|
||||||
|
|
||||||
|
if (!api || !user?.Id) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black'>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!libraryId) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||||
|
<Text className='text-neutral-500 text-center'>
|
||||||
|
Missing music library id.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only show loading if we have no cached data to display
|
||||||
|
if (isLoading && sections.length === 0) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black'>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only show error if we have no cached data to display
|
||||||
|
// This allows offline access to previously cached suggestions
|
||||||
|
if (
|
||||||
|
(isLatestError || isRecentlyPlayedError || isFrequentError) &&
|
||||||
|
sections.length === 0
|
||||||
|
) {
|
||||||
|
const msg =
|
||||||
|
(latestError as Error | undefined)?.message ||
|
||||||
|
(recentlyPlayedError as Error | undefined)?.message ||
|
||||||
|
(frequentError as Error | undefined)?.message ||
|
||||||
|
"Unknown error";
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||||
|
<Text className='text-neutral-500 text-center'>
|
||||||
|
Failed to load music: {msg}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sections.length === 0) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 justify-center items-center bg-black'>
|
||||||
|
<Text className='text-neutral-500'>{t("music.no_suggestions")}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='flex-1 bg-black'>
|
||||||
|
<FlashList
|
||||||
|
data={sections}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingBottom: insets.bottom + 100,
|
||||||
|
paddingTop: 16,
|
||||||
|
}}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={false}
|
||||||
|
onRefresh={handleRefresh}
|
||||||
|
tintColor='#9334E9'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
renderItem={({ item: section }) => (
|
||||||
|
<View className='mb-6'>
|
||||||
|
<Text className='text-lg font-bold px-4 mb-3'>{section.title}</Text>
|
||||||
|
{section.type === "albums" ? (
|
||||||
|
<HorizontalScroll
|
||||||
|
data={section.data}
|
||||||
|
height={200}
|
||||||
|
keyExtractor={(item) => item.Id!}
|
||||||
|
renderItem={(item) => <MusicAlbumCard album={item} />}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
section.data
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((track, index, _tracks) => (
|
||||||
|
<MusicTrackItem
|
||||||
|
key={track.Id}
|
||||||
|
track={track}
|
||||||
|
index={index + 1}
|
||||||
|
queue={section.data}
|
||||||
|
onOptionsPress={handleTrackOptionsPress}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
keyExtractor={(item) => item.title}
|
||||||
|
/>
|
||||||
|
<TrackOptionsSheet
|
||||||
|
open={trackOptionsOpen}
|
||||||
|
setOpen={setTrackOptionsOpen}
|
||||||
|
track={selectedTrack}
|
||||||
|
onAddToPlaylist={handleAddToPlaylist}
|
||||||
|
/>
|
||||||
|
<PlaylistPickerSheet
|
||||||
|
open={playlistPickerOpen}
|
||||||
|
setOpen={setPlaylistPickerOpen}
|
||||||
|
trackToAdd={selectedTrack}
|
||||||
|
onCreateNew={handleCreateNewPlaylist}
|
||||||
|
/>
|
||||||
|
<CreatePlaylistModal
|
||||||
|
open={createPlaylistOpen}
|
||||||
|
setOpen={setCreatePlaylistOpen}
|
||||||
|
initialTrackId={selectedTrack?.Id}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -39,6 +39,7 @@ import { useJellyseerr } from "@/hooks/useJellyseerr";
|
|||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { eventBus } from "@/utils/eventBus";
|
import { eventBus } from "@/utils/eventBus";
|
||||||
|
import { createStreamystatsApi } from "@/utils/streamystats";
|
||||||
|
|
||||||
type SearchType = "Library" | "Discover";
|
type SearchType = "Library" | "Discover";
|
||||||
|
|
||||||
@@ -117,6 +118,54 @@ export default function search() {
|
|||||||
|
|
||||||
return (searchApi.data.Items as BaseItemDto[]) || [];
|
return (searchApi.data.Items as BaseItemDto[]) || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (searchEngine === "Streamystats") {
|
||||||
|
if (!settings?.streamyStatsServerUrl || !api.accessToken) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const streamyStatsApi = createStreamystatsApi({
|
||||||
|
serverUrl: settings.streamyStatsServerUrl,
|
||||||
|
jellyfinToken: api.accessToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
const typeMap: Record<BaseItemKind, string> = {
|
||||||
|
Movie: "movies",
|
||||||
|
Series: "series",
|
||||||
|
Episode: "episodes",
|
||||||
|
Person: "actors",
|
||||||
|
BoxSet: "movies",
|
||||||
|
Audio: "audio",
|
||||||
|
} as Record<BaseItemKind, string>;
|
||||||
|
|
||||||
|
const searchType = types.length === 1 ? typeMap[types[0]] : "media";
|
||||||
|
const response = await streamyStatsApi.searchIds(
|
||||||
|
query,
|
||||||
|
searchType as "movies" | "series" | "episodes" | "actors" | "media",
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
|
||||||
|
const allIds: string[] = [
|
||||||
|
...(response.data.movies || []),
|
||||||
|
...(response.data.series || []),
|
||||||
|
...(response.data.episodes || []),
|
||||||
|
...(response.data.actors || []),
|
||||||
|
...(response.data.audio || []),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!allIds.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemsResponse = await getItemsApi(api).getItems({
|
||||||
|
ids: allIds,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
|
});
|
||||||
|
|
||||||
|
return (itemsResponse.data.Items as BaseItemDto[]) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marlin search
|
||||||
if (!settings?.marlinServerUrl) {
|
if (!settings?.marlinServerUrl) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -141,12 +190,11 @@ export default function search() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (response2.data.Items as BaseItemDto[]) || [];
|
return (response2.data.Items as BaseItemDto[]) || [];
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
console.error("Error during search:", error);
|
return [];
|
||||||
return []; // Ensure an empty array is returned in case of an error
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[api, searchEngine, settings],
|
[api, searchEngine, settings, user?.Id],
|
||||||
);
|
);
|
||||||
|
|
||||||
type HeaderSearchBarRef = {
|
type HeaderSearchBarRef = {
|
||||||
|
|||||||
297
app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx
Normal file
297
app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
import { useLocalSearchParams, useNavigation, useRouter } from "expo-router";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
RefreshControl,
|
||||||
|
TouchableOpacity,
|
||||||
|
useWindowDimensions,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
|
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||||
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
|
import {
|
||||||
|
useDeleteWatchlist,
|
||||||
|
useRemoveFromWatchlist,
|
||||||
|
} from "@/hooks/useWatchlistMutations";
|
||||||
|
import {
|
||||||
|
useWatchlistDetailQuery,
|
||||||
|
useWatchlistItemsQuery,
|
||||||
|
} from "@/hooks/useWatchlists";
|
||||||
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
|
import { userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
|
export default function WatchlistDetailScreen() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { watchlistId } = useLocalSearchParams<{ watchlistId: string }>();
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
|
const { width: screenWidth } = useWindowDimensions();
|
||||||
|
const { orientation } = useOrientation();
|
||||||
|
|
||||||
|
const watchlistIdNum = watchlistId
|
||||||
|
? Number.parseInt(watchlistId, 10)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const nrOfCols = useMemo(() => {
|
||||||
|
if (screenWidth < 300) return 2;
|
||||||
|
if (screenWidth < 500) return 3;
|
||||||
|
if (screenWidth < 800) return 5;
|
||||||
|
if (screenWidth < 1000) return 6;
|
||||||
|
if (screenWidth < 1500) return 7;
|
||||||
|
return 6;
|
||||||
|
}, [screenWidth]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: watchlist,
|
||||||
|
isLoading: watchlistLoading,
|
||||||
|
refetch: refetchWatchlist,
|
||||||
|
} = useWatchlistDetailQuery(watchlistIdNum);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: items,
|
||||||
|
isLoading: itemsLoading,
|
||||||
|
refetch: refetchItems,
|
||||||
|
} = useWatchlistItemsQuery(watchlistIdNum);
|
||||||
|
|
||||||
|
const deleteWatchlist = useDeleteWatchlist();
|
||||||
|
const removeFromWatchlist = useRemoveFromWatchlist();
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
|
const isOwner = useMemo(
|
||||||
|
() => watchlist?.userId === user?.Id,
|
||||||
|
[watchlist?.userId, user?.Id],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set up header
|
||||||
|
useEffect(() => {
|
||||||
|
navigation.setOptions({
|
||||||
|
headerTitle: watchlist?.name || "",
|
||||||
|
headerLeft: () => <HeaderBackButton />,
|
||||||
|
headerRight: isOwner
|
||||||
|
? () => (
|
||||||
|
<View className='flex-row gap-2'>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() =>
|
||||||
|
router.push(`/(auth)/(tabs)/(watchlists)/edit/${watchlistId}`)
|
||||||
|
}
|
||||||
|
className='p-2'
|
||||||
|
>
|
||||||
|
<Ionicons name='pencil' size={20} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity onPress={handleDelete} className='p-2'>
|
||||||
|
<Ionicons name='trash-outline' size={20} color='#ef4444' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
}, [navigation, watchlist?.name, isOwner, watchlistId]);
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(async () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
await Promise.all([refetchWatchlist(), refetchItems()]);
|
||||||
|
setRefreshing(false);
|
||||||
|
}, [refetchWatchlist, refetchItems]);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(() => {
|
||||||
|
Alert.alert(
|
||||||
|
t("watchlists.delete_confirm_title"),
|
||||||
|
t("watchlists.delete_confirm_message", { name: watchlist?.name }),
|
||||||
|
[
|
||||||
|
{ text: t("watchlists.cancel_button"), style: "cancel" },
|
||||||
|
{
|
||||||
|
text: t("watchlists.delete_button"),
|
||||||
|
style: "destructive",
|
||||||
|
onPress: async () => {
|
||||||
|
if (watchlistIdNum) {
|
||||||
|
await deleteWatchlist.mutateAsync(watchlistIdNum);
|
||||||
|
router.back();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}, [deleteWatchlist, watchlistIdNum, watchlist?.name, router, t]);
|
||||||
|
|
||||||
|
const handleRemoveItem = useCallback(
|
||||||
|
(item: BaseItemDto) => {
|
||||||
|
if (!watchlistIdNum || !item.Id) return;
|
||||||
|
|
||||||
|
Alert.alert(
|
||||||
|
t("watchlists.remove_item_title"),
|
||||||
|
t("watchlists.remove_item_message", { name: item.Name }),
|
||||||
|
[
|
||||||
|
{ text: t("watchlists.cancel_button"), style: "cancel" },
|
||||||
|
{
|
||||||
|
text: t("watchlists.remove_button"),
|
||||||
|
style: "destructive",
|
||||||
|
onPress: async () => {
|
||||||
|
await removeFromWatchlist.mutateAsync({
|
||||||
|
watchlistId: watchlistIdNum,
|
||||||
|
itemId: item.Id!,
|
||||||
|
watchlistName: watchlist?.name,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[removeFromWatchlist, watchlistIdNum, watchlist?.name, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderItem = useCallback(
|
||||||
|
({ item, index }: { item: BaseItemDto; index: number }) => (
|
||||||
|
<TouchableItemRouter
|
||||||
|
key={item.Id}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
|
item={item}
|
||||||
|
onLongPress={isOwner ? () => handleRemoveItem(item) : undefined}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
alignSelf:
|
||||||
|
orientation === ScreenOrientation.OrientationLock.PORTRAIT_UP
|
||||||
|
? index % nrOfCols === 0
|
||||||
|
? "flex-end"
|
||||||
|
: (index + 1) % nrOfCols === 0
|
||||||
|
? "flex-start"
|
||||||
|
: "center"
|
||||||
|
: "center",
|
||||||
|
width: "89%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ItemPoster item={item} />
|
||||||
|
<ItemCardText item={item} />
|
||||||
|
</View>
|
||||||
|
</TouchableItemRouter>
|
||||||
|
),
|
||||||
|
[isOwner, handleRemoveItem, orientation, nrOfCols],
|
||||||
|
);
|
||||||
|
|
||||||
|
const ListHeader = useMemo(
|
||||||
|
() =>
|
||||||
|
watchlist ? (
|
||||||
|
<View className='px-4 pt-4 pb-6 mb-4 border-b border-neutral-800'>
|
||||||
|
{watchlist.description && (
|
||||||
|
<Text className='text-neutral-400 mb-2'>
|
||||||
|
{watchlist.description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<View className='flex-row items-center gap-4'>
|
||||||
|
<View className='flex-row items-center gap-1'>
|
||||||
|
<Ionicons name='film-outline' size={14} color='#9ca3af' />
|
||||||
|
<Text className='text-neutral-400 text-sm'>
|
||||||
|
{items?.length ?? 0}{" "}
|
||||||
|
{(items?.length ?? 0) === 1
|
||||||
|
? t("watchlists.item")
|
||||||
|
: t("watchlists.items")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className='flex-row items-center gap-1'>
|
||||||
|
<Ionicons
|
||||||
|
name={
|
||||||
|
watchlist.isPublic ? "globe-outline" : "lock-closed-outline"
|
||||||
|
}
|
||||||
|
size={14}
|
||||||
|
color='#9ca3af'
|
||||||
|
/>
|
||||||
|
<Text className='text-neutral-400 text-sm'>
|
||||||
|
{watchlist.isPublic
|
||||||
|
? t("watchlists.public")
|
||||||
|
: t("watchlists.private")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{!isOwner && (
|
||||||
|
<Text className='text-neutral-500 text-sm'>
|
||||||
|
{t("watchlists.by_owner")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : null,
|
||||||
|
[watchlist, items?.length, isOwner, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const EmptyComponent = useMemo(
|
||||||
|
() => (
|
||||||
|
<View className='flex-1 items-center justify-center px-8 py-16'>
|
||||||
|
<Ionicons name='film-outline' size={48} color='#4b5563' />
|
||||||
|
<Text className='text-neutral-400 text-center mt-4'>
|
||||||
|
{t("watchlists.empty_watchlist")}
|
||||||
|
</Text>
|
||||||
|
{isOwner && (
|
||||||
|
<Text className='text-neutral-500 text-center mt-2 text-sm'>
|
||||||
|
{t("watchlists.empty_watchlist_hint")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
[isOwner, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||||
|
|
||||||
|
if (watchlistLoading || itemsLoading) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 items-center justify-center'>
|
||||||
|
<ActivityIndicator size='large' />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!watchlist) {
|
||||||
|
return (
|
||||||
|
<View className='flex-1 items-center justify-center px-8'>
|
||||||
|
<Text className='text-lg text-neutral-400'>
|
||||||
|
{t("watchlists.not_found")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlashList
|
||||||
|
key={orientation}
|
||||||
|
data={items ?? []}
|
||||||
|
numColumns={nrOfCols}
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
|
ListHeaderComponent={ListHeader}
|
||||||
|
ListEmptyComponent={EmptyComponent}
|
||||||
|
extraData={[orientation, nrOfCols]}
|
||||||
|
keyExtractor={keyExtractor}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingBottom: 24,
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
|
||||||
|
}
|
||||||
|
renderItem={renderItem}
|
||||||
|
ItemSeparatorComponent={() => (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
app/(auth)/(tabs)/(watchlists)/_layout.tsx
Normal file
74
app/(auth)/(tabs)/(watchlists)/_layout.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Stack, useRouter } from "expo-router";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform, TouchableOpacity } from "react-native";
|
||||||
|
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||||
|
import { useStreamystatsEnabled } from "@/hooks/useWatchlists";
|
||||||
|
|
||||||
|
export default function WatchlistsLayout() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
const streamystatsEnabled = useStreamystatsEnabled();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Stack.Screen
|
||||||
|
name='index'
|
||||||
|
options={{
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
|
headerTitle: t("watchlists.title"),
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
headerShadowVisible: false,
|
||||||
|
headerRight: streamystatsEnabled
|
||||||
|
? () => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() =>
|
||||||
|
router.push("/(auth)/(tabs)/(watchlists)/create")
|
||||||
|
}
|
||||||
|
className='p-1.5'
|
||||||
|
>
|
||||||
|
<Ionicons name='add' size={24} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='[watchlistId]'
|
||||||
|
options={{
|
||||||
|
title: "",
|
||||||
|
headerShown: true,
|
||||||
|
headerBlurEffect: "none",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
headerShadowVisible: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='create'
|
||||||
|
options={{
|
||||||
|
title: t("watchlists.create_title"),
|
||||||
|
presentation: "modal",
|
||||||
|
headerShown: true,
|
||||||
|
headerStyle: { backgroundColor: "#171717" },
|
||||||
|
headerTintColor: "white",
|
||||||
|
contentStyle: { backgroundColor: "#171717" },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='edit/[watchlistId]'
|
||||||
|
options={{
|
||||||
|
title: t("watchlists.edit_title"),
|
||||||
|
presentation: "modal",
|
||||||
|
headerShown: true,
|
||||||
|
headerStyle: { backgroundColor: "#171717" },
|
||||||
|
headerTintColor: "white",
|
||||||
|
contentStyle: { backgroundColor: "#171717" },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||||
|
<Stack.Screen key={name} name={name} options={options} />
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
221
app/(auth)/(tabs)/(watchlists)/create.tsx
Normal file
221
app/(auth)/(tabs)/(watchlists)/create.tsx
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
ScrollView,
|
||||||
|
Switch,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Button } from "@/components/Button";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useCreateWatchlist } from "@/hooks/useWatchlistMutations";
|
||||||
|
import type {
|
||||||
|
StreamystatsWatchlistAllowedItemType,
|
||||||
|
StreamystatsWatchlistSortOrder,
|
||||||
|
} from "@/utils/streamystats/types";
|
||||||
|
|
||||||
|
const ITEM_TYPES: Array<{
|
||||||
|
value: StreamystatsWatchlistAllowedItemType;
|
||||||
|
label: string;
|
||||||
|
}> = [
|
||||||
|
{ value: null, label: "All Types" },
|
||||||
|
{ value: "Movie", label: "Movies Only" },
|
||||||
|
{ value: "Series", label: "Series Only" },
|
||||||
|
{ value: "Episode", label: "Episodes Only" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SORT_OPTIONS: Array<{
|
||||||
|
value: StreamystatsWatchlistSortOrder;
|
||||||
|
label: string;
|
||||||
|
}> = [
|
||||||
|
{ value: "custom", label: "Custom Order" },
|
||||||
|
{ value: "name", label: "Name" },
|
||||||
|
{ value: "dateAdded", label: "Date Added" },
|
||||||
|
{ value: "releaseDate", label: "Release Date" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function CreateWatchlistScreen() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const createWatchlist = useCreateWatchlist();
|
||||||
|
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [isPublic, setIsPublic] = useState(false);
|
||||||
|
const [allowedItemType, setAllowedItemType] =
|
||||||
|
useState<StreamystatsWatchlistAllowedItemType>(null);
|
||||||
|
const [defaultSortOrder, setDefaultSortOrder] =
|
||||||
|
useState<StreamystatsWatchlistSortOrder>("custom");
|
||||||
|
|
||||||
|
const handleCreate = useCallback(async () => {
|
||||||
|
if (!name.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createWatchlist.mutateAsync({
|
||||||
|
name: name.trim(),
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
isPublic,
|
||||||
|
allowedItemType,
|
||||||
|
defaultSortOrder,
|
||||||
|
});
|
||||||
|
router.back();
|
||||||
|
} catch {
|
||||||
|
// Error handled by mutation
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
isPublic,
|
||||||
|
allowedItemType,
|
||||||
|
defaultSortOrder,
|
||||||
|
createWatchlist,
|
||||||
|
router,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||||
|
className='flex-1'
|
||||||
|
style={{ backgroundColor: "#171717" }}
|
||||||
|
>
|
||||||
|
<ScrollView
|
||||||
|
className='flex-1'
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingBottom: insets.bottom + 20,
|
||||||
|
}}
|
||||||
|
keyboardShouldPersistTaps='handled'
|
||||||
|
>
|
||||||
|
{/* Name */}
|
||||||
|
<View className='px-4 py-4'>
|
||||||
|
<Text className='text-sm font-medium text-neutral-400 mb-2'>
|
||||||
|
{t("watchlists.name_label")} *
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
value={name}
|
||||||
|
onChangeText={setName}
|
||||||
|
placeholder={t("watchlists.name_placeholder")}
|
||||||
|
placeholderTextColor='#6b7280'
|
||||||
|
className='bg-neutral-800 text-white px-4 py-3 rounded-lg text-base'
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<View className='px-4 py-4'>
|
||||||
|
<Text className='text-sm font-medium text-neutral-400 mb-2'>
|
||||||
|
{t("watchlists.description_label")}
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
value={description}
|
||||||
|
onChangeText={setDescription}
|
||||||
|
placeholder={t("watchlists.description_placeholder")}
|
||||||
|
placeholderTextColor='#6b7280'
|
||||||
|
className='bg-neutral-800 text-white px-4 py-3 rounded-lg text-base'
|
||||||
|
multiline
|
||||||
|
numberOfLines={3}
|
||||||
|
textAlignVertical='top'
|
||||||
|
style={{ minHeight: 80 }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Public Toggle */}
|
||||||
|
<View className='px-4 py-4 flex-row items-center justify-between'>
|
||||||
|
<View className='flex-1 mr-4'>
|
||||||
|
<Text className='text-base font-medium text-white'>
|
||||||
|
{t("watchlists.is_public_label")}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-sm text-neutral-400 mt-1'>
|
||||||
|
{t("watchlists.is_public_description")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={isPublic}
|
||||||
|
onValueChange={setIsPublic}
|
||||||
|
trackColor={{ false: "#374151", true: "#7c3aed" }}
|
||||||
|
thumbColor={isPublic ? "#a78bfa" : "#9ca3af"}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Content Type */}
|
||||||
|
<View className='px-4 py-4'>
|
||||||
|
<Text className='text-sm font-medium text-neutral-400 mb-2'>
|
||||||
|
{t("watchlists.allowed_type_label")}
|
||||||
|
</Text>
|
||||||
|
<View className='flex-row flex-wrap gap-2'>
|
||||||
|
{ITEM_TYPES.map((type) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={type.value ?? "all"}
|
||||||
|
onPress={() => setAllowedItemType(type.value)}
|
||||||
|
className={`px-4 py-2 rounded-lg ${allowedItemType === type.value ? "bg-purple-600" : "bg-neutral-800"}`}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={
|
||||||
|
allowedItemType === type.value
|
||||||
|
? "text-white font-medium"
|
||||||
|
: "text-neutral-300"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{type.label}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Sort Order */}
|
||||||
|
<View className='px-4 py-4'>
|
||||||
|
<Text className='text-sm font-medium text-neutral-400 mb-2'>
|
||||||
|
{t("watchlists.sort_order_label")}
|
||||||
|
</Text>
|
||||||
|
<View className='flex-row flex-wrap gap-2'>
|
||||||
|
{SORT_OPTIONS.map((sort) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={sort.value}
|
||||||
|
onPress={() => setDefaultSortOrder(sort.value)}
|
||||||
|
className={`px-4 py-2 rounded-lg ${defaultSortOrder === sort.value ? "bg-purple-600" : "bg-neutral-800"}`}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={
|
||||||
|
defaultSortOrder === sort.value
|
||||||
|
? "text-white font-medium"
|
||||||
|
: "text-neutral-300"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{sort.label}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Create Button */}
|
||||||
|
<View className='px-4 pt-4'>
|
||||||
|
<Button
|
||||||
|
onPress={handleCreate}
|
||||||
|
disabled={!name.trim() || createWatchlist.isPending}
|
||||||
|
className={`py-3 ${!name.trim() ? "opacity-50" : ""}`}
|
||||||
|
>
|
||||||
|
{createWatchlist.isPending ? (
|
||||||
|
<ActivityIndicator color='white' />
|
||||||
|
) : (
|
||||||
|
<View className='flex-row items-center'>
|
||||||
|
<Ionicons name='add' size={20} color='white' />
|
||||||
|
<Text className='text-white font-semibold text-base'>
|
||||||
|
{t("watchlists.create_button")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
273
app/(auth)/(tabs)/(watchlists)/edit/[watchlistId].tsx
Normal file
273
app/(auth)/(tabs)/(watchlists)/edit/[watchlistId].tsx
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
ScrollView,
|
||||||
|
Switch,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Button } from "@/components/Button";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useUpdateWatchlist } from "@/hooks/useWatchlistMutations";
|
||||||
|
import { useWatchlistDetailQuery } from "@/hooks/useWatchlists";
|
||||||
|
import type {
|
||||||
|
StreamystatsWatchlistAllowedItemType,
|
||||||
|
StreamystatsWatchlistSortOrder,
|
||||||
|
} from "@/utils/streamystats/types";
|
||||||
|
|
||||||
|
const ITEM_TYPES: Array<{
|
||||||
|
value: StreamystatsWatchlistAllowedItemType;
|
||||||
|
label: string;
|
||||||
|
}> = [
|
||||||
|
{ value: null, label: "All Types" },
|
||||||
|
{ value: "Movie", label: "Movies Only" },
|
||||||
|
{ value: "Series", label: "Series Only" },
|
||||||
|
{ value: "Episode", label: "Episodes Only" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SORT_OPTIONS: Array<{
|
||||||
|
value: StreamystatsWatchlistSortOrder;
|
||||||
|
label: string;
|
||||||
|
}> = [
|
||||||
|
{ value: "custom", label: "Custom Order" },
|
||||||
|
{ value: "name", label: "Name" },
|
||||||
|
{ value: "dateAdded", label: "Date Added" },
|
||||||
|
{ value: "releaseDate", label: "Release Date" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function EditWatchlistScreen() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { watchlistId } = useLocalSearchParams<{ watchlistId: string }>();
|
||||||
|
const watchlistIdNum = watchlistId
|
||||||
|
? Number.parseInt(watchlistId, 10)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const { data: watchlist, isLoading } =
|
||||||
|
useWatchlistDetailQuery(watchlistIdNum);
|
||||||
|
const updateWatchlist = useUpdateWatchlist();
|
||||||
|
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [isPublic, setIsPublic] = useState(false);
|
||||||
|
const [allowedItemType, setAllowedItemType] =
|
||||||
|
useState<StreamystatsWatchlistAllowedItemType>(null);
|
||||||
|
const [defaultSortOrder, setDefaultSortOrder] =
|
||||||
|
useState<StreamystatsWatchlistSortOrder>("custom");
|
||||||
|
|
||||||
|
// Initialize form with watchlist data
|
||||||
|
useEffect(() => {
|
||||||
|
if (watchlist) {
|
||||||
|
setName(watchlist.name);
|
||||||
|
setDescription(watchlist.description ?? "");
|
||||||
|
setIsPublic(watchlist.isPublic);
|
||||||
|
setAllowedItemType(
|
||||||
|
(watchlist.allowedItemType as StreamystatsWatchlistAllowedItemType) ??
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
setDefaultSortOrder(
|
||||||
|
(watchlist.defaultSortOrder as StreamystatsWatchlistSortOrder) ??
|
||||||
|
"custom",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [watchlist]);
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
if (!name.trim() || !watchlistIdNum) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateWatchlist.mutateAsync({
|
||||||
|
watchlistId: watchlistIdNum,
|
||||||
|
data: {
|
||||||
|
name: name.trim(),
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
isPublic,
|
||||||
|
allowedItemType,
|
||||||
|
defaultSortOrder,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
router.back();
|
||||||
|
} catch {
|
||||||
|
// Error handled by mutation
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
isPublic,
|
||||||
|
allowedItemType,
|
||||||
|
defaultSortOrder,
|
||||||
|
watchlistIdNum,
|
||||||
|
updateWatchlist,
|
||||||
|
router,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className='flex-1 items-center justify-center'
|
||||||
|
style={{ backgroundColor: "#171717" }}
|
||||||
|
>
|
||||||
|
<ActivityIndicator size='large' />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!watchlist) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className='flex-1 items-center justify-center px-8'
|
||||||
|
style={{ backgroundColor: "#171717" }}
|
||||||
|
>
|
||||||
|
<Text className='text-lg text-neutral-400'>
|
||||||
|
{t("watchlists.not_found")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||||
|
className='flex-1'
|
||||||
|
style={{ backgroundColor: "#171717" }}
|
||||||
|
>
|
||||||
|
<ScrollView
|
||||||
|
className='flex-1'
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingBottom: insets.bottom + 20,
|
||||||
|
}}
|
||||||
|
keyboardShouldPersistTaps='handled'
|
||||||
|
>
|
||||||
|
{/* Name */}
|
||||||
|
<View className='px-4 py-4'>
|
||||||
|
<Text className='text-sm font-medium text-neutral-400 mb-2'>
|
||||||
|
{t("watchlists.name_label")} *
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
value={name}
|
||||||
|
onChangeText={setName}
|
||||||
|
placeholder={t("watchlists.name_placeholder")}
|
||||||
|
placeholderTextColor='#6b7280'
|
||||||
|
className='bg-neutral-800 text-white px-4 py-3 rounded-lg text-base'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<View className='px-4 py-4'>
|
||||||
|
<Text className='text-sm font-medium text-neutral-400 mb-2'>
|
||||||
|
{t("watchlists.description_label")}
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
value={description}
|
||||||
|
onChangeText={setDescription}
|
||||||
|
placeholder={t("watchlists.description_placeholder")}
|
||||||
|
placeholderTextColor='#6b7280'
|
||||||
|
className='bg-neutral-800 text-white px-4 py-3 rounded-lg text-base'
|
||||||
|
multiline
|
||||||
|
numberOfLines={3}
|
||||||
|
textAlignVertical='top'
|
||||||
|
style={{ minHeight: 80 }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Public Toggle */}
|
||||||
|
<View className='px-4 py-4 flex-row items-center justify-between'>
|
||||||
|
<View className='flex-1 mr-4'>
|
||||||
|
<Text className='text-base font-medium text-white'>
|
||||||
|
{t("watchlists.is_public_label")}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-sm text-neutral-400 mt-1'>
|
||||||
|
{t("watchlists.is_public_description")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={isPublic}
|
||||||
|
onValueChange={setIsPublic}
|
||||||
|
trackColor={{ false: "#374151", true: "#7c3aed" }}
|
||||||
|
thumbColor={isPublic ? "#a78bfa" : "#9ca3af"}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Content Type */}
|
||||||
|
<View className='px-4 py-4'>
|
||||||
|
<Text className='text-sm font-medium text-neutral-400 mb-2'>
|
||||||
|
{t("watchlists.allowed_type_label")}
|
||||||
|
</Text>
|
||||||
|
<View className='flex-row flex-wrap gap-2'>
|
||||||
|
{ITEM_TYPES.map((type) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={type.value ?? "all"}
|
||||||
|
onPress={() => setAllowedItemType(type.value)}
|
||||||
|
className={`px-4 py-2 rounded-lg ${allowedItemType === type.value ? "bg-purple-600" : "bg-neutral-800"}`}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={
|
||||||
|
allowedItemType === type.value
|
||||||
|
? "text-white font-medium"
|
||||||
|
: "text-neutral-300"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{type.label}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Sort Order */}
|
||||||
|
<View className='px-4 py-4'>
|
||||||
|
<Text className='text-sm font-medium text-neutral-400 mb-2'>
|
||||||
|
{t("watchlists.sort_order_label")}
|
||||||
|
</Text>
|
||||||
|
<View className='flex-row flex-wrap gap-2'>
|
||||||
|
{SORT_OPTIONS.map((sort) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={sort.value}
|
||||||
|
onPress={() => setDefaultSortOrder(sort.value)}
|
||||||
|
className={`px-4 py-2 rounded-lg ${defaultSortOrder === sort.value ? "bg-purple-600" : "bg-neutral-800"}`}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={
|
||||||
|
defaultSortOrder === sort.value
|
||||||
|
? "text-white font-medium"
|
||||||
|
: "text-neutral-300"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{sort.label}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Save Button */}
|
||||||
|
<View className='px-4 pt-4'>
|
||||||
|
<Button
|
||||||
|
onPress={handleSave}
|
||||||
|
disabled={!name.trim() || updateWatchlist.isPending}
|
||||||
|
className={`py-3 ${!name.trim() ? "opacity-50" : ""}`}
|
||||||
|
>
|
||||||
|
{updateWatchlist.isPending ? (
|
||||||
|
<ActivityIndicator color='white' />
|
||||||
|
) : (
|
||||||
|
<View className='flex-row items-center'>
|
||||||
|
<Ionicons name='checkmark' size={20} color='white' />
|
||||||
|
<Text className='text-white font-semibold text-base'>
|
||||||
|
{t("watchlists.save_button")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
239
app/(auth)/(tabs)/(watchlists)/index.tsx
Normal file
239
app/(auth)/(tabs)/(watchlists)/index.tsx
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform, RefreshControl, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Button } from "@/components/Button";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import {
|
||||||
|
useStreamystatsEnabled,
|
||||||
|
useWatchlistsQuery,
|
||||||
|
} from "@/hooks/useWatchlists";
|
||||||
|
import { userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import type { StreamystatsWatchlist } from "@/utils/streamystats/types";
|
||||||
|
|
||||||
|
interface WatchlistCardProps {
|
||||||
|
watchlist: StreamystatsWatchlist;
|
||||||
|
isOwner: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WatchlistCard: React.FC<WatchlistCardProps> = ({
|
||||||
|
watchlist,
|
||||||
|
isOwner,
|
||||||
|
onPress,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onPress}
|
||||||
|
className='bg-neutral-900 rounded-xl p-4 mx-4 mb-3'
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<View className='flex-row items-center justify-between mb-2'>
|
||||||
|
<Text className='text-lg font-semibold flex-1' numberOfLines={1}>
|
||||||
|
{watchlist.name}
|
||||||
|
</Text>
|
||||||
|
<View className='flex-row items-center gap-2'>
|
||||||
|
{isOwner && (
|
||||||
|
<View className='bg-purple-600/20 px-2 py-1 rounded'>
|
||||||
|
<Text className='text-purple-400 text-xs'>
|
||||||
|
{t("watchlists.you")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<Ionicons
|
||||||
|
name={watchlist.isPublic ? "globe-outline" : "lock-closed-outline"}
|
||||||
|
size={16}
|
||||||
|
color='#9ca3af'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{watchlist.description && (
|
||||||
|
<Text className='text-neutral-400 text-sm mb-2' numberOfLines={2}>
|
||||||
|
{watchlist.description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View className='flex-row items-center gap-4'>
|
||||||
|
<View className='flex-row items-center gap-1'>
|
||||||
|
<Ionicons name='film-outline' size={14} color='#9ca3af' />
|
||||||
|
<Text className='text-neutral-400 text-sm'>
|
||||||
|
{watchlist.itemCount ?? 0}{" "}
|
||||||
|
{(watchlist.itemCount ?? 0) === 1
|
||||||
|
? t("watchlists.item")
|
||||||
|
: t("watchlists.items")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{watchlist.allowedItemType && (
|
||||||
|
<View className='bg-neutral-800 px-2 py-0.5 rounded'>
|
||||||
|
<Text className='text-neutral-400 text-xs'>
|
||||||
|
{watchlist.allowedItemType}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const EmptyState: React.FC<{ onCreatePress: () => void }> = ({
|
||||||
|
onCreatePress: _onCreatePress,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='flex-1 items-center justify-center px-8'>
|
||||||
|
<Ionicons name='list-outline' size={64} color='#4b5563' />
|
||||||
|
<Text className='text-xl font-semibold mt-4 text-center'>
|
||||||
|
{t("watchlists.empty_title")}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-neutral-400 text-center mt-2 mb-6'>
|
||||||
|
{t("watchlists.empty_description")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const NotConfiguredState: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='flex-1 items-center justify-center px-8'>
|
||||||
|
<Ionicons name='settings-outline' size={64} color='#4b5563' />
|
||||||
|
<Text className='text-xl font-semibold mt-4 text-center'>
|
||||||
|
{t("watchlists.not_configured_title")}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-neutral-400 text-center mt-2 mb-6'>
|
||||||
|
{t("watchlists.not_configured_description")}
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
onPress={() =>
|
||||||
|
router.push(
|
||||||
|
"/(auth)/(tabs)/(home)/settings/plugins/streamystats/page",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className='px-6'
|
||||||
|
>
|
||||||
|
<Text className='font-semibold'>{t("watchlists.go_to_settings")}</Text>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function WatchlistsScreen() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
|
const streamystatsEnabled = useStreamystatsEnabled();
|
||||||
|
const { data: watchlists, isLoading, refetch } = useWatchlistsQuery();
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(async () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
await refetch();
|
||||||
|
setRefreshing(false);
|
||||||
|
}, [refetch]);
|
||||||
|
|
||||||
|
const handleCreatePress = useCallback(() => {
|
||||||
|
router.push("/(auth)/(tabs)/(watchlists)/create");
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const handleWatchlistPress = useCallback(
|
||||||
|
(watchlistId: number) => {
|
||||||
|
router.push(`/(auth)/(tabs)/(watchlists)/${watchlistId}`);
|
||||||
|
},
|
||||||
|
[router],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Separate watchlists into "mine" and "public"
|
||||||
|
const { myWatchlists, publicWatchlists } = useMemo(() => {
|
||||||
|
if (!watchlists) return { myWatchlists: [], publicWatchlists: [] };
|
||||||
|
|
||||||
|
const mine: StreamystatsWatchlist[] = [];
|
||||||
|
const pub: StreamystatsWatchlist[] = [];
|
||||||
|
|
||||||
|
for (const w of watchlists) {
|
||||||
|
if (w.userId === user?.Id) {
|
||||||
|
mine.push(w);
|
||||||
|
} else {
|
||||||
|
pub.push(w);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { myWatchlists: mine, publicWatchlists: pub };
|
||||||
|
}, [watchlists, user?.Id]);
|
||||||
|
|
||||||
|
// Combine into sections for FlashList
|
||||||
|
const sections = useMemo(() => {
|
||||||
|
const result: Array<
|
||||||
|
| { type: "header"; title: string }
|
||||||
|
| { type: "watchlist"; data: StreamystatsWatchlist; isOwner: boolean }
|
||||||
|
> = [];
|
||||||
|
|
||||||
|
if (myWatchlists.length > 0) {
|
||||||
|
result.push({ type: "header", title: t("watchlists.my_watchlists") });
|
||||||
|
for (const w of myWatchlists) {
|
||||||
|
result.push({ type: "watchlist", data: w, isOwner: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (publicWatchlists.length > 0) {
|
||||||
|
result.push({ type: "header", title: t("watchlists.public_watchlists") });
|
||||||
|
for (const w of publicWatchlists) {
|
||||||
|
result.push({ type: "watchlist", data: w, isOwner: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [myWatchlists, publicWatchlists, t]);
|
||||||
|
|
||||||
|
if (!streamystatsEnabled) {
|
||||||
|
return <NotConfiguredState />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoading && (!watchlists || watchlists.length === 0)) {
|
||||||
|
return <EmptyState onCreatePress={handleCreatePress} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlashList
|
||||||
|
data={sections}
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingTop: Platform.OS === "android" ? 10 : 0,
|
||||||
|
paddingBottom: 100,
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
|
||||||
|
}
|
||||||
|
renderItem={({ item }) => {
|
||||||
|
if (item.type === "header") {
|
||||||
|
return (
|
||||||
|
<Text className='text-lg font-bold px-4 pt-4 pb-2'>
|
||||||
|
{item.title}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WatchlistCard
|
||||||
|
watchlist={item.data}
|
||||||
|
isOwner={item.isOwner}
|
||||||
|
onPress={() => handleWatchlistPress(item.data.id)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
getItemType={(item) => item.type}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,8 +10,10 @@ import type {
|
|||||||
import { useFocusEffect, useRouter, withLayoutContext } from "expo-router";
|
import { useFocusEffect, useRouter, withLayoutContext } from "expo-router";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
|
import { MiniPlayerBar } from "@/components/music/MiniPlayerBar";
|
||||||
|
import { MusicPlaybackEngine } from "@/components/music/MusicPlaybackEngine";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { eventBus } from "@/utils/eventBus";
|
import { eventBus } from "@/utils/eventBus";
|
||||||
@@ -47,7 +49,7 @@ export default function TabLayout() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<View style={{ flex: 1 }}>
|
||||||
<SystemBars hidden={false} style='light' />
|
<SystemBars hidden={false} style='light' />
|
||||||
<NativeTabs
|
<NativeTabs
|
||||||
sidebarAdaptable={false}
|
sidebarAdaptable={false}
|
||||||
@@ -100,6 +102,18 @@ export default function TabLayout() {
|
|||||||
: (_e) => ({ sfSymbol: "heart.fill" }),
|
: (_e) => ({ sfSymbol: "heart.fill" }),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<NativeTabs.Screen
|
||||||
|
name='(watchlists)'
|
||||||
|
options={{
|
||||||
|
title: t("watchlists.title"),
|
||||||
|
tabBarItemHidden:
|
||||||
|
!settings?.streamyStatsServerUrl || settings?.hideWatchlistsTab,
|
||||||
|
tabBarIcon:
|
||||||
|
Platform.OS === "android"
|
||||||
|
? (_e) => require("@/assets/icons/list.png")
|
||||||
|
: (_e) => ({ sfSymbol: "list.bullet.rectangle" }),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
name='(libraries)'
|
name='(libraries)'
|
||||||
options={{
|
options={{
|
||||||
@@ -122,6 +136,8 @@ export default function TabLayout() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</NativeTabs>
|
</NativeTabs>
|
||||||
</>
|
<MiniPlayerBar />
|
||||||
|
<MusicPlaybackEngine />
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
633
app/(auth)/now-playing.tsx
Normal file
633
app/(auth)/now-playing.tsx
Normal file
@@ -0,0 +1,633 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type {
|
||||||
|
BaseItemDto,
|
||||||
|
MediaSourceInfo,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Dimensions,
|
||||||
|
Platform,
|
||||||
|
ScrollView,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { Slider } from "react-native-awesome-slider";
|
||||||
|
import DraggableFlatList, {
|
||||||
|
type RenderItemParams,
|
||||||
|
ScaleDecorator,
|
||||||
|
} from "react-native-draggable-flatlist";
|
||||||
|
import { useSharedValue } from "react-native-reanimated";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Badge } from "@/components/Badge";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import {
|
||||||
|
type RepeatMode,
|
||||||
|
useMusicPlayer,
|
||||||
|
} from "@/providers/MusicPlayerProvider";
|
||||||
|
import { formatBitrate } from "@/utils/bitrate";
|
||||||
|
import { formatDuration } from "@/utils/time";
|
||||||
|
|
||||||
|
const formatFileSize = (bytes?: number | null) => {
|
||||||
|
if (!bytes) return null;
|
||||||
|
const sizes = ["B", "KB", "MB", "GB"];
|
||||||
|
if (bytes === 0) return "0 B";
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||||
|
return `${Math.round((bytes / 1024 ** i) * 100) / 100} ${sizes[i]}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatSampleRate = (sampleRate?: number | null) => {
|
||||||
|
if (!sampleRate) return null;
|
||||||
|
return `${(sampleRate / 1000).toFixed(1)} kHz`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { width: SCREEN_WIDTH } = Dimensions.get("window");
|
||||||
|
const ARTWORK_SIZE = SCREEN_WIDTH - 80;
|
||||||
|
|
||||||
|
type ViewMode = "player" | "queue";
|
||||||
|
|
||||||
|
export default function NowPlayingScreen() {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const router = useRouter();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>("player");
|
||||||
|
|
||||||
|
const {
|
||||||
|
currentTrack,
|
||||||
|
queue,
|
||||||
|
queueIndex,
|
||||||
|
isPlaying,
|
||||||
|
isLoading,
|
||||||
|
progress,
|
||||||
|
duration,
|
||||||
|
repeatMode,
|
||||||
|
shuffleEnabled,
|
||||||
|
mediaSource,
|
||||||
|
isTranscoding,
|
||||||
|
togglePlayPause,
|
||||||
|
next,
|
||||||
|
previous,
|
||||||
|
seek,
|
||||||
|
setRepeatMode,
|
||||||
|
toggleShuffle,
|
||||||
|
jumpToIndex,
|
||||||
|
removeFromQueue,
|
||||||
|
reorderQueue,
|
||||||
|
stop,
|
||||||
|
} = useMusicPlayer();
|
||||||
|
|
||||||
|
const sliderProgress = useSharedValue(0);
|
||||||
|
const sliderMin = useSharedValue(0);
|
||||||
|
const sliderMax = useSharedValue(1);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
sliderProgress.value = progress;
|
||||||
|
}, [progress, sliderProgress]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
sliderMax.value = duration > 0 ? duration : 1;
|
||||||
|
}, [duration, sliderMax]);
|
||||||
|
|
||||||
|
const imageUrl = useMemo(() => {
|
||||||
|
if (!api || !currentTrack) return null;
|
||||||
|
const albumId = currentTrack.AlbumId || currentTrack.ParentId;
|
||||||
|
if (albumId) {
|
||||||
|
return `${api.basePath}/Items/${albumId}/Images/Primary?maxHeight=600&maxWidth=600`;
|
||||||
|
}
|
||||||
|
return `${api.basePath}/Items/${currentTrack.Id}/Images/Primary?maxHeight=600&maxWidth=600`;
|
||||||
|
}, [api, currentTrack]);
|
||||||
|
|
||||||
|
const progressText = useMemo(() => {
|
||||||
|
const progressTicks = progress * 10000000;
|
||||||
|
return formatDuration(progressTicks);
|
||||||
|
}, [progress]);
|
||||||
|
|
||||||
|
const durationText = useMemo(() => {
|
||||||
|
const durationTicks = duration * 10000000;
|
||||||
|
return formatDuration(durationTicks);
|
||||||
|
}, [duration]);
|
||||||
|
|
||||||
|
const handleSliderComplete = useCallback(
|
||||||
|
(value: number) => {
|
||||||
|
seek(value);
|
||||||
|
},
|
||||||
|
[seek],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
router.back();
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const _handleStop = useCallback(() => {
|
||||||
|
stop();
|
||||||
|
router.back();
|
||||||
|
}, [stop, router]);
|
||||||
|
|
||||||
|
const cycleRepeatMode = useCallback(() => {
|
||||||
|
const modes: RepeatMode[] = ["off", "all", "one"];
|
||||||
|
const currentIndex = modes.indexOf(repeatMode);
|
||||||
|
const nextMode = modes[(currentIndex + 1) % modes.length];
|
||||||
|
setRepeatMode(nextMode);
|
||||||
|
}, [repeatMode, setRepeatMode]);
|
||||||
|
|
||||||
|
const getRepeatIcon = (): string => {
|
||||||
|
switch (repeatMode) {
|
||||||
|
case "one":
|
||||||
|
return "repeat";
|
||||||
|
case "all":
|
||||||
|
return "repeat";
|
||||||
|
default:
|
||||||
|
return "repeat";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const canGoNext = queueIndex < queue.length - 1 || repeatMode === "all";
|
||||||
|
const canGoPrevious = queueIndex > 0 || progress > 3 || repeatMode === "all";
|
||||||
|
|
||||||
|
if (!currentTrack) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className='flex-1 bg-[#121212] items-center justify-center'
|
||||||
|
style={{
|
||||||
|
paddingTop: Platform.OS === "android" ? insets.top : 0,
|
||||||
|
paddingBottom: Platform.OS === "android" ? insets.bottom : 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className='text-neutral-500'>No track playing</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className='flex-1 bg-[#121212]'
|
||||||
|
style={{
|
||||||
|
paddingTop: Platform.OS === "android" ? insets.top : 0,
|
||||||
|
paddingBottom: Platform.OS === "android" ? insets.bottom : 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<View className='flex-row items-center justify-between px-4 pt-3 pb-2'>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleClose}
|
||||||
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||||
|
className='p-2'
|
||||||
|
>
|
||||||
|
<Ionicons name='chevron-down' size={28} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<View className='flex-row'>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setViewMode("player")}
|
||||||
|
className='px-3 py-1'
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={
|
||||||
|
viewMode === "player"
|
||||||
|
? "text-white font-semibold"
|
||||||
|
: "text-neutral-500"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Now Playing
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setViewMode("queue")}
|
||||||
|
className='px-3 py-1'
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={
|
||||||
|
viewMode === "queue"
|
||||||
|
? "text-white font-semibold"
|
||||||
|
: "text-neutral-500"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Queue ({queue.length})
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<View style={{ width: 16 }} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{viewMode === "player" ? (
|
||||||
|
<PlayerView
|
||||||
|
api={api}
|
||||||
|
currentTrack={currentTrack}
|
||||||
|
imageUrl={imageUrl}
|
||||||
|
sliderProgress={sliderProgress}
|
||||||
|
sliderMin={sliderMin}
|
||||||
|
sliderMax={sliderMax}
|
||||||
|
progressText={progressText}
|
||||||
|
durationText={durationText}
|
||||||
|
isPlaying={isPlaying}
|
||||||
|
isLoading={isLoading}
|
||||||
|
repeatMode={repeatMode}
|
||||||
|
shuffleEnabled={shuffleEnabled}
|
||||||
|
canGoNext={canGoNext}
|
||||||
|
canGoPrevious={canGoPrevious}
|
||||||
|
onSliderComplete={handleSliderComplete}
|
||||||
|
onTogglePlayPause={togglePlayPause}
|
||||||
|
onNext={next}
|
||||||
|
onPrevious={previous}
|
||||||
|
onCycleRepeat={cycleRepeatMode}
|
||||||
|
onToggleShuffle={toggleShuffle}
|
||||||
|
getRepeatIcon={getRepeatIcon}
|
||||||
|
queue={queue}
|
||||||
|
queueIndex={queueIndex}
|
||||||
|
mediaSource={mediaSource}
|
||||||
|
isTranscoding={isTranscoding}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<QueueView
|
||||||
|
api={api}
|
||||||
|
queue={queue}
|
||||||
|
queueIndex={queueIndex}
|
||||||
|
onJumpToIndex={jumpToIndex}
|
||||||
|
onRemoveFromQueue={removeFromQueue}
|
||||||
|
onReorderQueue={reorderQueue}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlayerViewProps {
|
||||||
|
api: any;
|
||||||
|
currentTrack: BaseItemDto;
|
||||||
|
imageUrl: string | null;
|
||||||
|
sliderProgress: any;
|
||||||
|
sliderMin: any;
|
||||||
|
sliderMax: any;
|
||||||
|
progressText: string;
|
||||||
|
durationText: string;
|
||||||
|
isPlaying: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
repeatMode: RepeatMode;
|
||||||
|
shuffleEnabled: boolean;
|
||||||
|
canGoNext: boolean;
|
||||||
|
canGoPrevious: boolean;
|
||||||
|
onSliderComplete: (value: number) => void;
|
||||||
|
onTogglePlayPause: () => void;
|
||||||
|
onNext: () => void;
|
||||||
|
onPrevious: () => void;
|
||||||
|
onCycleRepeat: () => void;
|
||||||
|
onToggleShuffle: () => void;
|
||||||
|
getRepeatIcon: () => string;
|
||||||
|
queue: BaseItemDto[];
|
||||||
|
queueIndex: number;
|
||||||
|
mediaSource: MediaSourceInfo | null;
|
||||||
|
isTranscoding: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PlayerView: React.FC<PlayerViewProps> = ({
|
||||||
|
currentTrack,
|
||||||
|
imageUrl,
|
||||||
|
sliderProgress,
|
||||||
|
sliderMin,
|
||||||
|
sliderMax,
|
||||||
|
progressText,
|
||||||
|
durationText,
|
||||||
|
isPlaying,
|
||||||
|
isLoading,
|
||||||
|
repeatMode,
|
||||||
|
shuffleEnabled,
|
||||||
|
canGoNext,
|
||||||
|
canGoPrevious,
|
||||||
|
onSliderComplete,
|
||||||
|
onTogglePlayPause,
|
||||||
|
onNext,
|
||||||
|
onPrevious,
|
||||||
|
onCycleRepeat,
|
||||||
|
onToggleShuffle,
|
||||||
|
getRepeatIcon,
|
||||||
|
queue,
|
||||||
|
queueIndex,
|
||||||
|
mediaSource,
|
||||||
|
isTranscoding,
|
||||||
|
}) => {
|
||||||
|
const audioStream = useMemo(() => {
|
||||||
|
return mediaSource?.MediaStreams?.find((stream) => stream.Type === "Audio");
|
||||||
|
}, [mediaSource]);
|
||||||
|
|
||||||
|
const fileSize = formatFileSize(mediaSource?.Size);
|
||||||
|
const codec = audioStream?.Codec?.toUpperCase();
|
||||||
|
const bitrate = formatBitrate(audioStream?.BitRate);
|
||||||
|
const sampleRate = formatSampleRate(audioStream?.SampleRate);
|
||||||
|
const playbackMethod = isTranscoding ? "Transcoding" : "Direct";
|
||||||
|
|
||||||
|
const hasAudioStats =
|
||||||
|
mediaSource && (fileSize || codec || bitrate || sampleRate);
|
||||||
|
return (
|
||||||
|
<ScrollView className='flex-1 px-6' showsVerticalScrollIndicator={false}>
|
||||||
|
{/* Album artwork */}
|
||||||
|
<View
|
||||||
|
className='self-center mb-8 mt-4'
|
||||||
|
style={{
|
||||||
|
width: ARTWORK_SIZE,
|
||||||
|
height: ARTWORK_SIZE,
|
||||||
|
borderRadius: 12,
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 8 },
|
||||||
|
shadowOpacity: 0.4,
|
||||||
|
shadowRadius: 16,
|
||||||
|
elevation: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{imageUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: imageUrl }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
cachePolicy='memory-disk'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View className='flex-1 items-center justify-center bg-neutral-800'>
|
||||||
|
<Ionicons name='musical-note' size={80} color='#666' />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Track info */}
|
||||||
|
<View className='mb-6'>
|
||||||
|
<Text numberOfLines={1} className='text-white text-2xl font-bold'>
|
||||||
|
{currentTrack.Name}
|
||||||
|
</Text>
|
||||||
|
<Text numberOfLines={1} className='text-purple-400 text-lg mt-1'>
|
||||||
|
{currentTrack.Artists?.join(", ") || currentTrack.AlbumArtist}
|
||||||
|
</Text>
|
||||||
|
{currentTrack.Album && (
|
||||||
|
<Text numberOfLines={1} className='text-neutral-500 text-sm mt-1'>
|
||||||
|
{currentTrack.Album}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Audio Stats */}
|
||||||
|
{hasAudioStats && (
|
||||||
|
<View className='flex-row flex-wrap gap-1.5 mt-3'>
|
||||||
|
{fileSize && <Badge variant='gray' text={fileSize} />}
|
||||||
|
{codec && <Badge variant='gray' text={codec} />}
|
||||||
|
<Badge
|
||||||
|
variant='gray'
|
||||||
|
text={playbackMethod}
|
||||||
|
iconLeft={
|
||||||
|
<Ionicons
|
||||||
|
name={isTranscoding ? "swap-horizontal" : "play"}
|
||||||
|
size={12}
|
||||||
|
color='white'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{bitrate && bitrate !== "N/A" && (
|
||||||
|
<Badge variant='gray' text={bitrate} />
|
||||||
|
)}
|
||||||
|
{sampleRate && <Badge variant='gray' text={sampleRate} />}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Progress slider */}
|
||||||
|
<View className='mb-4'>
|
||||||
|
<Slider
|
||||||
|
theme={{
|
||||||
|
maximumTrackTintColor: "#333",
|
||||||
|
minimumTrackTintColor: "#9334E9",
|
||||||
|
bubbleBackgroundColor: "#9334E9",
|
||||||
|
bubbleTextColor: "#fff",
|
||||||
|
}}
|
||||||
|
progress={sliderProgress}
|
||||||
|
minimumValue={sliderMin}
|
||||||
|
maximumValue={sliderMax}
|
||||||
|
onSlidingComplete={onSliderComplete}
|
||||||
|
thumbWidth={16}
|
||||||
|
sliderHeight={6}
|
||||||
|
containerStyle={{ borderRadius: 10 }}
|
||||||
|
renderBubble={() => null}
|
||||||
|
/>
|
||||||
|
<View className='flex flex-row justify-between px-1 mt-2'>
|
||||||
|
<Text className='text-neutral-500 text-xs'>{progressText}</Text>
|
||||||
|
<Text className='text-neutral-500 text-xs'>{durationText}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Main Controls */}
|
||||||
|
<View className='flex flex-row items-center justify-center mb-2'>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onPrevious}
|
||||||
|
disabled={!canGoPrevious || isLoading}
|
||||||
|
className='p-4'
|
||||||
|
style={{ opacity: canGoPrevious && !isLoading ? 1 : 0.3 }}
|
||||||
|
>
|
||||||
|
<Ionicons name='play-skip-back' size={32} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onTogglePlayPause}
|
||||||
|
disabled={isLoading}
|
||||||
|
className='mx-8 bg-white rounded-full p-4'
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<ActivityIndicator size={36} color='#121212' />
|
||||||
|
) : (
|
||||||
|
<Ionicons
|
||||||
|
name={isPlaying ? "pause" : "play"}
|
||||||
|
size={36}
|
||||||
|
color='#121212'
|
||||||
|
style={isPlaying ? {} : { marginLeft: 4 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onNext}
|
||||||
|
disabled={!canGoNext || isLoading}
|
||||||
|
className='p-4'
|
||||||
|
style={{ opacity: canGoNext && !isLoading ? 1 : 0.3 }}
|
||||||
|
>
|
||||||
|
<Ionicons name='play-skip-forward' size={32} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Shuffle & Repeat Controls */}
|
||||||
|
<View className='flex flex-row items-center justify-center mb-2'>
|
||||||
|
<TouchableOpacity onPress={onToggleShuffle} className='p-3 mx-4'>
|
||||||
|
<Ionicons
|
||||||
|
name='shuffle'
|
||||||
|
size={24}
|
||||||
|
color={shuffleEnabled ? "#9334E9" : "#666"}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity onPress={onCycleRepeat} className='p-3 mx-4 relative'>
|
||||||
|
<Ionicons
|
||||||
|
name={getRepeatIcon() as any}
|
||||||
|
size={24}
|
||||||
|
color={repeatMode !== "off" ? "#9334E9" : "#666"}
|
||||||
|
/>
|
||||||
|
{repeatMode === "one" && (
|
||||||
|
<View className='absolute right-0 bg-purple-600 rounded-full w-4 h-4 items-center justify-center'>
|
||||||
|
<Text className='text-white text-[10px] font-bold'>1</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Queue info */}
|
||||||
|
{queue.length > 1 && (
|
||||||
|
<View className='items-center mb-4'>
|
||||||
|
<Text className='text-neutral-500 text-sm'>
|
||||||
|
{queueIndex + 1} of {queue.length}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface QueueViewProps {
|
||||||
|
api: any;
|
||||||
|
queue: BaseItemDto[];
|
||||||
|
queueIndex: number;
|
||||||
|
onJumpToIndex: (index: number) => void;
|
||||||
|
onRemoveFromQueue: (index: number) => void;
|
||||||
|
onReorderQueue: (newQueue: BaseItemDto[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QueueView: React.FC<QueueViewProps> = ({
|
||||||
|
api,
|
||||||
|
queue,
|
||||||
|
queueIndex,
|
||||||
|
onJumpToIndex,
|
||||||
|
onRemoveFromQueue,
|
||||||
|
onReorderQueue,
|
||||||
|
}) => {
|
||||||
|
const renderQueueItem = useCallback(
|
||||||
|
({ item, drag, isActive, getIndex }: RenderItemParams<BaseItemDto>) => {
|
||||||
|
const index = getIndex() ?? 0;
|
||||||
|
const isCurrentTrack = index === queueIndex;
|
||||||
|
const isPast = index < queueIndex;
|
||||||
|
|
||||||
|
const albumId = item.AlbumId || item.ParentId;
|
||||||
|
const imageUrl = api
|
||||||
|
? albumId
|
||||||
|
? `${api.basePath}/Items/${albumId}/Images/Primary?maxHeight=80&maxWidth=80`
|
||||||
|
: `${api.basePath}/Items/${item.Id}/Images/Primary?maxHeight=80&maxWidth=80`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScaleDecorator>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => onJumpToIndex(index)}
|
||||||
|
onLongPress={drag}
|
||||||
|
disabled={isActive}
|
||||||
|
className='flex-row items-center px-4 py-3'
|
||||||
|
style={{
|
||||||
|
opacity: isPast && !isActive ? 0.5 : 1,
|
||||||
|
backgroundColor: isActive
|
||||||
|
? "#2a2a2a"
|
||||||
|
: isCurrentTrack
|
||||||
|
? "rgba(147, 52, 233, 0.3)"
|
||||||
|
: "#121212",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Drag handle */}
|
||||||
|
<TouchableOpacity
|
||||||
|
onPressIn={drag}
|
||||||
|
disabled={isActive}
|
||||||
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||||
|
className='pr-2'
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name='reorder-three'
|
||||||
|
size={20}
|
||||||
|
color={isActive ? "#9334E9" : "#666"}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* Album art */}
|
||||||
|
<View className='w-12 h-12 rounded overflow-hidden bg-neutral-800 mr-3'>
|
||||||
|
{imageUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: imageUrl }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
cachePolicy='memory-disk'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View className='flex-1 items-center justify-center'>
|
||||||
|
<Ionicons name='musical-note' size={16} color='#666' />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Track info */}
|
||||||
|
<View className='flex-1 mr-2'>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
className={`text-base ${isCurrentTrack ? "text-purple-400 font-semibold" : "text-white"}`}
|
||||||
|
>
|
||||||
|
{item.Name}
|
||||||
|
</Text>
|
||||||
|
<Text numberOfLines={1} className='text-neutral-500 text-sm'>
|
||||||
|
{item.Artists?.join(", ") || item.AlbumArtist}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Now playing indicator */}
|
||||||
|
{isCurrentTrack && (
|
||||||
|
<Ionicons name='musical-note' size={16} color='#9334E9' />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Remove button (not for current track) */}
|
||||||
|
{!isCurrentTrack && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => onRemoveFromQueue(index)}
|
||||||
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||||
|
className='p-2'
|
||||||
|
>
|
||||||
|
<Ionicons name='close' size={20} color='#666' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</ScaleDecorator>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[api, queueIndex, onJumpToIndex, onRemoveFromQueue],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback(
|
||||||
|
({ data }: { data: BaseItemDto[] }) => {
|
||||||
|
onReorderQueue(data);
|
||||||
|
},
|
||||||
|
[onReorderQueue],
|
||||||
|
);
|
||||||
|
|
||||||
|
const history = queue.slice(0, queueIndex);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DraggableFlatList
|
||||||
|
data={queue}
|
||||||
|
keyExtractor={(item, index) => `${item.Id}-${index}`}
|
||||||
|
renderItem={renderQueueItem}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
ListHeaderComponent={
|
||||||
|
<View className='px-4 py-2'>
|
||||||
|
<Text className='text-neutral-400 text-xs uppercase tracking-wider'>
|
||||||
|
{history.length > 0 ? "Playing from queue" : "Up next"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View className='flex-1 items-center justify-center py-20'>
|
||||||
|
<Text className='text-neutral-500'>Queue is empty</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,7 +1,33 @@
|
|||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { AppState } from "react-native";
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
|
|
||||||
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
|
const { settings } = useSettings();
|
||||||
|
const { lockOrientation, unlockOrientation } = useOrientation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (settings?.defaultVideoOrientation) {
|
||||||
|
lockOrientation(settings.defaultVideoOrientation);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-apply orientation lock when app returns to foreground (iOS resets it)
|
||||||
|
const subscription = AppState.addEventListener("change", (nextAppState) => {
|
||||||
|
if (nextAppState === "active" && settings?.defaultVideoOrientation) {
|
||||||
|
lockOrientation(settings.defaultVideoOrientation);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
subscription.remove();
|
||||||
|
unlockOrientation();
|
||||||
|
};
|
||||||
|
}, [settings?.defaultVideoOrientation, lockOrientation, unlockOrientation]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SystemBars hidden />
|
<SystemBars hidden />
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
137
app/_layout.tsx
137
app/_layout.tsx
@@ -2,7 +2,9 @@ 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 { DarkTheme, ThemeProvider } from "@react-navigation/native";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
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 BackgroundTask from "expo-background-task";
|
||||||
import * as Device from "expo-device";
|
import * as Device from "expo-device";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
@@ -15,6 +17,7 @@ import {
|
|||||||
getOrSetDeviceId,
|
getOrSetDeviceId,
|
||||||
JellyfinProvider,
|
JellyfinProvider,
|
||||||
} from "@/providers/JellyfinProvider";
|
} from "@/providers/JellyfinProvider";
|
||||||
|
import { MusicPlayerProvider } from "@/providers/MusicPlayerProvider";
|
||||||
import { NetworkStatusProvider } from "@/providers/NetworkStatusProvider";
|
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";
|
||||||
@@ -187,11 +190,21 @@ export default function RootLayout() {
|
|||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
staleTime: 30000,
|
staleTime: 30000, // 30 seconds - data is fresh
|
||||||
|
gcTime: 1000 * 60 * 60 * 24, // 24 hours - keep in cache for persistence
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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);
|
||||||
@@ -337,68 +350,90 @@ function Layout() {
|
|||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<PersistQueryClientProvider
|
||||||
|
client={queryClient}
|
||||||
|
persistOptions={{
|
||||||
|
persister: mmkvPersister,
|
||||||
|
maxAge: 1000 * 60 * 60 * 24, // 24 hours max cache age
|
||||||
|
dehydrateOptions: {
|
||||||
|
shouldDehydrateQuery: (query) => {
|
||||||
|
// Only persist successful queries
|
||||||
|
return query.state.status === "success";
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
<JellyfinProvider>
|
<JellyfinProvider>
|
||||||
<NetworkStatusProvider>
|
<NetworkStatusProvider>
|
||||||
<PlaySettingsProvider>
|
<PlaySettingsProvider>
|
||||||
<LogProvider>
|
<LogProvider>
|
||||||
<WebSocketProvider>
|
<WebSocketProvider>
|
||||||
<DownloadProvider>
|
<DownloadProvider>
|
||||||
<GlobalModalProvider>
|
<MusicPlayerProvider>
|
||||||
<BottomSheetModalProvider>
|
<GlobalModalProvider>
|
||||||
<ThemeProvider value={DarkTheme}>
|
<BottomSheetModalProvider>
|
||||||
<SystemBars style='light' hidden={false} />
|
<ThemeProvider value={DarkTheme}>
|
||||||
<Stack initialRouteName='(auth)/(tabs)'>
|
<SystemBars style='light' hidden={false} />
|
||||||
<Stack.Screen
|
<Stack initialRouteName='(auth)/(tabs)'>
|
||||||
name='(auth)/(tabs)'
|
<Stack.Screen
|
||||||
options={{
|
name='(auth)/(tabs)'
|
||||||
headerShown: false,
|
options={{
|
||||||
title: "",
|
headerShown: false,
|
||||||
header: () => null,
|
title: "",
|
||||||
|
header: () => null,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='(auth)/player'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
title: "",
|
||||||
|
header: () => null,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='(auth)/now-playing'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: "modal",
|
||||||
|
gestureEnabled: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='login'
|
||||||
|
options={{
|
||||||
|
headerShown: true,
|
||||||
|
title: "",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen name='+not-found' />
|
||||||
|
</Stack>
|
||||||
|
<Toaster
|
||||||
|
duration={4000}
|
||||||
|
toastOptions={{
|
||||||
|
style: {
|
||||||
|
backgroundColor: "#262626",
|
||||||
|
borderColor: "#363639",
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
titleStyle: {
|
||||||
|
color: "white",
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
|
closeButton
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<GlobalModal />
|
||||||
name='(auth)/player'
|
</ThemeProvider>
|
||||||
options={{
|
</BottomSheetModalProvider>
|
||||||
headerShown: false,
|
</GlobalModalProvider>
|
||||||
title: "",
|
</MusicPlayerProvider>
|
||||||
header: () => null,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='login'
|
|
||||||
options={{
|
|
||||||
headerShown: true,
|
|
||||||
title: "",
|
|
||||||
headerTransparent: Platform.OS === "ios",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen name='+not-found' />
|
|
||||||
</Stack>
|
|
||||||
<Toaster
|
|
||||||
duration={4000}
|
|
||||||
toastOptions={{
|
|
||||||
style: {
|
|
||||||
backgroundColor: "#262626",
|
|
||||||
borderColor: "#363639",
|
|
||||||
borderWidth: 1,
|
|
||||||
},
|
|
||||||
titleStyle: {
|
|
||||||
color: "white",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
closeButton
|
|
||||||
/>
|
|
||||||
<GlobalModal />
|
|
||||||
</ThemeProvider>
|
|
||||||
</BottomSheetModalProvider>
|
|
||||||
</GlobalModalProvider>
|
|
||||||
</DownloadProvider>
|
</DownloadProvider>
|
||||||
</WebSocketProvider>
|
</WebSocketProvider>
|
||||||
</LogProvider>
|
</LogProvider>
|
||||||
</PlaySettingsProvider>
|
</PlaySettingsProvider>
|
||||||
</NetworkStatusProvider>
|
</NetworkStatusProvider>
|
||||||
</JellyfinProvider>
|
</JellyfinProvider>
|
||||||
</QueryClientProvider>
|
</PersistQueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -262,12 +262,12 @@ const Login: React.FC = () => {
|
|||||||
<Input
|
<Input
|
||||||
placeholder={t("login.username_placeholder")}
|
placeholder={t("login.username_placeholder")}
|
||||||
onChangeText={(text: string) =>
|
onChangeText={(text: string) =>
|
||||||
setCredentials({ ...credentials, username: text })
|
setCredentials((prev) => ({ ...prev, username: text }))
|
||||||
}
|
}
|
||||||
onEndEditing={(e) => {
|
onEndEditing={(e) => {
|
||||||
const newValue = e.nativeEvent.text;
|
const newValue = e.nativeEvent.text;
|
||||||
if (newValue && newValue !== credentials.username) {
|
if (newValue && newValue !== credentials.username) {
|
||||||
setCredentials({ ...credentials, username: newValue });
|
setCredentials((prev) => ({ ...prev, username: newValue }));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
value={credentials.username}
|
value={credentials.username}
|
||||||
@@ -286,12 +286,12 @@ const Login: React.FC = () => {
|
|||||||
<Input
|
<Input
|
||||||
placeholder={t("login.password_placeholder")}
|
placeholder={t("login.password_placeholder")}
|
||||||
onChangeText={(text: string) =>
|
onChangeText={(text: string) =>
|
||||||
setCredentials({ ...credentials, password: text })
|
setCredentials((prev) => ({ ...prev, password: text }))
|
||||||
}
|
}
|
||||||
onEndEditing={(e) => {
|
onEndEditing={(e) => {
|
||||||
const newValue = e.nativeEvent.text;
|
const newValue = e.nativeEvent.text;
|
||||||
if (newValue && newValue !== credentials.password) {
|
if (newValue && newValue !== credentials.password) {
|
||||||
setCredentials({ ...credentials, password: newValue });
|
setCredentials((prev) => ({ ...prev, password: newValue }));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
value={credentials.password}
|
value={credentials.password}
|
||||||
@@ -398,8 +398,8 @@ const Login: React.FC = () => {
|
|||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
>
|
>
|
||||||
{api?.basePath ? (
|
{api?.basePath ? (
|
||||||
<View className='flex flex-col flex-1 items-center justify-center'>
|
<View className='flex flex-col flex-1 justify-center'>
|
||||||
<View className='px-4 -mt-20 w-full'>
|
<View className='px-4 w-full'>
|
||||||
<View className='flex flex-col space-y-2'>
|
<View className='flex flex-col space-y-2'>
|
||||||
<Text className='text-2xl font-bold -mb-2'>
|
<Text className='text-2xl font-bold -mb-2'>
|
||||||
{serverName ? (
|
{serverName ? (
|
||||||
@@ -415,12 +415,15 @@ const Login: React.FC = () => {
|
|||||||
<Input
|
<Input
|
||||||
placeholder={t("login.username_placeholder")}
|
placeholder={t("login.username_placeholder")}
|
||||||
onChangeText={(text) =>
|
onChangeText={(text) =>
|
||||||
setCredentials({ ...credentials, username: text })
|
setCredentials((prev) => ({ ...prev, username: text }))
|
||||||
}
|
}
|
||||||
onEndEditing={(e) => {
|
onEndEditing={(e) => {
|
||||||
const newValue = e.nativeEvent.text;
|
const newValue = e.nativeEvent.text;
|
||||||
if (newValue && newValue !== credentials.username) {
|
if (newValue && newValue !== credentials.username) {
|
||||||
setCredentials({ ...credentials, username: newValue });
|
setCredentials((prev) => ({
|
||||||
|
...prev,
|
||||||
|
username: newValue,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
value={credentials.username}
|
value={credentials.username}
|
||||||
@@ -437,12 +440,15 @@ const Login: React.FC = () => {
|
|||||||
<Input
|
<Input
|
||||||
placeholder={t("login.password_placeholder")}
|
placeholder={t("login.password_placeholder")}
|
||||||
onChangeText={(text) =>
|
onChangeText={(text) =>
|
||||||
setCredentials({ ...credentials, password: text })
|
setCredentials((prev) => ({ ...prev, password: text }))
|
||||||
}
|
}
|
||||||
onEndEditing={(e) => {
|
onEndEditing={(e) => {
|
||||||
const newValue = e.nativeEvent.text;
|
const newValue = e.nativeEvent.text;
|
||||||
if (newValue && newValue !== credentials.password) {
|
if (newValue && newValue !== credentials.password) {
|
||||||
setCredentials({ ...credentials, password: newValue });
|
setCredentials((prev) => ({
|
||||||
|
...prev,
|
||||||
|
password: newValue,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
value={credentials.password}
|
value={credentials.password}
|
||||||
|
|||||||
BIN
assets/Download_with_Obtainium.png
Normal file
BIN
assets/Download_with_Obtainium.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
@@ -8,7 +8,8 @@
|
|||||||
"!android",
|
"!android",
|
||||||
"!Streamyfin.app",
|
"!Streamyfin.app",
|
||||||
"!utils/jellyseerr",
|
"!utils/jellyseerr",
|
||||||
"!.expo"
|
"!.expo",
|
||||||
|
"!docs/jellyfin-openapi-stable.json"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"linter": {
|
"linter": {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export const AddToFavorites: FC<Props> = ({ item, ...props }) => {
|
|||||||
<RoundButton
|
<RoundButton
|
||||||
size='large'
|
size='large'
|
||||||
icon={isFavorite ? "heart" : "heart-outline"}
|
icon={isFavorite ? "heart" : "heart-outline"}
|
||||||
|
color={isFavorite ? "purple" : "white"}
|
||||||
onPress={toggleFavorite}
|
onPress={toggleFavorite}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
43
components/AddToWatchlist.tsx
Normal file
43
components/AddToWatchlist.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import type { FC } from "react";
|
||||||
|
import { useCallback, useRef } from "react";
|
||||||
|
import { View, type ViewProps } from "react-native";
|
||||||
|
import { RoundButton } from "@/components/RoundButton";
|
||||||
|
import {
|
||||||
|
WatchlistSheet,
|
||||||
|
type WatchlistSheetRef,
|
||||||
|
} from "@/components/watchlists/WatchlistSheet";
|
||||||
|
import {
|
||||||
|
useItemInWatchlists,
|
||||||
|
useStreamystatsEnabled,
|
||||||
|
} from "@/hooks/useWatchlists";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {
|
||||||
|
item: BaseItemDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AddToWatchlist: FC<Props> = ({ item, ...props }) => {
|
||||||
|
const streamystatsEnabled = useStreamystatsEnabled();
|
||||||
|
const sheetRef = useRef<WatchlistSheetRef>(null);
|
||||||
|
|
||||||
|
const { data: watchlistsContainingItem } = useItemInWatchlists(item.Id);
|
||||||
|
const isInAnyWatchlist = (watchlistsContainingItem?.length ?? 0) > 0;
|
||||||
|
|
||||||
|
const handlePress = useCallback(() => {
|
||||||
|
sheetRef.current?.open(item);
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
// Don't render if Streamystats is not enabled
|
||||||
|
if (!streamystatsEnabled) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View {...props}>
|
||||||
|
<RoundButton
|
||||||
|
size='large'
|
||||||
|
icon={isInAnyWatchlist ? "list" : "list-outline"}
|
||||||
|
onPress={handlePress}
|
||||||
|
/>
|
||||||
|
<WatchlistSheet ref={sheetRef} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -2,7 +2,6 @@ import type React from "react";
|
|||||||
import {
|
import {
|
||||||
type PropsWithChildren,
|
type PropsWithChildren,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
useMemo,
|
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
@@ -18,6 +17,58 @@ import {
|
|||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { Loader } from "./Loader";
|
import { Loader } from "./Loader";
|
||||||
|
|
||||||
|
const getColorClasses = (
|
||||||
|
color: "purple" | "red" | "black" | "transparent" | "white",
|
||||||
|
variant: "solid" | "border",
|
||||||
|
focused: boolean,
|
||||||
|
): string => {
|
||||||
|
if (variant === "border") {
|
||||||
|
switch (color) {
|
||||||
|
case "purple":
|
||||||
|
return focused
|
||||||
|
? "bg-transparent border-2 border-purple-400"
|
||||||
|
: "bg-transparent border-2 border-purple-600";
|
||||||
|
case "red":
|
||||||
|
return focused
|
||||||
|
? "bg-transparent border-2 border-red-400"
|
||||||
|
: "bg-transparent border-2 border-red-600";
|
||||||
|
case "black":
|
||||||
|
return focused
|
||||||
|
? "bg-transparent border-2 border-neutral-700"
|
||||||
|
: "bg-transparent border-2 border-neutral-900";
|
||||||
|
case "white":
|
||||||
|
return focused
|
||||||
|
? "bg-transparent border-2 border-gray-100"
|
||||||
|
: "bg-transparent border-2 border-white";
|
||||||
|
case "transparent":
|
||||||
|
return focused
|
||||||
|
? "bg-transparent border-2 border-gray-400"
|
||||||
|
: "bg-transparent border-2 border-gray-600";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
switch (color) {
|
||||||
|
case "purple":
|
||||||
|
return focused
|
||||||
|
? "bg-purple-500 border-2 border-white"
|
||||||
|
: "bg-purple-600 border border-purple-700";
|
||||||
|
case "red":
|
||||||
|
return "bg-red-600";
|
||||||
|
case "black":
|
||||||
|
return "bg-neutral-900";
|
||||||
|
case "white":
|
||||||
|
return focused
|
||||||
|
? "bg-gray-100 border-2 border-gray-300"
|
||||||
|
: "bg-white border border-gray-200";
|
||||||
|
case "transparent":
|
||||||
|
return "bg-transparent";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export interface ButtonProps
|
export interface ButtonProps
|
||||||
extends React.ComponentProps<typeof TouchableOpacity> {
|
extends React.ComponentProps<typeof TouchableOpacity> {
|
||||||
onPress?: () => void;
|
onPress?: () => void;
|
||||||
@@ -26,7 +77,8 @@ export interface ButtonProps
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
children?: string | ReactNode;
|
children?: string | ReactNode;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
color?: "purple" | "red" | "black" | "transparent";
|
color?: "purple" | "red" | "black" | "transparent" | "white";
|
||||||
|
variant?: "solid" | "border";
|
||||||
iconRight?: ReactNode;
|
iconRight?: ReactNode;
|
||||||
iconLeft?: ReactNode;
|
iconLeft?: ReactNode;
|
||||||
justify?: "center" | "between";
|
justify?: "center" | "between";
|
||||||
@@ -39,6 +91,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
loading = false,
|
loading = false,
|
||||||
color = "purple",
|
color = "purple",
|
||||||
|
variant = "solid",
|
||||||
iconRight,
|
iconRight,
|
||||||
iconLeft,
|
iconLeft,
|
||||||
children,
|
children,
|
||||||
@@ -56,23 +109,13 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
|||||||
useNativeDriver: true,
|
useNativeDriver: true,
|
||||||
}).start();
|
}).start();
|
||||||
|
|
||||||
const colorClasses = useMemo(() => {
|
const colorClasses = getColorClasses(color, variant, focused);
|
||||||
switch (color) {
|
|
||||||
case "purple":
|
|
||||||
return focused
|
|
||||||
? "bg-purple-500 border-2 border-white"
|
|
||||||
: "bg-purple-600 border border-purple-700";
|
|
||||||
case "red":
|
|
||||||
return "bg-red-600";
|
|
||||||
case "black":
|
|
||||||
return "bg-neutral-900";
|
|
||||||
case "transparent":
|
|
||||||
return "bg-transparent";
|
|
||||||
}
|
|
||||||
}, [color, focused]);
|
|
||||||
|
|
||||||
const lightHapticFeedback = useHaptic("light");
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
|
const textColorClass =
|
||||||
|
color === "white" && variant === "solid" ? "text-black" : "text-white";
|
||||||
|
|
||||||
return Platform.isTV ? (
|
return Platform.isTV ? (
|
||||||
<Pressable
|
<Pressable
|
||||||
className='w-full'
|
className='w-full'
|
||||||
@@ -98,10 +141,12 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
|||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
className={`rounded-2xl py-5 items-center justify-center
|
className={`rounded-2xl py-5 items-center justify-center
|
||||||
${focused ? "bg-purple-500 border-2 border-white" : "bg-purple-600 border border-purple-700"}
|
${colorClasses}
|
||||||
${className}`}
|
${className}`}
|
||||||
>
|
>
|
||||||
<Text className='text-white text-xl font-bold'>{children}</Text>
|
<Text className={`${textColorClass} text-xl font-bold`}>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -135,7 +180,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
|||||||
{iconLeft ? iconLeft : <View className='w-4' />}
|
{iconLeft ? iconLeft : <View className='w-4' />}
|
||||||
<Text
|
<Text
|
||||||
className={`
|
className={`
|
||||||
text-white font-bold text-base
|
${textColorClass} font-bold text-base
|
||||||
${disabled ? "text-gray-300" : ""}
|
${disabled ? "text-gray-300" : ""}
|
||||||
${textClassName}
|
${textClassName}
|
||||||
${iconRight ? "mr-2" : ""}
|
${iconRight ? "mr-2" : ""}
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedOptions(() => ({
|
setSelectedOptions(() => ({
|
||||||
bitrate: defaultBitrate,
|
bitrate: defaultBitrate,
|
||||||
mediaSource: defaultMediaSource,
|
mediaSource: defaultMediaSource ?? undefined,
|
||||||
subtitleIndex: defaultSubtitleIndex ?? -1,
|
subtitleIndex: defaultSubtitleIndex ?? -1,
|
||||||
audioIndex: defaultAudioIndex,
|
audioIndex: defaultAudioIndex,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
type BottomSheetBackdropProps,
|
type BottomSheetBackdropProps,
|
||||||
BottomSheetModal,
|
BottomSheetModal,
|
||||||
} from "@gorhom/bottom-sheet";
|
} from "@gorhom/bottom-sheet";
|
||||||
import { useCallback } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,7 +16,13 @@ import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
|||||||
* after BottomSheetModalProvider.
|
* after BottomSheetModalProvider.
|
||||||
*/
|
*/
|
||||||
export const GlobalModal = () => {
|
export const GlobalModal = () => {
|
||||||
const { hideModal, modalState, modalRef } = useGlobalModal();
|
const { hideModal, modalState, modalRef, isVisible } = useGlobalModal();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isVisible && modalState.content) {
|
||||||
|
modalRef.current?.present();
|
||||||
|
}
|
||||||
|
}, [isVisible, modalState.content, modalRef]);
|
||||||
|
|
||||||
const handleSheetChanges = useCallback(
|
const handleSheetChanges = useCallback(
|
||||||
(index: number) => {
|
(index: number) => {
|
||||||
|
|||||||
@@ -6,19 +6,19 @@ import { Image } from "expo-image";
|
|||||||
import { useNavigation } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { type Bitrate } from "@/components/BitrateSelector";
|
import { type Bitrate } from "@/components/BitrateSelector";
|
||||||
import { ItemImage } from "@/components/common/ItemImage";
|
import { ItemImage } from "@/components/common/ItemImage";
|
||||||
import { DownloadSingleItem } from "@/components/DownloadItem";
|
import { DownloadSingleItem } from "@/components/DownloadItem";
|
||||||
|
import { ItemPeopleSections } from "@/components/item/ItemPeopleSections";
|
||||||
|
import { MediaSourceButton } from "@/components/MediaSourceButton";
|
||||||
import { OverviewText } from "@/components/OverviewText";
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
// const PlayButton = !Platform.isTV ? require("@/components/PlayButton") : null;
|
// const PlayButton = !Platform.isTV ? require("@/components/PlayButton") : null;
|
||||||
import { PlayButton } from "@/components/PlayButton";
|
import { PlayButton } from "@/components/PlayButton";
|
||||||
import { PlayedStatus } from "@/components/PlayedStatus";
|
import { PlayedStatus } from "@/components/PlayedStatus";
|
||||||
import { SimilarItems } from "@/components/SimilarItems";
|
import { SimilarItems } from "@/components/SimilarItems";
|
||||||
import { CastAndCrew } from "@/components/series/CastAndCrew";
|
|
||||||
import { CurrentSeries } from "@/components/series/CurrentSeries";
|
import { CurrentSeries } from "@/components/series/CurrentSeries";
|
||||||
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
|
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
|
||||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||||
@@ -29,13 +29,10 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
import { AddToFavorites } from "./AddToFavorites";
|
import { AddToFavorites } from "./AddToFavorites";
|
||||||
import { BitrateSheet } from "./BitRateSheet";
|
import { AddToWatchlist } from "./AddToWatchlist";
|
||||||
import { ItemHeader } from "./ItemHeader";
|
import { ItemHeader } from "./ItemHeader";
|
||||||
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
||||||
import { MediaSourceSheet } from "./MediaSourceSheet";
|
|
||||||
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
|
||||||
import { PlayInRemoteSessionButton } from "./PlayInRemoteSession";
|
import { PlayInRemoteSessionButton } from "./PlayInRemoteSession";
|
||||||
import { TrackSheet } from "./TrackSheet";
|
|
||||||
|
|
||||||
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
|
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
|
||||||
|
|
||||||
@@ -49,17 +46,17 @@ export type SelectedOptions = {
|
|||||||
interface ItemContentProps {
|
interface ItemContentProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
isOffline: boolean;
|
isOffline: boolean;
|
||||||
|
itemWithSources?: BaseItemDto | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||||
({ item, isOffline }) => {
|
({ item, isOffline, itemWithSources }) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const { orientation } = useOrientation();
|
const { orientation } = useOrientation();
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const itemColors = useImageColorsReturn({ item });
|
const itemColors = useImageColorsReturn({ item });
|
||||||
|
|
||||||
@@ -70,18 +67,23 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
SelectedOptions | undefined
|
SelectedOptions | undefined
|
||||||
>(undefined);
|
>(undefined);
|
||||||
|
|
||||||
|
// Use itemWithSources for play settings since it has MediaSources data
|
||||||
const {
|
const {
|
||||||
defaultAudioIndex,
|
defaultAudioIndex,
|
||||||
defaultBitrate,
|
defaultBitrate,
|
||||||
defaultMediaSource,
|
defaultMediaSource,
|
||||||
defaultSubtitleIndex,
|
defaultSubtitleIndex,
|
||||||
} = useDefaultPlaySettings(item!, settings);
|
} = useDefaultPlaySettings(itemWithSources ?? item, settings);
|
||||||
|
|
||||||
const logoUrl = useMemo(
|
const logoUrl = useMemo(
|
||||||
() => (item ? getLogoImageUrlById({ api, item }) : null),
|
() => (item ? getLogoImageUrlById({ api, item }) : null),
|
||||||
[api, item],
|
[api, item],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const onLogoLoad = React.useCallback(() => {
|
||||||
|
setLoadingLogo(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const loading = useMemo(() => {
|
const loading = useMemo(() => {
|
||||||
return Boolean(logoUrl && loadingLogo);
|
return Boolean(logoUrl && loadingLogo);
|
||||||
}, [loadingLogo, logoUrl]);
|
}, [loadingLogo, logoUrl]);
|
||||||
@@ -90,7 +92,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedOptions(() => ({
|
setSelectedOptions(() => ({
|
||||||
bitrate: defaultBitrate,
|
bitrate: defaultBitrate,
|
||||||
mediaSource: defaultMediaSource,
|
mediaSource: defaultMediaSource ?? undefined,
|
||||||
subtitleIndex: defaultSubtitleIndex ?? -1,
|
subtitleIndex: defaultSubtitleIndex ?? -1,
|
||||||
audioIndex: defaultAudioIndex,
|
audioIndex: defaultAudioIndex,
|
||||||
}));
|
}));
|
||||||
@@ -102,7 +104,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!Platform.isTV) {
|
if (!Platform.isTV && itemWithSources) {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () =>
|
headerRight: () =>
|
||||||
item &&
|
item &&
|
||||||
@@ -112,14 +114,19 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
{item.Type !== "Program" && (
|
{item.Type !== "Program" && (
|
||||||
<View className='flex flex-row items-center'>
|
<View className='flex flex-row items-center'>
|
||||||
{!Platform.isTV && (
|
{!Platform.isTV && (
|
||||||
<DownloadSingleItem item={item} size='large' />
|
<DownloadSingleItem item={itemWithSources} size='large' />
|
||||||
)}
|
|
||||||
{user?.Policy?.IsAdministrator && (
|
|
||||||
<PlayInRemoteSessionButton item={item} size='large' />
|
|
||||||
)}
|
)}
|
||||||
|
{user?.Policy?.IsAdministrator &&
|
||||||
|
!settings.hideRemoteSessionButton && (
|
||||||
|
<PlayInRemoteSessionButton item={item} size='large' />
|
||||||
|
)}
|
||||||
|
|
||||||
<PlayedStatus items={[item]} size='large' />
|
<PlayedStatus items={[item]} size='large' />
|
||||||
<AddToFavorites item={item} />
|
<AddToFavorites item={item} />
|
||||||
|
{settings.streamyStatsServerUrl &&
|
||||||
|
!settings.hideWatchlistsTab && (
|
||||||
|
<AddToWatchlist item={item} />
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -129,21 +136,34 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
{item.Type !== "Program" && (
|
{item.Type !== "Program" && (
|
||||||
<View className='flex flex-row items-center space-x-2'>
|
<View className='flex flex-row items-center space-x-2'>
|
||||||
{!Platform.isTV && (
|
{!Platform.isTV && (
|
||||||
<DownloadSingleItem item={item} size='large' />
|
<DownloadSingleItem item={itemWithSources} size='large' />
|
||||||
)}
|
|
||||||
{user?.Policy?.IsAdministrator && (
|
|
||||||
<PlayInRemoteSessionButton item={item} size='large' />
|
|
||||||
)}
|
)}
|
||||||
|
{user?.Policy?.IsAdministrator &&
|
||||||
|
!settings.hideRemoteSessionButton && (
|
||||||
|
<PlayInRemoteSessionButton item={item} size='large' />
|
||||||
|
)}
|
||||||
|
|
||||||
<PlayedStatus items={[item]} size='large' />
|
<PlayedStatus items={[item]} size='large' />
|
||||||
<AddToFavorites item={item} />
|
<AddToFavorites item={item} />
|
||||||
|
{settings.streamyStatsServerUrl &&
|
||||||
|
!settings.hideWatchlistsTab && (
|
||||||
|
<AddToWatchlist item={item} />
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
)),
|
)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [item, navigation, user]);
|
}, [
|
||||||
|
item,
|
||||||
|
navigation,
|
||||||
|
user,
|
||||||
|
itemWithSources,
|
||||||
|
settings.hideRemoteSessionButton,
|
||||||
|
settings.streamyStatsServerUrl,
|
||||||
|
settings.hideWatchlistsTab,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (item) {
|
if (item) {
|
||||||
@@ -165,7 +185,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ParallaxScrollView
|
<ParallaxScrollView
|
||||||
className={`flex-1 ${loading ? "opacity-0" : "opacity-100"}`}
|
className='flex-1'
|
||||||
headerHeight={headerHeight}
|
headerHeight={headerHeight}
|
||||||
headerImage={
|
headerImage={
|
||||||
<View style={[{ flex: 1 }]}>
|
<View style={[{ flex: 1 }]}>
|
||||||
@@ -192,8 +212,8 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
width: "100%",
|
width: "100%",
|
||||||
}}
|
}}
|
||||||
contentFit='contain'
|
contentFit='contain'
|
||||||
onLoad={() => setLoadingLogo(false)}
|
onLoad={onLogoLoad}
|
||||||
onError={() => setLoadingLogo(false)}
|
onError={onLogoLoad}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<View />
|
<View />
|
||||||
@@ -201,76 +221,27 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<View className='flex flex-col bg-transparent shrink'>
|
<View className='flex flex-col bg-transparent shrink'>
|
||||||
<View className='flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink'>
|
<View className='flex flex-col px-4 w-full pt-2 mb-2 shrink'>
|
||||||
<ItemHeader item={item} className='mb-2' />
|
<ItemHeader item={item} className='mb-2' />
|
||||||
{item.Type !== "Program" && !Platform.isTV && !isOffline && (
|
|
||||||
<View className='flex flex-row items-center justify-start w-full h-16 mb-2'>
|
|
||||||
<BitrateSheet
|
|
||||||
className='mr-1'
|
|
||||||
onChange={(val) =>
|
|
||||||
setSelectedOptions(
|
|
||||||
(prev) => prev && { ...prev, bitrate: val },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
selected={selectedOptions.bitrate}
|
|
||||||
/>
|
|
||||||
<MediaSourceSheet
|
|
||||||
className='mr-1'
|
|
||||||
item={item}
|
|
||||||
onChange={(val) =>
|
|
||||||
setSelectedOptions(
|
|
||||||
(prev) =>
|
|
||||||
prev && {
|
|
||||||
...prev,
|
|
||||||
mediaSource: val,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
selected={selectedOptions.mediaSource}
|
|
||||||
/>
|
|
||||||
<TrackSheet
|
|
||||||
className='mr-1'
|
|
||||||
streamType='Audio'
|
|
||||||
title={t("item_card.audio")}
|
|
||||||
source={selectedOptions.mediaSource}
|
|
||||||
onChange={(val) => {
|
|
||||||
setSelectedOptions(
|
|
||||||
(prev) =>
|
|
||||||
prev && {
|
|
||||||
...prev,
|
|
||||||
audioIndex: val,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
selected={selectedOptions.audioIndex}
|
|
||||||
/>
|
|
||||||
<TrackSheet
|
|
||||||
source={selectedOptions.mediaSource}
|
|
||||||
streamType='Subtitle'
|
|
||||||
title={t("item_card.subtitles")}
|
|
||||||
onChange={(val) =>
|
|
||||||
setSelectedOptions(
|
|
||||||
(prev) =>
|
|
||||||
prev && {
|
|
||||||
...prev,
|
|
||||||
subtitleIndex: val,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
selected={selectedOptions.subtitleIndex}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<PlayButton
|
<View className='flex flex-row px-0 mb-2 justify-between space-x-2'>
|
||||||
className='grow'
|
<PlayButton
|
||||||
selectedOptions={selectedOptions}
|
selectedOptions={selectedOptions}
|
||||||
item={item}
|
item={item}
|
||||||
isOffline={isOffline}
|
isOffline={isOffline}
|
||||||
colors={itemColors}
|
colors={itemColors}
|
||||||
/>
|
/>
|
||||||
|
<View className='w-1' />
|
||||||
|
{!isOffline && (
|
||||||
|
<MediaSourceButton
|
||||||
|
selectedOptions={selectedOptions}
|
||||||
|
setSelectedOptions={setSelectedOptions}
|
||||||
|
item={itemWithSources}
|
||||||
|
colors={itemColors}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{item.Type === "Episode" && (
|
{item.Type === "Episode" && (
|
||||||
<SeasonEpisodesCarousel
|
<SeasonEpisodesCarousel
|
||||||
item={item}
|
item={item}
|
||||||
@@ -279,33 +250,21 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isOffline && (
|
{!isOffline &&
|
||||||
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
|
selectedOptions.mediaSource?.MediaStreams &&
|
||||||
)}
|
selectedOptions.mediaSource.MediaStreams.length > 0 && (
|
||||||
|
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
|
||||||
|
)}
|
||||||
|
|
||||||
<OverviewText text={item.Overview} className='px-4 mb-4' />
|
<OverviewText text={item.Overview} className='px-4 mb-4' />
|
||||||
|
|
||||||
{item.Type !== "Program" && (
|
{item.Type !== "Program" && (
|
||||||
<>
|
<>
|
||||||
{item.Type === "Episode" && !isOffline && (
|
{item.Type === "Episode" && !isOffline && (
|
||||||
<CurrentSeries item={item} className='mb-4' />
|
<CurrentSeries item={item} className='mb-2' />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isOffline && (
|
<ItemPeopleSections item={item} isOffline={isOffline} />
|
||||||
<CastAndCrew item={item} className='mb-4' loading={loading} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{item.People && item.People.length > 0 && !isOffline && (
|
|
||||||
<View className='mb-4'>
|
|
||||||
{item.People.slice(0, 3).map((person, idx) => (
|
|
||||||
<MoreMoviesWithActor
|
|
||||||
currentItem={item}
|
|
||||||
key={idx}
|
|
||||||
actorId={person.Id!}
|
|
||||||
className='mb-4'
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isOffline && <SimilarItems itemId={item.Id} />}
|
{!isOffline && <SimilarItems itemId={item.Id} />}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -183,6 +183,12 @@ const VideoStreamInfo = ({ source }: { source?: MediaSourceInfo }) => {
|
|||||||
|
|
||||||
if (!source || !videoStream) return null;
|
if (!source || !videoStream) return null;
|
||||||
|
|
||||||
|
// Dolby Vision video check
|
||||||
|
const isDolbyVision =
|
||||||
|
videoStream.VideoRangeType === "DOVI" ||
|
||||||
|
videoStream.DvVersionMajor != null ||
|
||||||
|
videoStream.DvVersionMinor != null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='flex-row flex-wrap gap-2'>
|
<View className='flex-row flex-wrap gap-2'>
|
||||||
<Badge
|
<Badge
|
||||||
@@ -195,6 +201,15 @@ const VideoStreamInfo = ({ source }: { source?: MediaSourceInfo }) => {
|
|||||||
iconLeft={<Ionicons name='film-outline' size={16} color='white' />}
|
iconLeft={<Ionicons name='film-outline' size={16} color='white' />}
|
||||||
text={`${videoStream.Width}x${videoStream.Height}`}
|
text={`${videoStream.Width}x${videoStream.Height}`}
|
||||||
/>
|
/>
|
||||||
|
{isDolbyVision && (
|
||||||
|
<Badge
|
||||||
|
variant='gray'
|
||||||
|
iconLeft={
|
||||||
|
<Ionicons name='sparkles-outline' size={16} color='white' />
|
||||||
|
}
|
||||||
|
text={"DV"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Badge
|
<Badge
|
||||||
variant='gray'
|
variant='gray'
|
||||||
iconLeft={
|
iconLeft={
|
||||||
|
|||||||
193
components/MediaSourceButton.tsx
Normal file
193
components/MediaSourceButton.tsx
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type {
|
||||||
|
BaseItemDto,
|
||||||
|
MediaSourceInfo,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
||||||
|
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
||||||
|
import { BITRATES } from "./BitRateSheet";
|
||||||
|
import type { SelectedOptions } from "./ItemContent";
|
||||||
|
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||||
|
|
||||||
|
interface Props extends React.ComponentProps<typeof TouchableOpacity> {
|
||||||
|
item?: BaseItemDto | null;
|
||||||
|
selectedOptions: SelectedOptions;
|
||||||
|
setSelectedOptions: React.Dispatch<
|
||||||
|
React.SetStateAction<SelectedOptions | undefined>
|
||||||
|
>;
|
||||||
|
colors?: ThemeColors;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MediaSourceButton: React.FC<Props> = ({
|
||||||
|
item,
|
||||||
|
selectedOptions,
|
||||||
|
setSelectedOptions,
|
||||||
|
colors,
|
||||||
|
}: Props) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const effectiveColors = colors || {
|
||||||
|
primary: "#7c3aed",
|
||||||
|
text: "#000000",
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const firstMediaSource = item?.MediaSources?.[0];
|
||||||
|
if (!firstMediaSource) return;
|
||||||
|
setSelectedOptions((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
mediaSource: firstMediaSource,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [item, setSelectedOptions]);
|
||||||
|
|
||||||
|
const getMediaSourceDisplayName = useCallback((source: MediaSourceInfo) => {
|
||||||
|
const videoStream = source.MediaStreams?.find((x) => x.Type === "Video");
|
||||||
|
if (source.Name) return source.Name;
|
||||||
|
if (videoStream?.DisplayTitle) return videoStream.DisplayTitle;
|
||||||
|
return `Source ${source.Id}`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const audioStreams = useMemo(
|
||||||
|
() =>
|
||||||
|
selectedOptions.mediaSource?.MediaStreams?.filter(
|
||||||
|
(x) => x.Type === "Audio",
|
||||||
|
) || [],
|
||||||
|
[selectedOptions.mediaSource],
|
||||||
|
);
|
||||||
|
|
||||||
|
const subtitleStreams = useMemo(
|
||||||
|
() =>
|
||||||
|
selectedOptions.mediaSource?.MediaStreams?.filter(
|
||||||
|
(x) => x.Type === "Subtitle",
|
||||||
|
) || [],
|
||||||
|
[selectedOptions.mediaSource],
|
||||||
|
);
|
||||||
|
|
||||||
|
const optionGroups: OptionGroup[] = useMemo(() => {
|
||||||
|
const groups: OptionGroup[] = [];
|
||||||
|
|
||||||
|
// Bitrate group
|
||||||
|
groups.push({
|
||||||
|
title: t("item_card.quality"),
|
||||||
|
options: BITRATES.map((bitrate) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: bitrate.key,
|
||||||
|
value: bitrate,
|
||||||
|
selected: bitrate.value === selectedOptions.bitrate?.value,
|
||||||
|
onPress: () =>
|
||||||
|
setSelectedOptions((prev) => prev && { ...prev, bitrate }),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Media Source group (only if multiple sources)
|
||||||
|
if (item?.MediaSources && item.MediaSources.length > 1) {
|
||||||
|
groups.push({
|
||||||
|
title: t("item_card.video"),
|
||||||
|
options: item.MediaSources.map((source) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: getMediaSourceDisplayName(source),
|
||||||
|
value: source,
|
||||||
|
selected: source.Id === selectedOptions.mediaSource?.Id,
|
||||||
|
onPress: () =>
|
||||||
|
setSelectedOptions(
|
||||||
|
(prev) => prev && { ...prev, mediaSource: source },
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audio track group
|
||||||
|
if (audioStreams.length > 0) {
|
||||||
|
groups.push({
|
||||||
|
title: t("item_card.audio"),
|
||||||
|
options: audioStreams.map((stream) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: stream.DisplayTitle || `${t("common.track")} ${stream.Index}`,
|
||||||
|
value: stream.Index,
|
||||||
|
selected: stream.Index === selectedOptions.audioIndex,
|
||||||
|
onPress: () =>
|
||||||
|
setSelectedOptions(
|
||||||
|
(prev) => prev && { ...prev, audioIndex: stream.Index ?? 0 },
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subtitle track group (with None option)
|
||||||
|
if (subtitleStreams.length > 0) {
|
||||||
|
const noneOption = {
|
||||||
|
type: "radio" as const,
|
||||||
|
label: t("common.none"),
|
||||||
|
value: -1,
|
||||||
|
selected: selectedOptions.subtitleIndex === -1,
|
||||||
|
onPress: () =>
|
||||||
|
setSelectedOptions((prev) => prev && { ...prev, subtitleIndex: -1 }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const subtitleOptions = subtitleStreams.map((stream) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: stream.DisplayTitle || `${t("common.track")} ${stream.Index}`,
|
||||||
|
value: stream.Index,
|
||||||
|
selected: stream.Index === selectedOptions.subtitleIndex,
|
||||||
|
onPress: () =>
|
||||||
|
setSelectedOptions(
|
||||||
|
(prev) => prev && { ...prev, subtitleIndex: stream.Index ?? -1 },
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
groups.push({
|
||||||
|
title: t("item_card.subtitles"),
|
||||||
|
options: [noneOption, ...subtitleOptions],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}, [
|
||||||
|
item,
|
||||||
|
selectedOptions,
|
||||||
|
audioStreams,
|
||||||
|
subtitleStreams,
|
||||||
|
getMediaSourceDisplayName,
|
||||||
|
t,
|
||||||
|
setSelectedOptions,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const trigger = (
|
||||||
|
<TouchableOpacity
|
||||||
|
disabled={!item}
|
||||||
|
onPress={() => setOpen(true)}
|
||||||
|
className='relative'
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{ backgroundColor: effectiveColors.primary, opacity: 0.7 }}
|
||||||
|
className='absolute w-12 h-12 rounded-full'
|
||||||
|
/>
|
||||||
|
<View className='w-12 h-12 rounded-full z-10 items-center justify-center'>
|
||||||
|
{!item ? (
|
||||||
|
<ActivityIndicator size='small' color={effectiveColors.text} />
|
||||||
|
) : (
|
||||||
|
<Ionicons name='list' size={24} color={effectiveColors.text} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PlatformDropdown
|
||||||
|
groups={optionGroups}
|
||||||
|
trigger={trigger}
|
||||||
|
title={t("item_card.media_options")}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
bottomSheetConfig={{
|
||||||
|
enablePanDownToClose: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -3,6 +3,7 @@ import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
|
import { useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { View, type ViewProps } from "react-native";
|
import { View, type ViewProps } from "react-native";
|
||||||
import { HorizontalScroll } from "@/components/common/HorizontalScroll";
|
import { HorizontalScroll } from "@/components/common/HorizontalScroll";
|
||||||
@@ -10,16 +11,18 @@ import { Text } from "@/components/common/Text";
|
|||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
|
import { POSTER_CAROUSEL_HEIGHT } from "@/constants/Values";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
actorId: string;
|
actorId: string;
|
||||||
|
actorName?: string | null;
|
||||||
currentItem: BaseItemDto;
|
currentItem: BaseItemDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MoreMoviesWithActor: React.FC<Props> = ({
|
export const MoreMoviesWithActor: React.FC<Props> = ({
|
||||||
actorId,
|
actorId,
|
||||||
|
actorName,
|
||||||
currentItem,
|
currentItem,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
@@ -27,19 +30,6 @@ export const MoreMoviesWithActor: React.FC<Props> = ({
|
|||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { data: actor } = useQuery({
|
|
||||||
queryKey: ["actor", actorId],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api || !user?.Id) return null;
|
|
||||||
return await getUserItemData({
|
|
||||||
api,
|
|
||||||
userId: user.Id,
|
|
||||||
itemId: actorId,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
enabled: !!api && !!user?.Id && !!actorId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: items, isLoading } = useQuery({
|
const { data: items, isLoading } = useQuery({
|
||||||
queryKey: ["actor", "movies", actorId, currentItem.Id],
|
queryKey: ["actor", "movies", actorId, currentItem.Id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -72,29 +62,34 @@ export const MoreMoviesWithActor: React.FC<Props> = ({
|
|||||||
enabled: !!api && !!user?.Id && !!actorId,
|
enabled: !!api && !!user?.Id && !!actorId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const renderItem = useCallback(
|
||||||
|
(item: BaseItemDto, idx: number) => (
|
||||||
|
<TouchableItemRouter
|
||||||
|
key={item.Id ?? idx}
|
||||||
|
item={item}
|
||||||
|
className='flex flex-col w-28'
|
||||||
|
>
|
||||||
|
<View>
|
||||||
|
<MoviePoster item={item} />
|
||||||
|
<ItemCardText item={item} />
|
||||||
|
</View>
|
||||||
|
</TouchableItemRouter>
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
if (items?.length === 0) return null;
|
if (items?.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<Text className='text-lg font-bold mb-2 px-4'>
|
<Text className='text-lg font-bold mb-2 px-4'>
|
||||||
{t("item_card.more_with", { name: actor?.Name })}
|
{t("item_card.more_with", { name: actorName ?? "" })}
|
||||||
</Text>
|
</Text>
|
||||||
<HorizontalScroll
|
<HorizontalScroll
|
||||||
data={items}
|
data={items}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
height={247}
|
height={POSTER_CAROUSEL_HEIGHT}
|
||||||
renderItem={(item: BaseItemDto, idx: number) => (
|
renderItem={renderItem}
|
||||||
<TouchableItemRouter
|
|
||||||
key={idx}
|
|
||||||
item={item}
|
|
||||||
className='flex flex-col w-28'
|
|
||||||
>
|
|
||||||
<View>
|
|
||||||
<MoviePoster item={item} />
|
|
||||||
<ItemCardText item={item} />
|
|
||||||
</View>
|
|
||||||
</TouchableItemRouter>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -54,9 +54,7 @@ const ToggleSwitch: React.FC<{ value: boolean }> = ({ value }) => (
|
|||||||
className={`w-12 h-7 rounded-full ${value ? "bg-purple-600" : "bg-neutral-600"} flex-row items-center`}
|
className={`w-12 h-7 rounded-full ${value ? "bg-purple-600" : "bg-neutral-600"} flex-row items-center`}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
className={`w-5 h-5 rounded-full bg-white shadow-md transform transition-transform ${
|
className={`w-5 h-5 rounded-full bg-white shadow-md transform transition-transform ${value ? "translate-x-6" : "translate-x-1"}`}
|
||||||
value ? "translate-x-6" : "translate-x-1"
|
|
||||||
}`}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -73,9 +71,7 @@ const OptionItem: React.FC<{ option: Option; isLast?: boolean }> = ({
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={handlePress}
|
onPress={handlePress}
|
||||||
disabled={option.disabled}
|
disabled={option.disabled}
|
||||||
className={`px-4 py-3 flex flex-row items-center justify-between ${
|
className={`px-4 py-3 flex flex-row items-center justify-between ${option.disabled ? "opacity-50" : ""}`}
|
||||||
option.disabled ? "opacity-50" : ""
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<Text className='flex-1 text-white'>{option.label}</Text>
|
<Text className='flex-1 text-white'>{option.label}</Text>
|
||||||
{isToggle ? (
|
{isToggle ? (
|
||||||
@@ -184,7 +180,7 @@ const PlatformDropdownComponent = ({
|
|||||||
expoUIConfig,
|
expoUIConfig,
|
||||||
bottomSheetConfig,
|
bottomSheetConfig,
|
||||||
}: PlatformDropdownProps) => {
|
}: PlatformDropdownProps) => {
|
||||||
const { showModal, hideModal } = useGlobalModal();
|
const { showModal, hideModal, isVisible } = useGlobalModal();
|
||||||
|
|
||||||
// Handle controlled open state for Android
|
// Handle controlled open state for Android
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -207,15 +203,19 @@ const PlatformDropdownComponent = ({
|
|||||||
}
|
}
|
||||||
}, [controlledOpen]);
|
}, [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") {
|
if (Platform.OS === "ios") {
|
||||||
return (
|
return (
|
||||||
<Host style={expoUIConfig?.hostStyle}>
|
<Host style={expoUIConfig?.hostStyle}>
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<ContextMenu.Trigger>
|
<ContextMenu.Trigger>{trigger}</ContextMenu.Trigger>
|
||||||
<View className=''>
|
|
||||||
{trigger || <Button variant='bordered'>Show Menu</Button>}
|
|
||||||
</View>
|
|
||||||
</ContextMenu.Trigger>
|
|
||||||
<ContextMenu.Items>
|
<ContextMenu.Items>
|
||||||
{groups.flatMap((group, groupIndex) => {
|
{groups.flatMap((group, groupIndex) => {
|
||||||
// Check if this group has radio options
|
// Check if this group has radio options
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||||
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||||
|
import { BottomSheetView } from "@gorhom/bottom-sheet";
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Alert, TouchableOpacity, View } from "react-native";
|
import { Alert, Platform, TouchableOpacity, View } from "react-native";
|
||||||
import CastContext, {
|
import CastContext, {
|
||||||
CastButton,
|
CastButton,
|
||||||
PlayServicesState,
|
PlayServicesState,
|
||||||
@@ -24,6 +25,8 @@ import Animated, {
|
|||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
||||||
|
import { getDownloadedItemById } from "@/providers/Downloads/database";
|
||||||
|
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
@@ -33,6 +36,8 @@ 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 { 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 TouchableOpacity> {
|
||||||
@@ -55,6 +60,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
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);
|
||||||
@@ -84,12 +90,9 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
[router, isOffline],
|
[router, isOffline],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onPress = useCallback(async () => {
|
const handleNormalPlayFlow = useCallback(async () => {
|
||||||
console.log("onPress");
|
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
|
|
||||||
lightHapticFeedback();
|
|
||||||
|
|
||||||
const queryParams = new URLSearchParams({
|
const queryParams = new URLSearchParams({
|
||||||
itemId: item.Id!,
|
itemId: item.Id!,
|
||||||
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
|
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
|
||||||
@@ -271,6 +274,117 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
showActionSheetWithOptions,
|
showActionSheetWithOptions,
|
||||||
mediaStatus,
|
mediaStatus,
|
||||||
selectedOptions,
|
selectedOptions,
|
||||||
|
goToPlayer,
|
||||||
|
isOffline,
|
||||||
|
t,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const onPress = useCallback(async () => {
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
lightHapticFeedback();
|
||||||
|
|
||||||
|
// Check if item is downloaded
|
||||||
|
const downloadedItem = item.Id ? getDownloadedItemById(item.Id) : undefined;
|
||||||
|
|
||||||
|
if (downloadedItem) {
|
||||||
|
if (Platform.OS === "android") {
|
||||||
|
// Show bottom sheet for Android
|
||||||
|
showModal(
|
||||||
|
<BottomSheetView>
|
||||||
|
<View className='px-4 mt-4 mb-12'>
|
||||||
|
<View className='pb-6'>
|
||||||
|
<Text className='text-2xl font-bold mb-2'>
|
||||||
|
{t("player.downloaded_file_title")}
|
||||||
|
</Text>
|
||||||
|
<Text className='opacity-70 text-base'>
|
||||||
|
{t("player.downloaded_file_message")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className='space-y-3'>
|
||||||
|
<Button
|
||||||
|
onPress={() => {
|
||||||
|
hideModal();
|
||||||
|
const queryParams = new URLSearchParams({
|
||||||
|
itemId: item.Id!,
|
||||||
|
offline: "true",
|
||||||
|
playbackPosition:
|
||||||
|
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
||||||
|
});
|
||||||
|
goToPlayer(queryParams.toString());
|
||||||
|
}}
|
||||||
|
color='purple'
|
||||||
|
>
|
||||||
|
{Platform.OS === "android"
|
||||||
|
? "Play downloaded file"
|
||||||
|
: t("player.downloaded_file_yes")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onPress={() => {
|
||||||
|
hideModal();
|
||||||
|
handleNormalPlayFlow();
|
||||||
|
}}
|
||||||
|
color='white'
|
||||||
|
variant='border'
|
||||||
|
>
|
||||||
|
{Platform.OS === "android"
|
||||||
|
? "Stream file"
|
||||||
|
: t("player.downloaded_file_no")}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</BottomSheetView>,
|
||||||
|
{
|
||||||
|
snapPoints: ["35%"],
|
||||||
|
enablePanDownToClose: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Show alert for iOS
|
||||||
|
Alert.alert(
|
||||||
|
t("player.downloaded_file_title"),
|
||||||
|
t("player.downloaded_file_message"),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: t("player.downloaded_file_yes"),
|
||||||
|
onPress: () => {
|
||||||
|
const queryParams = new URLSearchParams({
|
||||||
|
itemId: item.Id!,
|
||||||
|
offline: "true",
|
||||||
|
playbackPosition:
|
||||||
|
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
||||||
|
});
|
||||||
|
goToPlayer(queryParams.toString());
|
||||||
|
},
|
||||||
|
isPreferred: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: t("player.downloaded_file_no"),
|
||||||
|
onPress: () => {
|
||||||
|
handleNormalPlayFlow();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: t("player.downloaded_file_cancel"),
|
||||||
|
style: "cancel",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not downloaded, proceed with normal flow
|
||||||
|
handleNormalPlayFlow();
|
||||||
|
}, [
|
||||||
|
item,
|
||||||
|
lightHapticFeedback,
|
||||||
|
handleNormalPlayFlow,
|
||||||
|
goToPlayer,
|
||||||
|
t,
|
||||||
|
showModal,
|
||||||
|
hideModal,
|
||||||
|
effectiveColors,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const derivedTargetWidth = useDerivedValue(() => {
|
const derivedTargetWidth = useDerivedValue(() => {
|
||||||
@@ -358,55 +472,6 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
[startColor.value.text, endColor.value.text],
|
[startColor.value.text, endColor.value.text],
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
/**
|
|
||||||
* *********************
|
|
||||||
*/
|
|
||||||
|
|
||||||
// if (Platform.OS === "ios")
|
|
||||||
// return (
|
|
||||||
// <Host
|
|
||||||
// style={{
|
|
||||||
// height: 50,
|
|
||||||
// flex: 1,
|
|
||||||
// flexShrink: 0,
|
|
||||||
// }}
|
|
||||||
// >
|
|
||||||
// <Button
|
|
||||||
// variant='glassProminent'
|
|
||||||
// onPress={onPress}
|
|
||||||
// color={effectiveColors.primary}
|
|
||||||
// modifiers={[fixedSize()]}
|
|
||||||
// >
|
|
||||||
// <View className='flex flex-row items-center space-x-2 h-full w-full justify-center -mb-3.5 '>
|
|
||||||
// <Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
|
|
||||||
// {runtimeTicksToMinutes(
|
|
||||||
// (item?.RunTimeTicks || 0) -
|
|
||||||
// (item?.UserData?.PlaybackPositionTicks || 0),
|
|
||||||
// )}
|
|
||||||
// {(item?.UserData?.PlaybackPositionTicks || 0) > 0 && " left"}
|
|
||||||
// </Animated.Text>
|
|
||||||
// <Animated.Text style={animatedTextStyle}>
|
|
||||||
// <Ionicons name='play-circle' size={24} />
|
|
||||||
// </Animated.Text>
|
|
||||||
// {client && (
|
|
||||||
// <Animated.Text style={animatedTextStyle}>
|
|
||||||
// <Feather name='cast' size={22} />
|
|
||||||
// <CastButton tintColor='transparent' />
|
|
||||||
// </Animated.Text>
|
|
||||||
// )}
|
|
||||||
// {!client && settings?.openInVLC && (
|
|
||||||
// <Animated.Text style={animatedTextStyle}>
|
|
||||||
// <MaterialCommunityIcons
|
|
||||||
// name='vlc'
|
|
||||||
// size={18}
|
|
||||||
// color={animatedTextStyle.color}
|
|
||||||
// />
|
|
||||||
// </Animated.Text>
|
|
||||||
// )}
|
|
||||||
// </View>
|
|
||||||
// </Button>
|
|
||||||
// </Host>
|
|
||||||
// );
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -414,7 +479,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
accessibilityLabel='Play button'
|
accessibilityLabel='Play button'
|
||||||
accessibilityHint='Tap to play the media'
|
accessibilityHint='Tap to play the media'
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
className={"relative"}
|
className={"relative flex-1"}
|
||||||
>
|
>
|
||||||
<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
|
||||||
@@ -457,15 +522,6 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
<CastButton tintColor='transparent' />
|
<CastButton tintColor='transparent' />
|
||||||
</Animated.Text>
|
</Animated.Text>
|
||||||
)}
|
)}
|
||||||
{!client && settings?.openInVLC && (
|
|
||||||
<Animated.Text style={animatedTextStyle}>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name='vlc'
|
|
||||||
size={18}
|
|
||||||
color={animatedTextStyle.color}
|
|
||||||
/>
|
|
||||||
</Animated.Text>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
@@ -17,7 +17,6 @@ import Animated, {
|
|||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
||||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
import type { Button } from "./Button";
|
import type { Button } from "./Button";
|
||||||
import type { SelectedOptions } from "./ItemContent";
|
import type { SelectedOptions } from "./ItemContent";
|
||||||
@@ -50,7 +49,6 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
const startColor = useSharedValue(effectiveColors);
|
const startColor = useSharedValue(effectiveColors);
|
||||||
const widthProgress = useSharedValue(0);
|
const widthProgress = useSharedValue(0);
|
||||||
const colorChangeProgress = useSharedValue(0);
|
const colorChangeProgress = useSharedValue(0);
|
||||||
const { settings } = useSettings();
|
|
||||||
const lightHapticFeedback = useHaptic("light");
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
const goToPlayer = useCallback(
|
const goToPlayer = useCallback(
|
||||||
@@ -61,7 +59,6 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const onPress = () => {
|
const onPress = () => {
|
||||||
console.log("onpress");
|
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
|
|
||||||
lightHapticFeedback();
|
lightHapticFeedback();
|
||||||
@@ -207,15 +204,6 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
<Animated.Text style={animatedTextStyle}>
|
<Animated.Text style={animatedTextStyle}>
|
||||||
<Ionicons name='play-circle' size={24} />
|
<Ionicons name='play-circle' size={24} />
|
||||||
</Animated.Text>
|
</Animated.Text>
|
||||||
{settings?.openInVLC && (
|
|
||||||
<Animated.Text style={animatedTextStyle}>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name='vlc'
|
|
||||||
size={18}
|
|
||||||
color={animatedTextStyle.color}
|
|
||||||
/>
|
|
||||||
</Animated.Text>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
180
components/PlaybackSpeedSelector.tsx
Normal file
180
components/PlaybackSpeedSelector.tsx
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform, View } from "react-native";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||||
|
import { PlaybackSpeedScope } from "./video-player/controls/utils/playback-speed-settings";
|
||||||
|
|
||||||
|
export const PLAYBACK_SPEEDS = [
|
||||||
|
{ label: "0.25x", value: 0.25 },
|
||||||
|
{ label: "0.5x", value: 0.5 },
|
||||||
|
{ label: "0.75x", value: 0.75 },
|
||||||
|
{ label: "1x", value: 1.0 },
|
||||||
|
{ label: "1.25x", value: 1.25 },
|
||||||
|
{ label: "1.5x", value: 1.5 },
|
||||||
|
{ label: "1.75x", value: 1.75 },
|
||||||
|
{ label: "2x", value: 2.0 },
|
||||||
|
{ label: "2.25x", value: 2.25 },
|
||||||
|
{ label: "2.5x", value: 2.5 },
|
||||||
|
{ label: "2.75x", value: 2.75 },
|
||||||
|
{ label: "3x", value: 3.0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Props extends React.ComponentProps<typeof View> {
|
||||||
|
onChange: (value: number, scope: PlaybackSpeedScope) => void;
|
||||||
|
selected: number;
|
||||||
|
item?: BaseItemDto;
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PlaybackSpeedSelector: React.FC<Props> = ({
|
||||||
|
onChange,
|
||||||
|
selected,
|
||||||
|
item,
|
||||||
|
open: controlledOpen,
|
||||||
|
onOpenChange,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const isTv = Platform.isTV;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { settings } = useSettings();
|
||||||
|
const [internalOpen, setInternalOpen] = useState(false);
|
||||||
|
|
||||||
|
// Determine initial scope based on existing settings
|
||||||
|
const initialScope = useMemo<PlaybackSpeedScope>(() => {
|
||||||
|
if (!item || !settings) return PlaybackSpeedScope.All;
|
||||||
|
|
||||||
|
const itemId = item?.Id;
|
||||||
|
if (!itemId) return PlaybackSpeedScope.All;
|
||||||
|
|
||||||
|
// Check for media-specific speed preference
|
||||||
|
if (settings?.playbackSpeedPerMedia?.[itemId] !== undefined) {
|
||||||
|
return PlaybackSpeedScope.Media;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for show-specific speed preference (only for episodes)
|
||||||
|
const seriesId = item?.SeriesId;
|
||||||
|
const perShowSettings = settings?.playbackSpeedPerShow;
|
||||||
|
if (
|
||||||
|
seriesId &&
|
||||||
|
perShowSettings &&
|
||||||
|
perShowSettings[seriesId] !== undefined
|
||||||
|
) {
|
||||||
|
return PlaybackSpeedScope.Show;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no custom setting exists, check default playback speed
|
||||||
|
// Show "All" if speed is not 1x, otherwise show "Media"
|
||||||
|
return (settings?.defaultPlaybackSpeed ?? 1.0) !== 1.0
|
||||||
|
? PlaybackSpeedScope.All
|
||||||
|
: PlaybackSpeedScope.Media;
|
||||||
|
}, [item?.Id, item?.SeriesId, settings]);
|
||||||
|
|
||||||
|
const [selectedScope, setSelectedScope] =
|
||||||
|
useState<PlaybackSpeedScope>(initialScope);
|
||||||
|
|
||||||
|
// Update selectedScope when initialScope changes
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedScope(initialScope);
|
||||||
|
}, [initialScope]);
|
||||||
|
|
||||||
|
const open = controlledOpen !== undefined ? controlledOpen : internalOpen;
|
||||||
|
const setOpen = onOpenChange || setInternalOpen;
|
||||||
|
|
||||||
|
const scopeLabels = useMemo<Record<PlaybackSpeedScope, string>>(() => {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
[PlaybackSpeedScope.Media]: t("playback_speed.scope.media"),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (item?.SeriesId) {
|
||||||
|
labels[PlaybackSpeedScope.Show] = t("playback_speed.scope.show");
|
||||||
|
}
|
||||||
|
|
||||||
|
labels[PlaybackSpeedScope.All] = t("playback_speed.scope.all");
|
||||||
|
|
||||||
|
return labels as Record<PlaybackSpeedScope, string>;
|
||||||
|
}, [item?.SeriesId, t]);
|
||||||
|
|
||||||
|
const availableScopes = useMemo<PlaybackSpeedScope[]>(() => {
|
||||||
|
const scopes = [PlaybackSpeedScope.Media];
|
||||||
|
if (item?.SeriesId) {
|
||||||
|
scopes.push(PlaybackSpeedScope.Show);
|
||||||
|
}
|
||||||
|
scopes.push(PlaybackSpeedScope.All);
|
||||||
|
return scopes;
|
||||||
|
}, [item?.SeriesId]);
|
||||||
|
|
||||||
|
const handleSpeedSelect = useCallback(
|
||||||
|
(speed: number) => {
|
||||||
|
onChange(speed, selectedScope);
|
||||||
|
setOpen(false);
|
||||||
|
},
|
||||||
|
[onChange, selectedScope, setOpen],
|
||||||
|
);
|
||||||
|
|
||||||
|
const optionGroups = useMemo<OptionGroup[]>(() => {
|
||||||
|
const groups: OptionGroup[] = [];
|
||||||
|
|
||||||
|
// Scope selection group
|
||||||
|
groups.push({
|
||||||
|
title: t("playback_speed.apply_to"),
|
||||||
|
options: availableScopes.map((scope) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: scopeLabels[scope],
|
||||||
|
value: scope,
|
||||||
|
selected: selectedScope === scope,
|
||||||
|
onPress: () => setSelectedScope(scope),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Speed selection group
|
||||||
|
groups.push({
|
||||||
|
title: t("playback_speed.speed"),
|
||||||
|
options: PLAYBACK_SPEEDS.map((speed) => ({
|
||||||
|
type: "radio" as const,
|
||||||
|
label: speed.label,
|
||||||
|
value: speed.value,
|
||||||
|
selected: selected === speed.value,
|
||||||
|
onPress: () => handleSpeedSelect(speed.value),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}, [
|
||||||
|
t,
|
||||||
|
availableScopes,
|
||||||
|
scopeLabels,
|
||||||
|
selectedScope,
|
||||||
|
selected,
|
||||||
|
handleSpeedSelect,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const trigger = useMemo(
|
||||||
|
() => (
|
||||||
|
<View className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'>
|
||||||
|
<Ionicons name='speedometer' size={24} color='white' />
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isTv) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='flex shrink' style={{ minWidth: 60 }} {...props}>
|
||||||
|
<PlatformDropdown
|
||||||
|
title={t("playback_speed.title")}
|
||||||
|
groups={optionGroups}
|
||||||
|
trigger={trigger}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
bottomSheetConfig={{
|
||||||
|
enablePanDownToClose: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
|
import { useCallback } from "react";
|
||||||
import { View, type ViewProps } from "react-native";
|
import { View, type ViewProps } from "react-native";
|
||||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||||
import { RoundButton } from "./RoundButton";
|
import { RoundButton } from "./RoundButton";
|
||||||
@@ -14,14 +15,16 @@ export const PlayedStatus: React.FC<Props> = ({ items, ...props }) => {
|
|||||||
const allPlayed = items.every((item) => item.UserData?.Played);
|
const allPlayed = items.every((item) => item.UserData?.Played);
|
||||||
const toggle = useMarkAsPlayed(items);
|
const toggle = useMarkAsPlayed(items);
|
||||||
|
|
||||||
|
const handlePress = useCallback(() => {
|
||||||
|
void toggle(!allPlayed);
|
||||||
|
}, [allPlayed, toggle]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<RoundButton
|
<RoundButton
|
||||||
color={allPlayed ? "purple" : "white"}
|
color={allPlayed ? "purple" : "white"}
|
||||||
icon={allPlayed ? "checkmark" : "checkmark"}
|
icon={allPlayed ? "checkmark" : "checkmark"}
|
||||||
onPress={async () => {
|
onPress={handlePress}
|
||||||
await toggle(!allPlayed);
|
|
||||||
}}
|
|
||||||
size={props.size}
|
size={props.size}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
<Ionicons
|
<Ionicons
|
||||||
name={icon}
|
name={icon}
|
||||||
size={size === "large" ? 22 : 18}
|
size={size === "large" ? 22 : 18}
|
||||||
color={"white"}
|
color={color === "white" ? "white" : "#9334E9"}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{children ? children : null}
|
{children ? children : null}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useMemo } 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 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 { HorizontalScroll } from "./common/HorizontalScroll";
|
import { HorizontalScroll } from "./common/HorizontalScroll";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
@@ -53,7 +54,7 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({
|
|||||||
<HorizontalScroll
|
<HorizontalScroll
|
||||||
data={movies}
|
data={movies}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
height={247}
|
height={POSTER_CAROUSEL_HEIGHT}
|
||||||
noItemsText={t("item_card.no_similar_items_found")}
|
noItemsText={t("item_card.no_similar_items_found")}
|
||||||
renderItem={(item: BaseItemDto, idx: number) => (
|
renderItem={(item: BaseItemDto, idx: number) => (
|
||||||
<TouchableItemRouter
|
<TouchableItemRouter
|
||||||
|
|||||||
@@ -282,7 +282,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|||||||
if (currentItem) {
|
if (currentItem) {
|
||||||
setSelectedOptions({
|
setSelectedOptions({
|
||||||
bitrate: defaultBitrate,
|
bitrate: defaultBitrate,
|
||||||
mediaSource: defaultMediaSource,
|
mediaSource: defaultMediaSource ?? undefined,
|
||||||
subtitleIndex: defaultSubtitleIndex ?? -1,
|
subtitleIndex: defaultSubtitleIndex ?? -1,
|
||||||
audioIndex: defaultAudioIndex,
|
audioIndex: defaultAudioIndex,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export const HorizontalScroll = <T,>(
|
|||||||
|
|
||||||
if (!data || loading) {
|
if (!data || loading) {
|
||||||
return (
|
return (
|
||||||
<View className='px-4 mb-2'>
|
<View className='px-4'>
|
||||||
<View className='bg-neutral-950 h-24 w-full rounded-md mb-2' />
|
<View className='bg-neutral-950 h-24 w-full rounded-md mb-2' />
|
||||||
<View className='bg-neutral-950 h-10 w-full rounded-md mb-1' />
|
<View className='bg-neutral-950 h-10 w-full rounded-md mb-1' />
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
42
components/common/SectionHeader.tsx
Normal file
42
components/common/SectionHeader.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { TouchableOpacity, View } from "react-native";
|
||||||
|
import { Colors } from "@/constants/Colors";
|
||||||
|
import { Text } from "./Text";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
title: string;
|
||||||
|
actionLabel?: string;
|
||||||
|
actionDisabled?: boolean;
|
||||||
|
onPressAction?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SectionHeader: React.FC<Props> = ({
|
||||||
|
title,
|
||||||
|
actionLabel,
|
||||||
|
actionDisabled = false,
|
||||||
|
onPressAction,
|
||||||
|
}) => {
|
||||||
|
const shouldShowAction = Boolean(actionLabel) && Boolean(onPressAction);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='px-4 flex flex-row items-center justify-between mb-2'>
|
||||||
|
<Text className='text-lg font-bold text-neutral-100'>{title}</Text>
|
||||||
|
{shouldShowAction && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onPressAction}
|
||||||
|
disabled={actionDisabled}
|
||||||
|
accessibilityRole='button'
|
||||||
|
accessibilityLabel={actionLabel}
|
||||||
|
className='py-1 pl-3'
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: actionDisabled ? "rgba(255,255,255,0.4)" : Colors.primary,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{actionLabel}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -16,6 +16,10 @@ export const itemRouter = (item: BaseItemDto, from: string) => {
|
|||||||
return `/(auth)/(tabs)/${from}/livetv`;
|
return `/(auth)/(tabs)/${from}/livetv`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ("CollectionType" in item && item.CollectionType === "music") {
|
||||||
|
return `/(auth)/(tabs)/(libraries)/music/${item.Id}`;
|
||||||
|
}
|
||||||
|
|
||||||
if (item.Type === "Series") {
|
if (item.Type === "Series") {
|
||||||
return `/(auth)/(tabs)/${from}/series/${item.Id}`;
|
return `/(auth)/(tabs)/${from}/series/${item.Id}`;
|
||||||
}
|
}
|
||||||
@@ -50,6 +54,13 @@ export const getItemNavigation = (item: BaseItemDto, _from: string) => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ("CollectionType" in item && item.CollectionType === "music") {
|
||||||
|
return {
|
||||||
|
pathname: "/music/[libraryId]" as const,
|
||||||
|
params: { libraryId: item.Id! },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (item.Type === "Series") {
|
if (item.Type === "Series") {
|
||||||
return {
|
return {
|
||||||
pathname: "/series/[id]" as const,
|
pathname: "/series/[id]" as const,
|
||||||
@@ -99,6 +110,25 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
|
|
||||||
const from = (segments as string[])[2] || "(home)";
|
const from = (segments as string[])[2] || "(home)";
|
||||||
|
|
||||||
|
const handlePress = useCallback(() => {
|
||||||
|
// For offline mode, we still need to use query params
|
||||||
|
if (isOffline) {
|
||||||
|
const url = `${itemRouter(item, from)}&offline=true`;
|
||||||
|
router.push(url as any);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force music libraries to navigate via the explicit string route.
|
||||||
|
// This avoids losing the dynamic [libraryId] param when going through a nested navigator.
|
||||||
|
if ("CollectionType" in item && item.CollectionType === "music") {
|
||||||
|
router.push(itemRouter(item, from) as any);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigation = getItemNavigation(item, from);
|
||||||
|
router.push(navigation as any);
|
||||||
|
}, [from, isOffline, item, router]);
|
||||||
|
|
||||||
const showActionSheet = useCallback(() => {
|
const showActionSheet = useCallback(() => {
|
||||||
if (
|
if (
|
||||||
!(
|
!(
|
||||||
@@ -108,13 +138,14 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
const options = [
|
|
||||||
|
const options: string[] = [
|
||||||
"Mark as Played",
|
"Mark as Played",
|
||||||
"Mark as Not Played",
|
"Mark as Not Played",
|
||||||
isFavorite ? "Unmark as Favorite" : "Mark as Favorite",
|
isFavorite ? "Unmark as Favorite" : "Mark as Favorite",
|
||||||
"Cancel",
|
"Cancel",
|
||||||
];
|
];
|
||||||
const cancelButtonIndex = 3;
|
const cancelButtonIndex = options.length - 1;
|
||||||
|
|
||||||
showActionSheetWithOptions(
|
showActionSheetWithOptions(
|
||||||
{
|
{
|
||||||
@@ -131,28 +162,24 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}, [showActionSheetWithOptions, isFavorite, markAsPlayedStatus]);
|
}, [
|
||||||
|
showActionSheetWithOptions,
|
||||||
|
isFavorite,
|
||||||
|
markAsPlayedStatus,
|
||||||
|
toggleFavorite,
|
||||||
|
]);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
from === "(home)" ||
|
from === "(home)" ||
|
||||||
from === "(search)" ||
|
from === "(search)" ||
|
||||||
from === "(libraries)" ||
|
from === "(libraries)" ||
|
||||||
from === "(favorites)"
|
from === "(favorites)" ||
|
||||||
|
from === "(watchlists)"
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onLongPress={showActionSheet}
|
onLongPress={showActionSheet}
|
||||||
onPress={() => {
|
onPress={handlePress}
|
||||||
if (isOffline) {
|
|
||||||
// For offline mode, we still need to use query params
|
|
||||||
const url = `${itemRouter(item, from)}&offline=true`;
|
|
||||||
router.push(url as any);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const navigation = getItemNavigation(item, from);
|
|
||||||
router.push(navigation as any);
|
|
||||||
}}
|
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Ionicons } from "@expo/vector-icons";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
|
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
|
||||||
import {
|
import {
|
||||||
|
filterByAtom,
|
||||||
genreFilterAtom,
|
genreFilterAtom,
|
||||||
tagsFilterAtom,
|
tagsFilterAtom,
|
||||||
yearFilterAtom,
|
yearFilterAtom,
|
||||||
@@ -13,11 +14,13 @@ export const ResetFiltersButton: React.FC<Props> = ({ ...props }) => {
|
|||||||
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
|
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
|
||||||
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
|
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
|
||||||
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
||||||
|
const [selectedFilters, setSelectedFilters] = useAtom(filterByAtom);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
selectedGenres.length === 0 &&
|
selectedGenres.length === 0 &&
|
||||||
selectedTags.length === 0 &&
|
selectedTags.length === 0 &&
|
||||||
selectedYears.length === 0
|
selectedYears.length === 0 &&
|
||||||
|
selectedFilters.length === 0
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -28,6 +31,7 @@ export const ResetFiltersButton: React.FC<Props> = ({ ...props }) => {
|
|||||||
setSelectedGenres([]);
|
setSelectedGenres([]);
|
||||||
setSelectedTags([]);
|
setSelectedTags([]);
|
||||||
setSelectedYears([]);
|
setSelectedYears([]);
|
||||||
|
setSelectedFilters([]);
|
||||||
}}
|
}}
|
||||||
className='bg-purple-600 rounded-full w-[30px] h-[30px] flex items-center justify-center mr-1'
|
className='bg-purple-600 rounded-full w-[30px] h-[30px] flex items-center justify-center mr-1'
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Api } from "@jellyfin/sdk";
|
|||||||
import type { BaseItemKind } from "@jellyfin/sdk/lib/generated-client";
|
import type { BaseItemKind } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
@@ -22,8 +23,10 @@ type FavoriteTypes =
|
|||||||
type EmptyState = Record<FavoriteTypes, boolean>;
|
type EmptyState = Record<FavoriteTypes, boolean>;
|
||||||
|
|
||||||
export const Favorites = () => {
|
export const Favorites = () => {
|
||||||
|
const router = useRouter();
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
|
const pageSize = 20;
|
||||||
const [emptyState, setEmptyState] = useState<EmptyState>({
|
const [emptyState, setEmptyState] = useState<EmptyState>({
|
||||||
Series: false,
|
Series: false,
|
||||||
Movie: false,
|
Movie: false,
|
||||||
@@ -91,35 +94,77 @@ export const Favorites = () => {
|
|||||||
|
|
||||||
const fetchFavoriteSeries = useCallback(
|
const fetchFavoriteSeries = useCallback(
|
||||||
({ pageParam }: { pageParam: number }) =>
|
({ pageParam }: { pageParam: number }) =>
|
||||||
fetchFavoritesByType("Series", pageParam),
|
fetchFavoritesByType("Series", pageParam, pageSize),
|
||||||
[fetchFavoritesByType],
|
[fetchFavoritesByType, pageSize],
|
||||||
);
|
);
|
||||||
const fetchFavoriteMovies = useCallback(
|
const fetchFavoriteMovies = useCallback(
|
||||||
({ pageParam }: { pageParam: number }) =>
|
({ pageParam }: { pageParam: number }) =>
|
||||||
fetchFavoritesByType("Movie", pageParam),
|
fetchFavoritesByType("Movie", pageParam, pageSize),
|
||||||
[fetchFavoritesByType],
|
[fetchFavoritesByType, pageSize],
|
||||||
);
|
);
|
||||||
const fetchFavoriteEpisodes = useCallback(
|
const fetchFavoriteEpisodes = useCallback(
|
||||||
({ pageParam }: { pageParam: number }) =>
|
({ pageParam }: { pageParam: number }) =>
|
||||||
fetchFavoritesByType("Episode", pageParam),
|
fetchFavoritesByType("Episode", pageParam, pageSize),
|
||||||
[fetchFavoritesByType],
|
[fetchFavoritesByType, pageSize],
|
||||||
);
|
);
|
||||||
const fetchFavoriteVideos = useCallback(
|
const fetchFavoriteVideos = useCallback(
|
||||||
({ pageParam }: { pageParam: number }) =>
|
({ pageParam }: { pageParam: number }) =>
|
||||||
fetchFavoritesByType("Video", pageParam),
|
fetchFavoritesByType("Video", pageParam, pageSize),
|
||||||
[fetchFavoritesByType],
|
[fetchFavoritesByType, pageSize],
|
||||||
);
|
);
|
||||||
const fetchFavoriteBoxsets = useCallback(
|
const fetchFavoriteBoxsets = useCallback(
|
||||||
({ pageParam }: { pageParam: number }) =>
|
({ pageParam }: { pageParam: number }) =>
|
||||||
fetchFavoritesByType("BoxSet", pageParam),
|
fetchFavoritesByType("BoxSet", pageParam, pageSize),
|
||||||
[fetchFavoritesByType],
|
[fetchFavoritesByType, pageSize],
|
||||||
);
|
);
|
||||||
const fetchFavoritePlaylists = useCallback(
|
const fetchFavoritePlaylists = useCallback(
|
||||||
({ pageParam }: { pageParam: number }) =>
|
({ pageParam }: { pageParam: number }) =>
|
||||||
fetchFavoritesByType("Playlist", pageParam),
|
fetchFavoritesByType("Playlist", pageParam, pageSize),
|
||||||
[fetchFavoritesByType],
|
[fetchFavoritesByType, pageSize],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleSeeAllSeries = useCallback(() => {
|
||||||
|
router.push({
|
||||||
|
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||||
|
params: { type: "Series", title: t("favorites.series") },
|
||||||
|
} as any);
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const handleSeeAllMovies = useCallback(() => {
|
||||||
|
router.push({
|
||||||
|
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||||
|
params: { type: "Movie", title: t("favorites.movies") },
|
||||||
|
} as any);
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const handleSeeAllEpisodes = useCallback(() => {
|
||||||
|
router.push({
|
||||||
|
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||||
|
params: { type: "Episode", title: t("favorites.episodes") },
|
||||||
|
} as any);
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const handleSeeAllVideos = useCallback(() => {
|
||||||
|
router.push({
|
||||||
|
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||||
|
params: { type: "Video", title: t("favorites.videos") },
|
||||||
|
} as any);
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const handleSeeAllBoxsets = useCallback(() => {
|
||||||
|
router.push({
|
||||||
|
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||||
|
params: { type: "BoxSet", title: t("favorites.boxsets") },
|
||||||
|
} as any);
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const handleSeeAllPlaylists = useCallback(() => {
|
||||||
|
router.push({
|
||||||
|
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||||
|
params: { type: "Playlist", title: t("favorites.playlists") },
|
||||||
|
} as any);
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='flex flex-co gap-y-4'>
|
<View className='flex flex-co gap-y-4'>
|
||||||
{areAllEmpty() && (
|
{areAllEmpty() && (
|
||||||
@@ -143,6 +188,8 @@ export const Favorites = () => {
|
|||||||
queryKey={["home", "favorites", "series"]}
|
queryKey={["home", "favorites", "series"]}
|
||||||
title={t("favorites.series")}
|
title={t("favorites.series")}
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
|
pageSize={pageSize}
|
||||||
|
onPressSeeAll={handleSeeAllSeries}
|
||||||
/>
|
/>
|
||||||
<InfiniteScrollingCollectionList
|
<InfiniteScrollingCollectionList
|
||||||
queryFn={fetchFavoriteMovies}
|
queryFn={fetchFavoriteMovies}
|
||||||
@@ -150,30 +197,40 @@ export const Favorites = () => {
|
|||||||
title={t("favorites.movies")}
|
title={t("favorites.movies")}
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
orientation='vertical'
|
orientation='vertical'
|
||||||
|
pageSize={pageSize}
|
||||||
|
onPressSeeAll={handleSeeAllMovies}
|
||||||
/>
|
/>
|
||||||
<InfiniteScrollingCollectionList
|
<InfiniteScrollingCollectionList
|
||||||
queryFn={fetchFavoriteEpisodes}
|
queryFn={fetchFavoriteEpisodes}
|
||||||
queryKey={["home", "favorites", "episodes"]}
|
queryKey={["home", "favorites", "episodes"]}
|
||||||
title={t("favorites.episodes")}
|
title={t("favorites.episodes")}
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
|
pageSize={pageSize}
|
||||||
|
onPressSeeAll={handleSeeAllEpisodes}
|
||||||
/>
|
/>
|
||||||
<InfiniteScrollingCollectionList
|
<InfiniteScrollingCollectionList
|
||||||
queryFn={fetchFavoriteVideos}
|
queryFn={fetchFavoriteVideos}
|
||||||
queryKey={["home", "favorites", "videos"]}
|
queryKey={["home", "favorites", "videos"]}
|
||||||
title={t("favorites.videos")}
|
title={t("favorites.videos")}
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
|
pageSize={pageSize}
|
||||||
|
onPressSeeAll={handleSeeAllVideos}
|
||||||
/>
|
/>
|
||||||
<InfiniteScrollingCollectionList
|
<InfiniteScrollingCollectionList
|
||||||
queryFn={fetchFavoriteBoxsets}
|
queryFn={fetchFavoriteBoxsets}
|
||||||
queryKey={["home", "favorites", "boxsets"]}
|
queryKey={["home", "favorites", "boxsets"]}
|
||||||
title={t("favorites.boxsets")}
|
title={t("favorites.boxsets")}
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
|
pageSize={pageSize}
|
||||||
|
onPressSeeAll={handleSeeAllBoxsets}
|
||||||
/>
|
/>
|
||||||
<InfiniteScrollingCollectionList
|
<InfiniteScrollingCollectionList
|
||||||
queryFn={fetchFavoritePlaylists}
|
queryFn={fetchFavoritePlaylists}
|
||||||
queryKey={["home", "favorites", "playlists"]}
|
queryKey={["home", "favorites", "playlists"]}
|
||||||
title={t("favorites.playlists")}
|
title={t("favorites.playlists")}
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
|
pageSize={pageSize}
|
||||||
|
onPressSeeAll={handleSeeAllPlaylists}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList";
|
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList";
|
||||||
|
import { StreamystatsPromotedWatchlists } from "@/components/home/StreamystatsPromotedWatchlists";
|
||||||
|
import { StreamystatsRecommendations } from "@/components/home/StreamystatsRecommendations";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
@@ -45,12 +47,14 @@ type InfiniteScrollingCollectionListSection = {
|
|||||||
queryFn: QueryFunction<BaseItemDto[], any, number>;
|
queryFn: QueryFunction<BaseItemDto[], any, number>;
|
||||||
orientation?: "horizontal" | "vertical";
|
orientation?: "horizontal" | "vertical";
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
|
priority?: 1 | 2; // 1 = high priority (loads first), 2 = low priority
|
||||||
};
|
};
|
||||||
|
|
||||||
type MediaListSectionType = {
|
type MediaListSectionType = {
|
||||||
type: "MediaListSection";
|
type: "MediaListSection";
|
||||||
queryKey: (string | undefined)[];
|
queryKey: (string | undefined)[];
|
||||||
queryFn: QueryFunction<BaseItemDto>;
|
queryFn: QueryFunction<BaseItemDto>;
|
||||||
|
priority?: 1 | 2;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Section = InfiniteScrollingCollectionListSection | MediaListSectionType;
|
type Section = InfiniteScrollingCollectionListSection | MediaListSectionType;
|
||||||
@@ -74,7 +78,7 @@ export const Home = () => {
|
|||||||
retryCheck,
|
retryCheck,
|
||||||
} = useNetworkStatus();
|
} = useNetworkStatus();
|
||||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||||
const [scrollY, setScrollY] = useState(0);
|
const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isConnected && !prevIsConnected.current) {
|
if (isConnected && !prevIsConnected.current) {
|
||||||
@@ -172,6 +176,7 @@ export const Home = () => {
|
|||||||
|
|
||||||
const refetch = async () => {
|
const refetch = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setLoadedSections(new Set());
|
||||||
await refreshStreamyfinPluginSettings();
|
await refreshStreamyfinPluginSettings();
|
||||||
await invalidateCache();
|
await invalidateCache();
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -194,10 +199,10 @@ export const Home = () => {
|
|||||||
(
|
(
|
||||||
await getUserLibraryApi(api).getLatestMedia({
|
await getUserLibraryApi(api).getLatestMedia({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
limit: 100, // Fetch a larger set for pagination
|
limit: 10,
|
||||||
fields: ["PrimaryImageAspectRatio", "Path", "Genres"],
|
fields: ["PrimaryImageAspectRatio"],
|
||||||
imageTypeLimit: 1,
|
imageTypeLimit: 1,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
includeItemTypes,
|
includeItemTypes,
|
||||||
parentId,
|
parentId,
|
||||||
})
|
})
|
||||||
@@ -236,64 +241,143 @@ export const Home = () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Helper to sort items by most recent activity
|
||||||
|
const sortByRecentActivity = (items: BaseItemDto[]): BaseItemDto[] => {
|
||||||
|
return items.sort((a, b) => {
|
||||||
|
const dateA = a.UserData?.LastPlayedDate || a.DateCreated || "";
|
||||||
|
const dateB = b.UserData?.LastPlayedDate || b.DateCreated || "";
|
||||||
|
return new Date(dateB).getTime() - new Date(dateA).getTime();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to deduplicate items by ID
|
||||||
|
const deduplicateById = (items: BaseItemDto[]): BaseItemDto[] => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return items.filter((item) => {
|
||||||
|
if (!item.Id || seen.has(item.Id)) return false;
|
||||||
|
seen.add(item.Id);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build the first sections based on merge setting
|
||||||
|
const firstSections: Section[] = settings.mergeNextUpAndContinueWatching
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
title: t("home.continue_and_next_up"),
|
||||||
|
queryKey: ["home", "continueAndNextUp"],
|
||||||
|
queryFn: async ({ pageParam = 0 }) => {
|
||||||
|
// Fetch both in parallel
|
||||||
|
const [resumeResponse, nextUpResponse] = await Promise.all([
|
||||||
|
getItemsApi(api).getResumeItems({
|
||||||
|
userId: user.Id,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
|
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||||
|
startIndex: 0,
|
||||||
|
limit: 20,
|
||||||
|
}),
|
||||||
|
getTvShowsApi(api).getNextUp({
|
||||||
|
userId: user?.Id,
|
||||||
|
startIndex: 0,
|
||||||
|
limit: 20,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
|
enableResumable: false,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const resumeItems = resumeResponse.data.Items || [];
|
||||||
|
const nextUpItems = nextUpResponse.data.Items || [];
|
||||||
|
|
||||||
|
// Combine, sort by recent activity, deduplicate
|
||||||
|
const combined = [...resumeItems, ...nextUpItems];
|
||||||
|
const sorted = sortByRecentActivity(combined);
|
||||||
|
const deduplicated = deduplicateById(sorted);
|
||||||
|
|
||||||
|
// Paginate client-side
|
||||||
|
return deduplicated.slice(pageParam, pageParam + 10);
|
||||||
|
},
|
||||||
|
type: "InfiniteScrollingCollectionList",
|
||||||
|
orientation: "horizontal",
|
||||||
|
pageSize: 10,
|
||||||
|
priority: 1,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
title: t("home.continue_watching"),
|
||||||
|
queryKey: ["home", "resumeItems"],
|
||||||
|
queryFn: async ({ pageParam = 0 }) =>
|
||||||
|
(
|
||||||
|
await getItemsApi(api).getResumeItems({
|
||||||
|
userId: user.Id,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
|
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||||
|
startIndex: pageParam,
|
||||||
|
limit: 10,
|
||||||
|
})
|
||||||
|
).data.Items || [],
|
||||||
|
type: "InfiniteScrollingCollectionList",
|
||||||
|
orientation: "horizontal",
|
||||||
|
pageSize: 10,
|
||||||
|
priority: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("home.next_up"),
|
||||||
|
queryKey: ["home", "nextUp-all"],
|
||||||
|
queryFn: async ({ pageParam = 0 }) =>
|
||||||
|
(
|
||||||
|
await getTvShowsApi(api).getNextUp({
|
||||||
|
userId: user?.Id,
|
||||||
|
startIndex: pageParam,
|
||||||
|
limit: 10,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
|
enableResumable: false,
|
||||||
|
})
|
||||||
|
).data.Items || [],
|
||||||
|
type: "InfiniteScrollingCollectionList",
|
||||||
|
orientation: "horizontal",
|
||||||
|
pageSize: 10,
|
||||||
|
priority: 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const ss: Section[] = [
|
const ss: Section[] = [
|
||||||
{
|
...firstSections,
|
||||||
title: t("home.continue_watching"),
|
...latestMediaViews.map((s) => ({ ...s, priority: 2 as const })),
|
||||||
queryKey: ["home", "resumeItems"],
|
// Only show Jellyfin suggested movies if StreamyStats recommendations are disabled
|
||||||
queryFn: async ({ pageParam = 0 }) =>
|
...(!settings?.streamyStatsMovieRecommendations
|
||||||
(
|
? [
|
||||||
await getItemsApi(api).getResumeItems({
|
{
|
||||||
userId: user.Id,
|
title: t("home.suggested_movies"),
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
queryKey: ["home", "suggestedMovies", user?.Id],
|
||||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
queryFn: async ({ pageParam = 0 }: { pageParam?: number }) =>
|
||||||
fields: ["Genres"],
|
(
|
||||||
startIndex: pageParam,
|
await getSuggestionsApi(api).getSuggestions({
|
||||||
limit: 10,
|
userId: user?.Id,
|
||||||
})
|
startIndex: pageParam,
|
||||||
).data.Items || [],
|
limit: 10,
|
||||||
type: "InfiniteScrollingCollectionList",
|
mediaType: ["Video"],
|
||||||
orientation: "horizontal",
|
type: ["Movie"],
|
||||||
pageSize: 10,
|
})
|
||||||
},
|
).data.Items || [],
|
||||||
{
|
type: "InfiniteScrollingCollectionList" as const,
|
||||||
title: t("home.next_up"),
|
orientation: "vertical" as const,
|
||||||
queryKey: ["home", "nextUp-all"],
|
pageSize: 10,
|
||||||
queryFn: async ({ pageParam = 0 }) =>
|
priority: 2 as const,
|
||||||
(
|
},
|
||||||
await getTvShowsApi(api).getNextUp({
|
]
|
||||||
userId: user?.Id,
|
: []),
|
||||||
fields: ["MediaSourceCount", "Genres"],
|
|
||||||
startIndex: pageParam,
|
|
||||||
limit: 10,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
|
||||||
enableResumable: false,
|
|
||||||
})
|
|
||||||
).data.Items || [],
|
|
||||||
type: "InfiniteScrollingCollectionList",
|
|
||||||
orientation: "horizontal",
|
|
||||||
pageSize: 10,
|
|
||||||
},
|
|
||||||
...latestMediaViews,
|
|
||||||
{
|
|
||||||
title: t("home.suggested_movies"),
|
|
||||||
queryKey: ["home", "suggestedMovies", user?.Id],
|
|
||||||
queryFn: async ({ pageParam = 0 }) =>
|
|
||||||
(
|
|
||||||
await getSuggestionsApi(api).getSuggestions({
|
|
||||||
userId: user?.Id,
|
|
||||||
startIndex: pageParam,
|
|
||||||
limit: 10,
|
|
||||||
mediaType: ["Video"],
|
|
||||||
type: ["Movie"],
|
|
||||||
})
|
|
||||||
).data.Items || [],
|
|
||||||
type: "InfiniteScrollingCollectionList",
|
|
||||||
orientation: "vertical",
|
|
||||||
pageSize: 10,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
return ss;
|
return ss;
|
||||||
}, [api, user?.Id, collections, t, createCollectionConfig]);
|
}, [
|
||||||
|
api,
|
||||||
|
user?.Id,
|
||||||
|
collections,
|
||||||
|
t,
|
||||||
|
createCollectionConfig,
|
||||||
|
settings?.streamyStatsMovieRecommendations,
|
||||||
|
settings.mergeNextUpAndContinueWatching,
|
||||||
|
]);
|
||||||
|
|
||||||
const customSections = useMemo(() => {
|
const customSections = useMemo(() => {
|
||||||
if (!api || !user?.Id || !settings?.home?.sections) return [];
|
if (!api || !user?.Id || !settings?.home?.sections) return [];
|
||||||
@@ -322,10 +406,9 @@ export const Home = () => {
|
|||||||
if (section.nextUp) {
|
if (section.nextUp) {
|
||||||
const response = await getTvShowsApi(api).getNextUp({
|
const response = await getTvShowsApi(api).getNextUp({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
fields: ["MediaSourceCount", "Genres"],
|
|
||||||
startIndex: pageParam,
|
startIndex: pageParam,
|
||||||
limit: section.nextUp?.limit || pageSize,
|
limit: section.nextUp?.limit || pageSize,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
enableResumable: section.nextUp?.enableResumable,
|
enableResumable: section.nextUp?.enableResumable,
|
||||||
enableRewatching: section.nextUp?.enableRewatching,
|
enableRewatching: section.nextUp?.enableRewatching,
|
||||||
});
|
});
|
||||||
@@ -338,7 +421,7 @@ export const Home = () => {
|
|||||||
await getUserLibraryApi(api).getLatestMedia({
|
await getUserLibraryApi(api).getLatestMedia({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
includeItemTypes: section.latest?.includeItemTypes,
|
includeItemTypes: section.latest?.includeItemTypes,
|
||||||
limit: section.latest?.limit || 100, // Fetch larger set
|
limit: section.latest?.limit || 10,
|
||||||
isPlayed: section.latest?.isPlayed,
|
isPlayed: section.latest?.isPlayed,
|
||||||
groupItems: section.latest?.groupItems,
|
groupItems: section.latest?.groupItems,
|
||||||
})
|
})
|
||||||
@@ -367,6 +450,8 @@ export const Home = () => {
|
|||||||
type: "InfiniteScrollingCollectionList",
|
type: "InfiniteScrollingCollectionList",
|
||||||
orientation: section?.orientation || "vertical",
|
orientation: section?.orientation || "vertical",
|
||||||
pageSize,
|
pageSize,
|
||||||
|
// First 2 custom sections are high priority
|
||||||
|
priority: index < 2 ? 1 : 2,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return ss;
|
return ss;
|
||||||
@@ -374,6 +459,25 @@ export const Home = () => {
|
|||||||
|
|
||||||
const sections = settings?.home?.sections ? customSections : defaultSections;
|
const sections = settings?.home?.sections ? customSections : defaultSections;
|
||||||
|
|
||||||
|
// Get all high priority section keys and check if all have loaded
|
||||||
|
const highPrioritySectionKeys = useMemo(() => {
|
||||||
|
return sections
|
||||||
|
.filter((s) => s.priority === 1)
|
||||||
|
.map((s) => s.queryKey.join("-"));
|
||||||
|
}, [sections]);
|
||||||
|
|
||||||
|
const allHighPriorityLoaded = useMemo(() => {
|
||||||
|
return highPrioritySectionKeys.every((key) => loadedSections.has(key));
|
||||||
|
}, [highPrioritySectionKeys, loadedSections]);
|
||||||
|
|
||||||
|
const markSectionLoaded = useCallback(
|
||||||
|
(queryKey: (string | undefined | null)[]) => {
|
||||||
|
const key = queryKey.join("-");
|
||||||
|
setLoadedSections((prev) => new Set(prev).add(key));
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
if (!isConnected || serverConnected !== true) {
|
if (!isConnected || serverConnected !== true) {
|
||||||
let title = "";
|
let title = "";
|
||||||
let subtitle = "";
|
let subtitle = "";
|
||||||
@@ -451,10 +555,6 @@ export const Home = () => {
|
|||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
nestedScrollEnabled
|
nestedScrollEnabled
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
onScroll={(event) => {
|
|
||||||
setScrollY(event.nativeEvent.contentOffset.y - 500);
|
|
||||||
}}
|
|
||||||
scrollEventThrottle={16}
|
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl
|
<RefreshControl
|
||||||
refreshing={loading}
|
refreshing={loading}
|
||||||
@@ -474,28 +574,77 @@ export const Home = () => {
|
|||||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||||
>
|
>
|
||||||
{sections.map((section, index) => {
|
{sections.map((section, index) => {
|
||||||
|
// Render Streamystats sections after Continue Watching and Next Up
|
||||||
|
// When merged, they appear after index 0; otherwise after index 1
|
||||||
|
const streamystatsIndex = settings.mergeNextUpAndContinueWatching
|
||||||
|
? 0
|
||||||
|
: 1;
|
||||||
|
const hasStreamystatsContent =
|
||||||
|
settings.streamyStatsMovieRecommendations ||
|
||||||
|
settings.streamyStatsSeriesRecommendations ||
|
||||||
|
settings.streamyStatsPromotedWatchlists;
|
||||||
|
const streamystatsSections =
|
||||||
|
index === streamystatsIndex && hasStreamystatsContent ? (
|
||||||
|
<View
|
||||||
|
key='streamystats-sections'
|
||||||
|
className='flex flex-col space-y-4'
|
||||||
|
>
|
||||||
|
{settings.streamyStatsMovieRecommendations && (
|
||||||
|
<StreamystatsRecommendations
|
||||||
|
title={t(
|
||||||
|
"home.settings.plugins.streamystats.recommended_movies",
|
||||||
|
)}
|
||||||
|
type='Movie'
|
||||||
|
enabled={allHighPriorityLoaded}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{settings.streamyStatsSeriesRecommendations && (
|
||||||
|
<StreamystatsRecommendations
|
||||||
|
title={t(
|
||||||
|
"home.settings.plugins.streamystats.recommended_series",
|
||||||
|
)}
|
||||||
|
type='Series'
|
||||||
|
enabled={allHighPriorityLoaded}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{settings.streamyStatsPromotedWatchlists && (
|
||||||
|
<StreamystatsPromotedWatchlists
|
||||||
|
enabled={allHighPriorityLoaded}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
) : null;
|
||||||
if (section.type === "InfiniteScrollingCollectionList") {
|
if (section.type === "InfiniteScrollingCollectionList") {
|
||||||
|
const isHighPriority = section.priority === 1;
|
||||||
return (
|
return (
|
||||||
<InfiniteScrollingCollectionList
|
<View key={index} className='flex flex-col space-y-4'>
|
||||||
key={index}
|
<InfiniteScrollingCollectionList
|
||||||
title={section.title}
|
title={section.title}
|
||||||
queryKey={section.queryKey}
|
queryKey={section.queryKey}
|
||||||
queryFn={section.queryFn}
|
queryFn={section.queryFn}
|
||||||
orientation={section.orientation}
|
orientation={section.orientation}
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
pageSize={section.pageSize}
|
pageSize={section.pageSize}
|
||||||
/>
|
enabled={isHighPriority || allHighPriorityLoaded}
|
||||||
|
onLoaded={
|
||||||
|
isHighPriority
|
||||||
|
? () => markSectionLoaded(section.queryKey)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{streamystatsSections}
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (section.type === "MediaListSection") {
|
if (section.type === "MediaListSection") {
|
||||||
return (
|
return (
|
||||||
<MediaListSection
|
<View key={index} className='flex flex-col space-y-4'>
|
||||||
key={index}
|
<MediaListSection
|
||||||
queryKey={section.queryKey}
|
queryKey={section.queryKey}
|
||||||
queryFn={section.queryFn}
|
queryFn={section.queryFn}
|
||||||
scrollY={scrollY}
|
/>
|
||||||
enableLazyLoading={true}
|
{streamystatsSections}
|
||||||
/>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList";
|
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList";
|
||||||
|
import { StreamystatsPromotedWatchlists } from "@/components/home/StreamystatsPromotedWatchlists";
|
||||||
|
import { StreamystatsRecommendations } from "@/components/home/StreamystatsRecommendations";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
@@ -241,64 +243,143 @@ export const HomeWithCarousel = () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Helper to sort items by most recent activity
|
||||||
|
const sortByRecentActivity = (items: BaseItemDto[]): BaseItemDto[] => {
|
||||||
|
return items.sort((a, b) => {
|
||||||
|
const dateA = a.UserData?.LastPlayedDate || a.DateCreated || "";
|
||||||
|
const dateB = b.UserData?.LastPlayedDate || b.DateCreated || "";
|
||||||
|
return new Date(dateB).getTime() - new Date(dateA).getTime();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to deduplicate items by ID
|
||||||
|
const deduplicateById = (items: BaseItemDto[]): BaseItemDto[] => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return items.filter((item) => {
|
||||||
|
if (!item.Id || seen.has(item.Id)) return false;
|
||||||
|
seen.add(item.Id);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build the first sections based on merge setting
|
||||||
|
const firstSections: Section[] = settings.mergeNextUpAndContinueWatching
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
title: t("home.continue_and_next_up"),
|
||||||
|
queryKey: ["home", "continueAndNextUp"],
|
||||||
|
queryFn: async ({ pageParam = 0 }) => {
|
||||||
|
// Fetch both in parallel
|
||||||
|
const [resumeResponse, nextUpResponse] = await Promise.all([
|
||||||
|
getItemsApi(api).getResumeItems({
|
||||||
|
userId: user.Id,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||||
|
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||||
|
fields: ["Genres"],
|
||||||
|
startIndex: 0,
|
||||||
|
limit: 20,
|
||||||
|
}),
|
||||||
|
getTvShowsApi(api).getNextUp({
|
||||||
|
userId: user?.Id,
|
||||||
|
fields: ["MediaSourceCount", "Genres"],
|
||||||
|
startIndex: 0,
|
||||||
|
limit: 20,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||||
|
enableResumable: false,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const resumeItems = resumeResponse.data.Items || [];
|
||||||
|
const nextUpItems = nextUpResponse.data.Items || [];
|
||||||
|
|
||||||
|
// Combine, sort by recent activity, deduplicate
|
||||||
|
const combined = [...resumeItems, ...nextUpItems];
|
||||||
|
const sorted = sortByRecentActivity(combined);
|
||||||
|
const deduplicated = deduplicateById(sorted);
|
||||||
|
|
||||||
|
// Paginate client-side
|
||||||
|
return deduplicated.slice(pageParam, pageParam + 10);
|
||||||
|
},
|
||||||
|
type: "InfiniteScrollingCollectionList",
|
||||||
|
orientation: "horizontal",
|
||||||
|
pageSize: 10,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
title: t("home.continue_watching"),
|
||||||
|
queryKey: ["home", "resumeItems"],
|
||||||
|
queryFn: async ({ pageParam = 0 }) =>
|
||||||
|
(
|
||||||
|
await getItemsApi(api).getResumeItems({
|
||||||
|
userId: user.Id,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||||
|
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||||
|
fields: ["Genres"],
|
||||||
|
startIndex: pageParam,
|
||||||
|
limit: 10,
|
||||||
|
})
|
||||||
|
).data.Items || [],
|
||||||
|
type: "InfiniteScrollingCollectionList",
|
||||||
|
orientation: "horizontal",
|
||||||
|
pageSize: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("home.next_up"),
|
||||||
|
queryKey: ["home", "nextUp-all"],
|
||||||
|
queryFn: async ({ pageParam = 0 }) =>
|
||||||
|
(
|
||||||
|
await getTvShowsApi(api).getNextUp({
|
||||||
|
userId: user?.Id,
|
||||||
|
fields: ["MediaSourceCount", "Genres"],
|
||||||
|
startIndex: pageParam,
|
||||||
|
limit: 10,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||||
|
enableResumable: false,
|
||||||
|
})
|
||||||
|
).data.Items || [],
|
||||||
|
type: "InfiniteScrollingCollectionList",
|
||||||
|
orientation: "horizontal",
|
||||||
|
pageSize: 10,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const ss: Section[] = [
|
const ss: Section[] = [
|
||||||
{
|
...firstSections,
|
||||||
title: t("home.continue_watching"),
|
|
||||||
queryKey: ["home", "resumeItems"],
|
|
||||||
queryFn: async ({ pageParam = 0 }) =>
|
|
||||||
(
|
|
||||||
await getItemsApi(api).getResumeItems({
|
|
||||||
userId: user.Id,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
|
||||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
|
||||||
fields: ["Genres"],
|
|
||||||
startIndex: pageParam,
|
|
||||||
limit: 10,
|
|
||||||
})
|
|
||||||
).data.Items || [],
|
|
||||||
type: "InfiniteScrollingCollectionList",
|
|
||||||
orientation: "horizontal",
|
|
||||||
pageSize: 10,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t("home.next_up"),
|
|
||||||
queryKey: ["home", "nextUp-all"],
|
|
||||||
queryFn: async ({ pageParam = 0 }) =>
|
|
||||||
(
|
|
||||||
await getTvShowsApi(api).getNextUp({
|
|
||||||
userId: user?.Id,
|
|
||||||
fields: ["MediaSourceCount", "Genres"],
|
|
||||||
startIndex: pageParam,
|
|
||||||
limit: 10,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
|
||||||
enableResumable: false,
|
|
||||||
})
|
|
||||||
).data.Items || [],
|
|
||||||
type: "InfiniteScrollingCollectionList",
|
|
||||||
orientation: "horizontal",
|
|
||||||
pageSize: 10,
|
|
||||||
},
|
|
||||||
...latestMediaViews,
|
...latestMediaViews,
|
||||||
{
|
// Only show Jellyfin suggested movies if StreamyStats recommendations are disabled
|
||||||
title: t("home.suggested_movies"),
|
...(!settings?.streamyStatsMovieRecommendations
|
||||||
queryKey: ["home", "suggestedMovies", user?.Id],
|
? [
|
||||||
queryFn: async ({ pageParam = 0 }) =>
|
{
|
||||||
(
|
title: t("home.suggested_movies"),
|
||||||
await getSuggestionsApi(api).getSuggestions({
|
queryKey: ["home", "suggestedMovies", user?.Id],
|
||||||
userId: user?.Id,
|
queryFn: async ({ pageParam = 0 }: { pageParam?: number }) =>
|
||||||
startIndex: pageParam,
|
(
|
||||||
limit: 10,
|
await getSuggestionsApi(api).getSuggestions({
|
||||||
mediaType: ["Video"],
|
userId: user?.Id,
|
||||||
type: ["Movie"],
|
startIndex: pageParam,
|
||||||
})
|
limit: 10,
|
||||||
).data.Items || [],
|
mediaType: ["Video"],
|
||||||
type: "InfiniteScrollingCollectionList",
|
type: ["Movie"],
|
||||||
orientation: "vertical",
|
})
|
||||||
pageSize: 10,
|
).data.Items || [],
|
||||||
},
|
type: "InfiniteScrollingCollectionList" as const,
|
||||||
|
orientation: "vertical" as const,
|
||||||
|
pageSize: 10,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
];
|
];
|
||||||
return ss;
|
return ss;
|
||||||
}, [api, user?.Id, collections, t, createCollectionConfig]);
|
}, [
|
||||||
|
api,
|
||||||
|
user?.Id,
|
||||||
|
collections,
|
||||||
|
t,
|
||||||
|
createCollectionConfig,
|
||||||
|
settings?.streamyStatsMovieRecommendations,
|
||||||
|
settings.mergeNextUpAndContinueWatching,
|
||||||
|
]);
|
||||||
|
|
||||||
const customSections = useMemo(() => {
|
const customSections = useMemo(() => {
|
||||||
if (!api || !user?.Id || !settings?.home?.sections) return [];
|
if (!api || !user?.Id || !settings?.home?.sections) return [];
|
||||||
@@ -477,28 +558,66 @@ export const HomeWithCarousel = () => {
|
|||||||
>
|
>
|
||||||
<View className='flex flex-col space-y-4'>
|
<View className='flex flex-col space-y-4'>
|
||||||
{sections.map((section, index) => {
|
{sections.map((section, index) => {
|
||||||
|
// Render Streamystats sections after Continue Watching and Next Up
|
||||||
|
// When merged, they appear after index 0; otherwise after index 1
|
||||||
|
const streamystatsIndex = settings.mergeNextUpAndContinueWatching
|
||||||
|
? 0
|
||||||
|
: 1;
|
||||||
|
const hasStreamystatsContent =
|
||||||
|
settings.streamyStatsMovieRecommendations ||
|
||||||
|
settings.streamyStatsSeriesRecommendations ||
|
||||||
|
settings.streamyStatsPromotedWatchlists;
|
||||||
|
const streamystatsSections =
|
||||||
|
index === streamystatsIndex && hasStreamystatsContent ? (
|
||||||
|
<>
|
||||||
|
{settings.streamyStatsMovieRecommendations && (
|
||||||
|
<StreamystatsRecommendations
|
||||||
|
title={t(
|
||||||
|
"home.settings.plugins.streamystats.recommended_movies",
|
||||||
|
)}
|
||||||
|
type='Movie'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{settings.streamyStatsSeriesRecommendations && (
|
||||||
|
<StreamystatsRecommendations
|
||||||
|
title={t(
|
||||||
|
"home.settings.plugins.streamystats.recommended_series",
|
||||||
|
)}
|
||||||
|
type='Series'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{settings.streamyStatsPromotedWatchlists && (
|
||||||
|
<StreamystatsPromotedWatchlists />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : null;
|
||||||
|
|
||||||
if (section.type === "InfiniteScrollingCollectionList") {
|
if (section.type === "InfiniteScrollingCollectionList") {
|
||||||
return (
|
return (
|
||||||
<InfiniteScrollingCollectionList
|
<View key={index} className='flex flex-col space-y-4'>
|
||||||
key={index}
|
<InfiniteScrollingCollectionList
|
||||||
title={section.title}
|
title={section.title}
|
||||||
queryKey={section.queryKey}
|
queryKey={section.queryKey}
|
||||||
queryFn={section.queryFn}
|
queryFn={section.queryFn}
|
||||||
orientation={section.orientation}
|
orientation={section.orientation}
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
pageSize={section.pageSize}
|
pageSize={section.pageSize}
|
||||||
/>
|
/>
|
||||||
|
{streamystatsSections}
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (section.type === "MediaListSection") {
|
if (section.type === "MediaListSection") {
|
||||||
return (
|
return (
|
||||||
<MediaListSection
|
<View key={index} className='flex flex-col space-y-4'>
|
||||||
key={index}
|
<MediaListSection
|
||||||
queryKey={section.queryKey}
|
queryKey={section.queryKey}
|
||||||
queryFn={section.queryFn}
|
queryFn={section.queryFn}
|
||||||
scrollY={scrollY}
|
scrollY={scrollY}
|
||||||
enableLazyLoading={true}
|
enableLazyLoading={true}
|
||||||
/>
|
/>
|
||||||
|
{streamystatsSections}
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
type QueryKey,
|
type QueryKey,
|
||||||
useInfiniteQuery,
|
useInfiniteQuery,
|
||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
@@ -11,6 +12,7 @@ import {
|
|||||||
View,
|
View,
|
||||||
type ViewProps,
|
type ViewProps,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
|
import { SectionHeader } from "@/components/common/SectionHeader";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
import { Colors } from "../../constants/Colors";
|
import { Colors } from "../../constants/Colors";
|
||||||
@@ -27,6 +29,9 @@ interface Props extends ViewProps {
|
|||||||
queryFn: QueryFunction<BaseItemDto[], QueryKey, number>;
|
queryFn: QueryFunction<BaseItemDto[], QueryKey, number>;
|
||||||
hideIfEmpty?: boolean;
|
hideIfEmpty?: boolean;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
|
onPressSeeAll?: () => void;
|
||||||
|
enabled?: boolean;
|
||||||
|
onLoaded?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||||
@@ -37,32 +42,72 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
queryKey,
|
queryKey,
|
||||||
hideIfEmpty = false,
|
hideIfEmpty = false,
|
||||||
pageSize = 10,
|
pageSize = 10,
|
||||||
|
onPressSeeAll,
|
||||||
|
enabled = true,
|
||||||
|
onLoaded,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
|
const effectivePageSize = Math.max(1, pageSize);
|
||||||
useInfiniteQuery({
|
const hasCalledOnLoaded = useRef(false);
|
||||||
queryKey: queryKey,
|
const {
|
||||||
queryFn: ({ pageParam = 0, ...context }) =>
|
data,
|
||||||
queryFn({ ...context, queryKey, pageParam }),
|
isLoading,
|
||||||
getNextPageParam: (lastPage, allPages) => {
|
isFetchingNextPage,
|
||||||
// If the last page has fewer items than pageSize, we've reached the end
|
hasNextPage,
|
||||||
if (lastPage.length < pageSize) {
|
fetchNextPage,
|
||||||
return undefined;
|
isSuccess,
|
||||||
}
|
} = useInfiniteQuery({
|
||||||
// Otherwise, return the next start index
|
queryKey: queryKey,
|
||||||
return allPages.length * pageSize;
|
queryFn: ({ pageParam = 0, ...context }) =>
|
||||||
},
|
queryFn({ ...context, queryKey, pageParam }),
|
||||||
initialPageParam: 0,
|
getNextPageParam: (lastPage, allPages) => {
|
||||||
staleTime: 60 * 1000, // 1 minute
|
// If the last page has fewer items than pageSize, we've reached the end
|
||||||
refetchOnMount: false,
|
if (lastPage.length < effectivePageSize) {
|
||||||
refetchOnWindowFocus: false,
|
return undefined;
|
||||||
refetchOnReconnect: true,
|
}
|
||||||
});
|
// Otherwise, return the next start index based on how many items we already loaded.
|
||||||
|
// This avoids overlaps if the server/page size differs from our configured page size.
|
||||||
|
return allPages.reduce((acc, page) => acc + page.length, 0);
|
||||||
|
},
|
||||||
|
initialPageParam: 0,
|
||||||
|
staleTime: 60 * 1000, // 1 minute
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnReconnect: true,
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notify parent when data has loaded
|
||||||
|
useEffect(() => {
|
||||||
|
if (isSuccess && !hasCalledOnLoaded.current && onLoaded) {
|
||||||
|
hasCalledOnLoaded.current = true;
|
||||||
|
onLoaded();
|
||||||
|
}
|
||||||
|
}, [isSuccess, onLoaded]);
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// Flatten all pages into a single array
|
// Flatten all pages into a single array (and de-dupe by Id to avoid UI duplicates)
|
||||||
const allItems = data?.pages.flat() || [];
|
const allItems = useMemo(() => {
|
||||||
|
const items = data?.pages.flat() ?? [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const deduped: BaseItemDto[] = [];
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const id = item.Id;
|
||||||
|
if (!id) continue;
|
||||||
|
if (seen.has(id)) continue;
|
||||||
|
seen.add(id);
|
||||||
|
deduped.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return deduped;
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const snapOffsets = useMemo(() => {
|
||||||
|
const itemWidth = orientation === "horizontal" ? 184 : 120; // w-44 (176px) + mr-2 (8px) or w-28 (112px) + mr-2 (8px)
|
||||||
|
return allItems.map((_, index) => index * itemWidth);
|
||||||
|
}, [allItems, orientation]);
|
||||||
|
|
||||||
if (hideIfEmpty === true && allItems.length === 0 && !isLoading) return null;
|
if (hideIfEmpty === true && allItems.length === 0 && !isLoading) return null;
|
||||||
if (disabled || !title) return null;
|
if (disabled || !title) return null;
|
||||||
@@ -84,9 +129,12 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<Text className='px-4 text-lg font-bold mb-2 text-neutral-100'>
|
<SectionHeader
|
||||||
{title}
|
title={title}
|
||||||
</Text>
|
actionLabel={t("common.seeAll", { defaultValue: "See all" })}
|
||||||
|
actionDisabled={isLoading}
|
||||||
|
onPressAction={onPressSeeAll}
|
||||||
|
/>
|
||||||
{isLoading === false && allItems.length === 0 && (
|
{isLoading === false && allItems.length === 0 && (
|
||||||
<View className='px-4'>
|
<View className='px-4'>
|
||||||
<Text className='text-neutral-500'>{t("home.no_items")}</Text>
|
<Text className='text-neutral-500'>{t("home.no_items")}</Text>
|
||||||
@@ -126,13 +174,15 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
scrollEventThrottle={16}
|
scrollEventThrottle={16}
|
||||||
|
snapToOffsets={snapOffsets}
|
||||||
|
decelerationRate='fast'
|
||||||
>
|
>
|
||||||
<View className='px-4 flex flex-row'>
|
<View className='px-4 flex flex-row'>
|
||||||
{allItems.map((item) => (
|
{allItems.map((item, index) => (
|
||||||
<TouchableItemRouter
|
<TouchableItemRouter
|
||||||
item={item}
|
item={item}
|
||||||
key={item.Id}
|
key={`${item.Id}-${index}`}
|
||||||
className={`mr-2
|
className={`mr-2
|
||||||
${orientation === "horizontal" ? "w-44" : "w-28"}
|
${orientation === "horizontal" ? "w-44" : "w-28"}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
|
|||||||
245
components/home/StreamystatsPromotedWatchlists.tsx
Normal file
245
components/home/StreamystatsPromotedWatchlists.tsx
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
import type {
|
||||||
|
BaseItemDto,
|
||||||
|
PublicSystemInfo,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getItemsApi, getSystemApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { ScrollView, View, type ViewProps } from "react-native";
|
||||||
|
import { SectionHeader } from "@/components/common/SectionHeader";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { createStreamystatsApi } from "@/utils/streamystats/api";
|
||||||
|
import type { StreamystatsWatchlist } from "@/utils/streamystats/types";
|
||||||
|
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||||
|
import { ItemCardText } from "../ItemCardText";
|
||||||
|
import SeriesPoster from "../posters/SeriesPoster";
|
||||||
|
|
||||||
|
const ITEM_WIDTH = 120; // w-28 (112px) + mr-2 (8px)
|
||||||
|
|
||||||
|
interface WatchlistSectionProps extends ViewProps {
|
||||||
|
watchlist: StreamystatsWatchlist;
|
||||||
|
jellyfinServerId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WatchlistSection: React.FC<WatchlistSectionProps> = ({
|
||||||
|
watchlist,
|
||||||
|
jellyfinServerId,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
|
const { settings } = useSettings();
|
||||||
|
|
||||||
|
const { data: items, isLoading } = useQuery({
|
||||||
|
queryKey: [
|
||||||
|
"streamystats",
|
||||||
|
"watchlist",
|
||||||
|
watchlist.id,
|
||||||
|
jellyfinServerId,
|
||||||
|
settings?.streamyStatsServerUrl,
|
||||||
|
],
|
||||||
|
queryFn: async (): Promise<BaseItemDto[]> => {
|
||||||
|
if (!settings?.streamyStatsServerUrl || !api?.accessToken || !user?.Id) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const streamystatsApi = createStreamystatsApi({
|
||||||
|
serverUrl: settings.streamyStatsServerUrl,
|
||||||
|
jellyfinToken: api.accessToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
const watchlistDetail = await streamystatsApi.getWatchlistItemIds({
|
||||||
|
watchlistId: watchlist.id,
|
||||||
|
jellyfinServerId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const itemIds = watchlistDetail.data?.items;
|
||||||
|
if (!itemIds?.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await getItemsApi(api).getItems({
|
||||||
|
userId: user.Id,
|
||||||
|
ids: itemIds,
|
||||||
|
fields: ["PrimaryImageAspectRatio", "Genres"],
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data.Items || [];
|
||||||
|
},
|
||||||
|
enabled:
|
||||||
|
Boolean(settings?.streamyStatsServerUrl) &&
|
||||||
|
Boolean(api?.accessToken) &&
|
||||||
|
Boolean(user?.Id),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const snapOffsets = useMemo(() => {
|
||||||
|
return items?.map((_, index) => index * ITEM_WIDTH) ?? [];
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
|
if (!isLoading && (!items || items.length === 0)) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View {...props}>
|
||||||
|
<SectionHeader title={watchlist.name} />
|
||||||
|
{isLoading ? (
|
||||||
|
<View className='flex flex-row gap-2 px-4'>
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<View className='w-28' key={i}>
|
||||||
|
<View className='bg-neutral-900 aspect-[2/3] w-full rounded-md mb-1' />
|
||||||
|
<View className='rounded-md overflow-hidden mb-1 self-start'>
|
||||||
|
<Text
|
||||||
|
className='text-neutral-900 bg-neutral-900 rounded-md'
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
Loading...
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
snapToOffsets={snapOffsets}
|
||||||
|
decelerationRate='fast'
|
||||||
|
>
|
||||||
|
<View className='px-4 flex flex-row'>
|
||||||
|
{items?.map((item) => (
|
||||||
|
<TouchableItemRouter
|
||||||
|
item={item}
|
||||||
|
key={item.Id}
|
||||||
|
className='mr-2 w-28'
|
||||||
|
>
|
||||||
|
{item.Type === "Movie" && <MoviePoster item={item} />}
|
||||||
|
{item.Type === "Series" && <SeriesPoster item={item} />}
|
||||||
|
<ItemCardText item={item} />
|
||||||
|
</TouchableItemRouter>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface StreamystatsPromotedWatchlistsProps extends ViewProps {
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StreamystatsPromotedWatchlists: React.FC<
|
||||||
|
StreamystatsPromotedWatchlistsProps
|
||||||
|
> = ({ enabled = true, ...props }) => {
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
|
const { settings } = useSettings();
|
||||||
|
|
||||||
|
const streamyStatsEnabled = useMemo(() => {
|
||||||
|
return Boolean(settings?.streamyStatsServerUrl);
|
||||||
|
}, [settings?.streamyStatsServerUrl]);
|
||||||
|
|
||||||
|
// Fetch server info to get the Jellyfin server ID
|
||||||
|
const { data: serverInfo } = useQuery({
|
||||||
|
queryKey: ["jellyfin", "serverInfo"],
|
||||||
|
queryFn: async (): Promise<PublicSystemInfo | null> => {
|
||||||
|
if (!api) return null;
|
||||||
|
const response = await getSystemApi(api).getPublicSystemInfo();
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
enabled: enabled && Boolean(api) && streamyStatsEnabled,
|
||||||
|
staleTime: 60 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const jellyfinServerId = serverInfo?.Id;
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: watchlists,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: [
|
||||||
|
"streamystats",
|
||||||
|
"promotedWatchlists",
|
||||||
|
jellyfinServerId,
|
||||||
|
settings?.streamyStatsServerUrl,
|
||||||
|
],
|
||||||
|
queryFn: async (): Promise<StreamystatsWatchlist[]> => {
|
||||||
|
if (
|
||||||
|
!settings?.streamyStatsServerUrl ||
|
||||||
|
!api?.accessToken ||
|
||||||
|
!jellyfinServerId
|
||||||
|
) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const streamystatsApi = createStreamystatsApi({
|
||||||
|
serverUrl: settings.streamyStatsServerUrl,
|
||||||
|
jellyfinToken: api.accessToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await streamystatsApi.getPromotedWatchlists({
|
||||||
|
jellyfinServerId,
|
||||||
|
includePreview: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data || [];
|
||||||
|
},
|
||||||
|
enabled:
|
||||||
|
enabled &&
|
||||||
|
streamyStatsEnabled &&
|
||||||
|
Boolean(api?.accessToken) &&
|
||||||
|
Boolean(jellyfinServerId) &&
|
||||||
|
Boolean(user?.Id),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!streamyStatsEnabled) return null;
|
||||||
|
if (isError) return null;
|
||||||
|
if (!isLoading && (!watchlists || watchlists.length === 0)) return null;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View {...props}>
|
||||||
|
<View className='h-4 w-32 bg-neutral-900 rounded ml-4 mb-2' />
|
||||||
|
<View className='flex flex-row gap-2 px-4'>
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<View className='w-28' key={i}>
|
||||||
|
<View className='bg-neutral-900 aspect-[2/3] w-full rounded-md mb-1' />
|
||||||
|
<View className='rounded-md overflow-hidden mb-1 self-start'>
|
||||||
|
<Text
|
||||||
|
className='text-neutral-900 bg-neutral-900 rounded-md'
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
Loading...
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{watchlists?.map((watchlist) => (
|
||||||
|
<WatchlistSection
|
||||||
|
key={watchlist.id}
|
||||||
|
watchlist={watchlist}
|
||||||
|
jellyfinServerId={jellyfinServerId!}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
197
components/home/StreamystatsRecommendations.tsx
Normal file
197
components/home/StreamystatsRecommendations.tsx
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import type {
|
||||||
|
BaseItemDto,
|
||||||
|
PublicSystemInfo,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getItemsApi, getSystemApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { ScrollView, View, type ViewProps } from "react-native";
|
||||||
|
|
||||||
|
import { SectionHeader } from "@/components/common/SectionHeader";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { createStreamystatsApi } from "@/utils/streamystats/api";
|
||||||
|
import type { StreamystatsRecommendationsIdsResponse } from "@/utils/streamystats/types";
|
||||||
|
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||||
|
import { ItemCardText } from "../ItemCardText";
|
||||||
|
import SeriesPoster from "../posters/SeriesPoster";
|
||||||
|
|
||||||
|
const ITEM_WIDTH = 120; // w-28 (112px) + mr-2 (8px)
|
||||||
|
|
||||||
|
interface Props extends ViewProps {
|
||||||
|
title: string;
|
||||||
|
type: "Movie" | "Series";
|
||||||
|
limit?: number;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StreamystatsRecommendations: React.FC<Props> = ({
|
||||||
|
title,
|
||||||
|
type,
|
||||||
|
limit = 20,
|
||||||
|
enabled = true,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
|
const { settings } = useSettings();
|
||||||
|
|
||||||
|
const streamyStatsEnabled = useMemo(() => {
|
||||||
|
return Boolean(settings?.streamyStatsServerUrl);
|
||||||
|
}, [settings?.streamyStatsServerUrl]);
|
||||||
|
|
||||||
|
// Fetch server info to get the Jellyfin server ID
|
||||||
|
const { data: serverInfo } = useQuery({
|
||||||
|
queryKey: ["jellyfin", "serverInfo"],
|
||||||
|
queryFn: async (): Promise<PublicSystemInfo | null> => {
|
||||||
|
if (!api) return null;
|
||||||
|
const response = await getSystemApi(api).getPublicSystemInfo();
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
enabled: enabled && Boolean(api) && streamyStatsEnabled,
|
||||||
|
staleTime: 60 * 60 * 1000, // 1 hour - server info rarely changes
|
||||||
|
});
|
||||||
|
|
||||||
|
const jellyfinServerId = serverInfo?.Id;
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: recommendationIds,
|
||||||
|
isLoading: isLoadingRecommendations,
|
||||||
|
isError: isRecommendationsError,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: [
|
||||||
|
"streamystats",
|
||||||
|
"recommendations",
|
||||||
|
type,
|
||||||
|
jellyfinServerId,
|
||||||
|
settings?.streamyStatsServerUrl,
|
||||||
|
],
|
||||||
|
queryFn: async (): Promise<string[]> => {
|
||||||
|
if (
|
||||||
|
!settings?.streamyStatsServerUrl ||
|
||||||
|
!api?.accessToken ||
|
||||||
|
!jellyfinServerId
|
||||||
|
) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const streamyStatsApi = createStreamystatsApi({
|
||||||
|
serverUrl: settings.streamyStatsServerUrl,
|
||||||
|
jellyfinToken: api.accessToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await streamyStatsApi.getRecommendationIds(
|
||||||
|
jellyfinServerId,
|
||||||
|
type,
|
||||||
|
limit,
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = response as StreamystatsRecommendationsIdsResponse;
|
||||||
|
|
||||||
|
if (type === "Movie") {
|
||||||
|
return data.data.movies || [];
|
||||||
|
}
|
||||||
|
return data.data.series || [];
|
||||||
|
},
|
||||||
|
enabled:
|
||||||
|
enabled &&
|
||||||
|
streamyStatsEnabled &&
|
||||||
|
Boolean(api?.accessToken) &&
|
||||||
|
Boolean(jellyfinServerId) &&
|
||||||
|
Boolean(user?.Id),
|
||||||
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: items,
|
||||||
|
isLoading: isLoadingItems,
|
||||||
|
isError: isItemsError,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: [
|
||||||
|
"streamystats",
|
||||||
|
"recommendations",
|
||||||
|
"items",
|
||||||
|
type,
|
||||||
|
recommendationIds,
|
||||||
|
],
|
||||||
|
queryFn: async (): Promise<BaseItemDto[]> => {
|
||||||
|
if (!api || !user?.Id || !recommendationIds?.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await getItemsApi(api).getItems({
|
||||||
|
userId: user.Id,
|
||||||
|
ids: recommendationIds,
|
||||||
|
fields: ["PrimaryImageAspectRatio", "Genres"],
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data.Items || [];
|
||||||
|
},
|
||||||
|
enabled:
|
||||||
|
Boolean(recommendationIds?.length) && Boolean(api) && Boolean(user?.Id),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isLoading = isLoadingRecommendations || isLoadingItems;
|
||||||
|
const isError = isRecommendationsError || isItemsError;
|
||||||
|
|
||||||
|
const snapOffsets = useMemo(() => {
|
||||||
|
return items?.map((_, index) => index * ITEM_WIDTH) ?? [];
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
|
if (!streamyStatsEnabled) return null;
|
||||||
|
if (isError) return null;
|
||||||
|
if (!isLoading && (!items || items.length === 0)) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View {...props}>
|
||||||
|
<SectionHeader title={title} />
|
||||||
|
{isLoading ? (
|
||||||
|
<View className='flex flex-row gap-2 px-4'>
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<View className='w-28' key={i}>
|
||||||
|
<View className='bg-neutral-900 aspect-[2/3] w-full rounded-md mb-1' />
|
||||||
|
<View className='rounded-md overflow-hidden mb-1 self-start'>
|
||||||
|
<Text
|
||||||
|
className='text-neutral-900 bg-neutral-900 rounded-md'
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
Loading title...
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
snapToOffsets={snapOffsets}
|
||||||
|
decelerationRate='fast'
|
||||||
|
>
|
||||||
|
<View className='px-4 flex flex-row'>
|
||||||
|
{items?.map((item) => (
|
||||||
|
<TouchableItemRouter
|
||||||
|
item={item}
|
||||||
|
key={item.Id}
|
||||||
|
className='mr-2 w-28'
|
||||||
|
>
|
||||||
|
{item.Type === "Movie" && <MoviePoster item={item} />}
|
||||||
|
{item.Type === "Series" && <SeriesPoster item={item} />}
|
||||||
|
<ItemCardText item={item} />
|
||||||
|
</TouchableItemRouter>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
80
components/item/ItemPeopleSections.tsx
Normal file
80
components/item/ItemPeopleSections.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import type {
|
||||||
|
BaseItemDto,
|
||||||
|
BaseItemPerson,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import type React from "react";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { InteractionManager, View, type ViewProps } from "react-native";
|
||||||
|
import { MoreMoviesWithActor } from "@/components/MoreMoviesWithActor";
|
||||||
|
import { CastAndCrew } from "@/components/series/CastAndCrew";
|
||||||
|
import { useItemPeopleQuery } from "@/hooks/useItemPeopleQuery";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {
|
||||||
|
item: BaseItemDto;
|
||||||
|
isOffline: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ItemPeopleSections: React.FC<Props> = ({
|
||||||
|
item,
|
||||||
|
isOffline,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const [enabled, setEnabled] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOffline) return;
|
||||||
|
const task = InteractionManager.runAfterInteractions(() =>
|
||||||
|
setEnabled(true),
|
||||||
|
);
|
||||||
|
return () => task.cancel();
|
||||||
|
}, [isOffline]);
|
||||||
|
|
||||||
|
const { data, isLoading } = useItemPeopleQuery(
|
||||||
|
item.Id,
|
||||||
|
enabled && !isOffline,
|
||||||
|
);
|
||||||
|
|
||||||
|
const people = useMemo(() => (Array.isArray(data) ? data : []), [data]);
|
||||||
|
|
||||||
|
const itemWithPeople = useMemo(() => {
|
||||||
|
return { ...item, People: people } as BaseItemDto;
|
||||||
|
}, [item, people]);
|
||||||
|
|
||||||
|
const topPeople = useMemo(() => people.slice(0, 3), [people]);
|
||||||
|
|
||||||
|
const renderActorSection = useCallback(
|
||||||
|
(person: BaseItemPerson, idx: number, total: number) => {
|
||||||
|
if (!person.Id) return null;
|
||||||
|
|
||||||
|
const spacingClassName = idx === total - 1 ? undefined : "mb-2";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MoreMoviesWithActor
|
||||||
|
key={person.Id}
|
||||||
|
currentItem={item}
|
||||||
|
actorId={person.Id}
|
||||||
|
actorName={person.Name}
|
||||||
|
className={spacingClassName}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[item],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isOffline || !enabled) return null;
|
||||||
|
|
||||||
|
const shouldSpaceCastAndCrew = topPeople.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View {...props}>
|
||||||
|
<CastAndCrew
|
||||||
|
item={itemWithPeople}
|
||||||
|
loading={isLoading}
|
||||||
|
className={shouldSpaceCastAndCrew ? "mb-2" : undefined}
|
||||||
|
/>
|
||||||
|
{topPeople.map((person, idx) =>
|
||||||
|
renderActorSection(person, idx, topPeople.length),
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
21
components/jellyseerr/GridSkeleton.tsx
Normal file
21
components/jellyseerr/GridSkeleton.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { View } from "react-native";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
// Dev note might be a good idea to standardize skeletons across the app and have one "file" for it.
|
||||||
|
export const GridSkeleton: React.FC<Props> = ({ index }) => {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={index}
|
||||||
|
className='flex flex-col mr-2 h-auto'
|
||||||
|
style={{ width: "30.5%" }}
|
||||||
|
>
|
||||||
|
<View className='relative rounded-lg overflow-hidden border border-neutral-900 w-full mt-4 aspect-[10/15] bg-neutral-800' />
|
||||||
|
<View className='mt-2 flex flex-col w-full'>
|
||||||
|
<View className='h-4 bg-neutral-800 rounded mb-1' />
|
||||||
|
<View className='h-3 bg-neutral-800 rounded w-1/2' />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -11,6 +11,7 @@ import { Animated, View, type ViewProps } 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 { ParallaxScrollView } from "@/components/ParallaxPage";
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
|
import { GridSkeleton } from "./GridSkeleton";
|
||||||
|
|
||||||
const ANIMATION_ENTER = 250;
|
const ANIMATION_ENTER = 250;
|
||||||
const ANIMATION_EXIT = 250;
|
const ANIMATION_EXIT = 250;
|
||||||
@@ -28,6 +29,7 @@ interface Props<T> {
|
|||||||
renderItem: (item: T, index: number) => Render;
|
renderItem: (item: T, index: number) => Render;
|
||||||
keyExtractor: (item: T) => string;
|
keyExtractor: (item: T) => string;
|
||||||
onEndReached?: (() => void) | null | undefined;
|
onEndReached?: (() => void) | null | undefined;
|
||||||
|
isLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ParallaxSlideShow = <T,>({
|
const ParallaxSlideShow = <T,>({
|
||||||
@@ -40,6 +42,7 @@ const ParallaxSlideShow = <T,>({
|
|||||||
renderItem,
|
renderItem,
|
||||||
keyExtractor,
|
keyExtractor,
|
||||||
onEndReached,
|
onEndReached,
|
||||||
|
isLoading = false,
|
||||||
}: PropsWithChildren<Props<T> & ViewProps>) => {
|
}: PropsWithChildren<Props<T> & ViewProps>) => {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
@@ -124,27 +127,40 @@ const ParallaxSlideShow = <T,>({
|
|||||||
</View>
|
</View>
|
||||||
{MainContent?.()}
|
{MainContent?.()}
|
||||||
<View>
|
<View>
|
||||||
<FlashList
|
{isLoading ? (
|
||||||
data={data}
|
<View>
|
||||||
ListEmptyComponent={
|
|
||||||
<View className='flex flex-col items-center justify-center h-full'>
|
|
||||||
<Text className='font-bold text-xl text-neutral-500'>
|
|
||||||
No results
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
|
||||||
ListHeaderComponent={
|
|
||||||
<Text className='text-lg font-bold my-2'>{listHeader}</Text>
|
<Text className='text-lg font-bold my-2'>{listHeader}</Text>
|
||||||
}
|
<View className='px-4'>
|
||||||
nestedScrollEnabled
|
<View className='flex flex-row flex-wrap'>
|
||||||
showsVerticalScrollIndicator={false}
|
{Array.from({ length: 9 }, (_, i) => (
|
||||||
//@ts-expect-error
|
<GridSkeleton key={i} index={i} />
|
||||||
renderItem={({ item, index }) => renderItem(item, index)}
|
))}
|
||||||
keyExtractor={keyExtractor}
|
</View>
|
||||||
numColumns={3}
|
</View>
|
||||||
ItemSeparatorComponent={() => <View className='h-2 w-2' />}
|
</View>
|
||||||
/>
|
) : (
|
||||||
|
<FlashList
|
||||||
|
data={data}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View className='flex flex-col items-center justify-center h-full'>
|
||||||
|
<Text className='font-bold text-xl text-neutral-500'>
|
||||||
|
No results
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
|
ListHeaderComponent={
|
||||||
|
<Text className='text-lg font-bold my-2'>{listHeader}</Text>
|
||||||
|
}
|
||||||
|
nestedScrollEnabled
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
//@ts-expect-error
|
||||||
|
renderItem={({ item, index }) => renderItem(item, index)}
|
||||||
|
keyExtractor={keyExtractor}
|
||||||
|
numColumns={3}
|
||||||
|
ItemSeparatorComponent={() => <View className='h-2 w-2' />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</ParallaxScrollView>
|
</ParallaxScrollView>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Text } from "../common/Text";
|
|||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
title?: string | null | undefined;
|
title?: string | null | undefined;
|
||||||
subtitle?: string | null | undefined;
|
subtitle?: string | null | undefined;
|
||||||
|
subtitleColor?: "default" | "red";
|
||||||
value?: string | null | undefined;
|
value?: string | null | undefined;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
iconAfter?: ReactNode;
|
iconAfter?: ReactNode;
|
||||||
@@ -14,6 +15,7 @@ interface Props extends ViewProps {
|
|||||||
textColor?: "default" | "blue" | "red";
|
textColor?: "default" | "blue" | "red";
|
||||||
onPress?: () => void;
|
onPress?: () => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
disabledByAdmin?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
||||||
@@ -27,21 +29,23 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
textColor = "default",
|
textColor = "default",
|
||||||
onPress,
|
onPress,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
disabledByAdmin = false,
|
||||||
...viewProps
|
...viewProps
|
||||||
}) => {
|
}) => {
|
||||||
|
const effectiveSubtitle = disabledByAdmin ? "Disabled by admin" : subtitle;
|
||||||
|
const isDisabled = disabled || disabledByAdmin;
|
||||||
if (onPress)
|
if (onPress)
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
disabled={disabled}
|
disabled={isDisabled}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
className={`flex flex-row items-center justify-between bg-neutral-900 min-h-[42px] py-2 pr-4 pl-4 ${
|
className={`flex flex-row items-center justify-between bg-neutral-900 min-h-[42px] py-2 pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`}
|
||||||
disabled ? "opacity-50" : ""
|
|
||||||
}`}
|
|
||||||
{...(viewProps as any)}
|
{...(viewProps as any)}
|
||||||
>
|
>
|
||||||
<ListItemContent
|
<ListItemContent
|
||||||
title={title}
|
title={title}
|
||||||
subtitle={subtitle}
|
subtitle={effectiveSubtitle}
|
||||||
|
subtitleColor={disabledByAdmin ? "red" : undefined}
|
||||||
value={value}
|
value={value}
|
||||||
icon={icon}
|
icon={icon}
|
||||||
textColor={textColor}
|
textColor={textColor}
|
||||||
@@ -54,14 +58,13 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className={`flex flex-row items-center justify-between bg-neutral-900 min-h-[42px] py-2 pr-4 pl-4 ${
|
className={`flex flex-row items-center justify-between bg-neutral-900 min-h-[42px] py-2 pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`}
|
||||||
disabled ? "opacity-50" : ""
|
|
||||||
}`}
|
|
||||||
{...viewProps}
|
{...viewProps}
|
||||||
>
|
>
|
||||||
<ListItemContent
|
<ListItemContent
|
||||||
title={title}
|
title={title}
|
||||||
subtitle={subtitle}
|
subtitle={effectiveSubtitle}
|
||||||
|
subtitleColor={disabledByAdmin ? "red" : undefined}
|
||||||
value={value}
|
value={value}
|
||||||
icon={icon}
|
icon={icon}
|
||||||
textColor={textColor}
|
textColor={textColor}
|
||||||
@@ -77,6 +80,7 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
const ListItemContent = ({
|
const ListItemContent = ({
|
||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
|
subtitleColor,
|
||||||
textColor,
|
textColor,
|
||||||
icon,
|
icon,
|
||||||
value,
|
value,
|
||||||
@@ -107,7 +111,7 @@ const ListItemContent = ({
|
|||||||
</Text>
|
</Text>
|
||||||
{subtitle && (
|
{subtitle && (
|
||||||
<Text
|
<Text
|
||||||
className='text-[#9899A1] text-[12px] mt-0.5'
|
className={`text-[12px] mt-0.5 ${subtitleColor === "red" ? "text-red-600" : "text-[#9899A1]"}`}
|
||||||
numberOfLines={2}
|
numberOfLines={2}
|
||||||
>
|
>
|
||||||
{subtitle}
|
{subtitle}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
useQuery,
|
useQuery,
|
||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { View, type ViewProps } from "react-native";
|
import { View, type ViewProps } from "react-native";
|
||||||
import { useInView } from "@/hooks/useInView";
|
import { useInView } from "@/hooks/useInView";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
@@ -67,6 +67,12 @@ export const MediaListSection: React.FC<Props> = ({
|
|||||||
[api, user?.Id, collection?.Id],
|
[api, user?.Id, collection?.Id],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const snapOffsets = useMemo(() => {
|
||||||
|
const itemWidth = 120; // w-28 (112px) + mr-2 (8px)
|
||||||
|
// Generate offsets for a reasonable number of items
|
||||||
|
return Array.from({ length: 50 }, (_, index) => index * itemWidth);
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (!collection) return null;
|
if (!collection) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -92,6 +98,8 @@ export const MediaListSection: React.FC<Props> = ({
|
|||||||
)}
|
)}
|
||||||
queryFn={fetchItems}
|
queryFn={fetchItems}
|
||||||
queryKey={["media-list", collection.Id!]}
|
queryKey={["media-list", collection.Id!]}
|
||||||
|
snapToOffsets={snapOffsets}
|
||||||
|
decelerationRate='fast'
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
157
components/music/CreatePlaylistModal.tsx
Normal file
157
components/music/CreatePlaylistModal.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import {
|
||||||
|
BottomSheetBackdrop,
|
||||||
|
type BottomSheetBackdropProps,
|
||||||
|
BottomSheetModal,
|
||||||
|
BottomSheetTextInput,
|
||||||
|
BottomSheetView,
|
||||||
|
} from "@gorhom/bottom-sheet";
|
||||||
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ActivityIndicator, Keyboard } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Button } from "@/components/Button";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useCreatePlaylist } from "@/hooks/usePlaylistMutations";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
onPlaylistCreated?: (playlistId: string) => void;
|
||||||
|
initialTrackId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CreatePlaylistModal: React.FC<Props> = ({
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
onPlaylistCreated,
|
||||||
|
initialTrackId,
|
||||||
|
}) => {
|
||||||
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const createPlaylist = useCreatePlaylist();
|
||||||
|
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const snapPoints = useMemo(() => ["40%"], []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setName("");
|
||||||
|
bottomSheetModalRef.current?.present();
|
||||||
|
} else {
|
||||||
|
bottomSheetModalRef.current?.dismiss();
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const handleSheetChanges = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
if (index === -1) {
|
||||||
|
setOpen(false);
|
||||||
|
Keyboard.dismiss();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setOpen],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderBackdrop = useCallback(
|
||||||
|
(props: BottomSheetBackdropProps) => (
|
||||||
|
<BottomSheetBackdrop
|
||||||
|
{...props}
|
||||||
|
disappearsOnIndex={-1}
|
||||||
|
appearsOnIndex={0}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreate = useCallback(async () => {
|
||||||
|
if (!name.trim()) return;
|
||||||
|
|
||||||
|
const result = await createPlaylist.mutateAsync({
|
||||||
|
name: name.trim(),
|
||||||
|
trackIds: initialTrackId ? [initialTrackId] : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
onPlaylistCreated?.(result);
|
||||||
|
}
|
||||||
|
setOpen(false);
|
||||||
|
}, [name, createPlaylist, initialTrackId, onPlaylistCreated, setOpen]);
|
||||||
|
|
||||||
|
const isValid = name.trim().length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BottomSheetModal
|
||||||
|
ref={bottomSheetModalRef}
|
||||||
|
index={0}
|
||||||
|
snapPoints={snapPoints}
|
||||||
|
onChange={handleSheetChanges}
|
||||||
|
backdropComponent={renderBackdrop}
|
||||||
|
handleIndicatorStyle={{
|
||||||
|
backgroundColor: "white",
|
||||||
|
}}
|
||||||
|
backgroundStyle={{
|
||||||
|
backgroundColor: "#171717",
|
||||||
|
}}
|
||||||
|
keyboardBehavior='interactive'
|
||||||
|
keyboardBlurBehavior='restore'
|
||||||
|
>
|
||||||
|
<BottomSheetView
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
paddingLeft: Math.max(16, insets.left),
|
||||||
|
paddingRight: Math.max(16, insets.right),
|
||||||
|
paddingBottom: insets.bottom + 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className='font-bold text-2xl mb-6'>
|
||||||
|
{t("music.playlists.create_playlist")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text className='text-neutral-400 mb-2 text-sm'>
|
||||||
|
{t("music.playlists.playlist_name")}
|
||||||
|
</Text>
|
||||||
|
<BottomSheetTextInput
|
||||||
|
placeholder={t("music.playlists.enter_name")}
|
||||||
|
placeholderTextColor='#737373'
|
||||||
|
value={name}
|
||||||
|
onChangeText={setName}
|
||||||
|
autoFocus
|
||||||
|
returnKeyType='done'
|
||||||
|
onSubmitEditing={handleCreate}
|
||||||
|
style={{
|
||||||
|
backgroundColor: "#262626",
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 14,
|
||||||
|
fontSize: 16,
|
||||||
|
color: "white",
|
||||||
|
marginBottom: 24,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onPress={handleCreate}
|
||||||
|
disabled={!isValid || createPlaylist.isPending}
|
||||||
|
className={`py-4 rounded-xl ${isValid ? "bg-purple-600" : "bg-neutral-700"}`}
|
||||||
|
>
|
||||||
|
{createPlaylist.isPending ? (
|
||||||
|
<ActivityIndicator color='white' />
|
||||||
|
) : (
|
||||||
|
<Text
|
||||||
|
className={`text-center font-semibold ${isValid ? "text-white" : "text-neutral-500"}`}
|
||||||
|
>
|
||||||
|
{t("music.playlists.create")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</BottomSheetView>
|
||||||
|
</BottomSheetModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
359
components/music/MiniPlayerBar.tsx
Normal file
359
components/music/MiniPlayerBar.tsx
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import React, { useCallback, useMemo } from "react";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Platform,
|
||||||
|
StyleSheet,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||||
|
import { GlassEffectView } from "react-native-glass-effect-view";
|
||||||
|
import Animated, {
|
||||||
|
Easing,
|
||||||
|
Extrapolation,
|
||||||
|
interpolate,
|
||||||
|
runOnJS,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withTiming,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
|
||||||
|
|
||||||
|
const HORIZONTAL_MARGIN = Platform.OS === "android" ? 8 : 16;
|
||||||
|
const BOTTOM_TAB_HEIGHT = Platform.OS === "android" ? 56 : 52;
|
||||||
|
const BAR_HEIGHT = Platform.OS === "android" ? 58 : 50;
|
||||||
|
|
||||||
|
// Gesture thresholds
|
||||||
|
const VELOCITY_THRESHOLD = 1000;
|
||||||
|
|
||||||
|
// Logarithmic slowdown - never stops, just gets progressively slower
|
||||||
|
const rubberBand = (distance: number, scale: number = 8): number => {
|
||||||
|
"worklet";
|
||||||
|
const absDistance = Math.abs(distance);
|
||||||
|
const sign = distance < 0 ? -1 : 1;
|
||||||
|
// Logarithmic: keeps growing but slower and slower
|
||||||
|
return sign * scale * Math.log(1 + absDistance / scale);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MiniPlayerBar: React.FC = () => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const router = useRouter();
|
||||||
|
const {
|
||||||
|
currentTrack,
|
||||||
|
isPlaying,
|
||||||
|
isLoading,
|
||||||
|
progress,
|
||||||
|
duration,
|
||||||
|
togglePlayPause,
|
||||||
|
next,
|
||||||
|
stop,
|
||||||
|
} = useMusicPlayer();
|
||||||
|
|
||||||
|
// Gesture state
|
||||||
|
const translateY = useSharedValue(0);
|
||||||
|
|
||||||
|
const imageUrl = useMemo(() => {
|
||||||
|
if (!api || !currentTrack) return null;
|
||||||
|
const albumId = currentTrack.AlbumId || currentTrack.ParentId;
|
||||||
|
if (albumId) {
|
||||||
|
return `${api.basePath}/Items/${albumId}/Images/Primary?maxHeight=100&maxWidth=100`;
|
||||||
|
}
|
||||||
|
return `${api.basePath}/Items/${currentTrack.Id}/Images/Primary?maxHeight=100&maxWidth=100`;
|
||||||
|
}, [api, currentTrack]);
|
||||||
|
|
||||||
|
const _progressPercentage = useMemo(() => {
|
||||||
|
if (!duration || duration === 0) return 0;
|
||||||
|
return (progress / duration) * 100;
|
||||||
|
}, [progress, duration]);
|
||||||
|
|
||||||
|
const handlePress = useCallback(() => {
|
||||||
|
router.push("/(auth)/now-playing");
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const handlePlayPause = useCallback(
|
||||||
|
(e: any) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
togglePlayPause();
|
||||||
|
},
|
||||||
|
[togglePlayPause],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleNext = useCallback(
|
||||||
|
(e: any) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
next();
|
||||||
|
},
|
||||||
|
[next],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDismiss = useCallback(() => {
|
||||||
|
stop();
|
||||||
|
}, [stop]);
|
||||||
|
|
||||||
|
// Pan gesture for swipe up (open modal) and swipe down (dismiss)
|
||||||
|
const panGesture = Gesture.Pan()
|
||||||
|
.activeOffsetY([-15, 15])
|
||||||
|
.onUpdate((event) => {
|
||||||
|
// Logarithmic slowdown - keeps moving but progressively slower
|
||||||
|
translateY.value = rubberBand(event.translationY, 6);
|
||||||
|
})
|
||||||
|
.onEnd((event) => {
|
||||||
|
const velocity = event.velocityY;
|
||||||
|
const currentPosition = translateY.value;
|
||||||
|
|
||||||
|
// Swipe up - open modal (check position OR velocity)
|
||||||
|
if (currentPosition < -16 || velocity < -VELOCITY_THRESHOLD) {
|
||||||
|
// Slow return animation - won't jank with navigation
|
||||||
|
translateY.value = withTiming(0, {
|
||||||
|
duration: 600,
|
||||||
|
easing: Easing.out(Easing.cubic),
|
||||||
|
});
|
||||||
|
runOnJS(handlePress)();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Swipe down - stop playback and dismiss (check position OR velocity)
|
||||||
|
if (currentPosition > 16 || velocity > VELOCITY_THRESHOLD) {
|
||||||
|
// No need to reset - component will unmount
|
||||||
|
runOnJS(handleDismiss)();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only animate back if no action was triggered
|
||||||
|
translateY.value = withTiming(0, {
|
||||||
|
duration: 200,
|
||||||
|
easing: Easing.out(Easing.cubic),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Animated styles for the container
|
||||||
|
const animatedContainerStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ translateY: translateY.value }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Animated styles for the inner bar
|
||||||
|
const animatedBarStyle = useAnimatedStyle(() => ({
|
||||||
|
height: interpolate(
|
||||||
|
translateY.value,
|
||||||
|
[-50, 0, 50],
|
||||||
|
[BAR_HEIGHT + 12, BAR_HEIGHT, BAR_HEIGHT],
|
||||||
|
Extrapolation.EXTEND,
|
||||||
|
),
|
||||||
|
opacity: interpolate(
|
||||||
|
translateY.value,
|
||||||
|
[0, 30],
|
||||||
|
[1, 0.6],
|
||||||
|
Extrapolation.CLAMP,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!currentTrack) return null;
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
{/* Tappable area: Album art + Track info */}
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handlePress}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
style={styles.tappableArea}
|
||||||
|
>
|
||||||
|
{/* Album art */}
|
||||||
|
<View style={styles.albumArt}>
|
||||||
|
{imageUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: imageUrl }}
|
||||||
|
style={styles.albumImage}
|
||||||
|
contentFit='cover'
|
||||||
|
cachePolicy='memory-disk'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View style={styles.albumPlaceholder}>
|
||||||
|
<Ionicons name='musical-note' size={20} color='#888' />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Track info */}
|
||||||
|
<View style={styles.trackInfo}>
|
||||||
|
<Text numberOfLines={1} style={styles.trackTitle}>
|
||||||
|
{currentTrack.Name}
|
||||||
|
</Text>
|
||||||
|
<Text numberOfLines={1} style={styles.artistName}>
|
||||||
|
{currentTrack.Artists?.join(", ") || currentTrack.AlbumArtist}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<View style={styles.controls}>
|
||||||
|
{isLoading ? (
|
||||||
|
<ActivityIndicator size='small' color='white' style={styles.loader} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handlePlayPause}
|
||||||
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||||
|
style={styles.controlButton}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={isPlaying ? "pause" : "play"}
|
||||||
|
size={26}
|
||||||
|
color='white'
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleNext}
|
||||||
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||||
|
style={styles.controlButton}
|
||||||
|
>
|
||||||
|
<Ionicons name='play-forward' size={22} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Progress bar at bottom */}
|
||||||
|
{/* <View style={styles.progressContainer}>
|
||||||
|
<View
|
||||||
|
style={[styles.progressFill, { width: `${progressPercentage}%` }]}
|
||||||
|
/>
|
||||||
|
</View> */}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GestureDetector gesture={panGesture}>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.container,
|
||||||
|
{
|
||||||
|
bottom:
|
||||||
|
BOTTOM_TAB_HEIGHT +
|
||||||
|
insets.bottom +
|
||||||
|
(Platform.OS === "android" ? 32 : 4),
|
||||||
|
},
|
||||||
|
animatedContainerStyle,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Animated.View style={[styles.touchable, animatedBarStyle]}>
|
||||||
|
{Platform.OS === "ios" ? (
|
||||||
|
<GlassEffectView style={styles.blurContainer}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingRight: 10,
|
||||||
|
paddingLeft: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</View>
|
||||||
|
</GlassEffectView>
|
||||||
|
) : (
|
||||||
|
<View style={styles.androidContainer}>{content}</View>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
</Animated.View>
|
||||||
|
</GestureDetector>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
position: "absolute",
|
||||||
|
left: HORIZONTAL_MARGIN,
|
||||||
|
right: HORIZONTAL_MARGIN,
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.3,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 8,
|
||||||
|
},
|
||||||
|
touchable: {
|
||||||
|
borderRadius: 50,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
blurContainer: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
androidContainer: {
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 8,
|
||||||
|
backgroundColor: "rgba(28, 28, 30, 0.97)",
|
||||||
|
borderRadius: 14,
|
||||||
|
borderWidth: 0.5,
|
||||||
|
borderColor: "rgba(255, 255, 255, 0.1)",
|
||||||
|
},
|
||||||
|
tappableArea: {
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
albumArt: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 8,
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundColor: "#333",
|
||||||
|
},
|
||||||
|
albumImage: {
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
},
|
||||||
|
albumPlaceholder: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
backgroundColor: "#2a2a2a",
|
||||||
|
},
|
||||||
|
trackInfo: {
|
||||||
|
flex: 1,
|
||||||
|
marginLeft: 12,
|
||||||
|
marginRight: 8,
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
trackTitle: {
|
||||||
|
color: "white",
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
artistName: {
|
||||||
|
color: "rgba(255, 255, 255, 0.6)",
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
controls: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
controlButton: {
|
||||||
|
padding: 8,
|
||||||
|
},
|
||||||
|
loader: {
|
||||||
|
marginHorizontal: 16,
|
||||||
|
},
|
||||||
|
progressContainer: {
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 0,
|
||||||
|
left: 10,
|
||||||
|
right: 10,
|
||||||
|
height: 3,
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.15)",
|
||||||
|
borderRadius: 1.5,
|
||||||
|
},
|
||||||
|
progressFill: {
|
||||||
|
height: "100%",
|
||||||
|
backgroundColor: "white",
|
||||||
|
borderRadius: 1.5,
|
||||||
|
},
|
||||||
|
});
|
||||||
68
components/music/MusicAlbumCard.tsx
Normal file
68
components/music/MusicAlbumCard.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import React, { useCallback, useMemo } from "react";
|
||||||
|
import { TouchableOpacity, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
album: BaseItemDto;
|
||||||
|
width?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MusicAlbumCard: React.FC<Props> = ({ album, width = 150 }) => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const imageUrl = useMemo(
|
||||||
|
() => getPrimaryImageUrl({ api, item: album }),
|
||||||
|
[api, album],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePress = useCallback(() => {
|
||||||
|
router.push({
|
||||||
|
pathname: "/music/album/[albumId]",
|
||||||
|
params: { albumId: album.Id! },
|
||||||
|
});
|
||||||
|
}, [router, album.Id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handlePress}
|
||||||
|
style={{ width }}
|
||||||
|
className='flex flex-col'
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width,
|
||||||
|
height: width,
|
||||||
|
borderRadius: 8,
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{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'>
|
||||||
|
<Text className='text-4xl'>🎵</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text numberOfLines={1} className='text-white text-sm font-medium mt-2'>
|
||||||
|
{album.Name}
|
||||||
|
</Text>
|
||||||
|
<Text numberOfLines={1} className='text-neutral-400 text-xs'>
|
||||||
|
{album.AlbumArtist || album.Artists?.join(", ")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
68
components/music/MusicArtistCard.tsx
Normal file
68
components/music/MusicArtistCard.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import React, { useCallback, useMemo } from "react";
|
||||||
|
import { TouchableOpacity, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
artist: BaseItemDto;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MusicArtistCard: React.FC<Props> = ({ artist, size = 100 }) => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const imageUrl = useMemo(
|
||||||
|
() => getPrimaryImageUrl({ api, item: artist }),
|
||||||
|
[api, artist],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePress = useCallback(() => {
|
||||||
|
router.push({
|
||||||
|
pathname: "/music/artist/[artistId]",
|
||||||
|
params: { artistId: artist.Id! },
|
||||||
|
});
|
||||||
|
}, [router, artist.Id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handlePress}
|
||||||
|
style={{ width: size }}
|
||||||
|
className='flex flex-col items-center'
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
borderRadius: size / 2,
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{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'>
|
||||||
|
<Text className='text-3xl'>👤</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
numberOfLines={2}
|
||||||
|
className='text-white text-xs font-medium mt-2 text-center'
|
||||||
|
>
|
||||||
|
{artist.Name}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
173
components/music/MusicPlaybackEngine.tsx
Normal file
173
components/music/MusicPlaybackEngine.tsx
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import TrackPlayer, {
|
||||||
|
Event,
|
||||||
|
type PlaybackActiveTrackChangedEvent,
|
||||||
|
State,
|
||||||
|
useActiveTrack,
|
||||||
|
usePlaybackState,
|
||||||
|
useProgress,
|
||||||
|
} from "react-native-track-player";
|
||||||
|
import { audioStorageEvents, getLocalPath } from "@/providers/AudioStorage";
|
||||||
|
import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
|
||||||
|
|
||||||
|
export const MusicPlaybackEngine: React.FC = () => {
|
||||||
|
const { position, duration } = useProgress(1000);
|
||||||
|
const playbackState = usePlaybackState();
|
||||||
|
const activeTrack = useActiveTrack();
|
||||||
|
const {
|
||||||
|
setProgress,
|
||||||
|
setDuration,
|
||||||
|
setIsPlaying,
|
||||||
|
reportProgress,
|
||||||
|
onTrackEnd,
|
||||||
|
syncFromTrackPlayer,
|
||||||
|
triggerLookahead,
|
||||||
|
} = useMusicPlayer();
|
||||||
|
|
||||||
|
const lastReportedProgressRef = useRef(0);
|
||||||
|
|
||||||
|
// Sync progress from TrackPlayer to our state
|
||||||
|
useEffect(() => {
|
||||||
|
if (position > 0) {
|
||||||
|
setProgress(position);
|
||||||
|
}
|
||||||
|
}, [position, setProgress]);
|
||||||
|
|
||||||
|
// Sync duration from TrackPlayer to our state
|
||||||
|
useEffect(() => {
|
||||||
|
if (duration > 0) {
|
||||||
|
setDuration(duration);
|
||||||
|
}
|
||||||
|
}, [duration, setDuration]);
|
||||||
|
|
||||||
|
// Sync playback state from TrackPlayer to our state
|
||||||
|
useEffect(() => {
|
||||||
|
const isPlaying = playbackState.state === State.Playing;
|
||||||
|
setIsPlaying(isPlaying);
|
||||||
|
}, [playbackState.state, setIsPlaying]);
|
||||||
|
|
||||||
|
// Sync active track changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTrack) {
|
||||||
|
syncFromTrackPlayer();
|
||||||
|
}
|
||||||
|
}, [activeTrack?.id, syncFromTrackPlayer]);
|
||||||
|
|
||||||
|
// Report progress every ~10 seconds
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
Math.floor(position) - Math.floor(lastReportedProgressRef.current) >=
|
||||||
|
10
|
||||||
|
) {
|
||||||
|
lastReportedProgressRef.current = position;
|
||||||
|
reportProgress();
|
||||||
|
}
|
||||||
|
}, [position, reportProgress]);
|
||||||
|
|
||||||
|
// Listen for track changes (native -> JS)
|
||||||
|
// This triggers look-ahead caching, checks for cached versions, and handles track end
|
||||||
|
useEffect(() => {
|
||||||
|
const subscription =
|
||||||
|
TrackPlayer.addEventListener<PlaybackActiveTrackChangedEvent>(
|
||||||
|
Event.PlaybackActiveTrackChanged,
|
||||||
|
async (event) => {
|
||||||
|
// Trigger look-ahead caching when a new track starts playing
|
||||||
|
if (event.track) {
|
||||||
|
triggerLookahead();
|
||||||
|
|
||||||
|
// Check if there's a cached version we should use instead
|
||||||
|
const trackId = event.track.id;
|
||||||
|
const currentUrl = event.track.url as string;
|
||||||
|
|
||||||
|
// Only check if currently using a remote URL
|
||||||
|
if (trackId && currentUrl && !currentUrl.startsWith("file://")) {
|
||||||
|
const cachedPath = getLocalPath(trackId);
|
||||||
|
if (cachedPath) {
|
||||||
|
console.log(
|
||||||
|
`[AudioCache] Switching to cached version for ${trackId}`,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
// Load the cached version, preserving position if any
|
||||||
|
const currentIndex = await TrackPlayer.getActiveTrackIndex();
|
||||||
|
if (currentIndex !== undefined && currentIndex >= 0) {
|
||||||
|
const queue = await TrackPlayer.getQueue();
|
||||||
|
const track = queue[currentIndex];
|
||||||
|
// Remove and re-add with cached URL
|
||||||
|
await TrackPlayer.remove(currentIndex);
|
||||||
|
await TrackPlayer.add(
|
||||||
|
{ ...track, url: cachedPath },
|
||||||
|
currentIndex,
|
||||||
|
);
|
||||||
|
await TrackPlayer.skip(currentIndex);
|
||||||
|
await TrackPlayer.play();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
"[AudioCache] Failed to switch to cached version:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If there's no next track and the previous track ended, call onTrackEnd
|
||||||
|
if (event.lastTrack && !event.track) {
|
||||||
|
onTrackEnd();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => subscription.remove();
|
||||||
|
}, [onTrackEnd, triggerLookahead]);
|
||||||
|
|
||||||
|
// Listen for audio cache download completion and update queue URLs
|
||||||
|
useEffect(() => {
|
||||||
|
const onComplete = async ({
|
||||||
|
itemId,
|
||||||
|
localPath,
|
||||||
|
}: {
|
||||||
|
itemId: string;
|
||||||
|
localPath: string;
|
||||||
|
}) => {
|
||||||
|
console.log(`[AudioCache] Track ${itemId} cached successfully`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const queue = await TrackPlayer.getQueue();
|
||||||
|
const currentIndex = await TrackPlayer.getActiveTrackIndex();
|
||||||
|
|
||||||
|
// Find the track in the queue
|
||||||
|
const trackIndex = queue.findIndex((t) => t.id === itemId);
|
||||||
|
|
||||||
|
// Only update if track is in queue and not currently playing
|
||||||
|
if (trackIndex >= 0 && trackIndex !== currentIndex) {
|
||||||
|
const track = queue[trackIndex];
|
||||||
|
const localUrl = localPath.startsWith("file://")
|
||||||
|
? localPath
|
||||||
|
: `file://${localPath}`;
|
||||||
|
|
||||||
|
// Skip if already using local URL
|
||||||
|
if (track.url === localUrl) return;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[AudioCache] Updating queue track ${trackIndex} to use cached file`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Remove old track and insert updated one at same position
|
||||||
|
await TrackPlayer.remove(trackIndex);
|
||||||
|
await TrackPlayer.add({ ...track, url: localUrl }, trackIndex);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[AudioCache] Failed to update queue:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
audioStorageEvents.on("complete", onComplete);
|
||||||
|
return () => {
|
||||||
|
audioStorageEvents.off("complete", onComplete);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// No visual component needed - TrackPlayer is headless
|
||||||
|
return null;
|
||||||
|
};
|
||||||
71
components/music/MusicPlaylistCard.tsx
Normal file
71
components/music/MusicPlaylistCard.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import React, { useCallback, useMemo } from "react";
|
||||||
|
import { TouchableOpacity, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
playlist: BaseItemDto;
|
||||||
|
width?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MusicPlaylistCard: React.FC<Props> = ({
|
||||||
|
playlist,
|
||||||
|
width = 150,
|
||||||
|
}) => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const imageUrl = useMemo(
|
||||||
|
() => getPrimaryImageUrl({ api, item: playlist }),
|
||||||
|
[api, playlist],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePress = useCallback(() => {
|
||||||
|
router.push({
|
||||||
|
pathname: "/music/playlist/[playlistId]",
|
||||||
|
params: { playlistId: playlist.Id! },
|
||||||
|
});
|
||||||
|
}, [router, playlist.Id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handlePress}
|
||||||
|
style={{ width }}
|
||||||
|
className='flex flex-col'
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width,
|
||||||
|
height: width,
|
||||||
|
borderRadius: 8,
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{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'>
|
||||||
|
<Text className='text-4xl'>🎶</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text numberOfLines={1} className='text-white text-sm font-medium mt-2'>
|
||||||
|
{playlist.Name}
|
||||||
|
</Text>
|
||||||
|
<Text numberOfLines={1} className='text-neutral-400 text-xs'>
|
||||||
|
{playlist.ChildCount} tracks
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
218
components/music/MusicTrackItem.tsx
Normal file
218
components/music/MusicTrackItem.tsx
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
||||||
|
import {
|
||||||
|
audioStorageEvents,
|
||||||
|
getLocalPath,
|
||||||
|
isPermanentDownloading,
|
||||||
|
isPermanentlyDownloaded,
|
||||||
|
} from "@/providers/AudioStorage";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
|
||||||
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
import { formatDuration } from "@/utils/time";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
track: BaseItemDto;
|
||||||
|
index?: number;
|
||||||
|
queue?: BaseItemDto[];
|
||||||
|
showArtwork?: boolean;
|
||||||
|
onOptionsPress?: (track: BaseItemDto) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MusicTrackItem: React.FC<Props> = ({
|
||||||
|
track,
|
||||||
|
index,
|
||||||
|
queue,
|
||||||
|
showArtwork = true,
|
||||||
|
onOptionsPress,
|
||||||
|
}) => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const { playTrack, currentTrack, isPlaying, loadingTrackId } =
|
||||||
|
useMusicPlayer();
|
||||||
|
const { isConnected, serverConnected } = useNetworkStatus();
|
||||||
|
|
||||||
|
const imageUrl = useMemo(() => {
|
||||||
|
const albumId = track.AlbumId || track.ParentId;
|
||||||
|
if (albumId) {
|
||||||
|
return `${api?.basePath}/Items/${albumId}/Images/Primary?maxHeight=100&maxWidth=100`;
|
||||||
|
}
|
||||||
|
return getPrimaryImageUrl({ api, item: track });
|
||||||
|
}, [api, track]);
|
||||||
|
|
||||||
|
const isCurrentTrack = currentTrack?.Id === track.Id;
|
||||||
|
const isTrackLoading = loadingTrackId === track.Id;
|
||||||
|
|
||||||
|
// Track download status with reactivity to completion events
|
||||||
|
// Only track permanent downloads - we don't show UI for auto-caching
|
||||||
|
const [downloadStatus, setDownloadStatus] = useState<
|
||||||
|
"none" | "downloading" | "downloaded"
|
||||||
|
>(() => {
|
||||||
|
if (isPermanentlyDownloaded(track.Id)) return "downloaded";
|
||||||
|
if (isPermanentDownloading(track.Id)) return "downloading";
|
||||||
|
return "none";
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for download completion/error events (only for permanent downloads)
|
||||||
|
useEffect(() => {
|
||||||
|
const onComplete = (event: { itemId: string; permanent: boolean }) => {
|
||||||
|
if (event.itemId === track.Id && event.permanent) {
|
||||||
|
setDownloadStatus("downloaded");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onError = (event: { itemId: string }) => {
|
||||||
|
if (event.itemId === track.Id) {
|
||||||
|
setDownloadStatus("none");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
audioStorageEvents.on("complete", onComplete);
|
||||||
|
audioStorageEvents.on("error", onError);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
audioStorageEvents.off("complete", onComplete);
|
||||||
|
audioStorageEvents.off("error", onError);
|
||||||
|
};
|
||||||
|
}, [track.Id]);
|
||||||
|
|
||||||
|
// Also check periodically if permanent download started (for when download is triggered externally)
|
||||||
|
useEffect(() => {
|
||||||
|
if (downloadStatus === "none" && isPermanentDownloading(track.Id)) {
|
||||||
|
setDownloadStatus("downloading");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const _isDownloaded = downloadStatus === "downloaded";
|
||||||
|
// Check if available locally (either cached or permanently downloaded)
|
||||||
|
const isAvailableLocally = !!getLocalPath(track.Id);
|
||||||
|
// Consider offline if either no network connection OR server is unreachable
|
||||||
|
const isOffline = !isConnected || serverConnected === false;
|
||||||
|
const isUnavailableOffline = isOffline && !isAvailableLocally;
|
||||||
|
|
||||||
|
const duration = useMemo(() => {
|
||||||
|
if (!track.RunTimeTicks) return "";
|
||||||
|
return formatDuration(track.RunTimeTicks);
|
||||||
|
}, [track.RunTimeTicks]);
|
||||||
|
|
||||||
|
const handlePress = useCallback(() => {
|
||||||
|
if (isUnavailableOffline) return;
|
||||||
|
playTrack(track, queue);
|
||||||
|
}, [playTrack, track, queue, isUnavailableOffline]);
|
||||||
|
|
||||||
|
const handleLongPress = useCallback(() => {
|
||||||
|
onOptionsPress?.(track);
|
||||||
|
}, [onOptionsPress, track]);
|
||||||
|
|
||||||
|
const handleOptionsPress = useCallback(() => {
|
||||||
|
onOptionsPress?.(track);
|
||||||
|
}, [onOptionsPress, track]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handlePress}
|
||||||
|
onLongPress={handleLongPress}
|
||||||
|
delayLongPress={300}
|
||||||
|
disabled={isUnavailableOffline}
|
||||||
|
className={`flex flex-row items-center py-3 ${isCurrentTrack ? "bg-purple-900/20" : ""}`}
|
||||||
|
style={isUnavailableOffline ? { opacity: 0.5 } : undefined}
|
||||||
|
>
|
||||||
|
{index !== undefined && (
|
||||||
|
<View className='w-8 items-center'>
|
||||||
|
{isCurrentTrack && isPlaying ? (
|
||||||
|
<Ionicons name='musical-note' size={16} color='#9334E9' />
|
||||||
|
) : (
|
||||||
|
<Text className='text-neutral-500 text-sm'>{index}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showArtwork && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: 4,
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
marginRight: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{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={20} color='#737373' />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{isTrackLoading && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.6)",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ActivityIndicator size='small' color='white' />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View className='flex-1 mr-3'>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
className={`text-sm ${isCurrentTrack ? "text-purple-400 font-medium" : "text-white"}`}
|
||||||
|
>
|
||||||
|
{track.Name}
|
||||||
|
</Text>
|
||||||
|
<Text numberOfLines={1} className='text-neutral-400 text-xs mt-0.5'>
|
||||||
|
{track.Artists?.join(", ") || track.AlbumArtist}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text className='text-neutral-500 text-xs mr-2'>{duration}</Text>
|
||||||
|
|
||||||
|
{/* Download status indicator */}
|
||||||
|
{downloadStatus === "downloading" && (
|
||||||
|
<ActivityIndicator
|
||||||
|
size={14}
|
||||||
|
color='#9334E9'
|
||||||
|
style={{ marginRight: 8 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{downloadStatus === "downloaded" && (
|
||||||
|
<Ionicons
|
||||||
|
name='checkmark-circle'
|
||||||
|
size={16}
|
||||||
|
color='#22c55e'
|
||||||
|
style={{ marginRight: 8 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{onOptionsPress && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleOptionsPress}
|
||||||
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||||
|
className='p-1'
|
||||||
|
>
|
||||||
|
<Ionicons name='ellipsis-vertical' size={18} color='#737373' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
136
components/music/PlaylistOptionsSheet.tsx
Normal file
136
components/music/PlaylistOptionsSheet.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import {
|
||||||
|
BottomSheetBackdrop,
|
||||||
|
type BottomSheetBackdropProps,
|
||||||
|
BottomSheetModal,
|
||||||
|
BottomSheetView,
|
||||||
|
} from "@gorhom/bottom-sheet";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import React, { useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Alert, StyleSheet, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useDeletePlaylist } from "@/hooks/usePlaylistMutations";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
playlist: BaseItemDto | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PlaylistOptionsSheet: React.FC<Props> = ({
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
playlist,
|
||||||
|
}) => {
|
||||||
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
|
const router = useRouter();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const deletePlaylist = useDeletePlaylist();
|
||||||
|
|
||||||
|
const snapPoints = useMemo(() => ["25%"], []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) bottomSheetModalRef.current?.present();
|
||||||
|
else bottomSheetModalRef.current?.dismiss();
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const handleSheetChanges = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
if (index === -1) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setOpen],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderBackdrop = useCallback(
|
||||||
|
(props: BottomSheetBackdropProps) => (
|
||||||
|
<BottomSheetBackdrop
|
||||||
|
{...props}
|
||||||
|
disappearsOnIndex={-1}
|
||||||
|
appearsOnIndex={0}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeletePlaylist = useCallback(() => {
|
||||||
|
if (!playlist?.Id) return;
|
||||||
|
|
||||||
|
Alert.alert(
|
||||||
|
t("music.playlists.delete_playlist"),
|
||||||
|
t("music.playlists.delete_confirm", { name: playlist.Name }),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: t("common.cancel"),
|
||||||
|
style: "cancel",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: t("common.delete"),
|
||||||
|
style: "destructive",
|
||||||
|
onPress: () => {
|
||||||
|
deletePlaylist.mutate(
|
||||||
|
{ playlistId: playlist.Id! },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setOpen(false);
|
||||||
|
router.back();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}, [playlist, deletePlaylist, setOpen, router, t]);
|
||||||
|
|
||||||
|
if (!playlist) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BottomSheetModal
|
||||||
|
ref={bottomSheetModalRef}
|
||||||
|
index={0}
|
||||||
|
snapPoints={snapPoints}
|
||||||
|
onChange={handleSheetChanges}
|
||||||
|
backdropComponent={renderBackdrop}
|
||||||
|
handleIndicatorStyle={{
|
||||||
|
backgroundColor: "white",
|
||||||
|
}}
|
||||||
|
backgroundStyle={{
|
||||||
|
backgroundColor: "#171717",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BottomSheetView
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
paddingLeft: Math.max(16, insets.left),
|
||||||
|
paddingRight: Math.max(16, insets.right),
|
||||||
|
paddingBottom: insets.bottom,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className='flex-col rounded-xl overflow-hidden bg-neutral-800'>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleDeletePlaylist}
|
||||||
|
className='flex-row items-center px-4 py-3.5'
|
||||||
|
>
|
||||||
|
<Ionicons name='trash-outline' size={22} color='#ef4444' />
|
||||||
|
<Text className='text-red-500 ml-4 text-base'>
|
||||||
|
{t("music.playlists.delete_playlist")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</BottomSheetView>
|
||||||
|
</BottomSheetModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const _styles = StyleSheet.create({
|
||||||
|
separator: {
|
||||||
|
height: StyleSheet.hairlineWidth,
|
||||||
|
backgroundColor: "#404040",
|
||||||
|
},
|
||||||
|
});
|
||||||
262
components/music/PlaylistPickerSheet.tsx
Normal file
262
components/music/PlaylistPickerSheet.tsx
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import {
|
||||||
|
BottomSheetBackdrop,
|
||||||
|
type BottomSheetBackdropProps,
|
||||||
|
BottomSheetModal,
|
||||||
|
BottomSheetScrollView,
|
||||||
|
} from "@gorhom/bottom-sheet";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
StyleSheet,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Input } from "@/components/common/Input";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useAddToPlaylist } from "@/hooks/usePlaylistMutations";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
trackToAdd: BaseItemDto | null;
|
||||||
|
onCreateNew: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PlaylistPickerSheet: React.FC<Props> = ({
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
trackToAdd,
|
||||||
|
onCreateNew,
|
||||||
|
}) => {
|
||||||
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const addToPlaylist = useAddToPlaylist();
|
||||||
|
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const snapPoints = useMemo(() => ["75%"], []);
|
||||||
|
|
||||||
|
// Fetch all playlists
|
||||||
|
const { data: playlists, isLoading } = useQuery({
|
||||||
|
queryKey: ["music-playlists-picker", user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api || !user?.Id) return [];
|
||||||
|
|
||||||
|
const response = await getItemsApi(api).getItems({
|
||||||
|
userId: user.Id,
|
||||||
|
includeItemTypes: ["Playlist"],
|
||||||
|
sortBy: ["SortName"],
|
||||||
|
sortOrder: ["Ascending"],
|
||||||
|
recursive: true,
|
||||||
|
mediaTypes: ["Audio"],
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data.Items || [];
|
||||||
|
},
|
||||||
|
enabled: Boolean(api && user?.Id && open),
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredPlaylists = useMemo(() => {
|
||||||
|
if (!playlists) return [];
|
||||||
|
if (!search) return playlists;
|
||||||
|
return playlists.filter((playlist) =>
|
||||||
|
playlist.Name?.toLowerCase().includes(search.toLowerCase()),
|
||||||
|
);
|
||||||
|
}, [playlists, search]);
|
||||||
|
|
||||||
|
const showSearch = (playlists?.length || 0) > 10;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setSearch("");
|
||||||
|
bottomSheetModalRef.current?.present();
|
||||||
|
} else {
|
||||||
|
bottomSheetModalRef.current?.dismiss();
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const handleSheetChanges = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
if (index === -1) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setOpen],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderBackdrop = useCallback(
|
||||||
|
(props: BottomSheetBackdropProps) => (
|
||||||
|
<BottomSheetBackdrop
|
||||||
|
{...props}
|
||||||
|
disappearsOnIndex={-1}
|
||||||
|
appearsOnIndex={0}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSelectPlaylist = useCallback(
|
||||||
|
async (playlist: BaseItemDto) => {
|
||||||
|
if (!trackToAdd?.Id || !playlist.Id) return;
|
||||||
|
|
||||||
|
await addToPlaylist.mutateAsync({
|
||||||
|
playlistId: playlist.Id,
|
||||||
|
trackIds: [trackToAdd.Id],
|
||||||
|
playlistName: playlist.Name || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
setOpen(false);
|
||||||
|
},
|
||||||
|
[trackToAdd, addToPlaylist, setOpen],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreateNew = useCallback(() => {
|
||||||
|
setOpen(false);
|
||||||
|
setTimeout(() => {
|
||||||
|
onCreateNew();
|
||||||
|
}, 300);
|
||||||
|
}, [onCreateNew, setOpen]);
|
||||||
|
|
||||||
|
const getPlaylistImageUrl = useCallback(
|
||||||
|
(playlist: BaseItemDto) => {
|
||||||
|
if (!api) return null;
|
||||||
|
return `${api.basePath}/Items/${playlist.Id}/Images/Primary?maxHeight=100&maxWidth=100`;
|
||||||
|
},
|
||||||
|
[api],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BottomSheetModal
|
||||||
|
ref={bottomSheetModalRef}
|
||||||
|
index={0}
|
||||||
|
snapPoints={snapPoints}
|
||||||
|
onChange={handleSheetChanges}
|
||||||
|
backdropComponent={renderBackdrop}
|
||||||
|
handleIndicatorStyle={{
|
||||||
|
backgroundColor: "white",
|
||||||
|
}}
|
||||||
|
backgroundStyle={{
|
||||||
|
backgroundColor: "#171717",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BottomSheetScrollView
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingLeft: Math.max(16, insets.left),
|
||||||
|
paddingRight: Math.max(16, insets.right),
|
||||||
|
paddingBottom: insets.bottom + 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className='font-bold text-2xl mb-2'>
|
||||||
|
{t("music.track_options.add_to_playlist")}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-neutral-500 mb-4'>{trackToAdd?.Name}</Text>
|
||||||
|
|
||||||
|
{showSearch && (
|
||||||
|
<Input
|
||||||
|
placeholder={t("music.playlists.search_playlists")}
|
||||||
|
className='mb-4 border-neutral-800 border'
|
||||||
|
value={search}
|
||||||
|
onChangeText={setSearch}
|
||||||
|
returnKeyType='done'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create New Playlist Button */}
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleCreateNew}
|
||||||
|
className='flex-row items-center bg-purple-900/30 rounded-xl px-4 py-3.5 mb-4'
|
||||||
|
>
|
||||||
|
<View className='w-12 h-12 rounded-lg bg-purple-600 items-center justify-center mr-3'>
|
||||||
|
<Ionicons name='add' size={28} color='white' />
|
||||||
|
</View>
|
||||||
|
<Text className='text-purple-400 font-semibold text-base'>
|
||||||
|
{t("music.playlists.create_new")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<View className='py-8 items-center'>
|
||||||
|
<ActivityIndicator color='#9334E9' />
|
||||||
|
</View>
|
||||||
|
) : filteredPlaylists.length === 0 ? (
|
||||||
|
<View className='py-8 items-center'>
|
||||||
|
<Text className='text-neutral-500'>
|
||||||
|
{search ? t("search.no_results") : t("music.no_playlists")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View className='rounded-xl overflow-hidden bg-neutral-800'>
|
||||||
|
{filteredPlaylists.map((playlist, index) => (
|
||||||
|
<View key={playlist.Id}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => handleSelectPlaylist(playlist)}
|
||||||
|
className='flex-row items-center px-4 py-3'
|
||||||
|
disabled={addToPlaylist.isPending}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: 6,
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
marginRight: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
source={{
|
||||||
|
uri: getPlaylistImageUrl(playlist) || undefined,
|
||||||
|
}}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
cachePolicy='memory-disk'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View className='flex-1'>
|
||||||
|
<Text numberOfLines={1} className='text-white text-base'>
|
||||||
|
{playlist.Name}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-neutral-500 text-sm'>
|
||||||
|
{playlist.ChildCount} {t("music.tabs.tracks")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{addToPlaylist.isPending && (
|
||||||
|
<ActivityIndicator size='small' color='#9334E9' />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
{index < filteredPlaylists.length - 1 && (
|
||||||
|
<View style={styles.separator} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</BottomSheetScrollView>
|
||||||
|
</BottomSheetModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
separator: {
|
||||||
|
height: StyleSheet.hairlineWidth,
|
||||||
|
backgroundColor: "#404040",
|
||||||
|
},
|
||||||
|
});
|
||||||
437
components/music/TrackOptionsSheet.tsx
Normal file
437
components/music/TrackOptionsSheet.tsx
Normal file
@@ -0,0 +1,437 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import {
|
||||||
|
BottomSheetBackdrop,
|
||||||
|
type BottomSheetBackdropProps,
|
||||||
|
BottomSheetModal,
|
||||||
|
BottomSheetView,
|
||||||
|
} from "@gorhom/bottom-sheet";
|
||||||
|
import type { BaseItemDto } 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,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
StyleSheet,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useFavorite } from "@/hooks/useFavorite";
|
||||||
|
import {
|
||||||
|
audioStorageEvents,
|
||||||
|
downloadTrack,
|
||||||
|
isCached,
|
||||||
|
isPermanentDownloading,
|
||||||
|
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";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
track: BaseItemDto | null;
|
||||||
|
onAddToPlaylist: () => void;
|
||||||
|
playlistId?: string;
|
||||||
|
onRemoveFromPlaylist?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TrackOptionsSheet: React.FC<Props> = ({
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
track,
|
||||||
|
onAddToPlaylist,
|
||||||
|
playlistId,
|
||||||
|
onRemoveFromPlaylist,
|
||||||
|
}) => {
|
||||||
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const router = useRouter();
|
||||||
|
const { playNext, addToQueue } = useMusicPlayer();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isDownloadingTrack, setIsDownloadingTrack] = useState(false);
|
||||||
|
// Counter to trigger re-evaluation of download status when storage changes
|
||||||
|
const [storageUpdateCounter, setStorageUpdateCounter] = useState(0);
|
||||||
|
|
||||||
|
// Listen for storage events to update download status
|
||||||
|
useEffect(() => {
|
||||||
|
const handleComplete = (event: { itemId: string }) => {
|
||||||
|
if (event.itemId === track?.Id) {
|
||||||
|
setStorageUpdateCounter((c) => c + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
audioStorageEvents.on("complete", handleComplete);
|
||||||
|
return () => {
|
||||||
|
audioStorageEvents.off("complete", handleComplete);
|
||||||
|
};
|
||||||
|
}, [track?.Id]);
|
||||||
|
|
||||||
|
// Use a placeholder item for useFavorite when track is null
|
||||||
|
const { isFavorite, toggleFavorite } = useFavorite(
|
||||||
|
track ?? ({ Id: "", UserData: { IsFavorite: false } } as BaseItemDto),
|
||||||
|
);
|
||||||
|
|
||||||
|
const snapPoints = useMemo(() => ["65%"], []);
|
||||||
|
|
||||||
|
// Check download status (storageUpdateCounter triggers re-evaluation when download completes)
|
||||||
|
const isAlreadyDownloaded = useMemo(
|
||||||
|
() => isPermanentlyDownloaded(track?.Id),
|
||||||
|
[track?.Id, storageUpdateCounter],
|
||||||
|
);
|
||||||
|
const isOnlyCached = useMemo(
|
||||||
|
() => isCached(track?.Id),
|
||||||
|
[track?.Id, storageUpdateCounter],
|
||||||
|
);
|
||||||
|
const isCurrentlyDownloading = useMemo(
|
||||||
|
() => isPermanentDownloading(track?.Id),
|
||||||
|
[track?.Id, storageUpdateCounter],
|
||||||
|
);
|
||||||
|
|
||||||
|
const imageUrl = useMemo(() => {
|
||||||
|
if (!track) return null;
|
||||||
|
const albumId = track.AlbumId || track.ParentId;
|
||||||
|
if (albumId) {
|
||||||
|
return `${api?.basePath}/Items/${albumId}/Images/Primary?maxHeight=200&maxWidth=200`;
|
||||||
|
}
|
||||||
|
return getPrimaryImageUrl({ api, item: track });
|
||||||
|
}, [api, track]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) bottomSheetModalRef.current?.present();
|
||||||
|
else bottomSheetModalRef.current?.dismiss();
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const handleSheetChanges = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
if (index === -1) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setOpen],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderBackdrop = useCallback(
|
||||||
|
(props: BottomSheetBackdropProps) => (
|
||||||
|
<BottomSheetBackdrop
|
||||||
|
{...props}
|
||||||
|
disappearsOnIndex={-1}
|
||||||
|
appearsOnIndex={0}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePlayNext = useCallback(() => {
|
||||||
|
if (track) {
|
||||||
|
playNext(track);
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}, [track, playNext, setOpen]);
|
||||||
|
|
||||||
|
const handleAddToQueue = useCallback(() => {
|
||||||
|
if (track) {
|
||||||
|
addToQueue(track);
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}, [track, addToQueue, setOpen]);
|
||||||
|
|
||||||
|
const handleAddToPlaylist = useCallback(() => {
|
||||||
|
setOpen(false);
|
||||||
|
setTimeout(() => {
|
||||||
|
onAddToPlaylist();
|
||||||
|
}, 300);
|
||||||
|
}, [onAddToPlaylist, setOpen]);
|
||||||
|
|
||||||
|
const handleRemoveFromPlaylist = useCallback(() => {
|
||||||
|
if (onRemoveFromPlaylist) {
|
||||||
|
onRemoveFromPlaylist();
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}, [onRemoveFromPlaylist, setOpen]);
|
||||||
|
|
||||||
|
const handleDownload = useCallback(async () => {
|
||||||
|
if (!track?.Id || !api || !user?.Id || isAlreadyDownloaded) return;
|
||||||
|
|
||||||
|
setIsDownloadingTrack(true);
|
||||||
|
try {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
setIsDownloadingTrack(false);
|
||||||
|
setOpen(false);
|
||||||
|
}, [track?.Id, api, user?.Id, isAlreadyDownloaded, setOpen]);
|
||||||
|
|
||||||
|
const handleGoToArtist = useCallback(() => {
|
||||||
|
const artistId = track?.ArtistItems?.[0]?.Id;
|
||||||
|
if (artistId) {
|
||||||
|
setOpen(false);
|
||||||
|
router.push({
|
||||||
|
pathname: "/music/artist/[artistId]",
|
||||||
|
params: { artistId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [track?.ArtistItems, router, setOpen]);
|
||||||
|
|
||||||
|
const handleGoToAlbum = useCallback(() => {
|
||||||
|
const albumId = track?.AlbumId || track?.ParentId;
|
||||||
|
if (albumId) {
|
||||||
|
setOpen(false);
|
||||||
|
router.push({
|
||||||
|
pathname: "/music/album/[albumId]",
|
||||||
|
params: { albumId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [track?.AlbumId, track?.ParentId, router, setOpen]);
|
||||||
|
|
||||||
|
const handleToggleFavorite = useCallback(() => {
|
||||||
|
if (track) {
|
||||||
|
toggleFavorite();
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}, [track, toggleFavorite, setOpen]);
|
||||||
|
|
||||||
|
// Check if navigation options are available
|
||||||
|
const hasArtist = !!track?.ArtistItems?.[0]?.Id;
|
||||||
|
const hasAlbum = !!(track?.AlbumId || track?.ParentId);
|
||||||
|
|
||||||
|
if (!track) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BottomSheetModal
|
||||||
|
ref={bottomSheetModalRef}
|
||||||
|
index={0}
|
||||||
|
snapPoints={snapPoints}
|
||||||
|
onChange={handleSheetChanges}
|
||||||
|
backdropComponent={renderBackdrop}
|
||||||
|
handleIndicatorStyle={{
|
||||||
|
backgroundColor: "white",
|
||||||
|
}}
|
||||||
|
backgroundStyle={{
|
||||||
|
backgroundColor: "#171717",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BottomSheetView
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
paddingLeft: Math.max(16, insets.left),
|
||||||
|
paddingRight: Math.max(16, insets.right),
|
||||||
|
paddingBottom: insets.bottom,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Track Info Header */}
|
||||||
|
<View className='flex-row items-center mb-6 px-2'>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
borderRadius: 6,
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
marginRight: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{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={24} color='#737373' />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View className='flex-1'>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
className='text-white font-semibold text-base'
|
||||||
|
>
|
||||||
|
{track.Name}
|
||||||
|
</Text>
|
||||||
|
<Text numberOfLines={1} className='text-neutral-400 text-sm mt-0.5'>
|
||||||
|
{track.Artists?.join(", ") || track.AlbumArtist}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Playback Options */}
|
||||||
|
<View className='flex-col rounded-xl overflow-hidden bg-neutral-800'>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handlePlayNext}
|
||||||
|
className='flex-row items-center px-4 py-3.5'
|
||||||
|
>
|
||||||
|
<Ionicons name='play-forward' size={22} color='white' />
|
||||||
|
<Text className='text-white ml-4 text-base'>
|
||||||
|
{t("music.track_options.play_next")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<View style={styles.separator} />
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleAddToQueue}
|
||||||
|
className='flex-row items-center px-4 py-3.5'
|
||||||
|
>
|
||||||
|
<Ionicons name='list' size={22} color='white' />
|
||||||
|
<Text className='text-white ml-4 text-base'>
|
||||||
|
{t("music.track_options.add_to_queue")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Library Options */}
|
||||||
|
<View className='flex-col rounded-xl overflow-hidden bg-neutral-800 mt-3'>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleToggleFavorite}
|
||||||
|
className='flex-row items-center px-4 py-3.5'
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={isFavorite ? "heart" : "heart-outline"}
|
||||||
|
size={22}
|
||||||
|
color={isFavorite ? "#ec4899" : "white"}
|
||||||
|
/>
|
||||||
|
<Text className='text-white ml-4 text-base'>
|
||||||
|
{isFavorite
|
||||||
|
? t("music.track_options.remove_from_favorites")
|
||||||
|
: t("music.track_options.add_to_favorites")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<View style={styles.separator} />
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleAddToPlaylist}
|
||||||
|
className='flex-row items-center px-4 py-3.5'
|
||||||
|
>
|
||||||
|
<Ionicons name='albums-outline' size={22} color='white' />
|
||||||
|
<Text className='text-white ml-4 text-base'>
|
||||||
|
{t("music.track_options.add_to_playlist")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{playlistId && (
|
||||||
|
<>
|
||||||
|
<View style={styles.separator} />
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleRemoveFromPlaylist}
|
||||||
|
className='flex-row items-center px-4 py-3.5'
|
||||||
|
>
|
||||||
|
<Ionicons name='trash-outline' size={22} color='#ef4444' />
|
||||||
|
<Text className='text-red-500 ml-4 text-base'>
|
||||||
|
{t("music.track_options.remove_from_playlist")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={styles.separator} />
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleDownload}
|
||||||
|
disabled={
|
||||||
|
isAlreadyDownloaded ||
|
||||||
|
isCurrentlyDownloading ||
|
||||||
|
isDownloadingTrack
|
||||||
|
}
|
||||||
|
className='flex-row items-center px-4 py-3.5'
|
||||||
|
>
|
||||||
|
{isCurrentlyDownloading || isDownloadingTrack ? (
|
||||||
|
<ActivityIndicator size={22} color='white' />
|
||||||
|
) : (
|
||||||
|
<Ionicons
|
||||||
|
name={
|
||||||
|
isAlreadyDownloaded ? "checkmark-circle" : "download-outline"
|
||||||
|
}
|
||||||
|
size={22}
|
||||||
|
color={isAlreadyDownloaded ? "#22c55e" : "white"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Text
|
||||||
|
className={`ml-4 text-base ${isAlreadyDownloaded ? "text-green-500" : "text-white"}`}
|
||||||
|
>
|
||||||
|
{isCurrentlyDownloading || isDownloadingTrack
|
||||||
|
? t("music.track_options.downloading")
|
||||||
|
: isAlreadyDownloaded
|
||||||
|
? t("music.track_options.downloaded")
|
||||||
|
: t("music.track_options.download")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{isOnlyCached && !isAlreadyDownloaded && (
|
||||||
|
<>
|
||||||
|
<View style={styles.separator} />
|
||||||
|
<View className='flex-row items-center px-4 py-3.5'>
|
||||||
|
<Ionicons name='cloud-done-outline' size={22} color='#737373' />
|
||||||
|
<Text className='text-neutral-500 ml-4 text-base'>
|
||||||
|
{t("music.track_options.cached")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Navigation Options */}
|
||||||
|
{(hasArtist || hasAlbum) && (
|
||||||
|
<View className='flex-col rounded-xl overflow-hidden bg-neutral-800 mt-3'>
|
||||||
|
{hasArtist && (
|
||||||
|
<>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleGoToArtist}
|
||||||
|
className='flex-row items-center px-4 py-3.5'
|
||||||
|
>
|
||||||
|
<Ionicons name='person-outline' size={22} color='white' />
|
||||||
|
<Text className='text-white ml-4 text-base'>
|
||||||
|
{t("music.track_options.go_to_artist")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
{hasAlbum && <View style={styles.separator} />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasAlbum && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleGoToAlbum}
|
||||||
|
className='flex-row items-center px-4 py-3.5'
|
||||||
|
>
|
||||||
|
<Ionicons name='disc-outline' size={22} color='white' />
|
||||||
|
<Text className='text-white ml-4 text-base'>
|
||||||
|
{t("music.track_options.go_to_album")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</BottomSheetView>
|
||||||
|
</BottomSheetModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
separator: {
|
||||||
|
height: StyleSheet.hairlineWidth,
|
||||||
|
backgroundColor: "#404040",
|
||||||
|
},
|
||||||
|
});
|
||||||
6
components/music/index.ts
Normal file
6
components/music/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export * from "./MiniPlayerBar";
|
||||||
|
export * from "./MusicAlbumCard";
|
||||||
|
export * from "./MusicArtistCard";
|
||||||
|
export * from "./MusicPlaybackEngine";
|
||||||
|
export * from "./MusicPlaylistCard";
|
||||||
|
export * from "./MusicTrackItem";
|
||||||
@@ -8,6 +8,7 @@ import type React from "react";
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
||||||
|
import { POSTER_CAROUSEL_HEIGHT } from "@/constants/Values";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
import { HorizontalScroll } from "../common/HorizontalScroll";
|
import { HorizontalScroll } from "../common/HorizontalScroll";
|
||||||
@@ -50,7 +51,7 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
|
|||||||
<HorizontalScroll
|
<HorizontalScroll
|
||||||
loading={loading}
|
loading={loading}
|
||||||
keyExtractor={(i, _idx) => i.Id?.toString() || ""}
|
keyExtractor={(i, _idx) => i.Id?.toString() || ""}
|
||||||
height={247}
|
height={POSTER_CAROUSEL_HEIGHT}
|
||||||
data={destinctPeople}
|
data={destinctPeople}
|
||||||
renderItem={(i) => (
|
renderItem={(i) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -65,8 +66,12 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
|
|||||||
className='flex flex-col w-28'
|
className='flex flex-col w-28'
|
||||||
>
|
>
|
||||||
<Poster id={i.Id} url={getPrimaryImageUrl({ api, item: i })} />
|
<Poster id={i.Id} url={getPrimaryImageUrl({ api, item: i })} />
|
||||||
<Text className='mt-2'>{i.Name}</Text>
|
<Text className='mt-2' numberOfLines={1}>
|
||||||
<Text className='text-xs opacity-50'>{i.Role}</Text>
|
{i.Name}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-xs opacity-50' numberOfLines={1}>
|
||||||
|
{i.Role}
|
||||||
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useAtom } from "jotai";
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
||||||
|
import { POSTER_CAROUSEL_HEIGHT } from "@/constants/Values";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
|
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
|
||||||
import { HorizontalScroll } from "../common/HorizontalScroll";
|
import { HorizontalScroll } from "../common/HorizontalScroll";
|
||||||
@@ -25,7 +26,7 @@ export const CurrentSeries: React.FC<Props> = ({ item, ...props }) => {
|
|||||||
</Text>
|
</Text>
|
||||||
<HorizontalScroll
|
<HorizontalScroll
|
||||||
data={[item]}
|
data={[item]}
|
||||||
height={247}
|
height={POSTER_CAROUSEL_HEIGHT}
|
||||||
renderItem={(item, _index) => (
|
renderItem={(item, _index) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={item?.Id}
|
key={item?.Id}
|
||||||
@@ -38,7 +39,7 @@ export const CurrentSeries: React.FC<Props> = ({ item, ...props }) => {
|
|||||||
id={item?.Id}
|
id={item?.Id}
|
||||||
url={getPrimaryImageUrlById({ api, id: item?.ParentId })}
|
url={getPrimaryImageUrlById({ api, id: item?.ParentId })}
|
||||||
/>
|
/>
|
||||||
<Text>{item?.SeriesName}</Text>
|
<Text numberOfLines={1}>{item?.SeriesName}</Text>
|
||||||
</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