mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-22 02:58:04 +00:00
Compare commits
1 Commits
0.51.0
...
fix/github
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c7a9a1218 |
9
.github/ISSUE_TEMPLATE/issue_report.yml
vendored
9
.github/ISSUE_TEMPLATE/issue_report.yml
vendored
@@ -77,8 +77,13 @@ body:
|
|||||||
label: Streamyfin Version
|
label: Streamyfin Version
|
||||||
description: What version of Streamyfin are you running?
|
description: What version of Streamyfin are you running?
|
||||||
options:
|
options:
|
||||||
- 0.47.1
|
|
||||||
- 0.30.2
|
- 0.30.2
|
||||||
|
- 0.29.0
|
||||||
|
- 0.28.0
|
||||||
|
- 0.27.0
|
||||||
|
- 0.26.1
|
||||||
|
- 0.26.0
|
||||||
|
- 0.25.0
|
||||||
- older
|
- older
|
||||||
- TestFlight/Development build
|
- TestFlight/Development build
|
||||||
validations:
|
validations:
|
||||||
@@ -111,4 +116,4 @@ body:
|
|||||||
id: additional-info
|
id: additional-info
|
||||||
attributes:
|
attributes:
|
||||||
label: Additional information
|
label: Additional information
|
||||||
description: Any additional context that might help us understand and reproduce the issue.
|
description: Any additional context that might help us understand and reproduce the issue.
|
||||||
12
.github/workflows/build-apps.yml
vendored
12
.github/workflows/build-apps.yml
vendored
@@ -170,6 +170,11 @@ jobs:
|
|||||||
submodules: recursive
|
submodules: recursive
|
||||||
show-progress: false
|
show-progress: false
|
||||||
|
|
||||||
|
- name: 🟢 Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "24"
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||||
with:
|
with:
|
||||||
@@ -192,7 +197,7 @@ jobs:
|
|||||||
run: bun run prebuild
|
run: bun run prebuild
|
||||||
|
|
||||||
- name: 🔧 Setup Xcode
|
- name: 🔧 Setup Xcode
|
||||||
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1
|
uses: maxim-lobanov/setup-xcode@v1
|
||||||
with:
|
with:
|
||||||
xcode-version: "26.0.1"
|
xcode-version: "26.0.1"
|
||||||
|
|
||||||
@@ -238,6 +243,11 @@ jobs:
|
|||||||
# submodules: recursive
|
# submodules: recursive
|
||||||
# show-progress: false
|
# show-progress: false
|
||||||
#
|
#
|
||||||
|
# - name: 🟢 Setup Node.js
|
||||||
|
# uses: actions/setup-node@v4
|
||||||
|
# with:
|
||||||
|
# node-version: '20'
|
||||||
|
#
|
||||||
# - name: 🍞 Setup Bun
|
# - name: 🍞 Setup Bun
|
||||||
# uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
# uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||||
# with:
|
# with:
|
||||||
|
|||||||
4
.github/workflows/ci-codeql.yml
vendored
4
.github/workflows/ci-codeql.yml
vendored
@@ -25,6 +25,10 @@ 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@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||||
with:
|
with:
|
||||||
node-version: '24.x'
|
node-version: '24.x'
|
||||||
|
|
||||||
|
|||||||
2
.github/workflows/update-issue-form.yml
vendored
2
.github/workflows/update-issue-form.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
|||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
|
|
||||||
- name: "🟢 Setup Node.js"
|
- name: "🟢 Setup Node.js"
|
||||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||||
with:
|
with:
|
||||||
node-version: '24.x'
|
node-version: '24.x'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -51,7 +51,6 @@ npm-debug.*
|
|||||||
.ruby-lsp
|
.ruby-lsp
|
||||||
.cursor/
|
.cursor/
|
||||||
.claude/
|
.claude/
|
||||||
CLAUDE.md
|
|
||||||
|
|
||||||
# Environment and Configuration
|
# Environment and Configuration
|
||||||
expo-env.d.ts
|
expo-env.d.ts
|
||||||
@@ -67,6 +66,3 @@ 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
119
CLAUDE.md
@@ -1,119 +0,0 @@
|
|||||||
# CLAUDE.md
|
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
||||||
|
|
||||||
## Project Overview
|
|
||||||
|
|
||||||
Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native). It supports mobile (iOS/Android) and TV platforms, with features including offline downloads, Chromecast support, and Jellyseerr integration.
|
|
||||||
|
|
||||||
## Development Commands
|
|
||||||
|
|
||||||
**CRITICAL: Always use `bun` for package management. Never use `npm`, `yarn`, or `npx`.**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Setup
|
|
||||||
bun i && bun run submodule-reload
|
|
||||||
|
|
||||||
# Development builds
|
|
||||||
bun run prebuild # Mobile prebuild
|
|
||||||
bun run ios # Run iOS
|
|
||||||
bun run android # Run Android
|
|
||||||
|
|
||||||
# TV builds (suffix with :tv)
|
|
||||||
bun run prebuild:tv
|
|
||||||
bun run ios:tv
|
|
||||||
bun run android:tv
|
|
||||||
|
|
||||||
# Code quality
|
|
||||||
bun run typecheck # TypeScript check
|
|
||||||
bun run check # BiomeJS check
|
|
||||||
bun run lint # BiomeJS lint + fix
|
|
||||||
bun run format # BiomeJS format
|
|
||||||
bun run test # Run all checks (typecheck, lint, format, doctor)
|
|
||||||
|
|
||||||
# iOS-specific
|
|
||||||
bun run ios:install-metal-toolchain # Fix "missing Metal Toolchain" build errors
|
|
||||||
```
|
|
||||||
|
|
||||||
## Tech Stack
|
|
||||||
|
|
||||||
- **Runtime**: Bun
|
|
||||||
- **Framework**: React Native (Expo SDK 54)
|
|
||||||
- **Language**: TypeScript (strict mode)
|
|
||||||
- **State Management**: Jotai (global state atoms) + React Query (server state)
|
|
||||||
- **API**: Jellyfin SDK (`@jellyfin/sdk`)
|
|
||||||
- **Navigation**: Expo Router (file-based)
|
|
||||||
- **Linting/Formatting**: BiomeJS
|
|
||||||
- **Storage**: react-native-mmkv
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### File Structure
|
|
||||||
|
|
||||||
- `app/` - Expo Router screens with file-based routing
|
|
||||||
- `components/` - Reusable UI components
|
|
||||||
- `providers/` - React Context providers
|
|
||||||
- `hooks/` - Custom React hooks
|
|
||||||
- `utils/` - Utilities including Jotai atoms
|
|
||||||
- `modules/` - Native modules (vlc-player, mpv-player, background-downloader)
|
|
||||||
- `translations/` - i18n translation files
|
|
||||||
|
|
||||||
### Key Patterns
|
|
||||||
|
|
||||||
**State Management**:
|
|
||||||
- Global state uses Jotai atoms in `utils/atoms/`
|
|
||||||
- `settingsAtom` in `utils/atoms/settings.ts` for app settings
|
|
||||||
- `apiAtom` and `userAtom` in `providers/JellyfinProvider.tsx` for auth state
|
|
||||||
- Server state uses React Query with `@tanstack/react-query`
|
|
||||||
|
|
||||||
**Jellyfin API Access**:
|
|
||||||
- Use `apiAtom` from `JellyfinProvider` for authenticated API calls
|
|
||||||
- Access user via `userAtom`
|
|
||||||
- Use Jellyfin SDK utilities from `@jellyfin/sdk/lib/utils/api`
|
|
||||||
|
|
||||||
**Navigation**:
|
|
||||||
- File-based routing in `app/` directory
|
|
||||||
- Tab navigation: `(home)`, `(search)`, `(favorites)`, `(libraries)`, `(watchlists)`
|
|
||||||
- Shared routes use parenthesized groups like `(home,libraries,search,favorites,watchlists)`
|
|
||||||
|
|
||||||
**Providers** (wrapping order in `app/_layout.tsx`):
|
|
||||||
1. JotaiProvider
|
|
||||||
2. QueryClientProvider
|
|
||||||
3. JellyfinProvider (auth, API)
|
|
||||||
4. NetworkStatusProvider
|
|
||||||
5. PlaySettingsProvider
|
|
||||||
6. WebSocketProvider
|
|
||||||
7. DownloadProvider
|
|
||||||
8. MusicPlayerProvider
|
|
||||||
|
|
||||||
### Native Modules
|
|
||||||
|
|
||||||
Located in `modules/`:
|
|
||||||
- `vlc-player` - VLC video player integration
|
|
||||||
- `mpv-player` - MPV video player integration (iOS)
|
|
||||||
- `background-downloader` - Background download functionality
|
|
||||||
- `sf-player` - Swift player module
|
|
||||||
|
|
||||||
### Path Aliases
|
|
||||||
|
|
||||||
Use `@/` prefix for imports (configured in `tsconfig.json`):
|
|
||||||
```typescript
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
```
|
|
||||||
|
|
||||||
## Coding Standards
|
|
||||||
|
|
||||||
- Use TypeScript for all files (no .js)
|
|
||||||
- Use functional React components with hooks
|
|
||||||
- Use Jotai atoms for global state, React Query for server state
|
|
||||||
- Follow BiomeJS formatting rules (2-space indent, semicolons, LF line endings)
|
|
||||||
- Handle both mobile and TV navigation patterns
|
|
||||||
- Use existing atoms, hooks, and utilities before creating new ones
|
|
||||||
- Use Conventional Commits: `feat(scope):`, `fix(scope):`, `chore(scope):`
|
|
||||||
|
|
||||||
## Platform Considerations
|
|
||||||
|
|
||||||
- TV version uses `:tv` suffix for scripts
|
|
||||||
- Platform checks: `Platform.isTV`, `Platform.OS === "android"` or `"ios"`
|
|
||||||
- Some features disabled on TV (e.g., notifications, Chromecast)
|
|
||||||
@@ -70,7 +70,6 @@ Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) To
|
|||||||
<a href="https://apps.apple.com/app/streamyfin/id6593660679?l=en-GB"><img height=50 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/></a>
|
<a href="https://apps.apple.com/app/streamyfin/id6593660679?l=en-GB"><img height=50 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/></a>
|
||||||
<a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin"><img height=50 alt="Get Streamyfin on Google Play Store" src="./assets/Google_Play_Store_badge_EN.svg"/></a>
|
<a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin"><img height=50 alt="Get Streamyfin on Google Play Store" src="./assets/Google_Play_Store_badge_EN.svg"/></a>
|
||||||
<a href="https://github.com/streamyfin/streamyfin/releases/latest"><img height=50 alt="Get Streamyfin on Github" src="./assets/Download_on_Github_.png"/></a>
|
<a href="https://github.com/streamyfin/streamyfin/releases/latest"><img height=50 alt="Get Streamyfin on Github" src="./assets/Download_on_Github_.png"/></a>
|
||||||
<a href="https://apps.obtainium.imranr.dev/redirect.html?r=obtainium://add/https://github.com/streamyfin/streamyfin"><img height=50 alt="Add Streamyfin to Obtainium" src="./assets/Download_with_Obtainium.png"/></a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
### 🧪 Beta Testing
|
### 🧪 Beta Testing
|
||||||
@@ -105,7 +104,6 @@ You can contribute translations directly on our [Crowdin project page](https://c
|
|||||||
1. Use node `>20`
|
1. Use node `>20`
|
||||||
2. Install dependencies `bun i && bun run submodule-reload`
|
2. Install dependencies `bun i && bun run submodule-reload`
|
||||||
3. Make sure you have xcode and/or android studio installed. (follow the guides for expo: https://docs.expo.dev/workflow/android-studio-emulator/)
|
3. Make sure you have xcode and/or android studio installed. (follow the guides for expo: https://docs.expo.dev/workflow/android-studio-emulator/)
|
||||||
- If iOS builds fail with `missing Metal Toolchain` (KSPlayer shaders), run `npm run ios:install-metal-toolchain` once
|
|
||||||
4. Install BiomeJS extension in VSCode/Your IDE (https://biomejs.dev/)
|
4. Install BiomeJS extension in VSCode/Your IDE (https://biomejs.dev/)
|
||||||
4. run `npm run prebuild`
|
4. run `npm run prebuild`
|
||||||
5. Create an expo dev build by running `npm run ios` or `npm run android`. This will open a simulator on your computer and run the app
|
5. Create an expo dev build by running `npm run ios` or `npm run android`. This will open a simulator on your computer and run the app
|
||||||
|
|||||||
@@ -6,9 +6,6 @@ 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.51.0",
|
"version": "0.47.1",
|
||||||
"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": 91,
|
"versionCode": 84,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/images/icon-android-plain.png",
|
"foregroundImage": "./assets/images/icon-android-plain.png",
|
||||||
"monochromeImage": "./assets/images/icon-android-themed.png",
|
"monochromeImage": "./assets/images/icon-android-themed.png",
|
||||||
@@ -53,16 +53,29 @@
|
|||||||
"@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": 36,
|
"compileSdkVersion": 35,
|
||||||
"targetSdkVersion": 35,
|
"targetSdkVersion": 35,
|
||||||
"buildToolsVersion": "35.0.0",
|
"buildToolsVersion": "35.0.0",
|
||||||
"kotlinVersion": "2.0.21",
|
"kotlinVersion": "2.0.21",
|
||||||
|
|||||||
@@ -1,212 +0,0 @@
|
|||||||
import type { Api } from "@jellyfin/sdk";
|
|
||||||
import type {
|
|
||||||
BaseItemDto,
|
|
||||||
BaseItemKind,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { FlashList } from "@shopify/flash-list";
|
|
||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
|
||||||
import { Stack, useLocalSearchParams } from "expo-router";
|
|
||||||
import { t } from "i18next";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useCallback, useMemo } from "react";
|
|
||||||
import { useWindowDimensions, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { ItemPoster } from "@/components/posters/ItemPoster";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
|
|
||||||
type FavoriteTypes =
|
|
||||||
| "Series"
|
|
||||||
| "Movie"
|
|
||||||
| "Episode"
|
|
||||||
| "Video"
|
|
||||||
| "BoxSet"
|
|
||||||
| "Playlist";
|
|
||||||
|
|
||||||
const favoriteTypes: readonly FavoriteTypes[] = [
|
|
||||||
"Series",
|
|
||||||
"Movie",
|
|
||||||
"Episode",
|
|
||||||
"Video",
|
|
||||||
"BoxSet",
|
|
||||||
"Playlist",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
function isFavoriteType(value: unknown): value is FavoriteTypes {
|
|
||||||
return (
|
|
||||||
typeof value === "string" &&
|
|
||||||
(favoriteTypes as readonly string[]).includes(value)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function FavoritesSeeAllScreen() {
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const { width: screenWidth } = useWindowDimensions();
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
|
|
||||||
const searchParams = useLocalSearchParams<{
|
|
||||||
type?: string;
|
|
||||||
title?: string;
|
|
||||||
}>();
|
|
||||||
const typeParam = searchParams.type;
|
|
||||||
const titleParam = searchParams.title;
|
|
||||||
|
|
||||||
const itemType = useMemo(() => {
|
|
||||||
if (!isFavoriteType(typeParam)) return null;
|
|
||||||
return typeParam as BaseItemKind;
|
|
||||||
}, [typeParam]);
|
|
||||||
|
|
||||||
const headerTitle = useMemo(() => {
|
|
||||||
if (typeof titleParam === "string" && titleParam.trim().length > 0)
|
|
||||||
return titleParam;
|
|
||||||
return "";
|
|
||||||
}, [titleParam]);
|
|
||||||
|
|
||||||
const pageSize = 50;
|
|
||||||
|
|
||||||
const fetchItems = useCallback(
|
|
||||||
async ({ pageParam }: { pageParam: number }): Promise<BaseItemDto[]> => {
|
|
||||||
if (!api || !user?.Id || !itemType) return [];
|
|
||||||
|
|
||||||
const response = await getItemsApi(api as Api).getItems({
|
|
||||||
userId: user.Id,
|
|
||||||
sortBy: ["SeriesSortName", "SortName"],
|
|
||||||
sortOrder: ["Ascending"],
|
|
||||||
filters: ["IsFavorite"],
|
|
||||||
recursive: true,
|
|
||||||
fields: ["PrimaryImageAspectRatio"],
|
|
||||||
collapseBoxSetItems: false,
|
|
||||||
excludeLocationTypes: ["Virtual"],
|
|
||||||
enableTotalRecordCount: true,
|
|
||||||
startIndex: pageParam,
|
|
||||||
limit: pageSize,
|
|
||||||
includeItemTypes: [itemType],
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.data.Items || [];
|
|
||||||
},
|
|
||||||
[api, itemType, user?.Id],
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
|
|
||||||
useInfiniteQuery({
|
|
||||||
queryKey: ["favorites", "see-all", itemType],
|
|
||||||
queryFn: ({ pageParam = 0 }) => fetchItems({ pageParam }),
|
|
||||||
getNextPageParam: (lastPage, pages) => {
|
|
||||||
if (!lastPage || lastPage.length < pageSize) return undefined;
|
|
||||||
return pages.reduce((acc, page) => acc + page.length, 0);
|
|
||||||
},
|
|
||||||
initialPageParam: 0,
|
|
||||||
enabled: !!api && !!user?.Id && !!itemType,
|
|
||||||
});
|
|
||||||
|
|
||||||
const flatData = useMemo(() => data?.pages.flat() ?? [], [data]);
|
|
||||||
|
|
||||||
const nrOfCols = useMemo(() => {
|
|
||||||
if (screenWidth < 350) return 2;
|
|
||||||
if (screenWidth < 600) return 3;
|
|
||||||
if (screenWidth < 900) return 5;
|
|
||||||
return 6;
|
|
||||||
}, [screenWidth]);
|
|
||||||
|
|
||||||
const renderItem = useCallback(
|
|
||||||
({ item, index }: { item: BaseItemDto; index: number }) => (
|
|
||||||
<TouchableItemRouter
|
|
||||||
item={item}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
alignSelf:
|
|
||||||
index % nrOfCols === 0
|
|
||||||
? "flex-end"
|
|
||||||
: (index + 1) % nrOfCols === 0
|
|
||||||
? "flex-start"
|
|
||||||
: "center",
|
|
||||||
width: "89%",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ItemPoster item={item} />
|
|
||||||
<ItemCardText item={item} />
|
|
||||||
</View>
|
|
||||||
</TouchableItemRouter>
|
|
||||||
),
|
|
||||||
[nrOfCols],
|
|
||||||
);
|
|
||||||
|
|
||||||
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
|
||||||
|
|
||||||
const handleEndReached = useCallback(() => {
|
|
||||||
if (hasNextPage) {
|
|
||||||
fetchNextPage();
|
|
||||||
}
|
|
||||||
}, [fetchNextPage, hasNextPage]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Stack.Screen
|
|
||||||
options={{
|
|
||||||
headerTitle: headerTitle,
|
|
||||||
headerBlurEffect: "none",
|
|
||||||
headerTransparent: true,
|
|
||||||
headerShadowVisible: false,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{!itemType ? (
|
|
||||||
<View className='flex-1 items-center justify-center px-6'>
|
|
||||||
<Text className='text-neutral-500'>
|
|
||||||
{t("favorites.noData", { defaultValue: "No items found." })}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : isLoading ? (
|
|
||||||
<View className='justify-center items-center h-full'>
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<FlashList
|
|
||||||
data={flatData}
|
|
||||||
renderItem={renderItem}
|
|
||||||
keyExtractor={keyExtractor}
|
|
||||||
numColumns={nrOfCols}
|
|
||||||
onEndReached={handleEndReached}
|
|
||||||
onEndReachedThreshold={0.8}
|
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
|
||||||
contentContainerStyle={{
|
|
||||||
paddingBottom: 24,
|
|
||||||
paddingLeft: insets.left,
|
|
||||||
paddingRight: insets.right,
|
|
||||||
}}
|
|
||||||
ItemSeparatorComponent={() => (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: 10,
|
|
||||||
height: 10,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
ListEmptyComponent={
|
|
||||||
<View className='flex flex-col items-center justify-center h-full py-12'>
|
|
||||||
<Text className='font-bold text-xl text-neutral-500'>
|
|
||||||
{t("home.no_items", { defaultValue: "No items" })}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
ListFooterComponent={
|
|
||||||
isFetching ? (
|
|
||||||
<View style={{ paddingVertical: 16 }}>
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -166,24 +166,6 @@ 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={{
|
||||||
@@ -256,42 +238,6 @@ 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,11 +70,6 @@ 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
|
||||||
|
|||||||
@@ -1,177 +0,0 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform, ScrollView, View } from "react-native";
|
|
||||||
import { Switch } from "react-native-gesture-handler";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
|
||||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
|
|
||||||
const CACHE_SIZE_OPTIONS = [
|
|
||||||
{ label: "100 MB", value: 100 },
|
|
||||||
{ label: "250 MB", value: 250 },
|
|
||||||
{ label: "500 MB", value: 500 },
|
|
||||||
{ label: "1 GB", value: 1024 },
|
|
||||||
{ label: "2 GB", value: 2048 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const LOOKAHEAD_COUNT_OPTIONS = [
|
|
||||||
{ label: "1 song", value: 1 },
|
|
||||||
{ label: "2 songs", value: 2 },
|
|
||||||
{ label: "3 songs", value: 3 },
|
|
||||||
{ label: "5 songs", value: 5 },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function MusicSettingsPage() {
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const cacheSizeOptions = useMemo(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
options: CACHE_SIZE_OPTIONS.map((option) => ({
|
|
||||||
type: "radio" as const,
|
|
||||||
label: option.label,
|
|
||||||
value: String(option.value),
|
|
||||||
selected: option.value === settings?.audioMaxCacheSizeMB,
|
|
||||||
onPress: () => updateSettings({ audioMaxCacheSizeMB: option.value }),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[settings?.audioMaxCacheSizeMB, updateSettings],
|
|
||||||
);
|
|
||||||
|
|
||||||
const currentCacheSizeLabel =
|
|
||||||
CACHE_SIZE_OPTIONS.find((o) => o.value === settings?.audioMaxCacheSizeMB)
|
|
||||||
?.label ?? `${settings?.audioMaxCacheSizeMB} MB`;
|
|
||||||
|
|
||||||
const lookaheadCountOptions = useMemo(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
options: LOOKAHEAD_COUNT_OPTIONS.map((option) => ({
|
|
||||||
type: "radio" as const,
|
|
||||||
label: option.label,
|
|
||||||
value: String(option.value),
|
|
||||||
selected: option.value === settings?.audioLookaheadCount,
|
|
||||||
onPress: () => updateSettings({ audioLookaheadCount: option.value }),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[settings?.audioLookaheadCount, updateSettings],
|
|
||||||
);
|
|
||||||
|
|
||||||
const currentLookaheadLabel =
|
|
||||||
LOOKAHEAD_COUNT_OPTIONS.find(
|
|
||||||
(o) => o.value === settings?.audioLookaheadCount,
|
|
||||||
)?.label ?? `${settings?.audioLookaheadCount} songs`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView
|
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
|
||||||
contentContainerStyle={{
|
|
||||||
paddingLeft: insets.left,
|
|
||||||
paddingRight: insets.right,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
className='p-4 flex flex-col'
|
|
||||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
|
||||||
>
|
|
||||||
<ListGroup
|
|
||||||
title={t("home.settings.music.playback_title")}
|
|
||||||
description={
|
|
||||||
<Text className='text-[#8E8D91] text-xs'>
|
|
||||||
{t("home.settings.music.playback_description")}
|
|
||||||
</Text>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ListItem
|
|
||||||
title={t("home.settings.music.prefer_downloaded")}
|
|
||||||
disabled={pluginSettings?.preferLocalAudio?.locked}
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
value={settings.preferLocalAudio}
|
|
||||||
disabled={pluginSettings?.preferLocalAudio?.locked}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
updateSettings({ preferLocalAudio: value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
</ListGroup>
|
|
||||||
|
|
||||||
<View className='mt-4'>
|
|
||||||
<ListGroup
|
|
||||||
title={t("home.settings.music.caching_title")}
|
|
||||||
description={
|
|
||||||
<Text className='text-[#8E8D91] text-xs'>
|
|
||||||
{t("home.settings.music.caching_description")}
|
|
||||||
</Text>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ListItem
|
|
||||||
title={t("home.settings.music.lookahead_enabled")}
|
|
||||||
disabled={pluginSettings?.audioLookaheadEnabled?.locked}
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
value={settings.audioLookaheadEnabled}
|
|
||||||
disabled={pluginSettings?.audioLookaheadEnabled?.locked}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
updateSettings({ audioLookaheadEnabled: value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
<ListItem
|
|
||||||
title={t("home.settings.music.lookahead_count")}
|
|
||||||
disabled={
|
|
||||||
pluginSettings?.audioLookaheadCount?.locked ||
|
|
||||||
!settings.audioLookaheadEnabled
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<PlatformDropdown
|
|
||||||
groups={lookaheadCountOptions}
|
|
||||||
trigger={
|
|
||||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
|
||||||
<Text className='mr-1 text-[#8E8D91]'>
|
|
||||||
{currentLookaheadLabel}
|
|
||||||
</Text>
|
|
||||||
<Ionicons
|
|
||||||
name='chevron-expand-sharp'
|
|
||||||
size={18}
|
|
||||||
color='#5A5960'
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
title={t("home.settings.music.lookahead_count")}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
<ListItem
|
|
||||||
title={t("home.settings.music.max_cache_size")}
|
|
||||||
disabled={pluginSettings?.audioMaxCacheSizeMB?.locked}
|
|
||||||
>
|
|
||||||
<PlatformDropdown
|
|
||||||
groups={cacheSizeOptions}
|
|
||||||
trigger={
|
|
||||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
|
||||||
<Text className='mr-1 text-[#8E8D91]'>
|
|
||||||
{currentCacheSizeLabel}
|
|
||||||
</Text>
|
|
||||||
<Ionicons
|
|
||||||
name='chevron-expand-sharp'
|
|
||||||
size={18}
|
|
||||||
color='#5A5960'
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
title={t("home.settings.music.max_cache_size")}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
</ListGroup>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { ScrollView } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
|
||||||
import { KefinTweaksSettings } from "@/components/settings/KefinTweaks";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
|
|
||||||
export default function page() {
|
|
||||||
const { pluginSettings } = useSettings();
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView
|
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
|
||||||
contentContainerStyle={{
|
|
||||||
paddingLeft: insets.left,
|
|
||||||
paddingRight: insets.right,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DisabledSetting
|
|
||||||
disabled={pluginSettings?.useKefinTweaks?.locked === true}
|
|
||||||
className='px-4'
|
|
||||||
>
|
|
||||||
<KefinTweaksSettings />
|
|
||||||
</DisabledSetting>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -60,7 +60,7 @@ export default function page() {
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [navigation, value, pluginSettings?.marlinServerUrl?.locked, t]);
|
}, [navigation, value]);
|
||||||
|
|
||||||
if (!settings) return null;
|
if (!settings) return null;
|
||||||
|
|
||||||
@@ -75,10 +75,7 @@ export default function page() {
|
|||||||
<DisabledSetting disabled={disabled} className='px-4'>
|
<DisabledSetting disabled={disabled} className='px-4'>
|
||||||
<ListGroup>
|
<ListGroup>
|
||||||
<DisabledSetting
|
<DisabledSetting
|
||||||
disabled={
|
disabled={pluginSettings?.searchEngine?.locked === true}
|
||||||
pluginSettings?.searchEngine?.locked === true ||
|
|
||||||
!!pluginSettings?.streamyStatsServerUrl?.value
|
|
||||||
}
|
|
||||||
showText={!pluginSettings?.marlinServerUrl?.locked}
|
showText={!pluginSettings?.marlinServerUrl?.locked}
|
||||||
>
|
>
|
||||||
<ListItem
|
<ListItem
|
||||||
@@ -92,7 +89,6 @@ 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",
|
||||||
|
|||||||
@@ -1,262 +0,0 @@
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useNavigation } from "expo-router";
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
Linking,
|
|
||||||
ScrollView,
|
|
||||||
Switch,
|
|
||||||
TextInput,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { toast } from "sonner-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
|
|
||||||
export default function page() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const navigation = useNavigation();
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
const {
|
|
||||||
settings,
|
|
||||||
updateSettings,
|
|
||||||
pluginSettings,
|
|
||||||
refreshStreamyfinPluginSettings,
|
|
||||||
} = useSettings();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
// Local state for all editable fields
|
|
||||||
const [url, setUrl] = useState<string>(settings?.streamyStatsServerUrl || "");
|
|
||||||
const [useForSearch, setUseForSearch] = useState<boolean>(
|
|
||||||
settings?.searchEngine === "Streamystats",
|
|
||||||
);
|
|
||||||
const [movieRecs, setMovieRecs] = useState<boolean>(
|
|
||||||
settings?.streamyStatsMovieRecommendations ?? false,
|
|
||||||
);
|
|
||||||
const [seriesRecs, setSeriesRecs] = useState<boolean>(
|
|
||||||
settings?.streamyStatsSeriesRecommendations ?? false,
|
|
||||||
);
|
|
||||||
const [promotedWatchlists, setPromotedWatchlists] = useState<boolean>(
|
|
||||||
settings?.streamyStatsPromotedWatchlists ?? false,
|
|
||||||
);
|
|
||||||
const [hideWatchlistsTab, setHideWatchlistsTab] = useState<boolean>(
|
|
||||||
settings?.hideWatchlistsTab ?? false,
|
|
||||||
);
|
|
||||||
|
|
||||||
const isUrlLocked = pluginSettings?.streamyStatsServerUrl?.locked === true;
|
|
||||||
const isStreamystatsEnabled = !!url;
|
|
||||||
|
|
||||||
const onSave = useCallback(() => {
|
|
||||||
const cleanUrl = url.endsWith("/") ? url.slice(0, -1) : url;
|
|
||||||
updateSettings({
|
|
||||||
streamyStatsServerUrl: cleanUrl,
|
|
||||||
searchEngine: useForSearch ? "Streamystats" : "Jellyfin",
|
|
||||||
streamyStatsMovieRecommendations: movieRecs,
|
|
||||||
streamyStatsSeriesRecommendations: seriesRecs,
|
|
||||||
streamyStatsPromotedWatchlists: promotedWatchlists,
|
|
||||||
hideWatchlistsTab: hideWatchlistsTab,
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["streamystats"] });
|
|
||||||
toast.success(t("home.settings.plugins.streamystats.toasts.saved"));
|
|
||||||
}, [
|
|
||||||
url,
|
|
||||||
useForSearch,
|
|
||||||
movieRecs,
|
|
||||||
seriesRecs,
|
|
||||||
promotedWatchlists,
|
|
||||||
hideWatchlistsTab,
|
|
||||||
updateSettings,
|
|
||||||
queryClient,
|
|
||||||
t,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Set up header save button
|
|
||||||
useEffect(() => {
|
|
||||||
navigation.setOptions({
|
|
||||||
headerRight: () => (
|
|
||||||
<TouchableOpacity onPress={onSave}>
|
|
||||||
<Text className='text-blue-500 font-medium'>
|
|
||||||
{t("home.settings.plugins.streamystats.save")}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}, [navigation, onSave, t]);
|
|
||||||
|
|
||||||
const handleClearStreamystats = useCallback(() => {
|
|
||||||
setUrl("");
|
|
||||||
setUseForSearch(false);
|
|
||||||
setMovieRecs(false);
|
|
||||||
setSeriesRecs(false);
|
|
||||||
setPromotedWatchlists(false);
|
|
||||||
setHideWatchlistsTab(false);
|
|
||||||
updateSettings({
|
|
||||||
streamyStatsServerUrl: "",
|
|
||||||
searchEngine: "Jellyfin",
|
|
||||||
streamyStatsMovieRecommendations: false,
|
|
||||||
streamyStatsSeriesRecommendations: false,
|
|
||||||
streamyStatsPromotedWatchlists: false,
|
|
||||||
hideWatchlistsTab: false,
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["streamystats"] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
|
||||||
toast.success(t("home.settings.plugins.streamystats.toasts.disabled"));
|
|
||||||
}, [updateSettings, queryClient, t]);
|
|
||||||
|
|
||||||
const handleOpenLink = () => {
|
|
||||||
Linking.openURL("https://github.com/fredrikburmester/streamystats");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRefreshFromServer = useCallback(async () => {
|
|
||||||
const newPluginSettings = await refreshStreamyfinPluginSettings(true);
|
|
||||||
// Update local state with new values
|
|
||||||
const newUrl = newPluginSettings?.streamyStatsServerUrl?.value || "";
|
|
||||||
setUrl(newUrl);
|
|
||||||
if (newUrl) {
|
|
||||||
setUseForSearch(true);
|
|
||||||
}
|
|
||||||
toast.success(t("home.settings.plugins.streamystats.toasts.refreshed"));
|
|
||||||
}, [refreshStreamyfinPluginSettings, t]);
|
|
||||||
|
|
||||||
if (!settings) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView
|
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
|
||||||
contentContainerStyle={{
|
|
||||||
paddingLeft: insets.left,
|
|
||||||
paddingRight: insets.right,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View className='px-4'>
|
|
||||||
<ListGroup className='flex-1'>
|
|
||||||
<ListItem
|
|
||||||
title={t("home.settings.plugins.streamystats.url")}
|
|
||||||
disabledByAdmin={isUrlLocked}
|
|
||||||
>
|
|
||||||
<TextInput
|
|
||||||
editable={!isUrlLocked}
|
|
||||||
className='text-white text-right flex-1'
|
|
||||||
placeholder={t(
|
|
||||||
"home.settings.plugins.streamystats.server_url_placeholder",
|
|
||||||
)}
|
|
||||||
value={url}
|
|
||||||
keyboardType='url'
|
|
||||||
returnKeyType='done'
|
|
||||||
autoCapitalize='none'
|
|
||||||
textContentType='URL'
|
|
||||||
onChangeText={setUrl}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
</ListGroup>
|
|
||||||
|
|
||||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
|
||||||
{t("home.settings.plugins.streamystats.streamystats_search_hint")}{" "}
|
|
||||||
<Text className='text-blue-500' onPress={handleOpenLink}>
|
|
||||||
{t(
|
|
||||||
"home.settings.plugins.streamystats.read_more_about_streamystats",
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<ListGroup
|
|
||||||
title={t("home.settings.plugins.streamystats.features_title")}
|
|
||||||
className='mt-4'
|
|
||||||
>
|
|
||||||
<ListItem
|
|
||||||
title={t("home.settings.plugins.streamystats.enable_search")}
|
|
||||||
disabledByAdmin={pluginSettings?.searchEngine?.locked === true}
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
value={useForSearch}
|
|
||||||
disabled={!isStreamystatsEnabled}
|
|
||||||
onValueChange={setUseForSearch}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
<ListItem
|
|
||||||
title={t(
|
|
||||||
"home.settings.plugins.streamystats.enable_movie_recommendations",
|
|
||||||
)}
|
|
||||||
disabledByAdmin={
|
|
||||||
pluginSettings?.streamyStatsMovieRecommendations?.locked === true
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
value={movieRecs}
|
|
||||||
onValueChange={setMovieRecs}
|
|
||||||
disabled={!isStreamystatsEnabled}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
<ListItem
|
|
||||||
title={t(
|
|
||||||
"home.settings.plugins.streamystats.enable_series_recommendations",
|
|
||||||
)}
|
|
||||||
disabledByAdmin={
|
|
||||||
pluginSettings?.streamyStatsSeriesRecommendations?.locked === true
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
value={seriesRecs}
|
|
||||||
onValueChange={setSeriesRecs}
|
|
||||||
disabled={!isStreamystatsEnabled}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
<ListItem
|
|
||||||
title={t(
|
|
||||||
"home.settings.plugins.streamystats.enable_promoted_watchlists",
|
|
||||||
)}
|
|
||||||
disabledByAdmin={
|
|
||||||
pluginSettings?.streamyStatsPromotedWatchlists?.locked === true
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
value={promotedWatchlists}
|
|
||||||
onValueChange={setPromotedWatchlists}
|
|
||||||
disabled={!isStreamystatsEnabled}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
<ListItem
|
|
||||||
title={t("home.settings.plugins.streamystats.hide_watchlists_tab")}
|
|
||||||
disabledByAdmin={pluginSettings?.hideWatchlistsTab?.locked === true}
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
value={hideWatchlistsTab}
|
|
||||||
onValueChange={setHideWatchlistsTab}
|
|
||||||
disabled={!isStreamystatsEnabled}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
</ListGroup>
|
|
||||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
|
||||||
{t("home.settings.plugins.streamystats.home_sections_hint")}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleRefreshFromServer}
|
|
||||||
className='mt-6 py-3 rounded-xl bg-neutral-800'
|
|
||||||
>
|
|
||||||
<Text className='text-center text-blue-500'>
|
|
||||||
{t("home.settings.plugins.streamystats.refresh_from_server")}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
{/* Disable button - only show if URL is not locked and Streamystats is enabled */}
|
|
||||||
{!isUrlLocked && isStreamystatsEnabled && (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleClearStreamystats}
|
|
||||||
className='mt-3 mb-4 py-3 rounded-xl bg-neutral-800'
|
|
||||||
>
|
|
||||||
<Text className='text-center text-red-500'>
|
|
||||||
{t("home.settings.plugins.streamystats.disable_streamystats")}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { ItemFields } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
@@ -21,16 +20,7 @@ const Page: React.FC = () => {
|
|||||||
const { offline } = useLocalSearchParams() as { offline?: string };
|
const { offline } = useLocalSearchParams() as { offline?: string };
|
||||||
const isOffline = offline === "true";
|
const isOffline = offline === "true";
|
||||||
|
|
||||||
// Exclude MediaSources/MediaStreams from initial fetch for faster loading
|
const { data: item, isError } = useItemQuery(id, isOffline);
|
||||||
// (especially important for plugins like Gelato)
|
|
||||||
const { data: item, isError } = useItemQuery(id, isOffline, undefined, [
|
|
||||||
ItemFields.MediaSources,
|
|
||||||
ItemFields.MediaSourceCount,
|
|
||||||
ItemFields.MediaStreams,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Lazily preload item with full media sources in background
|
|
||||||
const { data: itemWithSources } = useItemQuery(id, isOffline, undefined, []);
|
|
||||||
|
|
||||||
const opacity = useSharedValue(1);
|
const opacity = useSharedValue(1);
|
||||||
const animatedStyle = useAnimatedStyle(() => {
|
const animatedStyle = useAnimatedStyle(() => {
|
||||||
@@ -100,13 +90,7 @@ const Page: React.FC = () => {
|
|||||||
<View className='h-12 bg-neutral-900 rounded-lg w-full mb-2' />
|
<View className='h-12 bg-neutral-900 rounded-lg w-full mb-2' />
|
||||||
<View className='h-24 bg-neutral-900 rounded-lg mb-1 w-full' />
|
<View className='h-24 bg-neutral-900 rounded-lg mb-1 w-full' />
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
{item && (
|
{item && <ItemContent item={item} isOffline={isOffline} />}
|
||||||
<ItemContent
|
|
||||||
item={item}
|
|
||||||
isOffline={isOffline}
|
|
||||||
itemWithSources={itemWithSources}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -21,18 +21,19 @@ export default function page() {
|
|||||||
companyId: string;
|
companyId: string;
|
||||||
name: string;
|
name: string;
|
||||||
image: string;
|
image: string;
|
||||||
type: DiscoverSliderType; //This gets converted to a string because it's a url param
|
type: DiscoverSliderType;
|
||||||
};
|
};
|
||||||
|
|
||||||
const { data, fetchNextPage, hasNextPage, isLoading } = useInfiniteQuery({
|
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||||
queryKey: ["jellyseerr", "company", type, companyId],
|
queryKey: ["jellyseerr", "company", type, companyId],
|
||||||
queryFn: async ({ pageParam }) => {
|
queryFn: async ({ pageParam }) => {
|
||||||
const params: any = {
|
const params: any = {
|
||||||
page: Number(pageParam),
|
page: Number(pageParam),
|
||||||
};
|
};
|
||||||
|
|
||||||
return jellyseerrApi?.discover(
|
return jellyseerrApi?.discover(
|
||||||
`${
|
`${
|
||||||
Number(type) === DiscoverSliderType.NETWORKS
|
type === DiscoverSliderType.NETWORKS
|
||||||
? Endpoints.DISCOVER_TV_NETWORK
|
? Endpoints.DISCOVER_TV_NETWORK
|
||||||
: Endpoints.DISCOVER_MOVIES_STUDIO
|
: Endpoints.DISCOVER_MOVIES_STUDIO
|
||||||
}/${companyId}`,
|
}/${companyId}`,
|
||||||
@@ -85,7 +86,6 @@ export default function page() {
|
|||||||
fetchNextPage();
|
fetchNextPage();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
isLoading={isLoading}
|
|
||||||
logo={
|
logo={
|
||||||
<Image
|
<Image
|
||||||
id={companyId}
|
id={companyId}
|
||||||
@@ -14,7 +14,6 @@ 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";
|
||||||
@@ -34,16 +33,8 @@ import {
|
|||||||
type IssueType,
|
type IssueType,
|
||||||
IssueTypeName,
|
IssueTypeName,
|
||||||
} from "@/utils/jellyseerr/server/constants/issue";
|
} from "@/utils/jellyseerr/server/constants/issue";
|
||||||
import {
|
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||||
MediaRequestStatus,
|
|
||||||
MediaType,
|
|
||||||
} from "@/utils/jellyseerr/server/constants/media";
|
|
||||||
import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
|
|
||||||
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
import 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,
|
||||||
@@ -67,7 +58,7 @@ const Page: React.FC = () => {
|
|||||||
} & Partial<MovieResult | TvResult | MovieDetails | TvDetails>;
|
} & Partial<MovieResult | TvResult | MovieDetails | TvDetails>;
|
||||||
|
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
|
const { jellyseerrApi, requestMedia } = useJellyseerr();
|
||||||
|
|
||||||
const [issueType, setIssueType] = useState<IssueType>();
|
const [issueType, setIssueType] = useState<IssueType>();
|
||||||
const [issueMessage, setIssueMessage] = useState<string>();
|
const [issueMessage, setIssueMessage] = useState<string>();
|
||||||
@@ -100,46 +91,6 @@ const Page: React.FC = () => {
|
|||||||
const [canRequest, hasAdvancedRequestPermission] =
|
const [canRequest, hasAdvancedRequestPermission] =
|
||||||
useJellyseerrCanRequest(details);
|
useJellyseerrCanRequest(details);
|
||||||
|
|
||||||
const canManageRequests = useMemo(() => {
|
|
||||||
if (!jellyseerrUser) return false;
|
|
||||||
return hasPermission(
|
|
||||||
Permission.MANAGE_REQUESTS,
|
|
||||||
jellyseerrUser.permissions,
|
|
||||||
);
|
|
||||||
}, [jellyseerrUser]);
|
|
||||||
|
|
||||||
const pendingRequest = useMemo(() => {
|
|
||||||
return details?.mediaInfo?.requests?.find(
|
|
||||||
(r: MediaRequest) => r.status === MediaRequestStatus.PENDING,
|
|
||||||
);
|
|
||||||
}, [details]);
|
|
||||||
|
|
||||||
const handleApproveRequest = useCallback(async () => {
|
|
||||||
if (!pendingRequest?.id) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await jellyseerrApi?.approveRequest(pendingRequest.id);
|
|
||||||
toast.success(t("jellyseerr.toasts.request_approved"));
|
|
||||||
refetch();
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(t("jellyseerr.toasts.failed_to_approve_request"));
|
|
||||||
console.error("Failed to approve request:", error);
|
|
||||||
}
|
|
||||||
}, [jellyseerrApi, pendingRequest, refetch, t]);
|
|
||||||
|
|
||||||
const handleDeclineRequest = useCallback(async () => {
|
|
||||||
if (!pendingRequest?.id) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await jellyseerrApi?.declineRequest(pendingRequest.id);
|
|
||||||
toast.success(t("jellyseerr.toasts.request_declined"));
|
|
||||||
refetch();
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(t("jellyseerr.toasts.failed_to_decline_request"));
|
|
||||||
console.error("Failed to decline request:", error);
|
|
||||||
}
|
|
||||||
}, [jellyseerrApi, pendingRequest, refetch, t]);
|
|
||||||
|
|
||||||
const renderBackdrop = useCallback(
|
const renderBackdrop = useCallback(
|
||||||
(props: BottomSheetBackdropProps) => (
|
(props: BottomSheetBackdropProps) => (
|
||||||
<BottomSheetBackdrop
|
<BottomSheetBackdrop
|
||||||
@@ -383,60 +334,6 @@ const Page: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
{canManageRequests && pendingRequest && (
|
|
||||||
<View className='flex flex-col space-y-2 mt-4'>
|
|
||||||
<View className='flex flex-row items-center space-x-2'>
|
|
||||||
<Ionicons name='person-outline' size={16} color='#9CA3AF' />
|
|
||||||
<Text className='text-sm text-neutral-400'>
|
|
||||||
{t("jellyseerr.requested_by", {
|
|
||||||
user:
|
|
||||||
pendingRequest.requestedBy?.displayName ||
|
|
||||||
pendingRequest.requestedBy?.username ||
|
|
||||||
pendingRequest.requestedBy?.jellyfinUsername ||
|
|
||||||
t("jellyseerr.unknown_user"),
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View className='flex flex-row space-x-2'>
|
|
||||||
<Button
|
|
||||||
className='flex-1 bg-green-600/50 border-green-400 ring-green-400 text-green-100'
|
|
||||||
color='transparent'
|
|
||||||
onPress={handleApproveRequest}
|
|
||||||
iconLeft={
|
|
||||||
<Ionicons
|
|
||||||
name='checkmark-outline'
|
|
||||||
size={20}
|
|
||||||
color='white'
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
style={{
|
|
||||||
borderWidth: 1,
|
|
||||||
borderStyle: "solid",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text className='text-sm'>{t("jellyseerr.approve")}</Text>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className='flex-1 bg-red-600/50 border-red-400 ring-red-400 text-red-100'
|
|
||||||
color='transparent'
|
|
||||||
onPress={handleDeclineRequest}
|
|
||||||
iconLeft={
|
|
||||||
<Ionicons
|
|
||||||
name='close-outline'
|
|
||||||
size={20}
|
|
||||||
color='white'
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
style={{
|
|
||||||
borderWidth: 1,
|
|
||||||
borderStyle: "solid",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text className='text-sm'>{t("jellyseerr.decline")}</Text>
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
<OverviewText text={result.overview} className='mt-4' />
|
<OverviewText text={result.overview} className='mt-4' />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -1,300 +0,0 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { getItemsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { FlashList } from "@shopify/flash-list";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
ActivityIndicator,
|
|
||||||
Dimensions,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
|
|
||||||
import { MusicTrackItem } from "@/components/music/MusicTrackItem";
|
|
||||||
import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet";
|
|
||||||
import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet";
|
|
||||||
import {
|
|
||||||
downloadTrack,
|
|
||||||
isPermanentlyDownloaded,
|
|
||||||
} from "@/providers/AudioStorage";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
|
|
||||||
import { getAudioStreamUrl } from "@/utils/jellyfin/audio/getAudioStreamUrl";
|
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
|
||||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
|
||||||
|
|
||||||
const { width: SCREEN_WIDTH } = Dimensions.get("window");
|
|
||||||
const ARTWORK_SIZE = SCREEN_WIDTH * 0.5;
|
|
||||||
|
|
||||||
export default function AlbumDetailScreen() {
|
|
||||||
const { albumId } = useLocalSearchParams<{ albumId: string }>();
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const navigation = useNavigation();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { playQueue } = useMusicPlayer();
|
|
||||||
|
|
||||||
const [selectedTrack, setSelectedTrack] = useState<BaseItemDto | null>(null);
|
|
||||||
const [trackOptionsOpen, setTrackOptionsOpen] = useState(false);
|
|
||||||
const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false);
|
|
||||||
const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false);
|
|
||||||
const [isDownloading, setIsDownloading] = useState(false);
|
|
||||||
|
|
||||||
const handleTrackOptionsPress = useCallback((track: BaseItemDto) => {
|
|
||||||
setSelectedTrack(track);
|
|
||||||
setTrackOptionsOpen(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleAddToPlaylist = useCallback(() => {
|
|
||||||
setPlaylistPickerOpen(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleCreateNewPlaylist = useCallback(() => {
|
|
||||||
setCreatePlaylistOpen(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const { data: album, isLoading: loadingAlbum } = useQuery({
|
|
||||||
queryKey: ["music-album", albumId, user?.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await getUserLibraryApi(api!).getItem({
|
|
||||||
userId: user?.Id,
|
|
||||||
itemId: albumId!,
|
|
||||||
});
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
enabled: !!api && !!user?.Id && !!albumId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: tracks, isLoading: loadingTracks } = useQuery({
|
|
||||||
queryKey: ["music-album-tracks", albumId, user?.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await getItemsApi(api!).getItems({
|
|
||||||
userId: user?.Id,
|
|
||||||
parentId: albumId,
|
|
||||||
sortBy: ["IndexNumber"],
|
|
||||||
sortOrder: ["Ascending"],
|
|
||||||
});
|
|
||||||
return response.data.Items || [];
|
|
||||||
},
|
|
||||||
enabled: !!api && !!user?.Id && !!albumId,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
navigation.setOptions({
|
|
||||||
title: album?.Name ?? "",
|
|
||||||
headerTransparent: true,
|
|
||||||
headerStyle: { backgroundColor: "transparent" },
|
|
||||||
headerShadowVisible: false,
|
|
||||||
});
|
|
||||||
}, [album?.Name, navigation]);
|
|
||||||
|
|
||||||
const imageUrl = useMemo(
|
|
||||||
() => (album ? getPrimaryImageUrl({ api, item: album }) : null),
|
|
||||||
[api, album],
|
|
||||||
);
|
|
||||||
|
|
||||||
const totalDuration = useMemo(() => {
|
|
||||||
if (!tracks) return "";
|
|
||||||
const totalTicks = tracks.reduce(
|
|
||||||
(acc, track) => acc + (track.RunTimeTicks || 0),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
return runtimeTicksToMinutes(totalTicks);
|
|
||||||
}, [tracks]);
|
|
||||||
|
|
||||||
const handlePlayAll = useCallback(() => {
|
|
||||||
if (tracks && tracks.length > 0) {
|
|
||||||
playQueue(tracks, 0);
|
|
||||||
}
|
|
||||||
}, [playQueue, tracks]);
|
|
||||||
|
|
||||||
const handleShuffle = useCallback(() => {
|
|
||||||
if (tracks && tracks.length > 0) {
|
|
||||||
const shuffled = [...tracks].sort(() => Math.random() - 0.5);
|
|
||||||
playQueue(shuffled, 0);
|
|
||||||
}
|
|
||||||
}, [playQueue, tracks]);
|
|
||||||
|
|
||||||
// Check if all tracks are already permanently downloaded
|
|
||||||
const allTracksDownloaded = useMemo(() => {
|
|
||||||
if (!tracks || tracks.length === 0) return false;
|
|
||||||
return tracks.every((track) => isPermanentlyDownloaded(track.Id));
|
|
||||||
}, [tracks]);
|
|
||||||
|
|
||||||
const handleDownloadAlbum = useCallback(async () => {
|
|
||||||
if (!tracks || !api || !user?.Id || isDownloading) return;
|
|
||||||
|
|
||||||
setIsDownloading(true);
|
|
||||||
try {
|
|
||||||
for (const track of tracks) {
|
|
||||||
if (!track.Id || isPermanentlyDownloaded(track.Id)) continue;
|
|
||||||
const result = await getAudioStreamUrl(api, user.Id, track.Id);
|
|
||||||
if (result?.url && !result.isTranscoding) {
|
|
||||||
await downloadTrack(track.Id, result.url, {
|
|
||||||
permanent: true,
|
|
||||||
container: result.mediaSource?.Container || undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Silent fail
|
|
||||||
}
|
|
||||||
setIsDownloading(false);
|
|
||||||
}, [tracks, api, user?.Id, isDownloading]);
|
|
||||||
|
|
||||||
const isLoading = loadingAlbum || loadingTracks;
|
|
||||||
|
|
||||||
// Only show loading if we have no cached data to display
|
|
||||||
if (isLoading && !album) {
|
|
||||||
return (
|
|
||||||
<View className='flex-1 justify-center items-center bg-black'>
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!album) {
|
|
||||||
return (
|
|
||||||
<View className='flex-1 justify-center items-center bg-black'>
|
|
||||||
<Text className='text-neutral-500'>{t("music.album_not_found")}</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FlashList
|
|
||||||
data={tracks || []}
|
|
||||||
contentContainerStyle={{
|
|
||||||
paddingBottom: insets.bottom + 100,
|
|
||||||
}}
|
|
||||||
ListHeaderComponent={
|
|
||||||
<View
|
|
||||||
className='items-center px-4 pb-6 bg-black'
|
|
||||||
style={{ paddingTop: insets.top + 60 }}
|
|
||||||
>
|
|
||||||
{/* Album artwork */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: ARTWORK_SIZE,
|
|
||||||
height: ARTWORK_SIZE,
|
|
||||||
borderRadius: 8,
|
|
||||||
overflow: "hidden",
|
|
||||||
backgroundColor: "#1a1a1a",
|
|
||||||
shadowColor: "#000",
|
|
||||||
shadowOffset: { width: 0, height: 8 },
|
|
||||||
shadowOpacity: 0.3,
|
|
||||||
shadowRadius: 12,
|
|
||||||
elevation: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{imageUrl ? (
|
|
||||||
<Image
|
|
||||||
source={{ uri: imageUrl }}
|
|
||||||
style={{ width: "100%", height: "100%" }}
|
|
||||||
contentFit='cover'
|
|
||||||
cachePolicy='memory-disk'
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<View className='flex-1 items-center justify-center bg-neutral-800'>
|
|
||||||
<Ionicons name='disc' size={60} color='#666' />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Album info */}
|
|
||||||
<Text className='text-white text-xl font-bold mt-4 text-center'>
|
|
||||||
{album.Name}
|
|
||||||
</Text>
|
|
||||||
<Text className='text-purple-400 text-base mt-1'>
|
|
||||||
{album.AlbumArtist || album.Artists?.join(", ")}
|
|
||||||
</Text>
|
|
||||||
<Text className='text-neutral-500 text-sm mt-1'>
|
|
||||||
{album.ProductionYear && `${album.ProductionYear} • `}
|
|
||||||
{tracks?.length} tracks • {totalDuration}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* Play buttons */}
|
|
||||||
<View className='flex flex-row mt-4 items-center'>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handlePlayAll}
|
|
||||||
className='flex flex-row items-center bg-purple-600 px-6 py-3 rounded-full mr-3'
|
|
||||||
>
|
|
||||||
<Ionicons name='play' size={20} color='white' />
|
|
||||||
<Text className='text-white font-medium ml-2'>
|
|
||||||
{t("music.play")}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleShuffle}
|
|
||||||
className='flex flex-row items-center bg-neutral-800 px-6 py-3 rounded-full mr-3'
|
|
||||||
>
|
|
||||||
<Ionicons name='shuffle' size={20} color='white' />
|
|
||||||
<Text className='text-white font-medium ml-2'>
|
|
||||||
{t("music.shuffle")}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleDownloadAlbum}
|
|
||||||
disabled={allTracksDownloaded || isDownloading}
|
|
||||||
className='flex items-center justify-center bg-neutral-800 p-3 rounded-full'
|
|
||||||
>
|
|
||||||
{isDownloading ? (
|
|
||||||
<ActivityIndicator size={20} color='white' />
|
|
||||||
) : (
|
|
||||||
<Ionicons
|
|
||||||
name={
|
|
||||||
allTracksDownloaded
|
|
||||||
? "checkmark-circle"
|
|
||||||
: "download-outline"
|
|
||||||
}
|
|
||||||
size={20}
|
|
||||||
color={allTracksDownloaded ? "#22c55e" : "white"}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
renderItem={({ item, index }) => (
|
|
||||||
<MusicTrackItem
|
|
||||||
track={item}
|
|
||||||
index={index + 1}
|
|
||||||
queue={tracks}
|
|
||||||
showArtwork={false}
|
|
||||||
onOptionsPress={handleTrackOptionsPress}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
keyExtractor={(item) => item.Id!}
|
|
||||||
ListFooterComponent={
|
|
||||||
<>
|
|
||||||
<TrackOptionsSheet
|
|
||||||
open={trackOptionsOpen}
|
|
||||||
setOpen={setTrackOptionsOpen}
|
|
||||||
track={selectedTrack}
|
|
||||||
onAddToPlaylist={handleAddToPlaylist}
|
|
||||||
/>
|
|
||||||
<PlaylistPickerSheet
|
|
||||||
open={playlistPickerOpen}
|
|
||||||
setOpen={setPlaylistPickerOpen}
|
|
||||||
trackToAdd={selectedTrack}
|
|
||||||
onCreateNew={handleCreateNewPlaylist}
|
|
||||||
/>
|
|
||||||
<CreatePlaylistModal
|
|
||||||
open={createPlaylistOpen}
|
|
||||||
setOpen={setCreatePlaylistOpen}
|
|
||||||
initialTrackId={selectedTrack?.Id}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,273 +0,0 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { getItemsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { FlashList } from "@shopify/flash-list";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Dimensions, TouchableOpacity, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { HorizontalScroll } from "@/components/common/HorizontalScroll";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
|
|
||||||
import { MusicAlbumCard } from "@/components/music/MusicAlbumCard";
|
|
||||||
import { MusicTrackItem } from "@/components/music/MusicTrackItem";
|
|
||||||
import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet";
|
|
||||||
import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
|
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
|
||||||
|
|
||||||
const { width: SCREEN_WIDTH } = Dimensions.get("window");
|
|
||||||
const ARTWORK_SIZE = SCREEN_WIDTH * 0.4;
|
|
||||||
|
|
||||||
export default function ArtistDetailScreen() {
|
|
||||||
const { artistId } = useLocalSearchParams<{ artistId: string }>();
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const navigation = useNavigation();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { playQueue } = useMusicPlayer();
|
|
||||||
|
|
||||||
const [selectedTrack, setSelectedTrack] = useState<BaseItemDto | null>(null);
|
|
||||||
const [trackOptionsOpen, setTrackOptionsOpen] = useState(false);
|
|
||||||
const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false);
|
|
||||||
const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false);
|
|
||||||
|
|
||||||
const handleTrackOptionsPress = useCallback((track: BaseItemDto) => {
|
|
||||||
setSelectedTrack(track);
|
|
||||||
setTrackOptionsOpen(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleAddToPlaylist = useCallback(() => {
|
|
||||||
setPlaylistPickerOpen(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleCreateNewPlaylist = useCallback(() => {
|
|
||||||
setCreatePlaylistOpen(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const { data: artist, isLoading: loadingArtist } = useQuery({
|
|
||||||
queryKey: ["music-artist", artistId, user?.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await getUserLibraryApi(api!).getItem({
|
|
||||||
userId: user?.Id,
|
|
||||||
itemId: artistId!,
|
|
||||||
});
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
enabled: !!api && !!user?.Id && !!artistId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: albums, isLoading: loadingAlbums } = useQuery({
|
|
||||||
queryKey: ["music-artist-albums", artistId, user?.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await getItemsApi(api!).getItems({
|
|
||||||
userId: user?.Id,
|
|
||||||
artistIds: [artistId!],
|
|
||||||
includeItemTypes: ["MusicAlbum"],
|
|
||||||
sortBy: ["ProductionYear", "SortName"],
|
|
||||||
sortOrder: ["Descending", "Ascending"],
|
|
||||||
recursive: true,
|
|
||||||
});
|
|
||||||
return response.data.Items || [];
|
|
||||||
},
|
|
||||||
enabled: !!api && !!user?.Id && !!artistId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: topTracks, isLoading: loadingTracks } = useQuery({
|
|
||||||
queryKey: ["music-artist-top-tracks", artistId, user?.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await getItemsApi(api!).getItems({
|
|
||||||
userId: user?.Id,
|
|
||||||
artistIds: [artistId!],
|
|
||||||
includeItemTypes: ["Audio"],
|
|
||||||
sortBy: ["PlayCount"],
|
|
||||||
sortOrder: ["Descending"],
|
|
||||||
limit: 10,
|
|
||||||
recursive: true,
|
|
||||||
filters: ["IsPlayed"],
|
|
||||||
});
|
|
||||||
return response.data.Items || [];
|
|
||||||
},
|
|
||||||
enabled: !!api && !!user?.Id && !!artistId,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
navigation.setOptions({
|
|
||||||
title: artist?.Name ?? "",
|
|
||||||
headerTransparent: true,
|
|
||||||
headerStyle: { backgroundColor: "transparent" },
|
|
||||||
headerShadowVisible: false,
|
|
||||||
});
|
|
||||||
}, [artist?.Name, navigation]);
|
|
||||||
|
|
||||||
const imageUrl = useMemo(
|
|
||||||
() => (artist ? getPrimaryImageUrl({ api, item: artist }) : null),
|
|
||||||
[api, artist],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handlePlayAllTracks = useCallback(() => {
|
|
||||||
if (topTracks && topTracks.length > 0) {
|
|
||||||
playQueue(topTracks, 0);
|
|
||||||
}
|
|
||||||
}, [playQueue, topTracks]);
|
|
||||||
|
|
||||||
const isLoading = loadingArtist || loadingAlbums || loadingTracks;
|
|
||||||
|
|
||||||
// Only show loading if we have no cached data to display
|
|
||||||
if (isLoading && !artist) {
|
|
||||||
return (
|
|
||||||
<View className='flex-1 justify-center items-center bg-black'>
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!artist) {
|
|
||||||
return (
|
|
||||||
<View className='flex-1 justify-center items-center bg-black'>
|
|
||||||
<Text className='text-neutral-500'>{t("music.artist_not_found")}</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sections = [];
|
|
||||||
|
|
||||||
// Top tracks section
|
|
||||||
if (topTracks && topTracks.length > 0) {
|
|
||||||
sections.push({
|
|
||||||
id: "top-tracks",
|
|
||||||
title: t("music.top_tracks"),
|
|
||||||
type: "tracks" as const,
|
|
||||||
data: topTracks,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Albums section
|
|
||||||
if (albums && albums.length > 0) {
|
|
||||||
sections.push({
|
|
||||||
id: "albums",
|
|
||||||
title: t("music.tabs.albums"),
|
|
||||||
type: "albums" as const,
|
|
||||||
data: albums,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FlashList
|
|
||||||
data={sections}
|
|
||||||
contentContainerStyle={{
|
|
||||||
paddingBottom: insets.bottom + 100,
|
|
||||||
}}
|
|
||||||
ListHeaderComponent={
|
|
||||||
<View
|
|
||||||
className='items-center px-4 pb-6 bg-black'
|
|
||||||
style={{ paddingTop: insets.top + 50 }}
|
|
||||||
>
|
|
||||||
{/* Artist image */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: ARTWORK_SIZE,
|
|
||||||
height: ARTWORK_SIZE,
|
|
||||||
borderRadius: ARTWORK_SIZE / 2,
|
|
||||||
overflow: "hidden",
|
|
||||||
backgroundColor: "#1a1a1a",
|
|
||||||
shadowColor: "#000",
|
|
||||||
shadowOffset: { width: 0, height: 8 },
|
|
||||||
shadowOpacity: 0.3,
|
|
||||||
shadowRadius: 12,
|
|
||||||
elevation: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{imageUrl ? (
|
|
||||||
<Image
|
|
||||||
source={{ uri: imageUrl }}
|
|
||||||
style={{ width: "100%", height: "100%" }}
|
|
||||||
contentFit='cover'
|
|
||||||
cachePolicy='memory-disk'
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<View className='flex-1 items-center justify-center bg-neutral-800'>
|
|
||||||
<Ionicons name='person' size={60} color='#666' />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Artist info */}
|
|
||||||
<Text className='text-white text-2xl font-bold mt-4 text-center'>
|
|
||||||
{artist.Name}
|
|
||||||
</Text>
|
|
||||||
<Text className='text-neutral-500 text-sm mt-1'>
|
|
||||||
{albums?.length || 0} {t("music.tabs.albums").toLowerCase()}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* Play button */}
|
|
||||||
{topTracks && topTracks.length > 0 && (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handlePlayAllTracks}
|
|
||||||
className='flex flex-row items-center bg-purple-600 px-6 py-3 rounded-full mt-4'
|
|
||||||
>
|
|
||||||
<Ionicons name='play' size={20} color='white' />
|
|
||||||
<Text className='text-white font-medium ml-2'>
|
|
||||||
{t("music.play_top_tracks")}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
renderItem={({ item: section }) => (
|
|
||||||
<View className='mb-6'>
|
|
||||||
<Text className='text-lg font-bold px-4 mb-3'>{section.title}</Text>
|
|
||||||
{section.type === "albums" ? (
|
|
||||||
<HorizontalScroll
|
|
||||||
data={section.data}
|
|
||||||
height={200}
|
|
||||||
keyExtractor={(item) => item.Id!}
|
|
||||||
renderItem={(item) => <MusicAlbumCard album={item} />}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
section.data
|
|
||||||
.slice(0, 5)
|
|
||||||
.map((track, index) => (
|
|
||||||
<MusicTrackItem
|
|
||||||
key={track.Id}
|
|
||||||
track={track}
|
|
||||||
index={index + 1}
|
|
||||||
queue={section.data}
|
|
||||||
onOptionsPress={handleTrackOptionsPress}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
keyExtractor={(item) => item.id}
|
|
||||||
ListFooterComponent={
|
|
||||||
<>
|
|
||||||
<TrackOptionsSheet
|
|
||||||
open={trackOptionsOpen}
|
|
||||||
setOpen={setTrackOptionsOpen}
|
|
||||||
track={selectedTrack}
|
|
||||||
onAddToPlaylist={handleAddToPlaylist}
|
|
||||||
/>
|
|
||||||
<PlaylistPickerSheet
|
|
||||||
open={playlistPickerOpen}
|
|
||||||
setOpen={setPlaylistPickerOpen}
|
|
||||||
trackToAdd={selectedTrack}
|
|
||||||
onCreateNew={handleCreateNewPlaylist}
|
|
||||||
/>
|
|
||||||
<CreatePlaylistModal
|
|
||||||
open={createPlaylistOpen}
|
|
||||||
setOpen={setCreatePlaylistOpen}
|
|
||||||
initialTrackId={selectedTrack?.Id}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,321 +0,0 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { getItemsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { FlashList } from "@shopify/flash-list";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
ActivityIndicator,
|
|
||||||
Dimensions,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
|
|
||||||
import { MusicTrackItem } from "@/components/music/MusicTrackItem";
|
|
||||||
import { PlaylistOptionsSheet } from "@/components/music/PlaylistOptionsSheet";
|
|
||||||
import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet";
|
|
||||||
import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet";
|
|
||||||
import { useRemoveFromPlaylist } from "@/hooks/usePlaylistMutations";
|
|
||||||
import { downloadTrack, getLocalPath } from "@/providers/AudioStorage";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
|
|
||||||
import { getAudioStreamUrl } from "@/utils/jellyfin/audio/getAudioStreamUrl";
|
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
|
||||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
|
||||||
|
|
||||||
const { width: SCREEN_WIDTH } = Dimensions.get("window");
|
|
||||||
const ARTWORK_SIZE = SCREEN_WIDTH * 0.5;
|
|
||||||
|
|
||||||
export default function PlaylistDetailScreen() {
|
|
||||||
const { playlistId } = useLocalSearchParams<{ playlistId: string }>();
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const navigation = useNavigation();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { playQueue } = useMusicPlayer();
|
|
||||||
|
|
||||||
const [selectedTrack, setSelectedTrack] = useState<BaseItemDto | null>(null);
|
|
||||||
const [trackOptionsOpen, setTrackOptionsOpen] = useState(false);
|
|
||||||
const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false);
|
|
||||||
const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false);
|
|
||||||
const [playlistOptionsOpen, setPlaylistOptionsOpen] = useState(false);
|
|
||||||
const [isDownloading, setIsDownloading] = useState(false);
|
|
||||||
|
|
||||||
const removeFromPlaylist = useRemoveFromPlaylist();
|
|
||||||
|
|
||||||
const handleTrackOptionsPress = useCallback((track: BaseItemDto) => {
|
|
||||||
setSelectedTrack(track);
|
|
||||||
setTrackOptionsOpen(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleAddToPlaylist = useCallback(() => {
|
|
||||||
setPlaylistPickerOpen(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleCreateNewPlaylist = useCallback(() => {
|
|
||||||
setCreatePlaylistOpen(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleRemoveFromPlaylist = useCallback(() => {
|
|
||||||
if (selectedTrack?.Id && playlistId) {
|
|
||||||
removeFromPlaylist.mutate({
|
|
||||||
playlistId,
|
|
||||||
entryIds: [selectedTrack.PlaylistItemId ?? selectedTrack.Id],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [selectedTrack, playlistId, removeFromPlaylist]);
|
|
||||||
|
|
||||||
const { data: playlist, isLoading: loadingPlaylist } = useQuery({
|
|
||||||
queryKey: ["music-playlist", playlistId, user?.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await getUserLibraryApi(api!).getItem({
|
|
||||||
userId: user?.Id,
|
|
||||||
itemId: playlistId!,
|
|
||||||
});
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
enabled: !!api && !!user?.Id && !!playlistId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: tracks, isLoading: loadingTracks } = useQuery({
|
|
||||||
queryKey: ["music-playlist-tracks", playlistId, user?.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await getItemsApi(api!).getItems({
|
|
||||||
userId: user?.Id,
|
|
||||||
parentId: playlistId,
|
|
||||||
});
|
|
||||||
return response.data.Items || [];
|
|
||||||
},
|
|
||||||
enabled: !!api && !!user?.Id && !!playlistId,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
navigation.setOptions({
|
|
||||||
title: playlist?.Name ?? "",
|
|
||||||
headerTransparent: true,
|
|
||||||
headerStyle: { backgroundColor: "transparent" },
|
|
||||||
headerShadowVisible: false,
|
|
||||||
headerRight: () => (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => setPlaylistOptionsOpen(true)}
|
|
||||||
className='p-1.5'
|
|
||||||
>
|
|
||||||
<Ionicons name='ellipsis-horizontal' size={24} color='white' />
|
|
||||||
</TouchableOpacity>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}, [playlist?.Name, navigation]);
|
|
||||||
|
|
||||||
const imageUrl = useMemo(
|
|
||||||
() => (playlist ? getPrimaryImageUrl({ api, item: playlist }) : null),
|
|
||||||
[api, playlist],
|
|
||||||
);
|
|
||||||
|
|
||||||
const totalDuration = useMemo(() => {
|
|
||||||
if (!tracks) return "";
|
|
||||||
const totalTicks = tracks.reduce(
|
|
||||||
(acc, track) => acc + (track.RunTimeTicks || 0),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
return runtimeTicksToMinutes(totalTicks);
|
|
||||||
}, [tracks]);
|
|
||||||
|
|
||||||
const handlePlayAll = useCallback(() => {
|
|
||||||
if (tracks && tracks.length > 0) {
|
|
||||||
playQueue(tracks, 0);
|
|
||||||
}
|
|
||||||
}, [playQueue, tracks]);
|
|
||||||
|
|
||||||
const handleShuffle = useCallback(() => {
|
|
||||||
if (tracks && tracks.length > 0) {
|
|
||||||
const shuffled = [...tracks].sort(() => Math.random() - 0.5);
|
|
||||||
playQueue(shuffled, 0);
|
|
||||||
}
|
|
||||||
}, [playQueue, tracks]);
|
|
||||||
|
|
||||||
// Check if all tracks are already downloaded
|
|
||||||
const allTracksDownloaded = useMemo(() => {
|
|
||||||
if (!tracks || tracks.length === 0) return false;
|
|
||||||
return tracks.every((track) => !!getLocalPath(track.Id));
|
|
||||||
}, [tracks]);
|
|
||||||
|
|
||||||
const handleDownloadPlaylist = useCallback(async () => {
|
|
||||||
if (!tracks || !api || !user?.Id || isDownloading) return;
|
|
||||||
|
|
||||||
setIsDownloading(true);
|
|
||||||
try {
|
|
||||||
for (const track of tracks) {
|
|
||||||
if (!track.Id || getLocalPath(track.Id)) continue;
|
|
||||||
const result = await getAudioStreamUrl(api, user.Id, track.Id);
|
|
||||||
if (result?.url && !result.isTranscoding) {
|
|
||||||
await downloadTrack(track.Id, result.url, {
|
|
||||||
permanent: true,
|
|
||||||
container: result.mediaSource?.Container || undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Silent fail
|
|
||||||
}
|
|
||||||
setIsDownloading(false);
|
|
||||||
}, [tracks, api, user?.Id, isDownloading]);
|
|
||||||
|
|
||||||
const isLoading = loadingPlaylist || loadingTracks;
|
|
||||||
|
|
||||||
// Only show loading if we have no cached data to display
|
|
||||||
if (isLoading && !playlist) {
|
|
||||||
return (
|
|
||||||
<View className='flex-1 justify-center items-center bg-black'>
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!playlist) {
|
|
||||||
return (
|
|
||||||
<View className='flex-1 justify-center items-center bg-black'>
|
|
||||||
<Text className='text-neutral-500'>
|
|
||||||
{t("music.playlist_not_found")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FlashList
|
|
||||||
data={tracks || []}
|
|
||||||
contentContainerStyle={{
|
|
||||||
paddingBottom: insets.bottom + 100,
|
|
||||||
}}
|
|
||||||
ListHeaderComponent={
|
|
||||||
<View
|
|
||||||
className='items-center px-4 pb-6 bg-black'
|
|
||||||
style={{ paddingTop: insets.top + 50 }}
|
|
||||||
>
|
|
||||||
{/* Playlist artwork */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: ARTWORK_SIZE,
|
|
||||||
height: ARTWORK_SIZE,
|
|
||||||
borderRadius: 8,
|
|
||||||
overflow: "hidden",
|
|
||||||
backgroundColor: "#1a1a1a",
|
|
||||||
shadowColor: "#000",
|
|
||||||
shadowOffset: { width: 0, height: 8 },
|
|
||||||
shadowOpacity: 0.3,
|
|
||||||
shadowRadius: 12,
|
|
||||||
elevation: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{imageUrl ? (
|
|
||||||
<Image
|
|
||||||
source={{ uri: imageUrl }}
|
|
||||||
style={{ width: "100%", height: "100%" }}
|
|
||||||
contentFit='cover'
|
|
||||||
cachePolicy='memory-disk'
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<View className='flex-1 items-center justify-center bg-neutral-800'>
|
|
||||||
<Ionicons name='list' size={60} color='#666' />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Playlist info */}
|
|
||||||
<Text className='text-white text-xl font-bold mt-4 text-center'>
|
|
||||||
{playlist.Name}
|
|
||||||
</Text>
|
|
||||||
<Text className='text-neutral-500 text-sm mt-1'>
|
|
||||||
{tracks?.length} tracks • {totalDuration}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* Play buttons */}
|
|
||||||
<View className='flex flex-row mt-4 items-center'>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handlePlayAll}
|
|
||||||
className='flex flex-row items-center bg-purple-600 px-6 py-3 rounded-full mr-3'
|
|
||||||
>
|
|
||||||
<Ionicons name='play' size={20} color='white' />
|
|
||||||
<Text className='text-white font-medium ml-2'>
|
|
||||||
{t("music.play")}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleShuffle}
|
|
||||||
className='flex flex-row items-center bg-neutral-800 px-6 py-3 rounded-full mr-3'
|
|
||||||
>
|
|
||||||
<Ionicons name='shuffle' size={20} color='white' />
|
|
||||||
<Text className='text-white font-medium ml-2'>
|
|
||||||
{t("music.shuffle")}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleDownloadPlaylist}
|
|
||||||
disabled={allTracksDownloaded || isDownloading}
|
|
||||||
className='flex items-center justify-center bg-neutral-800 p-3 rounded-full'
|
|
||||||
>
|
|
||||||
{isDownloading ? (
|
|
||||||
<ActivityIndicator size={20} color='white' />
|
|
||||||
) : (
|
|
||||||
<Ionicons
|
|
||||||
name={
|
|
||||||
allTracksDownloaded
|
|
||||||
? "checkmark-circle"
|
|
||||||
: "download-outline"
|
|
||||||
}
|
|
||||||
size={20}
|
|
||||||
color={allTracksDownloaded ? "#22c55e" : "white"}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
renderItem={({ item, index }) => (
|
|
||||||
<MusicTrackItem
|
|
||||||
track={item}
|
|
||||||
index={index + 1}
|
|
||||||
queue={tracks}
|
|
||||||
onOptionsPress={handleTrackOptionsPress}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
keyExtractor={(item) => item.Id!}
|
|
||||||
ListFooterComponent={
|
|
||||||
<>
|
|
||||||
<TrackOptionsSheet
|
|
||||||
open={trackOptionsOpen}
|
|
||||||
setOpen={setTrackOptionsOpen}
|
|
||||||
track={selectedTrack}
|
|
||||||
onAddToPlaylist={handleAddToPlaylist}
|
|
||||||
playlistId={playlistId}
|
|
||||||
onRemoveFromPlaylist={handleRemoveFromPlaylist}
|
|
||||||
/>
|
|
||||||
<PlaylistPickerSheet
|
|
||||||
open={playlistPickerOpen}
|
|
||||||
setOpen={setPlaylistPickerOpen}
|
|
||||||
trackToAdd={selectedTrack}
|
|
||||||
onCreateNew={handleCreateNewPlaylist}
|
|
||||||
/>
|
|
||||||
<CreatePlaylistModal
|
|
||||||
open={createPlaylistOpen}
|
|
||||||
setOpen={setCreatePlaylistOpen}
|
|
||||||
initialTrackId={selectedTrack?.Id}
|
|
||||||
/>
|
|
||||||
<PlaylistOptionsSheet
|
|
||||||
open={playlistOptionsOpen}
|
|
||||||
setOpen={setPlaylistOptionsOpen}
|
|
||||||
playlist={playlist}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ import type {
|
|||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
BaseItemDtoQueryResult,
|
BaseItemDtoQueryResult,
|
||||||
BaseItemKind,
|
BaseItemKind,
|
||||||
ItemFilter,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import {
|
import {
|
||||||
getFilterApi,
|
getFilterApi,
|
||||||
@@ -28,11 +27,7 @@ import { useOrientation } from "@/hooks/useOrientation";
|
|||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import {
|
import {
|
||||||
FilterByOption,
|
|
||||||
FilterByPreferenceAtom,
|
|
||||||
filterByAtom,
|
|
||||||
genreFilterAtom,
|
genreFilterAtom,
|
||||||
getFilterByPreference,
|
|
||||||
getSortByPreference,
|
getSortByPreference,
|
||||||
getSortOrderPreference,
|
getSortOrderPreference,
|
||||||
SortByOption,
|
SortByOption,
|
||||||
@@ -44,10 +39,8 @@ import {
|
|||||||
sortOrderOptions,
|
sortOrderOptions,
|
||||||
sortOrderPreferenceAtom,
|
sortOrderPreferenceAtom,
|
||||||
tagsFilterAtom,
|
tagsFilterAtom,
|
||||||
useFilterOptions,
|
|
||||||
yearFilterAtom,
|
yearFilterAtom,
|
||||||
} from "@/utils/atoms/filters";
|
} from "@/utils/atoms/filters";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
|
|
||||||
const Page = () => {
|
const Page = () => {
|
||||||
const searchParams = useLocalSearchParams();
|
const searchParams = useLocalSearchParams();
|
||||||
@@ -61,13 +54,9 @@ const Page = () => {
|
|||||||
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
||||||
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
|
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
|
||||||
const [sortBy, _setSortBy] = useAtom(sortByAtom);
|
const [sortBy, _setSortBy] = useAtom(sortByAtom);
|
||||||
const [filterBy, _setFilterBy] = useAtom(filterByAtom);
|
|
||||||
const [sortOrder, _setSortOrder] = useAtom(sortOrderAtom);
|
const [sortOrder, _setSortOrder] = useAtom(sortOrderAtom);
|
||||||
const [sortByPreference, setSortByPreference] = useAtom(sortByPreferenceAtom);
|
const [sortByPreference, setSortByPreference] = useAtom(sortByPreferenceAtom);
|
||||||
const [filterByPreference, setFilterByPreference] = useAtom(
|
const [sortOrderPreference, setOderByPreference] = useAtom(
|
||||||
FilterByPreferenceAtom,
|
|
||||||
);
|
|
||||||
const [sortOrderPreference, setOrderByPreference] = useAtom(
|
|
||||||
sortOrderPreferenceAtom,
|
sortOrderPreferenceAtom,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -88,20 +77,12 @@ const Page = () => {
|
|||||||
} else {
|
} else {
|
||||||
_setSortBy([SortByOption.SortName]);
|
_setSortBy([SortByOption.SortName]);
|
||||||
}
|
}
|
||||||
const fp = getFilterByPreference(libraryId, filterByPreference);
|
|
||||||
if (fp) {
|
|
||||||
_setFilterBy([fp]);
|
|
||||||
} else {
|
|
||||||
_setFilterBy([]);
|
|
||||||
}
|
|
||||||
}, [
|
}, [
|
||||||
libraryId,
|
libraryId,
|
||||||
sortOrderPreference,
|
sortOrderPreference,
|
||||||
sortByPreference,
|
sortByPreference,
|
||||||
_setSortOrder,
|
_setSortOrder,
|
||||||
_setSortBy,
|
_setSortBy,
|
||||||
filterByPreference,
|
|
||||||
_setFilterBy,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const setSortBy = useCallback(
|
const setSortBy = useCallback(
|
||||||
@@ -119,28 +100,14 @@ const Page = () => {
|
|||||||
(sortOrder: SortOrderOption[]) => {
|
(sortOrder: SortOrderOption[]) => {
|
||||||
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
|
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
|
||||||
if (sortOrder[0] !== sop) {
|
if (sortOrder[0] !== sop) {
|
||||||
setOrderByPreference({
|
setOderByPreference({
|
||||||
...sortOrderPreference,
|
...sortOrderPreference,
|
||||||
[libraryId]: sortOrder[0],
|
[libraryId]: sortOrder[0],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
_setSortOrder(sortOrder);
|
_setSortOrder(sortOrder);
|
||||||
},
|
},
|
||||||
[libraryId, sortOrderPreference, setOrderByPreference, _setSortOrder],
|
[libraryId, sortOrderPreference, setOderByPreference, _setSortOrder],
|
||||||
);
|
|
||||||
|
|
||||||
const setFilter = useCallback(
|
|
||||||
(filterBy: FilterByOption[]) => {
|
|
||||||
const fp = getFilterByPreference(libraryId, filterByPreference);
|
|
||||||
if (filterBy[0] !== fp) {
|
|
||||||
setFilterByPreference({
|
|
||||||
...filterByPreference,
|
|
||||||
[libraryId]: filterBy[0],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
_setFilterBy(filterBy);
|
|
||||||
},
|
|
||||||
[libraryId, filterByPreference, setFilterByPreference, _setFilterBy],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const nrOfCols = useMemo(() => {
|
const nrOfCols = useMemo(() => {
|
||||||
@@ -201,7 +168,6 @@ const Page = () => {
|
|||||||
sortBy: [sortBy[0], "SortName", "ProductionYear"],
|
sortBy: [sortBy[0], "SortName", "ProductionYear"],
|
||||||
sortOrder: [sortOrder[0]],
|
sortOrder: [sortOrder[0]],
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
|
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
|
||||||
filters: filterBy as ItemFilter[],
|
|
||||||
// true is needed for merged versions
|
// true is needed for merged versions
|
||||||
recursive: true,
|
recursive: true,
|
||||||
imageTypeLimit: 1,
|
imageTypeLimit: 1,
|
||||||
@@ -224,7 +190,6 @@ const Page = () => {
|
|||||||
selectedTags,
|
selectedTags,
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
filterBy,
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -238,7 +203,6 @@ const Page = () => {
|
|||||||
selectedTags,
|
selectedTags,
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
filterBy,
|
|
||||||
],
|
],
|
||||||
queryFn: fetchItems,
|
queryFn: fetchItems,
|
||||||
getNextPageParam: (lastPage, pages) => {
|
getNextPageParam: (lastPage, pages) => {
|
||||||
@@ -304,8 +268,7 @@ 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
|
||||||
@@ -441,26 +404,6 @@ 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}
|
||||||
@@ -481,9 +424,6 @@ const Page = () => {
|
|||||||
sortOrder,
|
sortOrder,
|
||||||
setSortOrder,
|
setSortOrder,
|
||||||
isFetching,
|
isFetching,
|
||||||
filterBy,
|
|
||||||
setFilter,
|
|
||||||
settings,
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export default function index() {
|
|||||||
() =>
|
() =>
|
||||||
data
|
data
|
||||||
?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!))
|
?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!))
|
||||||
|
.filter((l) => l.CollectionType !== "music")
|
||||||
.filter((l) => l.CollectionType !== "books") || [],
|
.filter((l) => l.CollectionType !== "books") || [],
|
||||||
[data, settings?.hiddenLibraries],
|
[data, settings?.hiddenLibraries],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
import {
|
|
||||||
createMaterialTopTabNavigator,
|
|
||||||
MaterialTopTabNavigationEventMap,
|
|
||||||
MaterialTopTabNavigationOptions,
|
|
||||||
} from "@react-navigation/material-top-tabs";
|
|
||||||
import type {
|
|
||||||
ParamListBase,
|
|
||||||
TabNavigationState,
|
|
||||||
} from "@react-navigation/native";
|
|
||||||
import { Stack, useLocalSearchParams, withLayoutContext } from "expo-router";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
const { Navigator } = createMaterialTopTabNavigator();
|
|
||||||
|
|
||||||
const TAB_LABEL_FONT_SIZE = 13;
|
|
||||||
const TAB_ITEM_HORIZONTAL_PADDING = 18;
|
|
||||||
const TAB_ITEM_MIN_WIDTH = 110;
|
|
||||||
|
|
||||||
export const Tab = withLayoutContext<
|
|
||||||
MaterialTopTabNavigationOptions,
|
|
||||||
typeof Navigator,
|
|
||||||
TabNavigationState<ParamListBase>,
|
|
||||||
MaterialTopTabNavigationEventMap
|
|
||||||
>(Navigator);
|
|
||||||
|
|
||||||
const Layout = () => {
|
|
||||||
const { libraryId } = useLocalSearchParams<{ libraryId: string }>();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Stack.Screen
|
|
||||||
options={{
|
|
||||||
title: t("music.title"),
|
|
||||||
headerStyle: { backgroundColor: "black" },
|
|
||||||
headerShadowVisible: false,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Tab
|
|
||||||
initialRouteName='suggestions'
|
|
||||||
keyboardDismissMode='none'
|
|
||||||
screenOptions={{
|
|
||||||
tabBarBounces: true,
|
|
||||||
tabBarLabelStyle: {
|
|
||||||
fontSize: TAB_LABEL_FONT_SIZE,
|
|
||||||
fontWeight: "600",
|
|
||||||
flexWrap: "nowrap",
|
|
||||||
},
|
|
||||||
tabBarItemStyle: {
|
|
||||||
width: "auto",
|
|
||||||
minWidth: TAB_ITEM_MIN_WIDTH,
|
|
||||||
paddingHorizontal: TAB_ITEM_HORIZONTAL_PADDING,
|
|
||||||
},
|
|
||||||
tabBarStyle: { backgroundColor: "black" },
|
|
||||||
animationEnabled: true,
|
|
||||||
lazy: true,
|
|
||||||
swipeEnabled: true,
|
|
||||||
tabBarIndicatorStyle: { backgroundColor: "#9334E9" },
|
|
||||||
tabBarScrollEnabled: true,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Tab.Screen
|
|
||||||
name='suggestions'
|
|
||||||
initialParams={{ libraryId }}
|
|
||||||
options={{ title: t("music.tabs.suggestions") }}
|
|
||||||
/>
|
|
||||||
<Tab.Screen
|
|
||||||
name='albums'
|
|
||||||
initialParams={{ libraryId }}
|
|
||||||
options={{ title: t("music.tabs.albums") }}
|
|
||||||
/>
|
|
||||||
<Tab.Screen
|
|
||||||
name='artists'
|
|
||||||
initialParams={{ libraryId }}
|
|
||||||
options={{ title: t("music.tabs.artists") }}
|
|
||||||
/>
|
|
||||||
<Tab.Screen
|
|
||||||
name='playlists'
|
|
||||||
initialParams={{ libraryId }}
|
|
||||||
options={{ title: t("music.tabs.playlists") }}
|
|
||||||
/>
|
|
||||||
</Tab>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Layout;
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useRoute } from "@react-navigation/native";
|
|
||||||
import { FlashList } from "@shopify/flash-list";
|
|
||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
|
||||||
import { useLocalSearchParams } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useCallback, useMemo } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Dimensions, RefreshControl, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { MusicAlbumCard } from "@/components/music/MusicAlbumCard";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
|
|
||||||
const ITEMS_PER_PAGE = 40;
|
|
||||||
|
|
||||||
export default function AlbumsScreen() {
|
|
||||||
const localParams = useLocalSearchParams<{ libraryId?: string | string[] }>();
|
|
||||||
const route = useRoute<any>();
|
|
||||||
const libraryId =
|
|
||||||
(Array.isArray(localParams.libraryId)
|
|
||||||
? localParams.libraryId[0]
|
|
||||||
: localParams.libraryId) ?? route?.params?.libraryId;
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const {
|
|
||||||
data,
|
|
||||||
isLoading,
|
|
||||||
fetchNextPage,
|
|
||||||
hasNextPage,
|
|
||||||
isFetchingNextPage,
|
|
||||||
refetch,
|
|
||||||
} = useInfiniteQuery({
|
|
||||||
queryKey: ["music-albums", libraryId, user?.Id],
|
|
||||||
queryFn: async ({ pageParam = 0 }) => {
|
|
||||||
const response = await getItemsApi(api!).getItems({
|
|
||||||
userId: user?.Id,
|
|
||||||
parentId: libraryId,
|
|
||||||
includeItemTypes: ["MusicAlbum"],
|
|
||||||
sortBy: ["SortName"],
|
|
||||||
sortOrder: ["Ascending"],
|
|
||||||
limit: ITEMS_PER_PAGE,
|
|
||||||
startIndex: pageParam,
|
|
||||||
recursive: true,
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
items: response.data.Items || [],
|
|
||||||
totalCount: response.data.TotalRecordCount || 0,
|
|
||||||
startIndex: pageParam,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
getNextPageParam: (lastPage) => {
|
|
||||||
const nextStart = lastPage.startIndex + ITEMS_PER_PAGE;
|
|
||||||
return nextStart < lastPage.totalCount ? nextStart : undefined;
|
|
||||||
},
|
|
||||||
initialPageParam: 0,
|
|
||||||
enabled: !!api && !!user?.Id && !!libraryId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const albums = useMemo(() => {
|
|
||||||
return data?.pages.flatMap((page) => page.items) || [];
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
const numColumns = 2;
|
|
||||||
const screenWidth = Dimensions.get("window").width;
|
|
||||||
const gap = 12;
|
|
||||||
const padding = 16;
|
|
||||||
const itemWidth =
|
|
||||||
(screenWidth - padding * 2 - gap * (numColumns - 1)) / numColumns;
|
|
||||||
|
|
||||||
const handleEndReached = useCallback(() => {
|
|
||||||
if (hasNextPage && !isFetchingNextPage) {
|
|
||||||
fetchNextPage();
|
|
||||||
}
|
|
||||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<View className='flex-1 justify-center items-center bg-black'>
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (albums.length === 0) {
|
|
||||||
return (
|
|
||||||
<View className='flex-1 justify-center items-center bg-black'>
|
|
||||||
<Text className='text-neutral-500'>{t("music.no_albums")}</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className='flex-1 bg-black'>
|
|
||||||
<FlashList
|
|
||||||
data={albums}
|
|
||||||
numColumns={numColumns}
|
|
||||||
contentContainerStyle={{
|
|
||||||
paddingBottom: insets.bottom + 100,
|
|
||||||
paddingTop: 16,
|
|
||||||
paddingHorizontal: padding,
|
|
||||||
}}
|
|
||||||
refreshControl={
|
|
||||||
<RefreshControl
|
|
||||||
refreshing={false}
|
|
||||||
onRefresh={refetch}
|
|
||||||
tintColor='#9334E9'
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
onEndReached={handleEndReached}
|
|
||||||
onEndReachedThreshold={0.5}
|
|
||||||
renderItem={({ item, index }) => (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: itemWidth,
|
|
||||||
marginRight: index % numColumns === 0 ? gap : 0,
|
|
||||||
marginBottom: gap,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MusicAlbumCard album={item} width={itemWidth} />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
keyExtractor={(item) => item.Id!}
|
|
||||||
ListFooterComponent={
|
|
||||||
isFetchingNextPage ? (
|
|
||||||
<View className='py-4'>
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
import { getArtistsApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useRoute } from "@react-navigation/native";
|
|
||||||
import { FlashList } from "@shopify/flash-list";
|
|
||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
|
||||||
import { useLocalSearchParams } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useCallback, useMemo } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Dimensions, RefreshControl, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { MusicArtistCard } from "@/components/music/MusicArtistCard";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
|
|
||||||
// Web uses Limit=100
|
|
||||||
const ITEMS_PER_PAGE = 100;
|
|
||||||
|
|
||||||
export default function ArtistsScreen() {
|
|
||||||
const localParams = useLocalSearchParams<{ libraryId?: string | string[] }>();
|
|
||||||
const route = useRoute<any>();
|
|
||||||
const libraryId =
|
|
||||||
(Array.isArray(localParams.libraryId)
|
|
||||||
? localParams.libraryId[0]
|
|
||||||
: localParams.libraryId) ?? route?.params?.libraryId;
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const isReady = Boolean(api && user?.Id && libraryId);
|
|
||||||
|
|
||||||
const {
|
|
||||||
data,
|
|
||||||
isLoading,
|
|
||||||
isError,
|
|
||||||
error,
|
|
||||||
fetchNextPage,
|
|
||||||
hasNextPage,
|
|
||||||
isFetchingNextPage,
|
|
||||||
refetch,
|
|
||||||
} = useInfiniteQuery({
|
|
||||||
queryKey: ["music-artists", libraryId, user?.Id],
|
|
||||||
queryFn: async ({ pageParam = 0 }) => {
|
|
||||||
const response = await getArtistsApi(api!).getArtists({
|
|
||||||
userId: user?.Id,
|
|
||||||
parentId: libraryId,
|
|
||||||
sortBy: ["SortName"],
|
|
||||||
sortOrder: ["Ascending"],
|
|
||||||
fields: ["PrimaryImageAspectRatio", "SortName"],
|
|
||||||
imageTypeLimit: 1,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
|
|
||||||
limit: ITEMS_PER_PAGE,
|
|
||||||
startIndex: pageParam,
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
items: response.data.Items || [],
|
|
||||||
totalCount: response.data.TotalRecordCount || 0,
|
|
||||||
startIndex: pageParam,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
getNextPageParam: (lastPage) => {
|
|
||||||
const nextStart = lastPage.startIndex + ITEMS_PER_PAGE;
|
|
||||||
return nextStart < lastPage.totalCount ? nextStart : undefined;
|
|
||||||
},
|
|
||||||
initialPageParam: 0,
|
|
||||||
enabled: isReady,
|
|
||||||
});
|
|
||||||
|
|
||||||
const artists = useMemo(() => {
|
|
||||||
return data?.pages.flatMap((page) => page.items) || [];
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
const numColumns = 3;
|
|
||||||
const screenWidth = Dimensions.get("window").width;
|
|
||||||
const gap = 12;
|
|
||||||
const padding = 16;
|
|
||||||
const itemWidth =
|
|
||||||
(screenWidth - padding * 2 - gap * (numColumns - 1)) / numColumns;
|
|
||||||
|
|
||||||
const handleEndReached = useCallback(() => {
|
|
||||||
if (hasNextPage && !isFetchingNextPage) {
|
|
||||||
fetchNextPage();
|
|
||||||
}
|
|
||||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
|
||||||
|
|
||||||
if (!api || !user?.Id) {
|
|
||||||
return (
|
|
||||||
<View className='flex-1 justify-center items-center bg-black'>
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!libraryId) {
|
|
||||||
return (
|
|
||||||
<View className='flex-1 justify-center items-center bg-black px-6'>
|
|
||||||
<Text className='text-neutral-500 text-center'>
|
|
||||||
Missing music library id.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only show loading if we have no cached data to display
|
|
||||||
if (isLoading && artists.length === 0) {
|
|
||||||
return (
|
|
||||||
<View className='flex-1 justify-center items-center bg-black'>
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only show error if we have no cached data to display
|
|
||||||
// This allows offline access to previously cached artists
|
|
||||||
if (isError && artists.length === 0) {
|
|
||||||
return (
|
|
||||||
<View className='flex-1 justify-center items-center bg-black px-6'>
|
|
||||||
<Text className='text-neutral-500 text-center'>
|
|
||||||
Failed to load artists: {(error as Error)?.message || "Unknown error"}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (artists.length === 0) {
|
|
||||||
return (
|
|
||||||
<View className='flex-1 justify-center items-center bg-black'>
|
|
||||||
<Text className='text-neutral-500'>{t("music.no_artists")}</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className='flex-1 bg-black'>
|
|
||||||
<FlashList
|
|
||||||
data={artists}
|
|
||||||
numColumns={numColumns}
|
|
||||||
contentContainerStyle={{
|
|
||||||
paddingBottom: insets.bottom + 100,
|
|
||||||
paddingTop: 16,
|
|
||||||
paddingHorizontal: padding,
|
|
||||||
}}
|
|
||||||
refreshControl={
|
|
||||||
<RefreshControl
|
|
||||||
refreshing={false}
|
|
||||||
onRefresh={refetch}
|
|
||||||
tintColor='#9334E9'
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
onEndReached={handleEndReached}
|
|
||||||
onEndReachedThreshold={0.5}
|
|
||||||
renderItem={({ item, index }) => (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: itemWidth,
|
|
||||||
marginRight: index % numColumns !== numColumns - 1 ? gap : 0,
|
|
||||||
marginBottom: gap,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MusicArtistCard artist={item} size={itemWidth} />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
keyExtractor={(item) => item.Id!}
|
|
||||||
ListFooterComponent={
|
|
||||||
isFetchingNextPage ? (
|
|
||||||
<View className='py-4'>
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useNavigation, useRoute } from "@react-navigation/native";
|
|
||||||
import { FlashList } from "@shopify/flash-list";
|
|
||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
|
||||||
import { useLocalSearchParams } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useCallback, useLayoutEffect, useMemo, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
Dimensions,
|
|
||||||
RefreshControl,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
|
|
||||||
import { MusicPlaylistCard } from "@/components/music/MusicPlaylistCard";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
|
|
||||||
const ITEMS_PER_PAGE = 40;
|
|
||||||
|
|
||||||
export default function PlaylistsScreen() {
|
|
||||||
const localParams = useLocalSearchParams<{ libraryId?: string | string[] }>();
|
|
||||||
const route = useRoute<any>();
|
|
||||||
const navigation = useNavigation();
|
|
||||||
const libraryId =
|
|
||||||
(Array.isArray(localParams.libraryId)
|
|
||||||
? localParams.libraryId[0]
|
|
||||||
: localParams.libraryId) ?? route?.params?.libraryId;
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
|
||||||
|
|
||||||
const isReady = Boolean(api && user?.Id && libraryId);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
navigation.setOptions({
|
|
||||||
headerRight: () => (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => setCreateModalOpen(true)}
|
|
||||||
className='mr-4'
|
|
||||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
|
||||||
>
|
|
||||||
<Ionicons name='add' size={28} color='white' />
|
|
||||||
</TouchableOpacity>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}, [navigation]);
|
|
||||||
|
|
||||||
const {
|
|
||||||
data,
|
|
||||||
isLoading,
|
|
||||||
isError,
|
|
||||||
error,
|
|
||||||
fetchNextPage,
|
|
||||||
hasNextPage,
|
|
||||||
isFetchingNextPage,
|
|
||||||
refetch,
|
|
||||||
} = useInfiniteQuery({
|
|
||||||
queryKey: ["music-playlists", libraryId, user?.Id],
|
|
||||||
queryFn: async ({ pageParam = 0 }) => {
|
|
||||||
const response = await getItemsApi(api!).getItems({
|
|
||||||
userId: user?.Id,
|
|
||||||
includeItemTypes: ["Playlist"],
|
|
||||||
sortBy: ["SortName"],
|
|
||||||
sortOrder: ["Ascending"],
|
|
||||||
limit: ITEMS_PER_PAGE,
|
|
||||||
startIndex: pageParam,
|
|
||||||
recursive: true,
|
|
||||||
mediaTypes: ["Audio"],
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
items: response.data.Items || [],
|
|
||||||
totalCount: response.data.TotalRecordCount || 0,
|
|
||||||
startIndex: pageParam,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
getNextPageParam: (lastPage) => {
|
|
||||||
const nextStart = lastPage.startIndex + ITEMS_PER_PAGE;
|
|
||||||
return nextStart < lastPage.totalCount ? nextStart : undefined;
|
|
||||||
},
|
|
||||||
initialPageParam: 0,
|
|
||||||
enabled: isReady,
|
|
||||||
});
|
|
||||||
|
|
||||||
const playlists = useMemo(() => {
|
|
||||||
return data?.pages.flatMap((page) => page.items) || [];
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
const numColumns = 2;
|
|
||||||
const screenWidth = Dimensions.get("window").width;
|
|
||||||
const gap = 12;
|
|
||||||
const padding = 16;
|
|
||||||
const itemWidth =
|
|
||||||
(screenWidth - padding * 2 - gap * (numColumns - 1)) / numColumns;
|
|
||||||
|
|
||||||
const handleEndReached = useCallback(() => {
|
|
||||||
if (hasNextPage && !isFetchingNextPage) {
|
|
||||||
fetchNextPage();
|
|
||||||
}
|
|
||||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
|
||||||
|
|
||||||
if (!api || !user?.Id) {
|
|
||||||
return (
|
|
||||||
<View className='flex-1 justify-center items-center bg-black'>
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!libraryId) {
|
|
||||||
return (
|
|
||||||
<View className='flex-1 justify-center items-center bg-black px-6'>
|
|
||||||
<Text className='text-neutral-500 text-center'>
|
|
||||||
Missing music library id.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only show loading if we have no cached data to display
|
|
||||||
if (isLoading && playlists.length === 0) {
|
|
||||||
return (
|
|
||||||
<View className='flex-1 justify-center items-center bg-black'>
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only show error if we have no cached data to display
|
|
||||||
// This allows offline access to previously cached playlists
|
|
||||||
if (isError && playlists.length === 0) {
|
|
||||||
return (
|
|
||||||
<View className='flex-1 justify-center items-center bg-black px-6'>
|
|
||||||
<Text className='text-neutral-500 text-center'>
|
|
||||||
Failed to load playlists:{" "}
|
|
||||||
{(error as Error)?.message || "Unknown error"}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (playlists.length === 0) {
|
|
||||||
return (
|
|
||||||
<View className='flex-1 justify-center items-center bg-black'>
|
|
||||||
<Text className='text-neutral-500 mb-4'>{t("music.no_playlists")}</Text>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => setCreateModalOpen(true)}
|
|
||||||
className='flex-row items-center bg-purple-600 px-6 py-3 rounded-full'
|
|
||||||
>
|
|
||||||
<Ionicons name='add' size={20} color='white' />
|
|
||||||
<Text className='text-white font-semibold ml-2'>
|
|
||||||
{t("music.playlists.create_playlist")}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<CreatePlaylistModal
|
|
||||||
open={createModalOpen}
|
|
||||||
setOpen={setCreateModalOpen}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className='flex-1 bg-black'>
|
|
||||||
<FlashList
|
|
||||||
data={playlists}
|
|
||||||
numColumns={numColumns}
|
|
||||||
contentContainerStyle={{
|
|
||||||
paddingBottom: insets.bottom + 100,
|
|
||||||
paddingTop: 16,
|
|
||||||
paddingHorizontal: padding,
|
|
||||||
}}
|
|
||||||
refreshControl={
|
|
||||||
<RefreshControl
|
|
||||||
refreshing={false}
|
|
||||||
onRefresh={refetch}
|
|
||||||
tintColor='#9334E9'
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
onEndReached={handleEndReached}
|
|
||||||
onEndReachedThreshold={0.5}
|
|
||||||
renderItem={({ item, index }) => (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: itemWidth,
|
|
||||||
marginRight: index % numColumns === 0 ? gap : 0,
|
|
||||||
marginBottom: gap,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MusicPlaylistCard playlist={item} width={itemWidth} />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
keyExtractor={(item) => item.Id!}
|
|
||||||
ListFooterComponent={
|
|
||||||
isFetchingNextPage ? (
|
|
||||||
<View className='py-4'>
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<CreatePlaylistModal
|
|
||||||
open={createModalOpen}
|
|
||||||
setOpen={setCreateModalOpen}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,333 +0,0 @@
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useRoute } from "@react-navigation/native";
|
|
||||||
import { FlashList } from "@shopify/flash-list";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useLocalSearchParams } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useCallback, useMemo, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { RefreshControl, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { HorizontalScroll } from "@/components/common/HorizontalScroll";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal";
|
|
||||||
import { MusicAlbumCard } from "@/components/music/MusicAlbumCard";
|
|
||||||
import { MusicTrackItem } from "@/components/music/MusicTrackItem";
|
|
||||||
import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet";
|
|
||||||
import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { writeDebugLog } from "@/utils/log";
|
|
||||||
|
|
||||||
export default function SuggestionsScreen() {
|
|
||||||
const localParams = useLocalSearchParams<{ libraryId?: string | string[] }>();
|
|
||||||
const route = useRoute<any>();
|
|
||||||
const libraryId =
|
|
||||||
(Array.isArray(localParams.libraryId)
|
|
||||||
? localParams.libraryId[0]
|
|
||||||
: localParams.libraryId) ?? route?.params?.libraryId;
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const [selectedTrack, setSelectedTrack] = useState<BaseItemDto | null>(null);
|
|
||||||
const [trackOptionsOpen, setTrackOptionsOpen] = useState(false);
|
|
||||||
const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false);
|
|
||||||
const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false);
|
|
||||||
|
|
||||||
const handleTrackOptionsPress = useCallback((track: BaseItemDto) => {
|
|
||||||
setSelectedTrack(track);
|
|
||||||
setTrackOptionsOpen(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleAddToPlaylist = useCallback(() => {
|
|
||||||
setPlaylistPickerOpen(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleCreateNewPlaylist = useCallback(() => {
|
|
||||||
setCreatePlaylistOpen(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const isReady = Boolean(api && user?.Id && libraryId);
|
|
||||||
|
|
||||||
writeDebugLog("Music suggestions params", {
|
|
||||||
libraryId,
|
|
||||||
localParams,
|
|
||||||
routeParams: route?.params,
|
|
||||||
isReady,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Latest audio - uses the same endpoint as web: /Users/{userId}/Items/Latest
|
|
||||||
// This returns the most recently added albums
|
|
||||||
const {
|
|
||||||
data: latestAlbums,
|
|
||||||
isLoading: loadingLatest,
|
|
||||||
isError: isLatestError,
|
|
||||||
error: latestError,
|
|
||||||
refetch: refetchLatest,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["music-latest", libraryId, user?.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
// Prefer the exact endpoint the Web client calls (HAR):
|
|
||||||
// /Users/{userId}/Items/Latest?IncludeItemTypes=Audio&ParentId=...
|
|
||||||
// IMPORTANT: must use api.get(...) (not axiosInstance.get(fullUrl)) so the auth header is attached.
|
|
||||||
const res = await api!.get<BaseItemDto[]>(
|
|
||||||
`/Users/${user!.Id}/Items/Latest`,
|
|
||||||
{
|
|
||||||
params: {
|
|
||||||
IncludeItemTypes: "Audio",
|
|
||||||
Limit: 20,
|
|
||||||
Fields: "PrimaryImageAspectRatio",
|
|
||||||
ParentId: libraryId,
|
|
||||||
ImageTypeLimit: 1,
|
|
||||||
EnableImageTypes: "Primary,Backdrop,Banner,Thumb",
|
|
||||||
EnableTotalRecordCount: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (Array.isArray(res.data) && res.data.length > 0) {
|
|
||||||
return res.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: ask for albums directly via /Items (more reliable across server variants)
|
|
||||||
const fallback = await getItemsApi(api!).getItems({
|
|
||||||
userId: user!.Id,
|
|
||||||
parentId: libraryId,
|
|
||||||
includeItemTypes: ["MusicAlbum"],
|
|
||||||
sortBy: ["DateCreated"],
|
|
||||||
sortOrder: ["Descending"],
|
|
||||||
limit: 20,
|
|
||||||
recursive: true,
|
|
||||||
fields: ["PrimaryImageAspectRatio", "SortName"],
|
|
||||||
imageTypeLimit: 1,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
|
|
||||||
enableTotalRecordCount: false,
|
|
||||||
});
|
|
||||||
return fallback.data.Items || [];
|
|
||||||
},
|
|
||||||
enabled: isReady,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Recently played - matches web: SortBy=DatePlayed, Filters=IsPlayed
|
|
||||||
const {
|
|
||||||
data: recentlyPlayed,
|
|
||||||
isLoading: loadingRecentlyPlayed,
|
|
||||||
isError: isRecentlyPlayedError,
|
|
||||||
error: recentlyPlayedError,
|
|
||||||
refetch: refetchRecentlyPlayed,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["music-recently-played", libraryId, user?.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await getItemsApi(api!).getItems({
|
|
||||||
userId: user?.Id,
|
|
||||||
parentId: libraryId,
|
|
||||||
includeItemTypes: ["Audio"],
|
|
||||||
sortBy: ["DatePlayed"],
|
|
||||||
sortOrder: ["Descending"],
|
|
||||||
limit: 10,
|
|
||||||
recursive: true,
|
|
||||||
fields: ["PrimaryImageAspectRatio", "SortName"],
|
|
||||||
filters: ["IsPlayed"],
|
|
||||||
imageTypeLimit: 1,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
|
|
||||||
enableTotalRecordCount: false,
|
|
||||||
});
|
|
||||||
return response.data.Items || [];
|
|
||||||
},
|
|
||||||
enabled: isReady,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Frequently played - matches web: SortBy=PlayCount, Filters=IsPlayed
|
|
||||||
const {
|
|
||||||
data: frequentlyPlayed,
|
|
||||||
isLoading: loadingFrequent,
|
|
||||||
isError: isFrequentError,
|
|
||||||
error: frequentError,
|
|
||||||
refetch: refetchFrequent,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["music-frequently-played", libraryId, user?.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await getItemsApi(api!).getItems({
|
|
||||||
userId: user?.Id,
|
|
||||||
parentId: libraryId,
|
|
||||||
includeItemTypes: ["Audio"],
|
|
||||||
sortBy: ["PlayCount"],
|
|
||||||
sortOrder: ["Descending"],
|
|
||||||
limit: 10,
|
|
||||||
recursive: true,
|
|
||||||
fields: ["PrimaryImageAspectRatio", "SortName"],
|
|
||||||
filters: ["IsPlayed"],
|
|
||||||
imageTypeLimit: 1,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
|
|
||||||
enableTotalRecordCount: false,
|
|
||||||
});
|
|
||||||
return response.data.Items || [];
|
|
||||||
},
|
|
||||||
enabled: isReady,
|
|
||||||
});
|
|
||||||
|
|
||||||
const isLoading = loadingLatest || loadingRecentlyPlayed || loadingFrequent;
|
|
||||||
|
|
||||||
const handleRefresh = useCallback(() => {
|
|
||||||
refetchLatest();
|
|
||||||
refetchRecentlyPlayed();
|
|
||||||
refetchFrequent();
|
|
||||||
}, [refetchLatest, refetchRecentlyPlayed, refetchFrequent]);
|
|
||||||
|
|
||||||
const sections = useMemo(() => {
|
|
||||||
const result: {
|
|
||||||
title: string;
|
|
||||||
data: BaseItemDto[];
|
|
||||||
type: "albums" | "tracks";
|
|
||||||
}[] = [];
|
|
||||||
|
|
||||||
// Latest albums section
|
|
||||||
if (latestAlbums && latestAlbums.length > 0) {
|
|
||||||
result.push({
|
|
||||||
title: t("music.recently_added"),
|
|
||||||
data: latestAlbums,
|
|
||||||
type: "albums",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recently played tracks
|
|
||||||
if (recentlyPlayed && recentlyPlayed.length > 0) {
|
|
||||||
result.push({
|
|
||||||
title: t("music.recently_played"),
|
|
||||||
data: recentlyPlayed,
|
|
||||||
type: "tracks",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Frequently played tracks
|
|
||||||
if (frequentlyPlayed && frequentlyPlayed.length > 0) {
|
|
||||||
result.push({
|
|
||||||
title: t("music.frequently_played"),
|
|
||||||
data: frequentlyPlayed,
|
|
||||||
type: "tracks",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}, [latestAlbums, frequentlyPlayed, recentlyPlayed, t]);
|
|
||||||
|
|
||||||
if (!api || !user?.Id) {
|
|
||||||
return (
|
|
||||||
<View className='flex-1 justify-center items-center bg-black'>
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!libraryId) {
|
|
||||||
return (
|
|
||||||
<View className='flex-1 justify-center items-center bg-black px-6'>
|
|
||||||
<Text className='text-neutral-500 text-center'>
|
|
||||||
Missing music library id.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only show loading if we have no cached data to display
|
|
||||||
if (isLoading && sections.length === 0) {
|
|
||||||
return (
|
|
||||||
<View className='flex-1 justify-center items-center bg-black'>
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only show error if we have no cached data to display
|
|
||||||
// This allows offline access to previously cached suggestions
|
|
||||||
if (
|
|
||||||
(isLatestError || isRecentlyPlayedError || isFrequentError) &&
|
|
||||||
sections.length === 0
|
|
||||||
) {
|
|
||||||
const msg =
|
|
||||||
(latestError as Error | undefined)?.message ||
|
|
||||||
(recentlyPlayedError as Error | undefined)?.message ||
|
|
||||||
(frequentError as Error | undefined)?.message ||
|
|
||||||
"Unknown error";
|
|
||||||
return (
|
|
||||||
<View className='flex-1 justify-center items-center bg-black px-6'>
|
|
||||||
<Text className='text-neutral-500 text-center'>
|
|
||||||
Failed to load music: {msg}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sections.length === 0) {
|
|
||||||
return (
|
|
||||||
<View className='flex-1 justify-center items-center bg-black'>
|
|
||||||
<Text className='text-neutral-500'>{t("music.no_suggestions")}</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className='flex-1 bg-black'>
|
|
||||||
<FlashList
|
|
||||||
data={sections}
|
|
||||||
contentContainerStyle={{
|
|
||||||
paddingBottom: insets.bottom + 100,
|
|
||||||
paddingTop: 16,
|
|
||||||
}}
|
|
||||||
refreshControl={
|
|
||||||
<RefreshControl
|
|
||||||
refreshing={false}
|
|
||||||
onRefresh={handleRefresh}
|
|
||||||
tintColor='#9334E9'
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
renderItem={({ item: section }) => (
|
|
||||||
<View className='mb-6'>
|
|
||||||
<Text className='text-lg font-bold px-4 mb-3'>{section.title}</Text>
|
|
||||||
{section.type === "albums" ? (
|
|
||||||
<HorizontalScroll
|
|
||||||
data={section.data}
|
|
||||||
height={200}
|
|
||||||
keyExtractor={(item) => item.Id!}
|
|
||||||
renderItem={(item) => <MusicAlbumCard album={item} />}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
section.data
|
|
||||||
.slice(0, 5)
|
|
||||||
.map((track, index, _tracks) => (
|
|
||||||
<MusicTrackItem
|
|
||||||
key={track.Id}
|
|
||||||
track={track}
|
|
||||||
index={index + 1}
|
|
||||||
queue={section.data}
|
|
||||||
onOptionsPress={handleTrackOptionsPress}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
keyExtractor={(item) => item.title}
|
|
||||||
/>
|
|
||||||
<TrackOptionsSheet
|
|
||||||
open={trackOptionsOpen}
|
|
||||||
setOpen={setTrackOptionsOpen}
|
|
||||||
track={selectedTrack}
|
|
||||||
onAddToPlaylist={handleAddToPlaylist}
|
|
||||||
/>
|
|
||||||
<PlaylistPickerSheet
|
|
||||||
open={playlistPickerOpen}
|
|
||||||
setOpen={setPlaylistPickerOpen}
|
|
||||||
trackToAdd={selectedTrack}
|
|
||||||
onCreateNew={handleCreateNewPlaylist}
|
|
||||||
/>
|
|
||||||
<CreatePlaylistModal
|
|
||||||
open={createPlaylistOpen}
|
|
||||||
setOpen={setCreatePlaylistOpen}
|
|
||||||
initialTrackId={selectedTrack?.Id}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -39,7 +39,6 @@ import { useJellyseerr } from "@/hooks/useJellyseerr";
|
|||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { eventBus } from "@/utils/eventBus";
|
import { eventBus } from "@/utils/eventBus";
|
||||||
import { createStreamystatsApi } from "@/utils/streamystats";
|
|
||||||
|
|
||||||
type SearchType = "Library" | "Discover";
|
type SearchType = "Library" | "Discover";
|
||||||
|
|
||||||
@@ -118,54 +117,6 @@ export default function search() {
|
|||||||
|
|
||||||
return (searchApi.data.Items as BaseItemDto[]) || [];
|
return (searchApi.data.Items as BaseItemDto[]) || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (searchEngine === "Streamystats") {
|
|
||||||
if (!settings?.streamyStatsServerUrl || !api.accessToken) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const streamyStatsApi = createStreamystatsApi({
|
|
||||||
serverUrl: settings.streamyStatsServerUrl,
|
|
||||||
jellyfinToken: api.accessToken,
|
|
||||||
});
|
|
||||||
|
|
||||||
const typeMap: Record<BaseItemKind, string> = {
|
|
||||||
Movie: "movies",
|
|
||||||
Series: "series",
|
|
||||||
Episode: "episodes",
|
|
||||||
Person: "actors",
|
|
||||||
BoxSet: "movies",
|
|
||||||
Audio: "audio",
|
|
||||||
} as Record<BaseItemKind, string>;
|
|
||||||
|
|
||||||
const searchType = types.length === 1 ? typeMap[types[0]] : "media";
|
|
||||||
const response = await streamyStatsApi.searchIds(
|
|
||||||
query,
|
|
||||||
searchType as "movies" | "series" | "episodes" | "actors" | "media",
|
|
||||||
10,
|
|
||||||
);
|
|
||||||
|
|
||||||
const allIds: string[] = [
|
|
||||||
...(response.data.movies || []),
|
|
||||||
...(response.data.series || []),
|
|
||||||
...(response.data.episodes || []),
|
|
||||||
...(response.data.actors || []),
|
|
||||||
...(response.data.audio || []),
|
|
||||||
];
|
|
||||||
|
|
||||||
if (!allIds.length) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const itemsResponse = await getItemsApi(api).getItems({
|
|
||||||
ids: allIds,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
|
||||||
});
|
|
||||||
|
|
||||||
return (itemsResponse.data.Items as BaseItemDto[]) || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Marlin search
|
|
||||||
if (!settings?.marlinServerUrl) {
|
if (!settings?.marlinServerUrl) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -190,11 +141,12 @@ export default function search() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (response2.data.Items as BaseItemDto[]) || [];
|
return (response2.data.Items as BaseItemDto[]) || [];
|
||||||
} catch (_error) {
|
} catch (error) {
|
||||||
return [];
|
console.error("Error during search:", error);
|
||||||
|
return []; // Ensure an empty array is returned in case of an error
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[api, searchEngine, settings, user?.Id],
|
[api, searchEngine, settings],
|
||||||
);
|
);
|
||||||
|
|
||||||
type HeaderSearchBarRef = {
|
type HeaderSearchBarRef = {
|
||||||
|
|||||||
@@ -1,297 +0,0 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { FlashList } from "@shopify/flash-list";
|
|
||||||
import { useLocalSearchParams, useNavigation, useRouter } from "expo-router";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
ActivityIndicator,
|
|
||||||
Alert,
|
|
||||||
RefreshControl,
|
|
||||||
TouchableOpacity,
|
|
||||||
useWindowDimensions,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
|
||||||
import { ItemPoster } from "@/components/posters/ItemPoster";
|
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
|
||||||
import {
|
|
||||||
useDeleteWatchlist,
|
|
||||||
useRemoveFromWatchlist,
|
|
||||||
} from "@/hooks/useWatchlistMutations";
|
|
||||||
import {
|
|
||||||
useWatchlistDetailQuery,
|
|
||||||
useWatchlistItemsQuery,
|
|
||||||
} from "@/hooks/useWatchlists";
|
|
||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
|
||||||
import { userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
|
|
||||||
export default function WatchlistDetailScreen() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const router = useRouter();
|
|
||||||
const navigation = useNavigation();
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const { watchlistId } = useLocalSearchParams<{ watchlistId: string }>();
|
|
||||||
const user = useAtomValue(userAtom);
|
|
||||||
const { width: screenWidth } = useWindowDimensions();
|
|
||||||
const { orientation } = useOrientation();
|
|
||||||
|
|
||||||
const watchlistIdNum = watchlistId
|
|
||||||
? Number.parseInt(watchlistId, 10)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const nrOfCols = useMemo(() => {
|
|
||||||
if (screenWidth < 300) return 2;
|
|
||||||
if (screenWidth < 500) return 3;
|
|
||||||
if (screenWidth < 800) return 5;
|
|
||||||
if (screenWidth < 1000) return 6;
|
|
||||||
if (screenWidth < 1500) return 7;
|
|
||||||
return 6;
|
|
||||||
}, [screenWidth]);
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: watchlist,
|
|
||||||
isLoading: watchlistLoading,
|
|
||||||
refetch: refetchWatchlist,
|
|
||||||
} = useWatchlistDetailQuery(watchlistIdNum);
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: items,
|
|
||||||
isLoading: itemsLoading,
|
|
||||||
refetch: refetchItems,
|
|
||||||
} = useWatchlistItemsQuery(watchlistIdNum);
|
|
||||||
|
|
||||||
const deleteWatchlist = useDeleteWatchlist();
|
|
||||||
const removeFromWatchlist = useRemoveFromWatchlist();
|
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
|
||||||
|
|
||||||
const isOwner = useMemo(
|
|
||||||
() => watchlist?.userId === user?.Id,
|
|
||||||
[watchlist?.userId, user?.Id],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Set up header
|
|
||||||
useEffect(() => {
|
|
||||||
navigation.setOptions({
|
|
||||||
headerTitle: watchlist?.name || "",
|
|
||||||
headerLeft: () => <HeaderBackButton />,
|
|
||||||
headerRight: isOwner
|
|
||||||
? () => (
|
|
||||||
<View className='flex-row gap-2'>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() =>
|
|
||||||
router.push(`/(auth)/(tabs)/(watchlists)/edit/${watchlistId}`)
|
|
||||||
}
|
|
||||||
className='p-2'
|
|
||||||
>
|
|
||||||
<Ionicons name='pencil' size={20} color='white' />
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity onPress={handleDelete} className='p-2'>
|
|
||||||
<Ionicons name='trash-outline' size={20} color='#ef4444' />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
: undefined,
|
|
||||||
});
|
|
||||||
}, [navigation, watchlist?.name, isOwner, watchlistId]);
|
|
||||||
|
|
||||||
const handleRefresh = useCallback(async () => {
|
|
||||||
setRefreshing(true);
|
|
||||||
await Promise.all([refetchWatchlist(), refetchItems()]);
|
|
||||||
setRefreshing(false);
|
|
||||||
}, [refetchWatchlist, refetchItems]);
|
|
||||||
|
|
||||||
const handleDelete = useCallback(() => {
|
|
||||||
Alert.alert(
|
|
||||||
t("watchlists.delete_confirm_title"),
|
|
||||||
t("watchlists.delete_confirm_message", { name: watchlist?.name }),
|
|
||||||
[
|
|
||||||
{ text: t("watchlists.cancel_button"), style: "cancel" },
|
|
||||||
{
|
|
||||||
text: t("watchlists.delete_button"),
|
|
||||||
style: "destructive",
|
|
||||||
onPress: async () => {
|
|
||||||
if (watchlistIdNum) {
|
|
||||||
await deleteWatchlist.mutateAsync(watchlistIdNum);
|
|
||||||
router.back();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}, [deleteWatchlist, watchlistIdNum, watchlist?.name, router, t]);
|
|
||||||
|
|
||||||
const handleRemoveItem = useCallback(
|
|
||||||
(item: BaseItemDto) => {
|
|
||||||
if (!watchlistIdNum || !item.Id) return;
|
|
||||||
|
|
||||||
Alert.alert(
|
|
||||||
t("watchlists.remove_item_title"),
|
|
||||||
t("watchlists.remove_item_message", { name: item.Name }),
|
|
||||||
[
|
|
||||||
{ text: t("watchlists.cancel_button"), style: "cancel" },
|
|
||||||
{
|
|
||||||
text: t("watchlists.remove_button"),
|
|
||||||
style: "destructive",
|
|
||||||
onPress: async () => {
|
|
||||||
await removeFromWatchlist.mutateAsync({
|
|
||||||
watchlistId: watchlistIdNum,
|
|
||||||
itemId: item.Id!,
|
|
||||||
watchlistName: watchlist?.name,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[removeFromWatchlist, watchlistIdNum, watchlist?.name, t],
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderItem = useCallback(
|
|
||||||
({ item, index }: { item: BaseItemDto; index: number }) => (
|
|
||||||
<TouchableItemRouter
|
|
||||||
key={item.Id}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
marginBottom: 4,
|
|
||||||
}}
|
|
||||||
item={item}
|
|
||||||
onLongPress={isOwner ? () => handleRemoveItem(item) : undefined}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
alignSelf:
|
|
||||||
orientation === ScreenOrientation.OrientationLock.PORTRAIT_UP
|
|
||||||
? index % nrOfCols === 0
|
|
||||||
? "flex-end"
|
|
||||||
: (index + 1) % nrOfCols === 0
|
|
||||||
? "flex-start"
|
|
||||||
: "center"
|
|
||||||
: "center",
|
|
||||||
width: "89%",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ItemPoster item={item} />
|
|
||||||
<ItemCardText item={item} />
|
|
||||||
</View>
|
|
||||||
</TouchableItemRouter>
|
|
||||||
),
|
|
||||||
[isOwner, handleRemoveItem, orientation, nrOfCols],
|
|
||||||
);
|
|
||||||
|
|
||||||
const ListHeader = useMemo(
|
|
||||||
() =>
|
|
||||||
watchlist ? (
|
|
||||||
<View className='px-4 pt-4 pb-6 mb-4 border-b border-neutral-800'>
|
|
||||||
{watchlist.description && (
|
|
||||||
<Text className='text-neutral-400 mb-2'>
|
|
||||||
{watchlist.description}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
<View className='flex-row items-center gap-4'>
|
|
||||||
<View className='flex-row items-center gap-1'>
|
|
||||||
<Ionicons name='film-outline' size={14} color='#9ca3af' />
|
|
||||||
<Text className='text-neutral-400 text-sm'>
|
|
||||||
{items?.length ?? 0}{" "}
|
|
||||||
{(items?.length ?? 0) === 1
|
|
||||||
? t("watchlists.item")
|
|
||||||
: t("watchlists.items")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View className='flex-row items-center gap-1'>
|
|
||||||
<Ionicons
|
|
||||||
name={
|
|
||||||
watchlist.isPublic ? "globe-outline" : "lock-closed-outline"
|
|
||||||
}
|
|
||||||
size={14}
|
|
||||||
color='#9ca3af'
|
|
||||||
/>
|
|
||||||
<Text className='text-neutral-400 text-sm'>
|
|
||||||
{watchlist.isPublic
|
|
||||||
? t("watchlists.public")
|
|
||||||
: t("watchlists.private")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
{!isOwner && (
|
|
||||||
<Text className='text-neutral-500 text-sm'>
|
|
||||||
{t("watchlists.by_owner")}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
) : null,
|
|
||||||
[watchlist, items?.length, isOwner, t],
|
|
||||||
);
|
|
||||||
|
|
||||||
const EmptyComponent = useMemo(
|
|
||||||
() => (
|
|
||||||
<View className='flex-1 items-center justify-center px-8 py-16'>
|
|
||||||
<Ionicons name='film-outline' size={48} color='#4b5563' />
|
|
||||||
<Text className='text-neutral-400 text-center mt-4'>
|
|
||||||
{t("watchlists.empty_watchlist")}
|
|
||||||
</Text>
|
|
||||||
{isOwner && (
|
|
||||||
<Text className='text-neutral-500 text-center mt-2 text-sm'>
|
|
||||||
{t("watchlists.empty_watchlist_hint")}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
),
|
|
||||||
[isOwner, t],
|
|
||||||
);
|
|
||||||
|
|
||||||
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
|
||||||
|
|
||||||
if (watchlistLoading || itemsLoading) {
|
|
||||||
return (
|
|
||||||
<View className='flex-1 items-center justify-center'>
|
|
||||||
<ActivityIndicator size='large' />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!watchlist) {
|
|
||||||
return (
|
|
||||||
<View className='flex-1 items-center justify-center px-8'>
|
|
||||||
<Text className='text-lg text-neutral-400'>
|
|
||||||
{t("watchlists.not_found")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FlashList
|
|
||||||
key={orientation}
|
|
||||||
data={items ?? []}
|
|
||||||
numColumns={nrOfCols}
|
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
|
||||||
ListHeaderComponent={ListHeader}
|
|
||||||
ListEmptyComponent={EmptyComponent}
|
|
||||||
extraData={[orientation, nrOfCols]}
|
|
||||||
keyExtractor={keyExtractor}
|
|
||||||
contentContainerStyle={{
|
|
||||||
paddingBottom: 24,
|
|
||||||
paddingLeft: insets.left,
|
|
||||||
paddingRight: insets.right,
|
|
||||||
}}
|
|
||||||
refreshControl={
|
|
||||||
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
|
|
||||||
}
|
|
||||||
renderItem={renderItem}
|
|
||||||
ItemSeparatorComponent={() => (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: 10,
|
|
||||||
height: 10,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { Stack, useRouter } from "expo-router";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform, TouchableOpacity } from "react-native";
|
|
||||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
|
||||||
import { useStreamystatsEnabled } from "@/hooks/useWatchlists";
|
|
||||||
|
|
||||||
export default function WatchlistsLayout() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const router = useRouter();
|
|
||||||
const streamystatsEnabled = useStreamystatsEnabled();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack>
|
|
||||||
<Stack.Screen
|
|
||||||
name='index'
|
|
||||||
options={{
|
|
||||||
headerShown: !Platform.isTV,
|
|
||||||
headerTitle: t("watchlists.title"),
|
|
||||||
headerBlurEffect: "none",
|
|
||||||
headerTransparent: Platform.OS === "ios",
|
|
||||||
headerShadowVisible: false,
|
|
||||||
headerRight: streamystatsEnabled
|
|
||||||
? () => (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() =>
|
|
||||||
router.push("/(auth)/(tabs)/(watchlists)/create")
|
|
||||||
}
|
|
||||||
className='p-1.5'
|
|
||||||
>
|
|
||||||
<Ionicons name='add' size={24} color='white' />
|
|
||||||
</TouchableOpacity>
|
|
||||||
)
|
|
||||||
: undefined,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='[watchlistId]'
|
|
||||||
options={{
|
|
||||||
title: "",
|
|
||||||
headerShown: true,
|
|
||||||
headerBlurEffect: "none",
|
|
||||||
headerTransparent: Platform.OS === "ios",
|
|
||||||
headerShadowVisible: false,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='create'
|
|
||||||
options={{
|
|
||||||
title: t("watchlists.create_title"),
|
|
||||||
presentation: "modal",
|
|
||||||
headerShown: true,
|
|
||||||
headerStyle: { backgroundColor: "#171717" },
|
|
||||||
headerTintColor: "white",
|
|
||||||
contentStyle: { backgroundColor: "#171717" },
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='edit/[watchlistId]'
|
|
||||||
options={{
|
|
||||||
title: t("watchlists.edit_title"),
|
|
||||||
presentation: "modal",
|
|
||||||
headerShown: true,
|
|
||||||
headerStyle: { backgroundColor: "#171717" },
|
|
||||||
headerTintColor: "white",
|
|
||||||
contentStyle: { backgroundColor: "#171717" },
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
|
||||||
<Stack.Screen key={name} name={name} options={options} />
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,221 +0,0 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { useRouter } from "expo-router";
|
|
||||||
import { useCallback, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
ActivityIndicator,
|
|
||||||
KeyboardAvoidingView,
|
|
||||||
Platform,
|
|
||||||
ScrollView,
|
|
||||||
Switch,
|
|
||||||
TextInput,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { Button } from "@/components/Button";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { useCreateWatchlist } from "@/hooks/useWatchlistMutations";
|
|
||||||
import type {
|
|
||||||
StreamystatsWatchlistAllowedItemType,
|
|
||||||
StreamystatsWatchlistSortOrder,
|
|
||||||
} from "@/utils/streamystats/types";
|
|
||||||
|
|
||||||
const ITEM_TYPES: Array<{
|
|
||||||
value: StreamystatsWatchlistAllowedItemType;
|
|
||||||
label: string;
|
|
||||||
}> = [
|
|
||||||
{ value: null, label: "All Types" },
|
|
||||||
{ value: "Movie", label: "Movies Only" },
|
|
||||||
{ value: "Series", label: "Series Only" },
|
|
||||||
{ value: "Episode", label: "Episodes Only" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const SORT_OPTIONS: Array<{
|
|
||||||
value: StreamystatsWatchlistSortOrder;
|
|
||||||
label: string;
|
|
||||||
}> = [
|
|
||||||
{ value: "custom", label: "Custom Order" },
|
|
||||||
{ value: "name", label: "Name" },
|
|
||||||
{ value: "dateAdded", label: "Date Added" },
|
|
||||||
{ value: "releaseDate", label: "Release Date" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function CreateWatchlistScreen() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const router = useRouter();
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const createWatchlist = useCreateWatchlist();
|
|
||||||
|
|
||||||
const [name, setName] = useState("");
|
|
||||||
const [description, setDescription] = useState("");
|
|
||||||
const [isPublic, setIsPublic] = useState(false);
|
|
||||||
const [allowedItemType, setAllowedItemType] =
|
|
||||||
useState<StreamystatsWatchlistAllowedItemType>(null);
|
|
||||||
const [defaultSortOrder, setDefaultSortOrder] =
|
|
||||||
useState<StreamystatsWatchlistSortOrder>("custom");
|
|
||||||
|
|
||||||
const handleCreate = useCallback(async () => {
|
|
||||||
if (!name.trim()) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await createWatchlist.mutateAsync({
|
|
||||||
name: name.trim(),
|
|
||||||
description: description.trim() || undefined,
|
|
||||||
isPublic,
|
|
||||||
allowedItemType,
|
|
||||||
defaultSortOrder,
|
|
||||||
});
|
|
||||||
router.back();
|
|
||||||
} catch {
|
|
||||||
// Error handled by mutation
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
isPublic,
|
|
||||||
allowedItemType,
|
|
||||||
defaultSortOrder,
|
|
||||||
createWatchlist,
|
|
||||||
router,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<KeyboardAvoidingView
|
|
||||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
|
||||||
className='flex-1'
|
|
||||||
style={{ backgroundColor: "#171717" }}
|
|
||||||
>
|
|
||||||
<ScrollView
|
|
||||||
className='flex-1'
|
|
||||||
contentContainerStyle={{
|
|
||||||
paddingBottom: insets.bottom + 20,
|
|
||||||
}}
|
|
||||||
keyboardShouldPersistTaps='handled'
|
|
||||||
>
|
|
||||||
{/* Name */}
|
|
||||||
<View className='px-4 py-4'>
|
|
||||||
<Text className='text-sm font-medium text-neutral-400 mb-2'>
|
|
||||||
{t("watchlists.name_label")} *
|
|
||||||
</Text>
|
|
||||||
<TextInput
|
|
||||||
value={name}
|
|
||||||
onChangeText={setName}
|
|
||||||
placeholder={t("watchlists.name_placeholder")}
|
|
||||||
placeholderTextColor='#6b7280'
|
|
||||||
className='bg-neutral-800 text-white px-4 py-3 rounded-lg text-base'
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<View className='px-4 py-4'>
|
|
||||||
<Text className='text-sm font-medium text-neutral-400 mb-2'>
|
|
||||||
{t("watchlists.description_label")}
|
|
||||||
</Text>
|
|
||||||
<TextInput
|
|
||||||
value={description}
|
|
||||||
onChangeText={setDescription}
|
|
||||||
placeholder={t("watchlists.description_placeholder")}
|
|
||||||
placeholderTextColor='#6b7280'
|
|
||||||
className='bg-neutral-800 text-white px-4 py-3 rounded-lg text-base'
|
|
||||||
multiline
|
|
||||||
numberOfLines={3}
|
|
||||||
textAlignVertical='top'
|
|
||||||
style={{ minHeight: 80 }}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Public Toggle */}
|
|
||||||
<View className='px-4 py-4 flex-row items-center justify-between'>
|
|
||||||
<View className='flex-1 mr-4'>
|
|
||||||
<Text className='text-base font-medium text-white'>
|
|
||||||
{t("watchlists.is_public_label")}
|
|
||||||
</Text>
|
|
||||||
<Text className='text-sm text-neutral-400 mt-1'>
|
|
||||||
{t("watchlists.is_public_description")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Switch
|
|
||||||
value={isPublic}
|
|
||||||
onValueChange={setIsPublic}
|
|
||||||
trackColor={{ false: "#374151", true: "#7c3aed" }}
|
|
||||||
thumbColor={isPublic ? "#a78bfa" : "#9ca3af"}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Content Type */}
|
|
||||||
<View className='px-4 py-4'>
|
|
||||||
<Text className='text-sm font-medium text-neutral-400 mb-2'>
|
|
||||||
{t("watchlists.allowed_type_label")}
|
|
||||||
</Text>
|
|
||||||
<View className='flex-row flex-wrap gap-2'>
|
|
||||||
{ITEM_TYPES.map((type) => (
|
|
||||||
<TouchableOpacity
|
|
||||||
key={type.value ?? "all"}
|
|
||||||
onPress={() => setAllowedItemType(type.value)}
|
|
||||||
className={`px-4 py-2 rounded-lg ${allowedItemType === type.value ? "bg-purple-600" : "bg-neutral-800"}`}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
className={
|
|
||||||
allowedItemType === type.value
|
|
||||||
? "text-white font-medium"
|
|
||||||
: "text-neutral-300"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{type.label}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Sort Order */}
|
|
||||||
<View className='px-4 py-4'>
|
|
||||||
<Text className='text-sm font-medium text-neutral-400 mb-2'>
|
|
||||||
{t("watchlists.sort_order_label")}
|
|
||||||
</Text>
|
|
||||||
<View className='flex-row flex-wrap gap-2'>
|
|
||||||
{SORT_OPTIONS.map((sort) => (
|
|
||||||
<TouchableOpacity
|
|
||||||
key={sort.value}
|
|
||||||
onPress={() => setDefaultSortOrder(sort.value)}
|
|
||||||
className={`px-4 py-2 rounded-lg ${defaultSortOrder === sort.value ? "bg-purple-600" : "bg-neutral-800"}`}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
className={
|
|
||||||
defaultSortOrder === sort.value
|
|
||||||
? "text-white font-medium"
|
|
||||||
: "text-neutral-300"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{sort.label}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Create Button */}
|
|
||||||
<View className='px-4 pt-4'>
|
|
||||||
<Button
|
|
||||||
onPress={handleCreate}
|
|
||||||
disabled={!name.trim() || createWatchlist.isPending}
|
|
||||||
className={`py-3 ${!name.trim() ? "opacity-50" : ""}`}
|
|
||||||
>
|
|
||||||
{createWatchlist.isPending ? (
|
|
||||||
<ActivityIndicator color='white' />
|
|
||||||
) : (
|
|
||||||
<View className='flex-row items-center'>
|
|
||||||
<Ionicons name='add' size={20} color='white' />
|
|
||||||
<Text className='text-white font-semibold text-base'>
|
|
||||||
{t("watchlists.create_button")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
</KeyboardAvoidingView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,273 +0,0 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
ActivityIndicator,
|
|
||||||
KeyboardAvoidingView,
|
|
||||||
Platform,
|
|
||||||
ScrollView,
|
|
||||||
Switch,
|
|
||||||
TextInput,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { Button } from "@/components/Button";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { useUpdateWatchlist } from "@/hooks/useWatchlistMutations";
|
|
||||||
import { useWatchlistDetailQuery } from "@/hooks/useWatchlists";
|
|
||||||
import type {
|
|
||||||
StreamystatsWatchlistAllowedItemType,
|
|
||||||
StreamystatsWatchlistSortOrder,
|
|
||||||
} from "@/utils/streamystats/types";
|
|
||||||
|
|
||||||
const ITEM_TYPES: Array<{
|
|
||||||
value: StreamystatsWatchlistAllowedItemType;
|
|
||||||
label: string;
|
|
||||||
}> = [
|
|
||||||
{ value: null, label: "All Types" },
|
|
||||||
{ value: "Movie", label: "Movies Only" },
|
|
||||||
{ value: "Series", label: "Series Only" },
|
|
||||||
{ value: "Episode", label: "Episodes Only" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const SORT_OPTIONS: Array<{
|
|
||||||
value: StreamystatsWatchlistSortOrder;
|
|
||||||
label: string;
|
|
||||||
}> = [
|
|
||||||
{ value: "custom", label: "Custom Order" },
|
|
||||||
{ value: "name", label: "Name" },
|
|
||||||
{ value: "dateAdded", label: "Date Added" },
|
|
||||||
{ value: "releaseDate", label: "Release Date" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function EditWatchlistScreen() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const router = useRouter();
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const { watchlistId } = useLocalSearchParams<{ watchlistId: string }>();
|
|
||||||
const watchlistIdNum = watchlistId
|
|
||||||
? Number.parseInt(watchlistId, 10)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const { data: watchlist, isLoading } =
|
|
||||||
useWatchlistDetailQuery(watchlistIdNum);
|
|
||||||
const updateWatchlist = useUpdateWatchlist();
|
|
||||||
|
|
||||||
const [name, setName] = useState("");
|
|
||||||
const [description, setDescription] = useState("");
|
|
||||||
const [isPublic, setIsPublic] = useState(false);
|
|
||||||
const [allowedItemType, setAllowedItemType] =
|
|
||||||
useState<StreamystatsWatchlistAllowedItemType>(null);
|
|
||||||
const [defaultSortOrder, setDefaultSortOrder] =
|
|
||||||
useState<StreamystatsWatchlistSortOrder>("custom");
|
|
||||||
|
|
||||||
// Initialize form with watchlist data
|
|
||||||
useEffect(() => {
|
|
||||||
if (watchlist) {
|
|
||||||
setName(watchlist.name);
|
|
||||||
setDescription(watchlist.description ?? "");
|
|
||||||
setIsPublic(watchlist.isPublic);
|
|
||||||
setAllowedItemType(
|
|
||||||
(watchlist.allowedItemType as StreamystatsWatchlistAllowedItemType) ??
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
setDefaultSortOrder(
|
|
||||||
(watchlist.defaultSortOrder as StreamystatsWatchlistSortOrder) ??
|
|
||||||
"custom",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [watchlist]);
|
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
|
||||||
if (!name.trim() || !watchlistIdNum) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await updateWatchlist.mutateAsync({
|
|
||||||
watchlistId: watchlistIdNum,
|
|
||||||
data: {
|
|
||||||
name: name.trim(),
|
|
||||||
description: description.trim() || undefined,
|
|
||||||
isPublic,
|
|
||||||
allowedItemType,
|
|
||||||
defaultSortOrder,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
router.back();
|
|
||||||
} catch {
|
|
||||||
// Error handled by mutation
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
isPublic,
|
|
||||||
allowedItemType,
|
|
||||||
defaultSortOrder,
|
|
||||||
watchlistIdNum,
|
|
||||||
updateWatchlist,
|
|
||||||
router,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
className='flex-1 items-center justify-center'
|
|
||||||
style={{ backgroundColor: "#171717" }}
|
|
||||||
>
|
|
||||||
<ActivityIndicator size='large' />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!watchlist) {
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
className='flex-1 items-center justify-center px-8'
|
|
||||||
style={{ backgroundColor: "#171717" }}
|
|
||||||
>
|
|
||||||
<Text className='text-lg text-neutral-400'>
|
|
||||||
{t("watchlists.not_found")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<KeyboardAvoidingView
|
|
||||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
|
||||||
className='flex-1'
|
|
||||||
style={{ backgroundColor: "#171717" }}
|
|
||||||
>
|
|
||||||
<ScrollView
|
|
||||||
className='flex-1'
|
|
||||||
contentContainerStyle={{
|
|
||||||
paddingBottom: insets.bottom + 20,
|
|
||||||
}}
|
|
||||||
keyboardShouldPersistTaps='handled'
|
|
||||||
>
|
|
||||||
{/* Name */}
|
|
||||||
<View className='px-4 py-4'>
|
|
||||||
<Text className='text-sm font-medium text-neutral-400 mb-2'>
|
|
||||||
{t("watchlists.name_label")} *
|
|
||||||
</Text>
|
|
||||||
<TextInput
|
|
||||||
value={name}
|
|
||||||
onChangeText={setName}
|
|
||||||
placeholder={t("watchlists.name_placeholder")}
|
|
||||||
placeholderTextColor='#6b7280'
|
|
||||||
className='bg-neutral-800 text-white px-4 py-3 rounded-lg text-base'
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<View className='px-4 py-4'>
|
|
||||||
<Text className='text-sm font-medium text-neutral-400 mb-2'>
|
|
||||||
{t("watchlists.description_label")}
|
|
||||||
</Text>
|
|
||||||
<TextInput
|
|
||||||
value={description}
|
|
||||||
onChangeText={setDescription}
|
|
||||||
placeholder={t("watchlists.description_placeholder")}
|
|
||||||
placeholderTextColor='#6b7280'
|
|
||||||
className='bg-neutral-800 text-white px-4 py-3 rounded-lg text-base'
|
|
||||||
multiline
|
|
||||||
numberOfLines={3}
|
|
||||||
textAlignVertical='top'
|
|
||||||
style={{ minHeight: 80 }}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Public Toggle */}
|
|
||||||
<View className='px-4 py-4 flex-row items-center justify-between'>
|
|
||||||
<View className='flex-1 mr-4'>
|
|
||||||
<Text className='text-base font-medium text-white'>
|
|
||||||
{t("watchlists.is_public_label")}
|
|
||||||
</Text>
|
|
||||||
<Text className='text-sm text-neutral-400 mt-1'>
|
|
||||||
{t("watchlists.is_public_description")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Switch
|
|
||||||
value={isPublic}
|
|
||||||
onValueChange={setIsPublic}
|
|
||||||
trackColor={{ false: "#374151", true: "#7c3aed" }}
|
|
||||||
thumbColor={isPublic ? "#a78bfa" : "#9ca3af"}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Content Type */}
|
|
||||||
<View className='px-4 py-4'>
|
|
||||||
<Text className='text-sm font-medium text-neutral-400 mb-2'>
|
|
||||||
{t("watchlists.allowed_type_label")}
|
|
||||||
</Text>
|
|
||||||
<View className='flex-row flex-wrap gap-2'>
|
|
||||||
{ITEM_TYPES.map((type) => (
|
|
||||||
<TouchableOpacity
|
|
||||||
key={type.value ?? "all"}
|
|
||||||
onPress={() => setAllowedItemType(type.value)}
|
|
||||||
className={`px-4 py-2 rounded-lg ${allowedItemType === type.value ? "bg-purple-600" : "bg-neutral-800"}`}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
className={
|
|
||||||
allowedItemType === type.value
|
|
||||||
? "text-white font-medium"
|
|
||||||
: "text-neutral-300"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{type.label}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Sort Order */}
|
|
||||||
<View className='px-4 py-4'>
|
|
||||||
<Text className='text-sm font-medium text-neutral-400 mb-2'>
|
|
||||||
{t("watchlists.sort_order_label")}
|
|
||||||
</Text>
|
|
||||||
<View className='flex-row flex-wrap gap-2'>
|
|
||||||
{SORT_OPTIONS.map((sort) => (
|
|
||||||
<TouchableOpacity
|
|
||||||
key={sort.value}
|
|
||||||
onPress={() => setDefaultSortOrder(sort.value)}
|
|
||||||
className={`px-4 py-2 rounded-lg ${defaultSortOrder === sort.value ? "bg-purple-600" : "bg-neutral-800"}`}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
className={
|
|
||||||
defaultSortOrder === sort.value
|
|
||||||
? "text-white font-medium"
|
|
||||||
: "text-neutral-300"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{sort.label}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Save Button */}
|
|
||||||
<View className='px-4 pt-4'>
|
|
||||||
<Button
|
|
||||||
onPress={handleSave}
|
|
||||||
disabled={!name.trim() || updateWatchlist.isPending}
|
|
||||||
className={`py-3 ${!name.trim() ? "opacity-50" : ""}`}
|
|
||||||
>
|
|
||||||
{updateWatchlist.isPending ? (
|
|
||||||
<ActivityIndicator color='white' />
|
|
||||||
) : (
|
|
||||||
<View className='flex-row items-center'>
|
|
||||||
<Ionicons name='checkmark' size={20} color='white' />
|
|
||||||
<Text className='text-white font-semibold text-base'>
|
|
||||||
{t("watchlists.save_button")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
</KeyboardAvoidingView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { FlashList } from "@shopify/flash-list";
|
|
||||||
import { useRouter } from "expo-router";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { useCallback, useMemo, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Platform, RefreshControl, TouchableOpacity, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { Button } from "@/components/Button";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import {
|
|
||||||
useStreamystatsEnabled,
|
|
||||||
useWatchlistsQuery,
|
|
||||||
} from "@/hooks/useWatchlists";
|
|
||||||
import { userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import type { StreamystatsWatchlist } from "@/utils/streamystats/types";
|
|
||||||
|
|
||||||
interface WatchlistCardProps {
|
|
||||||
watchlist: StreamystatsWatchlist;
|
|
||||||
isOwner: boolean;
|
|
||||||
onPress: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const WatchlistCard: React.FC<WatchlistCardProps> = ({
|
|
||||||
watchlist,
|
|
||||||
isOwner,
|
|
||||||
onPress,
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={onPress}
|
|
||||||
className='bg-neutral-900 rounded-xl p-4 mx-4 mb-3'
|
|
||||||
activeOpacity={0.7}
|
|
||||||
>
|
|
||||||
<View className='flex-row items-center justify-between mb-2'>
|
|
||||||
<Text className='text-lg font-semibold flex-1' numberOfLines={1}>
|
|
||||||
{watchlist.name}
|
|
||||||
</Text>
|
|
||||||
<View className='flex-row items-center gap-2'>
|
|
||||||
{isOwner && (
|
|
||||||
<View className='bg-purple-600/20 px-2 py-1 rounded'>
|
|
||||||
<Text className='text-purple-400 text-xs'>
|
|
||||||
{t("watchlists.you")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
<Ionicons
|
|
||||||
name={watchlist.isPublic ? "globe-outline" : "lock-closed-outline"}
|
|
||||||
size={16}
|
|
||||||
color='#9ca3af'
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{watchlist.description && (
|
|
||||||
<Text className='text-neutral-400 text-sm mb-2' numberOfLines={2}>
|
|
||||||
{watchlist.description}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<View className='flex-row items-center gap-4'>
|
|
||||||
<View className='flex-row items-center gap-1'>
|
|
||||||
<Ionicons name='film-outline' size={14} color='#9ca3af' />
|
|
||||||
<Text className='text-neutral-400 text-sm'>
|
|
||||||
{watchlist.itemCount ?? 0}{" "}
|
|
||||||
{(watchlist.itemCount ?? 0) === 1
|
|
||||||
? t("watchlists.item")
|
|
||||||
: t("watchlists.items")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
{watchlist.allowedItemType && (
|
|
||||||
<View className='bg-neutral-800 px-2 py-0.5 rounded'>
|
|
||||||
<Text className='text-neutral-400 text-xs'>
|
|
||||||
{watchlist.allowedItemType}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const EmptyState: React.FC<{ onCreatePress: () => void }> = ({
|
|
||||||
onCreatePress: _onCreatePress,
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className='flex-1 items-center justify-center px-8'>
|
|
||||||
<Ionicons name='list-outline' size={64} color='#4b5563' />
|
|
||||||
<Text className='text-xl font-semibold mt-4 text-center'>
|
|
||||||
{t("watchlists.empty_title")}
|
|
||||||
</Text>
|
|
||||||
<Text className='text-neutral-400 text-center mt-2 mb-6'>
|
|
||||||
{t("watchlists.empty_description")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const NotConfiguredState: React.FC = () => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className='flex-1 items-center justify-center px-8'>
|
|
||||||
<Ionicons name='settings-outline' size={64} color='#4b5563' />
|
|
||||||
<Text className='text-xl font-semibold mt-4 text-center'>
|
|
||||||
{t("watchlists.not_configured_title")}
|
|
||||||
</Text>
|
|
||||||
<Text className='text-neutral-400 text-center mt-2 mb-6'>
|
|
||||||
{t("watchlists.not_configured_description")}
|
|
||||||
</Text>
|
|
||||||
<Button
|
|
||||||
onPress={() =>
|
|
||||||
router.push(
|
|
||||||
"/(auth)/(tabs)/(home)/settings/plugins/streamystats/page",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className='px-6'
|
|
||||||
>
|
|
||||||
<Text className='font-semibold'>{t("watchlists.go_to_settings")}</Text>
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function WatchlistsScreen() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const router = useRouter();
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const user = useAtomValue(userAtom);
|
|
||||||
const streamystatsEnabled = useStreamystatsEnabled();
|
|
||||||
const { data: watchlists, isLoading, refetch } = useWatchlistsQuery();
|
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
|
||||||
|
|
||||||
const handleRefresh = useCallback(async () => {
|
|
||||||
setRefreshing(true);
|
|
||||||
await refetch();
|
|
||||||
setRefreshing(false);
|
|
||||||
}, [refetch]);
|
|
||||||
|
|
||||||
const handleCreatePress = useCallback(() => {
|
|
||||||
router.push("/(auth)/(tabs)/(watchlists)/create");
|
|
||||||
}, [router]);
|
|
||||||
|
|
||||||
const handleWatchlistPress = useCallback(
|
|
||||||
(watchlistId: number) => {
|
|
||||||
router.push(`/(auth)/(tabs)/(watchlists)/${watchlistId}`);
|
|
||||||
},
|
|
||||||
[router],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Separate watchlists into "mine" and "public"
|
|
||||||
const { myWatchlists, publicWatchlists } = useMemo(() => {
|
|
||||||
if (!watchlists) return { myWatchlists: [], publicWatchlists: [] };
|
|
||||||
|
|
||||||
const mine: StreamystatsWatchlist[] = [];
|
|
||||||
const pub: StreamystatsWatchlist[] = [];
|
|
||||||
|
|
||||||
for (const w of watchlists) {
|
|
||||||
if (w.userId === user?.Id) {
|
|
||||||
mine.push(w);
|
|
||||||
} else {
|
|
||||||
pub.push(w);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { myWatchlists: mine, publicWatchlists: pub };
|
|
||||||
}, [watchlists, user?.Id]);
|
|
||||||
|
|
||||||
// Combine into sections for FlashList
|
|
||||||
const sections = useMemo(() => {
|
|
||||||
const result: Array<
|
|
||||||
| { type: "header"; title: string }
|
|
||||||
| { type: "watchlist"; data: StreamystatsWatchlist; isOwner: boolean }
|
|
||||||
> = [];
|
|
||||||
|
|
||||||
if (myWatchlists.length > 0) {
|
|
||||||
result.push({ type: "header", title: t("watchlists.my_watchlists") });
|
|
||||||
for (const w of myWatchlists) {
|
|
||||||
result.push({ type: "watchlist", data: w, isOwner: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (publicWatchlists.length > 0) {
|
|
||||||
result.push({ type: "header", title: t("watchlists.public_watchlists") });
|
|
||||||
for (const w of publicWatchlists) {
|
|
||||||
result.push({ type: "watchlist", data: w, isOwner: false });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}, [myWatchlists, publicWatchlists, t]);
|
|
||||||
|
|
||||||
if (!streamystatsEnabled) {
|
|
||||||
return <NotConfiguredState />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isLoading && (!watchlists || watchlists.length === 0)) {
|
|
||||||
return <EmptyState onCreatePress={handleCreatePress} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FlashList
|
|
||||||
data={sections}
|
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
|
||||||
contentContainerStyle={{
|
|
||||||
paddingTop: Platform.OS === "android" ? 10 : 0,
|
|
||||||
paddingBottom: 100,
|
|
||||||
paddingLeft: insets.left,
|
|
||||||
paddingRight: insets.right,
|
|
||||||
}}
|
|
||||||
refreshControl={
|
|
||||||
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
|
|
||||||
}
|
|
||||||
renderItem={({ item }) => {
|
|
||||||
if (item.type === "header") {
|
|
||||||
return (
|
|
||||||
<Text className='text-lg font-bold px-4 pt-4 pb-2'>
|
|
||||||
{item.title}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<WatchlistCard
|
|
||||||
watchlist={item.data}
|
|
||||||
isOwner={item.isOwner}
|
|
||||||
onPress={() => handleWatchlistPress(item.data.id)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
getItemType={(item) => item.type}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -10,10 +10,8 @@ import type {
|
|||||||
import { useFocusEffect, useRouter, withLayoutContext } from "expo-router";
|
import { useFocusEffect, useRouter, withLayoutContext } from "expo-router";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, View } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
import { MiniPlayerBar } from "@/components/music/MiniPlayerBar";
|
|
||||||
import { MusicPlaybackEngine } from "@/components/music/MusicPlaybackEngine";
|
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { eventBus } from "@/utils/eventBus";
|
import { eventBus } from "@/utils/eventBus";
|
||||||
@@ -49,7 +47,7 @@ export default function TabLayout() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1 }}>
|
<>
|
||||||
<SystemBars hidden={false} style='light' />
|
<SystemBars hidden={false} style='light' />
|
||||||
<NativeTabs
|
<NativeTabs
|
||||||
sidebarAdaptable={false}
|
sidebarAdaptable={false}
|
||||||
@@ -102,18 +100,6 @@ 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={{
|
||||||
@@ -136,8 +122,6 @@ export default function TabLayout() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</NativeTabs>
|
</NativeTabs>
|
||||||
<MiniPlayerBar />
|
</>
|
||||||
<MusicPlaybackEngine />
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,633 +0,0 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import type {
|
|
||||||
BaseItemDto,
|
|
||||||
MediaSourceInfo,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { useRouter } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import {
|
|
||||||
ActivityIndicator,
|
|
||||||
Dimensions,
|
|
||||||
Platform,
|
|
||||||
ScrollView,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import { Slider } from "react-native-awesome-slider";
|
|
||||||
import DraggableFlatList, {
|
|
||||||
type RenderItemParams,
|
|
||||||
ScaleDecorator,
|
|
||||||
} from "react-native-draggable-flatlist";
|
|
||||||
import { useSharedValue } from "react-native-reanimated";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { Badge } from "@/components/Badge";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import {
|
|
||||||
type RepeatMode,
|
|
||||||
useMusicPlayer,
|
|
||||||
} from "@/providers/MusicPlayerProvider";
|
|
||||||
import { formatBitrate } from "@/utils/bitrate";
|
|
||||||
import { formatDuration } from "@/utils/time";
|
|
||||||
|
|
||||||
const formatFileSize = (bytes?: number | null) => {
|
|
||||||
if (!bytes) return null;
|
|
||||||
const sizes = ["B", "KB", "MB", "GB"];
|
|
||||||
if (bytes === 0) return "0 B";
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
||||||
return `${Math.round((bytes / 1024 ** i) * 100) / 100} ${sizes[i]}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatSampleRate = (sampleRate?: number | null) => {
|
|
||||||
if (!sampleRate) return null;
|
|
||||||
return `${(sampleRate / 1000).toFixed(1)} kHz`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const { width: SCREEN_WIDTH } = Dimensions.get("window");
|
|
||||||
const ARTWORK_SIZE = SCREEN_WIDTH - 80;
|
|
||||||
|
|
||||||
type ViewMode = "player" | "queue";
|
|
||||||
|
|
||||||
export default function NowPlayingScreen() {
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const router = useRouter();
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>("player");
|
|
||||||
|
|
||||||
const {
|
|
||||||
currentTrack,
|
|
||||||
queue,
|
|
||||||
queueIndex,
|
|
||||||
isPlaying,
|
|
||||||
isLoading,
|
|
||||||
progress,
|
|
||||||
duration,
|
|
||||||
repeatMode,
|
|
||||||
shuffleEnabled,
|
|
||||||
mediaSource,
|
|
||||||
isTranscoding,
|
|
||||||
togglePlayPause,
|
|
||||||
next,
|
|
||||||
previous,
|
|
||||||
seek,
|
|
||||||
setRepeatMode,
|
|
||||||
toggleShuffle,
|
|
||||||
jumpToIndex,
|
|
||||||
removeFromQueue,
|
|
||||||
reorderQueue,
|
|
||||||
stop,
|
|
||||||
} = useMusicPlayer();
|
|
||||||
|
|
||||||
const sliderProgress = useSharedValue(0);
|
|
||||||
const sliderMin = useSharedValue(0);
|
|
||||||
const sliderMax = useSharedValue(1);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
sliderProgress.value = progress;
|
|
||||||
}, [progress, sliderProgress]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
sliderMax.value = duration > 0 ? duration : 1;
|
|
||||||
}, [duration, sliderMax]);
|
|
||||||
|
|
||||||
const imageUrl = useMemo(() => {
|
|
||||||
if (!api || !currentTrack) return null;
|
|
||||||
const albumId = currentTrack.AlbumId || currentTrack.ParentId;
|
|
||||||
if (albumId) {
|
|
||||||
return `${api.basePath}/Items/${albumId}/Images/Primary?maxHeight=600&maxWidth=600`;
|
|
||||||
}
|
|
||||||
return `${api.basePath}/Items/${currentTrack.Id}/Images/Primary?maxHeight=600&maxWidth=600`;
|
|
||||||
}, [api, currentTrack]);
|
|
||||||
|
|
||||||
const progressText = useMemo(() => {
|
|
||||||
const progressTicks = progress * 10000000;
|
|
||||||
return formatDuration(progressTicks);
|
|
||||||
}, [progress]);
|
|
||||||
|
|
||||||
const durationText = useMemo(() => {
|
|
||||||
const durationTicks = duration * 10000000;
|
|
||||||
return formatDuration(durationTicks);
|
|
||||||
}, [duration]);
|
|
||||||
|
|
||||||
const handleSliderComplete = useCallback(
|
|
||||||
(value: number) => {
|
|
||||||
seek(value);
|
|
||||||
},
|
|
||||||
[seek],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
|
||||||
router.back();
|
|
||||||
}, [router]);
|
|
||||||
|
|
||||||
const _handleStop = useCallback(() => {
|
|
||||||
stop();
|
|
||||||
router.back();
|
|
||||||
}, [stop, router]);
|
|
||||||
|
|
||||||
const cycleRepeatMode = useCallback(() => {
|
|
||||||
const modes: RepeatMode[] = ["off", "all", "one"];
|
|
||||||
const currentIndex = modes.indexOf(repeatMode);
|
|
||||||
const nextMode = modes[(currentIndex + 1) % modes.length];
|
|
||||||
setRepeatMode(nextMode);
|
|
||||||
}, [repeatMode, setRepeatMode]);
|
|
||||||
|
|
||||||
const getRepeatIcon = (): string => {
|
|
||||||
switch (repeatMode) {
|
|
||||||
case "one":
|
|
||||||
return "repeat";
|
|
||||||
case "all":
|
|
||||||
return "repeat";
|
|
||||||
default:
|
|
||||||
return "repeat";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const canGoNext = queueIndex < queue.length - 1 || repeatMode === "all";
|
|
||||||
const canGoPrevious = queueIndex > 0 || progress > 3 || repeatMode === "all";
|
|
||||||
|
|
||||||
if (!currentTrack) {
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
className='flex-1 bg-[#121212] items-center justify-center'
|
|
||||||
style={{
|
|
||||||
paddingTop: Platform.OS === "android" ? insets.top : 0,
|
|
||||||
paddingBottom: Platform.OS === "android" ? insets.bottom : 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text className='text-neutral-500'>No track playing</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
className='flex-1 bg-[#121212]'
|
|
||||||
style={{
|
|
||||||
paddingTop: Platform.OS === "android" ? insets.top : 0,
|
|
||||||
paddingBottom: Platform.OS === "android" ? insets.bottom : 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<View className='flex-row items-center justify-between px-4 pt-3 pb-2'>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleClose}
|
|
||||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
|
||||||
className='p-2'
|
|
||||||
>
|
|
||||||
<Ionicons name='chevron-down' size={28} color='white' />
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<View className='flex-row'>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => setViewMode("player")}
|
|
||||||
className='px-3 py-1'
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
className={
|
|
||||||
viewMode === "player"
|
|
||||||
? "text-white font-semibold"
|
|
||||||
: "text-neutral-500"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Now Playing
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => setViewMode("queue")}
|
|
||||||
className='px-3 py-1'
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
className={
|
|
||||||
viewMode === "queue"
|
|
||||||
? "text-white font-semibold"
|
|
||||||
: "text-neutral-500"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Queue ({queue.length})
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
<View style={{ width: 16 }} />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{viewMode === "player" ? (
|
|
||||||
<PlayerView
|
|
||||||
api={api}
|
|
||||||
currentTrack={currentTrack}
|
|
||||||
imageUrl={imageUrl}
|
|
||||||
sliderProgress={sliderProgress}
|
|
||||||
sliderMin={sliderMin}
|
|
||||||
sliderMax={sliderMax}
|
|
||||||
progressText={progressText}
|
|
||||||
durationText={durationText}
|
|
||||||
isPlaying={isPlaying}
|
|
||||||
isLoading={isLoading}
|
|
||||||
repeatMode={repeatMode}
|
|
||||||
shuffleEnabled={shuffleEnabled}
|
|
||||||
canGoNext={canGoNext}
|
|
||||||
canGoPrevious={canGoPrevious}
|
|
||||||
onSliderComplete={handleSliderComplete}
|
|
||||||
onTogglePlayPause={togglePlayPause}
|
|
||||||
onNext={next}
|
|
||||||
onPrevious={previous}
|
|
||||||
onCycleRepeat={cycleRepeatMode}
|
|
||||||
onToggleShuffle={toggleShuffle}
|
|
||||||
getRepeatIcon={getRepeatIcon}
|
|
||||||
queue={queue}
|
|
||||||
queueIndex={queueIndex}
|
|
||||||
mediaSource={mediaSource}
|
|
||||||
isTranscoding={isTranscoding}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<QueueView
|
|
||||||
api={api}
|
|
||||||
queue={queue}
|
|
||||||
queueIndex={queueIndex}
|
|
||||||
onJumpToIndex={jumpToIndex}
|
|
||||||
onRemoveFromQueue={removeFromQueue}
|
|
||||||
onReorderQueue={reorderQueue}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PlayerViewProps {
|
|
||||||
api: any;
|
|
||||||
currentTrack: BaseItemDto;
|
|
||||||
imageUrl: string | null;
|
|
||||||
sliderProgress: any;
|
|
||||||
sliderMin: any;
|
|
||||||
sliderMax: any;
|
|
||||||
progressText: string;
|
|
||||||
durationText: string;
|
|
||||||
isPlaying: boolean;
|
|
||||||
isLoading: boolean;
|
|
||||||
repeatMode: RepeatMode;
|
|
||||||
shuffleEnabled: boolean;
|
|
||||||
canGoNext: boolean;
|
|
||||||
canGoPrevious: boolean;
|
|
||||||
onSliderComplete: (value: number) => void;
|
|
||||||
onTogglePlayPause: () => void;
|
|
||||||
onNext: () => void;
|
|
||||||
onPrevious: () => void;
|
|
||||||
onCycleRepeat: () => void;
|
|
||||||
onToggleShuffle: () => void;
|
|
||||||
getRepeatIcon: () => string;
|
|
||||||
queue: BaseItemDto[];
|
|
||||||
queueIndex: number;
|
|
||||||
mediaSource: MediaSourceInfo | null;
|
|
||||||
isTranscoding: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PlayerView: React.FC<PlayerViewProps> = ({
|
|
||||||
currentTrack,
|
|
||||||
imageUrl,
|
|
||||||
sliderProgress,
|
|
||||||
sliderMin,
|
|
||||||
sliderMax,
|
|
||||||
progressText,
|
|
||||||
durationText,
|
|
||||||
isPlaying,
|
|
||||||
isLoading,
|
|
||||||
repeatMode,
|
|
||||||
shuffleEnabled,
|
|
||||||
canGoNext,
|
|
||||||
canGoPrevious,
|
|
||||||
onSliderComplete,
|
|
||||||
onTogglePlayPause,
|
|
||||||
onNext,
|
|
||||||
onPrevious,
|
|
||||||
onCycleRepeat,
|
|
||||||
onToggleShuffle,
|
|
||||||
getRepeatIcon,
|
|
||||||
queue,
|
|
||||||
queueIndex,
|
|
||||||
mediaSource,
|
|
||||||
isTranscoding,
|
|
||||||
}) => {
|
|
||||||
const audioStream = useMemo(() => {
|
|
||||||
return mediaSource?.MediaStreams?.find((stream) => stream.Type === "Audio");
|
|
||||||
}, [mediaSource]);
|
|
||||||
|
|
||||||
const fileSize = formatFileSize(mediaSource?.Size);
|
|
||||||
const codec = audioStream?.Codec?.toUpperCase();
|
|
||||||
const bitrate = formatBitrate(audioStream?.BitRate);
|
|
||||||
const sampleRate = formatSampleRate(audioStream?.SampleRate);
|
|
||||||
const playbackMethod = isTranscoding ? "Transcoding" : "Direct";
|
|
||||||
|
|
||||||
const hasAudioStats =
|
|
||||||
mediaSource && (fileSize || codec || bitrate || sampleRate);
|
|
||||||
return (
|
|
||||||
<ScrollView className='flex-1 px-6' showsVerticalScrollIndicator={false}>
|
|
||||||
{/* Album artwork */}
|
|
||||||
<View
|
|
||||||
className='self-center mb-8 mt-4'
|
|
||||||
style={{
|
|
||||||
width: ARTWORK_SIZE,
|
|
||||||
height: ARTWORK_SIZE,
|
|
||||||
borderRadius: 12,
|
|
||||||
overflow: "hidden",
|
|
||||||
backgroundColor: "#1a1a1a",
|
|
||||||
shadowColor: "#000",
|
|
||||||
shadowOffset: { width: 0, height: 8 },
|
|
||||||
shadowOpacity: 0.4,
|
|
||||||
shadowRadius: 16,
|
|
||||||
elevation: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{imageUrl ? (
|
|
||||||
<Image
|
|
||||||
source={{ uri: imageUrl }}
|
|
||||||
style={{ width: "100%", height: "100%" }}
|
|
||||||
contentFit='cover'
|
|
||||||
cachePolicy='memory-disk'
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<View className='flex-1 items-center justify-center bg-neutral-800'>
|
|
||||||
<Ionicons name='musical-note' size={80} color='#666' />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Track info */}
|
|
||||||
<View className='mb-6'>
|
|
||||||
<Text numberOfLines={1} className='text-white text-2xl font-bold'>
|
|
||||||
{currentTrack.Name}
|
|
||||||
</Text>
|
|
||||||
<Text numberOfLines={1} className='text-purple-400 text-lg mt-1'>
|
|
||||||
{currentTrack.Artists?.join(", ") || currentTrack.AlbumArtist}
|
|
||||||
</Text>
|
|
||||||
{currentTrack.Album && (
|
|
||||||
<Text numberOfLines={1} className='text-neutral-500 text-sm mt-1'>
|
|
||||||
{currentTrack.Album}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Audio Stats */}
|
|
||||||
{hasAudioStats && (
|
|
||||||
<View className='flex-row flex-wrap gap-1.5 mt-3'>
|
|
||||||
{fileSize && <Badge variant='gray' text={fileSize} />}
|
|
||||||
{codec && <Badge variant='gray' text={codec} />}
|
|
||||||
<Badge
|
|
||||||
variant='gray'
|
|
||||||
text={playbackMethod}
|
|
||||||
iconLeft={
|
|
||||||
<Ionicons
|
|
||||||
name={isTranscoding ? "swap-horizontal" : "play"}
|
|
||||||
size={12}
|
|
||||||
color='white'
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{bitrate && bitrate !== "N/A" && (
|
|
||||||
<Badge variant='gray' text={bitrate} />
|
|
||||||
)}
|
|
||||||
{sampleRate && <Badge variant='gray' text={sampleRate} />}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Progress slider */}
|
|
||||||
<View className='mb-4'>
|
|
||||||
<Slider
|
|
||||||
theme={{
|
|
||||||
maximumTrackTintColor: "#333",
|
|
||||||
minimumTrackTintColor: "#9334E9",
|
|
||||||
bubbleBackgroundColor: "#9334E9",
|
|
||||||
bubbleTextColor: "#fff",
|
|
||||||
}}
|
|
||||||
progress={sliderProgress}
|
|
||||||
minimumValue={sliderMin}
|
|
||||||
maximumValue={sliderMax}
|
|
||||||
onSlidingComplete={onSliderComplete}
|
|
||||||
thumbWidth={16}
|
|
||||||
sliderHeight={6}
|
|
||||||
containerStyle={{ borderRadius: 10 }}
|
|
||||||
renderBubble={() => null}
|
|
||||||
/>
|
|
||||||
<View className='flex flex-row justify-between px-1 mt-2'>
|
|
||||||
<Text className='text-neutral-500 text-xs'>{progressText}</Text>
|
|
||||||
<Text className='text-neutral-500 text-xs'>{durationText}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Main Controls */}
|
|
||||||
<View className='flex flex-row items-center justify-center mb-2'>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={onPrevious}
|
|
||||||
disabled={!canGoPrevious || isLoading}
|
|
||||||
className='p-4'
|
|
||||||
style={{ opacity: canGoPrevious && !isLoading ? 1 : 0.3 }}
|
|
||||||
>
|
|
||||||
<Ionicons name='play-skip-back' size={32} color='white' />
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={onTogglePlayPause}
|
|
||||||
disabled={isLoading}
|
|
||||||
className='mx-8 bg-white rounded-full p-4'
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
|
||||||
<ActivityIndicator size={36} color='#121212' />
|
|
||||||
) : (
|
|
||||||
<Ionicons
|
|
||||||
name={isPlaying ? "pause" : "play"}
|
|
||||||
size={36}
|
|
||||||
color='#121212'
|
|
||||||
style={isPlaying ? {} : { marginLeft: 4 }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={onNext}
|
|
||||||
disabled={!canGoNext || isLoading}
|
|
||||||
className='p-4'
|
|
||||||
style={{ opacity: canGoNext && !isLoading ? 1 : 0.3 }}
|
|
||||||
>
|
|
||||||
<Ionicons name='play-skip-forward' size={32} color='white' />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Shuffle & Repeat Controls */}
|
|
||||||
<View className='flex flex-row items-center justify-center mb-2'>
|
|
||||||
<TouchableOpacity onPress={onToggleShuffle} className='p-3 mx-4'>
|
|
||||||
<Ionicons
|
|
||||||
name='shuffle'
|
|
||||||
size={24}
|
|
||||||
color={shuffleEnabled ? "#9334E9" : "#666"}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity onPress={onCycleRepeat} className='p-3 mx-4 relative'>
|
|
||||||
<Ionicons
|
|
||||||
name={getRepeatIcon() as any}
|
|
||||||
size={24}
|
|
||||||
color={repeatMode !== "off" ? "#9334E9" : "#666"}
|
|
||||||
/>
|
|
||||||
{repeatMode === "one" && (
|
|
||||||
<View className='absolute right-0 bg-purple-600 rounded-full w-4 h-4 items-center justify-center'>
|
|
||||||
<Text className='text-white text-[10px] font-bold'>1</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Queue info */}
|
|
||||||
{queue.length > 1 && (
|
|
||||||
<View className='items-center mb-4'>
|
|
||||||
<Text className='text-neutral-500 text-sm'>
|
|
||||||
{queueIndex + 1} of {queue.length}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface QueueViewProps {
|
|
||||||
api: any;
|
|
||||||
queue: BaseItemDto[];
|
|
||||||
queueIndex: number;
|
|
||||||
onJumpToIndex: (index: number) => void;
|
|
||||||
onRemoveFromQueue: (index: number) => void;
|
|
||||||
onReorderQueue: (newQueue: BaseItemDto[]) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const QueueView: React.FC<QueueViewProps> = ({
|
|
||||||
api,
|
|
||||||
queue,
|
|
||||||
queueIndex,
|
|
||||||
onJumpToIndex,
|
|
||||||
onRemoveFromQueue,
|
|
||||||
onReorderQueue,
|
|
||||||
}) => {
|
|
||||||
const renderQueueItem = useCallback(
|
|
||||||
({ item, drag, isActive, getIndex }: RenderItemParams<BaseItemDto>) => {
|
|
||||||
const index = getIndex() ?? 0;
|
|
||||||
const isCurrentTrack = index === queueIndex;
|
|
||||||
const isPast = index < queueIndex;
|
|
||||||
|
|
||||||
const albumId = item.AlbumId || item.ParentId;
|
|
||||||
const imageUrl = api
|
|
||||||
? albumId
|
|
||||||
? `${api.basePath}/Items/${albumId}/Images/Primary?maxHeight=80&maxWidth=80`
|
|
||||||
: `${api.basePath}/Items/${item.Id}/Images/Primary?maxHeight=80&maxWidth=80`
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScaleDecorator>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => onJumpToIndex(index)}
|
|
||||||
onLongPress={drag}
|
|
||||||
disabled={isActive}
|
|
||||||
className='flex-row items-center px-4 py-3'
|
|
||||||
style={{
|
|
||||||
opacity: isPast && !isActive ? 0.5 : 1,
|
|
||||||
backgroundColor: isActive
|
|
||||||
? "#2a2a2a"
|
|
||||||
: isCurrentTrack
|
|
||||||
? "rgba(147, 52, 233, 0.3)"
|
|
||||||
: "#121212",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Drag handle */}
|
|
||||||
<TouchableOpacity
|
|
||||||
onPressIn={drag}
|
|
||||||
disabled={isActive}
|
|
||||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
|
||||||
className='pr-2'
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name='reorder-three'
|
|
||||||
size={20}
|
|
||||||
color={isActive ? "#9334E9" : "#666"}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
{/* Album art */}
|
|
||||||
<View className='w-12 h-12 rounded overflow-hidden bg-neutral-800 mr-3'>
|
|
||||||
{imageUrl ? (
|
|
||||||
<Image
|
|
||||||
source={{ uri: imageUrl }}
|
|
||||||
style={{ width: "100%", height: "100%" }}
|
|
||||||
contentFit='cover'
|
|
||||||
cachePolicy='memory-disk'
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<View className='flex-1 items-center justify-center'>
|
|
||||||
<Ionicons name='musical-note' size={16} color='#666' />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Track info */}
|
|
||||||
<View className='flex-1 mr-2'>
|
|
||||||
<Text
|
|
||||||
numberOfLines={1}
|
|
||||||
className={`text-base ${isCurrentTrack ? "text-purple-400 font-semibold" : "text-white"}`}
|
|
||||||
>
|
|
||||||
{item.Name}
|
|
||||||
</Text>
|
|
||||||
<Text numberOfLines={1} className='text-neutral-500 text-sm'>
|
|
||||||
{item.Artists?.join(", ") || item.AlbumArtist}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Now playing indicator */}
|
|
||||||
{isCurrentTrack && (
|
|
||||||
<Ionicons name='musical-note' size={16} color='#9334E9' />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Remove button (not for current track) */}
|
|
||||||
{!isCurrentTrack && (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => onRemoveFromQueue(index)}
|
|
||||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
|
||||||
className='p-2'
|
|
||||||
>
|
|
||||||
<Ionicons name='close' size={20} color='#666' />
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
</ScaleDecorator>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[api, queueIndex, onJumpToIndex, onRemoveFromQueue],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDragEnd = useCallback(
|
|
||||||
({ data }: { data: BaseItemDto[] }) => {
|
|
||||||
onReorderQueue(data);
|
|
||||||
},
|
|
||||||
[onReorderQueue],
|
|
||||||
);
|
|
||||||
|
|
||||||
const history = queue.slice(0, queueIndex);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DraggableFlatList
|
|
||||||
data={queue}
|
|
||||||
keyExtractor={(item, index) => `${item.Id}-${index}`}
|
|
||||||
renderItem={renderQueueItem}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
ListHeaderComponent={
|
|
||||||
<View className='px-4 py-2'>
|
|
||||||
<Text className='text-neutral-400 text-xs uppercase tracking-wider'>
|
|
||||||
{history.length > 0 ? "Playing from queue" : "Up next"}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
ListEmptyComponent={
|
|
||||||
<View className='flex-1 items-center justify-center py-20'>
|
|
||||||
<Text className='text-neutral-500'>Queue is empty</Text>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,33 +1,7 @@
|
|||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
import { useEffect } from "react";
|
|
||||||
import { AppState } from "react-native";
|
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
|
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
const { settings } = useSettings();
|
|
||||||
const { lockOrientation, unlockOrientation } = useOrientation();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (settings?.defaultVideoOrientation) {
|
|
||||||
lockOrientation(settings.defaultVideoOrientation);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-apply orientation lock when app returns to foreground (iOS resets it)
|
|
||||||
const subscription = AppState.addEventListener("change", (nextAppState) => {
|
|
||||||
if (nextAppState === "active" && settings?.defaultVideoOrientation) {
|
|
||||||
lockOrientation(settings.defaultVideoOrientation);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
subscription.remove();
|
|
||||||
unlockOrientation();
|
|
||||||
};
|
|
||||||
}, [settings?.defaultVideoOrientation, lockOrientation, unlockOrientation]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SystemBars hidden />
|
<SystemBars hidden />
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
137
app/_layout.tsx
137
app/_layout.tsx
@@ -2,9 +2,7 @@ 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 { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
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";
|
||||||
@@ -17,7 +15,6 @@ 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";
|
||||||
@@ -190,21 +187,11 @@ export default function RootLayout() {
|
|||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
staleTime: 30000, // 30 seconds - data is fresh
|
staleTime: 30000,
|
||||||
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);
|
||||||
@@ -350,90 +337,68 @@ function Layout() {
|
|||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PersistQueryClientProvider
|
<QueryClientProvider client={queryClient}>
|
||||||
client={queryClient}
|
|
||||||
persistOptions={{
|
|
||||||
persister: mmkvPersister,
|
|
||||||
maxAge: 1000 * 60 * 60 * 24, // 24 hours max cache age
|
|
||||||
dehydrateOptions: {
|
|
||||||
shouldDehydrateQuery: (query) => {
|
|
||||||
// Only persist successful queries
|
|
||||||
return query.state.status === "success";
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<JellyfinProvider>
|
<JellyfinProvider>
|
||||||
<NetworkStatusProvider>
|
<NetworkStatusProvider>
|
||||||
<PlaySettingsProvider>
|
<PlaySettingsProvider>
|
||||||
<LogProvider>
|
<LogProvider>
|
||||||
<WebSocketProvider>
|
<WebSocketProvider>
|
||||||
<DownloadProvider>
|
<DownloadProvider>
|
||||||
<MusicPlayerProvider>
|
<GlobalModalProvider>
|
||||||
<GlobalModalProvider>
|
<BottomSheetModalProvider>
|
||||||
<BottomSheetModalProvider>
|
<ThemeProvider value={DarkTheme}>
|
||||||
<ThemeProvider value={DarkTheme}>
|
<SystemBars style='light' hidden={false} />
|
||||||
<SystemBars style='light' hidden={false} />
|
<Stack initialRouteName='(auth)/(tabs)'>
|
||||||
<Stack initialRouteName='(auth)/(tabs)'>
|
<Stack.Screen
|
||||||
<Stack.Screen
|
name='(auth)/(tabs)'
|
||||||
name='(auth)/(tabs)'
|
options={{
|
||||||
options={{
|
headerShown: false,
|
||||||
headerShown: false,
|
title: "",
|
||||||
title: "",
|
header: () => null,
|
||||||
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
|
|
||||||
/>
|
/>
|
||||||
<GlobalModal />
|
<Stack.Screen
|
||||||
</ThemeProvider>
|
name='(auth)/player'
|
||||||
</BottomSheetModalProvider>
|
options={{
|
||||||
</GlobalModalProvider>
|
headerShown: false,
|
||||||
</MusicPlayerProvider>
|
title: "",
|
||||||
|
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>
|
||||||
</PersistQueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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((prev) => ({ ...prev, username: text }))
|
setCredentials({ ...credentials, 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((prev) => ({ ...prev, username: newValue }));
|
setCredentials({ ...credentials, 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((prev) => ({ ...prev, password: text }))
|
setCredentials({ ...credentials, 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((prev) => ({ ...prev, password: newValue }));
|
setCredentials({ ...credentials, 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 justify-center'>
|
<View className='flex flex-col flex-1 items-center justify-center'>
|
||||||
<View className='px-4 w-full'>
|
<View className='px-4 -mt-20 w-full'>
|
||||||
<View className='flex flex-col space-y-2'>
|
<View className='flex flex-col space-y-2'>
|
||||||
<Text className='text-2xl font-bold -mb-2'>
|
<Text className='text-2xl font-bold -mb-2'>
|
||||||
{serverName ? (
|
{serverName ? (
|
||||||
@@ -415,15 +415,12 @@ const Login: React.FC = () => {
|
|||||||
<Input
|
<Input
|
||||||
placeholder={t("login.username_placeholder")}
|
placeholder={t("login.username_placeholder")}
|
||||||
onChangeText={(text) =>
|
onChangeText={(text) =>
|
||||||
setCredentials((prev) => ({ ...prev, username: text }))
|
setCredentials({ ...credentials, username: text })
|
||||||
}
|
}
|
||||||
onEndEditing={(e) => {
|
onEndEditing={(e) => {
|
||||||
const newValue = e.nativeEvent.text;
|
const newValue = e.nativeEvent.text;
|
||||||
if (newValue && newValue !== credentials.username) {
|
if (newValue && newValue !== credentials.username) {
|
||||||
setCredentials((prev) => ({
|
setCredentials({ ...credentials, username: newValue });
|
||||||
...prev,
|
|
||||||
username: newValue,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
value={credentials.username}
|
value={credentials.username}
|
||||||
@@ -440,15 +437,12 @@ const Login: React.FC = () => {
|
|||||||
<Input
|
<Input
|
||||||
placeholder={t("login.password_placeholder")}
|
placeholder={t("login.password_placeholder")}
|
||||||
onChangeText={(text) =>
|
onChangeText={(text) =>
|
||||||
setCredentials((prev) => ({ ...prev, password: text }))
|
setCredentials({ ...credentials, password: text })
|
||||||
}
|
}
|
||||||
onEndEditing={(e) => {
|
onEndEditing={(e) => {
|
||||||
const newValue = e.nativeEvent.text;
|
const newValue = e.nativeEvent.text;
|
||||||
if (newValue && newValue !== credentials.password) {
|
if (newValue && newValue !== credentials.password) {
|
||||||
setCredentials((prev) => ({
|
setCredentials({ ...credentials, password: newValue });
|
||||||
...prev,
|
|
||||||
password: newValue,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
value={credentials.password}
|
value={credentials.password}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 18 KiB |
@@ -8,8 +8,7 @@
|
|||||||
"!android",
|
"!android",
|
||||||
"!Streamyfin.app",
|
"!Streamyfin.app",
|
||||||
"!utils/jellyseerr",
|
"!utils/jellyseerr",
|
||||||
"!.expo",
|
"!.expo"
|
||||||
"!docs/jellyfin-openapi-stable.json"
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"linter": {
|
"linter": {
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ 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>
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import type { FC } from "react";
|
|
||||||
import { useCallback, useRef } from "react";
|
|
||||||
import { View, type ViewProps } from "react-native";
|
|
||||||
import { RoundButton } from "@/components/RoundButton";
|
|
||||||
import {
|
|
||||||
WatchlistSheet,
|
|
||||||
type WatchlistSheetRef,
|
|
||||||
} from "@/components/watchlists/WatchlistSheet";
|
|
||||||
import {
|
|
||||||
useItemInWatchlists,
|
|
||||||
useStreamystatsEnabled,
|
|
||||||
} from "@/hooks/useWatchlists";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
|
||||||
item: BaseItemDto;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AddToWatchlist: FC<Props> = ({ item, ...props }) => {
|
|
||||||
const streamystatsEnabled = useStreamystatsEnabled();
|
|
||||||
const sheetRef = useRef<WatchlistSheetRef>(null);
|
|
||||||
|
|
||||||
const { data: watchlistsContainingItem } = useItemInWatchlists(item.Id);
|
|
||||||
const isInAnyWatchlist = (watchlistsContainingItem?.length ?? 0) > 0;
|
|
||||||
|
|
||||||
const handlePress = useCallback(() => {
|
|
||||||
sheetRef.current?.open(item);
|
|
||||||
}, [item]);
|
|
||||||
|
|
||||||
// Don't render if Streamystats is not enabled
|
|
||||||
if (!streamystatsEnabled) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View {...props}>
|
|
||||||
<RoundButton
|
|
||||||
size='large'
|
|
||||||
icon={isInAnyWatchlist ? "list" : "list-outline"}
|
|
||||||
onPress={handlePress}
|
|
||||||
/>
|
|
||||||
<WatchlistSheet ref={sheetRef} />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -2,6 +2,7 @@ import type React from "react";
|
|||||||
import {
|
import {
|
||||||
type PropsWithChildren,
|
type PropsWithChildren,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
@@ -17,58 +18,6 @@ import {
|
|||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { Loader } from "./Loader";
|
import { Loader } from "./Loader";
|
||||||
|
|
||||||
const getColorClasses = (
|
|
||||||
color: "purple" | "red" | "black" | "transparent" | "white",
|
|
||||||
variant: "solid" | "border",
|
|
||||||
focused: boolean,
|
|
||||||
): string => {
|
|
||||||
if (variant === "border") {
|
|
||||||
switch (color) {
|
|
||||||
case "purple":
|
|
||||||
return focused
|
|
||||||
? "bg-transparent border-2 border-purple-400"
|
|
||||||
: "bg-transparent border-2 border-purple-600";
|
|
||||||
case "red":
|
|
||||||
return focused
|
|
||||||
? "bg-transparent border-2 border-red-400"
|
|
||||||
: "bg-transparent border-2 border-red-600";
|
|
||||||
case "black":
|
|
||||||
return focused
|
|
||||||
? "bg-transparent border-2 border-neutral-700"
|
|
||||||
: "bg-transparent border-2 border-neutral-900";
|
|
||||||
case "white":
|
|
||||||
return focused
|
|
||||||
? "bg-transparent border-2 border-gray-100"
|
|
||||||
: "bg-transparent border-2 border-white";
|
|
||||||
case "transparent":
|
|
||||||
return focused
|
|
||||||
? "bg-transparent border-2 border-gray-400"
|
|
||||||
: "bg-transparent border-2 border-gray-600";
|
|
||||||
default:
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
switch (color) {
|
|
||||||
case "purple":
|
|
||||||
return focused
|
|
||||||
? "bg-purple-500 border-2 border-white"
|
|
||||||
: "bg-purple-600 border border-purple-700";
|
|
||||||
case "red":
|
|
||||||
return "bg-red-600";
|
|
||||||
case "black":
|
|
||||||
return "bg-neutral-900";
|
|
||||||
case "white":
|
|
||||||
return focused
|
|
||||||
? "bg-gray-100 border-2 border-gray-300"
|
|
||||||
: "bg-white border border-gray-200";
|
|
||||||
case "transparent":
|
|
||||||
return "bg-transparent";
|
|
||||||
default:
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface ButtonProps
|
export interface ButtonProps
|
||||||
extends React.ComponentProps<typeof TouchableOpacity> {
|
extends React.ComponentProps<typeof TouchableOpacity> {
|
||||||
onPress?: () => void;
|
onPress?: () => void;
|
||||||
@@ -77,8 +26,7 @@ export interface ButtonProps
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
children?: string | ReactNode;
|
children?: string | ReactNode;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
color?: "purple" | "red" | "black" | "transparent" | "white";
|
color?: "purple" | "red" | "black" | "transparent";
|
||||||
variant?: "solid" | "border";
|
|
||||||
iconRight?: ReactNode;
|
iconRight?: ReactNode;
|
||||||
iconLeft?: ReactNode;
|
iconLeft?: ReactNode;
|
||||||
justify?: "center" | "between";
|
justify?: "center" | "between";
|
||||||
@@ -91,7 +39,6 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
loading = false,
|
loading = false,
|
||||||
color = "purple",
|
color = "purple",
|
||||||
variant = "solid",
|
|
||||||
iconRight,
|
iconRight,
|
||||||
iconLeft,
|
iconLeft,
|
||||||
children,
|
children,
|
||||||
@@ -109,13 +56,23 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
|||||||
useNativeDriver: true,
|
useNativeDriver: true,
|
||||||
}).start();
|
}).start();
|
||||||
|
|
||||||
const colorClasses = getColorClasses(color, variant, focused);
|
const colorClasses = useMemo(() => {
|
||||||
|
switch (color) {
|
||||||
|
case "purple":
|
||||||
|
return focused
|
||||||
|
? "bg-purple-500 border-2 border-white"
|
||||||
|
: "bg-purple-600 border border-purple-700";
|
||||||
|
case "red":
|
||||||
|
return "bg-red-600";
|
||||||
|
case "black":
|
||||||
|
return "bg-neutral-900";
|
||||||
|
case "transparent":
|
||||||
|
return "bg-transparent";
|
||||||
|
}
|
||||||
|
}, [color, focused]);
|
||||||
|
|
||||||
const lightHapticFeedback = useHaptic("light");
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
const textColorClass =
|
|
||||||
color === "white" && variant === "solid" ? "text-black" : "text-white";
|
|
||||||
|
|
||||||
return Platform.isTV ? (
|
return Platform.isTV ? (
|
||||||
<Pressable
|
<Pressable
|
||||||
className='w-full'
|
className='w-full'
|
||||||
@@ -141,12 +98,10 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
|||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
className={`rounded-2xl py-5 items-center justify-center
|
className={`rounded-2xl py-5 items-center justify-center
|
||||||
${colorClasses}
|
${focused ? "bg-purple-500 border-2 border-white" : "bg-purple-600 border border-purple-700"}
|
||||||
${className}`}
|
${className}`}
|
||||||
>
|
>
|
||||||
<Text className={`${textColorClass} text-xl font-bold`}>
|
<Text className='text-white text-xl font-bold'>{children}</Text>
|
||||||
{children}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -180,7 +135,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
|||||||
{iconLeft ? iconLeft : <View className='w-4' />}
|
{iconLeft ? iconLeft : <View className='w-4' />}
|
||||||
<Text
|
<Text
|
||||||
className={`
|
className={`
|
||||||
${textColorClass} font-bold text-base
|
text-white font-bold text-base
|
||||||
${disabled ? "text-gray-300" : ""}
|
${disabled ? "text-gray-300" : ""}
|
||||||
${textClassName}
|
${textClassName}
|
||||||
${iconRight ? "mr-2" : ""}
|
${iconRight ? "mr-2" : ""}
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedOptions(() => ({
|
setSelectedOptions(() => ({
|
||||||
bitrate: defaultBitrate,
|
bitrate: defaultBitrate,
|
||||||
mediaSource: defaultMediaSource ?? undefined,
|
mediaSource: defaultMediaSource,
|
||||||
subtitleIndex: defaultSubtitleIndex ?? -1,
|
subtitleIndex: defaultSubtitleIndex ?? -1,
|
||||||
audioIndex: defaultAudioIndex,
|
audioIndex: defaultAudioIndex,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
type BottomSheetBackdropProps,
|
type BottomSheetBackdropProps,
|
||||||
BottomSheetModal,
|
BottomSheetModal,
|
||||||
} from "@gorhom/bottom-sheet";
|
} from "@gorhom/bottom-sheet";
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback } from "react";
|
||||||
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,13 +16,7 @@ import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
|||||||
* after BottomSheetModalProvider.
|
* after BottomSheetModalProvider.
|
||||||
*/
|
*/
|
||||||
export const GlobalModal = () => {
|
export const GlobalModal = () => {
|
||||||
const { hideModal, modalState, modalRef, isVisible } = useGlobalModal();
|
const { hideModal, modalState, modalRef } = 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,10 +29,13 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
import { AddToFavorites } from "./AddToFavorites";
|
import { AddToFavorites } from "./AddToFavorites";
|
||||||
import { AddToWatchlist } from "./AddToWatchlist";
|
import { BitrateSheet } from "./BitRateSheet";
|
||||||
import { ItemHeader } from "./ItemHeader";
|
import { ItemHeader } from "./ItemHeader";
|
||||||
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
||||||
|
import { MediaSourceSheet } from "./MediaSourceSheet";
|
||||||
|
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
||||||
import { PlayInRemoteSessionButton } from "./PlayInRemoteSession";
|
import { PlayInRemoteSessionButton } from "./PlayInRemoteSession";
|
||||||
|
import { TrackSheet } from "./TrackSheet";
|
||||||
|
|
||||||
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
|
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
|
||||||
|
|
||||||
@@ -46,17 +49,17 @@ export type SelectedOptions = {
|
|||||||
interface ItemContentProps {
|
interface ItemContentProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
isOffline: boolean;
|
isOffline: boolean;
|
||||||
itemWithSources?: BaseItemDto | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||||
({ item, isOffline, itemWithSources }) => {
|
({ item, isOffline }) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const { orientation } = useOrientation();
|
const { orientation } = useOrientation();
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const itemColors = useImageColorsReturn({ item });
|
const itemColors = useImageColorsReturn({ item });
|
||||||
|
|
||||||
@@ -67,23 +70,18 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
SelectedOptions | undefined
|
SelectedOptions | undefined
|
||||||
>(undefined);
|
>(undefined);
|
||||||
|
|
||||||
// Use itemWithSources for play settings since it has MediaSources data
|
|
||||||
const {
|
const {
|
||||||
defaultAudioIndex,
|
defaultAudioIndex,
|
||||||
defaultBitrate,
|
defaultBitrate,
|
||||||
defaultMediaSource,
|
defaultMediaSource,
|
||||||
defaultSubtitleIndex,
|
defaultSubtitleIndex,
|
||||||
} = useDefaultPlaySettings(itemWithSources ?? item, settings);
|
} = useDefaultPlaySettings(item!, settings);
|
||||||
|
|
||||||
const logoUrl = useMemo(
|
const logoUrl = useMemo(
|
||||||
() => (item ? getLogoImageUrlById({ api, item }) : null),
|
() => (item ? getLogoImageUrlById({ api, item }) : null),
|
||||||
[api, item],
|
[api, item],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onLogoLoad = React.useCallback(() => {
|
|
||||||
setLoadingLogo(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loading = useMemo(() => {
|
const loading = useMemo(() => {
|
||||||
return Boolean(logoUrl && loadingLogo);
|
return Boolean(logoUrl && loadingLogo);
|
||||||
}, [loadingLogo, logoUrl]);
|
}, [loadingLogo, logoUrl]);
|
||||||
@@ -92,7 +90,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedOptions(() => ({
|
setSelectedOptions(() => ({
|
||||||
bitrate: defaultBitrate,
|
bitrate: defaultBitrate,
|
||||||
mediaSource: defaultMediaSource ?? undefined,
|
mediaSource: defaultMediaSource,
|
||||||
subtitleIndex: defaultSubtitleIndex ?? -1,
|
subtitleIndex: defaultSubtitleIndex ?? -1,
|
||||||
audioIndex: defaultAudioIndex,
|
audioIndex: defaultAudioIndex,
|
||||||
}));
|
}));
|
||||||
@@ -104,7 +102,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!Platform.isTV && itemWithSources) {
|
if (!Platform.isTV) {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () =>
|
headerRight: () =>
|
||||||
item &&
|
item &&
|
||||||
@@ -114,19 +112,14 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
{item.Type !== "Program" && (
|
{item.Type !== "Program" && (
|
||||||
<View className='flex flex-row items-center'>
|
<View className='flex flex-row items-center'>
|
||||||
{!Platform.isTV && (
|
{!Platform.isTV && (
|
||||||
<DownloadSingleItem item={itemWithSources} size='large' />
|
<DownloadSingleItem item={item} size='large' />
|
||||||
|
)}
|
||||||
|
{user?.Policy?.IsAdministrator && (
|
||||||
|
<PlayInRemoteSessionButton item={item} size='large' />
|
||||||
)}
|
)}
|
||||||
{user?.Policy?.IsAdministrator &&
|
|
||||||
!settings.hideRemoteSessionButton && (
|
|
||||||
<PlayInRemoteSessionButton item={item} size='large' />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<PlayedStatus items={[item]} size='large' />
|
<PlayedStatus items={[item]} size='large' />
|
||||||
<AddToFavorites item={item} />
|
<AddToFavorites item={item} />
|
||||||
{settings.streamyStatsServerUrl &&
|
|
||||||
!settings.hideWatchlistsTab && (
|
|
||||||
<AddToWatchlist item={item} />
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -136,34 +129,21 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
{item.Type !== "Program" && (
|
{item.Type !== "Program" && (
|
||||||
<View className='flex flex-row items-center space-x-2'>
|
<View className='flex flex-row items-center space-x-2'>
|
||||||
{!Platform.isTV && (
|
{!Platform.isTV && (
|
||||||
<DownloadSingleItem item={itemWithSources} size='large' />
|
<DownloadSingleItem item={item} size='large' />
|
||||||
|
)}
|
||||||
|
{user?.Policy?.IsAdministrator && (
|
||||||
|
<PlayInRemoteSessionButton item={item} size='large' />
|
||||||
)}
|
)}
|
||||||
{user?.Policy?.IsAdministrator &&
|
|
||||||
!settings.hideRemoteSessionButton && (
|
|
||||||
<PlayInRemoteSessionButton item={item} size='large' />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<PlayedStatus items={[item]} size='large' />
|
<PlayedStatus items={[item]} size='large' />
|
||||||
<AddToFavorites item={item} />
|
<AddToFavorites item={item} />
|
||||||
{settings.streamyStatsServerUrl &&
|
|
||||||
!settings.hideWatchlistsTab && (
|
|
||||||
<AddToWatchlist item={item} />
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
)),
|
)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [
|
}, [item, navigation, user]);
|
||||||
item,
|
|
||||||
navigation,
|
|
||||||
user,
|
|
||||||
itemWithSources,
|
|
||||||
settings.hideRemoteSessionButton,
|
|
||||||
settings.streamyStatsServerUrl,
|
|
||||||
settings.hideWatchlistsTab,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (item) {
|
if (item) {
|
||||||
@@ -185,7 +165,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ParallaxScrollView
|
<ParallaxScrollView
|
||||||
className='flex-1'
|
className={`flex-1 ${loading ? "opacity-0" : "opacity-100"}`}
|
||||||
headerHeight={headerHeight}
|
headerHeight={headerHeight}
|
||||||
headerImage={
|
headerImage={
|
||||||
<View style={[{ flex: 1 }]}>
|
<View style={[{ flex: 1 }]}>
|
||||||
@@ -212,8 +192,8 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
width: "100%",
|
width: "100%",
|
||||||
}}
|
}}
|
||||||
contentFit='contain'
|
contentFit='contain'
|
||||||
onLoad={onLogoLoad}
|
onLoad={() => setLoadingLogo(false)}
|
||||||
onError={onLogoLoad}
|
onError={() => setLoadingLogo(false)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<View />
|
<View />
|
||||||
@@ -221,27 +201,76 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<View className='flex flex-col bg-transparent shrink'>
|
<View className='flex flex-col bg-transparent shrink'>
|
||||||
<View className='flex flex-col px-4 w-full pt-2 mb-2 shrink'>
|
<View className='flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink'>
|
||||||
<ItemHeader item={item} className='mb-2' />
|
<ItemHeader item={item} className='mb-2' />
|
||||||
|
{item.Type !== "Program" && !Platform.isTV && !isOffline && (
|
||||||
<View className='flex flex-row px-0 mb-2 justify-between space-x-2'>
|
<View className='flex flex-row items-center justify-start w-full h-16 mb-2'>
|
||||||
<PlayButton
|
<BitrateSheet
|
||||||
selectedOptions={selectedOptions}
|
className='mr-1'
|
||||||
item={item}
|
onChange={(val) =>
|
||||||
isOffline={isOffline}
|
setSelectedOptions(
|
||||||
colors={itemColors}
|
(prev) => prev && { ...prev, bitrate: val },
|
||||||
/>
|
)
|
||||||
<View className='w-1' />
|
}
|
||||||
{!isOffline && (
|
selected={selectedOptions.bitrate}
|
||||||
<MediaSourceButton
|
|
||||||
selectedOptions={selectedOptions}
|
|
||||||
setSelectedOptions={setSelectedOptions}
|
|
||||||
item={itemWithSources}
|
|
||||||
colors={itemColors}
|
|
||||||
/>
|
/>
|
||||||
)}
|
<MediaSourceSheet
|
||||||
</View>
|
className='mr-1'
|
||||||
|
item={item}
|
||||||
|
onChange={(val) =>
|
||||||
|
setSelectedOptions(
|
||||||
|
(prev) =>
|
||||||
|
prev && {
|
||||||
|
...prev,
|
||||||
|
mediaSource: val,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
selected={selectedOptions.mediaSource}
|
||||||
|
/>
|
||||||
|
<TrackSheet
|
||||||
|
className='mr-1'
|
||||||
|
streamType='Audio'
|
||||||
|
title={t("item_card.audio")}
|
||||||
|
source={selectedOptions.mediaSource}
|
||||||
|
onChange={(val) => {
|
||||||
|
setSelectedOptions(
|
||||||
|
(prev) =>
|
||||||
|
prev && {
|
||||||
|
...prev,
|
||||||
|
audioIndex: val,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
selected={selectedOptions.audioIndex}
|
||||||
|
/>
|
||||||
|
<TrackSheet
|
||||||
|
source={selectedOptions.mediaSource}
|
||||||
|
streamType='Subtitle'
|
||||||
|
title={t("item_card.subtitles")}
|
||||||
|
onChange={(val) =>
|
||||||
|
setSelectedOptions(
|
||||||
|
(prev) =>
|
||||||
|
prev && {
|
||||||
|
...prev,
|
||||||
|
subtitleIndex: val,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
selected={selectedOptions.subtitleIndex}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PlayButton
|
||||||
|
className='grow'
|
||||||
|
selectedOptions={selectedOptions}
|
||||||
|
item={item}
|
||||||
|
isOffline={isOffline}
|
||||||
|
colors={itemColors}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{item.Type === "Episode" && (
|
{item.Type === "Episode" && (
|
||||||
<SeasonEpisodesCarousel
|
<SeasonEpisodesCarousel
|
||||||
item={item}
|
item={item}
|
||||||
@@ -250,21 +279,33 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isOffline &&
|
{!isOffline && (
|
||||||
selectedOptions.mediaSource?.MediaStreams &&
|
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
|
||||||
selectedOptions.mediaSource.MediaStreams.length > 0 && (
|
)}
|
||||||
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<OverviewText text={item.Overview} className='px-4 mb-4' />
|
<OverviewText text={item.Overview} className='px-4 mb-4' />
|
||||||
|
|
||||||
{item.Type !== "Program" && (
|
{item.Type !== "Program" && (
|
||||||
<>
|
<>
|
||||||
{item.Type === "Episode" && !isOffline && (
|
{item.Type === "Episode" && !isOffline && (
|
||||||
<CurrentSeries item={item} className='mb-2' />
|
<CurrentSeries item={item} className='mb-4' />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ItemPeopleSections item={item} isOffline={isOffline} />
|
{!isOffline && (
|
||||||
|
<CastAndCrew item={item} className='mb-4' loading={loading} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{item.People && item.People.length > 0 && !isOffline && (
|
||||||
|
<View className='mb-4'>
|
||||||
|
{item.People.slice(0, 3).map((person, idx) => (
|
||||||
|
<MoreMoviesWithActor
|
||||||
|
currentItem={item}
|
||||||
|
key={idx}
|
||||||
|
actorId={person.Id!}
|
||||||
|
className='mb-4'
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{!isOffline && <SimilarItems itemId={item.Id} />}
|
{!isOffline && <SimilarItems itemId={item.Id} />}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -183,12 +183,6 @@ const VideoStreamInfo = ({ source }: { source?: MediaSourceInfo }) => {
|
|||||||
|
|
||||||
if (!source || !videoStream) return null;
|
if (!source || !videoStream) return null;
|
||||||
|
|
||||||
// Dolby Vision video check
|
|
||||||
const isDolbyVision =
|
|
||||||
videoStream.VideoRangeType === "DOVI" ||
|
|
||||||
videoStream.DvVersionMajor != null ||
|
|
||||||
videoStream.DvVersionMinor != null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='flex-row flex-wrap gap-2'>
|
<View className='flex-row flex-wrap gap-2'>
|
||||||
<Badge
|
<Badge
|
||||||
@@ -201,15 +195,6 @@ const VideoStreamInfo = ({ source }: { source?: MediaSourceInfo }) => {
|
|||||||
iconLeft={<Ionicons name='film-outline' size={16} color='white' />}
|
iconLeft={<Ionicons name='film-outline' size={16} color='white' />}
|
||||||
text={`${videoStream.Width}x${videoStream.Height}`}
|
text={`${videoStream.Width}x${videoStream.Height}`}
|
||||||
/>
|
/>
|
||||||
{isDolbyVision && (
|
|
||||||
<Badge
|
|
||||||
variant='gray'
|
|
||||||
iconLeft={
|
|
||||||
<Ionicons name='sparkles-outline' size={16} color='white' />
|
|
||||||
}
|
|
||||||
text={"DV"}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Badge
|
<Badge
|
||||||
variant='gray'
|
variant='gray'
|
||||||
iconLeft={
|
iconLeft={
|
||||||
|
|||||||
@@ -1,193 +0,0 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import type {
|
|
||||||
BaseItemDto,
|
|
||||||
MediaSourceInfo,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
|
||||||
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
|
||||||
import { BITRATES } from "./BitRateSheet";
|
|
||||||
import type { SelectedOptions } from "./ItemContent";
|
|
||||||
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof TouchableOpacity> {
|
|
||||||
item?: BaseItemDto | null;
|
|
||||||
selectedOptions: SelectedOptions;
|
|
||||||
setSelectedOptions: React.Dispatch<
|
|
||||||
React.SetStateAction<SelectedOptions | undefined>
|
|
||||||
>;
|
|
||||||
colors?: ThemeColors;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MediaSourceButton: React.FC<Props> = ({
|
|
||||||
item,
|
|
||||||
selectedOptions,
|
|
||||||
setSelectedOptions,
|
|
||||||
colors,
|
|
||||||
}: Props) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const effectiveColors = colors || {
|
|
||||||
primary: "#7c3aed",
|
|
||||||
text: "#000000",
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const firstMediaSource = item?.MediaSources?.[0];
|
|
||||||
if (!firstMediaSource) return;
|
|
||||||
setSelectedOptions((prev) => {
|
|
||||||
if (!prev) return prev;
|
|
||||||
return {
|
|
||||||
...prev,
|
|
||||||
mediaSource: firstMediaSource,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}, [item, setSelectedOptions]);
|
|
||||||
|
|
||||||
const getMediaSourceDisplayName = useCallback((source: MediaSourceInfo) => {
|
|
||||||
const videoStream = source.MediaStreams?.find((x) => x.Type === "Video");
|
|
||||||
if (source.Name) return source.Name;
|
|
||||||
if (videoStream?.DisplayTitle) return videoStream.DisplayTitle;
|
|
||||||
return `Source ${source.Id}`;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const audioStreams = useMemo(
|
|
||||||
() =>
|
|
||||||
selectedOptions.mediaSource?.MediaStreams?.filter(
|
|
||||||
(x) => x.Type === "Audio",
|
|
||||||
) || [],
|
|
||||||
[selectedOptions.mediaSource],
|
|
||||||
);
|
|
||||||
|
|
||||||
const subtitleStreams = useMemo(
|
|
||||||
() =>
|
|
||||||
selectedOptions.mediaSource?.MediaStreams?.filter(
|
|
||||||
(x) => x.Type === "Subtitle",
|
|
||||||
) || [],
|
|
||||||
[selectedOptions.mediaSource],
|
|
||||||
);
|
|
||||||
|
|
||||||
const optionGroups: OptionGroup[] = useMemo(() => {
|
|
||||||
const groups: OptionGroup[] = [];
|
|
||||||
|
|
||||||
// Bitrate group
|
|
||||||
groups.push({
|
|
||||||
title: t("item_card.quality"),
|
|
||||||
options: BITRATES.map((bitrate) => ({
|
|
||||||
type: "radio" as const,
|
|
||||||
label: bitrate.key,
|
|
||||||
value: bitrate,
|
|
||||||
selected: bitrate.value === selectedOptions.bitrate?.value,
|
|
||||||
onPress: () =>
|
|
||||||
setSelectedOptions((prev) => prev && { ...prev, bitrate }),
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Media Source group (only if multiple sources)
|
|
||||||
if (item?.MediaSources && item.MediaSources.length > 1) {
|
|
||||||
groups.push({
|
|
||||||
title: t("item_card.video"),
|
|
||||||
options: item.MediaSources.map((source) => ({
|
|
||||||
type: "radio" as const,
|
|
||||||
label: getMediaSourceDisplayName(source),
|
|
||||||
value: source,
|
|
||||||
selected: source.Id === selectedOptions.mediaSource?.Id,
|
|
||||||
onPress: () =>
|
|
||||||
setSelectedOptions(
|
|
||||||
(prev) => prev && { ...prev, mediaSource: source },
|
|
||||||
),
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Audio track group
|
|
||||||
if (audioStreams.length > 0) {
|
|
||||||
groups.push({
|
|
||||||
title: t("item_card.audio"),
|
|
||||||
options: audioStreams.map((stream) => ({
|
|
||||||
type: "radio" as const,
|
|
||||||
label: stream.DisplayTitle || `${t("common.track")} ${stream.Index}`,
|
|
||||||
value: stream.Index,
|
|
||||||
selected: stream.Index === selectedOptions.audioIndex,
|
|
||||||
onPress: () =>
|
|
||||||
setSelectedOptions(
|
|
||||||
(prev) => prev && { ...prev, audioIndex: stream.Index ?? 0 },
|
|
||||||
),
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subtitle track group (with None option)
|
|
||||||
if (subtitleStreams.length > 0) {
|
|
||||||
const noneOption = {
|
|
||||||
type: "radio" as const,
|
|
||||||
label: t("common.none"),
|
|
||||||
value: -1,
|
|
||||||
selected: selectedOptions.subtitleIndex === -1,
|
|
||||||
onPress: () =>
|
|
||||||
setSelectedOptions((prev) => prev && { ...prev, subtitleIndex: -1 }),
|
|
||||||
};
|
|
||||||
|
|
||||||
const subtitleOptions = subtitleStreams.map((stream) => ({
|
|
||||||
type: "radio" as const,
|
|
||||||
label: stream.DisplayTitle || `${t("common.track")} ${stream.Index}`,
|
|
||||||
value: stream.Index,
|
|
||||||
selected: stream.Index === selectedOptions.subtitleIndex,
|
|
||||||
onPress: () =>
|
|
||||||
setSelectedOptions(
|
|
||||||
(prev) => prev && { ...prev, subtitleIndex: stream.Index ?? -1 },
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
groups.push({
|
|
||||||
title: t("item_card.subtitles"),
|
|
||||||
options: [noneOption, ...subtitleOptions],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return groups;
|
|
||||||
}, [
|
|
||||||
item,
|
|
||||||
selectedOptions,
|
|
||||||
audioStreams,
|
|
||||||
subtitleStreams,
|
|
||||||
getMediaSourceDisplayName,
|
|
||||||
t,
|
|
||||||
setSelectedOptions,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const trigger = (
|
|
||||||
<TouchableOpacity
|
|
||||||
disabled={!item}
|
|
||||||
onPress={() => setOpen(true)}
|
|
||||||
className='relative'
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{ backgroundColor: effectiveColors.primary, opacity: 0.7 }}
|
|
||||||
className='absolute w-12 h-12 rounded-full'
|
|
||||||
/>
|
|
||||||
<View className='w-12 h-12 rounded-full z-10 items-center justify-center'>
|
|
||||||
{!item ? (
|
|
||||||
<ActivityIndicator size='small' color={effectiveColors.text} />
|
|
||||||
) : (
|
|
||||||
<Ionicons name='list' size={24} color={effectiveColors.text} />
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PlatformDropdown
|
|
||||||
groups={optionGroups}
|
|
||||||
trigger={trigger}
|
|
||||||
title={t("item_card.media_options")}
|
|
||||||
open={open}
|
|
||||||
onOpenChange={setOpen}
|
|
||||||
bottomSheetConfig={{
|
|
||||||
enablePanDownToClose: true,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -3,7 +3,6 @@ import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useCallback } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { View, type ViewProps } from "react-native";
|
import { View, type ViewProps } from "react-native";
|
||||||
import { HorizontalScroll } from "@/components/common/HorizontalScroll";
|
import { HorizontalScroll } from "@/components/common/HorizontalScroll";
|
||||||
@@ -11,18 +10,16 @@ import { Text } from "@/components/common/Text";
|
|||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
import { POSTER_CAROUSEL_HEIGHT } from "@/constants/Values";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
actorId: string;
|
actorId: string;
|
||||||
actorName?: string | null;
|
|
||||||
currentItem: BaseItemDto;
|
currentItem: BaseItemDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MoreMoviesWithActor: React.FC<Props> = ({
|
export const MoreMoviesWithActor: React.FC<Props> = ({
|
||||||
actorId,
|
actorId,
|
||||||
actorName,
|
|
||||||
currentItem,
|
currentItem,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
@@ -30,6 +27,19 @@ export const MoreMoviesWithActor: React.FC<Props> = ({
|
|||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const { data: actor } = useQuery({
|
||||||
|
queryKey: ["actor", actorId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api || !user?.Id) return null;
|
||||||
|
return await getUserItemData({
|
||||||
|
api,
|
||||||
|
userId: user.Id,
|
||||||
|
itemId: actorId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id && !!actorId,
|
||||||
|
});
|
||||||
|
|
||||||
const { data: items, isLoading } = useQuery({
|
const { data: items, isLoading } = useQuery({
|
||||||
queryKey: ["actor", "movies", actorId, currentItem.Id],
|
queryKey: ["actor", "movies", actorId, currentItem.Id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -62,34 +72,29 @@ export const MoreMoviesWithActor: React.FC<Props> = ({
|
|||||||
enabled: !!api && !!user?.Id && !!actorId,
|
enabled: !!api && !!user?.Id && !!actorId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const renderItem = useCallback(
|
|
||||||
(item: BaseItemDto, idx: number) => (
|
|
||||||
<TouchableItemRouter
|
|
||||||
key={item.Id ?? idx}
|
|
||||||
item={item}
|
|
||||||
className='flex flex-col w-28'
|
|
||||||
>
|
|
||||||
<View>
|
|
||||||
<MoviePoster item={item} />
|
|
||||||
<ItemCardText item={item} />
|
|
||||||
</View>
|
|
||||||
</TouchableItemRouter>
|
|
||||||
),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (items?.length === 0) return null;
|
if (items?.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<Text className='text-lg font-bold mb-2 px-4'>
|
<Text className='text-lg font-bold mb-2 px-4'>
|
||||||
{t("item_card.more_with", { name: actorName ?? "" })}
|
{t("item_card.more_with", { name: actor?.Name })}
|
||||||
</Text>
|
</Text>
|
||||||
<HorizontalScroll
|
<HorizontalScroll
|
||||||
data={items}
|
data={items}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
height={POSTER_CAROUSEL_HEIGHT}
|
height={247}
|
||||||
renderItem={renderItem}
|
renderItem={(item: BaseItemDto, idx: number) => (
|
||||||
|
<TouchableItemRouter
|
||||||
|
key={idx}
|
||||||
|
item={item}
|
||||||
|
className='flex flex-col w-28'
|
||||||
|
>
|
||||||
|
<View>
|
||||||
|
<MoviePoster item={item} />
|
||||||
|
<ItemCardText item={item} />
|
||||||
|
</View>
|
||||||
|
</TouchableItemRouter>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -54,7 +54,9 @@ 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 ${value ? "translate-x-6" : "translate-x-1"}`}
|
className={`w-5 h-5 rounded-full bg-white shadow-md transform transition-transform ${
|
||||||
|
value ? "translate-x-6" : "translate-x-1"
|
||||||
|
}`}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -71,7 +73,9 @@ 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 ${option.disabled ? "opacity-50" : ""}`}
|
className={`px-4 py-3 flex flex-row items-center justify-between ${
|
||||||
|
option.disabled ? "opacity-50" : ""
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<Text className='flex-1 text-white'>{option.label}</Text>
|
<Text className='flex-1 text-white'>{option.label}</Text>
|
||||||
{isToggle ? (
|
{isToggle ? (
|
||||||
@@ -180,7 +184,7 @@ const PlatformDropdownComponent = ({
|
|||||||
expoUIConfig,
|
expoUIConfig,
|
||||||
bottomSheetConfig,
|
bottomSheetConfig,
|
||||||
}: PlatformDropdownProps) => {
|
}: PlatformDropdownProps) => {
|
||||||
const { showModal, hideModal, isVisible } = useGlobalModal();
|
const { showModal, hideModal } = useGlobalModal();
|
||||||
|
|
||||||
// Handle controlled open state for Android
|
// Handle controlled open state for Android
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -203,19 +207,15 @@ 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>{trigger}</ContextMenu.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,12 +1,11 @@
|
|||||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
import { BottomSheetView } from "@gorhom/bottom-sheet";
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Alert, Platform, TouchableOpacity, View } from "react-native";
|
import { Alert, TouchableOpacity, View } from "react-native";
|
||||||
import CastContext, {
|
import CastContext, {
|
||||||
CastButton,
|
CastButton,
|
||||||
PlayServicesState,
|
PlayServicesState,
|
||||||
@@ -25,8 +24,6 @@ import Animated, {
|
|||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
||||||
import { getDownloadedItemById } from "@/providers/Downloads/database";
|
|
||||||
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
@@ -36,8 +33,6 @@ 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> {
|
||||||
@@ -60,7 +55,6 @@ 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);
|
||||||
@@ -90,9 +84,12 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
[router, isOffline],
|
[router, isOffline],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleNormalPlayFlow = useCallback(async () => {
|
const onPress = useCallback(async () => {
|
||||||
|
console.log("onPress");
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
|
|
||||||
|
lightHapticFeedback();
|
||||||
|
|
||||||
const queryParams = new URLSearchParams({
|
const queryParams = new URLSearchParams({
|
||||||
itemId: item.Id!,
|
itemId: item.Id!,
|
||||||
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
|
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
|
||||||
@@ -274,117 +271,6 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
showActionSheetWithOptions,
|
showActionSheetWithOptions,
|
||||||
mediaStatus,
|
mediaStatus,
|
||||||
selectedOptions,
|
selectedOptions,
|
||||||
goToPlayer,
|
|
||||||
isOffline,
|
|
||||||
t,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const onPress = useCallback(async () => {
|
|
||||||
if (!item) return;
|
|
||||||
|
|
||||||
lightHapticFeedback();
|
|
||||||
|
|
||||||
// Check if item is downloaded
|
|
||||||
const downloadedItem = item.Id ? getDownloadedItemById(item.Id) : undefined;
|
|
||||||
|
|
||||||
if (downloadedItem) {
|
|
||||||
if (Platform.OS === "android") {
|
|
||||||
// Show bottom sheet for Android
|
|
||||||
showModal(
|
|
||||||
<BottomSheetView>
|
|
||||||
<View className='px-4 mt-4 mb-12'>
|
|
||||||
<View className='pb-6'>
|
|
||||||
<Text className='text-2xl font-bold mb-2'>
|
|
||||||
{t("player.downloaded_file_title")}
|
|
||||||
</Text>
|
|
||||||
<Text className='opacity-70 text-base'>
|
|
||||||
{t("player.downloaded_file_message")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View className='space-y-3'>
|
|
||||||
<Button
|
|
||||||
onPress={() => {
|
|
||||||
hideModal();
|
|
||||||
const queryParams = new URLSearchParams({
|
|
||||||
itemId: item.Id!,
|
|
||||||
offline: "true",
|
|
||||||
playbackPosition:
|
|
||||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
|
||||||
});
|
|
||||||
goToPlayer(queryParams.toString());
|
|
||||||
}}
|
|
||||||
color='purple'
|
|
||||||
>
|
|
||||||
{Platform.OS === "android"
|
|
||||||
? "Play downloaded file"
|
|
||||||
: t("player.downloaded_file_yes")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onPress={() => {
|
|
||||||
hideModal();
|
|
||||||
handleNormalPlayFlow();
|
|
||||||
}}
|
|
||||||
color='white'
|
|
||||||
variant='border'
|
|
||||||
>
|
|
||||||
{Platform.OS === "android"
|
|
||||||
? "Stream file"
|
|
||||||
: t("player.downloaded_file_no")}
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</BottomSheetView>,
|
|
||||||
{
|
|
||||||
snapPoints: ["35%"],
|
|
||||||
enablePanDownToClose: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Show alert for iOS
|
|
||||||
Alert.alert(
|
|
||||||
t("player.downloaded_file_title"),
|
|
||||||
t("player.downloaded_file_message"),
|
|
||||||
[
|
|
||||||
{
|
|
||||||
text: t("player.downloaded_file_yes"),
|
|
||||||
onPress: () => {
|
|
||||||
const queryParams = new URLSearchParams({
|
|
||||||
itemId: item.Id!,
|
|
||||||
offline: "true",
|
|
||||||
playbackPosition:
|
|
||||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
|
||||||
});
|
|
||||||
goToPlayer(queryParams.toString());
|
|
||||||
},
|
|
||||||
isPreferred: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: t("player.downloaded_file_no"),
|
|
||||||
onPress: () => {
|
|
||||||
handleNormalPlayFlow();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: t("player.downloaded_file_cancel"),
|
|
||||||
style: "cancel",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If not downloaded, proceed with normal flow
|
|
||||||
handleNormalPlayFlow();
|
|
||||||
}, [
|
|
||||||
item,
|
|
||||||
lightHapticFeedback,
|
|
||||||
handleNormalPlayFlow,
|
|
||||||
goToPlayer,
|
|
||||||
t,
|
|
||||||
showModal,
|
|
||||||
hideModal,
|
|
||||||
effectiveColors,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const derivedTargetWidth = useDerivedValue(() => {
|
const derivedTargetWidth = useDerivedValue(() => {
|
||||||
@@ -472,6 +358,55 @@ 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
|
||||||
@@ -479,7 +414,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 flex-1"}
|
className={"relative"}
|
||||||
>
|
>
|
||||||
<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
|
||||||
@@ -522,6 +457,15 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
<CastButton tintColor='transparent' />
|
<CastButton tintColor='transparent' />
|
||||||
</Animated.Text>
|
</Animated.Text>
|
||||||
)}
|
)}
|
||||||
|
{!client && settings?.openInVLC && (
|
||||||
|
<Animated.Text style={animatedTextStyle}>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name='vlc'
|
||||||
|
size={18}
|
||||||
|
color={animatedTextStyle.color}
|
||||||
|
/>
|
||||||
|
</Animated.Text>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons, MaterialCommunityIcons } 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,6 +17,7 @@ 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";
|
||||||
@@ -49,6 +50,7 @@ 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(
|
||||||
@@ -59,6 +61,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const onPress = () => {
|
const onPress = () => {
|
||||||
|
console.log("onpress");
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
|
|
||||||
lightHapticFeedback();
|
lightHapticFeedback();
|
||||||
@@ -204,6 +207,15 @@ 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>
|
||||||
|
|||||||
@@ -1,180 +0,0 @@
|
|||||||
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,6 +1,5 @@
|
|||||||
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";
|
||||||
@@ -15,16 +14,14 @@ 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={handlePress}
|
onPress={async () => {
|
||||||
|
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={color === "white" ? "white" : "#9334E9"}
|
color={"white"}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{children ? children : null}
|
{children ? children : null}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ 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";
|
||||||
@@ -54,7 +53,7 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({
|
|||||||
<HorizontalScroll
|
<HorizontalScroll
|
||||||
data={movies}
|
data={movies}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
height={POSTER_CAROUSEL_HEIGHT}
|
height={247}
|
||||||
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 ?? undefined,
|
mediaSource: defaultMediaSource,
|
||||||
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'>
|
<View className='px-4 mb-2'>
|
||||||
<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>
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
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,10 +16,6 @@ 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}`;
|
||||||
}
|
}
|
||||||
@@ -54,13 +50,6 @@ 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,
|
||||||
@@ -110,25 +99,6 @@ 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 (
|
||||||
!(
|
!(
|
||||||
@@ -138,14 +108,13 @@ 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 = options.length - 1;
|
const cancelButtonIndex = 3;
|
||||||
|
|
||||||
showActionSheetWithOptions(
|
showActionSheetWithOptions(
|
||||||
{
|
{
|
||||||
@@ -162,24 +131,28 @@ 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={handlePress}
|
onPress={() => {
|
||||||
|
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,7 +2,6 @@ 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,
|
||||||
@@ -14,13 +13,11 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -31,7 +28,6 @@ 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,7 +2,6 @@ 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";
|
||||||
@@ -23,10 +22,8 @@ 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,
|
||||||
@@ -94,77 +91,35 @@ export const Favorites = () => {
|
|||||||
|
|
||||||
const fetchFavoriteSeries = useCallback(
|
const fetchFavoriteSeries = useCallback(
|
||||||
({ pageParam }: { pageParam: number }) =>
|
({ pageParam }: { pageParam: number }) =>
|
||||||
fetchFavoritesByType("Series", pageParam, pageSize),
|
fetchFavoritesByType("Series", pageParam),
|
||||||
[fetchFavoritesByType, pageSize],
|
[fetchFavoritesByType],
|
||||||
);
|
);
|
||||||
const fetchFavoriteMovies = useCallback(
|
const fetchFavoriteMovies = useCallback(
|
||||||
({ pageParam }: { pageParam: number }) =>
|
({ pageParam }: { pageParam: number }) =>
|
||||||
fetchFavoritesByType("Movie", pageParam, pageSize),
|
fetchFavoritesByType("Movie", pageParam),
|
||||||
[fetchFavoritesByType, pageSize],
|
[fetchFavoritesByType],
|
||||||
);
|
);
|
||||||
const fetchFavoriteEpisodes = useCallback(
|
const fetchFavoriteEpisodes = useCallback(
|
||||||
({ pageParam }: { pageParam: number }) =>
|
({ pageParam }: { pageParam: number }) =>
|
||||||
fetchFavoritesByType("Episode", pageParam, pageSize),
|
fetchFavoritesByType("Episode", pageParam),
|
||||||
[fetchFavoritesByType, pageSize],
|
[fetchFavoritesByType],
|
||||||
);
|
);
|
||||||
const fetchFavoriteVideos = useCallback(
|
const fetchFavoriteVideos = useCallback(
|
||||||
({ pageParam }: { pageParam: number }) =>
|
({ pageParam }: { pageParam: number }) =>
|
||||||
fetchFavoritesByType("Video", pageParam, pageSize),
|
fetchFavoritesByType("Video", pageParam),
|
||||||
[fetchFavoritesByType, pageSize],
|
[fetchFavoritesByType],
|
||||||
);
|
);
|
||||||
const fetchFavoriteBoxsets = useCallback(
|
const fetchFavoriteBoxsets = useCallback(
|
||||||
({ pageParam }: { pageParam: number }) =>
|
({ pageParam }: { pageParam: number }) =>
|
||||||
fetchFavoritesByType("BoxSet", pageParam, pageSize),
|
fetchFavoritesByType("BoxSet", pageParam),
|
||||||
[fetchFavoritesByType, pageSize],
|
[fetchFavoritesByType],
|
||||||
);
|
);
|
||||||
const fetchFavoritePlaylists = useCallback(
|
const fetchFavoritePlaylists = useCallback(
|
||||||
({ pageParam }: { pageParam: number }) =>
|
({ pageParam }: { pageParam: number }) =>
|
||||||
fetchFavoritesByType("Playlist", pageParam, pageSize),
|
fetchFavoritesByType("Playlist", pageParam),
|
||||||
[fetchFavoritesByType, pageSize],
|
[fetchFavoritesByType],
|
||||||
);
|
);
|
||||||
|
|
||||||
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() && (
|
||||||
@@ -188,8 +143,6 @@ 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}
|
||||||
@@ -197,40 +150,30 @@ 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,8 +28,6 @@ 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";
|
||||||
@@ -47,14 +45,12 @@ 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;
|
||||||
@@ -78,7 +74,7 @@ export const Home = () => {
|
|||||||
retryCheck,
|
retryCheck,
|
||||||
} = useNetworkStatus();
|
} = useNetworkStatus();
|
||||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||||
const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set());
|
const [scrollY, setScrollY] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isConnected && !prevIsConnected.current) {
|
if (isConnected && !prevIsConnected.current) {
|
||||||
@@ -176,7 +172,6 @@ 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);
|
||||||
@@ -199,10 +194,10 @@ export const Home = () => {
|
|||||||
(
|
(
|
||||||
await getUserLibraryApi(api).getLatestMedia({
|
await getUserLibraryApi(api).getLatestMedia({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
limit: 10,
|
limit: 100, // Fetch a larger set for pagination
|
||||||
fields: ["PrimaryImageAspectRatio"],
|
fields: ["PrimaryImageAspectRatio", "Path", "Genres"],
|
||||||
imageTypeLimit: 1,
|
imageTypeLimit: 1,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||||
includeItemTypes,
|
includeItemTypes,
|
||||||
parentId,
|
parentId,
|
||||||
})
|
})
|
||||||
@@ -241,143 +236,64 @@ 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,
|
{
|
||||||
...latestMediaViews.map((s) => ({ ...s, priority: 2 as const })),
|
title: t("home.continue_watching"),
|
||||||
// Only show Jellyfin suggested movies if StreamyStats recommendations are disabled
|
queryKey: ["home", "resumeItems"],
|
||||||
...(!settings?.streamyStatsMovieRecommendations
|
queryFn: async ({ pageParam = 0 }) =>
|
||||||
? [
|
(
|
||||||
{
|
await getItemsApi(api).getResumeItems({
|
||||||
title: t("home.suggested_movies"),
|
userId: user.Id,
|
||||||
queryKey: ["home", "suggestedMovies", user?.Id],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||||
queryFn: async ({ pageParam = 0 }: { pageParam?: number }) =>
|
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||||
(
|
fields: ["Genres"],
|
||||||
await getSuggestionsApi(api).getSuggestions({
|
startIndex: pageParam,
|
||||||
userId: user?.Id,
|
limit: 10,
|
||||||
startIndex: pageParam,
|
})
|
||||||
limit: 10,
|
).data.Items || [],
|
||||||
mediaType: ["Video"],
|
type: "InfiniteScrollingCollectionList",
|
||||||
type: ["Movie"],
|
orientation: "horizontal",
|
||||||
})
|
pageSize: 10,
|
||||||
).data.Items || [],
|
},
|
||||||
type: "InfiniteScrollingCollectionList" as const,
|
{
|
||||||
orientation: "vertical" as const,
|
title: t("home.next_up"),
|
||||||
pageSize: 10,
|
queryKey: ["home", "nextUp-all"],
|
||||||
priority: 2 as const,
|
queryFn: async ({ pageParam = 0 }) =>
|
||||||
},
|
(
|
||||||
]
|
await getTvShowsApi(api).getNextUp({
|
||||||
: []),
|
userId: user?.Id,
|
||||||
|
fields: ["MediaSourceCount", "Genres"],
|
||||||
|
startIndex: pageParam,
|
||||||
|
limit: 10,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||||
|
enableResumable: false,
|
||||||
|
})
|
||||||
|
).data.Items || [],
|
||||||
|
type: "InfiniteScrollingCollectionList",
|
||||||
|
orientation: "horizontal",
|
||||||
|
pageSize: 10,
|
||||||
|
},
|
||||||
|
...latestMediaViews,
|
||||||
|
{
|
||||||
|
title: t("home.suggested_movies"),
|
||||||
|
queryKey: ["home", "suggestedMovies", user?.Id],
|
||||||
|
queryFn: async ({ pageParam = 0 }) =>
|
||||||
|
(
|
||||||
|
await getSuggestionsApi(api).getSuggestions({
|
||||||
|
userId: user?.Id,
|
||||||
|
startIndex: pageParam,
|
||||||
|
limit: 10,
|
||||||
|
mediaType: ["Video"],
|
||||||
|
type: ["Movie"],
|
||||||
|
})
|
||||||
|
).data.Items || [],
|
||||||
|
type: "InfiniteScrollingCollectionList",
|
||||||
|
orientation: "vertical",
|
||||||
|
pageSize: 10,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
return ss;
|
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 [];
|
||||||
@@ -406,9 +322,10 @@ 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"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||||
enableResumable: section.nextUp?.enableResumable,
|
enableResumable: section.nextUp?.enableResumable,
|
||||||
enableRewatching: section.nextUp?.enableRewatching,
|
enableRewatching: section.nextUp?.enableRewatching,
|
||||||
});
|
});
|
||||||
@@ -421,7 +338,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 || 10,
|
limit: section.latest?.limit || 100, // Fetch larger set
|
||||||
isPlayed: section.latest?.isPlayed,
|
isPlayed: section.latest?.isPlayed,
|
||||||
groupItems: section.latest?.groupItems,
|
groupItems: section.latest?.groupItems,
|
||||||
})
|
})
|
||||||
@@ -450,8 +367,6 @@ 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;
|
||||||
@@ -459,25 +374,6 @@ 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 = "";
|
||||||
@@ -555,6 +451,10 @@ 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}
|
||||||
@@ -574,77 +474,28 @@ 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 (
|
||||||
<View key={index} className='flex flex-col space-y-4'>
|
<InfiniteScrollingCollectionList
|
||||||
<InfiniteScrollingCollectionList
|
key={index}
|
||||||
title={section.title}
|
title={section.title}
|
||||||
queryKey={section.queryKey}
|
queryKey={section.queryKey}
|
||||||
queryFn={section.queryFn}
|
queryFn={section.queryFn}
|
||||||
orientation={section.orientation}
|
orientation={section.orientation}
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
pageSize={section.pageSize}
|
pageSize={section.pageSize}
|
||||||
enabled={isHighPriority || allHighPriorityLoaded}
|
/>
|
||||||
onLoaded={
|
|
||||||
isHighPriority
|
|
||||||
? () => markSectionLoaded(section.queryKey)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{streamystatsSections}
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (section.type === "MediaListSection") {
|
if (section.type === "MediaListSection") {
|
||||||
return (
|
return (
|
||||||
<View key={index} className='flex flex-col space-y-4'>
|
<MediaListSection
|
||||||
<MediaListSection
|
key={index}
|
||||||
queryKey={section.queryKey}
|
queryKey={section.queryKey}
|
||||||
queryFn={section.queryFn}
|
queryFn={section.queryFn}
|
||||||
/>
|
scrollY={scrollY}
|
||||||
{streamystatsSections}
|
enableLazyLoading={true}
|
||||||
</View>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -30,8 +30,6 @@ 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";
|
||||||
@@ -243,143 +241,64 @@ 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
|
{
|
||||||
...(!settings?.streamyStatsMovieRecommendations
|
title: t("home.suggested_movies"),
|
||||||
? [
|
queryKey: ["home", "suggestedMovies", user?.Id],
|
||||||
{
|
queryFn: async ({ pageParam = 0 }) =>
|
||||||
title: t("home.suggested_movies"),
|
(
|
||||||
queryKey: ["home", "suggestedMovies", user?.Id],
|
await getSuggestionsApi(api).getSuggestions({
|
||||||
queryFn: async ({ pageParam = 0 }: { pageParam?: number }) =>
|
userId: user?.Id,
|
||||||
(
|
startIndex: pageParam,
|
||||||
await getSuggestionsApi(api).getSuggestions({
|
limit: 10,
|
||||||
userId: user?.Id,
|
mediaType: ["Video"],
|
||||||
startIndex: pageParam,
|
type: ["Movie"],
|
||||||
limit: 10,
|
})
|
||||||
mediaType: ["Video"],
|
).data.Items || [],
|
||||||
type: ["Movie"],
|
type: "InfiniteScrollingCollectionList",
|
||||||
})
|
orientation: "vertical",
|
||||||
).data.Items || [],
|
pageSize: 10,
|
||||||
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 [];
|
||||||
@@ -558,66 +477,28 @@ 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 (
|
||||||
<View key={index} className='flex flex-col space-y-4'>
|
<InfiniteScrollingCollectionList
|
||||||
<InfiniteScrollingCollectionList
|
key={index}
|
||||||
title={section.title}
|
title={section.title}
|
||||||
queryKey={section.queryKey}
|
queryKey={section.queryKey}
|
||||||
queryFn={section.queryFn}
|
queryFn={section.queryFn}
|
||||||
orientation={section.orientation}
|
orientation={section.orientation}
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
pageSize={section.pageSize}
|
pageSize={section.pageSize}
|
||||||
/>
|
/>
|
||||||
{streamystatsSections}
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (section.type === "MediaListSection") {
|
if (section.type === "MediaListSection") {
|
||||||
return (
|
return (
|
||||||
<View key={index} className='flex flex-col space-y-4'>
|
<MediaListSection
|
||||||
<MediaListSection
|
key={index}
|
||||||
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,7 +4,6 @@ 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,
|
||||||
@@ -12,7 +11,6 @@ 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";
|
||||||
@@ -29,9 +27,6 @@ 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> = ({
|
||||||
@@ -42,72 +37,32 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
queryKey,
|
queryKey,
|
||||||
hideIfEmpty = false,
|
hideIfEmpty = false,
|
||||||
pageSize = 10,
|
pageSize = 10,
|
||||||
onPressSeeAll,
|
|
||||||
enabled = true,
|
|
||||||
onLoaded,
|
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const effectivePageSize = Math.max(1, pageSize);
|
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
|
||||||
const hasCalledOnLoaded = useRef(false);
|
useInfiniteQuery({
|
||||||
const {
|
queryKey: queryKey,
|
||||||
data,
|
queryFn: ({ pageParam = 0, ...context }) =>
|
||||||
isLoading,
|
queryFn({ ...context, queryKey, pageParam }),
|
||||||
isFetchingNextPage,
|
getNextPageParam: (lastPage, allPages) => {
|
||||||
hasNextPage,
|
// If the last page has fewer items than pageSize, we've reached the end
|
||||||
fetchNextPage,
|
if (lastPage.length < pageSize) {
|
||||||
isSuccess,
|
return undefined;
|
||||||
} = useInfiniteQuery({
|
}
|
||||||
queryKey: queryKey,
|
// Otherwise, return the next start index
|
||||||
queryFn: ({ pageParam = 0, ...context }) =>
|
return allPages.length * pageSize;
|
||||||
queryFn({ ...context, queryKey, pageParam }),
|
},
|
||||||
getNextPageParam: (lastPage, allPages) => {
|
initialPageParam: 0,
|
||||||
// If the last page has fewer items than pageSize, we've reached the end
|
staleTime: 60 * 1000, // 1 minute
|
||||||
if (lastPage.length < effectivePageSize) {
|
refetchOnMount: false,
|
||||||
return undefined;
|
refetchOnWindowFocus: false,
|
||||||
}
|
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 (and de-dupe by Id to avoid UI duplicates)
|
// Flatten all pages into a single array
|
||||||
const allItems = useMemo(() => {
|
const allItems = data?.pages.flat() || [];
|
||||||
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;
|
||||||
@@ -129,12 +84,9 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<SectionHeader
|
<Text className='px-4 text-lg font-bold mb-2 text-neutral-100'>
|
||||||
title={title}
|
{title}
|
||||||
actionLabel={t("common.seeAll", { defaultValue: "See all" })}
|
</Text>
|
||||||
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>
|
||||||
@@ -174,15 +126,13 @@ 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, index) => (
|
{allItems.map((item) => (
|
||||||
<TouchableItemRouter
|
<TouchableItemRouter
|
||||||
item={item}
|
item={item}
|
||||||
key={`${item.Id}-${index}`}
|
key={item.Id}
|
||||||
className={`mr-2
|
className={`mr-2
|
||||||
${orientation === "horizontal" ? "w-44" : "w-28"}
|
${orientation === "horizontal" ? "w-44" : "w-28"}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,245 +0,0 @@
|
|||||||
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}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
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,7 +11,6 @@ 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;
|
||||||
@@ -29,7 +28,6 @@ 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,>({
|
||||||
@@ -42,7 +40,6 @@ 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();
|
||||||
|
|
||||||
@@ -127,40 +124,27 @@ const ParallaxSlideShow = <T,>({
|
|||||||
</View>
|
</View>
|
||||||
{MainContent?.()}
|
{MainContent?.()}
|
||||||
<View>
|
<View>
|
||||||
{isLoading ? (
|
<FlashList
|
||||||
<View>
|
data={data}
|
||||||
<Text className='text-lg font-bold my-2'>{listHeader}</Text>
|
ListEmptyComponent={
|
||||||
<View className='px-4'>
|
<View className='flex flex-col items-center justify-center h-full'>
|
||||||
<View className='flex flex-row flex-wrap'>
|
<Text className='font-bold text-xl text-neutral-500'>
|
||||||
{Array.from({ length: 9 }, (_, i) => (
|
No results
|
||||||
<GridSkeleton key={i} index={i} />
|
</Text>
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
}
|
||||||
) : (
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
<FlashList
|
ListHeaderComponent={
|
||||||
data={data}
|
<Text className='text-lg font-bold my-2'>{listHeader}</Text>
|
||||||
ListEmptyComponent={
|
}
|
||||||
<View className='flex flex-col items-center justify-center h-full'>
|
nestedScrollEnabled
|
||||||
<Text className='font-bold text-xl text-neutral-500'>
|
showsVerticalScrollIndicator={false}
|
||||||
No results
|
//@ts-expect-error
|
||||||
</Text>
|
renderItem={({ item, index }) => renderItem(item, index)}
|
||||||
</View>
|
keyExtractor={keyExtractor}
|
||||||
}
|
numColumns={3}
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
ItemSeparatorComponent={() => <View className='h-2 w-2' />}
|
||||||
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,7 +6,6 @@ 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;
|
||||||
@@ -15,7 +14,6 @@ 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>> = ({
|
||||||
@@ -29,23 +27,21 @@ 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={isDisabled}
|
disabled={disabled}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
className={`flex flex-row items-center justify-between bg-neutral-900 min-h-[42px] py-2 pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`}
|
className={`flex flex-row items-center justify-between bg-neutral-900 min-h-[42px] py-2 pr-4 pl-4 ${
|
||||||
|
disabled ? "opacity-50" : ""
|
||||||
|
}`}
|
||||||
{...(viewProps as any)}
|
{...(viewProps as any)}
|
||||||
>
|
>
|
||||||
<ListItemContent
|
<ListItemContent
|
||||||
title={title}
|
title={title}
|
||||||
subtitle={effectiveSubtitle}
|
subtitle={subtitle}
|
||||||
subtitleColor={disabledByAdmin ? "red" : undefined}
|
|
||||||
value={value}
|
value={value}
|
||||||
icon={icon}
|
icon={icon}
|
||||||
textColor={textColor}
|
textColor={textColor}
|
||||||
@@ -58,13 +54,14 @@ 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 ${isDisabled ? "opacity-50" : ""}`}
|
className={`flex flex-row items-center justify-between bg-neutral-900 min-h-[42px] py-2 pr-4 pl-4 ${
|
||||||
|
disabled ? "opacity-50" : ""
|
||||||
|
}`}
|
||||||
{...viewProps}
|
{...viewProps}
|
||||||
>
|
>
|
||||||
<ListItemContent
|
<ListItemContent
|
||||||
title={title}
|
title={title}
|
||||||
subtitle={effectiveSubtitle}
|
subtitle={subtitle}
|
||||||
subtitleColor={disabledByAdmin ? "red" : undefined}
|
|
||||||
value={value}
|
value={value}
|
||||||
icon={icon}
|
icon={icon}
|
||||||
textColor={textColor}
|
textColor={textColor}
|
||||||
@@ -80,7 +77,6 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
const ListItemContent = ({
|
const ListItemContent = ({
|
||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
subtitleColor,
|
|
||||||
textColor,
|
textColor,
|
||||||
icon,
|
icon,
|
||||||
value,
|
value,
|
||||||
@@ -111,7 +107,7 @@ const ListItemContent = ({
|
|||||||
</Text>
|
</Text>
|
||||||
{subtitle && (
|
{subtitle && (
|
||||||
<Text
|
<Text
|
||||||
className={`text-[12px] mt-0.5 ${subtitleColor === "red" ? "text-red-600" : "text-[#9899A1]"}`}
|
className='text-[#9899A1] text-[12px] mt-0.5'
|
||||||
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, useMemo } from "react";
|
import { useCallback } from "react";
|
||||||
import { View, type ViewProps } from "react-native";
|
import { View, type ViewProps } from "react-native";
|
||||||
import { useInView } from "@/hooks/useInView";
|
import { useInView } from "@/hooks/useInView";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
@@ -67,12 +67,6 @@ 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 (
|
||||||
@@ -98,8 +92,6 @@ 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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,157 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,359 +0,0 @@
|
|||||||
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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
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;
|
|
||||||
};
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
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",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,262 +0,0 @@
|
|||||||
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",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,437 +0,0 @@
|
|||||||
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",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export * from "./MiniPlayerBar";
|
|
||||||
export * from "./MusicAlbumCard";
|
|
||||||
export * from "./MusicArtistCard";
|
|
||||||
export * from "./MusicPlaybackEngine";
|
|
||||||
export * from "./MusicPlaylistCard";
|
|
||||||
export * from "./MusicTrackItem";
|
|
||||||
@@ -8,7 +8,6 @@ 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";
|
||||||
@@ -51,7 +50,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={POSTER_CAROUSEL_HEIGHT}
|
height={247}
|
||||||
data={destinctPeople}
|
data={destinctPeople}
|
||||||
renderItem={(i) => (
|
renderItem={(i) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -66,12 +65,8 @@ 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' numberOfLines={1}>
|
<Text className='mt-2'>{i.Name}</Text>
|
||||||
{i.Name}
|
<Text className='text-xs opacity-50'>{i.Role}</Text>
|
||||||
</Text>
|
|
||||||
<Text className='text-xs opacity-50' numberOfLines={1}>
|
|
||||||
{i.Role}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ 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";
|
||||||
@@ -26,7 +25,7 @@ export const CurrentSeries: React.FC<Props> = ({ item, ...props }) => {
|
|||||||
</Text>
|
</Text>
|
||||||
<HorizontalScroll
|
<HorizontalScroll
|
||||||
data={[item]}
|
data={[item]}
|
||||||
height={POSTER_CAROUSEL_HEIGHT}
|
height={247}
|
||||||
renderItem={(item, _index) => (
|
renderItem={(item, _index) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={item?.Id}
|
key={item?.Id}
|
||||||
@@ -39,7 +38,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 numberOfLines={1}>{item?.SeriesName}</Text>
|
<Text>{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