mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-16 16:18:09 +00:00
Compare commits
1 Commits
v0.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
|
||||
description: What version of Streamyfin are you running?
|
||||
options:
|
||||
- 0.47.1
|
||||
- 0.30.2
|
||||
- 0.29.0
|
||||
- 0.28.0
|
||||
- 0.27.0
|
||||
- 0.26.1
|
||||
- 0.26.0
|
||||
- 0.25.0
|
||||
- older
|
||||
- TestFlight/Development build
|
||||
validations:
|
||||
@@ -111,4 +116,4 @@ body:
|
||||
id: additional-info
|
||||
attributes:
|
||||
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
|
||||
show-progress: false
|
||||
|
||||
- name: 🟢 Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||
with:
|
||||
@@ -192,7 +197,7 @@ jobs:
|
||||
run: bun run prebuild
|
||||
|
||||
- name: 🔧 Setup Xcode
|
||||
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1
|
||||
uses: maxim-lobanov/setup-xcode@v1
|
||||
with:
|
||||
xcode-version: "26.0.1"
|
||||
|
||||
@@ -238,6 +243,11 @@ jobs:
|
||||
# submodules: recursive
|
||||
# show-progress: false
|
||||
#
|
||||
# - name: 🟢 Setup Node.js
|
||||
# uses: actions/setup-node@v4
|
||||
# with:
|
||||
# node-version: '20'
|
||||
#
|
||||
# - name: 🍞 Setup Bun
|
||||
# uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||
# with:
|
||||
|
||||
4
.github/workflows/ci-codeql.yml
vendored
4
.github/workflows/ci-codeql.yml
vendored
@@ -25,6 +25,10 @@ jobs:
|
||||
steps:
|
||||
- name: 📥 Checkout repository
|
||||
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
|
||||
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
|
||||
|
||||
- name: "🟢 Setup Node.js"
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
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
|
||||
|
||||
- name: "🟢 Setup Node.js"
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version: '24.x'
|
||||
cache: 'npm'
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -51,7 +51,6 @@ npm-debug.*
|
||||
.ruby-lsp
|
||||
.cursor/
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
|
||||
# Environment and Configuration
|
||||
expo-env.d.ts
|
||||
@@ -67,6 +66,3 @@ streamyfin-4fec1-firebase-adminsdk.json
|
||||
# Version and Backup Files
|
||||
/version-backup-*
|
||||
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://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://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>
|
||||
|
||||
### 🧪 Beta Testing
|
||||
@@ -105,7 +104,6 @@ You can contribute translations directly on our [Crowdin project page](https://c
|
||||
1. Use node `>20`
|
||||
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/)
|
||||
- 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. 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
|
||||
|
||||
@@ -6,9 +6,6 @@ module.exports = ({ config }) => {
|
||||
"react-native-google-cast",
|
||||
{ useDefaultExpandedMediaControls: true },
|
||||
]);
|
||||
|
||||
// KSPlayer for iOS (GPU acceleration + native PiP)
|
||||
config.plugins.push("./plugins/withKSPlayer.js");
|
||||
}
|
||||
|
||||
// Only override googleServicesFile if env var is set
|
||||
|
||||
23
app.json
23
app.json
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "Streamyfin",
|
||||
"slug": "streamyfin",
|
||||
"version": "0.51.0",
|
||||
"version": "0.47.1",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "streamyfin",
|
||||
@@ -34,7 +34,7 @@
|
||||
},
|
||||
"android": {
|
||||
"jsEngine": "hermes",
|
||||
"versionCode": 91,
|
||||
"versionCode": 84,
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/icon-android-plain.png",
|
||||
"monochromeImage": "./assets/images/icon-android-themed.png",
|
||||
@@ -53,16 +53,29 @@
|
||||
"@react-native-tvos/config-tv",
|
||||
"expo-router",
|
||||
"expo-font",
|
||||
"./plugins/withExcludeMedia3Dash.js",
|
||||
[
|
||||
"react-native-video",
|
||||
{
|
||||
"enableNotificationControls": true,
|
||||
"enableBackgroundAudio": true,
|
||||
"androidExtensions": {
|
||||
"useExoplayerRtsp": false,
|
||||
"useExoplayerSmoothStreaming": false,
|
||||
"useExoplayerHls": true,
|
||||
"useExoplayerDash": false
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-build-properties",
|
||||
{
|
||||
"ios": {
|
||||
"deploymentTarget": "15.6"
|
||||
"deploymentTarget": "15.6",
|
||||
"useFrameworks": "static"
|
||||
},
|
||||
"android": {
|
||||
"buildArchs": ["arm64-v8a", "x86_64"],
|
||||
"compileSdkVersion": 36,
|
||||
"compileSdkVersion": 35,
|
||||
"targetSdkVersion": 35,
|
||||
"buildToolsVersion": "35.0.0",
|
||||
"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
|
||||
name='settings/appearance/hide-libraries/page'
|
||||
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
|
||||
name='settings/intro/page'
|
||||
options={{
|
||||
|
||||
@@ -70,11 +70,6 @@ export default function settings() {
|
||||
showArrow
|
||||
title={t("home.settings.audio_subtitles.title")}
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/music/page")}
|
||||
showArrow
|
||||
title={t("home.settings.music.title")}
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/appearance/page")}
|
||||
showArrow
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -75,10 +75,7 @@ export default function page() {
|
||||
<DisabledSetting disabled={disabled} className='px-4'>
|
||||
<ListGroup>
|
||||
<DisabledSetting
|
||||
disabled={
|
||||
pluginSettings?.searchEngine?.locked === true ||
|
||||
!!pluginSettings?.streamyStatsServerUrl?.value
|
||||
}
|
||||
disabled={pluginSettings?.searchEngine?.locked === true}
|
||||
showText={!pluginSettings?.marlinServerUrl?.locked}
|
||||
>
|
||||
<ListItem
|
||||
@@ -92,7 +89,6 @@ export default function page() {
|
||||
>
|
||||
<Switch
|
||||
value={settings.searchEngine === "Marlin"}
|
||||
disabled={!!pluginSettings?.streamyStatsServerUrl?.value}
|
||||
onValueChange={(value) => {
|
||||
updateSettings({
|
||||
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 type React from "react";
|
||||
import { useEffect } from "react";
|
||||
@@ -21,16 +20,7 @@ const Page: React.FC = () => {
|
||||
const { offline } = useLocalSearchParams() as { offline?: string };
|
||||
const isOffline = offline === "true";
|
||||
|
||||
// Exclude MediaSources/MediaStreams from initial fetch for faster loading
|
||||
// (especially important for plugins like Gelato)
|
||||
const { data: item, isError } = useItemQuery(id, isOffline, undefined, [
|
||||
ItemFields.MediaSources,
|
||||
ItemFields.MediaSourceCount,
|
||||
ItemFields.MediaStreams,
|
||||
]);
|
||||
|
||||
// Lazily preload item with full media sources in background
|
||||
const { data: itemWithSources } = useItemQuery(id, isOffline, undefined, []);
|
||||
const { data: item, isError } = useItemQuery(id, isOffline);
|
||||
|
||||
const opacity = useSharedValue(1);
|
||||
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-24 bg-neutral-900 rounded-lg mb-1 w-full' />
|
||||
</Animated.View>
|
||||
{item && (
|
||||
<ItemContent
|
||||
item={item}
|
||||
isOffline={isOffline}
|
||||
itemWithSources={itemWithSources}
|
||||
/>
|
||||
)}
|
||||
{item && <ItemContent item={item} isOffline={isOffline} />}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -21,18 +21,19 @@ export default function page() {
|
||||
companyId: string;
|
||||
name: 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],
|
||||
queryFn: async ({ pageParam }) => {
|
||||
const params: any = {
|
||||
page: Number(pageParam),
|
||||
};
|
||||
|
||||
return jellyseerrApi?.discover(
|
||||
`${
|
||||
Number(type) === DiscoverSliderType.NETWORKS
|
||||
type === DiscoverSliderType.NETWORKS
|
||||
? Endpoints.DISCOVER_TV_NETWORK
|
||||
: Endpoints.DISCOVER_MOVIES_STUDIO
|
||||
}/${companyId}`,
|
||||
@@ -85,7 +86,6 @@ export default function page() {
|
||||
fetchNextPage();
|
||||
}
|
||||
}}
|
||||
isLoading={isLoading}
|
||||
logo={
|
||||
<Image
|
||||
id={companyId}
|
||||
@@ -14,7 +14,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { toast } from "sonner-native";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { GenreTags } from "@/components/GenreTags";
|
||||
@@ -34,16 +33,8 @@ import {
|
||||
type IssueType,
|
||||
IssueTypeName,
|
||||
} from "@/utils/jellyseerr/server/constants/issue";
|
||||
import {
|
||||
MediaRequestStatus,
|
||||
MediaType,
|
||||
} from "@/utils/jellyseerr/server/constants/media";
|
||||
import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
|
||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||
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 {
|
||||
MovieResult,
|
||||
@@ -67,7 +58,7 @@ const Page: React.FC = () => {
|
||||
} & Partial<MovieResult | TvResult | MovieDetails | TvDetails>;
|
||||
|
||||
const navigation = useNavigation();
|
||||
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
|
||||
const { jellyseerrApi, requestMedia } = useJellyseerr();
|
||||
|
||||
const [issueType, setIssueType] = useState<IssueType>();
|
||||
const [issueMessage, setIssueMessage] = useState<string>();
|
||||
@@ -100,46 +91,6 @@ const Page: React.FC = () => {
|
||||
const [canRequest, hasAdvancedRequestPermission] =
|
||||
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(
|
||||
(props: BottomSheetBackdropProps) => (
|
||||
<BottomSheetBackdrop
|
||||
@@ -383,60 +334,6 @@ const Page: React.FC = () => {
|
||||
</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' />
|
||||
</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,
|
||||
BaseItemDtoQueryResult,
|
||||
BaseItemKind,
|
||||
ItemFilter,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import {
|
||||
getFilterApi,
|
||||
@@ -28,11 +27,7 @@ import { useOrientation } from "@/hooks/useOrientation";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
FilterByOption,
|
||||
FilterByPreferenceAtom,
|
||||
filterByAtom,
|
||||
genreFilterAtom,
|
||||
getFilterByPreference,
|
||||
getSortByPreference,
|
||||
getSortOrderPreference,
|
||||
SortByOption,
|
||||
@@ -44,10 +39,8 @@ import {
|
||||
sortOrderOptions,
|
||||
sortOrderPreferenceAtom,
|
||||
tagsFilterAtom,
|
||||
useFilterOptions,
|
||||
yearFilterAtom,
|
||||
} from "@/utils/atoms/filters";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
const Page = () => {
|
||||
const searchParams = useLocalSearchParams();
|
||||
@@ -61,13 +54,9 @@ const Page = () => {
|
||||
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
||||
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
|
||||
const [sortBy, _setSortBy] = useAtom(sortByAtom);
|
||||
const [filterBy, _setFilterBy] = useAtom(filterByAtom);
|
||||
const [sortOrder, _setSortOrder] = useAtom(sortOrderAtom);
|
||||
const [sortByPreference, setSortByPreference] = useAtom(sortByPreferenceAtom);
|
||||
const [filterByPreference, setFilterByPreference] = useAtom(
|
||||
FilterByPreferenceAtom,
|
||||
);
|
||||
const [sortOrderPreference, setOrderByPreference] = useAtom(
|
||||
const [sortOrderPreference, setOderByPreference] = useAtom(
|
||||
sortOrderPreferenceAtom,
|
||||
);
|
||||
|
||||
@@ -88,20 +77,12 @@ const Page = () => {
|
||||
} else {
|
||||
_setSortBy([SortByOption.SortName]);
|
||||
}
|
||||
const fp = getFilterByPreference(libraryId, filterByPreference);
|
||||
if (fp) {
|
||||
_setFilterBy([fp]);
|
||||
} else {
|
||||
_setFilterBy([]);
|
||||
}
|
||||
}, [
|
||||
libraryId,
|
||||
sortOrderPreference,
|
||||
sortByPreference,
|
||||
_setSortOrder,
|
||||
_setSortBy,
|
||||
filterByPreference,
|
||||
_setFilterBy,
|
||||
]);
|
||||
|
||||
const setSortBy = useCallback(
|
||||
@@ -119,28 +100,14 @@ const Page = () => {
|
||||
(sortOrder: SortOrderOption[]) => {
|
||||
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
|
||||
if (sortOrder[0] !== sop) {
|
||||
setOrderByPreference({
|
||||
setOderByPreference({
|
||||
...sortOrderPreference,
|
||||
[libraryId]: sortOrder[0],
|
||||
});
|
||||
}
|
||||
_setSortOrder(sortOrder);
|
||||
},
|
||||
[libraryId, sortOrderPreference, setOrderByPreference, _setSortOrder],
|
||||
);
|
||||
|
||||
const setFilter = useCallback(
|
||||
(filterBy: FilterByOption[]) => {
|
||||
const fp = getFilterByPreference(libraryId, filterByPreference);
|
||||
if (filterBy[0] !== fp) {
|
||||
setFilterByPreference({
|
||||
...filterByPreference,
|
||||
[libraryId]: filterBy[0],
|
||||
});
|
||||
}
|
||||
_setFilterBy(filterBy);
|
||||
},
|
||||
[libraryId, filterByPreference, setFilterByPreference, _setFilterBy],
|
||||
[libraryId, sortOrderPreference, setOderByPreference, _setSortOrder],
|
||||
);
|
||||
|
||||
const nrOfCols = useMemo(() => {
|
||||
@@ -201,7 +168,6 @@ const Page = () => {
|
||||
sortBy: [sortBy[0], "SortName", "ProductionYear"],
|
||||
sortOrder: [sortOrder[0]],
|
||||
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
|
||||
filters: filterBy as ItemFilter[],
|
||||
// true is needed for merged versions
|
||||
recursive: true,
|
||||
imageTypeLimit: 1,
|
||||
@@ -224,7 +190,6 @@ const Page = () => {
|
||||
selectedTags,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
filterBy,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -238,7 +203,6 @@ const Page = () => {
|
||||
selectedTags,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
filterBy,
|
||||
],
|
||||
queryFn: fetchItems,
|
||||
getNextPageParam: (lastPage, pages) => {
|
||||
@@ -304,8 +268,7 @@ const Page = () => {
|
||||
);
|
||||
|
||||
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||
const generalFilters = useFilterOptions();
|
||||
const settings = useSettings();
|
||||
|
||||
const ListHeaderComponent = useCallback(
|
||||
() => (
|
||||
<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}
|
||||
keyExtractor={(item) => item.key}
|
||||
@@ -481,9 +424,6 @@ const Page = () => {
|
||||
sortOrder,
|
||||
setSortOrder,
|
||||
isFetching,
|
||||
filterBy,
|
||||
setFilter,
|
||||
settings,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ export default function index() {
|
||||
() =>
|
||||
data
|
||||
?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!))
|
||||
.filter((l) => l.CollectionType !== "music")
|
||||
.filter((l) => l.CollectionType !== "books") || [],
|
||||
[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 { useSettings } from "@/utils/atoms/settings";
|
||||
import { eventBus } from "@/utils/eventBus";
|
||||
import { createStreamystatsApi } from "@/utils/streamystats";
|
||||
|
||||
type SearchType = "Library" | "Discover";
|
||||
|
||||
@@ -118,54 +117,6 @@ export default function search() {
|
||||
|
||||
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) {
|
||||
return [];
|
||||
}
|
||||
@@ -190,11 +141,12 @@ export default function search() {
|
||||
});
|
||||
|
||||
return (response2.data.Items as BaseItemDto[]) || [];
|
||||
} catch (_error) {
|
||||
return [];
|
||||
} catch (error) {
|
||||
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 = {
|
||||
|
||||
@@ -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 { useCallback } from "react";
|
||||
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 { MiniPlayerBar } from "@/components/music/MiniPlayerBar";
|
||||
import { MusicPlaybackEngine } from "@/components/music/MusicPlaybackEngine";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { eventBus } from "@/utils/eventBus";
|
||||
@@ -49,7 +47,7 @@ export default function TabLayout() {
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
<>
|
||||
<SystemBars hidden={false} style='light' />
|
||||
<NativeTabs
|
||||
sidebarAdaptable={false}
|
||||
@@ -102,18 +100,6 @@ export default function TabLayout() {
|
||||
: (_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
|
||||
name='(libraries)'
|
||||
options={{
|
||||
@@ -136,8 +122,6 @@ export default function TabLayout() {
|
||||
}}
|
||||
/>
|
||||
</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 { useEffect } from "react";
|
||||
import { AppState } from "react-native";
|
||||
import { SystemBars } from "react-native-edge-to-edge";
|
||||
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
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 (
|
||||
<>
|
||||
<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 { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
|
||||
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import * as BackgroundTask from "expo-background-task";
|
||||
import * as Device from "expo-device";
|
||||
import { Platform } from "react-native";
|
||||
@@ -17,7 +15,6 @@ import {
|
||||
getOrSetDeviceId,
|
||||
JellyfinProvider,
|
||||
} from "@/providers/JellyfinProvider";
|
||||
import { MusicPlayerProvider } from "@/providers/MusicPlayerProvider";
|
||||
import { NetworkStatusProvider } from "@/providers/NetworkStatusProvider";
|
||||
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
||||
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
||||
@@ -190,21 +187,11 @@ export default function RootLayout() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30000, // 30 seconds - data is fresh
|
||||
gcTime: 1000 * 60 * 60 * 24, // 24 hours - keep in cache for persistence
|
||||
staleTime: 30000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 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() {
|
||||
const { settings } = useSettings();
|
||||
const [user] = useAtom(userAtom);
|
||||
@@ -350,90 +337,68 @@ function Layout() {
|
||||
}, [user]);
|
||||
|
||||
return (
|
||||
<PersistQueryClientProvider
|
||||
client={queryClient}
|
||||
persistOptions={{
|
||||
persister: mmkvPersister,
|
||||
maxAge: 1000 * 60 * 60 * 24, // 24 hours max cache age
|
||||
dehydrateOptions: {
|
||||
shouldDehydrateQuery: (query) => {
|
||||
// Only persist successful queries
|
||||
return query.state.status === "success";
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<JellyfinProvider>
|
||||
<NetworkStatusProvider>
|
||||
<PlaySettingsProvider>
|
||||
<LogProvider>
|
||||
<WebSocketProvider>
|
||||
<DownloadProvider>
|
||||
<MusicPlayerProvider>
|
||||
<GlobalModalProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<SystemBars style='light' hidden={false} />
|
||||
<Stack initialRouteName='(auth)/(tabs)'>
|
||||
<Stack.Screen
|
||||
name='(auth)/(tabs)'
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
header: () => null,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='(auth)/player'
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
header: () => null,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='(auth)/now-playing'
|
||||
options={{
|
||||
headerShown: false,
|
||||
presentation: "modal",
|
||||
gestureEnabled: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='login'
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: "",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name='+not-found' />
|
||||
</Stack>
|
||||
<Toaster
|
||||
duration={4000}
|
||||
toastOptions={{
|
||||
style: {
|
||||
backgroundColor: "#262626",
|
||||
borderColor: "#363639",
|
||||
borderWidth: 1,
|
||||
},
|
||||
titleStyle: {
|
||||
color: "white",
|
||||
},
|
||||
<GlobalModalProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<SystemBars style='light' hidden={false} />
|
||||
<Stack initialRouteName='(auth)/(tabs)'>
|
||||
<Stack.Screen
|
||||
name='(auth)/(tabs)'
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
header: () => null,
|
||||
}}
|
||||
closeButton
|
||||
/>
|
||||
<GlobalModal />
|
||||
</ThemeProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</GlobalModalProvider>
|
||||
</MusicPlayerProvider>
|
||||
<Stack.Screen
|
||||
name='(auth)/player'
|
||||
options={{
|
||||
headerShown: false,
|
||||
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>
|
||||
</WebSocketProvider>
|
||||
</LogProvider>
|
||||
</PlaySettingsProvider>
|
||||
</NetworkStatusProvider>
|
||||
</JellyfinProvider>
|
||||
</PersistQueryClientProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -262,12 +262,12 @@ const Login: React.FC = () => {
|
||||
<Input
|
||||
placeholder={t("login.username_placeholder")}
|
||||
onChangeText={(text: string) =>
|
||||
setCredentials((prev) => ({ ...prev, username: text }))
|
||||
setCredentials({ ...credentials, username: text })
|
||||
}
|
||||
onEndEditing={(e) => {
|
||||
const newValue = e.nativeEvent.text;
|
||||
if (newValue && newValue !== credentials.username) {
|
||||
setCredentials((prev) => ({ ...prev, username: newValue }));
|
||||
setCredentials({ ...credentials, username: newValue });
|
||||
}
|
||||
}}
|
||||
value={credentials.username}
|
||||
@@ -286,12 +286,12 @@ const Login: React.FC = () => {
|
||||
<Input
|
||||
placeholder={t("login.password_placeholder")}
|
||||
onChangeText={(text: string) =>
|
||||
setCredentials((prev) => ({ ...prev, password: text }))
|
||||
setCredentials({ ...credentials, password: text })
|
||||
}
|
||||
onEndEditing={(e) => {
|
||||
const newValue = e.nativeEvent.text;
|
||||
if (newValue && newValue !== credentials.password) {
|
||||
setCredentials((prev) => ({ ...prev, password: newValue }));
|
||||
setCredentials({ ...credentials, password: newValue });
|
||||
}
|
||||
}}
|
||||
value={credentials.password}
|
||||
@@ -398,8 +398,8 @@ const Login: React.FC = () => {
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
{api?.basePath ? (
|
||||
<View className='flex flex-col flex-1 justify-center'>
|
||||
<View className='px-4 w-full'>
|
||||
<View className='flex flex-col flex-1 items-center justify-center'>
|
||||
<View className='px-4 -mt-20 w-full'>
|
||||
<View className='flex flex-col space-y-2'>
|
||||
<Text className='text-2xl font-bold -mb-2'>
|
||||
{serverName ? (
|
||||
@@ -415,15 +415,12 @@ const Login: React.FC = () => {
|
||||
<Input
|
||||
placeholder={t("login.username_placeholder")}
|
||||
onChangeText={(text) =>
|
||||
setCredentials((prev) => ({ ...prev, username: text }))
|
||||
setCredentials({ ...credentials, username: text })
|
||||
}
|
||||
onEndEditing={(e) => {
|
||||
const newValue = e.nativeEvent.text;
|
||||
if (newValue && newValue !== credentials.username) {
|
||||
setCredentials((prev) => ({
|
||||
...prev,
|
||||
username: newValue,
|
||||
}));
|
||||
setCredentials({ ...credentials, username: newValue });
|
||||
}
|
||||
}}
|
||||
value={credentials.username}
|
||||
@@ -440,15 +437,12 @@ const Login: React.FC = () => {
|
||||
<Input
|
||||
placeholder={t("login.password_placeholder")}
|
||||
onChangeText={(text) =>
|
||||
setCredentials((prev) => ({ ...prev, password: text }))
|
||||
setCredentials({ ...credentials, password: text })
|
||||
}
|
||||
onEndEditing={(e) => {
|
||||
const newValue = e.nativeEvent.text;
|
||||
if (newValue && newValue !== credentials.password) {
|
||||
setCredentials((prev) => ({
|
||||
...prev,
|
||||
password: newValue,
|
||||
}));
|
||||
setCredentials({ ...credentials, password: newValue });
|
||||
}
|
||||
}}
|
||||
value={credentials.password}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 18 KiB |
@@ -8,8 +8,7 @@
|
||||
"!android",
|
||||
"!Streamyfin.app",
|
||||
"!utils/jellyseerr",
|
||||
"!.expo",
|
||||
"!docs/jellyfin-openapi-stable.json"
|
||||
"!.expo"
|
||||
]
|
||||
},
|
||||
"linter": {
|
||||
|
||||
@@ -16,7 +16,6 @@ export const AddToFavorites: FC<Props> = ({ item, ...props }) => {
|
||||
<RoundButton
|
||||
size='large'
|
||||
icon={isFavorite ? "heart" : "heart-outline"}
|
||||
color={isFavorite ? "purple" : "white"}
|
||||
onPress={toggleFavorite}
|
||||
/>
|
||||
</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 {
|
||||
type PropsWithChildren,
|
||||
type ReactNode,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
@@ -17,58 +18,6 @@ import {
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
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
|
||||
extends React.ComponentProps<typeof TouchableOpacity> {
|
||||
onPress?: () => void;
|
||||
@@ -77,8 +26,7 @@ export interface ButtonProps
|
||||
disabled?: boolean;
|
||||
children?: string | ReactNode;
|
||||
loading?: boolean;
|
||||
color?: "purple" | "red" | "black" | "transparent" | "white";
|
||||
variant?: "solid" | "border";
|
||||
color?: "purple" | "red" | "black" | "transparent";
|
||||
iconRight?: ReactNode;
|
||||
iconLeft?: ReactNode;
|
||||
justify?: "center" | "between";
|
||||
@@ -91,7 +39,6 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
||||
disabled = false,
|
||||
loading = false,
|
||||
color = "purple",
|
||||
variant = "solid",
|
||||
iconRight,
|
||||
iconLeft,
|
||||
children,
|
||||
@@ -109,13 +56,23 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
||||
useNativeDriver: true,
|
||||
}).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 textColorClass =
|
||||
color === "white" && variant === "solid" ? "text-black" : "text-white";
|
||||
|
||||
return Platform.isTV ? (
|
||||
<Pressable
|
||||
className='w-full'
|
||||
@@ -141,12 +98,10 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
||||
>
|
||||
<View
|
||||
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}`}
|
||||
>
|
||||
<Text className={`${textColorClass} text-xl font-bold`}>
|
||||
{children}
|
||||
</Text>
|
||||
<Text className='text-white text-xl font-bold'>{children}</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
@@ -180,7 +135,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
||||
{iconLeft ? iconLeft : <View className='w-4' />}
|
||||
<Text
|
||||
className={`
|
||||
${textColorClass} font-bold text-base
|
||||
text-white font-bold text-base
|
||||
${disabled ? "text-gray-300" : ""}
|
||||
${textClassName}
|
||||
${iconRight ? "mr-2" : ""}
|
||||
|
||||
@@ -109,7 +109,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
useEffect(() => {
|
||||
setSelectedOptions(() => ({
|
||||
bitrate: defaultBitrate,
|
||||
mediaSource: defaultMediaSource ?? undefined,
|
||||
mediaSource: defaultMediaSource,
|
||||
subtitleIndex: defaultSubtitleIndex ?? -1,
|
||||
audioIndex: defaultAudioIndex,
|
||||
}));
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
type BottomSheetBackdropProps,
|
||||
BottomSheetModal,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||
|
||||
/**
|
||||
@@ -16,13 +16,7 @@ import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||
* after BottomSheetModalProvider.
|
||||
*/
|
||||
export const GlobalModal = () => {
|
||||
const { hideModal, modalState, modalRef, isVisible } = useGlobalModal();
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible && modalState.content) {
|
||||
modalRef.current?.present();
|
||||
}
|
||||
}, [isVisible, modalState.content, modalRef]);
|
||||
const { hideModal, modalState, modalRef } = useGlobalModal();
|
||||
|
||||
const handleSheetChanges = useCallback(
|
||||
(index: number) => {
|
||||
|
||||
@@ -6,19 +6,19 @@ import { Image } from "expo-image";
|
||||
import { useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { type Bitrate } from "@/components/BitrateSelector";
|
||||
import { ItemImage } from "@/components/common/ItemImage";
|
||||
import { DownloadSingleItem } from "@/components/DownloadItem";
|
||||
import { ItemPeopleSections } from "@/components/item/ItemPeopleSections";
|
||||
import { MediaSourceButton } from "@/components/MediaSourceButton";
|
||||
import { OverviewText } from "@/components/OverviewText";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
// const PlayButton = !Platform.isTV ? require("@/components/PlayButton") : null;
|
||||
import { PlayButton } from "@/components/PlayButton";
|
||||
import { PlayedStatus } from "@/components/PlayedStatus";
|
||||
import { SimilarItems } from "@/components/SimilarItems";
|
||||
import { CastAndCrew } from "@/components/series/CastAndCrew";
|
||||
import { CurrentSeries } from "@/components/series/CurrentSeries";
|
||||
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
|
||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||
@@ -29,10 +29,13 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import { AddToFavorites } from "./AddToFavorites";
|
||||
import { AddToWatchlist } from "./AddToWatchlist";
|
||||
import { BitrateSheet } from "./BitRateSheet";
|
||||
import { ItemHeader } from "./ItemHeader";
|
||||
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
||||
import { MediaSourceSheet } from "./MediaSourceSheet";
|
||||
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
||||
import { PlayInRemoteSessionButton } from "./PlayInRemoteSession";
|
||||
import { TrackSheet } from "./TrackSheet";
|
||||
|
||||
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
|
||||
|
||||
@@ -46,17 +49,17 @@ export type SelectedOptions = {
|
||||
interface ItemContentProps {
|
||||
item: BaseItemDto;
|
||||
isOffline: boolean;
|
||||
itemWithSources?: BaseItemDto | null;
|
||||
}
|
||||
|
||||
export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
({ item, isOffline, itemWithSources }) => {
|
||||
({ item, isOffline }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const { settings } = useSettings();
|
||||
const { orientation } = useOrientation();
|
||||
const navigation = useNavigation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [user] = useAtom(userAtom);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const itemColors = useImageColorsReturn({ item });
|
||||
|
||||
@@ -67,23 +70,18 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
SelectedOptions | undefined
|
||||
>(undefined);
|
||||
|
||||
// Use itemWithSources for play settings since it has MediaSources data
|
||||
const {
|
||||
defaultAudioIndex,
|
||||
defaultBitrate,
|
||||
defaultMediaSource,
|
||||
defaultSubtitleIndex,
|
||||
} = useDefaultPlaySettings(itemWithSources ?? item, settings);
|
||||
} = useDefaultPlaySettings(item!, settings);
|
||||
|
||||
const logoUrl = useMemo(
|
||||
() => (item ? getLogoImageUrlById({ api, item }) : null),
|
||||
[api, item],
|
||||
);
|
||||
|
||||
const onLogoLoad = React.useCallback(() => {
|
||||
setLoadingLogo(false);
|
||||
}, []);
|
||||
|
||||
const loading = useMemo(() => {
|
||||
return Boolean(logoUrl && loadingLogo);
|
||||
}, [loadingLogo, logoUrl]);
|
||||
@@ -92,7 +90,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
useEffect(() => {
|
||||
setSelectedOptions(() => ({
|
||||
bitrate: defaultBitrate,
|
||||
mediaSource: defaultMediaSource ?? undefined,
|
||||
mediaSource: defaultMediaSource,
|
||||
subtitleIndex: defaultSubtitleIndex ?? -1,
|
||||
audioIndex: defaultAudioIndex,
|
||||
}));
|
||||
@@ -104,7 +102,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!Platform.isTV && itemWithSources) {
|
||||
if (!Platform.isTV) {
|
||||
navigation.setOptions({
|
||||
headerRight: () =>
|
||||
item &&
|
||||
@@ -114,19 +112,14 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
{item.Type !== "Program" && (
|
||||
<View className='flex flex-row items-center'>
|
||||
{!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' />
|
||||
<AddToFavorites item={item} />
|
||||
{settings.streamyStatsServerUrl &&
|
||||
!settings.hideWatchlistsTab && (
|
||||
<AddToWatchlist item={item} />
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
@@ -136,34 +129,21 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
{item.Type !== "Program" && (
|
||||
<View className='flex flex-row items-center space-x-2'>
|
||||
{!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' />
|
||||
<AddToFavorites item={item} />
|
||||
{settings.streamyStatsServerUrl &&
|
||||
!settings.hideWatchlistsTab && (
|
||||
<AddToWatchlist item={item} />
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)),
|
||||
});
|
||||
}
|
||||
}, [
|
||||
item,
|
||||
navigation,
|
||||
user,
|
||||
itemWithSources,
|
||||
settings.hideRemoteSessionButton,
|
||||
settings.streamyStatsServerUrl,
|
||||
settings.hideWatchlistsTab,
|
||||
]);
|
||||
}, [item, navigation, user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (item) {
|
||||
@@ -185,7 +165,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
}}
|
||||
>
|
||||
<ParallaxScrollView
|
||||
className='flex-1'
|
||||
className={`flex-1 ${loading ? "opacity-0" : "opacity-100"}`}
|
||||
headerHeight={headerHeight}
|
||||
headerImage={
|
||||
<View style={[{ flex: 1 }]}>
|
||||
@@ -212,8 +192,8 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
width: "100%",
|
||||
}}
|
||||
contentFit='contain'
|
||||
onLoad={onLogoLoad}
|
||||
onError={onLogoLoad}
|
||||
onLoad={() => setLoadingLogo(false)}
|
||||
onError={() => setLoadingLogo(false)}
|
||||
/>
|
||||
) : (
|
||||
<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 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' />
|
||||
|
||||
<View className='flex flex-row px-0 mb-2 justify-between space-x-2'>
|
||||
<PlayButton
|
||||
selectedOptions={selectedOptions}
|
||||
item={item}
|
||||
isOffline={isOffline}
|
||||
colors={itemColors}
|
||||
/>
|
||||
<View className='w-1' />
|
||||
{!isOffline && (
|
||||
<MediaSourceButton
|
||||
selectedOptions={selectedOptions}
|
||||
setSelectedOptions={setSelectedOptions}
|
||||
item={itemWithSources}
|
||||
colors={itemColors}
|
||||
{item.Type !== "Program" && !Platform.isTV && !isOffline && (
|
||||
<View className='flex flex-row items-center justify-start w-full h-16 mb-2'>
|
||||
<BitrateSheet
|
||||
className='mr-1'
|
||||
onChange={(val) =>
|
||||
setSelectedOptions(
|
||||
(prev) => prev && { ...prev, bitrate: val },
|
||||
)
|
||||
}
|
||||
selected={selectedOptions.bitrate}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
<MediaSourceSheet
|
||||
className='mr-1'
|
||||
item={item}
|
||||
onChange={(val) =>
|
||||
setSelectedOptions(
|
||||
(prev) =>
|
||||
prev && {
|
||||
...prev,
|
||||
mediaSource: val,
|
||||
},
|
||||
)
|
||||
}
|
||||
selected={selectedOptions.mediaSource}
|
||||
/>
|
||||
<TrackSheet
|
||||
className='mr-1'
|
||||
streamType='Audio'
|
||||
title={t("item_card.audio")}
|
||||
source={selectedOptions.mediaSource}
|
||||
onChange={(val) => {
|
||||
setSelectedOptions(
|
||||
(prev) =>
|
||||
prev && {
|
||||
...prev,
|
||||
audioIndex: val,
|
||||
},
|
||||
);
|
||||
}}
|
||||
selected={selectedOptions.audioIndex}
|
||||
/>
|
||||
<TrackSheet
|
||||
source={selectedOptions.mediaSource}
|
||||
streamType='Subtitle'
|
||||
title={t("item_card.subtitles")}
|
||||
onChange={(val) =>
|
||||
setSelectedOptions(
|
||||
(prev) =>
|
||||
prev && {
|
||||
...prev,
|
||||
subtitleIndex: val,
|
||||
},
|
||||
)
|
||||
}
|
||||
selected={selectedOptions.subtitleIndex}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<PlayButton
|
||||
className='grow'
|
||||
selectedOptions={selectedOptions}
|
||||
item={item}
|
||||
isOffline={isOffline}
|
||||
colors={itemColors}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{item.Type === "Episode" && (
|
||||
<SeasonEpisodesCarousel
|
||||
item={item}
|
||||
@@ -250,21 +279,33 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isOffline &&
|
||||
selectedOptions.mediaSource?.MediaStreams &&
|
||||
selectedOptions.mediaSource.MediaStreams.length > 0 && (
|
||||
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
|
||||
)}
|
||||
|
||||
{!isOffline && (
|
||||
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
|
||||
)}
|
||||
<OverviewText text={item.Overview} className='px-4 mb-4' />
|
||||
|
||||
{item.Type !== "Program" && (
|
||||
<>
|
||||
{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} />}
|
||||
</>
|
||||
|
||||
@@ -183,12 +183,6 @@ const VideoStreamInfo = ({ source }: { source?: MediaSourceInfo }) => {
|
||||
|
||||
if (!source || !videoStream) return null;
|
||||
|
||||
// Dolby Vision video check
|
||||
const isDolbyVision =
|
||||
videoStream.VideoRangeType === "DOVI" ||
|
||||
videoStream.DvVersionMajor != null ||
|
||||
videoStream.DvVersionMinor != null;
|
||||
|
||||
return (
|
||||
<View className='flex-row flex-wrap gap-2'>
|
||||
<Badge
|
||||
@@ -201,15 +195,6 @@ const VideoStreamInfo = ({ source }: { source?: MediaSourceInfo }) => {
|
||||
iconLeft={<Ionicons name='film-outline' size={16} color='white' />}
|
||||
text={`${videoStream.Width}x${videoStream.Height}`}
|
||||
/>
|
||||
{isDolbyVision && (
|
||||
<Badge
|
||||
variant='gray'
|
||||
iconLeft={
|
||||
<Ionicons name='sparkles-outline' size={16} color='white' />
|
||||
}
|
||||
text={"DV"}
|
||||
/>
|
||||
)}
|
||||
<Badge
|
||||
variant='gray'
|
||||
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 { useAtom } from "jotai";
|
||||
import type React from "react";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { HorizontalScroll } from "@/components/common/HorizontalScroll";
|
||||
@@ -11,18 +10,16 @@ import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import { POSTER_CAROUSEL_HEIGHT } from "@/constants/Values";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
actorId: string;
|
||||
actorName?: string | null;
|
||||
currentItem: BaseItemDto;
|
||||
}
|
||||
|
||||
export const MoreMoviesWithActor: React.FC<Props> = ({
|
||||
actorId,
|
||||
actorName,
|
||||
currentItem,
|
||||
...props
|
||||
}) => {
|
||||
@@ -30,6 +27,19 @@ export const MoreMoviesWithActor: React.FC<Props> = ({
|
||||
const [user] = useAtom(userAtom);
|
||||
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({
|
||||
queryKey: ["actor", "movies", actorId, currentItem.Id],
|
||||
queryFn: async () => {
|
||||
@@ -62,34 +72,29 @@ export const MoreMoviesWithActor: React.FC<Props> = ({
|
||||
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;
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<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>
|
||||
<HorizontalScroll
|
||||
data={items}
|
||||
loading={isLoading}
|
||||
height={POSTER_CAROUSEL_HEIGHT}
|
||||
renderItem={renderItem}
|
||||
height={247}
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -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`}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
@@ -71,7 +73,9 @@ const OptionItem: React.FC<{ option: Option; isLast?: boolean }> = ({
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
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>
|
||||
{isToggle ? (
|
||||
@@ -180,7 +184,7 @@ const PlatformDropdownComponent = ({
|
||||
expoUIConfig,
|
||||
bottomSheetConfig,
|
||||
}: PlatformDropdownProps) => {
|
||||
const { showModal, hideModal, isVisible } = useGlobalModal();
|
||||
const { showModal, hideModal } = useGlobalModal();
|
||||
|
||||
// Handle controlled open state for Android
|
||||
useEffect(() => {
|
||||
@@ -203,19 +207,15 @@ const PlatformDropdownComponent = ({
|
||||
}
|
||||
}, [controlledOpen]);
|
||||
|
||||
// Watch for modal dismissal on Android (e.g., swipe down, backdrop tap)
|
||||
// and sync the controlled open state
|
||||
useEffect(() => {
|
||||
if (Platform.OS === "android" && controlledOpen === true && !isVisible) {
|
||||
controlledOnOpenChange?.(false);
|
||||
}
|
||||
}, [isVisible, controlledOpen, controlledOnOpenChange]);
|
||||
|
||||
if (Platform.OS === "ios") {
|
||||
return (
|
||||
<Host style={expoUIConfig?.hostStyle}>
|
||||
<ContextMenu>
|
||||
<ContextMenu.Trigger>{trigger}</ContextMenu.Trigger>
|
||||
<ContextMenu.Trigger>
|
||||
<View className=''>
|
||||
{trigger || <Button variant='bordered'>Show Menu</Button>}
|
||||
</View>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Items>
|
||||
{groups.flatMap((group, groupIndex) => {
|
||||
// Check if this group has radio options
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||
import { BottomSheetView } from "@gorhom/bottom-sheet";
|
||||
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, Platform, TouchableOpacity, View } from "react-native";
|
||||
import { Alert, TouchableOpacity, View } from "react-native";
|
||||
import CastContext, {
|
||||
CastButton,
|
||||
PlayServicesState,
|
||||
@@ -25,8 +24,6 @@ import Animated, {
|
||||
} from "react-native-reanimated";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
||||
import { getDownloadedItemById } from "@/providers/Downloads/database";
|
||||
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
@@ -36,8 +33,6 @@ import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { chromecast } from "@/utils/profiles/chromecast";
|
||||
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
|
||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||
import { Button } from "./Button";
|
||||
import { Text } from "./common/Text";
|
||||
import type { SelectedOptions } from "./ItemContent";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof TouchableOpacity> {
|
||||
@@ -60,7 +55,6 @@ export const PlayButton: React.FC<Props> = ({
|
||||
const client = useRemoteMediaClient();
|
||||
const mediaStatus = useMediaStatus();
|
||||
const { t } = useTranslation();
|
||||
const { showModal, hideModal } = useGlobalModal();
|
||||
|
||||
const [globalColorAtom] = useAtom(itemThemeColorAtom);
|
||||
const api = useAtomValue(apiAtom);
|
||||
@@ -90,9 +84,12 @@ export const PlayButton: React.FC<Props> = ({
|
||||
[router, isOffline],
|
||||
);
|
||||
|
||||
const handleNormalPlayFlow = useCallback(async () => {
|
||||
const onPress = useCallback(async () => {
|
||||
console.log("onPress");
|
||||
if (!item) return;
|
||||
|
||||
lightHapticFeedback();
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: item.Id!,
|
||||
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
|
||||
@@ -274,117 +271,6 @@ export const PlayButton: React.FC<Props> = ({
|
||||
showActionSheetWithOptions,
|
||||
mediaStatus,
|
||||
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(() => {
|
||||
@@ -472,6 +358,55 @@ export const PlayButton: React.FC<Props> = ({
|
||||
[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 (
|
||||
<TouchableOpacity
|
||||
@@ -479,7 +414,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
accessibilityLabel='Play button'
|
||||
accessibilityHint='Tap to play the media'
|
||||
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'>
|
||||
<Animated.View
|
||||
@@ -522,6 +457,15 @@ export const PlayButton: React.FC<Props> = ({
|
||||
<CastButton tintColor='transparent' />
|
||||
</Animated.Text>
|
||||
)}
|
||||
{!client && settings?.openInVLC && (
|
||||
<Animated.Text style={animatedTextStyle}>
|
||||
<MaterialCommunityIcons
|
||||
name='vlc'
|
||||
size={18}
|
||||
color={animatedTextStyle.color}
|
||||
/>
|
||||
</Animated.Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</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 { useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
@@ -17,6 +17,7 @@ import Animated, {
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||
import type { Button } from "./Button";
|
||||
import type { SelectedOptions } from "./ItemContent";
|
||||
@@ -49,6 +50,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
const startColor = useSharedValue(effectiveColors);
|
||||
const widthProgress = useSharedValue(0);
|
||||
const colorChangeProgress = useSharedValue(0);
|
||||
const { settings } = useSettings();
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
|
||||
const goToPlayer = useCallback(
|
||||
@@ -59,6 +61,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
);
|
||||
|
||||
const onPress = () => {
|
||||
console.log("onpress");
|
||||
if (!item) return;
|
||||
|
||||
lightHapticFeedback();
|
||||
@@ -204,6 +207,15 @@ export const PlayButton: React.FC<Props> = ({
|
||||
<Animated.Text style={animatedTextStyle}>
|
||||
<Ionicons name='play-circle' size={24} />
|
||||
</Animated.Text>
|
||||
{settings?.openInVLC && (
|
||||
<Animated.Text style={animatedTextStyle}>
|
||||
<MaterialCommunityIcons
|
||||
name='vlc'
|
||||
size={18}
|
||||
color={animatedTextStyle.color}
|
||||
/>
|
||||
</Animated.Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</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 React from "react";
|
||||
import { useCallback } from "react";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||
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 toggle = useMarkAsPlayed(items);
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
void toggle(!allPlayed);
|
||||
}, [allPlayed, toggle]);
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<RoundButton
|
||||
color={allPlayed ? "purple" : "white"}
|
||||
icon={allPlayed ? "checkmark" : "checkmark"}
|
||||
onPress={handlePress}
|
||||
onPress={async () => {
|
||||
await toggle(!allPlayed);
|
||||
}}
|
||||
size={props.size}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -104,7 +104,7 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
|
||||
<Ionicons
|
||||
name={icon}
|
||||
size={size === "large" ? 22 : 18}
|
||||
color={color === "white" ? "white" : "#9334E9"}
|
||||
color={"white"}
|
||||
/>
|
||||
) : null}
|
||||
{children ? children : null}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import { POSTER_CAROUSEL_HEIGHT } from "@/constants/Values";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { HorizontalScroll } from "./common/HorizontalScroll";
|
||||
import { Text } from "./common/Text";
|
||||
@@ -54,7 +53,7 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({
|
||||
<HorizontalScroll
|
||||
data={movies}
|
||||
loading={isLoading}
|
||||
height={POSTER_CAROUSEL_HEIGHT}
|
||||
height={247}
|
||||
noItemsText={t("item_card.no_similar_items_found")}
|
||||
renderItem={(item: BaseItemDto, idx: number) => (
|
||||
<TouchableItemRouter
|
||||
|
||||
@@ -282,7 +282,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
||||
if (currentItem) {
|
||||
setSelectedOptions({
|
||||
bitrate: defaultBitrate,
|
||||
mediaSource: defaultMediaSource ?? undefined,
|
||||
mediaSource: defaultMediaSource,
|
||||
subtitleIndex: defaultSubtitleIndex ?? -1,
|
||||
audioIndex: defaultAudioIndex,
|
||||
});
|
||||
|
||||
@@ -58,7 +58,7 @@ export const HorizontalScroll = <T,>(
|
||||
|
||||
if (!data || loading) {
|
||||
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-10 w-full rounded-md mb-1' />
|
||||
</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`;
|
||||
}
|
||||
|
||||
if ("CollectionType" in item && item.CollectionType === "music") {
|
||||
return `/(auth)/(tabs)/(libraries)/music/${item.Id}`;
|
||||
}
|
||||
|
||||
if (item.Type === "Series") {
|
||||
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") {
|
||||
return {
|
||||
pathname: "/series/[id]" as const,
|
||||
@@ -110,25 +99,6 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
|
||||
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(() => {
|
||||
if (
|
||||
!(
|
||||
@@ -138,14 +108,13 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
const options: string[] = [
|
||||
const options = [
|
||||
"Mark as Played",
|
||||
"Mark as Not Played",
|
||||
isFavorite ? "Unmark as Favorite" : "Mark as Favorite",
|
||||
"Cancel",
|
||||
];
|
||||
const cancelButtonIndex = options.length - 1;
|
||||
const cancelButtonIndex = 3;
|
||||
|
||||
showActionSheetWithOptions(
|
||||
{
|
||||
@@ -162,24 +131,28 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
}
|
||||
},
|
||||
);
|
||||
}, [
|
||||
showActionSheetWithOptions,
|
||||
isFavorite,
|
||||
markAsPlayedStatus,
|
||||
toggleFavorite,
|
||||
]);
|
||||
}, [showActionSheetWithOptions, isFavorite, markAsPlayedStatus]);
|
||||
|
||||
if (
|
||||
from === "(home)" ||
|
||||
from === "(search)" ||
|
||||
from === "(libraries)" ||
|
||||
from === "(favorites)" ||
|
||||
from === "(watchlists)"
|
||||
from === "(favorites)"
|
||||
)
|
||||
return (
|
||||
<TouchableOpacity
|
||||
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}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Ionicons } from "@expo/vector-icons";
|
||||
import { useAtom } from "jotai";
|
||||
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
|
||||
import {
|
||||
filterByAtom,
|
||||
genreFilterAtom,
|
||||
tagsFilterAtom,
|
||||
yearFilterAtom,
|
||||
@@ -14,13 +13,11 @@ export const ResetFiltersButton: React.FC<Props> = ({ ...props }) => {
|
||||
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
|
||||
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
|
||||
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
||||
const [selectedFilters, setSelectedFilters] = useAtom(filterByAtom);
|
||||
|
||||
if (
|
||||
selectedGenres.length === 0 &&
|
||||
selectedTags.length === 0 &&
|
||||
selectedYears.length === 0 &&
|
||||
selectedFilters.length === 0
|
||||
selectedYears.length === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
@@ -31,7 +28,6 @@ export const ResetFiltersButton: React.FC<Props> = ({ ...props }) => {
|
||||
setSelectedGenres([]);
|
||||
setSelectedTags([]);
|
||||
setSelectedYears([]);
|
||||
setSelectedFilters([]);
|
||||
}}
|
||||
className='bg-purple-600 rounded-full w-[30px] h-[30px] flex items-center justify-center mr-1'
|
||||
{...props}
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { Api } from "@jellyfin/sdk";
|
||||
import type { BaseItemKind } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { Image } from "expo-image";
|
||||
import { useRouter } from "expo-router";
|
||||
import { t } from "i18next";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
@@ -23,10 +22,8 @@ type FavoriteTypes =
|
||||
type EmptyState = Record<FavoriteTypes, boolean>;
|
||||
|
||||
export const Favorites = () => {
|
||||
const router = useRouter();
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const pageSize = 20;
|
||||
const [emptyState, setEmptyState] = useState<EmptyState>({
|
||||
Series: false,
|
||||
Movie: false,
|
||||
@@ -94,77 +91,35 @@ export const Favorites = () => {
|
||||
|
||||
const fetchFavoriteSeries = useCallback(
|
||||
({ pageParam }: { pageParam: number }) =>
|
||||
fetchFavoritesByType("Series", pageParam, pageSize),
|
||||
[fetchFavoritesByType, pageSize],
|
||||
fetchFavoritesByType("Series", pageParam),
|
||||
[fetchFavoritesByType],
|
||||
);
|
||||
const fetchFavoriteMovies = useCallback(
|
||||
({ pageParam }: { pageParam: number }) =>
|
||||
fetchFavoritesByType("Movie", pageParam, pageSize),
|
||||
[fetchFavoritesByType, pageSize],
|
||||
fetchFavoritesByType("Movie", pageParam),
|
||||
[fetchFavoritesByType],
|
||||
);
|
||||
const fetchFavoriteEpisodes = useCallback(
|
||||
({ pageParam }: { pageParam: number }) =>
|
||||
fetchFavoritesByType("Episode", pageParam, pageSize),
|
||||
[fetchFavoritesByType, pageSize],
|
||||
fetchFavoritesByType("Episode", pageParam),
|
||||
[fetchFavoritesByType],
|
||||
);
|
||||
const fetchFavoriteVideos = useCallback(
|
||||
({ pageParam }: { pageParam: number }) =>
|
||||
fetchFavoritesByType("Video", pageParam, pageSize),
|
||||
[fetchFavoritesByType, pageSize],
|
||||
fetchFavoritesByType("Video", pageParam),
|
||||
[fetchFavoritesByType],
|
||||
);
|
||||
const fetchFavoriteBoxsets = useCallback(
|
||||
({ pageParam }: { pageParam: number }) =>
|
||||
fetchFavoritesByType("BoxSet", pageParam, pageSize),
|
||||
[fetchFavoritesByType, pageSize],
|
||||
fetchFavoritesByType("BoxSet", pageParam),
|
||||
[fetchFavoritesByType],
|
||||
);
|
||||
const fetchFavoritePlaylists = useCallback(
|
||||
({ pageParam }: { pageParam: number }) =>
|
||||
fetchFavoritesByType("Playlist", pageParam, pageSize),
|
||||
[fetchFavoritesByType, pageSize],
|
||||
fetchFavoritesByType("Playlist", pageParam),
|
||||
[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 (
|
||||
<View className='flex flex-co gap-y-4'>
|
||||
{areAllEmpty() && (
|
||||
@@ -188,8 +143,6 @@ export const Favorites = () => {
|
||||
queryKey={["home", "favorites", "series"]}
|
||||
title={t("favorites.series")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
onPressSeeAll={handleSeeAllSeries}
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteMovies}
|
||||
@@ -197,40 +150,30 @@ export const Favorites = () => {
|
||||
title={t("favorites.movies")}
|
||||
hideIfEmpty
|
||||
orientation='vertical'
|
||||
pageSize={pageSize}
|
||||
onPressSeeAll={handleSeeAllMovies}
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteEpisodes}
|
||||
queryKey={["home", "favorites", "episodes"]}
|
||||
title={t("favorites.episodes")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
onPressSeeAll={handleSeeAllEpisodes}
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteVideos}
|
||||
queryKey={["home", "favorites", "videos"]}
|
||||
title={t("favorites.videos")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
onPressSeeAll={handleSeeAllVideos}
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteBoxsets}
|
||||
queryKey={["home", "favorites", "boxsets"]}
|
||||
title={t("favorites.boxsets")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
onPressSeeAll={handleSeeAllBoxsets}
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoritePlaylists}
|
||||
queryKey={["home", "favorites", "playlists"]}
|
||||
title={t("favorites.playlists")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
onPressSeeAll={handleSeeAllPlaylists}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -28,8 +28,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList";
|
||||
import { StreamystatsPromotedWatchlists } from "@/components/home/StreamystatsPromotedWatchlists";
|
||||
import { StreamystatsRecommendations } from "@/components/home/StreamystatsRecommendations";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
@@ -47,14 +45,12 @@ type InfiniteScrollingCollectionListSection = {
|
||||
queryFn: QueryFunction<BaseItemDto[], any, number>;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
pageSize?: number;
|
||||
priority?: 1 | 2; // 1 = high priority (loads first), 2 = low priority
|
||||
};
|
||||
|
||||
type MediaListSectionType = {
|
||||
type: "MediaListSection";
|
||||
queryKey: (string | undefined)[];
|
||||
queryFn: QueryFunction<BaseItemDto>;
|
||||
priority?: 1 | 2;
|
||||
};
|
||||
|
||||
type Section = InfiniteScrollingCollectionListSection | MediaListSectionType;
|
||||
@@ -78,7 +74,7 @@ export const Home = () => {
|
||||
retryCheck,
|
||||
} = useNetworkStatus();
|
||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||
const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set());
|
||||
const [scrollY, setScrollY] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (isConnected && !prevIsConnected.current) {
|
||||
@@ -176,7 +172,6 @@ export const Home = () => {
|
||||
|
||||
const refetch = async () => {
|
||||
setLoading(true);
|
||||
setLoadedSections(new Set());
|
||||
await refreshStreamyfinPluginSettings();
|
||||
await invalidateCache();
|
||||
setLoading(false);
|
||||
@@ -199,10 +194,10 @@ export const Home = () => {
|
||||
(
|
||||
await getUserLibraryApi(api).getLatestMedia({
|
||||
userId: user?.Id,
|
||||
limit: 10,
|
||||
fields: ["PrimaryImageAspectRatio"],
|
||||
limit: 100, // Fetch a larger set for pagination
|
||||
fields: ["PrimaryImageAspectRatio", "Path", "Genres"],
|
||||
imageTypeLimit: 1,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||
includeItemTypes,
|
||||
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[] = [
|
||||
...firstSections,
|
||||
...latestMediaViews.map((s) => ({ ...s, priority: 2 as const })),
|
||||
// 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 }: { pageParam?: number }) =>
|
||||
(
|
||||
await getSuggestionsApi(api).getSuggestions({
|
||||
userId: user?.Id,
|
||||
startIndex: pageParam,
|
||||
limit: 10,
|
||||
mediaType: ["Video"],
|
||||
type: ["Movie"],
|
||||
})
|
||||
).data.Items || [],
|
||||
type: "InfiniteScrollingCollectionList" as const,
|
||||
orientation: "vertical" as const,
|
||||
pageSize: 10,
|
||||
priority: 2 as const,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
title: t("home.continue_watching"),
|
||||
queryKey: ["home", "resumeItems"],
|
||||
queryFn: async ({ pageParam = 0 }) =>
|
||||
(
|
||||
await getItemsApi(api).getResumeItems({
|
||||
userId: user.Id,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||
fields: ["Genres"],
|
||||
startIndex: pageParam,
|
||||
limit: 10,
|
||||
})
|
||||
).data.Items || [],
|
||||
type: "InfiniteScrollingCollectionList",
|
||||
orientation: "horizontal",
|
||||
pageSize: 10,
|
||||
},
|
||||
{
|
||||
title: t("home.next_up"),
|
||||
queryKey: ["home", "nextUp-all"],
|
||||
queryFn: async ({ pageParam = 0 }) =>
|
||||
(
|
||||
await getTvShowsApi(api).getNextUp({
|
||||
userId: user?.Id,
|
||||
fields: ["MediaSourceCount", "Genres"],
|
||||
startIndex: pageParam,
|
||||
limit: 10,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||
enableResumable: false,
|
||||
})
|
||||
).data.Items || [],
|
||||
type: "InfiniteScrollingCollectionList",
|
||||
orientation: "horizontal",
|
||||
pageSize: 10,
|
||||
},
|
||||
...latestMediaViews,
|
||||
{
|
||||
title: t("home.suggested_movies"),
|
||||
queryKey: ["home", "suggestedMovies", user?.Id],
|
||||
queryFn: async ({ pageParam = 0 }) =>
|
||||
(
|
||||
await getSuggestionsApi(api).getSuggestions({
|
||||
userId: user?.Id,
|
||||
startIndex: pageParam,
|
||||
limit: 10,
|
||||
mediaType: ["Video"],
|
||||
type: ["Movie"],
|
||||
})
|
||||
).data.Items || [],
|
||||
type: "InfiniteScrollingCollectionList",
|
||||
orientation: "vertical",
|
||||
pageSize: 10,
|
||||
},
|
||||
];
|
||||
return ss;
|
||||
}, [
|
||||
api,
|
||||
user?.Id,
|
||||
collections,
|
||||
t,
|
||||
createCollectionConfig,
|
||||
settings?.streamyStatsMovieRecommendations,
|
||||
settings.mergeNextUpAndContinueWatching,
|
||||
]);
|
||||
}, [api, user?.Id, collections, t, createCollectionConfig]);
|
||||
|
||||
const customSections = useMemo(() => {
|
||||
if (!api || !user?.Id || !settings?.home?.sections) return [];
|
||||
@@ -406,9 +322,10 @@ export const Home = () => {
|
||||
if (section.nextUp) {
|
||||
const response = await getTvShowsApi(api).getNextUp({
|
||||
userId: user?.Id,
|
||||
fields: ["MediaSourceCount", "Genres"],
|
||||
startIndex: pageParam,
|
||||
limit: section.nextUp?.limit || pageSize,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||
enableResumable: section.nextUp?.enableResumable,
|
||||
enableRewatching: section.nextUp?.enableRewatching,
|
||||
});
|
||||
@@ -421,7 +338,7 @@ export const Home = () => {
|
||||
await getUserLibraryApi(api).getLatestMedia({
|
||||
userId: user?.Id,
|
||||
includeItemTypes: section.latest?.includeItemTypes,
|
||||
limit: section.latest?.limit || 10,
|
||||
limit: section.latest?.limit || 100, // Fetch larger set
|
||||
isPlayed: section.latest?.isPlayed,
|
||||
groupItems: section.latest?.groupItems,
|
||||
})
|
||||
@@ -450,8 +367,6 @@ export const Home = () => {
|
||||
type: "InfiniteScrollingCollectionList",
|
||||
orientation: section?.orientation || "vertical",
|
||||
pageSize,
|
||||
// First 2 custom sections are high priority
|
||||
priority: index < 2 ? 1 : 2,
|
||||
});
|
||||
});
|
||||
return ss;
|
||||
@@ -459,25 +374,6 @@ export const Home = () => {
|
||||
|
||||
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) {
|
||||
let title = "";
|
||||
let subtitle = "";
|
||||
@@ -555,6 +451,10 @@ export const Home = () => {
|
||||
ref={scrollRef}
|
||||
nestedScrollEnabled
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
onScroll={(event) => {
|
||||
setScrollY(event.nativeEvent.contentOffset.y - 500);
|
||||
}}
|
||||
scrollEventThrottle={16}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={loading}
|
||||
@@ -574,77 +474,28 @@ export const Home = () => {
|
||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||
>
|
||||
{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") {
|
||||
const isHighPriority = section.priority === 1;
|
||||
return (
|
||||
<View key={index} className='flex flex-col space-y-4'>
|
||||
<InfiniteScrollingCollectionList
|
||||
title={section.title}
|
||||
queryKey={section.queryKey}
|
||||
queryFn={section.queryFn}
|
||||
orientation={section.orientation}
|
||||
hideIfEmpty
|
||||
pageSize={section.pageSize}
|
||||
enabled={isHighPriority || allHighPriorityLoaded}
|
||||
onLoaded={
|
||||
isHighPriority
|
||||
? () => markSectionLoaded(section.queryKey)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{streamystatsSections}
|
||||
</View>
|
||||
<InfiniteScrollingCollectionList
|
||||
key={index}
|
||||
title={section.title}
|
||||
queryKey={section.queryKey}
|
||||
queryFn={section.queryFn}
|
||||
orientation={section.orientation}
|
||||
hideIfEmpty
|
||||
pageSize={section.pageSize}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (section.type === "MediaListSection") {
|
||||
return (
|
||||
<View key={index} className='flex flex-col space-y-4'>
|
||||
<MediaListSection
|
||||
queryKey={section.queryKey}
|
||||
queryFn={section.queryFn}
|
||||
/>
|
||||
{streamystatsSections}
|
||||
</View>
|
||||
<MediaListSection
|
||||
key={index}
|
||||
queryKey={section.queryKey}
|
||||
queryFn={section.queryFn}
|
||||
scrollY={scrollY}
|
||||
enableLazyLoading={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -30,8 +30,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList";
|
||||
import { StreamystatsPromotedWatchlists } from "@/components/home/StreamystatsPromotedWatchlists";
|
||||
import { StreamystatsRecommendations } from "@/components/home/StreamystatsRecommendations";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
||||
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[] = [
|
||||
...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,
|
||||
// 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 }: { pageParam?: number }) =>
|
||||
(
|
||||
await getSuggestionsApi(api).getSuggestions({
|
||||
userId: user?.Id,
|
||||
startIndex: pageParam,
|
||||
limit: 10,
|
||||
mediaType: ["Video"],
|
||||
type: ["Movie"],
|
||||
})
|
||||
).data.Items || [],
|
||||
type: "InfiniteScrollingCollectionList" as const,
|
||||
orientation: "vertical" as const,
|
||||
pageSize: 10,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
title: t("home.suggested_movies"),
|
||||
queryKey: ["home", "suggestedMovies", user?.Id],
|
||||
queryFn: async ({ pageParam = 0 }) =>
|
||||
(
|
||||
await getSuggestionsApi(api).getSuggestions({
|
||||
userId: user?.Id,
|
||||
startIndex: pageParam,
|
||||
limit: 10,
|
||||
mediaType: ["Video"],
|
||||
type: ["Movie"],
|
||||
})
|
||||
).data.Items || [],
|
||||
type: "InfiniteScrollingCollectionList",
|
||||
orientation: "vertical",
|
||||
pageSize: 10,
|
||||
},
|
||||
];
|
||||
return ss;
|
||||
}, [
|
||||
api,
|
||||
user?.Id,
|
||||
collections,
|
||||
t,
|
||||
createCollectionConfig,
|
||||
settings?.streamyStatsMovieRecommendations,
|
||||
settings.mergeNextUpAndContinueWatching,
|
||||
]);
|
||||
}, [api, user?.Id, collections, t, createCollectionConfig]);
|
||||
|
||||
const customSections = useMemo(() => {
|
||||
if (!api || !user?.Id || !settings?.home?.sections) return [];
|
||||
@@ -558,66 +477,28 @@ export const HomeWithCarousel = () => {
|
||||
>
|
||||
<View className='flex flex-col space-y-4'>
|
||||
{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") {
|
||||
return (
|
||||
<View key={index} className='flex flex-col space-y-4'>
|
||||
<InfiniteScrollingCollectionList
|
||||
title={section.title}
|
||||
queryKey={section.queryKey}
|
||||
queryFn={section.queryFn}
|
||||
orientation={section.orientation}
|
||||
hideIfEmpty
|
||||
pageSize={section.pageSize}
|
||||
/>
|
||||
{streamystatsSections}
|
||||
</View>
|
||||
<InfiniteScrollingCollectionList
|
||||
key={index}
|
||||
title={section.title}
|
||||
queryKey={section.queryKey}
|
||||
queryFn={section.queryFn}
|
||||
orientation={section.orientation}
|
||||
hideIfEmpty
|
||||
pageSize={section.pageSize}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (section.type === "MediaListSection") {
|
||||
return (
|
||||
<View key={index} className='flex flex-col space-y-4'>
|
||||
<MediaListSection
|
||||
queryKey={section.queryKey}
|
||||
queryFn={section.queryFn}
|
||||
scrollY={scrollY}
|
||||
enableLazyLoading={true}
|
||||
/>
|
||||
{streamystatsSections}
|
||||
</View>
|
||||
<MediaListSection
|
||||
key={index}
|
||||
queryKey={section.queryKey}
|
||||
queryFn={section.queryFn}
|
||||
scrollY={scrollY}
|
||||
enableLazyLoading={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
type QueryKey,
|
||||
useInfiniteQuery,
|
||||
} from "@tanstack/react-query";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
@@ -12,7 +11,6 @@ import {
|
||||
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 { Colors } from "../../constants/Colors";
|
||||
@@ -29,9 +27,6 @@ interface Props extends ViewProps {
|
||||
queryFn: QueryFunction<BaseItemDto[], QueryKey, number>;
|
||||
hideIfEmpty?: boolean;
|
||||
pageSize?: number;
|
||||
onPressSeeAll?: () => void;
|
||||
enabled?: boolean;
|
||||
onLoaded?: () => void;
|
||||
}
|
||||
|
||||
export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
@@ -42,72 +37,32 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
queryKey,
|
||||
hideIfEmpty = false,
|
||||
pageSize = 10,
|
||||
onPressSeeAll,
|
||||
enabled = true,
|
||||
onLoaded,
|
||||
...props
|
||||
}) => {
|
||||
const effectivePageSize = Math.max(1, pageSize);
|
||||
const hasCalledOnLoaded = useRef(false);
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
isSuccess,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: queryKey,
|
||||
queryFn: ({ pageParam = 0, ...context }) =>
|
||||
queryFn({ ...context, queryKey, pageParam }),
|
||||
getNextPageParam: (lastPage, allPages) => {
|
||||
// If the last page has fewer items than pageSize, we've reached the end
|
||||
if (lastPage.length < effectivePageSize) {
|
||||
return undefined;
|
||||
}
|
||||
// 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 { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
|
||||
useInfiniteQuery({
|
||||
queryKey: queryKey,
|
||||
queryFn: ({ pageParam = 0, ...context }) =>
|
||||
queryFn({ ...context, queryKey, pageParam }),
|
||||
getNextPageParam: (lastPage, allPages) => {
|
||||
// If the last page has fewer items than pageSize, we've reached the end
|
||||
if (lastPage.length < pageSize) {
|
||||
return undefined;
|
||||
}
|
||||
// Otherwise, return the next start index
|
||||
return allPages.length * pageSize;
|
||||
},
|
||||
initialPageParam: 0,
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: true,
|
||||
});
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Flatten all pages into a single array (and de-dupe by Id to avoid UI duplicates)
|
||||
const allItems = useMemo(() => {
|
||||
const items = data?.pages.flat() ?? [];
|
||||
const seen = new Set<string>();
|
||||
const deduped: BaseItemDto[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
const id = item.Id;
|
||||
if (!id) continue;
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
deduped.push(item);
|
||||
}
|
||||
|
||||
return deduped;
|
||||
}, [data]);
|
||||
|
||||
const snapOffsets = useMemo(() => {
|
||||
const itemWidth = orientation === "horizontal" ? 184 : 120; // w-44 (176px) + mr-2 (8px) or w-28 (112px) + mr-2 (8px)
|
||||
return allItems.map((_, index) => index * itemWidth);
|
||||
}, [allItems, orientation]);
|
||||
// Flatten all pages into a single array
|
||||
const allItems = data?.pages.flat() || [];
|
||||
|
||||
if (hideIfEmpty === true && allItems.length === 0 && !isLoading) return null;
|
||||
if (disabled || !title) return null;
|
||||
@@ -129,12 +84,9 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<SectionHeader
|
||||
title={title}
|
||||
actionLabel={t("common.seeAll", { defaultValue: "See all" })}
|
||||
actionDisabled={isLoading}
|
||||
onPressAction={onPressSeeAll}
|
||||
/>
|
||||
<Text className='px-4 text-lg font-bold mb-2 text-neutral-100'>
|
||||
{title}
|
||||
</Text>
|
||||
{isLoading === false && allItems.length === 0 && (
|
||||
<View className='px-4'>
|
||||
<Text className='text-neutral-500'>{t("home.no_items")}</Text>
|
||||
@@ -174,15 +126,13 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
showsHorizontalScrollIndicator={false}
|
||||
onScroll={handleScroll}
|
||||
scrollEventThrottle={16}
|
||||
snapToOffsets={snapOffsets}
|
||||
decelerationRate='fast'
|
||||
>
|
||||
<View className='px-4 flex flex-row'>
|
||||
{allItems.map((item, index) => (
|
||||
{allItems.map((item) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={`${item.Id}-${index}`}
|
||||
className={`mr-2
|
||||
key={item.Id}
|
||||
className={`mr-2
|
||||
${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 { Text } from "@/components/common/Text";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
import { GridSkeleton } from "./GridSkeleton";
|
||||
|
||||
const ANIMATION_ENTER = 250;
|
||||
const ANIMATION_EXIT = 250;
|
||||
@@ -29,7 +28,6 @@ interface Props<T> {
|
||||
renderItem: (item: T, index: number) => Render;
|
||||
keyExtractor: (item: T) => string;
|
||||
onEndReached?: (() => void) | null | undefined;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const ParallaxSlideShow = <T,>({
|
||||
@@ -42,7 +40,6 @@ const ParallaxSlideShow = <T,>({
|
||||
renderItem,
|
||||
keyExtractor,
|
||||
onEndReached,
|
||||
isLoading = false,
|
||||
}: PropsWithChildren<Props<T> & ViewProps>) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
@@ -127,40 +124,27 @@ const ParallaxSlideShow = <T,>({
|
||||
</View>
|
||||
{MainContent?.()}
|
||||
<View>
|
||||
{isLoading ? (
|
||||
<View>
|
||||
<Text className='text-lg font-bold my-2'>{listHeader}</Text>
|
||||
<View className='px-4'>
|
||||
<View className='flex flex-row flex-wrap'>
|
||||
{Array.from({ length: 9 }, (_, i) => (
|
||||
<GridSkeleton key={i} index={i} />
|
||||
))}
|
||||
</View>
|
||||
<FlashList
|
||||
data={data}
|
||||
ListEmptyComponent={
|
||||
<View className='flex flex-col items-center justify-center h-full'>
|
||||
<Text className='font-bold text-xl text-neutral-500'>
|
||||
No results
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<FlashList
|
||||
data={data}
|
||||
ListEmptyComponent={
|
||||
<View className='flex flex-col items-center justify-center h-full'>
|
||||
<Text className='font-bold text-xl text-neutral-500'>
|
||||
No results
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
ListHeaderComponent={
|
||||
<Text className='text-lg font-bold my-2'>{listHeader}</Text>
|
||||
}
|
||||
nestedScrollEnabled
|
||||
showsVerticalScrollIndicator={false}
|
||||
//@ts-expect-error
|
||||
renderItem={({ item, index }) => renderItem(item, index)}
|
||||
keyExtractor={keyExtractor}
|
||||
numColumns={3}
|
||||
ItemSeparatorComponent={() => <View className='h-2 w-2' />}
|
||||
/>
|
||||
)}
|
||||
}
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
ListHeaderComponent={
|
||||
<Text className='text-lg font-bold my-2'>{listHeader}</Text>
|
||||
}
|
||||
nestedScrollEnabled
|
||||
showsVerticalScrollIndicator={false}
|
||||
//@ts-expect-error
|
||||
renderItem={({ item, index }) => renderItem(item, index)}
|
||||
keyExtractor={keyExtractor}
|
||||
numColumns={3}
|
||||
ItemSeparatorComponent={() => <View className='h-2 w-2' />}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</ParallaxScrollView>
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Text } from "../common/Text";
|
||||
interface Props extends ViewProps {
|
||||
title?: string | null | undefined;
|
||||
subtitle?: string | null | undefined;
|
||||
subtitleColor?: "default" | "red";
|
||||
value?: string | null | undefined;
|
||||
children?: ReactNode;
|
||||
iconAfter?: ReactNode;
|
||||
@@ -15,7 +14,6 @@ interface Props extends ViewProps {
|
||||
textColor?: "default" | "blue" | "red";
|
||||
onPress?: () => void;
|
||||
disabled?: boolean;
|
||||
disabledByAdmin?: boolean;
|
||||
}
|
||||
|
||||
export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
||||
@@ -29,23 +27,21 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
||||
textColor = "default",
|
||||
onPress,
|
||||
disabled = false,
|
||||
disabledByAdmin = false,
|
||||
...viewProps
|
||||
}) => {
|
||||
const effectiveSubtitle = disabledByAdmin ? "Disabled by admin" : subtitle;
|
||||
const isDisabled = disabled || disabledByAdmin;
|
||||
if (onPress)
|
||||
return (
|
||||
<TouchableOpacity
|
||||
disabled={isDisabled}
|
||||
disabled={disabled}
|
||||
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)}
|
||||
>
|
||||
<ListItemContent
|
||||
title={title}
|
||||
subtitle={effectiveSubtitle}
|
||||
subtitleColor={disabledByAdmin ? "red" : undefined}
|
||||
subtitle={subtitle}
|
||||
value={value}
|
||||
icon={icon}
|
||||
textColor={textColor}
|
||||
@@ -58,13 +54,14 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
||||
);
|
||||
return (
|
||||
<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}
|
||||
>
|
||||
<ListItemContent
|
||||
title={title}
|
||||
subtitle={effectiveSubtitle}
|
||||
subtitleColor={disabledByAdmin ? "red" : undefined}
|
||||
subtitle={subtitle}
|
||||
value={value}
|
||||
icon={icon}
|
||||
textColor={textColor}
|
||||
@@ -80,7 +77,6 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
||||
const ListItemContent = ({
|
||||
title,
|
||||
subtitle,
|
||||
subtitleColor,
|
||||
textColor,
|
||||
icon,
|
||||
value,
|
||||
@@ -111,7 +107,7 @@ const ListItemContent = ({
|
||||
</Text>
|
||||
{subtitle && (
|
||||
<Text
|
||||
className={`text-[12px] mt-0.5 ${subtitleColor === "red" ? "text-red-600" : "text-[#9899A1]"}`}
|
||||
className='text-[#9899A1] text-[12px] mt-0.5'
|
||||
numberOfLines={2}
|
||||
>
|
||||
{subtitle}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
useQuery,
|
||||
} from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { useInView } from "@/hooks/useInView";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
@@ -67,12 +67,6 @@ export const MediaListSection: React.FC<Props> = ({
|
||||
[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;
|
||||
|
||||
return (
|
||||
@@ -98,8 +92,6 @@ export const MediaListSection: React.FC<Props> = ({
|
||||
)}
|
||||
queryFn={fetchItems}
|
||||
queryKey={["media-list", collection.Id!]}
|
||||
snapToOffsets={snapOffsets}
|
||||
decelerationRate='fast'
|
||||
/>
|
||||
</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 { useTranslation } from "react-i18next";
|
||||
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
||||
import { POSTER_CAROUSEL_HEIGHT } from "@/constants/Values";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { HorizontalScroll } from "../common/HorizontalScroll";
|
||||
@@ -51,7 +50,7 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
|
||||
<HorizontalScroll
|
||||
loading={loading}
|
||||
keyExtractor={(i, _idx) => i.Id?.toString() || ""}
|
||||
height={POSTER_CAROUSEL_HEIGHT}
|
||||
height={247}
|
||||
data={destinctPeople}
|
||||
renderItem={(i) => (
|
||||
<TouchableOpacity
|
||||
@@ -66,12 +65,8 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
|
||||
className='flex flex-col w-28'
|
||||
>
|
||||
<Poster id={i.Id} url={getPrimaryImageUrl({ api, item: i })} />
|
||||
<Text className='mt-2' numberOfLines={1}>
|
||||
{i.Name}
|
||||
</Text>
|
||||
<Text className='text-xs opacity-50' numberOfLines={1}>
|
||||
{i.Role}
|
||||
</Text>
|
||||
<Text className='mt-2'>{i.Name}</Text>
|
||||
<Text className='text-xs opacity-50'>{i.Role}</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useAtom } from "jotai";
|
||||
import type React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
||||
import { POSTER_CAROUSEL_HEIGHT } from "@/constants/Values";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
|
||||
import { HorizontalScroll } from "../common/HorizontalScroll";
|
||||
@@ -26,7 +25,7 @@ export const CurrentSeries: React.FC<Props> = ({ item, ...props }) => {
|
||||
</Text>
|
||||
<HorizontalScroll
|
||||
data={[item]}
|
||||
height={POSTER_CAROUSEL_HEIGHT}
|
||||
height={247}
|
||||
renderItem={(item, _index) => (
|
||||
<TouchableOpacity
|
||||
key={item?.Id}
|
||||
@@ -39,7 +38,7 @@ export const CurrentSeries: React.FC<Props> = ({ item, ...props }) => {
|
||||
id={item?.Id}
|
||||
url={getPrimaryImageUrlById({ api, id: item?.ParentId })}
|
||||
/>
|
||||
<Text numberOfLines={1}>{item?.SeriesName}</Text>
|
||||
<Text>{item?.SeriesName}</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
/>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user