Compare commits

...

36 Commits

Author SHA1 Message Date
Fredrik Burmester
a39461e09a fix: handle optional chaining for 'other' media items in DownloadProvider 2025-10-02 19:49:20 +02:00
Fredrik Burmester
a0725d89a0 Merge branch 'develop' into feature/newarch 2025-10-02 19:46:34 +02:00
Fredrik Burmester
7e2cfb9790 chore: back to bun 2025-10-02 19:10:00 +02:00
Fredrik Burmester
38d1b513d4 fix: building at least 2025-10-02 19:09:37 +02:00
Fredrik Burmester
cc54a3a71b fix: update deps 2025-10-02 18:18:22 +02:00
Fredrik Burmester
6842ae03f9 chore: update deps 2025-10-02 10:26:17 +02:00
Fredrik Burmester
a5ffbd6a4c fix: deps linking 2025-10-01 20:59:17 +02:00
Fredrik Burmester
02fa738cfd wip: downloads "complete" is broken? 2025-10-01 16:57:02 +02:00
Fredrik Burmester
32c01c6f89 fix: rn downloads 2025-10-01 15:25:16 +02:00
Fredrik Burmester
6fc4c33759 fix: update package 2025-10-01 12:18:52 +02:00
Fredrik Burmester
49ea64b0fd wip: rnbd fix? 2025-10-01 12:15:58 +02:00
Fredrik Burmester
c866b105e7 Revert "chore(tmp): workaround download provider"
This reverts commit e9effd5436.
2025-10-01 11:36:21 +02:00
Fredrik Burmester
1b42e61310 Revert "fix(tmp): remove plugin temporarely"
This reverts commit 47c52e0739.
2025-10-01 11:36:16 +02:00
Fredrik Burmester
fb032fa973 feat: glass filter ios for search 2025-09-30 18:35:28 +02:00
Fredrik Burmester
a0a90e48d8 feat: home page design 2025-09-30 16:45:18 +02:00
Fredrik Burmester
ab472bab6e fix: modal for android + dropdown for ios 2025-09-30 15:23:15 +02:00
Fredrik Burmester
8407124464 fix: memoize dropdown rerender controls issue 2025-09-30 13:28:05 +02:00
Fredrik Burmester
afe57d4c76 wip 2025-09-30 13:07:15 +02:00
Fredrik Burmester
7a11f4a54b wip 2025-09-30 11:58:59 +02:00
Fredrik Burmester
47c52e0739 fix(tmp): remove plugin temporarely 2025-09-30 11:00:15 +02:00
Fredrik Burmester
e9effd5436 chore(tmp): workaround download provider 2025-09-30 10:20:28 +02:00
Fredrik Burmester
6ae655abf2 chore 2025-09-30 10:20:11 +02:00
Fredrik Burmester
c74a394a6a wip: global modal provider 2025-09-30 10:20:05 +02:00
Fredrik Burmester
5e6cd6bed6 wip: remove zeego + expo ui 2025-09-30 08:26:45 +02:00
Fredrik Burmester
dfb6bd03a9 fix: non supported prop 2025-09-29 15:21:04 +02:00
Fredrik Burmester
eaf0a9fae4 fix: update tvos dep 2025-09-29 15:11:51 +02:00
Fredrik Burmester
f2bd10b1a6 fix: deps 2025-09-29 15:07:27 +02:00
Fredrik Burmester
dd03c2038d Merge branch 'develop' into feature/newarch 2025-09-29 15:07:23 +02:00
Gauvain
6af9d88a72 Merge branch 'develop' into feature/newarch 2025-09-26 20:52:32 +02:00
Lance Chant
dfa3c06857 Merge branch 'feature/newarch' of https://github.com/streamyfin/streamyfin into feature/newarch 2025-09-08 13:41:36 +02:00
Lance Chant
b0bb9d10e5 chore: adding package updates + MMKV changes
Updated package versions
Updated mmkv.ts to work with the new layout/design

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2025-09-08 13:40:21 +02:00
Gauvain
5d080664a0 Merge branch 'develop' into feature/newarch 2025-09-03 17:09:32 +02:00
Lance Chant
cde205e762 fix: android building
It builds, but don't ask it to run, it'll give exception hell

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2025-09-02 13:01:19 +02:00
Lance Chant
c335a3269e chore: package updates to try get android to build
Updated packages to latest expo beta
Updated some MMKV usages

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2025-09-02 08:15:04 +02:00
sarendsen
ccf27284f6 wip 2025-09-01 12:57:04 +02:00
sarendsen
a11b9f5875 chore: upgrade to newarch 2025-09-01 12:52:43 +02:00
62 changed files with 3583 additions and 2015 deletions

232
GLOBAL_MODAL_GUIDE.md Normal file
View File

@@ -0,0 +1,232 @@
# Global Modal System with Gorhom Bottom Sheet
This guide explains how to use the global modal system implemented in this project.
## Overview
The global modal system allows you to trigger a bottom sheet modal from anywhere in your app programmatically, and render any component inside it.
## Architecture
The system consists of three main parts:
1. **GlobalModalProvider** (`providers/GlobalModalProvider.tsx`) - Context provider that manages modal state
2. **GlobalModal** (`components/GlobalModal.tsx`) - The actual modal component rendered at root level
3. **useGlobalModal** hook - Hook to interact with the modal from anywhere
## Setup (Already Configured)
The system is already integrated into your app:
```tsx
// In app/_layout.tsx
<BottomSheetModalProvider>
<GlobalModalProvider>
{/* Your app content */}
<GlobalModal />
</GlobalModalProvider>
</BottomSheetModalProvider>
```
## Usage
### Basic Usage
```tsx
import { useGlobalModal } from "@/providers/GlobalModalProvider";
import { View, Text } from "react-native";
function MyComponent() {
const { showModal, hideModal } = useGlobalModal();
const handleOpenModal = () => {
showModal(
<View className='p-6'>
<Text className='text-white text-2xl'>Hello from Modal!</Text>
</View>
);
};
return (
<Button onPress={handleOpenModal} title="Open Modal" />
);
}
```
### With Custom Options
```tsx
const handleOpenModal = () => {
showModal(
<YourCustomComponent />,
{
snapPoints: ["25%", "50%", "90%"], // Custom snap points
enablePanDownToClose: true, // Allow swipe to close
backgroundStyle: { // Custom background
backgroundColor: "#000000",
},
}
);
};
```
### Programmatic Control
```tsx
// Open modal
showModal(<Content />);
// Close modal from within the modal content
function ModalContent() {
const { hideModal } = useGlobalModal();
return (
<View>
<Button onPress={hideModal} title="Close" />
</View>
);
}
// Close modal from outside
hideModal();
```
### In Event Handlers or Functions
```tsx
function useApiCall() {
const { showModal } = useGlobalModal();
const fetchData = async () => {
try {
const result = await api.fetch();
// Show success modal
showModal(
<SuccessMessage data={result} />
);
} catch (error) {
// Show error modal
showModal(
<ErrorMessage error={error} />
);
}
};
return fetchData;
}
```
## API Reference
### `useGlobalModal()`
Returns an object with the following properties:
- **`showModal(content, options?)`** - Show the modal with given content
- `content: ReactNode` - Any React component or element to render
- `options?: ModalOptions` - Optional configuration object
- **`hideModal()`** - Programmatically hide the modal
- **`isVisible: boolean`** - Current visibility state of the modal
### `ModalOptions`
```typescript
interface ModalOptions {
enableDynamicSizing?: boolean; // Auto-size based on content (default: true)
snapPoints?: (string | number)[]; // Fixed snap points (e.g., ["50%", "90%"])
enablePanDownToClose?: boolean; // Allow swipe down to close (default: true)
backgroundStyle?: object; // Custom background styles
handleIndicatorStyle?: object; // Custom handle indicator styles
}
```
## Examples
See `components/ExampleGlobalModalUsage.tsx` for comprehensive examples including:
- Simple content modal
- Modal with custom snap points
- Complex component in modal
- Success/error modals triggered from functions
## Default Styling
The modal uses these default styles (can be overridden via options):
```typescript
{
enableDynamicSizing: true,
enablePanDownToClose: true,
backgroundStyle: {
backgroundColor: "#171717", // Dark background
},
handleIndicatorStyle: {
backgroundColor: "white",
},
}
```
## Best Practices
1. **Keep content in separate components** - Don't inline large JSX in `showModal()` calls
2. **Use the hook in custom hooks** - Create specialized hooks like `useShowSuccessModal()` for reusable modal patterns
3. **Handle cleanup** - The modal automatically clears content when closed
4. **Avoid nesting** - Don't show modals from within modals
5. **Consider UX** - Only use for important, contextual information that requires user attention
## Using with PlatformDropdown
When using `PlatformDropdown` with option groups, avoid setting a `title` on the `OptionGroup` if you're already passing a `title` prop to `PlatformDropdown`. This prevents nested menu behavior on iOS where users have to click through an extra layer.
```tsx
// Good - No title in option group (title is on PlatformDropdown)
const optionGroups: OptionGroup[] = [
{
options: items.map((item) => ({
type: "radio",
label: item.name,
value: item,
selected: item.id === selected?.id,
onPress: () => onChange(item),
})),
},
];
<PlatformDropdown
groups={optionGroups}
title="Select Item" // Title here
// ...
/>
// Bad - Causes nested menu on iOS
const optionGroups: OptionGroup[] = [
{
title: "Items", // This creates a nested Picker on iOS
options: items.map((item) => ({
type: "radio",
label: item.name,
value: item,
selected: item.id === selected?.id,
onPress: () => onChange(item),
})),
},
];
```
## Troubleshooting
### Modal doesn't appear
- Ensure `GlobalModalProvider` is above the component calling `useGlobalModal()`
- Check that `BottomSheetModalProvider` is present in the tree
- Verify `GlobalModal` component is rendered
### Content is cut off
- Use `enableDynamicSizing: true` for auto-sizing
- Or specify appropriate `snapPoints`
### Modal won't close
- Ensure `enablePanDownToClose` is `true`
- Check that backdrop is clickable
- Use `hideModal()` for programmatic closing

View File

@@ -8,6 +8,7 @@
"scheme": "streamyfin", "scheme": "streamyfin",
"userInterfaceStyle": "dark", "userInterfaceStyle": "dark",
"jsEngine": "hermes", "jsEngine": "hermes",
"newArchEnabled": true,
"assetBundlePatterns": ["**/*"], "assetBundlePatterns": ["**/*"],
"ios": { "ios": {
"requireFullScreen": true, "requireFullScreen": true,
@@ -77,6 +78,7 @@
"useFrameworks": "static" "useFrameworks": "static"
}, },
"android": { "android": {
"buildArchs": ["arm64-v8a", "x86_64"],
"compileSdkVersion": 35, "compileSdkVersion": 35,
"targetSdkVersion": 35, "targetSdkVersion": 35,
"buildToolsVersion": "35.0.0", "buildToolsVersion": "35.0.0",
@@ -154,7 +156,6 @@
}, },
"updates": { "updates": {
"url": "https://u.expo.dev/e79219d1-797f-4fbe-9fa1-cfd360690a68" "url": "https://u.expo.dev/e79219d1-797f-4fbe-9fa1-cfd360690a68"
}, }
"newArchEnabled": false
} }
} }

View File

@@ -9,7 +9,7 @@ export default function CustomMenuLayout() {
<Stack.Screen <Stack.Screen
name='index' name='index'
options={{ options={{
headerShown: true, headerShown: Platform.OS !== "ios",
headerLargeTitle: true, headerLargeTitle: true,
headerTitle: t("tabs.custom_links"), headerTitle: t("tabs.custom_links"),
headerBlurEffect: "none", headerBlurEffect: "none",

View File

@@ -22,6 +22,11 @@ export default function IndexLayout() {
options={{ options={{
headerShown: !Platform.isTV, headerShown: !Platform.isTV,
headerTitle: t("tabs.home"), headerTitle: t("tabs.home"),
headerLeft: () => (
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
headerBlurEffect: "none", headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
@@ -43,48 +48,88 @@ export default function IndexLayout() {
name='downloads/index' name='downloads/index'
options={{ options={{
title: t("home.downloads.downloads_title"), title: t("home.downloads.downloads_title"),
headerLeft: () => (
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
}} }}
/> />
<Stack.Screen <Stack.Screen
name='downloads/[seriesId]' name='downloads/[seriesId]'
options={{ options={{
title: t("home.downloads.tvseries"), title: t("home.downloads.tvseries"),
headerLeft: () => (
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
}} }}
/> />
<Stack.Screen <Stack.Screen
name='sessions/index' name='sessions/index'
options={{ options={{
title: t("home.sessions.title"), title: t("home.sessions.title"),
headerLeft: () => (
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
}} }}
/> />
<Stack.Screen <Stack.Screen
name='settings' name='settings'
options={{ options={{
title: t("home.settings.settings_title"), title: t("home.settings.settings_title"),
headerLeft: () => (
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
}} }}
/> />
<Stack.Screen <Stack.Screen
name='settings/marlin-search/page' name='settings/marlin-search/page'
options={{ options={{
title: "", title: "",
headerLeft: () => (
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
}} }}
/> />
<Stack.Screen <Stack.Screen
name='settings/jellyseerr/page' name='settings/jellyseerr/page'
options={{ options={{
title: "", title: "",
headerLeft: () => (
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
}} }}
/> />
<Stack.Screen <Stack.Screen
name='settings/hide-libraries/page' name='settings/hide-libraries/page'
options={{ options={{
title: "", title: "",
headerLeft: () => (
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
}} }}
/> />
<Stack.Screen <Stack.Screen
name='settings/logs/page' name='settings/logs/page'
options={{ options={{
title: "", title: "",
headerLeft: () => (
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
}} }}
/> />
<Stack.Screen <Stack.Screen
@@ -92,6 +137,11 @@ export default function IndexLayout() {
options={{ options={{
headerShown: false, headerShown: false,
title: "", title: "",
headerLeft: () => (
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
presentation: "modal", presentation: "modal",
}} }}
/> />
@@ -102,6 +152,11 @@ export default function IndexLayout() {
name='collections/[collectionId]' name='collections/[collectionId]'
options={{ options={{
title: "", title: "",
headerLeft: () => (
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
headerShown: true, headerShown: true,
headerBlurEffect: "prominent", headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",

View File

@@ -91,7 +91,7 @@ export default function page() {
title: series[0].item.SeriesName, title: series[0].item.SeriesName,
}); });
} else { } else {
storage.delete(seriesId); storage.remove(seriesId);
router.back(); router.back();
} }
}, [series]); }, [series]);

View File

@@ -62,7 +62,10 @@ export default function page() {
); );
}; };
const downloadedFiles = getDownloadedItems(); const downloadedFiles = useMemo(
() => getDownloadedItems(),
[getDownloadedItems],
);
const movies = useMemo(() => { const movies = useMemo(() => {
try { try {

View File

@@ -1,4 +1,4 @@
import { HomeIndex } from "@/components/settings/HomeIndex"; import { HomeIndex } from "@/components/home/HomeIndex";
export default function page() { export default function page() {
return <HomeIndex />; return <HomeIndex />;

View File

@@ -46,7 +46,7 @@ export default function settings() {
logout(); logout();
}} }}
> >
<Text className='text-red-600'> <Text className='text-red-600 px-2'>
{t("home.settings.log_out_button")} {t("home.settings.log_out_button")}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>

View File

@@ -1,4 +1,3 @@
import * as FileSystem from "expo-file-system";
import { useNavigation } from "expo-router"; import { useNavigation } from "expo-router";
import * as Sharing from "expo-sharing"; import * as Sharing from "expo-sharing";
import { useCallback, useEffect, useId, useMemo, useState } from "react"; import { useCallback, useEffect, useId, useMemo, useState } from "react";

View File

@@ -393,7 +393,6 @@ const page: React.FC = () => {
data={flatData} data={flatData}
renderItem={renderItem} renderItem={renderItem}
keyExtractor={keyExtractor} keyExtractor={keyExtractor}
estimatedItemSize={255}
numColumns={ numColumns={
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5 orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5
} }

View File

@@ -19,31 +19,29 @@ import { Text } from "@/components/common/Text";
import { GenreTags } from "@/components/GenreTags"; import { GenreTags } from "@/components/GenreTags";
import Cast from "@/components/jellyseerr/Cast"; import Cast from "@/components/jellyseerr/Cast";
import DetailFacts from "@/components/jellyseerr/DetailFacts"; import DetailFacts from "@/components/jellyseerr/DetailFacts";
import RequestModal from "@/components/jellyseerr/RequestModal";
import { OverviewText } from "@/components/OverviewText"; import { OverviewText } from "@/components/OverviewText";
import { ParallaxScrollView } from "@/components/ParallaxPage"; import { ParallaxScrollView } from "@/components/ParallaxPage";
import { PlatformDropdown } from "@/components/PlatformDropdown";
import { JellyserrRatings } from "@/components/Ratings"; import { JellyserrRatings } from "@/components/Ratings";
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons"; import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
import { ItemActions } from "@/components/series/SeriesActions"; import { ItemActions } from "@/components/series/SeriesActions";
import { useJellyseerr } from "@/hooks/useJellyseerr"; import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest"; import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
import { import {
type IssueType, type IssueType,
IssueTypeName, IssueTypeName,
} from "@/utils/jellyseerr/server/constants/issue"; } from "@/utils/jellyseerr/server/constants/issue";
import { MediaType } from "@/utils/jellyseerr/server/constants/media"; import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
import type { import type {
MovieResult, MovieResult,
TvResult, TvResult,
} from "@/utils/jellyseerr/server/models/Search"; } from "@/utils/jellyseerr/server/models/Search";
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv"; import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import RequestModal from "@/components/jellyseerr/RequestModal";
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
const Page: React.FC = () => { const Page: React.FC = () => {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const params = useLocalSearchParams(); const params = useLocalSearchParams();
@@ -156,6 +154,24 @@ const Page: React.FC = () => {
[details], [details],
); );
const issueTypeOptionGroups = useMemo(
() => [
{
title: t("jellyseerr.types"),
options: Object.entries(IssueTypeName)
.reverse()
.map(([key, value]) => ({
type: "radio" as const,
label: value,
value: key,
selected: key === String(issueType),
onPress: () => setIssueType(key as unknown as IssueType),
})),
},
],
[issueType, t],
);
useEffect(() => { useEffect(() => {
if (details) { if (details) {
navigation.setOptions({ navigation.setOptions({
@@ -364,50 +380,23 @@ const Page: React.FC = () => {
</Text> </Text>
</View> </View>
<View className='flex flex-col space-y-2 items-start'> <View className='flex flex-col space-y-2 items-start'>
<View className='flex flex-col'> <View className='flex flex-col w-full'>
<DropdownMenu.Root> <Text className='opacity-50 mb-1 text-xs'>
<DropdownMenu.Trigger> {t("jellyseerr.issue_type")}
<View className='flex flex-col'> </Text>
<Text className='opacity-50 mb-1 text-xs'> <PlatformDropdown
{t("jellyseerr.issue_type")} groups={issueTypeOptionGroups}
trigger={
<View className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
<Text numberOfLines={1}>
{issueType
? IssueTypeName[issueType]
: t("jellyseerr.select_an_issue")}
</Text> </Text>
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
<Text style={{}} className='' numberOfLines={1}>
{issueType
? IssueTypeName[issueType]
: t("jellyseerr.select_an_issue")}
</Text>
</TouchableOpacity>
</View> </View>
</DropdownMenu.Trigger> }
<DropdownMenu.Content title={t("jellyseerr.types")}
loop={false} />
side='bottom'
align='center'
alignOffset={0}
avoidCollisions={true}
collisionPadding={0}
sideOffset={0}
>
<DropdownMenu.Label>
{t("jellyseerr.types")}
</DropdownMenu.Label>
{Object.entries(IssueTypeName)
.reverse()
.map(([key, value], _idx) => (
<DropdownMenu.Item
key={value}
onSelect={() =>
setIssueType(key as unknown as IssueType)
}
>
<DropdownMenu.ItemTitle>
{value}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View> </View>
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full'> <View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full'>

View File

@@ -1,85 +1,164 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { Stack } from "expo-router"; import { Stack } from "expo-router";
import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity } from "react-native"; import { Platform } from "react-native";
import { LibraryOptionsSheet } from "@/components/settings/LibraryOptionsSheet"; import { PlatformDropdown } from "@/components/PlatformDropdown";
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack"; import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
export default function IndexLayout() { export default function IndexLayout() {
const { settings, updateSettings, pluginSettings } = useSettings(); const { settings, updateSettings, pluginSettings } = useSettings();
const [optionsSheetOpen, setOptionsSheetOpen] = useState(false);
const { t } = useTranslation(); const { t } = useTranslation();
if (!settings?.libraryOptions) return null; if (!settings?.libraryOptions) return null;
return ( return (
<> <Stack>
<Stack> <Stack.Screen
<Stack.Screen name='index'
name='index' options={{
options={{ headerShown: !Platform.isTV,
headerShown: !Platform.isTV, headerTitle: t("tabs.library"),
headerTitle: t("tabs.library"), headerBlurEffect: "none",
headerBlurEffect: "none", headerTransparent: Platform.OS === "ios",
headerTransparent: Platform.OS === "ios", headerShadowVisible: false,
headerShadowVisible: false, headerRight: () =>
headerRight: () => !pluginSettings?.libraryOptions?.locked &&
!pluginSettings?.libraryOptions?.locked && !Platform.isTV && (
!Platform.isTV && ( <PlatformDropdown
<TouchableOpacity trigger={
onPress={() => setOptionsSheetOpen(true)}
className='flex flex-row items-center justify-center w-9 h-9'
>
<Ionicons <Ionicons
name='ellipsis-horizontal-outline' name='ellipsis-horizontal-outline'
size={24} size={24}
color='white' color='white'
/> />
</TouchableOpacity> }
), title={t("library.options.display")}
}} groups={[
/> {
<Stack.Screen title: t("library.options.display"),
name='[libraryId]' options: [
options={{ {
title: "", type: "radio",
headerShown: !Platform.isTV, label: t("library.options.row"),
headerBlurEffect: "none", value: "row",
headerTransparent: Platform.OS === "ios", selected: settings.libraryOptions.display === "row",
headerShadowVisible: false, onPress: () =>
}} updateSettings({
/> libraryOptions: {
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => ( ...settings.libraryOptions,
<Stack.Screen key={name} name={name} options={options} /> display: "row",
))} },
<Stack.Screen }),
name='collections/[collectionId]' },
options={{ {
title: "", type: "radio",
headerShown: !Platform.isTV, label: t("library.options.list"),
headerBlurEffect: "none", value: "list",
headerTransparent: Platform.OS === "ios", selected: settings.libraryOptions.display === "list",
headerShadowVisible: false, onPress: () =>
}} updateSettings({
/> libraryOptions: {
</Stack> ...settings.libraryOptions,
<LibraryOptionsSheet display: "list",
open={optionsSheetOpen} },
setOpen={setOptionsSheetOpen} }),
settings={settings.libraryOptions} },
updateSettings={(options) => ],
updateSettings({ },
libraryOptions: { {
...settings.libraryOptions, title: t("library.options.image_style"),
...options, options: [
}, {
}) type: "radio",
} label: t("library.options.poster"),
disabled={pluginSettings?.libraryOptions?.locked} value: "poster",
selected:
settings.libraryOptions.imageStyle === "poster",
onPress: () =>
updateSettings({
libraryOptions: {
...settings.libraryOptions,
imageStyle: "poster",
},
}),
},
{
type: "radio",
label: t("library.options.cover"),
value: "cover",
selected:
settings.libraryOptions.imageStyle === "cover",
onPress: () =>
updateSettings({
libraryOptions: {
...settings.libraryOptions,
imageStyle: "cover",
},
}),
},
],
},
{
title: "Options",
options: [
{
type: "toggle",
label: t("library.options.show_titles"),
value: settings.libraryOptions.showTitles,
onToggle: () =>
updateSettings({
libraryOptions: {
...settings.libraryOptions,
showTitles: !settings.libraryOptions.showTitles,
},
}),
disabled:
settings.libraryOptions.imageStyle === "poster",
},
{
type: "toggle",
label: t("library.options.show_stats"),
value: settings.libraryOptions.showStats,
onToggle: () =>
updateSettings({
libraryOptions: {
...settings.libraryOptions,
showStats: !settings.libraryOptions.showStats,
},
}),
},
],
},
]}
/>
),
}}
/> />
</> <Stack.Screen
name='[libraryId]'
options={{
title: "",
headerShown: !Platform.isTV,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
}}
/>
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
<Stack.Screen key={name} name={name} options={options} />
))}
<Stack.Screen
name='collections/[collectionId]'
options={{
title: "",
headerShown: !Platform.isTV,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
}}
/>
</Stack>
); );
} }

View File

@@ -87,8 +87,8 @@ export default function index() {
paddingTop: 17, paddingTop: 17,
paddingHorizontal: settings?.libraryOptions?.display === "row" ? 0 : 17, paddingHorizontal: settings?.libraryOptions?.display === "row" ? 0 : 17,
paddingBottom: 150, paddingBottom: 150,
paddingLeft: insets.left, paddingLeft: insets.left + 17,
paddingRight: insets.right, paddingRight: insets.right + 17,
}} }}
data={libraries} data={libraries}
renderItem={({ item }) => <LibraryItemCard library={item} />} renderItem={({ item }) => <LibraryItemCard library={item} />}
@@ -105,7 +105,6 @@ export default function index() {
<View className='h-4' /> <View className='h-4' />
) )
} }
estimatedItemSize={200}
/> />
); );
} }

View File

@@ -24,8 +24,6 @@ import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import { Input } from "@/components/common/Input"; import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { FilterButton } from "@/components/filters/FilterButton";
import { Tag } from "@/components/GenreTags";
import { ItemCardText } from "@/components/ItemCardText"; import { ItemCardText } from "@/components/ItemCardText";
import { import {
JellyseerrSearchSort, JellyseerrSearchSort,
@@ -33,8 +31,10 @@ import {
} from "@/components/jellyseerr/JellyseerrIndexPage"; } from "@/components/jellyseerr/JellyseerrIndexPage";
import MoviePoster from "@/components/posters/MoviePoster"; import MoviePoster from "@/components/posters/MoviePoster";
import SeriesPoster from "@/components/posters/SeriesPoster"; import SeriesPoster from "@/components/posters/SeriesPoster";
import { DiscoverFilters } from "@/components/search/DiscoverFilters";
import { LoadingSkeleton } from "@/components/search/LoadingSkeleton"; import { LoadingSkeleton } from "@/components/search/LoadingSkeleton";
import { SearchItemWrapper } from "@/components/search/SearchItemWrapper"; import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
import { SearchTabButtons } from "@/components/search/SearchTabButtons";
import { useJellyseerr } from "@/hooks/useJellyseerr"; import { useJellyseerr } from "@/hooks/useJellyseerr";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
@@ -282,69 +282,29 @@ export default function search() {
maxLength={500} maxLength={500}
/> />
)} )}
<View <View className='flex flex-col'>
className='flex flex-col'
style={{
marginTop: Platform.OS === "android" ? 16 : 0,
}}
>
{jellyseerrApi && ( {jellyseerrApi && (
<ScrollView <View className='pl-4 pr-4 pt-2 flex flex-row'>
horizontal <SearchTabButtons
className='flex flex-row flex-wrap space-x-2 px-4 mb-2' searchType={searchType}
> setSearchType={setSearchType}
<TouchableOpacity onPress={() => setSearchType("Library")}> t={t}
<Tag />
text={t("search.library")}
textClass='p-1'
className={
searchType === "Library" ? "bg-purple-600" : undefined
}
/>
</TouchableOpacity>
<TouchableOpacity onPress={() => setSearchType("Discover")}>
<Tag
text={t("search.discover")}
textClass='p-1'
className={
searchType === "Discover" ? "bg-purple-600" : undefined
}
/>
</TouchableOpacity>
{searchType === "Discover" && {searchType === "Discover" &&
!loading && !loading &&
noResults && noResults &&
debouncedSearch.length > 0 && ( debouncedSearch.length > 0 && (
<View className='flex flex-row justify-end items-center space-x-1'> <DiscoverFilters
<FilterButton searchFilterId={searchFilterId}
id={searchFilterId} orderFilterId={orderFilterId}
queryKey='jellyseerr_search' jellyseerrOrderBy={jellyseerrOrderBy}
queryFn={async () => setJellyseerrOrderBy={setJellyseerrOrderBy}
Object.keys(JellyseerrSearchSort).filter((v) => jellyseerrSortOrder={jellyseerrSortOrder}
Number.isNaN(Number(v)), setJellyseerrSortOrder={setJellyseerrSortOrder}
) t={t}
} />
set={(value) => setJellyseerrOrderBy(value[0])}
values={[jellyseerrOrderBy]}
title={t("library.filters.sort_by")}
renderItemLabel={(item) =>
t(`home.settings.plugins.jellyseerr.order_by.${item}`)
}
disableSearch={true}
/>
<FilterButton
id={orderFilterId}
queryKey='jellysearr_search'
queryFn={async () => ["asc", "desc"]}
set={(value) => setJellyseerrSortOrder(value[0])}
values={[jellyseerrSortOrder]}
title={t("library.filters.sort_order")}
renderItemLabel={(item) => t(`library.filters.${item}`)}
disableSearch={true}
/>
</View>
)} )}
</ScrollView> </View>
)} )}
<View className='mt-2'> <View className='mt-2'>

View File

@@ -75,7 +75,10 @@ export default function page() {
: require("react-native-volume-manager"); : require("react-native-volume-manager");
const downloadUtils = useDownload(); const downloadUtils = useDownload();
const downloadedFiles = downloadUtils.getDownloadedItems(); const downloadedFiles = useMemo(
() => downloadUtils.getDownloadedItems(),
[downloadUtils.getDownloadedItems],
);
const revalidateProgressCache = useInvalidatePlaybackProgressCache(); const revalidateProgressCache = useInvalidatePlaybackProgressCache();

View File

@@ -2,8 +2,10 @@ import "@/augmentations";
import { ActionSheetProvider } from "@expo/react-native-action-sheet"; import { ActionSheetProvider } from "@expo/react-native-action-sheet";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { GlobalModal } from "@/components/GlobalModal";
import i18n from "@/i18n"; import i18n from "@/i18n";
import { DownloadProvider } from "@/providers/DownloadProvider"; import { DownloadProvider } from "@/providers/DownloadProvider";
import { GlobalModalProvider } from "@/providers/GlobalModalProvider";
import { import {
apiAtom, apiAtom,
getOrSetDeviceId, getOrSetDeviceId,
@@ -36,7 +38,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import * as BackgroundTask from "expo-background-task"; import * as BackgroundTask from "expo-background-task";
import * as Device from "expo-device"; import * as Device from "expo-device";
import * as FileSystem from "expo-file-system"; import { Paths } from "expo-file-system";
const Notifications = !Platform.isTV ? require("expo-notifications") : null; const Notifications = !Platform.isTV ? require("expo-notifications") : null;
@@ -143,7 +145,7 @@ if (!Platform.isTV) {
const token = getTokenFromStorage(); const token = getTokenFromStorage();
const deviceId = getOrSetDeviceId(); const deviceId = getOrSetDeviceId();
const baseDirectory = FileSystem.documentDirectory; const baseDirectory = Paths.document.uri;
if (!token || !deviceId || !baseDirectory) if (!token || !deviceId || !baseDirectory)
return BackgroundTask.BackgroundTaskResult.Failed; return BackgroundTask.BackgroundTaskResult.Failed;
@@ -386,7 +388,7 @@ function Layout() {
]); ]);
useEffect(() => { useEffect(() => {
if (Platform.isTV) { if (Platform.isTV || !BackGroundDownloader) {
return; return;
} }
@@ -395,7 +397,7 @@ function Layout() {
appState.current.match(/inactive|background/) && appState.current.match(/inactive|background/) &&
nextAppState === "active" nextAppState === "active"
) { ) {
BackGroundDownloader.checkForExistingDownloads().catch( BackGroundDownloader?.checkForExistingDownloads().catch(
(error: unknown) => { (error: unknown) => {
writeErrorLog("Failed to resume background downloads", error); writeErrorLog("Failed to resume background downloads", error);
}, },
@@ -403,9 +405,11 @@ function Layout() {
} }
}); });
BackGroundDownloader.checkForExistingDownloads().catch((error: unknown) => { BackGroundDownloader?.checkForExistingDownloads().catch(
writeErrorLog("Failed to resume background downloads", error); (error: unknown) => {
}); writeErrorLog("Failed to resume background downloads", error);
},
);
return () => { return () => {
subscription.remove(); subscription.remove();
}; };
@@ -418,52 +422,55 @@ function Layout() {
<LogProvider> <LogProvider>
<WebSocketProvider> <WebSocketProvider>
<DownloadProvider> <DownloadProvider>
<BottomSheetModalProvider> <GlobalModalProvider>
<SystemBars style='light' hidden={false} /> <BottomSheetModalProvider>
<ThemeProvider value={DarkTheme}> <ThemeProvider value={DarkTheme}>
<Stack initialRouteName='(auth)/(tabs)'> <SystemBars style='light' hidden={false} />
<Stack.Screen <Stack initialRouteName='(auth)/(tabs)'>
name='(auth)/(tabs)' <Stack.Screen
options={{ name='(auth)/(tabs)'
headerShown: false, options={{
title: "", headerShown: false,
header: () => null, title: "",
header: () => null,
}}
/>
<Stack.Screen
name='(auth)/player'
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name='login'
options={{
headerShown: true,
title: "",
headerTransparent: true,
}}
/>
<Stack.Screen name='+not-found' />
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}} }}
closeButton
/> />
<Stack.Screen <GlobalModal />
name='(auth)/player' </ThemeProvider>
options={{ </BottomSheetModalProvider>
headerShown: false, </GlobalModalProvider>
title: "",
header: () => null,
}}
/>
<Stack.Screen
name='login'
options={{
headerShown: true,
title: "",
headerTransparent: true,
}}
/>
<Stack.Screen name='+not-found' />
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}}
closeButton
/>
</ThemeProvider>
</BottomSheetModalProvider>
</DownloadProvider> </DownloadProvider>
</WebSocketProvider> </WebSocketProvider>
</LogProvider> </LogProvider>

View File

@@ -4,7 +4,6 @@ import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router"; import { useLocalSearchParams, useNavigation } from "expo-router";
import { t } from "i18next"; import { t } from "i18next";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import type React from "react";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { import {
Alert, Alert,
@@ -82,10 +81,10 @@ const Login: React.FC = () => {
onPress={() => { onPress={() => {
removeServer(); removeServer();
}} }}
className='flex flex-row items-center' className='flex flex-row items-center pr-2 pl-1'
> >
<Ionicons name='chevron-back' size={18} color={Colors.primary} /> <Ionicons name='chevron-back' size={18} color={Colors.primary} />
<Text className='ml-2 text-purple-600'> <Text className=' ml-1 text-purple-600'>
{t("login.change_server")} {t("login.change_server")}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>

View File

@@ -1,4 +1,4 @@
import { MMKV } from "react-native-mmkv"; import { storage } from "@/utils/mmkv";
declare module "react-native-mmkv" { declare module "react-native-mmkv" {
interface MMKV { interface MMKV {
@@ -9,7 +9,7 @@ declare module "react-native-mmkv" {
// Add the augmentation methods directly to the MMKV prototype // Add the augmentation methods directly to the MMKV prototype
// This follows the recommended pattern while adding the helper methods your app uses // This follows the recommended pattern while adding the helper methods your app uses
MMKV.prototype.get = function <T>(key: string): T | undefined { (storage as any).get = function <T>(key: string): T | undefined {
try { try {
const serializedItem = this.getString(key); const serializedItem = this.getString(key);
if (!serializedItem) return undefined; if (!serializedItem) return undefined;
@@ -20,10 +20,10 @@ MMKV.prototype.get = function <T>(key: string): T | undefined {
} }
}; };
MMKV.prototype.setAny = function (key: string, value: any | undefined): void { (storage as any).setAny = function (key: string, value: any | undefined): void {
try { try {
if (value === undefined) { if (value === undefined) {
this.delete(key); this.remove(key);
} else { } else {
this.set(key, JSON.stringify(value)); this.set(key, JSON.stringify(value));
} }

View File

@@ -2,6 +2,6 @@ module.exports = (api) => {
api.cache(true); api.cache(true);
return { return {
presets: ["babel-preset-expo"], presets: ["babel-preset-expo"],
plugins: ["nativewind/babel", "react-native-reanimated/plugin"], plugins: ["nativewind/babel", "react-native-worklets/plugin"],
}; };
}; };

755
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,9 @@
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models"; import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
import { useMemo } from "react"; import { useMemo, useState } from "react";
import { Platform, TouchableOpacity, View } from "react-native";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
interface Props extends React.ComponentProps<typeof View> { interface Props extends React.ComponentProps<typeof View> {
source?: MediaSourceInfo; source?: MediaSourceInfo;
@@ -20,6 +18,8 @@ export const AudioTrackSelector: React.FC<Props> = ({
...props ...props
}) => { }) => {
const isTv = Platform.isTV; const isTv = Platform.isTV;
const [open, setOpen] = useState(false);
const { t } = useTranslation();
const audioStreams = useMemo( const audioStreams = useMemo(
() => source?.MediaStreams?.filter((x) => x.Type === "Audio"), () => source?.MediaStreams?.filter((x) => x.Type === "Audio"),
@@ -31,55 +31,58 @@ export const AudioTrackSelector: React.FC<Props> = ({
[audioStreams, selected], [audioStreams, selected],
); );
const { t } = useTranslation(); const optionGroups: OptionGroup[] = useMemo(
() => [
{
options:
audioStreams?.map((audio, idx) => ({
type: "radio" as const,
label: audio.DisplayTitle || `Audio Stream ${idx + 1}`,
value: audio.Index ?? idx,
selected: audio.Index === selected,
onPress: () => {
if (audio.Index !== null && audio.Index !== undefined) {
onChange(audio.Index);
}
},
})) || [],
},
],
[audioStreams, selected, onChange],
);
const handleOptionSelect = () => {
setOpen(false);
};
const trigger = (
<View className='flex flex-col' {...props}>
<Text className='opacity-50 mb-1 text-xs'>{t("item_card.audio")}</Text>
<TouchableOpacity
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'
onPress={() => setOpen(true)}
>
<Text numberOfLines={1}>{selectedAudioSteam?.DisplayTitle}</Text>
</TouchableOpacity>
</View>
);
if (isTv) return null; if (isTv) return null;
return ( return (
<View <PlatformDropdown
className='flex shrink' groups={optionGroups}
style={{ trigger={trigger}
minWidth: 50, title={t("item_card.audio")}
open={open}
onOpenChange={setOpen}
onOptionSelect={handleOptionSelect}
expoUIConfig={{
hostStyle: { flex: 1 },
}} }}
> bottomSheetConfig={{
<DropdownMenu.Root> enablePanDownToClose: true,
<DropdownMenu.Trigger> }}
<View className='flex flex-col' {...props}> />
<Text className='opacity-50 mb-1 text-xs'>
{t("item_card.audio")}
</Text>
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
<Text className='' numberOfLines={1}>
{selectedAudioSteam?.DisplayTitle}
</Text>
</TouchableOpacity>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side='bottom'
align='start'
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Audio streams</DropdownMenu.Label>
{audioStreams?.map((audio, idx: number) => (
<DropdownMenu.Item
key={idx.toString()}
onSelect={() => {
if (audio.Index !== null && audio.Index !== undefined)
onChange(audio.Index);
}}
>
<DropdownMenu.ItemTitle>
{audio.DisplayTitle}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
); );
}; };

View File

@@ -1,10 +1,8 @@
import { Platform, TouchableOpacity, View } from "react-native"; import { useMemo, useState } from "react";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
export type Bitrate = { export type Bitrate = {
key: string; key: string;
@@ -61,6 +59,8 @@ export const BitrateSelector: React.FC<Props> = ({
...props ...props
}) => { }) => {
const isTv = Platform.isTV; const isTv = Platform.isTV;
const [open, setOpen] = useState(false);
const { t } = useTranslation();
const sorted = useMemo(() => { const sorted = useMemo(() => {
if (inverted) if (inverted)
@@ -76,53 +76,59 @@ export const BitrateSelector: React.FC<Props> = ({
); );
}, [inverted]); }, [inverted]);
const { t } = useTranslation(); const optionGroups: OptionGroup[] = useMemo(
() => [
{
options: sorted.map((bitrate) => ({
type: "radio" as const,
label: bitrate.key,
value: bitrate,
selected: bitrate.value === selected?.value,
onPress: () => onChange(bitrate),
})),
},
],
[sorted, selected, onChange],
);
const handleOptionSelect = (optionId: string) => {
const selectedBitrate = sorted.find((b) => b.key === optionId);
if (selectedBitrate) {
onChange(selectedBitrate);
}
setOpen(false);
};
const trigger = (
<View className='flex flex-col' {...props}>
<Text className='opacity-50 mb-1 text-xs'>{t("item_card.quality")}</Text>
<TouchableOpacity
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'
onPress={() => setOpen(true)}
>
<Text numberOfLines={1}>
{BITRATES.find((b) => b.value === selected?.value)?.key}
</Text>
</TouchableOpacity>
</View>
);
if (isTv) return null; if (isTv) return null;
return ( return (
<View <PlatformDropdown
className='flex shrink' groups={optionGroups}
style={{ trigger={trigger}
minWidth: 60, title={t("item_card.quality")}
maxWidth: 200, open={open}
onOpenChange={setOpen}
onOptionSelect={handleOptionSelect}
expoUIConfig={{
hostStyle: { flex: 1 },
}} }}
> bottomSheetConfig={{
<DropdownMenu.Root> enablePanDownToClose: true,
<DropdownMenu.Trigger> }}
<View className='flex flex-col' {...props}> />
<Text className='opacity-50 mb-1 text-xs'>
{t("item_card.quality")}
</Text>
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
<Text style={{}} className='' numberOfLines={1}>
{BITRATES.find((b) => b.value === selected?.value)?.key}
</Text>
</TouchableOpacity>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={false}
side='bottom'
align='center'
alignOffset={0}
avoidCollisions={true}
collisionPadding={0}
sideOffset={0}
>
<DropdownMenu.Label>Bitrates</DropdownMenu.Label>
{sorted.map((b) => (
<DropdownMenu.Item
key={b.key}
onSelect={() => {
onChange(b);
}}
>
<DropdownMenu.ItemTitle>{b.key}</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
); );
}; };

View File

@@ -66,7 +66,10 @@ export const DownloadItems: React.FC<DownloadProps> = ({
const { processes, startBackgroundDownload, getDownloadedItems } = const { processes, startBackgroundDownload, getDownloadedItems } =
useDownload(); useDownload();
const downloadedFiles = getDownloadedItems(); const downloadedFiles = useMemo(
() => getDownloadedItems(),
[getDownloadedItems],
);
const [selectedOptions, setSelectedOptions] = useState< const [selectedOptions, setSelectedOptions] = useState<
SelectedOptions | undefined SelectedOptions | undefined
@@ -359,16 +362,18 @@ export const DownloadItems: React.FC<DownloadProps> = ({
})} })}
</Text> </Text>
</View> </View>
<View className='flex flex-col space-y-2 w-full items-start'> <View className='flex flex-col space-y-2 w-full'>
<BitrateSelector <View className='items-start'>
inverted <BitrateSelector
onChange={(val) => inverted
setSelectedOptions( onChange={(val) =>
(prev) => prev && { ...prev, bitrate: val }, setSelectedOptions(
) (prev) => prev && { ...prev, bitrate: val },
} )
selected={selectedOptions?.bitrate} }
/> selected={selectedOptions?.bitrate}
/>
</View>
{itemsNotDownloaded.length > 1 && ( {itemsNotDownloaded.length > 1 && (
<View className='flex flex-row items-center justify-between w-full py-2'> <View className='flex flex-row items-center justify-between w-full py-2'>
<Text>{t("item_card.download.download_unwatched_only")}</Text> <Text>{t("item_card.download.download_unwatched_only")}</Text>
@@ -380,21 +385,23 @@ export const DownloadItems: React.FC<DownloadProps> = ({
)} )}
{itemsNotDownloaded.length === 1 && ( {itemsNotDownloaded.length === 1 && (
<View> <View>
<MediaSourceSelector <View className='items-start'>
item={items[0]} <MediaSourceSelector
onChange={(val) => item={items[0]}
setSelectedOptions( onChange={(val) =>
(prev) => setSelectedOptions(
prev && { (prev) =>
...prev, prev && {
mediaSource: val, ...prev,
}, mediaSource: val,
) },
} )
selected={selectedOptions?.mediaSource} }
/> selected={selectedOptions?.mediaSource}
/>
</View>
{selectedOptions?.mediaSource && ( {selectedOptions?.mediaSource && (
<View className='flex flex-col space-y-2'> <View className='flex flex-col space-y-2 items-start'>
<AudioTrackSelector <AudioTrackSelector
source={selectedOptions.mediaSource} source={selectedOptions.mediaSource}
onChange={(val) => { onChange={(val) => {
@@ -427,11 +434,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
)} )}
</View> </View>
<Button <Button onPress={acceptDownloadOptions} color='purple'>
className='mt-auto'
onPress={acceptDownloadOptions}
color='purple'
>
{t("item_card.download.download_button")} {t("item_card.download.download_button")}
</Button> </Button>
</View> </View>

View File

@@ -0,0 +1,203 @@
/**
* Example Usage of Global Modal
*
* This file demonstrates how to use the global modal system from anywhere in your app.
* You can delete this file after understanding how it works.
*/
import { Ionicons } from "@expo/vector-icons";
import { TouchableOpacity, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useGlobalModal } from "@/providers/GlobalModalProvider";
/**
* Example 1: Simple Content Modal
*/
export const SimpleModalExample = () => {
const { showModal } = useGlobalModal();
const handleOpenModal = () => {
showModal(
<View className='p-6'>
<Text className='text-2xl font-bold mb-4 text-white'>Simple Modal</Text>
<Text className='text-white mb-4'>
This is a simple modal with just some text content.
</Text>
<Text className='text-neutral-400'>
Swipe down or tap outside to close.
</Text>
</View>,
);
};
return (
<TouchableOpacity
onPress={handleOpenModal}
className='bg-purple-600 px-4 py-2 rounded-lg'
>
<Text className='text-white font-semibold'>Open Simple Modal</Text>
</TouchableOpacity>
);
};
/**
* Example 2: Modal with Custom Snap Points
*/
export const CustomSnapPointsExample = () => {
const { showModal } = useGlobalModal();
const handleOpenModal = () => {
showModal(
<View className='p-6' style={{ minHeight: 400 }}>
<Text className='text-2xl font-bold mb-4 text-white'>
Custom Snap Points
</Text>
<Text className='text-white mb-4'>
This modal has custom snap points (25%, 50%, 90%).
</Text>
<View className='bg-neutral-800 p-4 rounded-lg'>
<Text className='text-white'>
Try dragging the modal to different heights!
</Text>
</View>
</View>,
{
snapPoints: ["25%", "50%", "90%"],
enableDynamicSizing: false,
},
);
};
return (
<TouchableOpacity
onPress={handleOpenModal}
className='bg-blue-600 px-4 py-2 rounded-lg'
>
<Text className='text-white font-semibold'>Custom Snap Points</Text>
</TouchableOpacity>
);
};
/**
* Example 3: Complex Component in Modal
*/
const SettingsModalContent = () => {
const { hideModal } = useGlobalModal();
const settings = [
{
id: 1,
title: "Notifications",
icon: "notifications-outline" as const,
enabled: true,
},
{ id: 2, title: "Dark Mode", icon: "moon-outline" as const, enabled: true },
{
id: 3,
title: "Auto-play",
icon: "play-outline" as const,
enabled: false,
},
];
return (
<View className='p-6'>
<Text className='text-2xl font-bold mb-6 text-white'>Settings</Text>
{settings.map((setting, index) => (
<View
key={setting.id}
className={`flex-row items-center justify-between py-4 ${
index !== settings.length - 1 ? "border-b border-neutral-700" : ""
}`}
>
<View className='flex-row items-center gap-3'>
<Ionicons name={setting.icon} size={24} color='white' />
<Text className='text-white text-lg'>{setting.title}</Text>
</View>
<View
className={`w-12 h-7 rounded-full ${
setting.enabled ? "bg-purple-600" : "bg-neutral-600"
}`}
>
<View
className={`w-5 h-5 rounded-full bg-white shadow-md transform ${
setting.enabled ? "translate-x-6" : "translate-x-1"
}`}
style={{ marginTop: 4 }}
/>
</View>
</View>
))}
<TouchableOpacity
onPress={hideModal}
className='bg-purple-600 px-4 py-3 rounded-lg mt-6'
>
<Text className='text-white font-semibold text-center'>Close</Text>
</TouchableOpacity>
</View>
);
};
export const ComplexModalExample = () => {
const { showModal } = useGlobalModal();
const handleOpenModal = () => {
showModal(<SettingsModalContent />);
};
return (
<TouchableOpacity
onPress={handleOpenModal}
className='bg-green-600 px-4 py-2 rounded-lg'
>
<Text className='text-white font-semibold'>Complex Component</Text>
</TouchableOpacity>
);
};
/**
* Example 4: Modal Triggered from Function (e.g., API response)
*/
export const useShowSuccessModal = () => {
const { showModal } = useGlobalModal();
return (message: string) => {
showModal(
<View className='p-6 items-center'>
<View className='bg-green-500 rounded-full p-4 mb-4'>
<Ionicons name='checkmark' size={48} color='white' />
</View>
<Text className='text-2xl font-bold mb-2 text-white'>Success!</Text>
<Text className='text-white text-center'>{message}</Text>
</View>,
);
};
};
/**
* Main Demo Component
*/
export const GlobalModalDemo = () => {
const showSuccess = useShowSuccessModal();
return (
<View className='p-6 gap-4'>
<Text className='text-2xl font-bold mb-4 text-white'>
Global Modal Examples
</Text>
<SimpleModalExample />
<CustomSnapPointsExample />
<ComplexModalExample />
<TouchableOpacity
onPress={() => showSuccess("Operation completed successfully!")}
className='bg-orange-600 px-4 py-2 rounded-lg'
>
<Text className='text-white font-semibold'>Show Success Modal</Text>
</TouchableOpacity>
</View>
);
};

View File

@@ -0,0 +1,71 @@
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
} from "@gorhom/bottom-sheet";
import { useCallback } from "react";
import { useGlobalModal } from "@/providers/GlobalModalProvider";
/**
* GlobalModal Component
*
* This component renders a global bottom sheet modal that can be controlled
* from anywhere in the app using the useGlobalModal hook.
*
* Place this component at the root level of your app (in _layout.tsx)
* after BottomSheetModalProvider.
*/
export const GlobalModal = () => {
const { hideModal, modalState, modalRef } = useGlobalModal();
const handleSheetChanges = useCallback(
(index: number) => {
if (index === -1) {
hideModal();
}
},
[hideModal],
);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const defaultOptions = {
enableDynamicSizing: true,
enablePanDownToClose: true,
backgroundStyle: {
backgroundColor: "#171717",
},
handleIndicatorStyle: {
backgroundColor: "white",
},
};
// Merge default options with provided options
const modalOptions = { ...defaultOptions, ...modalState.options };
return (
<BottomSheetModal
ref={modalRef}
{...(modalOptions.snapPoints
? { snapPoints: modalOptions.snapPoints }
: { enableDynamicSizing: modalOptions.enableDynamicSizing })}
onChange={handleSheetChanges}
backdropComponent={renderBackdrop}
handleIndicatorStyle={modalOptions.handleIndicatorStyle}
backgroundStyle={modalOptions.backgroundStyle}
enablePanDownToClose={modalOptions.enablePanDownToClose}
enableDismissOnClose
>
{modalState.content}
</BottomSheetModal>
);
};

View File

@@ -2,13 +2,11 @@ import type {
BaseItemDto, BaseItemDto,
MediaSourceInfo, MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models"; } from "@jellyfin/sdk/lib/generated-client/models";
import { useCallback, useMemo } from "react"; import { useCallback, useMemo, useState } from "react";
import { Platform, TouchableOpacity, View } from "react-native";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
interface Props extends React.ComponentProps<typeof View> { interface Props extends React.ComponentProps<typeof View> {
item: BaseItemDto; item: BaseItemDto;
@@ -23,7 +21,7 @@ export const MediaSourceSelector: React.FC<Props> = ({
...props ...props
}) => { }) => {
const isTv = Platform.isTV; const isTv = Platform.isTV;
const [open, setOpen] = useState(false);
const { t } = useTranslation(); const { t } = useTranslation();
const getDisplayName = useCallback((source: MediaSourceInfo) => { const getDisplayName = useCallback((source: MediaSourceInfo) => {
@@ -46,50 +44,60 @@ export const MediaSourceSelector: React.FC<Props> = ({
return getDisplayName(selected); return getDisplayName(selected);
}, [selected, getDisplayName]); }, [selected, getDisplayName]);
const optionGroups: OptionGroup[] = useMemo(
() => [
{
options:
item.MediaSources?.map((source) => ({
type: "radio" as const,
label: getDisplayName(source),
value: source,
selected: source.Id === selected?.Id,
onPress: () => onChange(source),
})) || [],
},
],
[item.MediaSources, selected, getDisplayName, onChange],
);
const handleOptionSelect = (optionId: string) => {
const selectedSource = item.MediaSources?.find(
(source, idx) => `${source.Id || idx}` === optionId,
);
if (selectedSource) {
onChange(selectedSource);
}
setOpen(false);
};
const trigger = (
<View className='flex flex-col' {...props}>
<Text className='opacity-50 mb-1 text-xs'>{t("item_card.video")}</Text>
<TouchableOpacity
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center'
onPress={() => setOpen(true)}
>
<Text numberOfLines={1}>{selectedName}</Text>
</TouchableOpacity>
</View>
);
if (isTv) return null; if (isTv) return null;
return ( return (
<View <PlatformDropdown
className='flex shrink' groups={optionGroups}
style={{ trigger={trigger}
minWidth: 50, title={t("item_card.video")}
open={open}
onOpenChange={setOpen}
onOptionSelect={handleOptionSelect}
expoUIConfig={{
hostStyle: { flex: 1 },
}} }}
> bottomSheetConfig={{
<DropdownMenu.Root> enablePanDownToClose: true,
<DropdownMenu.Trigger> }}
<View className='flex flex-col' {...props}> />
<Text className='opacity-50 mb-1 text-xs'>
{t("item_card.video")}
</Text>
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center'>
<Text numberOfLines={1}>{selectedName}</Text>
</TouchableOpacity>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side='bottom'
align='start'
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Media sources</DropdownMenu.Label>
{item.MediaSources?.map((source, idx: number) => (
<DropdownMenu.Item
key={idx.toString()}
onSelect={() => {
onChange(source);
}}
>
<DropdownMenu.ItemTitle>
{getDisplayName(source)}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
); );
}; };

View File

@@ -0,0 +1,323 @@
import { Button, ContextMenu, Host, Picker } from "@expo/ui/swift-ui";
import { Ionicons } from "@expo/vector-icons";
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
import React, { useEffect } from "react";
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { useGlobalModal } from "@/providers/GlobalModalProvider";
// Option types
export type RadioOption<T = any> = {
type: "radio";
label: string;
value: T;
selected: boolean;
onPress: () => void;
disabled?: boolean;
};
export type ToggleOption = {
type: "toggle";
label: string;
value: boolean;
onToggle: () => void;
disabled?: boolean;
};
export type Option = RadioOption | ToggleOption;
// Option group structure
export type OptionGroup = {
title?: string;
options: Option[];
};
interface PlatformDropdownProps {
trigger?: React.ReactNode;
title?: string;
groups: OptionGroup[];
open?: boolean;
onOpenChange?: (open: boolean) => void;
onOptionSelect?: (value?: any) => void;
expoUIConfig?: {
hostStyle?: any;
};
bottomSheetConfig?: {
enableDynamicSizing?: boolean;
enablePanDownToClose?: boolean;
};
}
const ToggleSwitch: React.FC<{ value: boolean }> = ({ value }) => (
<View
className={`w-12 h-7 rounded-full ${value ? "bg-purple-600" : "bg-neutral-600"} flex-row items-center`}
>
<View
className={`w-5 h-5 rounded-full bg-white shadow-md transform transition-transform ${
value ? "translate-x-6" : "translate-x-1"
}`}
/>
</View>
);
const OptionItem: React.FC<{ option: Option; isLast?: boolean }> = ({
option,
isLast,
}) => {
const isToggle = option.type === "toggle";
const handlePress = isToggle ? option.onToggle : option.onPress;
return (
<>
<TouchableOpacity
onPress={handlePress}
disabled={option.disabled}
className={`px-4 py-3 flex flex-row items-center justify-between ${
option.disabled ? "opacity-50" : ""
}`}
>
<Text className='flex-1 text-white'>{option.label}</Text>
{isToggle ? (
<ToggleSwitch value={option.value} />
) : option.selected ? (
<Ionicons name='checkmark-circle' size={24} color='#9333ea' />
) : (
<Ionicons name='ellipse-outline' size={24} color='#6b7280' />
)}
</TouchableOpacity>
{!isLast && (
<View
style={{
height: StyleSheet.hairlineWidth,
}}
className='bg-neutral-700 mx-4'
/>
)}
</>
);
};
const OptionGroupComponent: React.FC<{ group: OptionGroup }> = ({ group }) => (
<View className='mb-6'>
{group.title && (
<Text className='text-lg font-semibold mb-3 text-neutral-300'>
{group.title}
</Text>
)}
<View
style={{
borderRadius: 12,
overflow: "hidden",
}}
className='bg-neutral-800 rounded-xl overflow-hidden'
>
{group.options.map((option, index) => (
<OptionItem
key={index}
option={option}
isLast={index === group.options.length - 1}
/>
))}
</View>
</View>
);
const BottomSheetContent: React.FC<{
title?: string;
groups: OptionGroup[];
onOptionSelect?: (value?: any) => void;
}> = ({ title, groups, onOptionSelect }) => {
const insets = useSafeAreaInsets();
// Wrap the groups to call onOptionSelect when an option is pressed
const wrappedGroups = groups.map((group) => ({
...group,
options: group.options.map((option) => {
if (option.type === "radio") {
return {
...option,
onPress: () => {
option.onPress();
onOptionSelect?.(option.value);
},
};
}
if (option.type === "toggle") {
return {
...option,
onToggle: () => {
option.onToggle();
onOptionSelect?.(option.value);
},
};
}
return option;
}),
}));
return (
<BottomSheetScrollView
className='px-4 pb-8 pt-2'
style={{
paddingLeft: Math.max(16, insets.left),
paddingRight: Math.max(16, insets.right),
}}
>
{title && <Text className='font-bold text-2xl mb-6'>{title}</Text>}
{wrappedGroups.map((group, index) => (
<OptionGroupComponent key={index} group={group} />
))}
</BottomSheetScrollView>
);
};
const PlatformDropdownComponent = ({
trigger,
title,
groups,
open,
onOpenChange,
onOptionSelect,
expoUIConfig,
bottomSheetConfig,
}: PlatformDropdownProps) => {
const { showModal, hideModal } = useGlobalModal();
const handlePress = () => {
if (Platform.OS === "android") {
onOpenChange?.(true);
showModal(
<BottomSheetContent
title={title}
groups={groups}
onOptionSelect={onOptionSelect}
/>,
{
snapPoints: ["90%"],
enablePanDownToClose: bottomSheetConfig?.enablePanDownToClose ?? true,
},
);
}
};
// Close modal when open prop changes to false
useEffect(() => {
if (Platform.OS === "android" && open === false) {
hideModal();
}
}, [open, hideModal]);
if (Platform.OS === "ios") {
return (
<Host style={expoUIConfig?.hostStyle}>
<ContextMenu>
<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
const radioOptions = group.options.filter(
(opt) => opt.type === "radio",
) as RadioOption[];
const toggleOptions = group.options.filter(
(opt) => opt.type === "toggle",
) as ToggleOption[];
const items = [];
// Add Picker for radio options ONLY if there's a group title
// Otherwise render as individual buttons
if (radioOptions.length > 0) {
if (group.title) {
// Use Picker for grouped options
items.push(
<Picker
key={`picker-${groupIndex}`}
label={group.title}
options={radioOptions.map((opt) => opt.label)}
variant='menu'
selectedIndex={radioOptions.findIndex(
(opt) => opt.selected,
)}
onOptionSelected={(event: any) => {
const index = event.nativeEvent.index;
const selectedOption = radioOptions[index];
selectedOption?.onPress();
onOptionSelect?.(selectedOption?.value);
}}
/>,
);
} else {
// Render radio options as direct buttons
radioOptions.forEach((option, optionIndex) => {
items.push(
<Button
key={`radio-${groupIndex}-${optionIndex}`}
systemImage={
option.selected ? "checkmark.circle.fill" : "circle"
}
onPress={() => {
option.onPress();
onOptionSelect?.(option.value);
}}
disabled={option.disabled}
>
{option.label}
</Button>,
);
});
}
}
// Add Buttons for toggle options
toggleOptions.forEach((option, optionIndex) => {
items.push(
<Button
key={`toggle-${groupIndex}-${optionIndex}`}
systemImage={
option.value ? "checkmark.circle.fill" : "circle"
}
onPress={() => {
option.onToggle();
onOptionSelect?.(option.value);
}}
disabled={option.disabled}
>
{option.label}
</Button>,
);
});
return items;
})}
</ContextMenu.Items>
</ContextMenu>
</Host>
);
}
// Android: Trigger button for bottom modal
return (
<TouchableOpacity onPress={handlePress}>
{trigger || <Text className='text-white'>Open Menu</Text>}
</TouchableOpacity>
);
};
// Memoize to prevent unnecessary re-renders when parent re-renders
export const PlatformDropdown = React.memo(
PlatformDropdownComponent,
(prevProps, nextProps) => {
// Custom comparison - only re-render if these props actually change
return (
prevProps.title === nextProps.title &&
prevProps.open === nextProps.open &&
prevProps.groups === nextProps.groups && // Reference equality (works because we memoize groups in caller)
prevProps.trigger === nextProps.trigger // Reference equality
);
},
);

View File

@@ -1,11 +1,12 @@
import { useActionSheet } from "@expo/react-native-action-sheet"; import { useActionSheet } from "@expo/react-native-action-sheet";
import { Button, Host } from "@expo/ui/swift-ui";
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"; import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { useAtom, useAtomValue } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import { useCallback, useEffect } from "react"; import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Alert, TouchableOpacity, View } from "react-native"; import { Alert, Platform, TouchableOpacity, View } from "react-native";
import CastContext, { import CastContext, {
CastButton, CastButton,
PlayServicesState, PlayServicesState,
@@ -33,10 +34,9 @@ import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { chromecast } from "@/utils/profiles/chromecast"; import { chromecast } from "@/utils/profiles/chromecast";
import { chromecasth265 } from "@/utils/profiles/chromecasth265"; import { chromecasth265 } from "@/utils/profiles/chromecasth265";
import { runtimeTicksToMinutes } from "@/utils/time"; import { runtimeTicksToMinutes } from "@/utils/time";
import type { Button } from "./Button";
import type { SelectedOptions } from "./ItemContent"; import type { SelectedOptions } from "./ItemContent";
interface Props extends React.ComponentProps<typeof Button> { interface Props extends React.ComponentProps<typeof TouchableOpacity> {
item: BaseItemDto; item: BaseItemDto;
selectedOptions: SelectedOptions; selectedOptions: SelectedOptions;
isOffline?: boolean; isOffline?: boolean;
@@ -364,6 +364,46 @@ export const PlayButton: React.FC<Props> = ({
* ********************* * *********************
*/ */
if (Platform.OS === "ios")
return (
<Host
style={{
height: 50,
flex: 1,
}}
>
<Button
variant='glassProminent'
onPress={onPress}
color={effectiveColors.primary}
>
<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)}
</Animated.Text>
<Animated.Text style={animatedTextStyle}>
<Ionicons name='play-circle' size={24} />
</Animated.Text>
{client && (
<Animated.Text style={animatedTextStyle}>
<Feather name='cast' size={22} />
<CastButton tintColor='transparent' />
</Animated.Text>
)}
{!client && settings?.openInVLC && (
<Animated.Text style={animatedTextStyle}>
<MaterialCommunityIcons
name='vlc'
size={18}
color={animatedTextStyle.color}
/>
</Animated.Text>
)}
</View>
</Button>
</Host>
);
return ( return (
<TouchableOpacity <TouchableOpacity
disabled={!item} disabled={!item}

View File

@@ -1,12 +1,10 @@
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models"; import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
import { useMemo } from "react"; import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View } from "react-native";
import { tc } from "@/utils/textTools"; import { tc } from "@/utils/textTools";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { useTranslation } from "react-i18next";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
interface Props extends React.ComponentProps<typeof View> { interface Props extends React.ComponentProps<typeof View> {
source?: MediaSourceInfo; source?: MediaSourceInfo;
@@ -21,6 +19,8 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
...props ...props
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [open, setOpen] = useState(false);
const subtitleStreams = useMemo(() => { const subtitleStreams = useMemo(() => {
return source?.MediaStreams?.filter((x) => x.Type === "Subtitle"); return source?.MediaStreams?.filter((x) => x.Type === "Subtitle");
}, [source]); }, [source]);
@@ -30,64 +30,83 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
[subtitleStreams, selected], [subtitleStreams, selected],
); );
const optionGroups: OptionGroup[] = useMemo(() => {
const options = [
{
type: "radio" as const,
label: t("item_card.none"),
value: -1,
selected: selected === -1,
onPress: () => onChange(-1),
},
...(subtitleStreams?.map((subtitle, idx) => ({
type: "radio" as const,
label: subtitle.DisplayTitle || `Subtitle Stream ${idx + 1}`,
value: subtitle.Index,
selected: subtitle.Index === selected,
onPress: () => onChange(subtitle.Index ?? -1),
})) || []),
];
return [
{
options,
},
];
}, [subtitleStreams, selected, t, onChange]);
const handleOptionSelect = (optionId: string) => {
if (optionId === "none") {
onChange(-1);
} else {
const selectedStream = subtitleStreams?.find(
(subtitle, idx) => `${subtitle.Index || idx}` === optionId,
);
if (
selectedStream &&
selectedStream.Index !== undefined &&
selectedStream.Index !== null
) {
onChange(selectedStream.Index);
}
}
setOpen(false);
};
const trigger = (
<View className='flex flex-col' {...props}>
<Text numberOfLines={1} className='opacity-50 mb-1 text-xs'>
{t("item_card.subtitles")}
</Text>
<TouchableOpacity
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'
onPress={() => setOpen(true)}
>
<Text>
{selectedSubtitleSteam
? tc(selectedSubtitleSteam?.DisplayTitle, 7)
: t("item_card.none")}
</Text>
</TouchableOpacity>
</View>
);
if (Platform.isTV || subtitleStreams?.length === 0) return null; if (Platform.isTV || subtitleStreams?.length === 0) return null;
return ( return (
<View <PlatformDropdown
className='flex col shrink justify-start place-self-start items-start' groups={optionGroups}
style={{ trigger={trigger}
minWidth: 60, title={t("item_card.subtitles")}
maxWidth: 200, open={open}
onOpenChange={setOpen}
onOptionSelect={handleOptionSelect}
expoUIConfig={{
hostStyle: { flex: 1 },
}} }}
> bottomSheetConfig={{
<DropdownMenu.Root> enablePanDownToClose: true,
<DropdownMenu.Trigger> }}
<View className='flex flex-col ' {...props}> />
<Text numberOfLines={1} className='opacity-50 mb-1 text-xs'>
{t("item_card.subtitles")}
</Text>
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
<Text className=' '>
{selectedSubtitleSteam
? tc(selectedSubtitleSteam?.DisplayTitle, 7)
: t("item_card.none")}
</Text>
</TouchableOpacity>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side='bottom'
align='start'
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Subtitle tracks</DropdownMenu.Label>
<DropdownMenu.Item
key={"-1"}
onSelect={() => {
onChange(-1);
}}
>
<DropdownMenu.ItemTitle>None</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
{subtitleStreams?.map((subtitle, idx: number) => (
<DropdownMenu.Item
key={idx.toString()}
onSelect={() => {
if (subtitle.Index !== undefined && subtitle.Index !== null)
onChange(subtitle.Index);
}}
>
<DropdownMenu.ItemTitle>
{subtitle.DisplayTitle}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
); );
}; };

View File

@@ -21,15 +21,16 @@ import Animated, {
} from "react-native-reanimated"; } from "react-native-reanimated";
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings"; import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn"; import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
import { useNetworkStatus } from "@/hooks/useNetworkStatus"; import { useNetworkStatus } from "@/hooks/useNetworkStatus";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { ItemImage } from "./common/ItemImage"; import { ItemImage } from "../common/ItemImage";
import { getItemNavigation } from "./common/TouchableItemRouter"; import { getItemNavigation } from "../common/TouchableItemRouter";
import type { SelectedOptions } from "./ItemContent"; import type { SelectedOptions } from "../ItemContent";
import { PlayButton } from "./PlayButton"; import { PlayButton } from "../PlayButton";
import { PlayedStatus } from "./PlayedStatus"; import { MarkAsPlayedLargeButton } from "./MarkAsPlayedLargeButton";
interface AppleTVCarouselProps { interface AppleTVCarouselProps {
initialIndex?: number; initialIndex?: number;
@@ -45,10 +46,11 @@ const GRADIENT_HEIGHT_BOTTOM = 150;
const LOGO_HEIGHT = 80; const LOGO_HEIGHT = 80;
// Position Constants // Position Constants
const LOGO_BOTTOM_POSITION = 210; const LOGO_BOTTOM_POSITION = 260;
const GENRES_BOTTOM_POSITION = 170; const GENRES_BOTTOM_POSITION = 220;
const CONTROLS_BOTTOM_POSITION = 100; const OVERVIEW_BOTTOM_POSITION = 165;
const DOTS_BOTTOM_POSITION = 60; const CONTROLS_BOTTOM_POSITION = 80;
const DOTS_BOTTOM_POSITION = 40;
// Size Constants // Size Constants
const DOT_HEIGHT = 6; const DOT_HEIGHT = 6;
@@ -58,13 +60,15 @@ const PLAY_BUTTON_SKELETON_HEIGHT = 50;
const PLAYED_STATUS_SKELETON_SIZE = 40; const PLAYED_STATUS_SKELETON_SIZE = 40;
const TEXT_SKELETON_HEIGHT = 20; const TEXT_SKELETON_HEIGHT = 20;
const TEXT_SKELETON_WIDTH = 250; const TEXT_SKELETON_WIDTH = 250;
const OVERVIEW_SKELETON_HEIGHT = 16;
const OVERVIEW_SKELETON_WIDTH = 400;
const _EMPTY_STATE_ICON_SIZE = 64; const _EMPTY_STATE_ICON_SIZE = 64;
// Spacing Constants // Spacing Constants
const HORIZONTAL_PADDING = 40; const HORIZONTAL_PADDING = 40;
const DOT_PADDING = 2; const DOT_PADDING = 2;
const DOT_GAP = 4; const DOT_GAP = 4;
const CONTROLS_GAP = 20; const CONTROLS_GAP = 10;
const _TEXT_MARGIN_TOP = 16; const _TEXT_MARGIN_TOP = 16;
// Border Radius Constants // Border Radius Constants
@@ -83,13 +87,16 @@ const VELOCITY_THRESHOLD = 400;
// Text Constants // Text Constants
const GENRES_FONT_SIZE = 16; const GENRES_FONT_SIZE = 16;
const OVERVIEW_FONT_SIZE = 14;
const _EMPTY_STATE_FONT_SIZE = 18; const _EMPTY_STATE_FONT_SIZE = 18;
const TEXT_SHADOW_RADIUS = 2; const TEXT_SHADOW_RADIUS = 2;
const MAX_GENRES_COUNT = 2; const MAX_GENRES_COUNT = 2;
const MAX_BUTTON_WIDTH = 300; const MAX_BUTTON_WIDTH = 300;
const OVERVIEW_MAX_LINES = 2;
const OVERVIEW_MAX_WIDTH = "80%";
// Opacity Constants // Opacity Constants
const OVERLAY_OPACITY = 0.4; const OVERLAY_OPACITY = 0.3;
const DOT_INACTIVE_OPACITY = 0.6; const DOT_INACTIVE_OPACITY = 0.6;
const TEXT_OPACITY = 0.9; const TEXT_OPACITY = 0.9;
@@ -168,7 +175,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
userId: user.Id, userId: user.Id,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
includeItemTypes: ["Movie", "Series", "Episode"], includeItemTypes: ["Movie", "Series", "Episode"],
fields: ["Genres"], fields: ["Genres", "Overview"],
limit: 2, limit: 2,
}); });
return response.data.Items || []; return response.data.Items || [];
@@ -183,7 +190,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
if (!api || !user?.Id) return []; if (!api || !user?.Id) return [];
const response = await getTvShowsApi(api).getNextUp({ const response = await getTvShowsApi(api).getNextUp({
userId: user.Id, userId: user.Id,
fields: ["MediaSourceCount", "Genres"], fields: ["MediaSourceCount", "Genres", "Overview"],
limit: 2, limit: 2,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
enableResumable: false, enableResumable: false,
@@ -202,7 +209,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
const response = await getUserLibraryApi(api).getLatestMedia({ const response = await getUserLibraryApi(api).getLatestMedia({
userId: user.Id, userId: user.Id,
limit: 2, limit: 2,
fields: ["PrimaryImageAspectRatio", "Path", "Genres"], fields: ["PrimaryImageAspectRatio", "Path", "Genres", "Overview"],
imageTypeLimit: 1, imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"], enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
}); });
@@ -348,6 +355,8 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
}; };
}); });
const togglePlayedStatus = useMarkAsPlayed(items);
const renderDots = () => { const renderDots = () => {
if (!hasItems || items.length <= 1) return null; if (!hasItems || items.length <= 1) return null;
@@ -473,6 +482,36 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
/> />
</View> </View>
{/* Overview Skeleton */}
<View
style={{
position: "absolute",
bottom: OVERVIEW_BOTTOM_POSITION,
left: 0,
right: 0,
paddingHorizontal: HORIZONTAL_PADDING,
alignItems: "center",
gap: 6,
}}
>
<View
style={{
height: OVERVIEW_SKELETON_HEIGHT,
width: OVERVIEW_SKELETON_WIDTH,
backgroundColor: SKELETON_ELEMENT_COLOR,
borderRadius: TEXT_SKELETON_BORDER_RADIUS,
}}
/>
<View
style={{
height: OVERVIEW_SKELETON_HEIGHT,
width: OVERVIEW_SKELETON_WIDTH * 0.7,
backgroundColor: SKELETON_ELEMENT_COLOR,
borderRadius: TEXT_SKELETON_BORDER_RADIUS,
}}
/>
</View>
{/* Controls Skeleton */} {/* Controls Skeleton */}
<View <View
style={{ style={{
@@ -689,6 +728,39 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
</TouchableOpacity> </TouchableOpacity>
</View> </View>
{/* Overview Section - for Episodes and Movies */}
{(item.Type === "Episode" || item.Type === "Movie") &&
item.Overview && (
<View
style={{
position: "absolute",
bottom: OVERVIEW_BOTTOM_POSITION,
left: 0,
right: 0,
paddingHorizontal: HORIZONTAL_PADDING,
alignItems: "center",
}}
>
<TouchableOpacity onPress={() => navigateToItem(item)}>
<Animated.Text
numberOfLines={OVERVIEW_MAX_LINES}
style={{
color: `rgba(255, 255, 255, ${TEXT_OPACITY * 0.85})`,
fontSize: OVERVIEW_FONT_SIZE,
fontWeight: "400",
textAlign: "center",
maxWidth: OVERVIEW_MAX_WIDTH,
textShadowColor: TEXT_SHADOW_COLOR,
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: TEXT_SHADOW_RADIUS,
}}
>
{item.Overview}
</Animated.Text>
</TouchableOpacity>
</View>
)}
{/* Controls Section */} {/* Controls Section */}
<View <View
style={{ style={{
@@ -719,7 +791,10 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
</View> </View>
{/* Mark as Played */} {/* Mark as Played */}
<PlayedStatus items={[item]} size='large' /> <MarkAsPlayedLargeButton
isPlayed={item.UserData?.Played ?? false}
onToggle={togglePlayedStatus}
/>
</View> </View>
</View> </View>
</View> </View>

View File

@@ -0,0 +1,51 @@
import { Button, Host } from "@expo/ui/swift-ui";
import { Ionicons } from "@expo/vector-icons";
import { Platform, View } from "react-native";
import { RoundButton } from "../RoundButton";
interface MarkAsPlayedLargeButtonProps {
isPlayed: boolean;
onToggle: (isPlayed: boolean) => void;
}
export const MarkAsPlayedLargeButton: React.FC<
MarkAsPlayedLargeButtonProps
> = ({ isPlayed, onToggle }) => {
if (Platform.OS === "ios")
return (
<Host
style={{
flex: 0,
width: 50,
height: 50,
justifyContent: "center",
alignItems: "center",
flexDirection: "row",
}}
>
<Button onPress={() => onToggle(isPlayed)} variant='glass'>
<View>
<Ionicons
name='checkmark'
size={24}
color='white'
style={{
marginTop: 6,
marginLeft: 1,
}}
/>
</View>
</Button>
</Host>
);
return (
<View>
<RoundButton
size='large'
icon={isPlayed ? "checkmark" : "checkmark"}
onPress={() => onToggle(isPlayed)}
/>
</View>
);
};

View File

@@ -1,125 +0,0 @@
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import {
type PropsWithChildren,
type ReactNode,
useEffect,
useState,
} from "react";
import { Platform, TouchableOpacity, View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import DisabledSetting from "@/components/settings/DisabledSetting";
interface Props<T> {
data: T[];
disabled?: boolean;
placeholderText?: string;
keyExtractor: (item: T) => string;
titleExtractor: (item: T) => string | undefined;
title: string | ReactNode;
label: string;
onSelected: (...item: T[]) => void;
multiple?: boolean;
}
const Dropdown = <T,>({
data,
disabled,
placeholderText,
keyExtractor,
titleExtractor,
title,
label,
onSelected,
multiple = false,
...props
}: PropsWithChildren<Props<T> & ViewProps>) => {
const isTv = Platform.isTV;
const [selected, setSelected] = useState<T[]>();
useEffect(() => {
if (selected !== undefined) {
onSelected(...selected);
}
}, [selected, onSelected]);
if (isTv) return null;
return (
<DisabledSetting disabled={disabled === true} showText={false} {...props}>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{typeof title === "string" ? (
<View className='flex flex-col'>
<Text className='opacity-50 mb-1 text-xs'>{title}</Text>
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
<Text style={{}} className='' numberOfLines={1}>
{selected?.length !== undefined
? selected.map(titleExtractor).join(",")
: placeholderText}
</Text>
</TouchableOpacity>
</View>
) : (
title
)}
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={false}
side='bottom'
align='center'
alignOffset={0}
avoidCollisions={true}
collisionPadding={0}
sideOffset={0}
>
<DropdownMenu.Label>{label}</DropdownMenu.Label>
{data.map((item, _idx) =>
multiple ? (
<DropdownMenu.CheckboxItem
value={
selected?.some((s) => keyExtractor(s) === keyExtractor(item))
? "on"
: "off"
}
key={keyExtractor(item)}
onValueChange={(
next: "on" | "off",
_previous: "on" | "off",
) => {
setSelected((p) => {
const prev = p || [];
if (next === "on") {
return [...prev, item];
}
return [
...prev.filter(
(p) => keyExtractor(p) !== keyExtractor(item),
),
];
});
}}
>
<DropdownMenu.ItemTitle>
{titleExtractor(item)}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
) : (
<DropdownMenu.Item
key={keyExtractor(item)}
onSelect={() => setSelected([item])}
>
<DropdownMenu.ItemTitle>
{titleExtractor(item)}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
),
)}
</DropdownMenu.Content>
</DropdownMenu.Root>
</DisabledSetting>
);
};
export default Dropdown;

View File

@@ -1,14 +1,8 @@
import { useRouter, useSegments } from "expo-router"; import { useRouter, useSegments } from "expo-router";
import type React from "react"; import type React from "react";
import { type PropsWithChildren, useCallback, useMemo } from "react"; import { type PropsWithChildren } from "react";
import { TouchableOpacity, type TouchableOpacityProps } from "react-native"; import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
import * as ContextMenu from "zeego/context-menu";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { MediaType } from "@/utils/jellyseerr/server/constants/media"; import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import {
hasPermission,
Permission,
} from "@/utils/jellyseerr/server/lib/permissions";
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie"; import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person"; import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
import type { import type {
@@ -38,90 +32,33 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
}) => { }) => {
const router = useRouter(); const router = useRouter();
const segments = useSegments(); const segments = useSegments();
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
const from = (segments as string[])[2] || "(home)"; const from = (segments as string[])[2] || "(home)";
const autoApprove = useMemo(() => {
return (
jellyseerrUser &&
hasPermission(Permission.AUTO_APPROVE, jellyseerrUser.permissions, {
type: "or",
})
);
}, [jellyseerrApi, jellyseerrUser]);
const request = useCallback(() => {
if (!result) return;
requestMedia(mediaTitle, {
mediaId: result.id,
mediaType,
});
}, [jellyseerrApi, result]);
if (from === "(home)" || from === "(search)" || from === "(libraries)") if (from === "(home)" || from === "(search)" || from === "(libraries)")
return ( return (
<ContextMenu.Root> <TouchableOpacity
<ContextMenu.Trigger> onPress={() => {
<TouchableOpacity if (!result) return;
onPress={() => {
if (!result) return;
router.push({ router.push({
pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`, pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`,
// @ts-expect-error // @ts-expect-error
params: { params: {
...result, ...result,
mediaTitle, mediaTitle,
releaseYear, releaseYear,
canRequest: canRequest.toString(), canRequest: canRequest.toString(),
posterSrc, posterSrc,
mediaType, mediaType,
}, },
}); });
}} }}
{...props} {...props}
> >
{children} {children}
</TouchableOpacity> </TouchableOpacity>
</ContextMenu.Trigger>
<ContextMenu.Content
avoidCollisions
alignOffset={0}
collisionPadding={0}
loop={false}
key={"content"}
>
<ContextMenu.Label key='label-1'>Actions</ContextMenu.Label>
{canRequest && mediaType === MediaType.MOVIE && (
<ContextMenu.Item
key='item-1'
onSelect={() => {
if (autoApprove) {
request();
}
}}
shouldDismissMenuOnSelect
>
<ContextMenu.ItemTitle key='item-1-title'>
Request
</ContextMenu.ItemTitle>
<ContextMenu.ItemIcon
ios={{
name: "arrow.down.to.line",
pointSize: 18,
weight: "semibold",
scale: "medium",
hierarchicalColor: {
dark: "purple",
light: "purple",
},
}}
androidIconName='download'
/>
</ContextMenu.Item>
)}
</ContextMenu.Content>
</ContextMenu.Root>
); );
return null;
}; };

View File

@@ -14,7 +14,10 @@ export const DownloadSize: React.FC<DownloadSizeProps> = ({
...props ...props
}) => { }) => {
const { getDownloadedItemSize, getDownloadedItems } = useDownload(); const { getDownloadedItemSize, getDownloadedItems } = useDownload();
const downloadedFiles = getDownloadedItems(); const downloadedFiles = useMemo(
() => getDownloadedItems(),
[getDownloadedItems],
);
const [size, setSize] = useState<string | undefined>(); const [size, setSize] = useState<string | undefined>();
const itemIds = useMemo(() => items.map((i) => i.Id), [items]); const itemIds = useMemo(() => items.map((i) => i.Id), [items]);

View File

@@ -37,7 +37,7 @@ import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus"; import { eventBus } from "@/utils/eventBus";
import { AppleTVCarousel } from "../AppleTVCarousel"; import { AppleTVCarousel } from "../apple-tv-carousel/AppleTVCarousel";
type ScrollingCollectionListSection = { type ScrollingCollectionListSection = {
type: "ScrollingCollectionList"; type: "ScrollingCollectionList";
@@ -90,6 +90,11 @@ export const HomeIndex = () => {
prevIsConnected.current = isConnected; prevIsConnected.current = isConnected;
}, [isConnected, invalidateCache]); }, [isConnected, invalidateCache]);
const hasDownloads = useMemo(() => {
if (Platform.isTV) return false;
return getDownloadedItems().length > 0;
}, [getDownloadedItems]);
useEffect(() => { useEffect(() => {
if (Platform.isTV) { if (Platform.isTV) {
navigation.setOptions({ navigation.setOptions({
@@ -97,7 +102,6 @@ export const HomeIndex = () => {
}); });
return; return;
} }
const hasDownloads = getDownloadedItems().length > 0;
navigation.setOptions({ navigation.setOptions({
headerLeft: () => ( headerLeft: () => (
<TouchableOpacity <TouchableOpacity
@@ -114,7 +118,7 @@ export const HomeIndex = () => {
</TouchableOpacity> </TouchableOpacity>
), ),
}); });
}, [navigation, router]); }, [navigation, router, hasDownloads]);
useEffect(() => { useEffect(() => {
cleanCacheDirectory().catch((_e) => cleanCacheDirectory().catch((_e) =>

View File

@@ -8,10 +8,10 @@ import type { BottomSheetModalMethods } from "@gorhom/bottom-sheet/lib/typescrip
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { forwardRef, useCallback, useMemo, useState } from "react"; import { forwardRef, useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { View, type ViewProps } from "react-native"; import { TouchableOpacity, View, type ViewProps } from "react-native";
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
import Dropdown from "@/components/common/Dropdown";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { PlatformDropdown } from "@/components/PlatformDropdown";
import { useJellyseerr } from "@/hooks/useJellyseerr"; import { useJellyseerr } from "@/hooks/useJellyseerr";
import type { import type {
QualityProfile, QualityProfile,
@@ -138,6 +138,115 @@ const RequestModal = forwardRef<
}); });
}, [requestBody?.seasons]); }, [requestBody?.seasons]);
const pathTitleExtractor = (item: RootFolder) =>
`${item.path} (${item.freeSpace.bytesToReadable()})`;
const qualityProfileOptions = useMemo(
() => [
{
title: t("jellyseerr.quality_profile"),
options:
defaultServiceDetails?.profiles.map((profile) => ({
type: "radio" as const,
label: profile.name,
value: profile.id.toString(),
selected:
(requestOverrides.profileId || defaultProfile?.id) ===
profile.id,
onPress: () =>
setRequestOverrides((prev) => ({
...prev,
profileId: profile.id,
})),
})) || [],
},
],
[
defaultServiceDetails?.profiles,
defaultProfile,
requestOverrides.profileId,
t,
],
);
const rootFolderOptions = useMemo(
() => [
{
title: t("jellyseerr.root_folder"),
options:
defaultServiceDetails?.rootFolders.map((folder) => ({
type: "radio" as const,
label: pathTitleExtractor(folder),
value: folder.id.toString(),
selected:
(requestOverrides.rootFolder || defaultFolder?.path) ===
folder.path,
onPress: () =>
setRequestOverrides((prev) => ({
...prev,
rootFolder: folder.path,
})),
})) || [],
},
],
[
defaultServiceDetails?.rootFolders,
defaultFolder,
requestOverrides.rootFolder,
t,
],
);
const tagsOptions = useMemo(
() => [
{
title: t("jellyseerr.tags"),
options:
defaultServiceDetails?.tags.map((tag) => ({
type: "toggle" as const,
label: tag.label,
value:
requestOverrides.tags?.includes(tag.id) ||
defaultTags.some((dt) => dt.id === tag.id),
onToggle: () =>
setRequestOverrides((prev) => {
const currentTags = prev.tags || defaultTags.map((t) => t.id);
const hasTag = currentTags.includes(tag.id);
return {
...prev,
tags: hasTag
? currentTags.filter((id) => id !== tag.id)
: [...currentTags, tag.id],
};
}),
})) || [],
},
],
[defaultServiceDetails?.tags, defaultTags, requestOverrides.tags, t],
);
const usersOptions = useMemo(
() => [
{
title: t("jellyseerr.request_as"),
options:
users?.map((user) => ({
type: "radio" as const,
label: user.displayName,
value: user.id.toString(),
selected:
(requestOverrides.userId || jellyseerrUser?.id) === user.id,
onPress: () =>
setRequestOverrides((prev) => ({
...prev,
userId: user.id,
})),
})) || [],
},
],
[users, jellyseerrUser, requestOverrides.userId, t],
);
const request = useCallback(() => { const request = useCallback(() => {
const body = { const body = {
is4k: defaultService?.is4k || defaultServiceDetails?.server.is4k, is4k: defaultService?.is4k || defaultServiceDetails?.server.is4k,
@@ -163,9 +272,6 @@ const RequestModal = forwardRef<
defaultTags, defaultTags,
]); ]);
const pathTitleExtractor = (item: RootFolder) =>
`${item.path} (${item.freeSpace.bytesToReadable()})`;
return ( return (
<BottomSheetModal <BottomSheetModal
ref={ref} ref={ref}
@@ -199,70 +305,104 @@ const RequestModal = forwardRef<
<View className='flex flex-col space-y-2'> <View className='flex flex-col space-y-2'>
{defaultService && defaultServiceDetails && users && ( {defaultService && defaultServiceDetails && users && (
<> <>
<Dropdown <View className='flex flex-col'>
data={defaultServiceDetails.profiles} <Text className='opacity-50 mb-1 text-xs'>
titleExtractor={(item) => item.name} {t("jellyseerr.quality_profile")}
placeholderText={ </Text>
requestOverrides.profileName || defaultProfile.name <PlatformDropdown
} groups={qualityProfileOptions}
keyExtractor={(item) => item.id.toString()} trigger={
label={t("jellyseerr.quality_profile")} <TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
onSelected={(item) => <Text numberOfLines={1}>
item && {defaultServiceDetails.profiles.find(
setRequestOverrides((prev) => ({ (p) =>
...prev, p.id ===
profileId: item?.id, (requestOverrides.profileId ||
})) defaultProfile?.id),
} )?.name || defaultProfile?.name}
title={t("jellyseerr.quality_profile")} </Text>
/> </TouchableOpacity>
<Dropdown }
data={defaultServiceDetails.rootFolders} title={t("jellyseerr.quality_profile")}
titleExtractor={pathTitleExtractor} />
placeholderText={ </View>
defaultFolder ? pathTitleExtractor(defaultFolder) : ""
} <View className='flex flex-col'>
keyExtractor={(item) => item.id.toString()} <Text className='opacity-50 mb-1 text-xs'>
label={t("jellyseerr.root_folder")} {t("jellyseerr.root_folder")}
onSelected={(item) => </Text>
item && <PlatformDropdown
setRequestOverrides((prev) => ({ groups={rootFolderOptions}
...prev, trigger={
rootFolder: item.path, <TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
})) <Text numberOfLines={1}>
} {defaultServiceDetails.rootFolders.find(
title={t("jellyseerr.root_folder")} (f) =>
/> f.path ===
<Dropdown (requestOverrides.rootFolder ||
multiple defaultFolder?.path),
data={defaultServiceDetails.tags} )
titleExtractor={(item) => item.label} ? pathTitleExtractor(
placeholderText={defaultTags.map((t) => t.label).join(",")} defaultServiceDetails.rootFolders.find(
keyExtractor={(item) => item.id.toString()} (f) =>
label={t("jellyseerr.tags")} f.path ===
onSelected={(...selected) => (requestOverrides.rootFolder ||
setRequestOverrides((prev) => ({ defaultFolder?.path),
...prev, )!,
tags: selected.map((i) => i.id), )
})) : pathTitleExtractor(defaultFolder!)}
} </Text>
title={t("jellyseerr.tags")} </TouchableOpacity>
/> }
<Dropdown title={t("jellyseerr.root_folder")}
data={users} />
titleExtractor={(item) => item.displayName} </View>
placeholderText={jellyseerrUser!.displayName}
keyExtractor={(item) => item.id.toString() || ""} <View className='flex flex-col'>
label={t("jellyseerr.request_as")} <Text className='opacity-50 mb-1 text-xs'>
onSelected={(item) => {t("jellyseerr.tags")}
item && </Text>
setRequestOverrides((prev) => ({ <PlatformDropdown
...prev, groups={tagsOptions}
userId: item?.id, trigger={
})) <TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
} <Text numberOfLines={1}>
title={t("jellyseerr.request_as")} {requestOverrides.tags
/> ? defaultServiceDetails.tags
.filter((t) =>
requestOverrides.tags!.includes(t.id),
)
.map((t) => t.label)
.join(", ") ||
defaultTags.map((t) => t.label).join(", ")
: defaultTags.map((t) => t.label).join(", ")}
</Text>
</TouchableOpacity>
}
title={t("jellyseerr.tags")}
/>
</View>
<View className='flex flex-col'>
<Text className='opacity-50 mb-1 text-xs'>
{t("jellyseerr.request_as")}
</Text>
<PlatformDropdown
groups={usersOptions}
trigger={
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
<Text numberOfLines={1}>
{users.find(
(u) =>
u.id ===
(requestOverrides.userId || jellyseerrUser?.id),
)?.displayName || jellyseerrUser!.displayName}
</Text>
</TouchableOpacity>
}
title={t("jellyseerr.request_as")}
/>
</View>
</> </>
)} )}
</View> </View>

View File

@@ -0,0 +1,115 @@
import { Button, ContextMenu, Host, Picker } from "@expo/ui/swift-ui";
import { Platform, View } from "react-native";
import { FilterButton } from "@/components/filters/FilterButton";
import { JellyseerrSearchSort } from "@/components/jellyseerr/JellyseerrIndexPage";
interface DiscoverFiltersProps {
searchFilterId: string;
orderFilterId: string;
jellyseerrOrderBy: JellyseerrSearchSort;
setJellyseerrOrderBy: (value: JellyseerrSearchSort) => void;
jellyseerrSortOrder: "asc" | "desc";
setJellyseerrSortOrder: (value: "asc" | "desc") => void;
t: (key: string) => string;
}
const sortOptions = Object.keys(JellyseerrSearchSort).filter((v) =>
Number.isNaN(Number(v)),
);
const orderOptions = ["asc", "desc"] as const;
export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
searchFilterId,
orderFilterId,
jellyseerrOrderBy,
setJellyseerrOrderBy,
jellyseerrSortOrder,
setJellyseerrSortOrder,
t,
}) => {
if (Platform.OS === "ios") {
return (
<Host
style={{
justifyContent: "center",
alignItems: "center",
overflow: "visible",
height: 40,
width: 50,
marginLeft: "auto",
}}
>
<ContextMenu>
<ContextMenu.Trigger>
<Button
variant='glass'
modifiers={[]}
systemImage='line.3.horizontal.decrease.circle'
></Button>
</ContextMenu.Trigger>
<ContextMenu.Items>
<Picker
label={t("library.filters.sort_by")}
options={sortOptions.map((item) =>
t(`home.settings.plugins.jellyseerr.order_by.${item}`),
)}
variant='menu'
selectedIndex={sortOptions.indexOf(
jellyseerrOrderBy as unknown as string,
)}
onOptionSelected={(event: any) => {
const index = event.nativeEvent.index;
setJellyseerrOrderBy(
sortOptions[index] as unknown as JellyseerrSearchSort,
);
}}
/>
<Picker
label={t("library.filters.sort_order")}
options={orderOptions.map((item) => t(`library.filters.${item}`))}
variant='menu'
selectedIndex={orderOptions.indexOf(jellyseerrSortOrder)}
onOptionSelected={(event: any) => {
const index = event.nativeEvent.index;
setJellyseerrSortOrder(orderOptions[index]);
}}
/>
</ContextMenu.Items>
</ContextMenu>
</Host>
);
}
// Android UI
return (
<View className='flex flex-row justify-end items-center space-x-1'>
<FilterButton
id={searchFilterId}
queryKey='jellyseerr_search'
queryFn={async () =>
Object.keys(JellyseerrSearchSort).filter((v) =>
Number.isNaN(Number(v)),
)
}
set={(value) => setJellyseerrOrderBy(value[0])}
values={[jellyseerrOrderBy]}
title={t("library.filters.sort_by")}
renderItemLabel={(item) =>
t(`home.settings.plugins.jellyseerr.order_by.${item}`)
}
disableSearch={true}
/>
<FilterButton
id={orderFilterId}
queryKey='jellysearr_search'
queryFn={async () => ["asc", "desc"]}
set={(value) => setJellyseerrSortOrder(value[0])}
values={[jellyseerrSortOrder]}
title={t("library.filters.sort_order")}
renderItemLabel={(item) => t(`library.filters.${item}`)}
disableSearch={true}
/>
</View>
);
};

View File

@@ -0,0 +1,76 @@
import { Button, Host } from "@expo/ui/swift-ui";
import { Platform, TouchableOpacity, View } from "react-native";
import { Tag } from "@/components/GenreTags";
type SearchType = "Library" | "Discover";
interface SearchTabButtonsProps {
searchType: SearchType;
setSearchType: (type: SearchType) => void;
t: (key: string) => string;
}
export const SearchTabButtons: React.FC<SearchTabButtonsProps> = ({
searchType,
setSearchType,
t,
}) => {
if (Platform.OS === "ios") {
return (
<>
<Host
style={{
height: 40,
width: 80,
flexDirection: "row",
gap: 10,
justifyContent: "space-between",
}}
>
<Button
variant={searchType === "Library" ? "glassProminent" : "glass"}
onPress={() => setSearchType("Library")}
>
{t("search.library")}
</Button>
</Host>
<Host
style={{
height: 40,
width: 100,
flexDirection: "row",
gap: 10,
justifyContent: "space-between",
}}
>
<Button
variant={searchType === "Discover" ? "glassProminent" : "glass"}
onPress={() => setSearchType("Discover")}
>
{t("search.discover")}
</Button>
</Host>
</>
);
}
// Android UI
return (
<View className='flex flex-row gap-1 mr-1'>
<TouchableOpacity onPress={() => setSearchType("Library")}>
<Tag
text={t("search.library")}
textClass='p-1'
className={searchType === "Library" ? "bg-purple-600" : undefined}
/>
</TouchableOpacity>
<TouchableOpacity onPress={() => setSearchType("Discover")}>
<Tag
text={t("search.discover")}
textClass='p-1'
className={searchType === "Discover" ? "bg-purple-600" : undefined}
/>
</TouchableOpacity>
</View>
);
};

View File

@@ -1,11 +1,9 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useEffect, useMemo } from "react";
import { Platform, TouchableOpacity, View } from "react-native";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { t } from "i18next"; import { t } from "i18next";
import { useEffect, useMemo } from "react";
import { Platform, View } from "react-native";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
import { PlatformDropdown } from "../PlatformDropdown";
type Props = { type Props = {
item: BaseItemDto; item: BaseItemDto;
@@ -55,6 +53,32 @@ export const SeasonDropdown: React.FC<Props> = ({
[state, item, keys], [state, item, keys],
); );
const sortByIndex = (a: BaseItemDto, b: BaseItemDto) =>
Number(a[keys.index]) - Number(b[keys.index]);
const optionGroups = useMemo(
() => [
{
title: t("item_card.seasons"),
options:
seasons?.sort(sortByIndex).map((season: any) => {
const title =
season[keys.title] ||
season.Name ||
`Season ${season.IndexNumber}`;
return {
type: "radio" as const,
label: title,
value: season.Id || season.IndexNumber,
selected: Number(season[keys.index]) === Number(seasonIndex),
onPress: () => onSelect(season),
};
}) || [],
},
],
[seasons, keys, seasonIndex, onSelect],
);
useEffect(() => { useEffect(() => {
if (isTv) return; if (isTv) return;
if (seasons && seasons.length > 0 && seasonIndex === undefined) { if (seasons && seasons.length > 0 && seasonIndex === undefined) {
@@ -96,45 +120,19 @@ export const SeasonDropdown: React.FC<Props> = ({
keys, keys,
]); ]);
const sortByIndex = (a: BaseItemDto, b: BaseItemDto) =>
Number(a[keys.index]) - Number(b[keys.index]);
if (isTv) return null; if (isTv) return null;
return ( return (
<DropdownMenu.Root> <PlatformDropdown
<DropdownMenu.Trigger> groups={optionGroups}
<View className='flex flex-row'> trigger={
<TouchableOpacity className='bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between'> <View className='bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between'>
<Text> <Text>
{t("item_card.season")} {seasonIndex} {t("item_card.season")} {seasonIndex}
</Text> </Text>
</TouchableOpacity>
</View> </View>
</DropdownMenu.Trigger> }
<DropdownMenu.Content title={t("item_card.seasons")}
loop={true} />
side='bottom'
align='start'
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>{t("item_card.seasons")}</DropdownMenu.Label>
{seasons?.sort(sortByIndex).map((season: any) => {
const title =
season[keys.title] || season.Name || `Season ${season.IndexNumber}`;
return (
<DropdownMenu.Item
key={season.Id || season.IndexNumber}
onSelect={() => onSelect(season)}
>
<DropdownMenu.ItemTitle>{title}</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
);
})}
</DropdownMenu.Content>
</DropdownMenu.Root>
); );
}; };

View File

@@ -29,7 +29,10 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const { getDownloadedItems } = useDownload(); const { getDownloadedItems } = useDownload();
const downloadedFiles = getDownloadedItems(); const downloadedFiles = useMemo(
() => getDownloadedItems(),
[getDownloadedItems],
);
const scrollRef = useRef<HorizontalScrollRef>(null); const scrollRef = useRef<HorizontalScrollRef>(null);

View File

@@ -1,12 +1,12 @@
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null; import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View, type ViewProps } from "react-native"; import { Platform, View, type ViewProps } from "react-native";
import { APP_LANGUAGES } from "@/i18n"; import { APP_LANGUAGES } from "@/i18n";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup"; import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem"; import { ListItem } from "../list/ListItem";
import { PlatformDropdown } from "../PlatformDropdown";
interface Props extends ViewProps {} interface Props extends ViewProps {}
@@ -15,6 +15,31 @@ export const AppLanguageSelector: React.FC<Props> = () => {
const { settings, updateSettings } = useSettings(); const { settings, updateSettings } = useSettings();
const { t } = useTranslation(); const { t } = useTranslation();
const optionGroups = useMemo(() => {
const options = [
{
type: "radio" as const,
label: t("home.settings.languages.system"),
value: "system",
selected: !settings?.preferedLanguage,
onPress: () => updateSettings({ preferedLanguage: undefined }),
},
...APP_LANGUAGES.map((lang) => ({
type: "radio" as const,
label: lang.label,
value: lang.value,
selected: lang.value === settings?.preferedLanguage,
onPress: () => updateSettings({ preferedLanguage: lang.value }),
})),
];
return [
{
options,
},
];
}, [settings?.preferedLanguage, t, updateSettings]);
if (isTv) return null; if (isTv) return null;
if (!settings) return null; if (!settings) return null;
@@ -22,54 +47,19 @@ export const AppLanguageSelector: React.FC<Props> = () => {
<View> <View>
<ListGroup title={t("home.settings.languages.title")}> <ListGroup title={t("home.settings.languages.title")}>
<ListItem title={t("home.settings.languages.app_language")}> <ListItem title={t("home.settings.languages.app_language")}>
<DropdownMenu.Root> <PlatformDropdown
<DropdownMenu.Trigger> groups={optionGroups}
<TouchableOpacity className='bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between'> trigger={
<View className='bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between'>
<Text> <Text>
{APP_LANGUAGES.find( {APP_LANGUAGES.find(
(l) => l.value === settings?.preferedLanguage, (l) => l.value === settings?.preferedLanguage,
)?.label || t("home.settings.languages.system")} )?.label || t("home.settings.languages.system")}
</Text> </Text>
</TouchableOpacity> </View>
</DropdownMenu.Trigger> }
<DropdownMenu.Content title={t("home.settings.languages.title")}
loop={true} />
side='bottom'
align='start'
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>
{t("home.settings.languages.title")}
</DropdownMenu.Label>
<DropdownMenu.Item
key={"unknown"}
onSelect={() => {
updateSettings({
preferedLanguage: undefined,
});
}}
>
<DropdownMenu.ItemTitle>
{t("home.settings.languages.system")}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
{APP_LANGUAGES?.map((l) => (
<DropdownMenu.Item
key={l?.value ?? "unknown"}
onSelect={() => {
updateSettings({
preferedLanguage: l.value,
});
}}
>
<DropdownMenu.ItemTitle>{l.label}</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</ListItem> </ListItem>
</ListGroup> </ListGroup>
</View> </View>

View File

@@ -1,14 +1,13 @@
import { Platform, TouchableOpacity, View, type ViewProps } from "react-native";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, View, type ViewProps } from "react-native";
import { Switch } from "react-native-gesture-handler"; import { Switch } from "react-native-gesture-handler";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup"; import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem"; import { ListItem } from "../list/ListItem";
import { PlatformDropdown } from "../PlatformDropdown";
import { useMedia } from "./MediaContext"; import { useMedia } from "./MediaContext";
interface Props extends ViewProps {} interface Props extends ViewProps {}
@@ -22,6 +21,39 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
const cultures = media.cultures; const cultures = media.cultures;
const { t } = useTranslation(); const { t } = useTranslation();
const optionGroups = useMemo(() => {
const options = [
{
type: "radio" as const,
label: t("home.settings.audio.none"),
value: "none",
selected: !settings?.defaultAudioLanguage,
onPress: () => updateSettings({ defaultAudioLanguage: null }),
},
...(cultures?.map((culture) => ({
type: "radio" as const,
label:
culture.DisplayName ||
culture.ThreeLetterISOLanguageName ||
"Unknown",
value:
culture.ThreeLetterISOLanguageName ||
culture.DisplayName ||
"unknown",
selected:
culture.ThreeLetterISOLanguageName ===
settings?.defaultAudioLanguage?.ThreeLetterISOLanguageName,
onPress: () => updateSettings({ defaultAudioLanguage: culture }),
})) || []),
];
return [
{
options,
},
];
}, [cultures, settings?.defaultAudioLanguage, t, updateSettings]);
if (isTv) return null; if (isTv) return null;
if (!settings) return null; if (!settings) return null;
@@ -48,9 +80,10 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
/> />
</ListItem> </ListItem>
<ListItem title={t("home.settings.audio.audio_language")}> <ListItem title={t("home.settings.audio.audio_language")}>
<DropdownMenu.Root> <PlatformDropdown
<DropdownMenu.Trigger> groups={optionGroups}
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3 '> trigger={
<View className='flex flex-row items-center justify-between py-3 pl-3'>
<Text className='mr-1 text-[#8E8D91]'> <Text className='mr-1 text-[#8E8D91]'>
{settings?.defaultAudioLanguage?.DisplayName || {settings?.defaultAudioLanguage?.DisplayName ||
t("home.settings.audio.none")} t("home.settings.audio.none")}
@@ -60,48 +93,10 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
size={18} size={18}
color='#5A5960' color='#5A5960'
/> />
</TouchableOpacity> </View>
</DropdownMenu.Trigger> }
<DropdownMenu.Content title={t("home.settings.audio.language")}
loop={true} />
side='bottom'
align='start'
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>
{t("home.settings.audio.language")}
</DropdownMenu.Label>
<DropdownMenu.Item
key={"none-audio"}
onSelect={() => {
updateSettings({
defaultAudioLanguage: null,
});
}}
>
<DropdownMenu.ItemTitle>
{t("home.settings.audio.none")}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
{cultures?.map((l) => (
<DropdownMenu.Item
key={l?.ThreeLetterISOLanguageName ?? "unknown"}
onSelect={() => {
updateSettings({
defaultAudioLanguage: l,
});
}}
>
<DropdownMenu.ItemTitle>
{l.DisplayName}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</ListItem> </ListItem>
</ListGroup> </ListGroup>
</View> </View>

View File

@@ -5,10 +5,10 @@ import { TFunction } from "i18next";
import type React from "react"; import type React from "react";
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Linking, Platform, Switch, TouchableOpacity } from "react-native"; import { Linking, Platform, Switch, View } from "react-native";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import { BITRATES } from "@/components/BitrateSelector"; import { BITRATES } from "@/components/BitrateSelector";
import Dropdown from "@/components/common/Dropdown"; import { PlatformDropdown } from "@/components/PlatformDropdown";
import DisabledSetting from "@/components/settings/DisabledSetting"; import DisabledSetting from "@/components/settings/DisabledSetting";
import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings"; import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
@@ -89,6 +89,52 @@ export const OtherSettings: React.FC = () => {
[], [],
); );
const orientationOptions = useMemo(
() => [
{
options: orientations.map((orientation) => ({
type: "radio" as const,
label: t(ScreenOrientationEnum[orientation]),
value: String(orientation),
selected: orientation === settings?.defaultVideoOrientation,
onPress: () =>
updateSettings({ defaultVideoOrientation: orientation }),
})),
},
],
[orientations, settings?.defaultVideoOrientation, t, updateSettings],
);
const bitrateOptions = useMemo(
() => [
{
options: BITRATES.map((bitrate) => ({
type: "radio" as const,
label: bitrate.key,
value: bitrate.key,
selected: bitrate.key === settings?.defaultBitrate?.key,
onPress: () => updateSettings({ defaultBitrate: bitrate }),
})),
},
],
[settings?.defaultBitrate?.key, t, updateSettings],
);
const autoPlayEpisodeOptions = useMemo(
() => [
{
options: AUTOPLAY_EPISODES_COUNT(t).map((item) => ({
type: "radio" as const,
label: item.key,
value: item.key,
selected: item.key === settings?.maxAutoPlayEpisodeCount?.key,
onPress: () => updateSettings({ maxAutoPlayEpisodeCount: item }),
})),
},
],
[settings?.maxAutoPlayEpisodeCount?.key, t, updateSettings],
);
if (!settings) return null; if (!settings) return null;
return ( return (
@@ -114,16 +160,10 @@ export const OtherSettings: React.FC = () => {
settings.followDeviceOrientation settings.followDeviceOrientation
} }
> >
<Dropdown <PlatformDropdown
data={orientations} groups={orientationOptions}
disabled={ trigger={
pluginSettings?.defaultVideoOrientation?.locked || <View className='flex flex-row items-center justify-between py-3 pl-3'>
settings.followDeviceOrientation
}
keyExtractor={String}
titleExtractor={(item) => t(ScreenOrientationEnum[item])}
title={
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
<Text className='mr-1 text-[#8E8D91]'> <Text className='mr-1 text-[#8E8D91]'>
{t( {t(
orientationTranslations[ orientationTranslations[
@@ -136,12 +176,9 @@ export const OtherSettings: React.FC = () => {
size={18} size={18}
color='#5A5960' color='#5A5960'
/> />
</TouchableOpacity> </View>
}
label={t("home.settings.other.orientation")}
onSelected={(defaultVideoOrientation) =>
updateSettings({ defaultVideoOrientation })
} }
title={t("home.settings.other.orientation")}
/> />
</ListItem> </ListItem>
@@ -214,13 +251,10 @@ export const OtherSettings: React.FC = () => {
title={t("home.settings.other.default_quality")} title={t("home.settings.other.default_quality")}
disabled={pluginSettings?.defaultBitrate?.locked} disabled={pluginSettings?.defaultBitrate?.locked}
> >
<Dropdown <PlatformDropdown
data={BITRATES} groups={bitrateOptions}
disabled={pluginSettings?.defaultBitrate?.locked} trigger={
keyExtractor={(item) => item.key} <View className='flex flex-row items-center justify-between py-3 pl-3'>
titleExtractor={(item) => item.key}
title={
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
<Text className='mr-1 text-[#8E8D91]'> <Text className='mr-1 text-[#8E8D91]'>
{settings.defaultBitrate?.key} {settings.defaultBitrate?.key}
</Text> </Text>
@@ -229,10 +263,9 @@ export const OtherSettings: React.FC = () => {
size={18} size={18}
color='#5A5960' color='#5A5960'
/> />
</TouchableOpacity> </View>
} }
label={t("home.settings.other.default_quality")} title={t("home.settings.other.default_quality")}
onSelected={(defaultBitrate) => updateSettings({ defaultBitrate })}
/> />
</ListItem> </ListItem>
<ListItem <ListItem
@@ -248,12 +281,10 @@ export const OtherSettings: React.FC = () => {
/> />
</ListItem> </ListItem>
<ListItem title={t("home.settings.other.max_auto_play_episode_count")}> <ListItem title={t("home.settings.other.max_auto_play_episode_count")}>
<Dropdown <PlatformDropdown
data={AUTOPLAY_EPISODES_COUNT(t)} groups={autoPlayEpisodeOptions}
keyExtractor={(item) => item.key} trigger={
titleExtractor={(item) => item.key} <View className='flex flex-row items-center justify-between py-3 pl-3'>
title={
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
<Text className='mr-1 text-[#8E8D91]'> <Text className='mr-1 text-[#8E8D91]'>
{t(settings?.maxAutoPlayEpisodeCount.key)} {t(settings?.maxAutoPlayEpisodeCount.key)}
</Text> </Text>
@@ -262,12 +293,9 @@ export const OtherSettings: React.FC = () => {
size={18} size={18}
color='#5A5960' color='#5A5960'
/> />
</TouchableOpacity> </View>
}
label={t("home.settings.other.max_auto_play_episode_count")}
onSelected={(maxAutoPlayEpisodeCount) =>
updateSettings({ maxAutoPlayEpisodeCount })
} }
title={t("home.settings.other.max_auto_play_episode_count")}
/> />
</ListItem> </ListItem>
</ListGroup> </ListGroup>

View File

@@ -1,23 +1,25 @@
import { Platform, TouchableOpacity, View, type ViewProps } from "react-native";
const _DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client"; import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, View, type ViewProps } from "react-native";
import { Switch } from "react-native-gesture-handler"; import { Switch } from "react-native-gesture-handler";
import Dropdown from "@/components/common/Dropdown";
import { Stepper } from "@/components/inputs/Stepper"; import { Stepper } from "@/components/inputs/Stepper";
import {
OUTLINE_THICKNESS,
type OutlineThickness,
VLC_COLORS,
type VLCColor,
} from "@/constants/SubtitleConstants";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
import { ListGroup } from "../list/ListGroup"; import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem"; import { ListItem } from "../list/ListItem";
import { PlatformDropdown } from "../PlatformDropdown";
import { useMedia } from "./MediaContext"; import { useMedia } from "./MediaContext";
interface Props extends ViewProps {} interface Props extends ViewProps {}
import { OUTLINE_THICKNESS, VLC_COLORS } from "@/constants/SubtitleConstants";
export const SubtitleToggles: React.FC<Props> = ({ ...props }) => { export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
const isTv = Platform.isTV; const isTv = Platform.isTV;
@@ -27,18 +29,6 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
const cultures = media.cultures; const cultures = media.cultures;
const { t } = useTranslation(); const { t } = useTranslation();
// Get VLC subtitle settings from the settings system
const textColor = pluginSettings?.vlcTextColor ?? "White";
const backgroundColor = pluginSettings?.vlcBackgroundColor ?? "Black";
const outlineColor = pluginSettings?.vlcOutlineColor ?? "Black";
const outlineThickness = pluginSettings?.vlcOutlineThickness ?? "Normal";
const backgroundOpacity = pluginSettings?.vlcBackgroundOpacity ?? 128;
const outlineOpacity = pluginSettings?.vlcOutlineOpacity ?? 255;
const isBold = pluginSettings?.vlcIsBold ?? false;
if (isTv) return null;
if (!settings) return null;
const subtitleModes = [ const subtitleModes = [
SubtitlePlaybackMode.Default, SubtitlePlaybackMode.Default,
SubtitlePlaybackMode.Smart, SubtitlePlaybackMode.Smart,
@@ -56,6 +46,133 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
[SubtitlePlaybackMode.None]: "home.settings.subtitles.modes.None", [SubtitlePlaybackMode.None]: "home.settings.subtitles.modes.None",
}; };
const subtitleLanguageOptionGroups = useMemo(() => {
const options = [
{
type: "radio" as const,
label: t("home.settings.subtitles.none"),
value: "none",
selected: !settings?.defaultSubtitleLanguage,
onPress: () => updateSettings({ defaultSubtitleLanguage: null }),
},
...(cultures?.map((culture) => ({
type: "radio" as const,
label: culture.DisplayName || "Unknown",
value:
culture.ThreeLetterISOLanguageName ||
culture.DisplayName ||
"unknown",
selected:
culture.ThreeLetterISOLanguageName ===
settings?.defaultSubtitleLanguage?.ThreeLetterISOLanguageName,
onPress: () => updateSettings({ defaultSubtitleLanguage: culture }),
})) || []),
];
return [
{
options,
},
];
}, [cultures, settings?.defaultSubtitleLanguage, t, updateSettings]);
const subtitleModeOptionGroups = useMemo(() => {
const options = subtitleModes.map((mode) => ({
type: "radio" as const,
label: t(subtitleModeKeys[mode]) || String(mode),
value: String(mode),
selected: mode === settings?.subtitleMode,
onPress: () => updateSettings({ subtitleMode: mode }),
}));
return [
{
options,
},
];
}, [settings?.subtitleMode, t, updateSettings]);
const textColorOptionGroups = useMemo(() => {
const colors = Object.keys(VLC_COLORS) as VLCColor[];
const options = colors.map((color) => ({
type: "radio" as const,
label: t(`home.settings.subtitles.colors.${color}`),
value: color,
selected: (settings?.vlcTextColor || "White") === color,
onPress: () => updateSettings({ vlcTextColor: color }),
}));
return [{ options }];
}, [settings?.vlcTextColor, t, updateSettings]);
const backgroundColorOptionGroups = useMemo(() => {
const colors = Object.keys(VLC_COLORS) as VLCColor[];
const options = colors.map((color) => ({
type: "radio" as const,
label: t(`home.settings.subtitles.colors.${color}`),
value: color,
selected: (settings?.vlcBackgroundColor || "Black") === color,
onPress: () => updateSettings({ vlcBackgroundColor: color }),
}));
return [{ options }];
}, [settings?.vlcBackgroundColor, t, updateSettings]);
const outlineColorOptionGroups = useMemo(() => {
const colors = Object.keys(VLC_COLORS) as VLCColor[];
const options = colors.map((color) => ({
type: "radio" as const,
label: t(`home.settings.subtitles.colors.${color}`),
value: color,
selected: (settings?.vlcOutlineColor || "Black") === color,
onPress: () => updateSettings({ vlcOutlineColor: color }),
}));
return [{ options }];
}, [settings?.vlcOutlineColor, t, updateSettings]);
const outlineThicknessOptionGroups = useMemo(() => {
const thicknesses = Object.keys(OUTLINE_THICKNESS) as OutlineThickness[];
const options = thicknesses.map((thickness) => ({
type: "radio" as const,
label: t(`home.settings.subtitles.thickness.${thickness}`),
value: thickness,
selected: (settings?.vlcOutlineThickness || "Normal") === thickness,
onPress: () => updateSettings({ vlcOutlineThickness: thickness }),
}));
return [{ options }];
}, [settings?.vlcOutlineThickness, t, updateSettings]);
const backgroundOpacityOptionGroups = useMemo(() => {
const opacities = [0, 32, 64, 96, 128, 160, 192, 224, 255];
const options = opacities.map((opacity) => ({
type: "radio" as const,
label: `${Math.round((opacity / 255) * 100)}%`,
value: opacity,
selected: (settings?.vlcBackgroundOpacity ?? 128) === opacity,
onPress: () => updateSettings({ vlcBackgroundOpacity: opacity }),
}));
return [{ options }];
}, [settings?.vlcBackgroundOpacity, updateSettings]);
const outlineOpacityOptionGroups = useMemo(() => {
const opacities = [0, 32, 64, 96, 128, 160, 192, 224, 255];
const options = opacities.map((opacity) => ({
type: "radio" as const,
label: `${Math.round((opacity / 255) * 100)}%`,
value: opacity,
selected: (settings?.vlcOutlineOpacity ?? 255) === opacity,
onPress: () => updateSettings({ vlcOutlineOpacity: opacity }),
}));
return [{ options }];
}, [settings?.vlcOutlineOpacity, updateSettings]);
if (isTv) return null;
if (!settings) return null;
return ( return (
<View {...props}> <View {...props}>
<ListGroup <ListGroup
@@ -67,20 +184,10 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
} }
> >
<ListItem title={t("home.settings.subtitles.subtitle_language")}> <ListItem title={t("home.settings.subtitles.subtitle_language")}>
<Dropdown <PlatformDropdown
data={[ groups={subtitleLanguageOptionGroups}
{ trigger={
DisplayName: t("home.settings.subtitles.none"), <View className='flex flex-row items-center justify-between py-3 pl-3'>
ThreeLetterISOLanguageName: "none-subs",
},
...(cultures ?? []),
]}
keyExtractor={(item) =>
item?.ThreeLetterISOLanguageName ?? "unknown"
}
titleExtractor={(item) => item?.DisplayName}
title={
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
<Text className='mr-1 text-[#8E8D91]'> <Text className='mr-1 text-[#8E8D91]'>
{settings?.defaultSubtitleLanguage?.DisplayName || {settings?.defaultSubtitleLanguage?.DisplayName ||
t("home.settings.subtitles.none")} t("home.settings.subtitles.none")}
@@ -90,18 +197,9 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
size={18} size={18}
color='#5A5960' color='#5A5960'
/> />
</TouchableOpacity> </View>
}
label={t("home.settings.subtitles.language")}
onSelected={(defaultSubtitleLanguage) =>
updateSettings({
defaultSubtitleLanguage:
defaultSubtitleLanguage.DisplayName ===
t("home.settings.subtitles.none")
? null
: defaultSubtitleLanguage,
})
} }
title={t("home.settings.subtitles.language")}
/> />
</ListItem> </ListItem>
@@ -109,13 +207,10 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
title={t("home.settings.subtitles.subtitle_mode")} title={t("home.settings.subtitles.subtitle_mode")}
disabled={pluginSettings?.subtitleMode?.locked} disabled={pluginSettings?.subtitleMode?.locked}
> >
<Dropdown <PlatformDropdown
data={subtitleModes} groups={subtitleModeOptionGroups}
disabled={pluginSettings?.subtitleMode?.locked} trigger={
keyExtractor={String} <View className='flex flex-row items-center justify-between py-3 pl-3'>
titleExtractor={(item) => t(subtitleModeKeys[item]) || String(item)}
title={
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
<Text className='mr-1 text-[#8E8D91]'> <Text className='mr-1 text-[#8E8D91]'>
{t(subtitleModeKeys[settings?.subtitleMode]) || {t(subtitleModeKeys[settings?.subtitleMode]) ||
t("home.settings.subtitles.loading")} t("home.settings.subtitles.loading")}
@@ -125,10 +220,9 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
size={18} size={18}
color='#5A5960' color='#5A5960'
/> />
</TouchableOpacity> </View>
} }
label={t("home.settings.subtitles.subtitle_mode")} title={t("home.settings.subtitles.subtitle_mode")}
onSelected={(subtitleMode) => updateSettings({ subtitleMode })}
/> />
</ListItem> </ListItem>
@@ -159,144 +253,120 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
/> />
</ListItem> </ListItem>
<ListItem title={t("home.settings.subtitles.text_color")}> <ListItem title={t("home.settings.subtitles.text_color")}>
<Dropdown <PlatformDropdown
data={Object.keys(VLC_COLORS)} groups={textColorOptionGroups}
keyExtractor={(item) => item} trigger={
titleExtractor={(item) => <View className='flex flex-row items-center justify-between py-3 pl-3'>
t(`home.settings.subtitles.colors.${item}`)
}
title={
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
<Text className='mr-1 text-[#8E8D91]'> <Text className='mr-1 text-[#8E8D91]'>
{t(`home.settings.subtitles.colors.${textColor}`)} {t(
`home.settings.subtitles.colors.${settings?.vlcTextColor || "White"}`,
)}
</Text> </Text>
<Ionicons <Ionicons
name='chevron-expand-sharp' name='chevron-expand-sharp'
size={18} size={18}
color='#5A5960' color='#5A5960'
/> />
</TouchableOpacity> </View>
} }
label={t("home.settings.subtitles.text_color")} title={t("home.settings.subtitles.text_color")}
onSelected={(value) => updateSettings({ vlcTextColor: value })}
/> />
</ListItem> </ListItem>
<ListItem title={t("home.settings.subtitles.background_color")}> <ListItem title={t("home.settings.subtitles.background_color")}>
<Dropdown <PlatformDropdown
data={Object.keys(VLC_COLORS)} groups={backgroundColorOptionGroups}
keyExtractor={(item) => item} trigger={
titleExtractor={(item) => <View className='flex flex-row items-center justify-between py-3 pl-3'>
t(`home.settings.subtitles.colors.${item}`)
}
title={
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
<Text className='mr-1 text-[#8E8D91]'> <Text className='mr-1 text-[#8E8D91]'>
{t(`home.settings.subtitles.colors.${backgroundColor}`)} {t(
`home.settings.subtitles.colors.${settings?.vlcBackgroundColor || "Black"}`,
)}
</Text> </Text>
<Ionicons <Ionicons
name='chevron-expand-sharp' name='chevron-expand-sharp'
size={18} size={18}
color='#5A5960' color='#5A5960'
/> />
</TouchableOpacity> </View>
}
label={t("home.settings.subtitles.background_color")}
onSelected={(value) =>
updateSettings({ vlcBackgroundColor: value })
} }
title={t("home.settings.subtitles.background_color")}
/> />
</ListItem> </ListItem>
<ListItem title={t("home.settings.subtitles.outline_color")}> <ListItem title={t("home.settings.subtitles.outline_color")}>
<Dropdown <PlatformDropdown
data={Object.keys(VLC_COLORS)} groups={outlineColorOptionGroups}
keyExtractor={(item) => item} trigger={
titleExtractor={(item) => <View className='flex flex-row items-center justify-between py-3 pl-3'>
t(`home.settings.subtitles.colors.${item}`)
}
title={
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
<Text className='mr-1 text-[#8E8D91]'> <Text className='mr-1 text-[#8E8D91]'>
{t(`home.settings.subtitles.colors.${outlineColor}`)} {t(
`home.settings.subtitles.colors.${settings?.vlcOutlineColor || "Black"}`,
)}
</Text> </Text>
<Ionicons <Ionicons
name='chevron-expand-sharp' name='chevron-expand-sharp'
size={18} size={18}
color='#5A5960' color='#5A5960'
/> />
</TouchableOpacity> </View>
} }
label={t("home.settings.subtitles.outline_color")} title={t("home.settings.subtitles.outline_color")}
onSelected={(value) => updateSettings({ vlcOutlineColor: value })}
/> />
</ListItem> </ListItem>
<ListItem title={t("home.settings.subtitles.outline_thickness")}> <ListItem title={t("home.settings.subtitles.outline_thickness")}>
<Dropdown <PlatformDropdown
data={Object.keys(OUTLINE_THICKNESS)} groups={outlineThicknessOptionGroups}
keyExtractor={(item) => item} trigger={
titleExtractor={(item) => <View className='flex flex-row items-center justify-between py-3 pl-3'>
t(`home.settings.subtitles.thickness.${item}`)
}
title={
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
<Text className='mr-1 text-[#8E8D91]'> <Text className='mr-1 text-[#8E8D91]'>
{t(`home.settings.subtitles.thickness.${outlineThickness}`)} {t(
`home.settings.subtitles.thickness.${settings?.vlcOutlineThickness || "Normal"}`,
)}
</Text> </Text>
<Ionicons <Ionicons
name='chevron-expand-sharp' name='chevron-expand-sharp'
size={18} size={18}
color='#5A5960' color='#5A5960'
/> />
</TouchableOpacity> </View>
}
label={t("home.settings.subtitles.outline_thickness")}
onSelected={(value) =>
updateSettings({ vlcOutlineThickness: value })
} }
title={t("home.settings.subtitles.outline_thickness")}
/> />
</ListItem> </ListItem>
<ListItem title={t("home.settings.subtitles.background_opacity")}> <ListItem title={t("home.settings.subtitles.background_opacity")}>
<Dropdown <PlatformDropdown
data={[0, 32, 64, 96, 128, 160, 192, 224, 255]} groups={backgroundOpacityOptionGroups}
keyExtractor={String} trigger={
titleExtractor={(item) => `${Math.round((item / 255) * 100)}%`} <View className='flex flex-row items-center justify-between py-3 pl-3'>
title={ <Text className='mr-1 text-[#8E8D91]'>{`${Math.round(((settings?.vlcBackgroundOpacity ?? 128) / 255) * 100)}%`}</Text>
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>{`${Math.round((backgroundOpacity / 255) * 100)}%`}</Text>
<Ionicons <Ionicons
name='chevron-expand-sharp' name='chevron-expand-sharp'
size={18} size={18}
color='#5A5960' color='#5A5960'
/> />
</TouchableOpacity> </View>
}
label={t("home.settings.subtitles.background_opacity")}
onSelected={(value) =>
updateSettings({ vlcBackgroundOpacity: value })
} }
title={t("home.settings.subtitles.background_opacity")}
/> />
</ListItem> </ListItem>
<ListItem title={t("home.settings.subtitles.outline_opacity")}> <ListItem title={t("home.settings.subtitles.outline_opacity")}>
<Dropdown <PlatformDropdown
data={[0, 32, 64, 96, 128, 160, 192, 224, 255]} groups={outlineOpacityOptionGroups}
keyExtractor={String} trigger={
titleExtractor={(item) => `${Math.round((item / 255) * 100)}%`} <View className='flex flex-row items-center justify-between py-3 pl-3'>
title={ <Text className='mr-1 text-[#8E8D91]'>{`${Math.round(((settings?.vlcOutlineOpacity ?? 255) / 255) * 100)}%`}</Text>
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
<Text className='mr-1 text-[#8E8D91]'>{`${Math.round((outlineOpacity / 255) * 100)}%`}</Text>
<Ionicons <Ionicons
name='chevron-expand-sharp' name='chevron-expand-sharp'
size={18} size={18}
color='#5A5960' color='#5A5960'
/> />
</TouchableOpacity> </View>
} }
label={t("home.settings.subtitles.outline_opacity")} title={t("home.settings.subtitles.outline_opacity")}
onSelected={(value) => updateSettings({ vlcOutlineOpacity: value })}
/> />
</ListItem> </ListItem>
<ListItem title={t("home.settings.subtitles.bold_text")}> <ListItem title={t("home.settings.subtitles.bold_text")}>
<Switch <Switch
value={isBold} value={settings?.vlcIsBold ?? false}
onValueChange={(value) => updateSettings({ vlcIsBold: value })} onValueChange={(value) => updateSettings({ vlcIsBold: value })}
/> />
</ListItem> </ListItem>

View File

@@ -15,7 +15,7 @@ export const commonScreenOptions: ICommonScreenOptions = {
headerShown: true, headerShown: true,
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
headerBlurEffect: "none", headerBlurEffect: Platform.OS === "ios" ? "none" : undefined,
headerLeft: () => <HeaderBackButton />, headerLeft: () => <HeaderBackButton />,
}; };

View File

@@ -56,7 +56,10 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
}, []); }, []);
const { getDownloadedItems } = useDownload(); const { getDownloadedItems } = useDownload();
const downloadedFiles = getDownloadedItems(); const downloadedFiles = useMemo(
() => getDownloadedItems(),
[getDownloadedItems],
);
const seasonIndex = seasonIndexState[item.ParentId ?? ""]; const seasonIndex = seasonIndexState[item.ParentId ?? ""];

View File

@@ -111,7 +111,7 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
pointerEvents={showControls ? "auto" : "none"} pointerEvents={showControls ? "auto" : "none"}
className={"flex flex-row w-full pt-2"} className={"flex flex-row w-full pt-2"}
> >
<View className='mr-auto'> <View className='mr-auto' pointerEvents='box-none'>
{!Platform.isTV && (!offline || !mediaSource?.TranscodingUrl) && ( {!Platform.isTV && (!offline || !mediaSource?.TranscodingUrl) && (
<VideoProvider <VideoProvider
getAudioTracks={getAudioTracks} getAudioTracks={getAudioTracks}
@@ -120,7 +120,9 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
setSubtitleTrack={setSubtitleTrack} setSubtitleTrack={setSubtitleTrack}
setSubtitleURL={setSubtitleURL} setSubtitleURL={setSubtitleURL}
> >
<DropdownView /> <View pointerEvents='auto'>
<DropdownView />
</View>
</VideoProvider> </VideoProvider>
)} )}
</View> </View>

View File

@@ -1,8 +1,10 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import React, { useState } from "react"; import React, { useMemo } from "react";
import { Platform, TouchableOpacity } from "react-native"; import { Platform, View } from "react-native";
import { Text } from "@/components/common/Text"; import {
import { FilterSheet } from "@/components/filters/FilterSheet"; type OptionGroup,
PlatformDropdown,
} from "@/components/PlatformDropdown";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
export type ScaleFactor = export type ScaleFactor =
@@ -94,56 +96,51 @@ export const ScaleFactorSelector: React.FC<ScaleFactorSelectorProps> = ({
disabled = false, disabled = false,
}) => { }) => {
const lightHapticFeedback = useHaptic("light"); const lightHapticFeedback = useHaptic("light");
const [open, setOpen] = useState(false);
// Hide on TV platforms
if (Platform.isTV) return null;
const handleScaleSelect = (scale: ScaleFactor) => { const handleScaleSelect = (scale: ScaleFactor) => {
onScaleChange(scale); onScaleChange(scale);
lightHapticFeedback(); lightHapticFeedback();
}; };
const currentOption = SCALE_FACTOR_OPTIONS.find( const optionGroups = useMemo<OptionGroup[]>(() => {
(option) => option.id === currentScale, return [
); {
options: SCALE_FACTOR_OPTIONS.map((option) => ({
type: "radio" as const,
label: option.label,
value: option.id,
selected: option.id === currentScale,
onPress: () => handleScaleSelect(option.id),
disabled,
})),
},
];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentScale, disabled]);
return ( const trigger = useMemo(
<> () => (
<TouchableOpacity <View
disabled={disabled}
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2' className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
style={{ opacity: disabled ? 0.5 : 1 }} style={{ opacity: disabled ? 0.5 : 1 }}
onPress={() => setOpen(true)}
> >
<Ionicons name='search-outline' size={24} color='white' /> <Ionicons name='search-outline' size={24} color='white' />
</TouchableOpacity> </View>
),
[disabled],
);
<FilterSheet // Hide on TV platforms
open={open} if (Platform.isTV) return null;
setOpen={setOpen}
title='Scale Factor' return (
data={SCALE_FACTOR_OPTIONS} <PlatformDropdown
values={currentOption ? [currentOption] : []} title='Scale Factor'
multiple={false} groups={optionGroups}
searchFilter={(item, query) => { trigger={trigger}
const option = item as ScaleFactorOption; bottomSheetConfig={{
return ( enablePanDownToClose: true,
option.label.toLowerCase().includes(query.toLowerCase()) || }}
option.description.toLowerCase().includes(query.toLowerCase()) />
);
}}
renderItemLabel={(item) => {
const option = item as ScaleFactorOption;
return <Text>{option.label}</Text>;
}}
set={(vals) => {
const chosen = vals[0] as ScaleFactorOption | undefined;
if (chosen) {
handleScaleSelect(chosen.id);
}
}}
/>
</>
); );
}; };

View File

@@ -1,8 +1,10 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import React, { useState } from "react"; import React, { useMemo } from "react";
import { Platform, TouchableOpacity } from "react-native"; import { Platform, View } from "react-native";
import { Text } from "@/components/common/Text"; import {
import { FilterSheet } from "@/components/filters/FilterSheet"; type OptionGroup,
PlatformDropdown,
} from "@/components/PlatformDropdown";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
export type AspectRatio = "default" | "16:9" | "4:3" | "1:1" | "21:9"; export type AspectRatio = "default" | "16:9" | "4:3" | "1:1" | "21:9";
@@ -53,56 +55,51 @@ export const AspectRatioSelector: React.FC<AspectRatioSelectorProps> = ({
disabled = false, disabled = false,
}) => { }) => {
const lightHapticFeedback = useHaptic("light"); const lightHapticFeedback = useHaptic("light");
const [open, setOpen] = useState(false);
// Hide on TV platforms
if (Platform.isTV) return null;
const handleRatioSelect = (ratio: AspectRatio) => { const handleRatioSelect = (ratio: AspectRatio) => {
onRatioChange(ratio); onRatioChange(ratio);
lightHapticFeedback(); lightHapticFeedback();
}; };
const currentOption = ASPECT_RATIO_OPTIONS.find( const optionGroups = useMemo<OptionGroup[]>(() => {
(option) => option.id === currentRatio, return [
); {
options: ASPECT_RATIO_OPTIONS.map((option) => ({
type: "radio" as const,
label: option.label,
value: option.id,
selected: option.id === currentRatio,
onPress: () => handleRatioSelect(option.id),
disabled,
})),
},
];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentRatio, disabled]);
return ( const trigger = useMemo(
<> () => (
<TouchableOpacity <View
disabled={disabled}
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2' className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
style={{ opacity: disabled ? 0.5 : 1 }} style={{ opacity: disabled ? 0.5 : 1 }}
onPress={() => setOpen(true)}
> >
<Ionicons name='crop-outline' size={24} color='white' /> <Ionicons name='crop-outline' size={24} color='white' />
</TouchableOpacity> </View>
),
[disabled],
);
<FilterSheet // Hide on TV platforms
open={open} if (Platform.isTV) return null;
setOpen={setOpen}
title='Aspect Ratio' return (
data={ASPECT_RATIO_OPTIONS} <PlatformDropdown
values={currentOption ? [currentOption] : []} title='Aspect Ratio'
multiple={false} groups={optionGroups}
searchFilter={(item, query) => { trigger={trigger}
const option = item as AspectRatioOption; bottomSheetConfig={{
return ( enablePanDownToClose: true,
option.label.toLowerCase().includes(query.toLowerCase()) || }}
option.description.toLowerCase().includes(query.toLowerCase()) />
);
}}
renderItemLabel={(item) => {
const option = item as AspectRatioOption;
return <Text>{option.label}</Text>;
}}
set={(vals) => {
const chosen = vals[0] as AspectRatioOption | undefined;
if (chosen) {
handleRatioSelect(chosen.id);
}
}}
/>
</>
); );
}; };

View File

@@ -1,16 +1,12 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetScrollView,
} from "@gorhom/bottom-sheet";
import { useLocalSearchParams, useRouter } from "expo-router"; import { useLocalSearchParams, useRouter } from "expo-router";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useMemo, useRef } from "react";
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native"; import { Platform, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { BITRATES } from "@/components/BitrateSelector"; import { BITRATES } from "@/components/BitrateSelector";
import { Text } from "@/components/common/Text"; import {
type OptionGroup,
PlatformDropdown,
} from "@/components/PlatformDropdown";
import { useControlContext } from "../contexts/ControlContext"; import { useControlContext } from "../contexts/ControlContext";
import { useVideoContext } from "../contexts/VideoContext"; import { useVideoContext } from "../contexts/VideoContext";
@@ -23,10 +19,6 @@ const DropdownView = () => {
ControlContext?.mediaSource, ControlContext?.mediaSource,
]; ];
const router = useRouter(); const router = useRouter();
const insets = useSafeAreaInsets();
const [open, setOpen] = useState(false);
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const snapPoints = useMemo(() => ["75%"], []);
const { subtitleIndex, audioIndex, bitrateValue, playbackPosition, offline } = const { subtitleIndex, audioIndex, bitrateValue, playbackPosition, offline } =
useLocalSearchParams<{ useLocalSearchParams<{
@@ -39,248 +31,127 @@ const DropdownView = () => {
offline: string; offline: string;
}>(); }>();
// Use ref to track playbackPosition without causing re-renders
const playbackPositionRef = useRef(playbackPosition);
playbackPositionRef.current = playbackPosition;
const isOffline = offline === "true"; const isOffline = offline === "true";
// Stabilize IDs to prevent unnecessary recalculations
const itemIdRef = useRef(item.Id);
const mediaSourceIdRef = useRef(mediaSource?.Id);
itemIdRef.current = item.Id;
mediaSourceIdRef.current = mediaSource?.Id;
const changeBitrate = useCallback( const changeBitrate = useCallback(
(bitrate: string) => { (bitrate: string) => {
const queryParams = new URLSearchParams({ const queryParams = new URLSearchParams({
itemId: item.Id ?? "", itemId: itemIdRef.current ?? "",
audioIndex: audioIndex?.toString() ?? "", audioIndex: audioIndex?.toString() ?? "",
subtitleIndex: subtitleIndex.toString() ?? "", subtitleIndex: subtitleIndex?.toString() ?? "",
mediaSourceId: mediaSource?.Id ?? "", mediaSourceId: mediaSourceIdRef.current ?? "",
bitrateValue: bitrate.toString(), bitrateValue: bitrate.toString(),
playbackPosition: playbackPosition, playbackPosition: playbackPositionRef.current,
}).toString(); }).toString();
router.replace(`player/direct-player?${queryParams}` as any); router.replace(`player/direct-player?${queryParams}` as any);
}, },
[item, mediaSource, subtitleIndex, audioIndex, playbackPosition], [audioIndex, subtitleIndex, router],
); );
const handleSheetChanges = useCallback((index: number) => { // Create stable identifiers for tracks
if (index === -1) { const subtitleTracksKey = useMemo(
setOpen(false); () => subtitleTracks?.map((t) => `${t.index}-${t.name}`).join(",") ?? "",
} [subtitleTracks],
}, []); );
const renderBackdrop = useCallback( const audioTracksKey = useMemo(
(props: BottomSheetBackdropProps) => ( () => audioTracks?.map((t) => `${t.index}-${t.name}`).join(",") ?? "",
<BottomSheetBackdrop [audioTracks],
{...props} );
disappearsOnIndex={-1}
appearsOnIndex={0} // Transform sections into OptionGroup format
/> const optionGroups = useMemo<OptionGroup[]>(() => {
const groups: OptionGroup[] = [];
// Quality Section
if (!isOffline) {
groups.push({
title: "Quality",
options:
BITRATES?.map((bitrate) => ({
type: "radio" as const,
label: bitrate.key,
value: bitrate.value?.toString() ?? "",
selected: bitrateValue === (bitrate.value?.toString() ?? ""),
onPress: () => changeBitrate(bitrate.value?.toString() ?? ""),
})) || [],
});
}
// Subtitle Section
if (subtitleTracks && subtitleTracks.length > 0) {
groups.push({
title: "Subtitles",
options: subtitleTracks.map((sub) => ({
type: "radio" as const,
label: sub.name,
value: sub.index.toString(),
selected: subtitleIndex === sub.index.toString(),
onPress: () => sub.setTrack(),
})),
});
}
// Audio Section
if (audioTracks && audioTracks.length > 0) {
groups.push({
title: "Audio",
options: audioTracks.map((track) => ({
type: "radio" as const,
label: track.name,
value: track.index.toString(),
selected: audioIndex === track.index.toString(),
onPress: () => track.setTrack(),
})),
});
}
return groups;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
isOffline,
bitrateValue,
changeBitrate,
subtitleTracksKey,
audioTracksKey,
subtitleIndex,
audioIndex,
// Note: subtitleTracks and audioTracks are intentionally excluded
// because we use subtitleTracksKey and audioTracksKey for stability
]);
// Memoize the trigger to prevent re-renders
const trigger = useMemo(
() => (
<View className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'>
<Ionicons name='ellipsis-horizontal' size={24} color={"white"} />
</View>
), ),
[], [],
); );
const handleOpen = () => {
setOpen(true);
bottomSheetModalRef.current?.present();
};
const handleClose = () => {
setOpen(false);
bottomSheetModalRef.current?.dismiss();
};
useEffect(() => {
if (open) bottomSheetModalRef.current?.present();
else bottomSheetModalRef.current?.dismiss();
}, [open]);
// Hide on TV platforms // Hide on TV platforms
if (Platform.isTV) return null; if (Platform.isTV) return null;
return ( return (
<> <PlatformDropdown
<TouchableOpacity title='Playback Options'
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2' groups={optionGroups}
onPress={handleOpen} trigger={trigger}
> bottomSheetConfig={{
<Ionicons name='ellipsis-horizontal' size={24} color={"white"} /> enablePanDownToClose: true,
</TouchableOpacity> }}
/>
<BottomSheetModal
ref={bottomSheetModalRef}
index={0}
snapPoints={snapPoints}
onChange={handleSheetChanges}
backdropComponent={renderBackdrop}
handleIndicatorStyle={{
backgroundColor: "white",
}}
backgroundStyle={{
backgroundColor: "#171717",
}}
>
<BottomSheetScrollView
style={{
flex: 1,
}}
>
<View
className='mt-2 mb-8'
style={{
paddingLeft: Math.max(16, insets.left),
paddingRight: Math.max(16, insets.right),
}}
>
<Text className='font-bold text-2xl mb-6'>Playback Options</Text>
{/* Quality Section */}
{!isOffline && (
<View className='mb-6'>
<Text className='font-semibold text-lg mb-3 text-neutral-300'>
Quality
</Text>
<View
style={{
borderRadius: 20,
overflow: "hidden",
}}
className='flex flex-col rounded-xl overflow-hidden'
>
{BITRATES?.map((bitrate, idx: number) => (
<View key={`quality-item-${idx}`}>
<TouchableOpacity
onPress={() => {
changeBitrate(bitrate.value?.toString() ?? "");
setTimeout(() => handleClose(), 250);
}}
className='bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between'
>
<Text className='flex shrink'>{bitrate.key}</Text>
{bitrateValue === (bitrate.value?.toString() ?? "") ? (
<Ionicons
name='radio-button-on'
size={24}
color='white'
/>
) : (
<Ionicons
name='radio-button-off'
size={24}
color='white'
/>
)}
</TouchableOpacity>
{idx < BITRATES.length - 1 && (
<View
style={{
height: StyleSheet.hairlineWidth,
}}
className='bg-neutral-700'
/>
)}
</View>
))}
</View>
</View>
)}
{/* Subtitle Section */}
<View className='mb-6'>
<Text className='font-semibold text-lg mb-3 text-neutral-300'>
Subtitles
</Text>
<View
style={{
borderRadius: 20,
overflow: "hidden",
}}
className='flex flex-col rounded-xl overflow-hidden'
>
{subtitleTracks?.map((sub, idx: number) => (
<View key={`subtitle-item-${idx}`}>
<TouchableOpacity
onPress={() => {
sub.setTrack();
setTimeout(() => handleClose(), 250);
}}
className='bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between'
>
<Text className='flex shrink'>{sub.name}</Text>
{subtitleIndex === sub.index.toString() ? (
<Ionicons
name='radio-button-on'
size={24}
color='white'
/>
) : (
<Ionicons
name='radio-button-off'
size={24}
color='white'
/>
)}
</TouchableOpacity>
{idx < (subtitleTracks?.length ?? 0) - 1 && (
<View
style={{
height: StyleSheet.hairlineWidth,
}}
className='bg-neutral-700'
/>
)}
</View>
))}
</View>
</View>
{/* Audio Section */}
{(audioTracks?.length ?? 0) > 0 && (
<View className='mb-6'>
<Text className='font-semibold text-lg mb-3 text-neutral-300'>
Audio
</Text>
<View
style={{
borderRadius: 20,
overflow: "hidden",
}}
className='flex flex-col rounded-xl overflow-hidden'
>
{audioTracks?.map((track, idx: number) => (
<View key={`audio-item-${idx}`}>
<TouchableOpacity
onPress={() => {
track.setTrack();
setTimeout(() => handleClose(), 250);
}}
className='bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between'
>
<Text className='flex shrink'>{track.name}</Text>
{audioIndex === track.index.toString() ? (
<Ionicons
name='radio-button-on'
size={24}
color='white'
/>
) : (
<Ionicons
name='radio-button-off'
size={24}
color='white'
/>
)}
</TouchableOpacity>
{idx < (audioTracks?.length ?? 0) - 1 && (
<View
style={{
height: StyleSheet.hairlineWidth,
}}
className='bg-neutral-700'
/>
)}
</View>
))}
</View>
</View>
)}
</View>
</BottomSheetScrollView>
</BottomSheetModal>
</>
); );
}; };

View File

@@ -13,7 +13,7 @@ export const useControlsTimeout = ({
isSliding, isSliding,
episodeView, episodeView,
onHideControls, onHideControls,
timeout = 4000, timeout = 10000,
}: UseControlsTimeoutProps) => { }: UseControlsTimeoutProps) => {
const controlsTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const controlsTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

View File

@@ -66,8 +66,8 @@ const JELLYSEERR_USER = "JELLYSEERR_USER";
const JELLYSEERR_COOKIES = "JELLYSEERR_COOKIES"; const JELLYSEERR_COOKIES = "JELLYSEERR_COOKIES";
export const clearJellyseerrStorageData = () => { export const clearJellyseerrStorageData = () => {
storage.delete(JELLYSEERR_USER); storage.remove(JELLYSEERR_USER);
storage.delete(JELLYSEERR_COOKIES); storage.remove(JELLYSEERR_COOKIES);
}; };
export enum Endpoints { export enum Endpoints {

View File

@@ -2,7 +2,7 @@
const { getDefaultConfig } = require("expo/metro-config"); const { getDefaultConfig } = require("expo/metro-config");
/** @type {import('expo/metro-config').MetroConfig} */ /** @type {import('expo/metro-config').MetroConfig} */
const config = getDefaultConfig(__dirname); // eslint-disable-line no-undef const config = getDefaultConfig(__dirname);
// Add Hermes parser // Add Hermes parser
config.transformer.hermesParser = true; config.transformer.hermesParser = true;

View File

@@ -22,82 +22,85 @@
"test": "bun run typecheck && bun run lint && bun run format && bun run doctor" "test": "bun run typecheck && bun run lint && bun run format && bun run doctor"
}, },
"dependencies": { "dependencies": {
"@bottom-tabs/react-navigation": "^0.11.2", "@bottom-tabs/react-navigation": "^0.12.2",
"@expo/metro-runtime": "~5.0.5", "@expo/metro-runtime": "~6.1.1",
"@expo/react-native-action-sheet": "^4.1.1", "@expo/react-native-action-sheet": "^4.1.1",
"@expo/vector-icons": "^14.1.0", "@expo/ui": "^0.2.0-beta.4",
"@expo/vector-icons": "^15.0.2",
"@gorhom/bottom-sheet": "^5.1.0", "@gorhom/bottom-sheet": "^5.1.0",
"@jellyfin/sdk": "^0.11.0", "@jellyfin/sdk": "^0.11.0",
"@kesha-antonov/react-native-background-downloader": "^3.2.6", "@kesha-antonov/react-native-background-downloader": "github:fredrikburmester/react-native-background-downloader#d78699b60866062f6d95887412cee3649a548bf2",
"@react-native-community/netinfo": "^11.4.1", "@react-native-community/netinfo": "^11.4.1",
"@react-native-menu/menu": "1.2.3",
"@react-navigation/material-top-tabs": "^7.2.14", "@react-navigation/material-top-tabs": "^7.2.14",
"@react-navigation/native": "^7.0.14", "@react-navigation/native": "^7.0.14",
"@shopify/flash-list": "^1.8.3", "@shopify/flash-list": "2.0.2",
"@tanstack/react-query": "^5.66.0", "@tanstack/react-query": "^5.66.0",
"axios": "^1.7.9", "axios": "^1.7.9",
"expo": "^53.0.23", "expo": "^54.0.10",
"expo-application": "~6.1.4", "expo-application": "~7.0.5",
"expo-asset": "~11.1.7", "expo-asset": "~12.0.6",
"expo-background-task": "~0.2.8", "expo-background-task": "~1.0.5",
"expo-blur": "~14.1.4", "expo-blur": "~15.0.5",
"expo-brightness": "~13.1.4", "expo-brightness": "~14.0.5",
"expo-build-properties": "~0.14.6", "expo-build-properties": "~1.0.6",
"expo-constants": "~17.1.5", "expo-constants": "~18.0.6",
"expo-dev-client": "^5.2.0", "expo-dev-client": "~6.0.7",
"expo-device": "~7.1.4", "expo-device": "~8.0.5",
"expo-font": "~13.3.1", "expo-font": "~14.0.6",
"expo-haptics": "~14.1.4", "expo-haptics": "~15.0.5",
"expo-image": "~2.4.0", "expo-image": "~3.0.5",
"expo-linear-gradient": "~14.1.4", "expo-linear-gradient": "~15.0.5",
"expo-linking": "~7.1.4", "expo-linking": "~8.0.6",
"expo-localization": "~16.1.5", "expo-localization": "~17.0.5",
"expo-notifications": "~0.31.2", "expo-notifications": "~0.32.7",
"expo-router": "~5.1.7", "expo-router": "~6.0.0-preview.12",
"expo-screen-orientation": "~8.1.6", "expo-screen-orientation": "~9.0.5",
"expo-sensors": "~14.1.4", "expo-sensors": "~15.0.5",
"expo-sharing": "~13.1.5", "expo-sharing": "~14.0.5",
"expo-splash-screen": "~0.30.8", "expo-splash-screen": "~31.0.7",
"expo-status-bar": "~2.2.3", "expo-status-bar": "~3.0.6",
"expo-system-ui": "~5.0.11", "expo-system-ui": "~6.0.5",
"expo-task-manager": "~13.1.6", "expo-task-manager": "~14.0.5",
"expo-web-browser": "~14.2.0", "expo-web-browser": "~15.0.5",
"i18next": "^25.0.0", "i18next": "^25.0.0",
"jotai": "^2.12.5", "jotai": "^2.12.5",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"nativewind": "^2.0.11", "nativewind": "^2.0.11",
"react": "19.0.0", "patch-package": "^8.0.0",
"react-dom": "19.0.0", "react": "19.1.0",
"react-dom": "19.1.0",
"react-i18next": "^15.4.0", "react-i18next": "^15.4.0",
"react-native": "npm:react-native-tvos@0.79.5-0", "react-native": "npm:react-native-tvos@0.81.4-0",
"react-native-awesome-slider": "^2.9.0", "react-native-awesome-slider": "^2.9.0",
"react-native-bottom-tabs": "^0.11.2", "react-native-bottom-tabs": "^0.12.2",
"react-native-circular-progress": "^1.4.1", "react-native-circular-progress": "^1.4.1",
"react-native-collapsible": "^1.6.2", "react-native-collapsible": "^1.6.2",
"react-native-country-flag": "^2.0.2", "react-native-country-flag": "^2.0.2",
"react-native-device-info": "^14.0.4", "react-native-device-info": "^14.0.4",
"react-native-gesture-handler": "~2.24.0", "react-native-edge-to-edge": "^1.7.0",
"react-native-gesture-handler": "~2.28.0",
"react-native-google-cast": "^4.9.0", "react-native-google-cast": "^4.9.0",
"react-native-image-colors": "^2.4.0", "react-native-image-colors": "^2.4.0",
"react-native-ios-context-menu": "^3.1.0", "react-native-ios-context-menu": "^3.2.1",
"react-native-ios-utilities": "5.1.8", "react-native-ios-utilities": "5.2.0",
"react-native-mmkv": "2.12.2", "react-native-mmkv": "4.0.0-beta.12",
"react-native-nitro-modules": "^0.29.1",
"react-native-pager-view": "^6.9.1", "react-native-pager-view": "^6.9.1",
"react-native-reanimated": "~3.19.1", "react-native-reanimated": "~4.1.0",
"react-native-reanimated-carousel": "4.0.2", "react-native-reanimated-carousel": "4.0.2",
"react-native-safe-area-context": "5.4.0", "react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.11.1", "react-native-screens": "~4.16.0",
"react-native-svg": "15.11.2", "react-native-svg": "15.12.1",
"react-native-udp": "^4.1.7", "react-native-udp": "^4.1.7",
"react-native-url-polyfill": "^2.0.0", "react-native-url-polyfill": "^2.0.0",
"react-native-uuid": "^2.0.3", "react-native-uuid": "^2.0.3",
"react-native-video": "6.14.1", "react-native-video": "6.16.1",
"react-native-volume-manager": "^2.0.8", "react-native-volume-manager": "^2.0.8",
"react-native-web": "^0.20.0", "react-native-web": "^0.21.0",
"react-native-worklets": "0.5.1",
"sonner-native": "^0.21.0", "sonner-native": "^0.21.0",
"tailwindcss": "3.3.2", "tailwindcss": "3.3.2",
"use-debounce": "^10.0.4", "use-debounce": "^10.0.4",
"zeego": "^3.0.6",
"zod": "^4.1.3" "zod": "^4.1.3"
}, },
"devDependencies": { "devDependencies": {
@@ -107,23 +110,20 @@
"@react-native-tvos/config-tv": "^0.1.1", "@react-native-tvos/config-tv": "^0.1.1",
"@types/jest": "^29.5.12", "@types/jest": "^29.5.12",
"@types/lodash": "^4.17.15", "@types/lodash": "^4.17.15",
"@types/react": "~19.0.10", "@types/react": "~19.1.10",
"@types/react-test-renderer": "^19.0.0", "@types/react-test-renderer": "^19.0.0",
"expo-doctor": "^1.17.0",
"cross-env": "^10.0.0", "cross-env": "^10.0.0",
"expo-doctor": "^1.17.0",
"husky": "^9.1.7", "husky": "^9.1.7",
"lint-staged": "^16.1.5", "lint-staged": "^16.1.5",
"postinstall-postinstall": "^2.1.0", "postinstall-postinstall": "^2.1.0",
"react-test-renderer": "19.1.1", "react-test-renderer": "19.1.1",
"typescript": "~5.8.3" "typescript": "~5.9.2"
}, },
"expo": { "expo": {
"install": { "install": {
"exclude": [ "exclude": [
"react-native", "react-native"
"@shopify/flash-list",
"react-native-reanimated",
"react-native-pager-view"
] ]
}, },
"doctor": { "doctor": {
@@ -140,6 +140,9 @@
} }
}, },
"private": true, "private": true,
"disabledDependencies": {
"@kesha-antonov/react-native-background-downloader": "^3.2.6"
},
"lint-staged": { "lint-staged": {
"*.{js,jsx,ts,tsx}": [ "*.{js,jsx,ts,tsx}": [
"biome check --write --unsafe --no-errors-on-unmatched" "biome check --write --unsafe --no-errors-on-unmatched"

View File

@@ -0,0 +1,58 @@
diff --git a/node_modules/@react-native-menu/menu/android/.DS_Store b/node_modules/@react-native-menu/menu/android/.DS_Store
new file mode 100644
index 0000000..5008ddf
Binary files /dev/null and b/node_modules/@react-native-menu/menu/android/.DS_Store differ
diff --git a/node_modules/@react-native-menu/menu/android/src/main/java/com/reactnativemenu/MenuView.kt b/node_modules/@react-native-menu/menu/android/src/main/java/com/reactnativemenu/MenuView.kt
index 17ed7c6..c45f5cc 100644
--- a/node_modules/@react-native-menu/menu/android/src/main/java/com/reactnativemenu/MenuView.kt
+++ b/node_modules/@react-native-menu/menu/android/src/main/java/com/reactnativemenu/MenuView.kt
@@ -24,6 +24,11 @@ class MenuView(private val mContext: ReactContext) : ReactViewGroup(mContext) {
private var mIsOnLongPress = false
private var mGestureDetector: GestureDetector
private var mHitSlopRect: Rect? = null
+ set(value) {
+ super.hitSlopRect = value
+ mHitSlopRect = value
+ updateTouchDelegate()
+ }
init {
mGestureDetector = GestureDetector(mContext, object : GestureDetector.SimpleOnGestureListener() {
@@ -47,12 +52,6 @@ class MenuView(private val mContext: ReactContext) : ReactViewGroup(mContext) {
prepareMenu()
}
- override fun setHitSlopRect(rect: Rect?) {
- super.setHitSlopRect(rect)
- mHitSlopRect = rect
- updateTouchDelegate()
- }
-
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
return true
}
diff --git a/node_modules/@react-native-menu/menu/android/src/main/java/com/reactnativemenu/MenuViewManagerBase.kt b/node_modules/@react-native-menu/menu/android/src/main/java/com/reactnativemenu/MenuViewManagerBase.kt
index 4731e1a..e4d2743 100644
--- a/node_modules/@react-native-menu/menu/android/src/main/java/com/reactnativemenu/MenuViewManagerBase.kt
+++ b/node_modules/@react-native-menu/menu/android/src/main/java/com/reactnativemenu/MenuViewManagerBase.kt
@@ -123,9 +123,9 @@ abstract class MenuViewManagerBase : ReactClippingViewManager<MenuView>() {
fun setHitSlop(view: ReactViewGroup, @Nullable hitSlop: ReadableMap?) {
if (hitSlop == null) {
// We should keep using setters as `Val cannot be reassigned`
- view.setHitSlopRect(null)
+ view.hitSlopRect = null
} else {
- view.setHitSlopRect(
+ view.hitSlopRect = (
Rect(
if (hitSlop.hasKey("left"))
PixelUtil.toPixelFromDIP(hitSlop.getDouble("left")).toInt()
@@ -206,7 +206,7 @@ abstract class MenuViewManagerBase : ReactClippingViewManager<MenuView>() {
@ReactProp(name = ViewProps.OVERFLOW)
fun setOverflow(view: ReactViewGroup, overflow: String?) {
- view.setOverflow(overflow)
+ view.overflow = overflow
}
@ReactProp(name = "backfaceVisibility")

View File

@@ -3,12 +3,11 @@ import type {
MediaSourceInfo, MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models"; } from "@jellyfin/sdk/lib/generated-client/models";
import * as Application from "expo-application"; import * as Application from "expo-application";
import * as FileSystem from "expo-file-system"; import { Directory, File, Paths } from "expo-file-system";
import * as Notifications from "expo-notifications"; import * as Notifications from "expo-notifications";
import { router } from "expo-router"; import { router } from "expo-router";
import { atom, useAtom } from "jotai"; import { atom, useAtom } from "jotai";
import { throttle } from "lodash"; import {
import React, {
createContext, createContext,
useCallback, useCallback,
useContext, useContext,
@@ -16,7 +15,7 @@ import React, {
useMemo, useMemo,
} from "react"; } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform } from "react-native"; import { DeviceEventEmitter, Platform } from "react-native";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
import useImageStorage from "@/hooks/useImageStorage"; import useImageStorage from "@/hooks/useImageStorage";
@@ -114,6 +113,20 @@ function useDownloadProvider() {
const { settings } = useSettings(); const { settings } = useSettings();
const successHapticFeedback = useHaptic("success"); const successHapticFeedback = useHaptic("success");
// Set up global download complete listener for debugging
useEffect(() => {
const listener = DeviceEventEmitter.addListener(
"downloadComplete",
(data) => {
console.log("🔥 GLOBAL TEST LISTENER received downloadComplete:", data);
},
);
return () => {
listener.remove();
};
}, []);
// Generate notification content based on item type // Generate notification content based on item type
const getNotificationContent = useCallback( const getNotificationContent = useCallback(
(item: BaseItemDto, isSuccess: boolean) => { (item: BaseItemDto, isSuccess: boolean) => {
@@ -180,9 +193,12 @@ function useDownloadProvider() {
/// Cant use the background downloader callback. As its not triggered if size is unknown. /// Cant use the background downloader callback. As its not triggered if size is unknown.
const updateProgress = async () => { const updateProgress = async () => {
const tasks = await BackGroundDownloader.checkForExistingDownloads(); const tasks = await BackGroundDownloader.checkForExistingDownloads();
if (!tasks) { if (!tasks || tasks.length === 0) {
return; return;
} }
console.log(`[UPDATE_PROGRESS] Checking ${tasks.length} active tasks`);
// check if processes are missing // check if processes are missing
setProcesses((processes) => { setProcesses((processes) => {
const missingProcesses = tasks const missingProcesses = tasks
@@ -201,10 +217,41 @@ function useDownloadProvider() {
// Find task for this process // Find task for this process
const task = tasks.find((s: any) => s.id === p.id); const task = tasks.find((s: any) => s.id === p.id);
if (!task) { if (!task) {
// ORPHANED DOWNLOAD CHECK: Task disappeared, but was it because it completed?
// This handles the race condition where download finishes between polling intervals
if (p.progress >= 90) {
// Lower threshold to catch more cases
console.log(
`[UPDATE_PROGRESS] Orphaned download detected for ${p.item.Name} at ${p.progress.toFixed(1)}%, checking file...`,
);
const filename = generateFilename(p.item);
const videoFile = new File(Paths.document, `${filename}.mp4`);
if (videoFile.exists && videoFile.size > 0) {
console.log(
`[UPDATE_PROGRESS] Orphaned download complete! File size: ${videoFile.size}, marking as complete`,
);
return {
...p,
progress: 100,
speed: 0,
bytesDownloaded: videoFile.size,
lastProgressUpdateTime: new Date(),
estimatedTotalSizeBytes: videoFile.size,
lastSessionBytes: videoFile.size,
lastSessionUpdateTime: new Date(),
status: "completed" as const,
};
} else {
console.warn(
`[UPDATE_PROGRESS] Orphaned download at ${p.progress.toFixed(1)}% but file not found. Keeping current state.`,
);
}
}
return p; // No task found, keep current state return p; // No task found, keep current state
} }
/* /*
// TODO: Uncomment this block to re-enable iOS zombie task detection // TODO: Uncomment this block to re-enable iOS zombie task detection
// iOS: Extra validation to prevent zombie task interference // iOS: Extra validation to prevent zombie task interference
@@ -272,6 +319,52 @@ function useDownloadProvider() {
progress = MAX_PROGRESS_BEFORE_COMPLETION; progress = MAX_PROGRESS_BEFORE_COMPLETION;
} }
const speed = calculateSpeed(p, task.bytesDownloaded); const speed = calculateSpeed(p, task.bytesDownloaded);
console.log(
`[UPDATE_PROGRESS] Task ${p.item.Name}: ${progress.toFixed(1)}% (${task.bytesDownloaded}/${estimatedSize} bytes), state: ${task.state}`,
);
// WORKAROUND: Check if download is actually complete by checking file existence
// This handles cases where the .done() callback doesn't fire (unknown content length, simulator issues, etc.)
if (progress >= 90 && task.state === "DONE") {
console.log(
`[UPDATE_PROGRESS] Task appears complete (state=DONE), checking file...`,
);
const filename = generateFilename(p.item);
const videoFile = new File(Paths.document, `${filename}.mp4`);
console.log(
`[UPDATE_PROGRESS] Looking for file at: ${videoFile.uri}`,
);
console.log(
`[UPDATE_PROGRESS] Paths.document.uri: ${Paths.document.uri}`,
);
console.log(`[UPDATE_PROGRESS] File exists: ${videoFile.exists}`);
console.log(`[UPDATE_PROGRESS] File size: ${videoFile.size}`);
if (videoFile.exists && videoFile.size > 0) {
console.log(
`[UPDATE_PROGRESS] File exists with size ${videoFile.size}, marking as complete!`,
);
// Mark as complete by setting status - this will trigger removal from processes
return {
...p,
progress: 100,
speed: 0,
bytesDownloaded: videoFile.size,
lastProgressUpdateTime: new Date(),
estimatedTotalSizeBytes: videoFile.size,
lastSessionBytes: videoFile.size,
lastSessionUpdateTime: new Date(),
status: "completed" as const,
};
} else {
console.warn(
`[UPDATE_PROGRESS] File not found or empty! Task state=${task.state}, progress=${progress}%`,
);
}
}
return { return {
...p, ...p,
progress, progress,
@@ -291,13 +384,14 @@ function useDownloadProvider() {
}); });
}; };
useInterval(updateProgress, 2000); useInterval(updateProgress, 1000);
const getDownloadedItemById = (id: string): DownloadedItem | undefined => { const getDownloadedItemById = (id: string): DownloadedItem | undefined => {
const db = getDownloadsDatabase(); const db = getDownloadsDatabase();
// Check movies first // Check movies first
if (db.movies[id]) { if (db.movies[id]) {
console.log(`[DB] Found movie with ID: ${id}`);
return db.movies[id]; return db.movies[id];
} }
@@ -306,14 +400,16 @@ function useDownloadProvider() {
for (const season of Object.values(series.seasons)) { for (const season of Object.values(series.seasons)) {
for (const episode of Object.values(season.episodes)) { for (const episode of Object.values(season.episodes)) {
if (episode.item.Id === id) { if (episode.item.Id === id) {
console.log(`[DB] Found episode with ID: ${id}`);
return episode; return episode;
} }
} }
} }
} }
console.log(`[DB] No item found with ID: ${id}`);
// Check other media types // Check other media types
if (db.other[id]) { if (db.other?.[id]) {
return db.other[id]; return db.other[id];
} }
@@ -346,34 +442,41 @@ function useDownloadProvider() {
return api?.accessToken; return api?.accessToken;
}, [api]); }, [api]);
const APP_CACHE_DOWNLOAD_DIRECTORY = `${FileSystem.cacheDirectory}${Application.applicationId}/Downloads/`; const APP_CACHE_DOWNLOAD_DIRECTORY = new Directory(
Paths.cache,
`${Application.applicationId}/Downloads/`,
);
const getDownloadsDatabase = (): DownloadsDatabase => { const getDownloadsDatabase = (): DownloadsDatabase => {
const file = storage.getString(DOWNLOADS_DATABASE_KEY); const file = storage.getString(DOWNLOADS_DATABASE_KEY);
if (file) { if (file) {
return JSON.parse(file) as DownloadsDatabase; const db = JSON.parse(file) as DownloadsDatabase;
return db;
} }
return { movies: {}, series: {}, other: {} }; // Initialize other media types storage return { movies: {}, series: {}, other: {} }; // Initialize other media types storage
}; };
const getDownloadedItems = () => { const getDownloadedItems = useCallback(() => {
const db = getDownloadsDatabase(); const db = getDownloadsDatabase();
const allItems = [ const movies = Object.values(db.movies);
...Object.values(db.movies), const episodes = Object.values(db.series).flatMap((series) =>
...Object.values(db.series).flatMap((series) => Object.values(series.seasons).flatMap((season) =>
Object.values(series.seasons).flatMap((season) => Object.values(season.episodes),
Object.values(season.episodes),
),
), ),
...Object.values(db.other), // Include other media types in results );
]; const otherItems = Object.values(db.other || {});
const allItems = [...movies, ...episodes, ...otherItems];
return allItems; return allItems;
}; }, []);
const downloadedItems = getDownloadedItems();
const saveDownloadsDatabase = (db: DownloadsDatabase) => { const saveDownloadsDatabase = (db: DownloadsDatabase) => {
const movieCount = Object.keys(db.movies).length;
const seriesCount = Object.keys(db.series).length;
console.log(
`[DB] Saving database: ${movieCount} movies, ${seriesCount} series`,
);
storage.set(DOWNLOADS_DATABASE_KEY, JSON.stringify(db)); storage.set(DOWNLOADS_DATABASE_KEY, JSON.stringify(db));
console.log(`[DB] Database saved successfully to MMKV`);
}; };
/** Generates a filename for a given item */ /** Generates a filename for a given item */
@@ -412,20 +515,17 @@ function useDownloadProvider() {
} }
const filename = generateFilename(item); const filename = generateFilename(item);
const trickplayDir = `${FileSystem.documentDirectory}${filename}_trickplay/`; const trickplayDir = new Directory(Paths.document, `${filename}_trickplay`);
await FileSystem.makeDirectoryAsync(trickplayDir, { intermediates: true }); trickplayDir.create({ intermediates: true });
let totalSize = 0; let totalSize = 0;
for (let index = 0; index < trickplayInfo.totalImageSheets; index++) { for (let index = 0; index < trickplayInfo.totalImageSheets; index++) {
const url = generateTrickplayUrl(item, index); const url = generateTrickplayUrl(item, index);
if (!url) continue; if (!url) continue;
const destination = `${trickplayDir}${index}.jpg`; const destination = new File(trickplayDir, `${index}.jpg`);
try { try {
await FileSystem.downloadAsync(url, destination); await File.downloadFileAsync(url, destination);
const fileInfo = await FileSystem.getInfoAsync(destination); totalSize += destination.size;
if (fileInfo.exists) {
totalSize += fileInfo.size;
}
} catch (e) { } catch (e) {
console.error( console.error(
`Failed to download trickplay image ${index} for item ${item.Id}`, `Failed to download trickplay image ${index} for item ${item.Id}`,
@@ -434,7 +534,7 @@ function useDownloadProvider() {
} }
} }
return { path: trickplayDir, size: totalSize }; return { path: trickplayDir.uri, size: totalSize };
}; };
/** /**
@@ -454,9 +554,12 @@ function useDownloadProvider() {
externalSubtitles.map(async (subtitle) => { externalSubtitles.map(async (subtitle) => {
const url = api.basePath + subtitle.DeliveryUrl; const url = api.basePath + subtitle.DeliveryUrl;
const filename = generateFilename(item); const filename = generateFilename(item);
const destination = `${FileSystem.documentDirectory}${filename}_subtitle_${subtitle.Index}`; const destination = new File(
await FileSystem.downloadAsync(url, destination); Paths.document,
subtitle.DeliveryUrl = destination; `${filename}_subtitle_${subtitle.Index}`,
);
await File.downloadFileAsync(url, destination);
subtitle.DeliveryUrl = destination.uri;
}), }),
); );
} }
@@ -542,86 +645,86 @@ function useDownloadProvider() {
progress: process.progress || 0, // Preserve existing progress for resume progress: process.progress || 0, // Preserve existing progress for resume
}); });
BackGroundDownloader?.setConfig({ if (!BackGroundDownloader) {
isLogsEnabled: false, throw new Error("Background downloader not available");
}
BackGroundDownloader.setConfig({
isLogsEnabled: true, // Enable logs to debug
progressInterval: 500, progressInterval: 500,
headers: { headers: {
Authorization: authHeader, Authorization: authHeader,
}, },
}); });
const filename = generateFilename(process.item); const filename = generateFilename(process.item);
const videoFilePath = `${FileSystem.documentDirectory}${filename}.mp4`; const videoFile = new File(Paths.document, `${filename}.mp4`);
BackGroundDownloader?.download({ const videoFilePath = videoFile.uri;
console.log(`[DOWNLOAD] Starting download for ${filename}`);
console.log(`[DOWNLOAD] Destination path: ${videoFilePath}`);
BackGroundDownloader.download({
id: process.id, id: process.id,
url: process.inputUrl, url: process.inputUrl,
destination: videoFilePath, destination: videoFilePath,
metadata: process, metadata: process,
}) });
.begin(() => { },
updateProcess(process.id, { [authHeader, sendDownloadNotification, getNotificationContent],
status: "downloading", );
progress: process.progress || 0,
bytesDownloaded: process.bytesDownloaded || 0,
lastProgressUpdateTime: new Date(),
lastSessionBytes: process.lastSessionBytes || 0,
lastSessionUpdateTime: new Date(),
});
})
.progress(
throttle((data) => {
updateProcess(process.id, (currentProcess) => {
// If this is a resumed download, add the paused bytes to current session bytes
const resumedBytes = currentProcess.pausedBytes || 0;
const totalBytes = data.bytesDownloaded + resumedBytes;
// Calculate progress based on total bytes if we have resumed bytes const manageDownloadQueue = useCallback(() => {
let percent: number; // Handle completed downloads (workaround for when .done() callback doesn't fire)
if (resumedBytes > 0 && data.bytesTotal > 0) { const completedDownloads = processes.filter(
// For resumed downloads, calculate based on estimated total size (p) => p.status === "completed",
const estimatedTotal = );
currentProcess.estimatedTotalSizeBytes || for (const completedProcess of completedDownloads) {
data.bytesTotal + resumedBytes; console.log(
percent = (totalBytes / estimatedTotal) * 100; `[QUEUE] Processing completed download: ${completedProcess.item.Name}`,
} else { );
// For fresh downloads, use normal calculation
percent = (data.bytesDownloaded / data.bytesTotal) * 100;
}
return { // Save to database
speed: calculateSpeed(currentProcess, totalBytes), (async () => {
status: "downloading", try {
progress: Math.min(percent, MAX_PROGRESS_BEFORE_COMPLETION), const filename = generateFilename(completedProcess.item);
bytesDownloaded: totalBytes, const videoFile = new File(Paths.document, `${filename}.mp4`);
lastProgressUpdateTime: new Date(), const videoFilePath = videoFile.uri;
// update session-only counters - use current session bytes only for speed calc const videoFileSize = videoFile.size;
lastSessionBytes: data.bytesDownloaded,
lastSessionUpdateTime: new Date(), console.log(`[QUEUE] Saving completed download to database`);
}; console.log(`[QUEUE] Video file path: ${videoFilePath}`);
}); console.log(`[QUEUE] Video file size: ${videoFileSize}`);
}, 500), console.log(`[QUEUE] Video file exists: ${videoFile.exists}`);
)
.done(async () => { if (!videoFile.exists) {
const trickPlayData = await downloadTrickplayImages(process.item); console.error(
const videoFileInfo = await FileSystem.getInfoAsync(videoFilePath); `[QUEUE] Cannot save - video file does not exist at ${videoFilePath}`,
if (!videoFileInfo.exists) { );
throw new Error("Downloaded file does not exist"); removeProcess(completedProcess.id);
return;
} }
const videoFileSize = videoFileInfo.size;
const trickPlayData = await downloadTrickplayImages(
completedProcess.item,
);
const db = getDownloadsDatabase(); const db = getDownloadsDatabase();
const { item, mediaSource } = process; const { item, mediaSource } = completedProcess;
// Only download external subtitles for non-transcoded streams. // Only download external subtitles for non-transcoded streams.
if (!mediaSource.TranscodingUrl) { if (!mediaSource.TranscodingUrl) {
await downloadAndLinkSubtitles(mediaSource, item); await downloadAndLinkSubtitles(mediaSource, item);
} }
const { introSegments, creditSegments } = await fetchAndParseSegments( const { introSegments, creditSegments } = await fetchAndParseSegments(
item.Id!, item.Id!,
api!, api!,
); );
const downloadedItem: DownloadedItem = { const downloadedItem: DownloadedItem = {
item, item,
mediaSource, mediaSource,
videoFilePath, videoFilePath,
videoFileSize, videoFileSize,
videoFileName: `${filename}.mp4`,
trickPlayData, trickPlayData,
userData: { userData: {
audioStreamIndex: 0, audioStreamIndex: 0,
@@ -666,63 +769,29 @@ function useDownloadProvider() {
] = downloadedItem; ] = downloadedItem;
} else if (item.Id) { } else if (item.Id) {
// Handle other media types // Handle other media types
if (!db.other) db.other = {};
db.other[item.Id] = downloadedItem; db.other[item.Id] = downloadedItem;
} }
await saveDownloadsDatabase(db);
// Send native notification for successful download await saveDownloadsDatabase(db);
const successNotification = getNotificationContent(
process.item,
true,
);
await sendDownloadNotification(
successNotification.title,
successNotification.body,
{
itemId: process.item.Id,
itemName: process.item.Name,
type: "download_completed",
},
);
toast.success( toast.success(
t("home.downloads.toasts.download_completed_for_item", { t("home.downloads.toasts.download_completed_for_item", {
item: process.item.Name, item: item.Name,
}), }),
); );
removeProcess(process.id);
})
.error(async (error: any) => {
console.error("Download error:", error);
// Send native notification for failed download console.log(
const failureNotification = getNotificationContent( `[QUEUE] Removing completed process: ${completedProcess.id}`,
process.item,
false,
);
await sendDownloadNotification(
failureNotification.title,
failureNotification.body,
{
itemId: process.item.Id,
itemName: process.item.Name,
type: "download_failed",
error: error?.message || "Unknown error",
},
); );
removeProcess(completedProcess.id);
} catch (error) {
console.error(`[QUEUE] Error processing completed download:`, error);
removeProcess(completedProcess.id);
}
})();
}
toast.error(
t("home.downloads.toasts.download_failed_for_item", {
item: process.item.Name,
}),
);
removeProcess(process.id);
});
},
[authHeader, sendDownloadNotification, getNotificationContent],
);
const manageDownloadQueue = useCallback(() => {
const activeDownloads = processes.filter( const activeDownloads = processes.filter(
(p) => p.status === "downloading", (p) => p.status === "downloading",
).length; ).length;
@@ -743,7 +812,7 @@ function useDownloadProvider() {
}); });
} }
} }
}, [processes, settings?.remuxConcurrentLimit, startDownload]); }, [processes, settings?.remuxConcurrentLimit, startDownload, api, t]);
const removeProcess = useCallback( const removeProcess = useCallback(
async (id: string) => { async (id: string) => {
@@ -796,11 +865,12 @@ function useDownloadProvider() {
*/ */
const cleanCacheDirectory = async (): Promise<void> => { const cleanCacheDirectory = async (): Promise<void> => {
try { try {
await FileSystem.deleteAsync(APP_CACHE_DOWNLOAD_DIRECTORY, { if (APP_CACHE_DOWNLOAD_DIRECTORY.exists) {
idempotent: true, APP_CACHE_DOWNLOAD_DIRECTORY.delete();
}); }
await FileSystem.makeDirectoryAsync(APP_CACHE_DOWNLOAD_DIRECTORY, { APP_CACHE_DOWNLOAD_DIRECTORY.create({
intermediates: true, intermediates: true,
idempotent: true,
}); });
} catch (_error) { } catch (_error) {
toast.error(t("home.downloads.toasts.failed_to_clean_cache_directory")); toast.error(t("home.downloads.toasts.failed_to_clean_cache_directory"));
@@ -814,8 +884,14 @@ function useDownloadProvider() {
mediaSource: MediaSourceInfo, mediaSource: MediaSourceInfo,
maxBitrate: Bitrate, maxBitrate: Bitrate,
) => { ) => {
if (!api || !item.Id || !authHeader) if (!api || !item.Id || !authHeader) {
console.warn("startBackgroundDownload ~ Missing required params", {
api,
item,
authHeader,
});
throw new Error("startBackgroundDownload ~ Missing required params"); throw new Error("startBackgroundDownload ~ Missing required params");
}
try { try {
const deviceId = getOrSetDeviceId(); const deviceId = getOrSetDeviceId();
await saveSeriesPrimaryImage(item); await saveSeriesPrimaryImage(item);
@@ -906,35 +982,109 @@ function useDownloadProvider() {
} }
} else { } else {
// Handle other media types // Handle other media types
downloadedItem = db.other[id]; if (db.other) {
if (downloadedItem) { downloadedItem = db.other[id];
delete db.other[id]; if (downloadedItem) {
delete db.other[id];
}
} }
} }
if (downloadedItem?.videoFilePath) { if (downloadedItem?.videoFilePath) {
await FileSystem.deleteAsync(downloadedItem.videoFilePath, { try {
idempotent: true, console.log(
}); `[DELETE] Attempting to delete video file: ${downloadedItem.videoFilePath}`,
);
// Properly reconstruct File object using Paths.document and filename
let videoFile: File;
if (downloadedItem.videoFileName) {
// New approach: use stored filename with Paths.document
videoFile = new File(Paths.document, downloadedItem.videoFileName);
console.log(
`[DELETE] Reconstructed file from stored filename: ${downloadedItem.videoFileName}`,
);
} else {
// Fallback for old downloads: extract filename from URI
const filename = downloadedItem.videoFilePath.split("/").pop();
if (!filename) {
throw new Error("Could not extract filename from path");
}
videoFile = new File(Paths.document, filename);
console.log(
`[DELETE] Reconstructed file from URI (legacy): ${filename}`,
);
}
console.log(`[DELETE] File URI: ${videoFile.uri}`);
console.log(
`[DELETE] File exists before deletion: ${videoFile.exists}`,
);
if (videoFile.exists) {
videoFile.delete();
console.log(`[DELETE] Video file deleted successfully`);
} else {
console.warn(`[DELETE] File does not exist, skipping deletion`);
}
} catch (err) {
console.error(`[DELETE] Failed to delete video file:`, err);
// File might not exist, continue anyway
}
} }
if (downloadedItem?.mediaSource?.MediaStreams) { if (downloadedItem?.mediaSource?.MediaStreams) {
for (const stream of downloadedItem.mediaSource.MediaStreams) { for (const stream of downloadedItem.mediaSource.MediaStreams) {
if ( if (
stream.Type === "Subtitle" && stream.Type === "Subtitle" &&
stream.DeliveryMethod === "External" stream.DeliveryMethod === "External" &&
stream.DeliveryUrl
) { ) {
await FileSystem.deleteAsync(stream.DeliveryUrl!, { try {
idempotent: true, console.log(
}); `[DELETE] Deleting subtitle file: ${stream.DeliveryUrl}`,
);
// Extract filename from the subtitle URI
const subtitleFilename = stream.DeliveryUrl.split("/").pop();
if (subtitleFilename) {
const subtitleFile = new File(Paths.document, subtitleFilename);
if (subtitleFile.exists) {
subtitleFile.delete();
console.log(
`[DELETE] Subtitle file deleted: ${subtitleFilename}`,
);
}
}
} catch (err) {
console.error(`[DELETE] Failed to delete subtitle:`, err);
// File might not exist, ignore
}
} }
} }
} }
if (downloadedItem?.trickPlayData?.path) { if (downloadedItem?.trickPlayData?.path) {
await FileSystem.deleteAsync(downloadedItem.trickPlayData.path, { try {
idempotent: true, console.log(
}); `[DELETE] Deleting trickplay directory: ${downloadedItem.trickPlayData.path}`,
);
// Extract directory name from URI
const trickplayDirName = downloadedItem.trickPlayData.path
.split("/")
.pop();
if (trickplayDirName) {
const trickplayDir = new Directory(Paths.document, trickplayDirName);
if (trickplayDir.exists) {
trickplayDir.delete();
console.log(
`[DELETE] Trickplay directory deleted: ${trickplayDirName}`,
);
}
}
} catch (err) {
console.error(`[DELETE] Failed to delete trickplay directory:`, err);
// Directory might not exist, ignore
}
} }
await saveDownloadsDatabase(db); await saveDownloadsDatabase(db);
@@ -962,6 +1112,7 @@ function useDownloadProvider() {
/** Deletes all files of a given type. */ /** Deletes all files of a given type. */
const deleteFileByType = async (type: BaseItemDto["Type"]) => { const deleteFileByType = async (type: BaseItemDto["Type"]) => {
const downloadedItems = getDownloadedItems();
const itemsToDelete = downloadedItems?.filter( const itemsToDelete = downloadedItems?.filter(
(file) => file.item.Type === type, (file) => file.item.Type === type,
); );
@@ -985,7 +1136,7 @@ function useDownloadProvider() {
const db = getDownloadsDatabase(); const db = getDownloadsDatabase();
if (db.movies[itemId]) { if (db.movies[itemId]) {
db.movies[itemId] = updatedItem; db.movies[itemId] = updatedItem;
} else if (db.other[itemId]) { } else if (db.other?.[itemId]) {
db.other[itemId] = updatedItem; db.other[itemId] = updatedItem;
} else { } else {
for (const series of Object.values(db.series)) { for (const series of Object.values(db.series)) {
@@ -1006,22 +1157,41 @@ function useDownloadProvider() {
* @returns The size of the app and the remaining space on the device. * @returns The size of the app and the remaining space on the device.
*/ */
const appSizeUsage = async () => { const appSizeUsage = async () => {
const [total, remaining] = await Promise.all([ const total = Paths.totalDiskSpace;
FileSystem.getTotalDiskCapacityAsync(), const remaining = Paths.availableDiskSpace;
FileSystem.getFreeDiskStorageAsync(),
]);
let appSize = 0; let appSize = 0;
const downloadedFiles = await FileSystem.readDirectoryAsync( try {
`${FileSystem.documentDirectory!}`, // Paths.document is a Directory object in the new API
); const documentDir = Paths.document;
for (const file of downloadedFiles) { console.log(`[STORAGE] Listing contents of: ${documentDir.uri}`);
const fileInfo = await FileSystem.getInfoAsync( console.log(`[STORAGE] Document dir exists: ${documentDir.exists}`);
`${FileSystem.documentDirectory!}${file}`,
); if (!documentDir.exists) {
if (fileInfo.exists) { console.warn(`[STORAGE] Document directory does not exist`);
appSize += fileInfo.size; return { total, remaining, appSize: 0 };
} }
const contents = documentDir.list();
console.log(
`[STORAGE] Found ${contents.length} items in document directory`,
);
for (const item of contents) {
if (item instanceof File) {
console.log(`[STORAGE] File: ${item.name}, size: ${item.size} bytes`);
appSize += item.size;
} else if (item instanceof Directory) {
const dirSize = item.size || 0;
console.log(
`[STORAGE] Directory: ${item.name}, size: ${dirSize} bytes`,
);
appSize += dirSize;
}
}
console.log(`[STORAGE] Total app size: ${appSize} bytes`);
} catch (error) {
console.error(`[STORAGE] Error calculating app size:`, error);
} }
return { total, remaining, appSize: appSize }; return { total, remaining, appSize: appSize };
}; };
@@ -1225,7 +1395,7 @@ function useDownloadProvider() {
deleteFileByType, deleteFileByType,
getDownloadedItemSize, getDownloadedItemSize,
getDownloadedItemById, getDownloadedItemById,
APP_CACHE_DOWNLOAD_DIRECTORY, APP_CACHE_DOWNLOAD_DIRECTORY: APP_CACHE_DOWNLOAD_DIRECTORY.uri,
cleanCacheDirectory, cleanCacheDirectory,
updateDownloadedItem, updateDownloadedItem,
appSizeUsage, appSizeUsage,

View File

@@ -46,6 +46,8 @@ export interface DownloadedItem {
videoFilePath: string; videoFilePath: string;
/** The size of the video file in bytes. */ /** The size of the video file in bytes. */
videoFileSize: number; videoFileSize: number;
/** The video filename (for easy File object reconstruction). Optional for backwards compatibility. */
videoFileName?: string;
/** The local file path of the downloaded trickplay images. */ /** The local file path of the downloaded trickplay images. */
trickPlayData?: TrickPlayData; trickPlayData?: TrickPlayData;
/** The intro segments for the item. */ /** The intro segments for the item. */

View File

@@ -0,0 +1,95 @@
import type { BottomSheetModal } from "@gorhom/bottom-sheet";
import type React from "react";
import {
createContext,
type ReactNode,
useCallback,
useContext,
useRef,
useState,
} from "react";
interface ModalOptions {
enableDynamicSizing?: boolean;
snapPoints?: (string | number)[];
enablePanDownToClose?: boolean;
backgroundStyle?: object;
handleIndicatorStyle?: object;
}
interface GlobalModalState {
content: ReactNode | null;
options?: ModalOptions;
}
interface GlobalModalContextType {
showModal: (content: ReactNode, options?: ModalOptions) => void;
hideModal: () => void;
isVisible: boolean;
modalState: GlobalModalState;
modalRef: React.RefObject<BottomSheetModal>;
}
const GlobalModalContext = createContext<GlobalModalContextType | undefined>(
undefined,
);
export const useGlobalModal = () => {
const context = useContext(GlobalModalContext);
if (!context) {
throw new Error("useGlobalModal must be used within GlobalModalProvider");
}
return context;
};
interface GlobalModalProviderProps {
children: ReactNode;
}
export const GlobalModalProvider: React.FC<GlobalModalProviderProps> = ({
children,
}) => {
const [modalState, setModalState] = useState<GlobalModalState>({
content: null,
options: undefined,
});
const [isVisible, setIsVisible] = useState(false);
const modalRef = useRef<BottomSheetModal>(null);
const showModal = useCallback(
(content: ReactNode, options?: ModalOptions) => {
setModalState({ content, options });
setIsVisible(true);
// Small delay to ensure state is updated before presenting
setTimeout(() => {
modalRef.current?.present();
}, 100);
},
[],
);
const hideModal = useCallback(() => {
modalRef.current?.dismiss();
setIsVisible(false);
// Clear content after animation completes
setTimeout(() => {
setModalState({ content: null, options: undefined });
}, 300);
}, []);
const value = {
showModal,
hideModal,
isVisible,
modalState,
modalRef,
};
return (
<GlobalModalContext.Provider value={value}>
{children}
</GlobalModalContext.Provider>
);
};
export type { GlobalModalContextType, ModalOptions };

View File

@@ -203,7 +203,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const removeServerMutation = useMutation({ const removeServerMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
storage.delete("serverUrl"); storage.remove("serverUrl");
setApi(null); setApi(null);
}, },
onError: (error) => { onError: (error) => {
@@ -286,7 +286,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
writeErrorLog("Failed to delete expo push token for device"), writeErrorLog("Failed to delete expo push token for device"),
); );
storage.delete("token"); storage.remove("token");
setUser(null); setUser(null);
setApi(null); setApi(null);
setPluginSettings(undefined); setPluginSettings(undefined);

View File

@@ -91,7 +91,7 @@ export const sortByPreferenceAtom = atomWithStorage<SortPreference>(
storage.set(key, JSON.stringify(value)); storage.set(key, JSON.stringify(value));
}, },
removeItem: (key) => { removeItem: (key) => {
storage.delete(key); storage.remove(key);
}, },
}, },
); );
@@ -108,7 +108,7 @@ export const sortOrderPreferenceAtom = atomWithStorage<SortOrderPreference>(
storage.set(key, JSON.stringify(value)); storage.set(key, JSON.stringify(value));
}, },
removeItem: (key) => { removeItem: (key) => {
storage.delete(key); storage.remove(key);
}, },
}, },
); );

View File

@@ -16,7 +16,7 @@ interface LogEntry {
const mmkvStorage = createJSONStorage(() => ({ const mmkvStorage = createJSONStorage(() => ({
getItem: (key: string) => storage.getString(key) || null, getItem: (key: string) => storage.getString(key) || null,
setItem: (key: string, value: string) => storage.set(key, value), setItem: (key: string, value: string) => storage.set(key, value),
removeItem: (key: string) => storage.delete(key), removeItem: (key: string) => storage.remove(key),
})); }));
const logsAtom = atomWithStorage("logs", [], mmkvStorage); const logsAtom = atomWithStorage("logs", [], mmkvStorage);
@@ -74,7 +74,7 @@ export const readFromLog = (): LogEntry[] => {
}; };
export const clearLogs = () => { export const clearLogs = () => {
storage.delete("logs"); storage.remove("logs");
}; };
export const dumpDownloadDiagnostics = (extra: any = {}) => { export const dumpDownloadDiagnostics = (extra: any = {}) => {

View File

@@ -1,5 +1,5 @@
import { MMKV } from "react-native-mmkv"; import { createMMKV } from "react-native-mmkv";
// Create a single MMKV instance following the official documentation // Create a single MMKV instance following the official documentation
// https://github.com/mrousavy/react-native-mmkv // https://github.com/mrousavy/react-native-mmkv
export const storage = new MMKV(); export const storage = createMMKV();