feat: Expo 54 (new arch) support + new in-house download module (#1174)

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
Co-authored-by: sarendsen <coding-mosses0z@icloud.com>
Co-authored-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com>
This commit is contained in:
Fredrik Burmester
2025-11-11 08:53:23 +01:00
committed by GitHub
parent 154788cf91
commit 485dc6eeac
181 changed files with 8422 additions and 4298 deletions

3
.gitignore vendored
View File

@@ -64,4 +64,5 @@ credentials.json
streamyfin-4fec1-firebase-adminsdk.json streamyfin-4fec1-firebase-adminsdk.json
# Version and Backup Files # Version and Backup Files
/version-backup-* /version-backup-*
modules/background-downloader/android/build/*

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

@@ -6,9 +6,6 @@ module.exports = ({ config }) => {
"react-native-google-cast", "react-native-google-cast",
{ useDefaultExpandedMediaControls: true }, { useDefaultExpandedMediaControls: true },
]); ]);
// Add the background downloader plugin only for non-TV builds
config.plugins.push("./plugins/withRNBackgroundDownloader.js");
} }
// Only override googleServicesFile if env var is set // Only override googleServicesFile if env var is set

View File

@@ -2,12 +2,13 @@
"expo": { "expo": {
"name": "Streamyfin", "name": "Streamyfin",
"slug": "streamyfin", "slug": "streamyfin",
"version": "0.40.4", "version": "0.46.2",
"orientation": "default", "orientation": "default",
"icon": "./assets/images/icon.png", "icon": "./assets/images/icon.png",
"scheme": "streamyfin", "scheme": "streamyfin",
"userInterfaceStyle": "dark", "userInterfaceStyle": "dark",
"jsEngine": "hermes", "jsEngine": "hermes",
"newArchEnabled": true,
"assetBundlePatterns": ["**/*"], "assetBundlePatterns": ["**/*"],
"ios": { "ios": {
"requireFullScreen": true, "requireFullScreen": true,
@@ -37,7 +38,7 @@
}, },
"android": { "android": {
"jsEngine": "hermes", "jsEngine": "hermes",
"versionCode": 76, "versionCode": 81,
"adaptiveIcon": { "adaptiveIcon": {
"foregroundImage": "./assets/images/icon-android-plain.png", "foregroundImage": "./assets/images/icon-android-plain.png",
"monochromeImage": "./assets/images/icon-android-themed.png", "monochromeImage": "./assets/images/icon-android-themed.png",
@@ -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",
@@ -115,10 +117,6 @@
} }
} }
], ],
["./plugins/withChangeNativeAndroidTextToWhite.js"],
["./plugins/withAndroidManifest.js"],
["./plugins/withTrustLocalCerts.js"],
["./plugins/withGradleProperties.js"],
[ [
"expo-splash-screen", "expo-splash-screen",
{ {
@@ -137,8 +135,12 @@
} }
} }
], ],
"./plugins/with-runtime-framework-headers.js", "expo-web-browser",
"react-native-bottom-tabs" ["./plugins/with-runtime-framework-headers.js"],
["./plugins/withChangeNativeAndroidTextToWhite.js"],
["./plugins/withAndroidManifest.js"],
["./plugins/withTrustLocalCerts.js"],
["./plugins/withGradleProperties.js"]
], ],
"experiments": { "experiments": {
"typedRoutes": true "typedRoutes": true
@@ -157,7 +159,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

@@ -1,5 +1,5 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { RefreshControl, ScrollView, View } from "react-native"; import { Platform, RefreshControl, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Favorites } from "@/components/home/Favorites"; import { Favorites } from "@/components/home/Favorites";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
@@ -28,7 +28,7 @@ export default function favorites() {
paddingBottom: 16, paddingBottom: 16,
}} }}
> >
<View className='my-4'> <View style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}>
<Favorites /> <Favorites />
</View> </View>
</ScrollView> </ScrollView>

View File

@@ -1,15 +1,8 @@
import { Feather, Ionicons } from "@expo/vector-icons"; import { Feather, Ionicons } from "@expo/vector-icons";
import { Stack, useRouter } from "expo-router"; import { Stack, useRouter } from "expo-router";
import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import { Platform, TouchableOpacity, View } from "react-native";
ActivityIndicator,
Platform,
TouchableOpacity,
View,
} from "react-native";
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack"; import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import { eventBus } from "@/utils/eventBus";
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast"); const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
@@ -37,7 +30,6 @@ export default function IndexLayout() {
{!Platform.isTV && ( {!Platform.isTV && (
<> <>
<Chromecast.Chromecast background='transparent' /> <Chromecast.Chromecast background='transparent' />
<RefreshButton />
{user?.Policy?.IsAdministrator && <SessionsButton />} {user?.Policy?.IsAdministrator && <SessionsButton />}
<SettingsButton /> <SettingsButton />
</> </>
@@ -49,49 +41,119 @@ export default function IndexLayout() {
<Stack.Screen <Stack.Screen
name='downloads/index' name='downloads/index'
options={{ options={{
headerShown: true,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
title: t("home.downloads.downloads_title"), title: t("home.downloads.downloads_title"),
headerLeft: () => (
<TouchableOpacity
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
}} }}
/> />
<Stack.Screen <Stack.Screen
name='downloads/[seriesId]' name='downloads/[seriesId]'
options={{ options={{
headerShown: true,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
title: t("home.downloads.tvseries"), title: t("home.downloads.tvseries"),
headerLeft: () => (
<TouchableOpacity
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
}} }}
/> />
<Stack.Screen <Stack.Screen
name='sessions/index' name='sessions/index'
options={{ options={{
title: t("home.sessions.title"), title: t("home.sessions.title"),
headerShown: true,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<TouchableOpacity
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
}} }}
/> />
<Stack.Screen <Stack.Screen
name='settings' name='settings'
options={{ options={{
title: t("home.settings.settings_title"), title: t("home.settings.settings_title"),
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<TouchableOpacity
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
}} }}
/> />
<Stack.Screen <Stack.Screen
name='settings/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
@@ -99,6 +161,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",
}} }}
/> />
@@ -109,6 +176,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",
@@ -133,32 +205,6 @@ const SettingsButton = () => {
); );
}; };
const RefreshButton = () => {
const [isRefreshing, setIsRefreshing] = useState(false);
const handleRefresh = () => {
setIsRefreshing(true);
eventBus.emit("refreshHome");
setTimeout(() => {
setIsRefreshing(false);
}, 2000);
};
return (
<TouchableOpacity
onPress={handleRefresh}
className='mr-4'
disabled={isRefreshing}
>
{isRefreshing ? (
<ActivityIndicator size='small' color='white' />
) : (
<Ionicons name='refresh-outline' color='white' size={24} />
)}
</TouchableOpacity>
);
};
const SessionsButton = () => { const SessionsButton = () => {
const router = useRouter(); const router = useRouter();
const { sessions = [] } = useSessions({} as useSessionsProps); const { sessions = [] } = useSessions({} as useSessionsProps);

View File

@@ -1,8 +1,9 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { FlashList } from "@shopify/flash-list";
import { router, useLocalSearchParams, useNavigation } from "expo-router"; import { router, useLocalSearchParams, useNavigation } from "expo-router";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { Alert, ScrollView, TouchableOpacity, View } from "react-native"; import { Alert, Platform, TouchableOpacity, View } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { EpisodeCard } from "@/components/downloads/EpisodeCard"; import { EpisodeCard } from "@/components/downloads/EpisodeCard";
import { import {
@@ -23,21 +24,22 @@ export default function page() {
const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>( const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>(
{}, {},
); );
const { getDownloadedItems, deleteItems } = useDownload(); const { downloadedItems, deleteItems } = useDownload();
const series = useMemo(() => { const series = useMemo(() => {
try { try {
return ( return (
getDownloadedItems() downloadedItems
?.filter((f) => f.item.SeriesId === seriesId) ?.filter((f) => f.item.SeriesId === seriesId)
?.sort( ?.sort(
(a, b) => a?.item.ParentIndexNumber! - b.item.ParentIndexNumber!, (a, b) =>
(a.item.ParentIndexNumber ?? 0) - (b.item.ParentIndexNumber ?? 0),
) || [] ) || []
); );
} catch { } catch {
return []; return [];
} }
}, [getDownloadedItems]); }, [downloadedItems, seriesId]);
// Group episodes by season in a single pass // Group episodes by season in a single pass
const seasonGroups = useMemo(() => { const seasonGroups = useMemo(() => {
@@ -91,7 +93,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]);
@@ -107,17 +109,22 @@ export default function page() {
}, },
{ {
text: "Delete", text: "Delete",
onPress: () => deleteItems(groupBySeason), onPress: () =>
deleteItems(
groupBySeason
.map((item) => item.Id)
.filter((id) => id !== undefined),
),
style: "destructive", style: "destructive",
}, },
], ],
); );
}, [groupBySeason]); }, [groupBySeason, deleteItems]);
return ( return (
<View className='flex-1'> <View style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}>
{series.length > 0 && ( {series.length > 0 && (
<View className='flex flex-row items-center justify-start my-2 px-4'> <View className='flex flex-row items-center justify-start px-4 pb-2'>
<SeasonDropdown <SeasonDropdown
item={series[0].item} item={series[0].item}
seasons={uniqueSeasons} seasons={uniqueSeasons}
@@ -140,11 +147,13 @@ export default function page() {
</View> </View>
</View> </View>
)} )}
<ScrollView key={seasonIndex} className='px-4'> <FlashList
{groupBySeason.map((episode, index) => ( key={seasonIndex}
<EpisodeCard key={index} item={episode} /> data={groupBySeason}
))} renderItem={({ item }) => <EpisodeCard item={item} />}
</ScrollView> keyExtractor={(item, index) => item.Id ?? `episode-${index}`}
contentContainerStyle={{ paddingHorizontal: 16 }}
/>
</View> </View>
); );
} }

View File

@@ -1,17 +1,17 @@
import { Ionicons } from "@expo/vector-icons"; import { BottomSheetModal } from "@gorhom/bottom-sheet";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import { useNavigation, useRouter } from "expo-router"; import { useNavigation, useRouter } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Alert, ScrollView, TouchableOpacity, View } from "react-native"; import {
Alert,
Platform,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import ActiveDownloads from "@/components/downloads/ActiveDownloads"; import ActiveDownloads from "@/components/downloads/ActiveDownloads";
@@ -26,18 +26,15 @@ import { writeToLog } from "@/utils/log";
export default function page() { export default function page() {
const navigation = useNavigation(); const navigation = useNavigation();
const { t } = useTranslation(); const { t } = useTranslation();
const [queue, setQueue] = useAtom(queueAtom); const [_queue, _setQueue] = useAtom(queueAtom);
const { const { downloadedItems, deleteFileByType, deleteAllFiles } = useDownload();
removeProcess,
getDownloadedItems,
deleteFileByType,
deleteAllFiles,
} = useDownload();
const router = useRouter(); const router = useRouter();
const bottomSheetModalRef = useRef<BottomSheetModal>(null); const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const [showMigration, setShowMigration] = useState(false); const [showMigration, setShowMigration] = useState(false);
const _insets = useSafeAreaInsets();
const migration_20241124 = () => { const migration_20241124 = () => {
Alert.alert( Alert.alert(
t("home.downloads.new_app_version_requires_re_download"), t("home.downloads.new_app_version_requires_re_download"),
@@ -62,7 +59,7 @@ export default function page() {
); );
}; };
const downloadedFiles = getDownloadedItems(); const downloadedFiles = useMemo(() => downloadedItems, [downloadedItems]);
const movies = useMemo(() => { const movies = useMemo(() => {
try { try {
@@ -106,7 +103,10 @@ export default function page() {
useEffect(() => { useEffect(() => {
navigation.setOptions({ navigation.setOptions({
headerRight: () => ( headerRight: () => (
<TouchableOpacity onPress={bottomSheetModalRef.current?.present}> <TouchableOpacity
onPress={bottomSheetModalRef.current?.present}
className='px-2'
>
<DownloadSize items={downloadedFiles?.map((f) => f.item) || []} /> <DownloadSize items={downloadedFiles?.map((f) => f.item) || []} />
</TouchableOpacity> </TouchableOpacity>
), ),
@@ -119,7 +119,7 @@ export default function page() {
} }
}, [showMigration]); }, [showMigration]);
const deleteMovies = () => const _deleteMovies = () =>
deleteFileByType("Movie") deleteFileByType("Movie")
.then(() => .then(() =>
toast.success( toast.success(
@@ -130,7 +130,7 @@ export default function page() {
writeToLog("ERROR", reason); writeToLog("ERROR", reason);
toast.error(t("home.downloads.toasts.failed_to_delete_all_movies")); toast.error(t("home.downloads.toasts.failed_to_delete_all_movies"));
}); });
const deleteShows = () => const _deleteShows = () =>
deleteFileByType("Episode") deleteFileByType("Episode")
.then(() => .then(() =>
toast.success( toast.success(
@@ -141,38 +141,39 @@ export default function page() {
writeToLog("ERROR", reason); writeToLog("ERROR", reason);
toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries")); toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
}); });
const deleteOtherMedia = () => const _deleteOtherMedia = () =>
Promise.all( Promise.all(
otherMedia.map((item) => otherMedia
deleteFileByType(item.item.Type) .filter((item) => item.item.Type)
.then(() => .map((item) =>
toast.success( deleteFileByType(item.item.Type!)
t("home.downloads.toasts.deleted_media_successfully", { .then(() =>
type: item.item.Type, toast.success(
}), t("home.downloads.toasts.deleted_media_successfully", {
), type: item.item.Type,
) }),
.catch((reason) => { ),
writeToLog("ERROR", reason); )
toast.error( .catch((reason) => {
t("home.downloads.toasts.failed_to_delete_media", { writeToLog("ERROR", reason);
type: item.item.Type, toast.error(
}), t("home.downloads.toasts.failed_to_delete_media", {
); type: item.item.Type,
}), }),
), );
}),
),
); );
const deleteAllMedia = async () =>
await Promise.all([deleteMovies(), deleteShows(), deleteOtherMedia()]);
return ( return (
<> <ScrollView
<View style={{ flex: 1 }}> showsVerticalScrollIndicator={false}
<ScrollView showsVerticalScrollIndicator={false} className='flex-1'> contentInsetAdjustmentBehavior='automatic'
<View className='py-4'> >
<View className='mb-4 flex flex-col space-y-4 px-4'> <View style={{ paddingTop: Platform.OS === "android" ? 17 : 0 }}>
<View className='bg-neutral-900 p-4 rounded-2xl'> <View className='mb-4 flex flex-col space-y-4 px-4'>
{/* Queue card - hidden */}
{/* <View className='bg-neutral-900 p-4 rounded-2xl'>
<Text className='text-lg font-bold'> <Text className='text-lg font-bold'>
{t("home.downloads.queue")} {t("home.downloads.queue")}
</Text> </Text>
@@ -214,139 +215,96 @@ export default function page() {
{t("home.downloads.no_items_in_queue")} {t("home.downloads.no_items_in_queue")}
</Text> </Text>
)} )}
</View> </View> */}
<ActiveDownloads /> <ActiveDownloads />
</View>
{movies.length > 0 && (
<View className='mb-4'>
<View className='flex flex-row items-center justify-between mb-2 px-4'>
<Text className='text-lg font-bold'>
{t("home.downloads.movies")}
</Text>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
<Text className='text-xs font-bold'>{movies?.length}</Text>
</View>
</View> </View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
{movies.length > 0 && ( <View className='px-4 flex flex-row'>
<View className='mb-4'> {movies?.map((item) => (
<View className='flex flex-row items-center justify-between mb-2 px-4'> <TouchableItemRouter
<Text className='text-lg font-bold'> item={item.item}
{t("home.downloads.movies")} isOffline
</Text> key={item.item.Id}
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'> >
<Text className='text-xs font-bold'>{movies?.length}</Text> <MovieCard item={item.item} />
</View> </TouchableItemRouter>
</View> ))}
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className='px-4 flex flex-row'>
{movies?.map((item) => (
<TouchableItemRouter
item={item.item}
isOffline
key={item.item.Id}
>
<MovieCard item={item.item} />
</TouchableItemRouter>
))}
</View>
</ScrollView>
</View> </View>
)} </ScrollView>
{groupedBySeries.length > 0 && ( </View>
<View className='mb-4'> )}
<View className='flex flex-row items-center justify-between mb-2 px-4'> {groupedBySeries.length > 0 && (
<Text className='text-lg font-bold'> <View className='mb-4'>
{t("home.downloads.tvseries")} <View className='flex flex-row items-center justify-between mb-2 px-4'>
</Text> <Text className='text-lg font-bold'>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'> {t("home.downloads.tvseries")}
<Text className='text-xs font-bold'> </Text>
{groupedBySeries?.length} <View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
</Text> <Text className='text-xs font-bold'>
</View> {groupedBySeries?.length}
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className='px-4 flex flex-row'>
{groupedBySeries?.map((items) => (
<View
className='mb-2 last:mb-0'
key={items[0].item.SeriesId}
>
<SeriesCard
items={items.map((i) => i.item)}
key={items[0].item.SeriesId}
/>
</View>
))}
</View>
</ScrollView>
</View>
)}
{otherMedia.length > 0 && (
<View className='mb-4'>
<View className='flex flex-row items-center justify-between mb-2 px-4'>
<Text className='text-lg font-bold'>
{t("home.downloads.other_media")}
</Text>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
<Text className='text-xs font-bold'>
{otherMedia?.length}
</Text>
</View>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className='px-4 flex flex-row'>
{otherMedia?.map((item) => (
<TouchableItemRouter
item={item.item}
isOffline
key={item.item.Id}
>
<MovieCard item={item.item} />
</TouchableItemRouter>
))}
</View>
</ScrollView>
</View>
)}
{downloadedFiles?.length === 0 && (
<View className='flex px-4'>
<Text className='opacity-50'>
{t("home.downloads.no_downloaded_items")}
</Text> </Text>
</View> </View>
)} </View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className='px-4 flex flex-row'>
{groupedBySeries?.map((items) => (
<View className='mb-2 last:mb-0' key={items[0].item.SeriesId}>
<SeriesCard
items={items.map((i) => i.item)}
key={items[0].item.SeriesId}
/>
</View>
))}
</View>
</ScrollView>
</View> </View>
</ScrollView>
</View>
<BottomSheetModal
ref={bottomSheetModalRef}
enableDynamicSizing
handleIndicatorStyle={{
backgroundColor: "white",
}}
backgroundStyle={{
backgroundColor: "#171717",
}}
backdropComponent={(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
)} )}
>
<BottomSheetView> {otherMedia.length > 0 && (
<View className='p-4 space-y-4 mb-4'> <View className='mb-4'>
<Button color='purple' onPress={deleteMovies}> <View className='flex flex-row items-center justify-between mb-2 px-4'>
{t("home.downloads.delete_all_movies_button")} <Text className='text-lg font-bold'>
</Button> {t("home.downloads.other_media")}
<Button color='purple' onPress={deleteShows}> </Text>
{t("home.downloads.delete_all_tvseries_button")} <View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
</Button> <Text className='text-xs font-bold'>{otherMedia?.length}</Text>
{otherMedia.length > 0 && ( </View>
<Button color='purple' onPress={deleteOtherMedia}> </View>
{t("home.downloads.delete_all_other_media_button")} <ScrollView horizontal showsHorizontalScrollIndicator={false}>
</Button> <View className='px-4 flex flex-row'>
)} {otherMedia?.map((item) => (
<Button color='red' onPress={deleteAllMedia}> <TouchableItemRouter
{t("home.downloads.delete_all_button")} item={item.item}
</Button> isOffline
key={item.item.Id}
>
<MovieCard item={item.item} />
</TouchableItemRouter>
))}
</View>
</ScrollView>
</View> </View>
</BottomSheetView> )}
</BottomSheetModal> {downloadedFiles?.length === 0 && (
</> <View className='flex px-4'>
<Text className='opacity-50'>
{t("home.downloads.no_downloaded_items")}
</Text>
</View>
)}
</View>
</ScrollView>
); );
} }

View File

@@ -1,5 +1,16 @@
import { HomeIndex } from "@/components/settings/HomeIndex"; import { useSettings } from "@/utils/atoms/settings";
import { Home } from "../../../../components/home/Home";
import { HomeWithCarousel } from "../../../../components/home/HomeWithCarousel";
export default function page() { const Index = () => {
return <HomeIndex />; const { settings } = useSettings();
} const showLargeHomeCarousel = settings.showLargeHomeCarousel ?? false;
if (showLargeHomeCarousel) {
return <HomeWithCarousel />;
}
return <Home />;
};
export default Index;

View File

@@ -1,11 +1,9 @@
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"; import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { import { HardwareAccelerationType } from "@jellyfin/sdk/lib/generated-client";
HardwareAccelerationType,
type SessionInfoDto,
} from "@jellyfin/sdk/lib/generated-client";
import { import {
GeneralCommandType, GeneralCommandType,
PlaystateCommand, PlaystateCommand,
SessionInfoDto,
} from "@jellyfin/sdk/lib/generated-client/models"; } from "@jellyfin/sdk/lib/generated-client/models";
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api"; import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
import { FlashList } from "@shopify/flash-list"; import { FlashList } from "@shopify/flash-list";
@@ -13,7 +11,7 @@ import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View } from "react-native";
import { Badge } from "@/components/Badge"; import { Badge } from "@/components/Badge";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
@@ -49,14 +47,13 @@ export default function page() {
<FlashList <FlashList
contentInsetAdjustmentBehavior='automatic' contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{ contentContainerStyle={{
paddingTop: 17, paddingTop: Platform.OS === "android" ? 17 : 0,
paddingHorizontal: 17, paddingHorizontal: 17,
paddingBottom: 150, paddingBottom: 150,
}} }}
data={sessions} data={sessions}
renderItem={({ item }) => <SessionCard session={item} />} renderItem={({ item }) => <SessionCard session={item} />}
keyExtractor={(item) => item.Id || ""} keyExtractor={(item) => item.Id || ""}
estimatedItemSize={200}
/> />
); );
} }

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>
@@ -56,12 +56,16 @@ export default function settings() {
return ( return (
<ScrollView <ScrollView
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{ contentContainerStyle={{
paddingLeft: insets.left, paddingLeft: insets.left,
paddingRight: insets.right, paddingRight: insets.right,
}} }}
> >
<View className='p-4 flex flex-col gap-y-4'> <View
className='p-4 flex flex-col gap-y-4'
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
>
<UserInfo /> <UserInfo />
<QuickConnect className='mb-4' /> <QuickConnect className='mb-4' />
@@ -115,6 +119,7 @@ export default function settings() {
</View> </View>
{!Platform.isTV && <StorageSettings />} {!Platform.isTV && <StorageSettings />}
<View className='h-24' />
</View> </View>
</ScrollView> </ScrollView>
); );

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

@@ -16,6 +16,7 @@ import type React from "react";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FlatList, View } from "react-native"; import { FlatList, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { FilterButton } from "@/components/filters/FilterButton"; import { FilterButton } from "@/components/filters/FilterButton";
@@ -204,154 +205,154 @@ const page: React.FC = () => {
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []); const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
const _insets = useSafeAreaInsets();
const ListHeaderComponent = useCallback( const ListHeaderComponent = useCallback(
() => ( () => (
<View className=''> <FlatList
<FlatList horizontal
horizontal showsHorizontalScrollIndicator={false}
showsHorizontalScrollIndicator={false} contentContainerStyle={{
contentContainerStyle={{ display: "flex",
display: "flex", paddingHorizontal: 15,
paddingHorizontal: 15, paddingVertical: 16,
paddingVertical: 16, flexDirection: "row",
flexDirection: "row", }}
}} extraData={[
extraData={[ selectedGenres,
selectedGenres, selectedYears,
selectedYears, selectedTags,
selectedTags, sortBy,
sortBy, sortOrder,
sortOrder, ]}
]} data={[
data={[ {
{ key: "reset",
key: "reset", component: <ResetFiltersButton />,
component: <ResetFiltersButton />, },
}, {
{ key: "genre",
key: "genre", component: (
component: ( <FilterButton
<FilterButton className='mr-1'
className='mr-1' id={collectionId}
id={collectionId} queryKey='genreFilter'
queryKey='genreFilter' queryFn={async () => {
queryFn={async () => { if (!api) return null;
if (!api) return null; const response = await getFilterApi(
const response = await getFilterApi( api,
api, ).getQueryFiltersLegacy({
).getQueryFiltersLegacy({ userId: user?.Id,
userId: user?.Id, parentId: collectionId,
parentId: collectionId, });
}); return response.data.Genres || [];
return response.data.Genres || []; }}
}} set={setSelectedGenres}
set={setSelectedGenres} values={selectedGenres}
values={selectedGenres} title={t("library.filters.genres")}
title={t("library.filters.genres")} renderItemLabel={(item) => item.toString()}
renderItemLabel={(item) => item.toString()} searchFilter={(item, search) =>
searchFilter={(item, search) => item.toLowerCase().includes(search.toLowerCase())
item.toLowerCase().includes(search.toLowerCase()) }
} />
/> ),
), },
}, {
{ key: "year",
key: "year", component: (
component: ( <FilterButton
<FilterButton className='mr-1'
className='mr-1' id={collectionId}
id={collectionId} queryKey='yearFilter'
queryKey='yearFilter' queryFn={async () => {
queryFn={async () => { if (!api) return null;
if (!api) return null; const response = await getFilterApi(
const response = await getFilterApi( api,
api, ).getQueryFiltersLegacy({
).getQueryFiltersLegacy({ userId: user?.Id,
userId: user?.Id, parentId: collectionId,
parentId: collectionId, });
}); return response.data.Years || [];
return response.data.Years || []; }}
}} set={setSelectedYears}
set={setSelectedYears} values={selectedYears}
values={selectedYears} title={t("library.filters.years")}
title={t("library.filters.years")} renderItemLabel={(item) => item.toString()}
renderItemLabel={(item) => item.toString()} searchFilter={(item, search) => item.includes(search)}
searchFilter={(item, search) => item.includes(search)} />
/> ),
), },
}, {
{ key: "tags",
key: "tags", component: (
component: ( <FilterButton
<FilterButton className='mr-1'
className='mr-1' id={collectionId}
id={collectionId} queryKey='tagsFilter'
queryKey='tagsFilter' queryFn={async () => {
queryFn={async () => { if (!api) return null;
if (!api) return null; const response = await getFilterApi(
const response = await getFilterApi( api,
api, ).getQueryFiltersLegacy({
).getQueryFiltersLegacy({ userId: user?.Id,
userId: user?.Id, parentId: collectionId,
parentId: collectionId, });
}); return response.data.Tags || [];
return response.data.Tags || []; }}
}} set={setSelectedTags}
set={setSelectedTags} values={selectedTags}
values={selectedTags} title={t("library.filters.tags")}
title={t("library.filters.tags")} renderItemLabel={(item) => item.toString()}
renderItemLabel={(item) => item.toString()} searchFilter={(item, search) =>
searchFilter={(item, search) => item.toLowerCase().includes(search.toLowerCase())
item.toLowerCase().includes(search.toLowerCase()) }
} />
/> ),
), },
}, {
{ key: "sortBy",
key: "sortBy", component: (
component: ( <FilterButton
<FilterButton className='mr-1'
className='mr-1' id={collectionId}
id={collectionId} queryKey='sortBy'
queryKey='sortBy' queryFn={async () => sortOptions.map((s) => s.key)}
queryFn={async () => sortOptions.map((s) => s.key)} set={setSortBy}
set={setSortBy} values={sortBy}
values={sortBy} title={t("library.filters.sort_by")}
title={t("library.filters.sort_by")} renderItemLabel={(item) =>
renderItemLabel={(item) => sortOptions.find((i) => i.key === item)?.value || ""
sortOptions.find((i) => i.key === item)?.value || "" }
} searchFilter={(item, search) =>
searchFilter={(item, search) => item.toLowerCase().includes(search.toLowerCase())
item.toLowerCase().includes(search.toLowerCase()) }
} />
/> ),
), },
}, {
{ key: "sortOrder",
key: "sortOrder", component: (
component: ( <FilterButton
<FilterButton className='mr-1'
className='mr-1' id={collectionId}
id={collectionId} queryKey='sortOrder'
queryKey='sortOrder' queryFn={async () => sortOrderOptions.map((s) => s.key)}
queryFn={async () => sortOrderOptions.map((s) => s.key)} set={setSortOrder}
set={setSortOrder} values={sortOrder}
values={sortOrder} title={t("library.filters.sort_order")}
title={t("library.filters.sort_order")} renderItemLabel={(item) =>
renderItemLabel={(item) => sortOrderOptions.find((i) => i.key === item)?.value || ""
sortOrderOptions.find((i) => i.key === item)?.value || "" }
} searchFilter={(item, search) =>
searchFilter={(item, search) => item.toLowerCase().includes(search.toLowerCase())
item.toLowerCase().includes(search.toLowerCase()) }
} />
/> ),
), },
}, ]}
]} renderItem={({ item }) => item.component}
renderItem={({ item }) => item.component} keyExtractor={(item) => item.key}
keyExtractor={(item) => item.key} />
/>
</View>
), ),
[ [
collectionId, collectionId,
@@ -393,7 +394,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();
@@ -65,6 +63,7 @@ const Page: React.FC = () => {
const [issueType, setIssueType] = useState<IssueType>(); const [issueType, setIssueType] = useState<IssueType>();
const [issueMessage, setIssueMessage] = useState<string>(); const [issueMessage, setIssueMessage] = useState<string>();
const [requestBody, _setRequestBody] = useState<MediaRequestBody>(); const [requestBody, _setRequestBody] = useState<MediaRequestBody>();
const [issueTypeDropdownOpen, setIssueTypeDropdownOpen] = useState(false);
const advancedReqModalRef = useRef<BottomSheetModal>(null); const advancedReqModalRef = useRef<BottomSheetModal>(null);
const bottomSheetModalRef = useRef<BottomSheetModal>(null); const bottomSheetModalRef = useRef<BottomSheetModal>(null);
@@ -115,6 +114,10 @@ const Page: React.FC = () => {
} }
}, [jellyseerrApi, details, result, issueType, issueMessage]); }, [jellyseerrApi, details, result, issueType, issueMessage]);
const handleIssueModalDismiss = useCallback(() => {
setIssueTypeDropdownOpen(false);
}, []);
const setRequestBody = useCallback( const setRequestBody = useCallback(
(body: MediaRequestBody) => { (body: MediaRequestBody) => {
_setRequestBody(body); _setRequestBody(body);
@@ -156,11 +159,31 @@ const Page: React.FC = () => {
[details], [details],
); );
const issueTypeOptionGroups = useMemo(
() => [
{
title: t("jellyseerr.types"),
options: Object.entries(IssueTypeName)
.reverse()
.map(([key, value]) => ({
type: "radio" as const,
label: value,
value: key,
selected: key === String(issueType),
onPress: () => setIssueType(key as unknown as IssueType),
})),
},
],
[issueType, t],
);
useEffect(() => { useEffect(() => {
if (details) { if (details) {
navigation.setOptions({ navigation.setOptions({
headerRight: () => ( headerRight: () => (
<TouchableOpacity className='rounded-full p-2 bg-neutral-800/80'> <TouchableOpacity
className={`rounded-full pl-1.5 ${Platform.OS === "android" ? "" : "bg-neutral-800/80"}`}
>
<ItemActions item={details} /> <ItemActions item={details} />
</TouchableOpacity> </TouchableOpacity>
), ),
@@ -355,6 +378,8 @@ const Page: React.FC = () => {
backgroundColor: "#171717", backgroundColor: "#171717",
}} }}
backdropComponent={renderBackdrop} backdropComponent={renderBackdrop}
stackBehavior='push'
onDismiss={handleIssueModalDismiss}
> >
<BottomSheetView> <BottomSheetView>
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'> <View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
@@ -364,50 +389,25 @@ 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} open={issueTypeDropdownOpen}
side='bottom' onOpenChange={setIssueTypeDropdownOpen}
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

@@ -65,9 +65,11 @@ const page: React.FC = () => {
const { data: allEpisodes, isLoading } = useQuery({ const { data: allEpisodes, isLoading } = useQuery({
queryKey: ["AllEpisodes", item?.Id], queryKey: ["AllEpisodes", item?.Id],
queryFn: async () => { queryFn: async () => {
const res = await getTvShowsApi(api!).getEpisodes({ if (!api || !user?.Id || !item?.Id) return [];
seriesId: item?.Id!,
userId: user?.Id!, const res = await getTvShowsApi(api).getEpisodes({
seriesId: item.Id,
userId: user.Id,
enableUserData: true, enableUserData: true,
// Note: Including trick play is necessary to enable trick play downloads // Note: Including trick play is necessary to enable trick play downloads
fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"], fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],

View File

@@ -271,145 +271,143 @@ const Page = () => {
const ListHeaderComponent = useCallback( const ListHeaderComponent = useCallback(
() => ( () => (
<View className=''> <FlatList
<FlatList horizontal
horizontal showsHorizontalScrollIndicator={false}
showsHorizontalScrollIndicator={false} contentContainerStyle={{
contentContainerStyle={{ display: "flex",
display: "flex", paddingHorizontal: 15,
paddingHorizontal: 15, paddingVertical: 16,
paddingVertical: 16, flexDirection: "row",
flexDirection: "row", }}
}} data={[
data={[ {
{ key: "reset",
key: "reset", component: <ResetFiltersButton />,
component: <ResetFiltersButton />, },
}, {
{ key: "genre",
key: "genre", component: (
component: ( <FilterButton
<FilterButton className='mr-1'
className='mr-1' id={libraryId}
id={libraryId} queryKey='genreFilter'
queryKey='genreFilter' queryFn={async () => {
queryFn={async () => { if (!api) return null;
if (!api) return null; const response = await getFilterApi(
const response = await getFilterApi( api,
api, ).getQueryFiltersLegacy({
).getQueryFiltersLegacy({ userId: user?.Id,
userId: user?.Id, parentId: libraryId,
parentId: libraryId, });
}); return response.data.Genres || [];
return response.data.Genres || []; }}
}} set={setSelectedGenres}
set={setSelectedGenres} values={selectedGenres}
values={selectedGenres} title={t("library.filters.genres")}
title={t("library.filters.genres")} renderItemLabel={(item) => item.toString()}
renderItemLabel={(item) => item.toString()} searchFilter={(item, search) =>
searchFilter={(item, search) => item.toLowerCase().includes(search.toLowerCase())
item.toLowerCase().includes(search.toLowerCase()) }
} />
/> ),
), },
}, {
{ key: "year",
key: "year", component: (
component: ( <FilterButton
<FilterButton className='mr-1'
className='mr-1' id={libraryId}
id={libraryId} queryKey='yearFilter'
queryKey='yearFilter' queryFn={async () => {
queryFn={async () => { if (!api) return null;
if (!api) return null; const response = await getFilterApi(
const response = await getFilterApi( api,
api, ).getQueryFiltersLegacy({
).getQueryFiltersLegacy({ userId: user?.Id,
userId: user?.Id, parentId: libraryId,
parentId: libraryId, });
}); return response.data.Years || [];
return response.data.Years || []; }}
}} set={setSelectedYears}
set={setSelectedYears} values={selectedYears}
values={selectedYears} title={t("library.filters.years")}
title={t("library.filters.years")} renderItemLabel={(item) => item.toString()}
renderItemLabel={(item) => item.toString()} searchFilter={(item, search) => item.includes(search)}
searchFilter={(item, search) => item.includes(search)} />
/> ),
), },
}, {
{ key: "tags",
key: "tags", component: (
component: ( <FilterButton
<FilterButton className='mr-1'
className='mr-1' id={libraryId}
id={libraryId} queryKey='tagsFilter'
queryKey='tagsFilter' queryFn={async () => {
queryFn={async () => { if (!api) return null;
if (!api) return null; const response = await getFilterApi(
const response = await getFilterApi( api,
api, ).getQueryFiltersLegacy({
).getQueryFiltersLegacy({ userId: user?.Id,
userId: user?.Id, parentId: libraryId,
parentId: libraryId, });
}); return response.data.Tags || [];
return response.data.Tags || []; }}
}} set={setSelectedTags}
set={setSelectedTags} values={selectedTags}
values={selectedTags} title={t("library.filters.tags")}
title={t("library.filters.tags")} renderItemLabel={(item) => item.toString()}
renderItemLabel={(item) => item.toString()} searchFilter={(item, search) =>
searchFilter={(item, search) => item.toLowerCase().includes(search.toLowerCase())
item.toLowerCase().includes(search.toLowerCase()) }
} />
/> ),
), },
}, {
{ key: "sortBy",
key: "sortBy", component: (
component: ( <FilterButton
<FilterButton className='mr-1'
className='mr-1' id={libraryId}
id={libraryId} queryKey='sortBy'
queryKey='sortBy' queryFn={async () => sortOptions.map((s) => s.key)}
queryFn={async () => sortOptions.map((s) => s.key)} set={setSortBy}
set={setSortBy} values={sortBy}
values={sortBy} title={t("library.filters.sort_by")}
title={t("library.filters.sort_by")} renderItemLabel={(item) =>
renderItemLabel={(item) => sortOptions.find((i) => i.key === item)?.value || ""
sortOptions.find((i) => i.key === item)?.value || "" }
} searchFilter={(item, search) =>
searchFilter={(item, search) => item.toLowerCase().includes(search.toLowerCase())
item.toLowerCase().includes(search.toLowerCase()) }
} />
/> ),
), },
}, {
{ key: "sortOrder",
key: "sortOrder", component: (
component: ( <FilterButton
<FilterButton className='mr-1'
className='mr-1' id={libraryId}
id={libraryId} queryKey='sortOrder'
queryKey='sortOrder' queryFn={async () => sortOrderOptions.map((s) => s.key)}
queryFn={async () => sortOrderOptions.map((s) => s.key)} set={setSortOrder}
set={setSortOrder} values={sortOrder}
values={sortOrder} title={t("library.filters.sort_order")}
title={t("library.filters.sort_order")} renderItemLabel={(item) =>
renderItemLabel={(item) => sortOrderOptions.find((i) => i.key === item)?.value || ""
sortOrderOptions.find((i) => i.key === item)?.value || "" }
} searchFilter={(item, search) =>
searchFilter={(item, search) => item.toLowerCase().includes(search.toLowerCase())
item.toLowerCase().includes(search.toLowerCase()) }
} />
/> ),
), },
}, ]}
]} renderItem={({ item }) => item.component}
renderItem={({ item }) => item.component} keyExtractor={(item) => item.key}
keyExtractor={(item) => item.key} />
/>
</View>
), ),
[ [
libraryId, libraryId,
@@ -453,7 +451,6 @@ const Page = () => {
renderItem={renderItem} renderItem={renderItem}
extraData={[orientation, nrOfCols]} extraData={[orientation, nrOfCols]}
keyExtractor={keyExtractor} keyExtractor={keyExtractor}
estimatedItemSize={244}
numColumns={nrOfCols} numColumns={nrOfCols}
onEndReached={() => { onEndReached={() => {
if (hasNextPage) { if (hasNextPage) {

View File

@@ -1,85 +1,166 @@
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, View } 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)} <View className='pl-1.5'>
className='flex flex-row items-center justify-center w-9 h-9' <Ionicons
> name='ellipsis-horizontal-outline'
<Ionicons size={24}
name='ellipsis-horizontal-outline' color='white'
size={24} />
color='white' </View>
/> }
</TouchableOpacity> title={t("library.options.display")}
), groups={[
}} {
/> title: t("library.options.display"),
<Stack.Screen options: [
name='[libraryId]' {
options={{ type: "radio",
title: "", label: t("library.options.row"),
headerShown: !Platform.isTV, value: "row",
headerBlurEffect: "none", selected: settings.libraryOptions.display === "row",
headerTransparent: Platform.OS === "ios", onPress: () =>
headerShadowVisible: false, updateSettings({
}} libraryOptions: {
/> ...settings.libraryOptions,
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => ( display: "row",
<Stack.Screen key={name} name={name} options={options} /> },
))} }),
<Stack.Screen },
name='collections/[collectionId]' {
options={{ type: "radio",
title: "", label: t("library.options.list"),
headerShown: !Platform.isTV, value: "list",
headerBlurEffect: "none", selected: settings.libraryOptions.display === "list",
headerTransparent: Platform.OS === "ios", onPress: () =>
headerShadowVisible: false, updateSettings({
}} libraryOptions: {
/> ...settings.libraryOptions,
</Stack> display: "list",
<LibraryOptionsSheet },
open={optionsSheetOpen} }),
setOpen={setOptionsSheetOpen} },
settings={settings.libraryOptions} ],
updateSettings={(options) => },
updateSettings({ {
libraryOptions: { title: t("library.options.image_style"),
...settings.libraryOptions, options: [
...options, {
}, type: "radio",
}) label: t("library.options.poster"),
} value: "poster",
disabled={pluginSettings?.libraryOptions?.locked} 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

@@ -7,7 +7,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { StyleSheet, View } from "react-native"; import { Platform, StyleSheet, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
@@ -84,11 +84,11 @@ export default function index() {
extraData={settings} extraData={settings}
contentInsetAdjustmentBehavior='automatic' contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{ contentContainerStyle={{
paddingTop: 17, paddingTop: Platform.OS === "android" ? 17 : 0,
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

@@ -28,7 +28,7 @@ export default function SearchLayout() {
options={{ options={{
title: "", title: "",
headerShown: !Platform.isTV, headerShown: !Platform.isTV,
headerBlurEffect: "prominent", headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
}} }}

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";
@@ -284,67 +284,30 @@ export default function search() {
)} )}
<View <View
className='flex flex-col' className='flex flex-col'
style={{ style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
marginTop: Platform.OS === "android" ? 16 : 0,
}}
> >
{jellyseerrApi && ( {jellyseerrApi && (
<ScrollView <View className='pl-4 pr-4 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

@@ -55,6 +55,7 @@ export default function TabLayout() {
backgroundColor: "#121212", backgroundColor: "#121212",
}} }}
tabBarActiveTintColor={Colors.primary} tabBarActiveTintColor={Colors.primary}
activeIndicatorColor={"#392c3b"}
scrollEdgeAppearance='default' scrollEdgeAppearance='default'
> >
<NativeTabs.Screen redirect name='index' /> <NativeTabs.Screen redirect name='index' />
@@ -70,10 +71,7 @@ export default function TabLayout() {
tabBarIcon: tabBarIcon:
Platform.OS === "android" Platform.OS === "android"
? (_e) => require("@/assets/icons/house.fill.png") ? (_e) => require("@/assets/icons/house.fill.png")
: ({ focused }) => : (_e) => ({ sfSymbol: "house.fill" }),
focused
? { sfSymbol: "house.fill" }
: { sfSymbol: "house" },
}} }}
/> />
<NativeTabs.Screen <NativeTabs.Screen
@@ -84,14 +82,12 @@ export default function TabLayout() {
})} })}
name='(search)' name='(search)'
options={{ options={{
role: "search",
title: t("tabs.search"), title: t("tabs.search"),
tabBarIcon: tabBarIcon:
Platform.OS === "android" Platform.OS === "android"
? (_e) => require("@/assets/icons/magnifyingglass.png") ? (_e) => require("@/assets/icons/magnifyingglass.png")
: ({ focused }) => : (_e) => ({ sfSymbol: "magnifyingglass" }),
focused
? { sfSymbol: "magnifyingglass" }
: { sfSymbol: "magnifyingglass" },
}} }}
/> />
<NativeTabs.Screen <NativeTabs.Screen
@@ -100,14 +96,8 @@ export default function TabLayout() {
title: t("tabs.favorites"), title: t("tabs.favorites"),
tabBarIcon: tabBarIcon:
Platform.OS === "android" Platform.OS === "android"
? ({ focused }) => ? (_e) => require("@/assets/icons/heart.fill.png")
focused : (_e) => ({ sfSymbol: "heart.fill" }),
? require("@/assets/icons/heart.fill.png")
: require("@/assets/icons/heart.png")
: ({ focused }) =>
focused
? { sfSymbol: "heart.fill" }
: { sfSymbol: "heart" },
}} }}
/> />
<NativeTabs.Screen <NativeTabs.Screen
@@ -117,10 +107,7 @@ export default function TabLayout() {
tabBarIcon: tabBarIcon:
Platform.OS === "android" Platform.OS === "android"
? (_e) => require("@/assets/icons/server.rack.png") ? (_e) => require("@/assets/icons/server.rack.png")
: ({ focused }) => : (_e) => ({ sfSymbol: "rectangle.stack.fill" }),
focused
? { sfSymbol: "rectangle.stack.fill" }
: { sfSymbol: "rectangle.stack" },
}} }}
/> />
<NativeTabs.Screen <NativeTabs.Screen
@@ -131,10 +118,7 @@ export default function TabLayout() {
tabBarIcon: tabBarIcon:
Platform.OS === "android" Platform.OS === "android"
? (_e) => require("@/assets/icons/list.png") ? (_e) => require("@/assets/icons/list.png")
: ({ focused }) => : (_e) => ({ sfSymbol: "list.dash.fill" }),
focused
? { sfSymbol: "list.dash.fill" }
: { sfSymbol: "list.dash" },
}} }}
/> />
</NativeTabs> </NativeTabs>

View File

@@ -29,6 +29,7 @@ import {
VLCColor, VLCColor,
} from "@/constants/SubtitleConstants"; } from "@/constants/SubtitleConstants";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
import { useOrientation } from "@/hooks/useOrientation";
import { usePlaybackManager } from "@/hooks/usePlaybackManager"; import { usePlaybackManager } from "@/hooks/usePlaybackManager";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useWebSocket } from "@/hooks/useWebsockets"; import { useWebSocket } from "@/hooks/useWebsockets";
@@ -76,7 +77,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();
@@ -106,6 +110,7 @@ export default function page() {
playbackPosition?: string; playbackPosition?: string;
}>(); }>();
const { settings } = useSettings(); const { settings } = useSettings();
const { lockOrientation, unlockOrientation } = useOrientation();
const offline = offlineStr === "true"; const offline = offlineStr === "true";
const playbackManager = usePlaybackManager(); const playbackManager = usePlaybackManager();
@@ -168,6 +173,16 @@ export default function page() {
} }
}, [itemId, offline, api, user?.Id]); }, [itemId, offline, api, user?.Id]);
useEffect(() => {
if (settings?.defaultVideoOrientation) {
lockOrientation(settings.defaultVideoOrientation);
}
return () => {
unlockOrientation();
};
}, [settings?.defaultVideoOrientation]);
interface Stream { interface Stream {
mediaSource: MediaSourceInfo; mediaSource: MediaSourceInfo;
sessionId: string; sessionId: string;
@@ -283,12 +298,14 @@ export default function page() {
}; };
const reportPlaybackStopped = useCallback(async () => { const reportPlaybackStopped = useCallback(async () => {
if (!item?.Id || !stream?.sessionId) return;
const currentTimeInTicks = msToTicks(progress.get()); const currentTimeInTicks = msToTicks(progress.get());
await getPlaystateApi(api!).onPlaybackStopped({ await getPlaystateApi(api!).onPlaybackStopped({
itemId: item?.Id!, itemId: item.Id,
mediaSourceId: mediaSourceId, mediaSourceId: mediaSourceId,
positionTicks: currentTimeInTicks, positionTicks: currentTimeInTicks,
playSessionId: stream?.sessionId!, playSessionId: stream.sessionId,
}); });
}, [ }, [
api, api,
@@ -319,9 +336,9 @@ export default function page() {
}, [navigation, stop]); }, [navigation, stop]);
const currentPlayStateInfo = useCallback(() => { const currentPlayStateInfo = useCallback(() => {
if (!stream) return; if (!stream || !item?.Id) return;
return { return {
itemId: item?.Id!, itemId: item.Id,
audioStreamIndex: audioIndex ? audioIndex : undefined, audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined, subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId, mediaSourceId: mediaSourceId,
@@ -765,6 +782,7 @@ export default function page() {
setAspectRatio={setAspectRatio} setAspectRatio={setAspectRatio}
setScaleFactor={setScaleFactor} setScaleFactor={setScaleFactor}
isVlc isVlc
api={api}
downloadedFiles={downloadedFiles} downloadedFiles={downloadedFiles}
/> />
)} )}

View File

@@ -1,18 +1,23 @@
import "@/augmentations"; import "@/augmentations";
import { ActionSheetProvider } from "@expo/react-native-action-sheet"; import { ActionSheetProvider } from "@expo/react-native-action-sheet";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import * as BackgroundTask from "expo-background-task";
import * as Device from "expo-device";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { GlobalModal } from "@/components/GlobalModal";
import i18n from "@/i18n"; import i18n from "@/i18n";
import { DownloadProvider } from "@/providers/DownloadProvider"; import { DownloadProvider } from "@/providers/DownloadProvider";
import { GlobalModalProvider } from "@/providers/GlobalModalProvider";
import { import {
apiAtom, apiAtom,
getOrSetDeviceId, getOrSetDeviceId,
getTokenFromStorage,
JellyfinProvider, JellyfinProvider,
} from "@/providers/JellyfinProvider"; } from "@/providers/JellyfinProvider";
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider"; import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
import { WebSocketProvider } from "@/providers/WebSocketProvider"; import { WebSocketProvider } from "@/providers/WebSocketProvider";
import { type Settings, useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { import {
BACKGROUND_FETCH_TASK, BACKGROUND_FETCH_TASK,
BACKGROUND_FETCH_TASK_SESSIONS, BACKGROUND_FETCH_TASK_SESSIONS,
@@ -26,44 +31,30 @@ import {
} from "@/utils/log"; } from "@/utils/log";
import { storage } from "@/utils/mmkv"; import { storage } from "@/utils/mmkv";
const BackGroundDownloader = !Platform.isTV
? require("@kesha-antonov/react-native-background-downloader")
: null;
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import * as BackgroundTask from "expo-background-task";
import * as Device from "expo-device";
import * as FileSystem from "expo-file-system";
const Notifications = !Platform.isTV ? require("expo-notifications") : null; const Notifications = !Platform.isTV ? require("expo-notifications") : null;
import { getLocales } from "expo-localization";
import { router, Stack, useSegments } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import * as TaskManager from "expo-task-manager";
import { Provider as JotaiProvider } from "jotai";
import { useEffect, useRef, useState } from "react";
import { I18nextProvider } from "react-i18next";
import { Appearance, AppState } from "react-native";
import { SystemBars } from "react-native-edge-to-edge";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import "react-native-reanimated";
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api"; import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
import { getLocales } from "expo-localization";
import type { EventSubscription } from "expo-modules-core"; import type { EventSubscription } from "expo-modules-core";
import { getDevicePushTokenAsync } from "expo-notifications";
import type { import type {
Notification, Notification,
NotificationResponse, NotificationResponse,
} from "expo-notifications/build/Notifications.types"; } from "expo-notifications/build/Notifications.types";
import type { ExpoPushToken } from "expo-notifications/build/Tokens.types"; import type { DevicePushToken } from "expo-notifications/build/Tokens.types";
import { useAtom } from "jotai"; import { router, Stack, useSegments } from "expo-router";
import { Toaster } from "sonner-native"; import * as SplashScreen from "expo-splash-screen";
import * as TaskManager from "expo-task-manager";
import { Provider as JotaiProvider, useAtom } from "jotai";
import { useCallback, useEffect, useRef, useState } from "react";
import { I18nextProvider } from "react-i18next";
import { Appearance } from "react-native";
import { SystemBars } from "react-native-edge-to-edge";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { userAtom } from "@/providers/JellyfinProvider"; import { userAtom } from "@/providers/JellyfinProvider";
import { store } from "@/utils/store"; import { store } from "@/utils/store";
import "react-native-reanimated";
import { Toaster } from "sonner-native";
if (!Platform.isTV) { if (!Platform.isTV) {
Notifications.setNotificationHandler({ Notifications.setNotificationHandler({
@@ -131,24 +122,7 @@ if (!Platform.isTV) {
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => { TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
console.log("TaskManager ~ trigger"); console.log("TaskManager ~ trigger");
// Background fetch task placeholder - currently unused
const settingsData = storage.getString("settings");
if (!settingsData) return BackgroundTask.BackgroundTaskResult.Failed;
const settings: Partial<Settings> = JSON.parse(settingsData);
if (!settings?.autoDownload)
return BackgroundTask.BackgroundTaskResult.Failed;
const token = getTokenFromStorage();
const deviceId = getOrSetDeviceId();
const baseDirectory = FileSystem.documentDirectory;
if (!token || !deviceId || !baseDirectory)
return BackgroundTask.BackgroundTaskResult.Failed;
// Be sure to return the successful result type!
return BackgroundTask.BackgroundTaskResult.Success; return BackgroundTask.BackgroundTaskResult.Success;
}); });
} }
@@ -213,11 +187,7 @@ export default function RootLayout() {
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
staleTime: 0, staleTime: 30000,
refetchOnMount: true,
refetchOnReconnect: true,
refetchOnWindowFocus: true,
retryOnMount: true,
}, },
}, },
}); });
@@ -226,8 +196,7 @@ function Layout() {
const { settings } = useSettings(); const { settings } = useSettings();
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const appState = useRef(AppState.currentState); const _segments = useSegments();
const segments = useSegments();
useEffect(() => { useEffect(() => {
i18n.changeLanguage( i18n.changeLanguage(
@@ -237,26 +206,26 @@ function Layout() {
useNotificationObserver(); useNotificationObserver();
const [expoPushToken, setExpoPushToken] = useState<ExpoPushToken>(); const [pushToken, setPushToken] = useState<DevicePushToken>();
const notificationListener = useRef<EventSubscription>(null); const notificationListener = useRef<EventSubscription>(null);
const responseListener = useRef<EventSubscription>(null); const responseListener = useRef<EventSubscription>(null);
useEffect(() => { useEffect(() => {
if (!Platform.isTV && expoPushToken && api && user) { if (!Platform.isTV && pushToken && api && user) {
api api
?.post("/Streamyfin/device", { ?.post("/Streamyfin/device", {
token: expoPushToken.data, token: pushToken.data,
deviceId: getOrSetDeviceId(), deviceId: getOrSetDeviceId(),
userId: user.Id, userId: user.Id,
}) })
.then((_) => console.log("Posted expo push token")) .then((_) => console.log("Posted device push token"))
.catch((_) => .catch((_) =>
writeErrorLog("Failed to push expo push token to plugin"), writeErrorLog("Failed to push device push token to plugin"),
); );
} else console.log("No token available"); } else console.log("No token available");
}, [api, expoPushToken, user]); }, [api, pushToken, user]);
async function registerNotifications() { const registerNotifications = useCallback(async () => {
if (Platform.OS === "android") { if (Platform.OS === "android") {
console.log("Setting android notification channel 'default'"); console.log("Setting android notification channel 'default'");
await Notifications?.setNotificationChannelAsync("default", { await Notifications?.setNotificationChannelAsync("default", {
@@ -287,13 +256,11 @@ function Layout() {
// only create push token for real devices (pointless for emulators) // only create push token for real devices (pointless for emulators)
if (Device.isDevice) { if (Device.isDevice) {
Notifications?.getExpoPushTokenAsync({ getDevicePushTokenAsync()
projectId: "streamyfin-4fec1", .then((token: DevicePushToken) => token && setPushToken(token))
})
.then((token: ExpoPushToken) => token && setExpoPushToken(token))
.catch((reason: any) => console.log("Failed to get token", reason)); .catch((reason: any) => console.log("Failed to get token", reason));
} }
} }, [user]);
useEffect(() => { useEffect(() => {
if (!Platform.isTV) { if (!Platform.isTV) {
@@ -357,61 +324,7 @@ function Layout() {
responseListener.current?.remove(); responseListener.current?.remove();
}; };
} }
}, [user, api]); }, [user]);
useEffect(() => {
if (Platform.isTV) {
return;
}
if (segments.includes("direct-player" as never)) {
if (
!settings.followDeviceOrientation &&
settings.defaultVideoOrientation
) {
ScreenOrientation.lockAsync(settings.defaultVideoOrientation);
}
return;
}
if (settings.followDeviceOrientation === true) {
ScreenOrientation.unlockAsync();
} else {
ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP,
);
}
}, [
settings.followDeviceOrientation,
settings.defaultVideoOrientation,
segments,
]);
useEffect(() => {
if (Platform.isTV) {
return;
}
const subscription = AppState.addEventListener("change", (nextAppState) => {
if (
appState.current.match(/inactive|background/) &&
nextAppState === "active"
) {
BackGroundDownloader.checkForExistingDownloads().catch(
(error: unknown) => {
writeErrorLog("Failed to resume background downloads", error);
},
);
}
});
BackGroundDownloader.checkForExistingDownloads().catch((error: unknown) => {
writeErrorLog("Failed to resume background downloads", error);
});
return () => {
subscription.remove();
};
}, []);
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
@@ -420,52 +333,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: Platform.OS === "ios",
}}
/>
<Stack.Screen name='+not-found' />
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}} }}
closeButton
/> />
<Stack.Screen <GlobalModal />
name='(auth)/player' </ThemeProvider>
options={{ </BottomSheetModalProvider>
headerShown: false, </GlobalModalProvider>
title: "",
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,17 +4,16 @@ import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router"; import { useLocalSearchParams, useNavigation } from "expo-router";
import { t } from "i18next"; import { t } from "i18next";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import type React from "react";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { import {
Alert, Alert,
Keyboard, Keyboard,
KeyboardAvoidingView, KeyboardAvoidingView,
Platform, Platform,
SafeAreaView,
TouchableOpacity, TouchableOpacity,
View, View,
} from "react-native"; } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { z } from "zod"; import { z } from "zod";
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
import { Input } from "@/components/common/Input"; import { Input } from "@/components/common/Input";
@@ -63,12 +62,13 @@ const Login: React.FC = () => {
address: _apiUrl, address: _apiUrl,
}); });
// Wait for server setup and state updates to complete
setTimeout(() => { setTimeout(() => {
if (_username && _password) { if (_username && _password) {
setCredentials({ username: _username, password: _password }); setCredentials({ username: _username, password: _password });
login(_username, _password); login(_username, _password);
} }
}, 300); }, 0);
} }
})(); })();
}, [_apiUrl, _username, _password]); }, [_apiUrl, _username, _password]);
@@ -82,10 +82,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>
@@ -371,10 +371,11 @@ const Login: React.FC = () => {
// Mobile layout // Mobile layout
<SafeAreaView style={{ flex: 1, paddingBottom: 16 }}> <SafeAreaView style={{ flex: 1, paddingBottom: 16 }}>
<KeyboardAvoidingView <KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"} behavior={Platform.OS === "ios" ? "padding" : undefined}
style={{ flex: 1 }}
> >
{api?.basePath ? ( {api?.basePath ? (
<View className='flex flex-col h-full relative items-center justify-center'> <View className='flex flex-col flex-1 items-center justify-center'>
<View className='px-4 -mt-20 w-full'> <View className='px-4 -mt-20 w-full'>
<View className='flex flex-col space-y-2'> <View className='flex flex-col space-y-2'>
<Text className='text-2xl font-bold -mb-2'> <Text className='text-2xl font-bold -mb-2'>
@@ -443,7 +444,7 @@ const Login: React.FC = () => {
<View className='absolute bottom-0 left-0 w-full px-4 mb-2' /> <View className='absolute bottom-0 left-0 w-full px-4 mb-2' />
</View> </View>
) : ( ) : (
<View className='flex flex-col h-full items-center justify-center w-full'> <View className='flex flex-col flex-1 items-center justify-center w-full'>
<View className='flex flex-col gap-y-2 px-4 w-full -mt-36'> <View className='flex flex-col gap-y-2 px-4 w-full -mt-36'>
<Image <Image
style={{ style={{

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"],
}; };
}; };

727
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import type { FC } from "react"; import type { FC } from "react";
import { Platform, View, type ViewProps } from "react-native"; import { View, type ViewProps } from "react-native";
import { RoundButton } from "@/components/RoundButton"; import { RoundButton } from "@/components/RoundButton";
import { useFavorite } from "@/hooks/useFavorite"; import { useFavorite } from "@/hooks/useFavorite";
@@ -11,24 +11,11 @@ interface Props extends ViewProps {
export const AddToFavorites: FC<Props> = ({ item, ...props }) => { export const AddToFavorites: FC<Props> = ({ item, ...props }) => {
const { isFavorite, toggleFavorite } = useFavorite(item); const { isFavorite, toggleFavorite } = useFavorite(item);
if (Platform.OS === "ios") {
return (
<View {...props}>
<RoundButton
size='large'
icon={isFavorite ? "heart" : "heart-outline"}
onPress={toggleFavorite}
/>
</View>
);
}
return ( return (
<View {...props}> <View {...props}>
<RoundButton <RoundButton
size='large' size='large'
icon={isFavorite ? "heart" : "heart-outline"} icon={isFavorite ? "heart" : "heart-outline"}
fillColor={isFavorite ? "primary" : undefined}
onPress={toggleFavorite} onPress={toggleFavorite}
/> />
</View> </View>

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

@@ -64,9 +64,8 @@ export const DownloadItems: React.FC<DownloadProps> = ({
const { settings } = useSettings(); const { settings } = useSettings();
const [downloadUnwatchedOnly, setDownloadUnwatchedOnly] = useState(false); const [downloadUnwatchedOnly, setDownloadUnwatchedOnly] = useState(false);
const { processes, startBackgroundDownload, getDownloadedItems } = const { processes, startBackgroundDownload, downloadedItems } = useDownload();
useDownload(); const downloadedFiles = downloadedItems;
const downloadedFiles = getDownloadedItems();
const [selectedOptions, setSelectedOptions] = useState< const [selectedOptions, setSelectedOptions] = useState<
SelectedOptions | undefined SelectedOptions | undefined
@@ -90,11 +89,8 @@ export const DownloadItems: React.FC<DownloadProps> = ({
bottomSheetModalRef.current?.present(); bottomSheetModalRef.current?.present();
}, []); }, []);
const handleSheetChanges = useCallback((index: number) => { const handleSheetChanges = useCallback((_index: number) => {
// Ensure modal is fully dismissed when index is -1 // Modal state tracking handled by BottomSheetModal
if (index === -1) {
// Modal is fully closed
}
}, []); }, []);
const closeModal = useCallback(() => { const closeModal = useCallback(() => {
@@ -157,6 +153,13 @@ export const DownloadItems: React.FC<DownloadProps> = ({
itemsNotDownloaded.every((p) => queue.some((q) => p.Id === q.item.Id)) itemsNotDownloaded.every((p) => queue.some((q) => p.Id === q.item.Id))
); );
}, [queue, itemsNotDownloaded]); }, [queue, itemsNotDownloaded]);
const itemsInProgressOrQueued = useMemo(() => {
const inProgress = itemsProcesses.length;
const inQueue = queue.filter((q) => itemIds.includes(q.item.Id)).length;
return inProgress + inQueue;
}, [itemsProcesses, queue, itemIds]);
const navigateToDownloads = () => router.push("/downloads"); const navigateToDownloads = () => router.push("/downloads");
const onDownloadedPress = () => { const onDownloadedPress = () => {
@@ -256,13 +259,12 @@ export const DownloadItems: React.FC<DownloadProps> = ({
throw new Error("No item id"); throw new Error("No item id");
} }
// Ensure modal is dismissed before starting download closeModal();
await closeModal();
// Small delay to ensure modal is fully dismissed // Wait for modal dismiss animation to complete
setTimeout(() => { requestAnimationFrame(() => {
initiateDownload(...itemsToDownload); initiateDownload(...itemsToDownload);
}, 100); });
} else { } else {
toast.error( toast.error(
t("home.downloads.toasts.you_are_not_allowed_to_download_files"), t("home.downloads.toasts.you_are_not_allowed_to_download_files"),
@@ -282,7 +284,14 @@ export const DownloadItems: React.FC<DownloadProps> = ({
); );
const renderButtonContent = () => { const renderButtonContent = () => {
if (processes.length > 0 && itemsProcesses.length > 0) { // For single item downloads, show progress if item is being processed
// For multi-item downloads (season/series), show progress only if 2+ items are in progress or queued
const shouldShowProgress =
itemIds.length === 1
? itemsProcesses.length > 0
: itemsInProgressOrQueued > 1;
if (processes.length > 0 && shouldShowProgress) {
return progress === 0 ? ( return progress === 0 ? (
<Loader /> <Loader />
) : ( ) : (
@@ -336,9 +345,6 @@ export const DownloadItems: React.FC<DownloadProps> = ({
backgroundColor: "#171717", backgroundColor: "#171717",
}} }}
onChange={handleSheetChanges} onChange={handleSheetChanges}
onDismiss={() => {
// Ensure any pending state is cleared when modal is dismissed
}}
backdropComponent={renderBackdrop} backdropComponent={renderBackdrop}
enablePanDownToClose enablePanDownToClose
enableDismissOnClose enableDismissOnClose
@@ -359,16 +365,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 +388,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 +437,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,73 @@
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
stackBehavior='push'
style={{ zIndex: 1000 }}
>
{modalState.content}
</BottomSheetModal>
);
};

View File

@@ -204,7 +204,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
<View className='flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink'> <View className='flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink'>
<ItemHeader item={item} className='mb-2' /> <ItemHeader item={item} className='mb-2' />
{item.Type !== "Program" && !Platform.isTV && !isOffline && ( {item.Type !== "Program" && !Platform.isTV && !isOffline && (
<View className='flex flex-row items-center justify-start w-full h-16'> <View className='flex flex-row items-center justify-start w-full h-16 mb-2'>
<BitrateSheet <BitrateSheet
className='mr-1' className='mr-1'
onChange={(val) => onChange={(val) =>

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

@@ -99,7 +99,7 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
style={{ style={{
top: -50, top: -50,
}} }}
className='relative flex-1 bg-transparent pb-24' className='relative flex-1 bg-transparent pb-4'
> >
<LinearGradient <LinearGradient
// Background Linear Gradient // Background Linear Gradient

View File

@@ -0,0 +1,337 @@
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, useState } from "react";
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { useGlobalModal } from "@/providers/GlobalModalProvider";
// Option types
export type RadioOption<T = any> = {
type: "radio";
label: string;
value: T;
selected: boolean;
onPress: () => void;
disabled?: boolean;
};
export type ToggleOption = {
type: "toggle";
label: string;
value: boolean;
onToggle: () => void;
disabled?: boolean;
};
export type Option = RadioOption | ToggleOption;
// Option group structure
export type OptionGroup = {
title?: string;
options: Option[];
};
interface PlatformDropdownProps {
trigger?: React.ReactNode;
title?: string;
groups: OptionGroup[];
open?: boolean;
onOpenChange?: (open: boolean) => void;
onOptionSelect?: (value?: any) => void;
expoUIConfig?: {
hostStyle?: any;
};
bottomSheetConfig?: {
enableDynamicSizing?: boolean;
enablePanDownToClose?: boolean;
};
}
const ToggleSwitch: React.FC<{ value: boolean }> = ({ value }) => (
<View
className={`w-12 h-7 rounded-full ${value ? "bg-purple-600" : "bg-neutral-600"} flex-row items-center`}
>
<View
className={`w-5 h-5 rounded-full bg-white shadow-md transform transition-transform ${
value ? "translate-x-6" : "translate-x-1"
}`}
/>
</View>
);
const OptionItem: React.FC<{ option: Option; isLast?: boolean }> = ({
option,
isLast,
}) => {
const isToggle = option.type === "toggle";
const handlePress = isToggle ? option.onToggle : option.onPress;
return (
<>
<TouchableOpacity
onPress={handlePress}
disabled={option.disabled}
className={`px-4 py-3 flex flex-row items-center justify-between ${
option.disabled ? "opacity-50" : ""
}`}
>
<Text className='flex-1 text-white'>{option.label}</Text>
{isToggle ? (
<ToggleSwitch value={option.value} />
) : option.selected ? (
<Ionicons name='checkmark-circle' size={24} color='#9333ea' />
) : (
<Ionicons name='ellipse-outline' size={24} color='#6b7280' />
)}
</TouchableOpacity>
{!isLast && (
<View
style={{
height: StyleSheet.hairlineWidth,
}}
className='bg-neutral-700 mx-4'
/>
)}
</>
);
};
const OptionGroupComponent: React.FC<{ group: OptionGroup }> = ({ group }) => (
<View className='mb-6'>
{group.title && (
<Text className='text-lg font-semibold mb-3 text-neutral-300'>
{group.title}
</Text>
)}
<View
style={{
borderRadius: 12,
overflow: "hidden",
}}
className='bg-neutral-800 rounded-xl overflow-hidden'
>
{group.options.map((option, index) => (
<OptionItem
key={index}
option={option}
isLast={index === group.options.length - 1}
/>
))}
</View>
</View>
);
const BottomSheetContent: React.FC<{
title?: string;
groups: OptionGroup[];
onOptionSelect?: (value?: any) => void;
onClose?: () => void;
}> = ({ title, groups, onOptionSelect, onClose }) => {
const insets = useSafeAreaInsets();
// Wrap the groups to call onOptionSelect when an option is pressed
const wrappedGroups = groups.map((group) => ({
...group,
options: group.options.map((option) => {
if (option.type === "radio") {
return {
...option,
onPress: () => {
option.onPress();
onOptionSelect?.(option.value);
onClose?.();
},
};
}
if (option.type === "toggle") {
return {
...option,
onToggle: () => {
option.onToggle();
onOptionSelect?.(option.value);
},
};
}
return option;
}),
}));
return (
<BottomSheetScrollView
className='px-4 pb-8 pt-2'
style={{
paddingLeft: Math.max(16, insets.left),
paddingRight: Math.max(16, insets.right),
}}
>
{title && <Text className='font-bold text-2xl mb-6'>{title}</Text>}
{wrappedGroups.map((group, index) => (
<OptionGroupComponent key={index} group={group} />
))}
</BottomSheetScrollView>
);
};
const PlatformDropdownComponent = ({
trigger,
title,
groups,
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
onOptionSelect,
expoUIConfig,
bottomSheetConfig,
}: PlatformDropdownProps) => {
const { showModal, hideModal } = useGlobalModal();
// Use internal state if not controlled externally
const [internalOpen, setInternalOpen] = useState(false);
const open = controlledOpen ?? internalOpen;
const onOpenChange = controlledOnOpenChange ?? setInternalOpen;
// Handle open/close state changes for Android
useEffect(() => {
if (Platform.OS === "android" && open === true) {
showModal(
<BottomSheetContent
title={title}
groups={groups}
onOptionSelect={onOptionSelect}
onClose={() => {
hideModal();
onOpenChange?.(false);
}}
/>,
{
snapPoints: ["90%"],
enablePanDownToClose: bottomSheetConfig?.enablePanDownToClose ?? true,
},
);
}
}, [
open,
title,
groups,
onOptionSelect,
onOpenChange,
bottomSheetConfig,
showModal,
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: Wrap trigger in TouchableOpacity to handle press events
// The useEffect above watches for open state changes and shows/hides the modal
return (
<TouchableOpacity onPress={() => onOpenChange(true)} activeOpacity={0.7}>
{trigger || <Text className='text-white'>Open Menu</Text>}
</TouchableOpacity>
);
};
// Memoize to prevent unnecessary re-renders when parent re-renders
export const PlatformDropdown = React.memo(
PlatformDropdownComponent,
(prevProps, nextProps) => {
// Custom comparison - only re-render if these props actually change
return (
prevProps.title === nextProps.title &&
prevProps.open === nextProps.open &&
prevProps.groups === nextProps.groups && // Reference equality (works because we memoize groups in caller)
prevProps.trigger === nextProps.trigger // Reference equality
);
},
);

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;
@@ -165,7 +165,7 @@ export const PlayButton: React.FC<Props> = ({
api, api,
item, item,
deviceProfile: enableH265 ? chromecasth265 : chromecast, deviceProfile: enableH265 ? chromecasth265 : chromecast,
startTimeTicks: item?.UserData?.PlaybackPositionTicks!, startTimeTicks: item?.UserData?.PlaybackPositionTicks ?? 0,
userId: user.Id, userId: user.Id,
audioStreamIndex: selectedOptions.audioIndex, audioStreamIndex: selectedOptions.audioIndex,
maxStreamingBitrate: selectedOptions.bitrate?.value, maxStreamingBitrate: selectedOptions.bitrate?.value,
@@ -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}
@@ -371,7 +411,6 @@ export const PlayButton: React.FC<Props> = ({
accessibilityHint='Tap to play the media' accessibilityHint='Tap to play the media'
onPress={onPress} onPress={onPress}
className={"relative"} className={"relative"}
{...props}
> >
<View className='absolute w-full h-full top-0 left-0 rounded-full z-10 overflow-hidden'> <View className='absolute w-full h-full top-0 left-0 rounded-full z-10 overflow-hidden'>
<Animated.View <Animated.View

View File

@@ -1,6 +1,6 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import type React from "react"; import type React from "react";
import { Platform, View, type ViewProps } from "react-native"; import { View, type ViewProps } from "react-native";
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed"; import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
import { RoundButton } from "./RoundButton"; import { RoundButton } from "./RoundButton";
@@ -14,25 +14,10 @@ export const PlayedStatus: React.FC<Props> = ({ items, ...props }) => {
const allPlayed = items.every((item) => item.UserData?.Played); const allPlayed = items.every((item) => item.UserData?.Played);
const toggle = useMarkAsPlayed(items); const toggle = useMarkAsPlayed(items);
if (Platform.OS === "ios") {
return (
<View {...props}>
<RoundButton
color={allPlayed ? "purple" : "white"}
icon={allPlayed ? "checkmark" : "checkmark"}
onPress={async () => {
await toggle(!allPlayed);
}}
size={props.size}
/>
</View>
);
}
return ( return (
<View {...props}> <View {...props}>
<RoundButton <RoundButton
fillColor={allPlayed ? "primary" : undefined} color={allPlayed ? "purple" : "white"}
icon={allPlayed ? "checkmark" : "checkmark"} icon={allPlayed ? "checkmark" : "checkmark"}
onPress={async () => { onPress={async () => {
await toggle(!allPlayed); await toggle(!allPlayed);

View File

@@ -96,7 +96,7 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
<TouchableOpacity <TouchableOpacity
onPress={handlePress} onPress={handlePress}
className={`rounded-full ${buttonSize} flex items-center justify-center ${ className={`rounded-full ${buttonSize} flex items-center justify-center ${
fillColor ? fillColorClass : "bg-neutral-800/80" fillColor ? fillColorClass : "bg-transparent"
}`} }`}
{...(viewProps as any)} {...(viewProps as any)}
> >

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

@@ -28,15 +28,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;
@@ -50,10 +51,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;
@@ -63,13 +65,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
@@ -88,13 +92,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;
@@ -180,7 +187,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 || [];
@@ -195,7 +202,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,
@@ -214,7 +221,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"],
}); });
@@ -374,6 +381,8 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
}; };
}); });
const togglePlayedStatus = useMarkAsPlayed(items);
const headerAnimatedStyle = useAnimatedStyle(() => { const headerAnimatedStyle = useAnimatedStyle(() => {
if (!scrollOffset) return {}; if (!scrollOffset) return {};
return { return {
@@ -521,6 +530,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={{
@@ -747,6 +786,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={{
@@ -777,7 +849,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

@@ -55,7 +55,7 @@ export const HeaderBackButton: React.FC<Props> = ({
return ( return (
<TouchableOpacity <TouchableOpacity
onPress={() => router.back()} onPress={() => router.back()}
className=' bg-neutral-800/80 rounded-full p-2' className=' rounded-full p-2'
{...touchableOpacityProps} {...touchableOpacityProps}
> >
<Ionicons <Ionicons

View File

@@ -3,17 +3,12 @@ import React, { useImperativeHandle, useRef } from "react";
import { View, type ViewStyle } from "react-native"; import { View, type ViewStyle } from "react-native";
import { Text } from "./Text"; import { Text } from "./Text";
type PartialExcept<T, K extends keyof T> = Partial<T> & Pick<T, K>;
export interface HorizontalScrollRef { export interface HorizontalScrollRef {
scrollToIndex: (index: number, viewOffset: number) => void; scrollToIndex: (index: number, viewOffset: number) => void;
} }
interface HorizontalScrollProps<T> interface HorizontalScrollProps<T>
extends PartialExcept< extends Omit<FlashListProps<T>, "renderItem" | "estimatedItemSize" | "data"> {
Omit<FlashListProps<T>, "renderItem">,
"estimatedItemSize"
> {
data?: T[] | null; data?: T[] | null;
renderItem: (item: T, index: number) => React.ReactNode; renderItem: (item: T, index: number) => React.ReactNode;
keyExtractor?: (item: T, index: number) => string; keyExtractor?: (item: T, index: number) => string;
@@ -44,7 +39,7 @@ export const HorizontalScroll = <T,>(
...restProps ...restProps
} = props; } = props;
const flashListRef = useRef<FlashList<T>>(null); const flashListRef = useRef<React.ComponentRef<typeof FlashList<T>>>(null);
useImperativeHandle(ref!, () => ({ useImperativeHandle(ref!, () => ({
scrollToIndex: (index: number, viewOffset: number) => { scrollToIndex: (index: number, viewOffset: number) => {
@@ -78,7 +73,6 @@ export const HorizontalScroll = <T,>(
extraData={extraData} extraData={extraData}
renderItem={renderFlashListItem} renderItem={renderFlashListItem}
horizontal horizontal
estimatedItemSize={200}
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
contentContainerStyle={{ contentContainerStyle={{
paddingHorizontal: 16, paddingHorizontal: 16,

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

@@ -26,7 +26,7 @@ export default function ActiveDownloads({ ...props }: ActiveDownloadsProps) {
<Text className='text-lg font-bold mb-2'> <Text className='text-lg font-bold mb-2'>
{t("home.downloads.active_downloads")} {t("home.downloads.active_downloads")}
</Text> </Text>
<View className='space-y-2'> <View className='gap-y-2'>
{processes?.map((p: JobStatus) => ( {processes?.map((p: JobStatus) => (
<DownloadCard key={p.item.Id} process={p} /> <DownloadCard key={p.item.Id} process={p} />
))} ))}

View File

@@ -6,7 +6,6 @@ import { t } from "i18next";
import { useMemo } from "react"; import { useMemo } from "react";
import { import {
ActivityIndicator, ActivityIndicator,
Platform,
TouchableOpacity, TouchableOpacity,
type TouchableOpacityProps, type TouchableOpacityProps,
View, View,
@@ -14,49 +13,36 @@ import {
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { calculateSmoothedETA } from "@/providers/Downloads/hooks/useDownloadSpeedCalculator";
import { JobStatus } from "@/providers/Downloads/types"; import { JobStatus } from "@/providers/Downloads/types";
import { estimateDownloadSize } from "@/utils/download";
import { storage } from "@/utils/mmkv"; import { storage } from "@/utils/mmkv";
import { formatTimeString } from "@/utils/time"; import { formatTimeString } from "@/utils/time";
import { Button } from "../Button";
const bytesToMB = (bytes: number) => { const bytesToMB = (bytes: number) => {
return bytes / 1024 / 1024; return bytes / 1024 / 1024;
}; };
const formatBytes = (bytes: number): string => {
if (bytes >= 1024 * 1024 * 1024) {
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
return `${(bytes / (1024 * 1024)).toFixed(0)} MB`;
};
interface DownloadCardProps extends TouchableOpacityProps { interface DownloadCardProps extends TouchableOpacityProps {
process: JobStatus; process: JobStatus;
} }
export const DownloadCard = ({ process, ...props }: DownloadCardProps) => { export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
const { startDownload, pauseDownload, resumeDownload, removeProcess } = const { cancelDownload } = useDownload();
useDownload();
const router = useRouter(); const router = useRouter();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const handlePause = async (id: string) => {
try {
await pauseDownload(id);
toast.success(t("home.downloads.toasts.download_paused"));
} catch (error) {
console.error("Error pausing download:", error);
toast.error(t("home.downloads.toasts.could_not_pause_download"));
}
};
const handleResume = async (id: string) => {
try {
await resumeDownload(id);
toast.success(t("home.downloads.toasts.download_resumed"));
} catch (error) {
console.error("Error resuming download:", error);
toast.error(t("home.downloads.toasts.could_not_resume_download"));
}
};
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
try { try {
await removeProcess(id); await cancelDownload(id);
toast.success(t("home.downloads.toasts.download_deleted")); // cancelDownload already shows a toast, so don't show another one
queryClient.invalidateQueries({ queryKey: ["downloads"] }); queryClient.invalidateQueries({ queryKey: ["downloads"] });
} catch (error) { } catch (error) {
console.error("Error deleting download:", error); console.error("Error deleting download:", error);
@@ -64,16 +50,48 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
} }
}; };
const eta = (p: JobStatus) => { const eta = useMemo(() => {
if (!p.speed || p.speed <= 0 || !p.estimatedTotalSizeBytes) return null; if (!process.estimatedTotalSizeBytes || !process.bytesDownloaded) {
return null;
}
const bytesRemaining = p.estimatedTotalSizeBytes - (p.bytesDownloaded || 0); const secondsRemaining = calculateSmoothedETA(
if (bytesRemaining <= 0) return null; process.id,
process.bytesDownloaded,
process.estimatedTotalSizeBytes,
);
const secondsRemaining = bytesRemaining / p.speed; if (!secondsRemaining || secondsRemaining <= 0) {
return null;
}
return formatTimeString(secondsRemaining, "s"); return formatTimeString(secondsRemaining, "s");
}; }, [process.id, process.bytesDownloaded, process.estimatedTotalSizeBytes]);
const estimatedSize = useMemo(() => {
if (process.estimatedTotalSizeBytes) return process.estimatedTotalSizeBytes;
// Calculate from bitrate + duration (only if bitrate value is defined)
if (process.maxBitrate.value) {
return estimateDownloadSize(
process.maxBitrate.value,
process.item.RunTimeTicks,
);
}
return undefined;
}, [
process.maxBitrate.value,
process.item.RunTimeTicks,
process.estimatedTotalSizeBytes,
]);
const isTranscoding = process.isTranscoding || false;
const downloadedAmount = useMemo(() => {
if (!process.bytesDownloaded) return null;
return formatBytes(process.bytesDownloaded);
}, [process.bytesDownloaded]);
const base64Image = useMemo(() => { const base64Image = useMemo(() => {
return storage.getString(process.item.Id!); return storage.getString(process.item.Id!);
@@ -98,9 +116,7 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
> >
{process.status === "downloading" && ( {process.status === "downloading" && (
<View <View
className={` className={`bg-purple-600 h-1 absolute bottom-0 left-0 ${isTranscoding ? "animate-pulse" : ""}`}
bg-purple-600 h-1 absolute bottom-0 left-0
`}
style={{ style={{
width: width:
sanitizedProgress > 0 sanitizedProgress > 0
@@ -111,26 +127,10 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
)} )}
{/* Action buttons in bottom right corner */} {/* Action buttons in bottom right corner */}
<View className='absolute bottom-2 right-2 flex flex-row items-center space-x-2 z-10'> <View className='absolute bottom-2 right-2 flex flex-row items-center z-10'>
{process.status === "downloading" && Platform.OS !== "ios" && (
<TouchableOpacity
onPress={() => handlePause(process.id)}
className='p-1'
>
<Ionicons name='pause' size={20} color='white' />
</TouchableOpacity>
)}
{process.status === "paused" && Platform.OS !== "ios" && (
<TouchableOpacity
onPress={() => handleResume(process.id)}
className='p-1'
>
<Ionicons name='play' size={20} color='white' />
</TouchableOpacity>
)}
<TouchableOpacity <TouchableOpacity
onPress={() => handleDelete(process.id)} onPress={() => handleDelete(process.id)}
className='p-1' className='p-2 bg-neutral-800 rounded-full'
> >
<Ionicons name='close' size={20} color='red' /> <Ionicons name='close' size={20} color='red' />
</TouchableOpacity> </TouchableOpacity>
@@ -152,47 +152,53 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
/> />
</View> </View>
)} )}
<View className='shrink mb-1 flex-1'> <View className='shrink mb-1 flex-1 pr-12'>
<Text className='text-xs opacity-50'>{process.item.Type}</Text> <Text className='text-xs opacity-50'>{process.item.Type}</Text>
<Text className='font-semibold shrink'>{process.item.Name}</Text> <Text className='font-semibold shrink'>{process.item.Name}</Text>
<Text className='text-xs opacity-50'> <Text className='text-xs opacity-50'>
{process.item.ProductionYear} {process.item.ProductionYear}
</Text> </Text>
<View className='flex flex-row items-center space-x-2 mt-1 text-purple-600'>
{isTranscoding && (
<View className='bg-purple-600/20 px-2 py-0.5 rounded-md mt-1 self-start'>
<Text className='text-xs text-purple-400'>Transcoding</Text>
</View>
)}
{/* Row 1: Progress + Downloaded/Total */}
<View className='flex flex-row items-center gap-x-2 mt-1.5'>
{sanitizedProgress === 0 ? ( {sanitizedProgress === 0 ? (
<ActivityIndicator size={"small"} color={"white"} /> <ActivityIndicator size={"small"} color={"white"} />
) : ( ) : (
<Text className='text-xs'>{sanitizedProgress.toFixed(0)}%</Text> <Text className='text-xs font-semibold'>
)} {sanitizedProgress.toFixed(0)}%
{process.speed && process.speed > 0 && (
<Text className='text-xs'>
{bytesToMB(process.speed).toFixed(2)} MB/s
</Text> </Text>
)} )}
{eta(process) && ( {downloadedAmount && (
<Text className='text-xs'> <Text className='text-xs opacity-75'>
{t("home.downloads.eta", { eta: eta(process) })} {downloadedAmount}
{estimatedSize
? ` / ${isTranscoding ? "~" : ""}${formatBytes(estimatedSize)}`
: ""}
</Text> </Text>
)} )}
</View> </View>
<View className='flex flex-row items-center space-x-2 mt-1 text-purple-600'> {/* Row 2: Speed + ETA */}
<Text className='text-xs capitalize'>{process.status}</Text> <View className='flex flex-row items-center gap-x-2 mt-0.5'>
{process.speed && process.speed > 0 && (
<Text className='text-xs text-purple-400'>
{bytesToMB(process.speed).toFixed(2)} MB/s
</Text>
)}
{eta && (
<Text className='text-xs text-green-400'>
{t("home.downloads.eta", { eta: eta })}
</Text>
)}
</View> </View>
</View> </View>
</View> </View>
{process.status === "completed" && (
<View className='flex flex-row mt-4 space-x-4'>
<Button
onPress={() => {
startDownload(process);
}}
className='w-full'
>
Download now
</Button>
</View>
)}
</View> </View>
</TouchableOpacity> </TouchableOpacity>
); );

View File

@@ -13,14 +13,13 @@ export const DownloadSize: React.FC<DownloadSizeProps> = ({
items, items,
...props ...props
}) => { }) => {
const { getDownloadedItemSize, getDownloadedItems } = useDownload(); const { getDownloadedItemSize, downloadedItems } = useDownload();
const downloadedFiles = 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]);
useEffect(() => { useEffect(() => {
if (!downloadedFiles) return; if (!downloadedItems) return;
let s = 0; let s = 0;
@@ -32,7 +31,7 @@ export const DownloadSize: React.FC<DownloadSizeProps> = ({
} }
} }
setSize(s.bytesToReadable()); setSize(s.bytesToReadable());
}, [itemIds]); }, [itemIds, downloadedItems, getDownloadedItemSize]);
const sizeText = useMemo(() => { const sizeText = useMemo(() => {
if (!size) return "..."; if (!size) return "...";

View File

@@ -28,7 +28,7 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
*/ */
const handleDeleteFile = useCallback(() => { const handleDeleteFile = useCallback(() => {
if (item.Id) { if (item.Id) {
deleteFile(item.Id, "Episode"); deleteFile(item.Id);
successHapticFeedback(); successHapticFeedback();
} }
}, [deleteFile, item.Id]); }, [deleteFile, item.Id]);

View File

@@ -19,7 +19,13 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
return storage.getString(items[0].SeriesId!); return storage.getString(items[0].SeriesId!);
}, []); }, []);
const deleteSeries = useCallback(async () => deleteItems(items), [items]); const deleteSeries = useCallback(
async () =>
deleteItems(
items.map((item) => item.Id).filter((id) => id !== undefined),
),
[items],
);
const showActionSheet = useCallback(() => { const showActionSheet = useCallback(() => {
const options = ["Delete", "Cancel"]; const options = ["Delete", "Cancel"];

515
components/home/Home.tsx Normal file
View File

@@ -0,0 +1,515 @@
import { Feather, Ionicons } from "@expo/vector-icons";
import type { Api } from "@jellyfin/sdk";
import type {
BaseItemDto,
BaseItemDtoQueryResult,
BaseItemKind,
} from "@jellyfin/sdk/lib/generated-client/models";
import {
getItemsApi,
getSuggestionsApi,
getTvShowsApi,
getUserLibraryApi,
getUserViewsApi,
} from "@jellyfin/sdk/lib/utils/api";
import { type QueryFunction, useQuery } from "@tanstack/react-query";
import { useNavigation, useRouter, useSegments } from "expo-router";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Platform,
RefreshControl,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
import { Loader } from "@/components/Loader";
import { MediaListSection } from "@/components/medialists/MediaListSection";
import { Colors } from "@/constants/Colors";
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus";
type ScrollingCollectionListSection = {
type: "ScrollingCollectionList";
title?: string;
queryKey: (string | undefined | null)[];
queryFn: QueryFunction<BaseItemDto[]>;
orientation?: "horizontal" | "vertical";
};
type MediaListSectionType = {
type: "MediaListSection";
queryKey: (string | undefined)[];
queryFn: QueryFunction<BaseItemDto>;
};
type Section = ScrollingCollectionListSection | MediaListSectionType;
export const Home = () => {
const router = useRouter();
const { t } = useTranslation();
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const insets = useSafeAreaInsets();
const [loading, setLoading] = useState(false);
const { settings, refreshStreamyfinPluginSettings } = useSettings();
const navigation = useNavigation();
const scrollRef = useRef<ScrollView>(null);
const { downloadedItems, cleanCacheDirectory } = useDownload();
const prevIsConnected = useRef<boolean | null>(false);
const {
isConnected,
serverConnected,
loading: retryLoading,
retryCheck,
} = useNetworkStatus();
const invalidateCache = useInvalidatePlaybackProgressCache();
useEffect(() => {
if (isConnected && !prevIsConnected.current) {
invalidateCache();
}
prevIsConnected.current = isConnected;
}, [isConnected, invalidateCache]);
const hasDownloads = useMemo(() => {
if (Platform.isTV) return false;
return downloadedItems.length > 0;
}, [downloadedItems]);
useEffect(() => {
if (Platform.isTV) {
navigation.setOptions({
headerLeft: () => null,
});
return;
}
navigation.setOptions({
headerLeft: () => (
<TouchableOpacity
onPress={() => {
router.push("/(auth)/downloads");
}}
className='ml-1.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather
name='download'
color={hasDownloads ? Colors.primary : "white"}
size={24}
/>
</TouchableOpacity>
),
});
}, [navigation, router, hasDownloads]);
useEffect(() => {
cleanCacheDirectory().catch((_e) =>
console.error("Something went wrong cleaning cache directory"),
);
}, []);
const segments = useSegments();
useEffect(() => {
const unsubscribe = eventBus.on("scrollToTop", () => {
if ((segments as string[])[2] === "(home)")
scrollRef.current?.scrollTo({
y: Platform.isTV ? -152 : -100,
animated: true,
});
});
return () => {
unsubscribe();
};
}, [segments]);
const {
data,
isError: e1,
isLoading: l1,
} = useQuery({
queryKey: ["home", "userViews", user?.Id],
queryFn: async () => {
if (!api || !user?.Id) {
return null;
}
const response = await getUserViewsApi(api).getUserViews({
userId: user.Id,
});
return response.data.Items || null;
},
enabled: !!api && !!user?.Id,
staleTime: 60 * 1000,
});
const userViews = useMemo(
() => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
[data, settings?.hiddenLibraries],
);
const collections = useMemo(() => {
const allow = ["movies", "tvshows"];
return (
userViews?.filter(
(c) => c.CollectionType && allow.includes(c.CollectionType),
) || []
);
}, [userViews]);
const refetch = async () => {
setLoading(true);
await refreshStreamyfinPluginSettings();
await invalidateCache();
setLoading(false);
};
const createCollectionConfig = useCallback(
(
title: string,
queryKey: string[],
includeItemTypes: BaseItemKind[],
parentId: string | undefined,
): ScrollingCollectionListSection => ({
title,
queryKey,
queryFn: async () => {
if (!api) return [];
return (
(
await getUserLibraryApi(api).getLatestMedia({
userId: user?.Id,
limit: 20,
fields: ["PrimaryImageAspectRatio", "Path", "Genres"],
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
includeItemTypes,
parentId,
})
).data || []
);
},
type: "ScrollingCollectionList",
}),
[api, user?.Id],
);
const defaultSections = useMemo(() => {
if (!api || !user?.Id) return [];
const latestMediaViews = collections.map((c) => {
const includeItemTypes: BaseItemKind[] =
c.CollectionType === "tvshows" || c.CollectionType === "movies"
? []
: ["Movie"];
const title = t("home.recently_added_in", { libraryName: c.Name });
const queryKey: string[] = [
"home",
`recentlyAddedIn${c.CollectionType}`,
user.Id!,
c.Id!,
];
return createCollectionConfig(
title || "",
queryKey,
includeItemTypes,
c.Id,
);
});
const ss: Section[] = [
{
title: t("home.continue_watching"),
queryKey: ["home", "resumeItems"],
queryFn: async () =>
(
await getItemsApi(api).getResumeItems({
userId: user.Id,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
includeItemTypes: ["Movie", "Series", "Episode"],
fields: ["Genres"],
})
).data.Items || [],
type: "ScrollingCollectionList",
orientation: "horizontal",
},
{
title: t("home.next_up"),
queryKey: ["home", "nextUp-all"],
queryFn: async () =>
(
await getTvShowsApi(api).getNextUp({
userId: user?.Id,
fields: ["MediaSourceCount", "Genres"],
limit: 20,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
enableResumable: false,
})
).data.Items || [],
type: "ScrollingCollectionList",
orientation: "horizontal",
},
...latestMediaViews,
{
title: t("home.suggested_movies"),
queryKey: ["home", "suggestedMovies", user?.Id],
queryFn: async () =>
(
await getSuggestionsApi(api).getSuggestions({
userId: user?.Id,
limit: 10,
mediaType: ["Video"],
type: ["Movie"],
})
).data.Items || [],
type: "ScrollingCollectionList",
orientation: "vertical",
},
{
title: t("home.suggested_episodes"),
queryKey: ["home", "suggestedEpisodes", user?.Id],
queryFn: async () => {
try {
const suggestions = await getSuggestions(api, user.Id);
const nextUpPromises = suggestions.map((series) =>
getNextUp(api, user.Id, series.Id),
);
const nextUpResults = await Promise.all(nextUpPromises);
return nextUpResults.filter((item) => item !== null) || [];
} catch (error) {
console.error("Error fetching data:", error);
return [];
}
},
type: "ScrollingCollectionList",
orientation: "horizontal",
},
];
return ss;
}, [api, user?.Id, collections, t, createCollectionConfig]);
const customSections = useMemo(() => {
if (!api || !user?.Id || !settings?.home?.sections) return [];
const ss: Section[] = [];
settings.home.sections.forEach((section, index) => {
const id = section.title || `section-${index}`;
ss.push({
title: t(`${id}`),
queryKey: ["home", "custom", String(index), section.title ?? null],
queryFn: async () => {
if (section.items) {
const response = await getItemsApi(api).getItems({
userId: user?.Id,
limit: section.items?.limit || 25,
recursive: true,
includeItemTypes: section.items?.includeItemTypes,
sortBy: section.items?.sortBy,
sortOrder: section.items?.sortOrder,
filters: section.items?.filters,
parentId: section.items?.parentId,
});
return response.data.Items || [];
}
if (section.nextUp) {
const response = await getTvShowsApi(api).getNextUp({
userId: user?.Id,
fields: ["MediaSourceCount", "Genres"],
limit: section.nextUp?.limit || 25,
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
enableResumable: section.nextUp?.enableResumable,
enableRewatching: section.nextUp?.enableRewatching,
});
return response.data.Items || [];
}
if (section.latest) {
const response = await getUserLibraryApi(api).getLatestMedia({
userId: user?.Id,
includeItemTypes: section.latest?.includeItemTypes,
limit: section.latest?.limit || 25,
isPlayed: section.latest?.isPlayed,
groupItems: section.latest?.groupItems,
});
return response.data || [];
}
if (section.custom) {
const response = await api.get<BaseItemDtoQueryResult>(
section.custom.endpoint,
{
params: { ...(section.custom.query || {}), userId: user?.Id },
headers: section.custom.headers || {},
},
);
return response.data.Items || [];
}
return [];
},
type: "ScrollingCollectionList",
orientation: section?.orientation || "vertical",
});
});
return ss;
}, [api, user?.Id, settings?.home?.sections]);
const sections = settings?.home?.sections ? customSections : defaultSections;
if (!isConnected || serverConnected !== true) {
let title = "";
let subtitle = "";
if (!isConnected) {
title = t("home.no_internet");
subtitle = t("home.no_internet_message");
} else if (serverConnected === null) {
title = t("home.checking_server_connection");
subtitle = t("home.checking_server_connection_message");
} else if (!serverConnected) {
title = t("home.server_unreachable");
subtitle = t("home.server_unreachable_message");
}
return (
<View className='flex flex-col items-center justify-center h-full -mt-6 px-8'>
<Text className='text-3xl font-bold mb-2'>{title}</Text>
<Text className='text-center opacity-70'>{subtitle}</Text>
<View className='mt-4'>
{!Platform.isTV && (
<Button
color='purple'
onPress={() => router.push("/(auth)/downloads")}
justify='center'
iconRight={
<Ionicons name='arrow-forward' size={20} color='white' />
}
>
{t("home.go_to_downloads")}
</Button>
)}
<Button
color='black'
onPress={retryCheck}
justify='center'
className='mt-2'
iconRight={
retryLoading ? null : (
<Ionicons name='refresh' size={20} color='white' />
)
}
>
{retryLoading ? (
<ActivityIndicator size='small' color='white' />
) : (
t("home.retry")
)}
</Button>
</View>
</View>
);
}
if (e1)
return (
<View className='flex flex-col items-center justify-center h-full -mt-6'>
<Text className='text-3xl font-bold mb-2'>{t("home.oops")}</Text>
<Text className='text-center opacity-70'>
{t("home.error_message")}
</Text>
</View>
);
if (l1)
return (
<View className='justify-center items-center h-full'>
<Loader />
</View>
);
return (
<ScrollView
ref={scrollRef}
nestedScrollEnabled
contentInsetAdjustmentBehavior='automatic'
refreshControl={
<RefreshControl
refreshing={loading}
onRefresh={refetch}
tintColor='white'
colors={["white"]}
/>
}
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 16,
}}
>
<View
className='flex flex-col space-y-4'
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
>
{sections.map((section, index) => {
if (section.type === "ScrollingCollectionList") {
return (
<ScrollingCollectionList
key={index}
title={section.title}
queryKey={section.queryKey}
queryFn={section.queryFn}
orientation={section.orientation}
hideIfEmpty
/>
);
}
if (section.type === "MediaListSection") {
return (
<MediaListSection
key={index}
queryKey={section.queryKey}
queryFn={section.queryFn}
/>
);
}
return null;
})}
</View>
</ScrollView>
);
};
async function getSuggestions(api: Api, userId: string | undefined) {
if (!userId) return [];
const response = await getSuggestionsApi(api).getSuggestions({
userId,
limit: 10,
mediaType: ["Unknown"],
type: ["Series"],
});
return response.data.Items ?? [];
}
async function getNextUp(
api: Api,
userId: string | undefined,
seriesId: string | undefined,
) {
if (!userId || !seriesId) return null;
const response = await getTvShowsApi(api).getNextUp({
userId,
seriesId,
limit: 1,
});
return response.data.Items?.[0] ?? null;
}

View File

@@ -12,11 +12,7 @@ import {
getUserLibraryApi, getUserLibraryApi,
getUserViewsApi, getUserViewsApi,
} from "@jellyfin/sdk/lib/utils/api"; } from "@jellyfin/sdk/lib/utils/api";
import { import { type QueryFunction, useQuery } from "@tanstack/react-query";
type QueryFunction,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { useNavigation, useRouter, useSegments } from "expo-router"; import { useNavigation, useRouter, useSegments } from "expo-router";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -24,7 +20,6 @@ import { useTranslation } from "react-i18next";
import { import {
ActivityIndicator, ActivityIndicator,
Platform, Platform,
RefreshControl,
TouchableOpacity, TouchableOpacity,
View, View,
} from "react-native"; } from "react-native";
@@ -45,7 +40,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";
@@ -63,32 +58,19 @@ type MediaListSectionType = {
type Section = ScrollingCollectionListSection | MediaListSectionType; type Section = ScrollingCollectionListSection | MediaListSectionType;
export const HomeIndex = () => { export const HomeWithCarousel = () => {
const router = useRouter(); const router = useRouter();
const { t } = useTranslation(); const { t } = useTranslation();
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom); const user = useAtomValue(userAtom);
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [_loading, setLoading] = useState(false);
const [loading, setLoading] = useState(false);
const { settings, refreshStreamyfinPluginSettings } = useSettings(); const { settings, refreshStreamyfinPluginSettings } = useSettings();
const showLargeHomeCarousel = settings.showLargeHomeCarousel ?? true; const headerOverlayOffset = Platform.isTV ? 0 : 60;
const queryClient = useQueryClient();
const headerOverlayOffset = Platform.isTV
? 0
: showLargeHomeCarousel
? 60
: 0;
const navigation = useNavigation(); const navigation = useNavigation();
const animatedScrollRef = useAnimatedRef<Animated.ScrollView>(); const animatedScrollRef = useAnimatedRef<Animated.ScrollView>();
const scrollOffset = useScrollViewOffset(animatedScrollRef); const scrollOffset = useScrollViewOffset(animatedScrollRef);
const { downloadedItems, cleanCacheDirectory } = useDownload();
const { getDownloadedItems, cleanCacheDirectory } = useDownload();
const prevIsConnected = useRef<boolean | null>(false); const prevIsConnected = useRef<boolean | null>(false);
const { const {
isConnected, isConnected,
@@ -97,15 +79,19 @@ export const HomeIndex = () => {
retryCheck, retryCheck,
} = useNetworkStatus(); } = useNetworkStatus();
const invalidateCache = useInvalidatePlaybackProgressCache(); const invalidateCache = useInvalidatePlaybackProgressCache();
useEffect(() => { useEffect(() => {
// Only invalidate cache when transitioning from offline to online
if (isConnected && !prevIsConnected.current) { if (isConnected && !prevIsConnected.current) {
invalidateCache(); invalidateCache();
} }
// Update the ref to the current state for the next render
prevIsConnected.current = isConnected; prevIsConnected.current = isConnected;
}, [isConnected, invalidateCache]); }, [isConnected, invalidateCache]);
const hasDownloads = useMemo(() => {
if (Platform.isTV) return false;
return downloadedItems.length > 0;
}, [downloadedItems]);
useEffect(() => { useEffect(() => {
if (Platform.isTV) { if (Platform.isTV) {
navigation.setOptions({ navigation.setOptions({
@@ -113,7 +99,6 @@ export const HomeIndex = () => {
}); });
return; return;
} }
const hasDownloads = getDownloadedItems().length > 0;
navigation.setOptions({ navigation.setOptions({
headerLeft: () => ( headerLeft: () => (
<TouchableOpacity <TouchableOpacity
@@ -121,6 +106,7 @@ export const HomeIndex = () => {
router.push("/(auth)/downloads"); router.push("/(auth)/downloads");
}} }}
className='ml-1.5' className='ml-1.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
> >
<Feather <Feather
name='download' name='download'
@@ -130,7 +116,7 @@ export const HomeIndex = () => {
</TouchableOpacity> </TouchableOpacity>
), ),
}); });
}, [navigation, router]); }, [navigation, router, hasDownloads]);
useEffect(() => { useEffect(() => {
cleanCacheDirectory().catch((_e) => cleanCacheDirectory().catch((_e) =>
@@ -188,24 +174,13 @@ export const HomeIndex = () => {
); );
}, [userViews]); }, [userViews]);
const refetch = async () => { const _refetch = async () => {
setLoading(true); setLoading(true);
await refreshStreamyfinPluginSettings(); await refreshStreamyfinPluginSettings();
await queryClient.clear();
await invalidateCache(); await invalidateCache();
setLoading(false); setLoading(false);
}; };
useEffect(() => {
const unsubscribe = eventBus.on("refreshHome", () => {
refetch();
});
return () => {
unsubscribe();
};
}, [refetch]);
const createCollectionConfig = useCallback( const createCollectionConfig = useCallback(
( (
title: string, title: string,
@@ -236,7 +211,6 @@ export const HomeIndex = () => {
[api, user?.Id], [api, user?.Id],
); );
// Always call useMemo() at the top-level, using computed dependencies for both "default"/custom sections
const defaultSections = useMemo(() => { const defaultSections = useMemo(() => {
if (!api || !user?.Id) return []; if (!api || !user?.Id) return [];
@@ -246,10 +220,10 @@ export const HomeIndex = () => {
? [] ? []
: ["Movie"]; : ["Movie"];
const title = t("home.recently_added_in", { libraryName: c.Name }); const title = t("home.recently_added_in", { libraryName: c.Name });
const queryKey = [ const queryKey: string[] = [
"home", "home",
`recentlyAddedIn${c.CollectionType}`, `recentlyAddedIn${c.CollectionType}`,
user?.Id!, user.Id!,
c.Id!, c.Id!,
]; ];
return createCollectionConfig( return createCollectionConfig(
@@ -293,16 +267,6 @@ export const HomeIndex = () => {
orientation: "horizontal", orientation: "horizontal",
}, },
...latestMediaViews, ...latestMediaViews,
// ...(mediaListCollections?.map(
// (ml) =>
// ({
// title: ml.Name,
// queryKey: ["home", "mediaList", ml.Id!],
// queryFn: async () => ml,
// type: "MediaListSection",
// orientation: "vertical",
// } as Section)
// ) || []),
{ {
title: t("home.suggested_movies"), title: t("home.suggested_movies"),
queryKey: ["home", "suggestedMovies", user?.Id], queryKey: ["home", "suggestedMovies", user?.Id],
@@ -411,15 +375,12 @@ export const HomeIndex = () => {
let subtitle = ""; let subtitle = "";
if (!isConnected) { if (!isConnected) {
// No network connection
title = t("home.no_internet"); title = t("home.no_internet");
subtitle = t("home.no_internet_message"); subtitle = t("home.no_internet_message");
} else if (serverConnected === null) { } else if (serverConnected === null) {
// Network is up, but server is being checked
title = t("home.checking_server_connection"); title = t("home.checking_server_connection");
subtitle = t("home.checking_server_connection_message"); subtitle = t("home.checking_server_connection_message");
} else if (!serverConnected) { } else if (!serverConnected) {
// Network is up, but server is unreachable
title = t("home.server_unreachable"); title = t("home.server_unreachable");
subtitle = t("home.server_unreachable_message"); subtitle = t("home.server_unreachable_message");
} }
@@ -488,35 +449,18 @@ export const HomeIndex = () => {
nestedScrollEnabled nestedScrollEnabled
contentInsetAdjustmentBehavior='never' contentInsetAdjustmentBehavior='never'
scrollEventThrottle={16} scrollEventThrottle={16}
bounces={!showLargeHomeCarousel} bounces={false}
overScrollMode={showLargeHomeCarousel ? "never" : "auto"} overScrollMode='never'
refreshControl={
showLargeHomeCarousel ? undefined : (
<RefreshControl
refreshing={loading}
onRefresh={refetch}
tintColor='white'
colors={["white"]}
progressViewOffset={100}
/>
)
}
style={{ marginTop: -headerOverlayOffset }} style={{ marginTop: -headerOverlayOffset }}
contentContainerStyle={{ paddingTop: headerOverlayOffset }} contentContainerStyle={{ paddingTop: headerOverlayOffset }}
> >
{showLargeHomeCarousel && ( <AppleTVCarousel initialIndex={0} scrollOffset={scrollOffset} />
<AppleTVCarousel initialIndex={0} scrollOffset={scrollOffset} />
)}
<View <View
style={{ style={{
paddingLeft: insets.left, paddingLeft: insets.left,
paddingRight: insets.right, paddingRight: insets.right,
paddingBottom: 16, paddingBottom: 16,
paddingTop: Platform.isTV paddingTop: 0,
? 0
: showLargeHomeCarousel
? 0
: insets.top + 60,
}} }}
> >
<View className='flex flex-col space-y-4'> <View className='flex flex-col space-y-4'>
@@ -551,7 +495,6 @@ export const HomeIndex = () => {
); );
}; };
// Function to get suggestions
async function getSuggestions(api: Api, userId: string | undefined) { async function getSuggestions(api: Api, userId: string | undefined) {
if (!userId) return []; if (!userId) return [];
const response = await getSuggestionsApi(api).getSuggestions({ const response = await getSuggestionsApi(api).getSuggestions({
@@ -563,7 +506,6 @@ async function getSuggestions(api: Api, userId: string | undefined) {
return response.data.Items ?? []; return response.data.Items ?? [];
} }
// Function to get the next up TV show for a series
async function getNextUp( async function getNextUp(
api: Api, api: Api,
userId: string | undefined, userId: string | undefined,

View File

@@ -23,7 +23,6 @@ const CastSlide: React.FC<
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
data={details?.credits.cast} data={details?.credits.cast}
ItemSeparatorComponent={() => <View className='w-2' />} ItemSeparatorComponent={() => <View className='w-2' />}
estimatedItemSize={15}
keyExtractor={(item) => item?.id?.toString()} keyExtractor={(item) => item?.id?.toString()}
contentContainerStyle={{ paddingHorizontal: 16 }} contentContainerStyle={{ paddingHorizontal: 16 }}
renderItem={({ item }) => ( renderItem={({ item }) => (

View File

@@ -10,8 +10,8 @@ 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 { 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,
@@ -48,8 +48,22 @@ const RequestModal = forwardRef<
userId: jellyseerrUser?.id, userId: jellyseerrUser?.id,
}); });
const [qualityProfileOpen, setQualityProfileOpen] = useState(false);
const [rootFolderOpen, setRootFolderOpen] = useState(false);
const [tagsOpen, setTagsOpen] = useState(false);
const [usersOpen, setUsersOpen] = useState(false);
const { t } = useTranslation(); const { t } = useTranslation();
// Reset all dropdown states when modal closes
const handleDismiss = useCallback(() => {
setQualityProfileOpen(false);
setRootFolderOpen(false);
setTagsOpen(false);
setUsersOpen(false);
onDismiss?.();
}, [onDismiss]);
const { data: serviceSettings } = useQuery({ const { data: serviceSettings } = useQuery({
queryKey: ["jellyseerr", "request", type, "service"], queryKey: ["jellyseerr", "request", type, "service"],
queryFn: async () => queryFn: async () =>
@@ -138,6 +152,109 @@ const RequestModal = forwardRef<
}); });
}, [requestBody?.seasons]); }, [requestBody?.seasons]);
const pathTitleExtractor = (item: RootFolder) =>
`${item.path} (${item.freeSpace.bytesToReadable()})`;
const qualityProfileOptions = useMemo(
() => [
{
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,
],
);
const rootFolderOptions = useMemo(
() => [
{
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,
],
);
const tagsOptions = useMemo(
() => [
{
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],
);
const usersOptions = useMemo(
() => [
{
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],
);
const request = useCallback(() => { const request = useCallback(() => {
const body = { const body = {
is4k: defaultService?.is4k || defaultServiceDetails?.server.is4k, is4k: defaultService?.is4k || defaultServiceDetails?.server.is4k,
@@ -163,15 +280,12 @@ const RequestModal = forwardRef<
defaultTags, defaultTags,
]); ]);
const pathTitleExtractor = (item: RootFolder) =>
`${item.path} (${item.freeSpace.bytesToReadable()})`;
return ( return (
<BottomSheetModal <BottomSheetModal
ref={ref} ref={ref}
enableDynamicSizing enableDynamicSizing
enableDismissOnClose enableDismissOnClose
onDismiss={onDismiss} onDismiss={handleDismiss}
handleIndicatorStyle={{ handleIndicatorStyle={{
backgroundColor: "white", backgroundColor: "white",
}} }}
@@ -185,6 +299,7 @@ const RequestModal = forwardRef<
appearsOnIndex={0} appearsOnIndex={0}
/> />
)} )}
stackBehavior='push'
> >
<BottomSheetView> <BottomSheetView>
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'> <View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
@@ -199,70 +314,112 @@ 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")} <View 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>
/> </View>
<Dropdown }
data={defaultServiceDetails.rootFolders} title={t("jellyseerr.quality_profile")}
titleExtractor={pathTitleExtractor} open={qualityProfileOpen}
placeholderText={ onOpenChange={setQualityProfileOpen}
defaultFolder ? pathTitleExtractor(defaultFolder) : "" />
} </View>
keyExtractor={(item) => item.id.toString()}
label={t("jellyseerr.root_folder")} <View className='flex flex-col'>
onSelected={(item) => <Text className='opacity-50 mb-1 text-xs'>
item && {t("jellyseerr.root_folder")}
setRequestOverrides((prev) => ({ </Text>
...prev, <PlatformDropdown
rootFolder: item.path, groups={rootFolderOptions}
})) 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'>
title={t("jellyseerr.root_folder")} <Text numberOfLines={1}>
/> {defaultServiceDetails.rootFolders.find(
<Dropdown (f) =>
multiple f.path ===
data={defaultServiceDetails.tags} (requestOverrides.rootFolder ||
titleExtractor={(item) => item.label} defaultFolder?.path),
placeholderText={defaultTags.map((t) => t.label).join(",")} )
keyExtractor={(item) => item.id.toString()} ? pathTitleExtractor(
label={t("jellyseerr.tags")} defaultServiceDetails.rootFolders.find(
onSelected={(...selected) => (f) =>
setRequestOverrides((prev) => ({ f.path ===
...prev, (requestOverrides.rootFolder ||
tags: selected.map((i) => i.id), defaultFolder?.path),
})) )!,
} )
title={t("jellyseerr.tags")} : pathTitleExtractor(defaultFolder!)}
/> </Text>
<Dropdown </View>
data={users} }
titleExtractor={(item) => item.displayName} title={t("jellyseerr.root_folder")}
placeholderText={jellyseerrUser!.displayName} open={rootFolderOpen}
keyExtractor={(item) => item.id.toString() || ""} onOpenChange={setRootFolderOpen}
label={t("jellyseerr.request_as")} />
onSelected={(item) => </View>
item &&
setRequestOverrides((prev) => ({ <View className='flex flex-col'>
...prev, <Text className='opacity-50 mb-1 text-xs'>
userId: item?.id, {t("jellyseerr.tags")}
})) </Text>
} <PlatformDropdown
title={t("jellyseerr.request_as")} groups={tagsOptions}
/> 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}>
{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>
</View>
}
title={t("jellyseerr.tags")}
open={tagsOpen}
onOpenChange={setTagsOpen}
/>
</View>
<View className='flex flex-col'>
<Text className='opacity-50 mb-1 text-xs'>
{t("jellyseerr.request_as")}
</Text>
<PlatformDropdown
groups={usersOptions}
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}>
{users.find(
(u) =>
u.id ===
(requestOverrides.userId || jellyseerrUser?.id),
)?.displayName || jellyseerrUser!.displayName}
</Text>
</View>
}
title={t("jellyseerr.request_as")}
open={usersOpen}
onOpenChange={setUsersOpen}
/>
</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, useState } from "react";
import { Platform, TouchableOpacity, 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;
@@ -33,6 +31,7 @@ export const SeasonDropdown: React.FC<Props> = ({
onSelect, onSelect,
}) => { }) => {
const isTv = Platform.isTV; const isTv = Platform.isTV;
const [open, setOpen] = useState(false);
const keys = useMemo<SeasonKeys>( const keys = useMemo<SeasonKeys>(
() => () =>
@@ -55,6 +54,31 @@ 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(
() => [
{
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,23 @@ 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'> open={open}
<TouchableOpacity className='bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between'> onOpenChange={setOpen}
trigger={
<TouchableOpacity onPress={() => setOpen(true)}>
<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> </TouchableOpacity>
</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

@@ -86,7 +86,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
seasonId: selectedSeasonId, seasonId: selectedSeasonId,
enableUserData: true, enableUserData: true,
// Note: Including trick play is necessary to enable trick play downloads // Note: Including trick play is necessary to enable trick play downloads
fields: ["Overview", "Trickplay"], fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
}); });
if (res.data.TotalRecordCount === 0) if (res.data.TotalRecordCount === 0)

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

@@ -1,44 +1,3 @@
import { useMemo } from "react"; export default function DownloadSettings() {
import { useTranslation } from "react-i18next"; return null;
import { Stepper } from "@/components/inputs/Stepper";
import DisabledSetting from "@/components/settings/DisabledSetting";
import { type Settings, useSettings } from "@/utils/atoms/settings";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
export default function DownloadSettings({ ...props }) {
const { settings, updateSettings, pluginSettings } = useSettings();
const { t } = useTranslation();
const allDisabled = useMemo(
() =>
pluginSettings?.remuxConcurrentLimit?.locked === true &&
pluginSettings?.autoDownload?.locked === true,
[pluginSettings],
);
if (!settings) return null;
return (
<DisabledSetting disabled={allDisabled} {...props} className='mb-4'>
<ListGroup title={t("home.settings.downloads.downloads_title")}>
<ListItem
title={t("home.settings.downloads.remux_max_download")}
disabled={pluginSettings?.remuxConcurrentLimit?.locked}
>
<Stepper
value={settings.remuxConcurrentLimit}
step={1}
min={1}
max={4}
onUpdate={(value) =>
updateSettings({
remuxConcurrentLimit: value as Settings["remuxConcurrentLimit"],
})
}
/>
</ListItem>
</ListGroup>
</DisabledSetting>
);
} }

View File

@@ -1,22 +1,15 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import * as TaskManager from "expo-task-manager";
import { TFunction } from "i18next"; import { TFunction } from "i18next";
import type React from "react"; import type React from "react";
import { useEffect, useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Linking, Platform, Switch, TouchableOpacity } from "react-native"; import { Linking, Switch, View } from "react-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";
import {
BACKGROUND_FETCH_TASK,
registerBackgroundFetchAsync,
unregisterBackgroundFetchAsync,
} from "@/utils/background-tasks";
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";
@@ -27,39 +20,8 @@ export const OtherSettings: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
/********************
* Background task
*******************/
const checkStatusAsync = async () => {
if (Platform.isTV) return false;
return TaskManager.isTaskRegisteredAsync(BACKGROUND_FETCH_TASK);
};
useEffect(() => {
(async () => {
const registered = await checkStatusAsync();
if (settings?.autoDownload === true && !registered) {
registerBackgroundFetchAsync();
toast.success(t("home.settings.toasts.background_downloads_enabled"));
} else if (settings?.autoDownload === false && registered) {
unregisterBackgroundFetchAsync();
toast.info(t("home.settings.toasts.background_downloads_disabled"));
} else if (settings?.autoDownload === true && registered) {
// Don't to anything
} else if (settings?.autoDownload === false && !registered) {
// Don't to anything
} else {
updateSettings({ autoDownload: false });
}
})();
}, [settings?.autoDownload]);
/**********************
*********************/
const disabled = useMemo( const disabled = useMemo(
() => () =>
pluginSettings?.followDeviceOrientation?.locked === true &&
pluginSettings?.defaultVideoOrientation?.locked === true && pluginSettings?.defaultVideoOrientation?.locked === true &&
pluginSettings?.safeAreaInControlsEnabled?.locked === true && pluginSettings?.safeAreaInControlsEnabled?.locked === true &&
pluginSettings?.showCustomMenuLinks?.locked === true && pluginSettings?.showCustomMenuLinks?.locked === true &&
@@ -89,41 +51,65 @@ 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 (
<DisabledSetting disabled={disabled}> <DisabledSetting disabled={disabled}>
<ListGroup title={t("home.settings.other.other_title")} className=''> <ListGroup title={t("home.settings.other.other_title")} className=''>
<ListItem
title={t("home.settings.other.follow_device_orientation")}
disabled={pluginSettings?.followDeviceOrientation?.locked}
>
<Switch
value={settings.followDeviceOrientation}
disabled={pluginSettings?.followDeviceOrientation?.locked}
onValueChange={(value) =>
updateSettings({ followDeviceOrientation: value })
}
/>
</ListItem>
<ListItem <ListItem
title={t("home.settings.other.video_orientation")} title={t("home.settings.other.video_orientation")}
disabled={ disabled={pluginSettings?.defaultVideoOrientation?.locked}
pluginSettings?.defaultVideoOrientation?.locked ||
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 +122,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>
@@ -222,13 +205,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>
@@ -237,10 +217,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
@@ -256,12 +235,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>
@@ -270,12 +247,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

@@ -14,7 +14,7 @@ export const PluginSettings = () => {
if (!settings) return null; if (!settings) return null;
return ( return (
<View> <View className='mt-4'>
<ListGroup <ListGroup
title={t("home.settings.plugins.plugins_title")} title={t("home.settings.plugins.plugins_title")}
className='mb-4' className='mb-4'

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 = settings?.vlcTextColor ?? "White";
const backgroundColor = settings?.vlcBackgroundColor ?? "Black";
const outlineColor = settings?.vlcOutlineColor ?? "Black";
const outlineThickness = settings?.vlcOutlineThickness ?? "Normal";
const backgroundOpacity = settings?.vlcBackgroundOpacity ?? 128;
const outlineOpacity = settings?.vlcOutlineOpacity ?? 255;
const isBold = settings?.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

@@ -114,10 +114,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
}} }}
> >
<View <View
style={{ className='flex flex-col items-start shrink'
flexDirection: "column",
alignSelf: "flex-end",
}}
pointerEvents={showControls ? "box-none" : "none"} pointerEvents={showControls ? "box-none" : "none"}
> >
{item?.Type === "Episode" && ( {item?.Type === "Episode" && (
@@ -133,7 +130,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
<Text className='text-xs opacity-50'>{item?.Album}</Text> <Text className='text-xs opacity-50'>{item?.Album}</Text>
)} )}
</View> </View>
<View className='flex flex-row space-x-2'> <View className='flex flex-row space-x-2 shrink-0'>
<SkipButton <SkipButton
showButton={showSkipButton} showButton={showSkipButton}
onPress={skipIntro} onPress={skipIntro}

View File

@@ -321,7 +321,7 @@ export const Controls: FC<Props> = ({
}>(); }>();
const { showSkipButton, skipIntro } = useIntroSkipper( const { showSkipButton, skipIntro } = useIntroSkipper(
item?.Id!, item.Id!,
currentTime, currentTime,
seek, seek,
play, play,
@@ -332,7 +332,7 @@ export const Controls: FC<Props> = ({
); );
const { showSkipCreditButton, skipCredit } = useCreditSkipper( const { showSkipCreditButton, skipCredit } = useCreditSkipper(
item?.Id!, item.Id!,
currentTime, currentTime,
seek, seek,
play, play,

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 ?? ""];
@@ -68,13 +71,13 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
const seriesEpisodes = downloadedFiles?.filter( const seriesEpisodes = downloadedFiles?.filter(
(f: DownloadedItem) => f.item.SeriesId === item.SeriesId, (f: DownloadedItem) => f.item.SeriesId === item.SeriesId,
); );
const seasonNumbers = [ const seasonNumbers = Array.from(
...new Set( new Set(
seriesEpisodes seriesEpisodes
?.map((f: DownloadedItem) => f.item.ParentIndexNumber) ?.map((f: DownloadedItem) => f.item.ParentIndexNumber)
.filter(Boolean), .filter(Boolean),
), ),
]; );
// Create fake season objects // Create fake season objects
return seasonNumbers.map((seasonNumber) => ({ return seasonNumbers.map((seasonNumber) => ({
Id: seasonNumber?.toString(), Id: seasonNumber?.toString(),

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

@@ -13,6 +13,12 @@ const SkipButton: React.FC<SkipButtonProps> = ({
buttonText, buttonText,
...props ...props
}) => { }) => {
console.log(`[SKIP_BUTTON] Render:`, {
buttonText,
showButton,
className: showButton ? "flex" : "hidden",
});
return ( return (
<View className={showButton ? "flex" : "hidden"} {...props}> <View className={showButton ? "flex" : "hidden"} {...props}>
<TouchableOpacity <TouchableOpacity

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

@@ -1,9 +1,23 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useTVEventHandler } from "react-native"; import { Platform } from "react-native";
import { type SharedValue, useSharedValue } from "react-native-reanimated"; import { type SharedValue, useSharedValue } from "react-native-reanimated";
import { msToTicks, ticksToSeconds } from "@/utils/time"; import { msToTicks, ticksToSeconds } from "@/utils/time";
import { CONTROLS_CONSTANTS } from "../constants"; import { CONTROLS_CONSTANTS } from "../constants";
// TV event handler with fallback for non-TV platforms
let useTVEventHandler: (callback: (evt: any) => void) => void;
if (Platform.isTV) {
try {
useTVEventHandler = require("react-native").useTVEventHandler;
} catch {
// Fallback for non-TV platforms
useTVEventHandler = () => {};
}
} else {
// No-op hook for non-TV platforms
useTVEventHandler = () => {};
}
interface UseRemoteControlProps { interface UseRemoteControlProps {
progress: SharedValue<number>; progress: SharedValue<number>;
min: SharedValue<number>; min: SharedValue<number>;
@@ -63,6 +77,7 @@ export function useRemoteControl({
[isVlc], [isVlc],
); );
// TV remote control handling (no-op on non-TV platforms)
useTVEventHandler((evt) => { useTVEventHandler((evt) => {
if (!evt) return; if (!evt) return;

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);

58
docs/nested-modals.md Normal file
View File

@@ -0,0 +1,58 @@
# Nested Modals with PlatformDropdown
## Issue
PlatformDropdowns inside BottomSheetModals don't open on Android.
## Solution
1. **Add controlled state** for each PlatformDropdown:
```tsx
const [open, setOpen] = useState(false);
<PlatformDropdown
open={open}
onOpenChange={setOpen}
// ...
/>
```
2. **Use `View` for triggers, not `TouchableOpacity`**:
```tsx
// ✅ Correct
<PlatformDropdown
trigger={<View>...</View>}
/>
// ❌ Wrong - causes nested TouchableOpacity conflicts
<PlatformDropdown
trigger={<TouchableOpacity>...</TouchableOpacity>}
/>
```
3. **Add `stackBehavior='push'` to parent BottomSheetModal**:
```tsx
<BottomSheetModal
stackBehavior='push'
// ...
/>
```
4. **Reset dropdown states on modal dismiss**:
```tsx
const handleDismiss = useCallback(() => {
setDropdown1Open(false);
setDropdown2Open(false);
// reset all dropdown states
onDismiss?.();
}, [onDismiss]);
<BottomSheetModal
onDismiss={handleDismiss}
// ...
/>
```
## Why
- PlatformDropdown wraps triggers in TouchableOpacity on Android. Nested TouchableOpacity causes touch event conflicts.
- PlatformDropdown's useEffect should only call `showModal()` when `open === true`, not call `hideModal()` when `open === false` (interferes with parent modals).
- Dropdown states must be reset on modal dismiss to prevent them from reopening automatically when parent modal reopens.

View File

@@ -45,14 +45,14 @@
}, },
"production": { "production": {
"environment": "production", "environment": "production",
"channel": "0.40.4", "channel": "0.46.2",
"android": { "android": {
"image": "latest" "image": "latest"
} }
}, },
"production-apk": { "production-apk": {
"environment": "production", "environment": "production",
"channel": "0.40.4", "channel": "0.46.2",
"android": { "android": {
"buildType": "apk", "buildType": "apk",
"image": "latest" "image": "latest"
@@ -60,7 +60,7 @@
}, },
"production-apk-tv": { "production-apk-tv": {
"environment": "production", "environment": "production",
"channel": "0.40.4", "channel": "0.46.2",
"android": { "android": {
"buildType": "apk", "buildType": "apk",
"image": "latest" "image": "latest"

View File

@@ -43,26 +43,60 @@ export const useIntroSkipper = (
const introTimestamps = segments?.introSegments?.[0]; const introTimestamps = segments?.introSegments?.[0];
useEffect(() => { useEffect(() => {
console.log(`[INTRO_SKIPPER] Hook state:`, {
itemId,
currentTime,
hasSegments: !!segments,
segments: segments,
introSegmentsCount: segments?.introSegments?.length || 0,
introSegments: segments?.introSegments,
hasIntroTimestamps: !!introTimestamps,
introTimestamps,
isVlc,
isOffline,
});
if (introTimestamps) { if (introTimestamps) {
setShowSkipButton( const shouldShow =
currentTime > introTimestamps.startTime && currentTime > introTimestamps.startTime &&
currentTime < introTimestamps.endTime, currentTime < introTimestamps.endTime;
);
console.log(`[INTRO_SKIPPER] Button visibility check:`, {
currentTime,
introStart: introTimestamps.startTime,
introEnd: introTimestamps.endTime,
afterStart: currentTime > introTimestamps.startTime,
beforeEnd: currentTime < introTimestamps.endTime,
shouldShow,
});
setShowSkipButton(shouldShow);
} else {
if (showSkipButton) {
console.log(`[INTRO_SKIPPER] No intro timestamps, hiding button`);
setShowSkipButton(false);
}
} }
}, [introTimestamps, currentTime]); }, [introTimestamps, currentTime, showSkipButton]);
const skipIntro = useCallback(() => { const skipIntro = useCallback(() => {
if (!introTimestamps) return; if (!introTimestamps) return;
try { try {
console.log(
`[INTRO_SKIPPER] Skipping intro to:`,
introTimestamps.endTime,
);
lightHapticFeedback(); lightHapticFeedback();
wrappedSeek(introTimestamps.endTime); wrappedSeek(introTimestamps.endTime);
setTimeout(() => { setTimeout(() => {
play(); play();
}, 200); }, 200);
} catch (error) { } catch (error) {
console.error("Error skipping intro", error); console.error("[INTRO_SKIPPER] Error skipping intro", error);
} }
}, [introTimestamps, lightHapticFeedback, wrappedSeek, play]); }, [introTimestamps, lightHapticFeedback, wrappedSeek, play]);
console.log(`[INTRO_SKIPPER] Returning state:`, { showSkipButton });
return { showSkipButton, skipIntro }; return { showSkipButton, skipIntro };
}; };

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

@@ -1,7 +1,23 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Platform } from "react-native"; import { Platform } from "react-native";
import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import orientationToOrientationLock from "@/utils/OrientationLockConverter"; import { OrientationLock } from "@/packages/expo-screen-orientation";
import { Orientation } from "../packages/expo-screen-orientation.tv";
const orientationToOrientationLock = (
orientation: Orientation,
): OrientationLock => {
switch (orientation) {
case Orientation.LANDSCAPE_LEFT:
return OrientationLock.LANDSCAPE_LEFT;
case Orientation.LANDSCAPE_RIGHT:
return OrientationLock.LANDSCAPE_RIGHT;
case Orientation.PORTRAIT_UP:
return OrientationLock.PORTRAIT_UP;
default:
return OrientationLock.PORTRAIT_UP;
}
};
export const useOrientation = () => { export const useOrientation = () => {
const [orientation, setOrientation] = useState( const [orientation, setOrientation] = useState(
@@ -29,5 +45,20 @@ export const useOrientation = () => {
}; };
}, []); }, []);
return { orientation, setOrientation }; const lockOrientation = async (lock: OrientationLock) => {
if (Platform.isTV) return;
if (lock === ScreenOrientation.OrientationLock.DEFAULT) {
await ScreenOrientation.unlockAsync();
} else {
await ScreenOrientation.lockAsync(lock);
}
};
const unlockOrientation = async () => {
if (Platform.isTV) return;
await ScreenOrientation.unlockAsync();
};
return { orientation, setOrientation, lockOrientation, unlockOrientation };
}; };

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

@@ -0,0 +1,258 @@
# Background Downloader Module
A native iOS and Android module for downloading large files in the background using `NSURLSession` (iOS) and `DownloadManager` (Android).
## Features
- **Background Downloads**: Downloads continue even when the app is backgrounded or suspended
- **Progress Tracking**: Real-time progress updates via events
- **Multiple Downloads**: Support for concurrent downloads
- **Cancellation**: Cancel individual or all downloads
- **Custom Destination**: Optionally specify custom file paths
- **Error Handling**: Comprehensive error reporting
- **Cross-Platform**: Works on both iOS and Android
## Usage
### Basic Example
```typescript
import { BackgroundDownloader } from '@/modules';
// Start a download
const taskId = await BackgroundDownloader.startDownload(
'https://example.com/largefile.mp4'
);
// Listen for progress updates
const progressSub = BackgroundDownloader.addProgressListener((event) => {
console.log(`Progress: ${Math.floor(event.progress * 100)}%`);
console.log(`Downloaded: ${event.bytesWritten} / ${event.totalBytes}`);
});
// Listen for completion
const completeSub = BackgroundDownloader.addCompleteListener((event) => {
console.log('Download complete!');
console.log('File saved to:', event.filePath);
console.log('Task ID:', event.taskId);
});
// Listen for errors
const errorSub = BackgroundDownloader.addErrorListener((event) => {
console.error('Download failed:', event.error);
});
// Cancel a download
BackgroundDownloader.cancelDownload(taskId);
// Get all active downloads
const activeDownloads = await BackgroundDownloader.getActiveDownloads();
// Cleanup listeners when done
progressSub.remove();
completeSub.remove();
errorSub.remove();
```
### Custom Destination Path
```typescript
import { BackgroundDownloader } from '@/modules';
import * as FileSystem from 'expo-file-system';
const destinationPath = `${FileSystem.documentDirectory}myfile.mp4`;
const taskId = await BackgroundDownloader.startDownload(
'https://example.com/video.mp4',
destinationPath
);
```
### Managing Multiple Downloads
```typescript
import { BackgroundDownloader } from '@/modules';
const downloads = new Map();
async function startMultipleDownloads(urls: string[]) {
for (const url of urls) {
const taskId = await BackgroundDownloader.startDownload(url);
downloads.set(taskId, { url, progress: 0 });
}
}
// Track progress for each download
const progressSub = BackgroundDownloader.addProgressListener((event) => {
const download = downloads.get(event.taskId);
if (download) {
download.progress = event.progress;
}
});
// Cancel all downloads
BackgroundDownloader.cancelAllDownloads();
```
## API Reference
### Methods
#### `startDownload(url: string, destinationPath?: string): Promise<number>`
Starts a new background download.
- **Parameters:**
- `url`: The URL of the file to download
- `destinationPath`: (Optional) Custom file path for the downloaded file
- **Returns:** Promise that resolves to the task ID (number)
#### `cancelDownload(taskId: number): void`
Cancels a specific download by task ID.
- **Parameters:**
- `taskId`: The task ID returned by `startDownload`
#### `cancelAllDownloads(): void`
Cancels all active downloads.
#### `getActiveDownloads(): Promise<ActiveDownload[]>`
Gets information about all active downloads.
- **Returns:** Promise that resolves to an array of active downloads
### Event Listeners
#### `addProgressListener(listener: (event: DownloadProgressEvent) => void): Subscription`
Listens for download progress updates.
- **Event payload:**
- `taskId`: number
- `bytesWritten`: number
- `totalBytes`: number
- `progress`: number (0.0 to 1.0)
#### `addCompleteListener(listener: (event: DownloadCompleteEvent) => void): Subscription`
Listens for download completion.
- **Event payload:**
- `taskId`: number
- `filePath`: string
- `url`: string
#### `addErrorListener(listener: (event: DownloadErrorEvent) => void): Subscription`
Listens for download errors.
- **Event payload:**
- `taskId`: number
- `error`: string
#### `addStartedListener(listener: (event: DownloadStartedEvent) => void): Subscription`
Listens for download start confirmation.
- **Event payload:**
- `taskId`: number
- `url`: string
## Types
```typescript
interface DownloadProgressEvent {
taskId: number;
bytesWritten: number;
totalBytes: number;
progress: number;
}
interface DownloadCompleteEvent {
taskId: number;
filePath: string;
url: string;
}
interface DownloadErrorEvent {
taskId: number;
error: string;
}
interface DownloadStartedEvent {
taskId: number;
url: string;
}
interface ActiveDownload {
taskId: number;
url: string;
state: 'running' | 'suspended' | 'canceling' | 'completed' | 'unknown';
}
```
## Implementation Details
### iOS Background Downloads
- Uses `NSURLSession` with background configuration
- Session identifier: `com.fredrikburmester.streamyfin.backgrounddownloader`
- Downloads continue when app is backgrounded or suspended
- System may terminate downloads if app is force-quit
### Android Background Downloads
- Uses Android's `DownloadManager` API
- Downloads are managed by the system and continue in the background
- Shows download notification in the notification tray
- Downloads continue even if the app is closed
- Requires `INTERNET` permission (automatically added by Expo)
### Background Modes
The app's `Info.plist` already includes the required background mode for iOS:
- `UIBackgroundModes`: `["audio", "fetch"]`
### File Storage
**iOS:** By default, downloaded files are saved to the app's Documents directory.
**Android:** By default, files are saved to the app's external files directory (accessible via `FileSystem.documentDirectory` in Expo).
You can specify a custom path using the `destinationPath` parameter on both platforms.
## Building
After adding this module, rebuild the app:
```bash
# iOS
npx expo prebuild -p ios
npx expo run:ios
# Android
npx expo prebuild -p android
npx expo run:android
```
Or install manually:
```bash
# iOS
cd ios
pod install
cd ..
# Android - prebuild handles everything
npx expo prebuild -p android
```
## Notes
- Background downloads may be cancelled if the user force-quits the app (iOS)
- The OS manages download priority and may pause downloads to save battery
- Android shows a system notification for ongoing downloads
- Downloads over cellular are allowed by default on both platforms

View File

@@ -0,0 +1,46 @@
plugins {
id 'com.android.library'
id 'kotlin-android'
}
group = 'expo.modules.backgrounddownloader'
version = '1.0.0'
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
def kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.25'
apply from: expoModulesCorePlugin
applyKotlinExpoModulesCorePlugin()
useDefaultAndroidSdkVersions()
useCoreDependencies()
useExpoPublishing()
android {
namespace "expo.modules.backgrounddownloader"
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
lintOptions {
abortOnError false
}
}
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
implementation "com.squareup.okhttp3:okhttp:4.12.0"
}
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
kotlinOptions {
jvmTarget = "17"
}
}

View File

@@ -0,0 +1,13 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application>
<service
android:name=".DownloadService"
android:foregroundServiceType="dataSync"
android:exported="false" />
</application>
</manifest>

View File

@@ -0,0 +1,300 @@
package expo.modules.backgrounddownloader
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.IBinder
import android.util.Log
import expo.modules.kotlin.Promise
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
data class DownloadTaskInfo(
val url: String,
val destinationPath: String?
)
class BackgroundDownloaderModule : Module() {
companion object {
private const val TAG = "BackgroundDownloader"
}
private val context
get() = requireNotNull(appContext.reactContext)
private val downloadManager = OkHttpDownloadManager()
private val downloadTasks = mutableMapOf<Int, DownloadTaskInfo>()
private val downloadQueue = mutableListOf<Pair<String, String?>>()
private var taskIdCounter = 1
private var downloadService: DownloadService? = null
private var serviceBound = false
private val serviceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
Log.d(TAG, "Service connected")
val binder = service as DownloadService.DownloadServiceBinder
downloadService = binder.getService()
serviceBound = true
}
override fun onServiceDisconnected(name: ComponentName?) {
Log.d(TAG, "Service disconnected")
downloadService = null
serviceBound = false
}
}
override fun definition() = ModuleDefinition {
Name("BackgroundDownloader")
Events(
"onDownloadProgress",
"onDownloadComplete",
"onDownloadError",
"onDownloadStarted"
)
OnCreate {
Log.d(TAG, "Module created")
}
OnDestroy {
Log.d(TAG, "Module destroyed")
downloadManager.cancelAllDownloads()
if (serviceBound) {
try {
context.unbindService(serviceConnection)
serviceBound = false
} catch (e: Exception) {
Log.e(TAG, "Error unbinding service: ${e.message}")
}
}
}
AsyncFunction("startDownload") { urlString: String, destinationPath: String?, promise: Promise ->
try {
val taskId = startDownloadInternal(urlString, destinationPath)
promise.resolve(taskId)
} catch (e: Exception) {
promise.reject("DOWNLOAD_ERROR", "Failed to start download: ${e.message}", e)
}
}
AsyncFunction("enqueueDownload") { urlString: String, destinationPath: String?, promise: Promise ->
try {
Log.d(TAG, "Enqueuing download: url=$urlString")
// Add to queue
val wasEmpty = downloadQueue.isEmpty()
downloadQueue.add(Pair(urlString, destinationPath))
Log.d(TAG, "Queue size: ${downloadQueue.size}")
// If queue was empty and no active downloads, start processing immediately
if (wasEmpty && downloadTasks.isEmpty()) {
val taskId = processNextInQueue()
promise.resolve(taskId)
} else {
// Return placeholder taskId for queued items
promise.resolve(-1)
}
} catch (e: Exception) {
promise.reject("DOWNLOAD_ERROR", "Failed to enqueue download: ${e.message}", e)
}
}
Function("cancelDownload") { taskId: Int ->
Log.d(TAG, "Cancelling download: taskId=$taskId")
downloadManager.cancelDownload(taskId)
downloadTasks.remove(taskId)
downloadService?.stopDownload()
// Process next item in queue after cancellation
processNextInQueue()
}
Function("cancelQueuedDownload") { url: String ->
// Remove from queue by URL
downloadQueue.removeAll { queuedItem ->
queuedItem.first == url
}
Log.d(TAG, "Removed queued download: $url, queue size: ${downloadQueue.size}")
}
Function("cancelAllDownloads") {
Log.d(TAG, "Cancelling all downloads")
downloadManager.cancelAllDownloads()
downloadTasks.clear()
downloadQueue.clear()
stopDownloadService()
}
AsyncFunction("getActiveDownloads") { promise: Promise ->
try {
val activeDownloads = downloadTasks.map { (taskId, taskInfo) ->
mapOf(
"taskId" to taskId,
"url" to taskInfo.url
)
}
promise.resolve(activeDownloads)
} catch (e: Exception) {
promise.reject("ERROR", "Failed to get active downloads: ${e.message}", e)
}
}
}
private fun startDownloadInternal(urlString: String, destinationPath: String?): Int {
val taskId = taskIdCounter++
if (destinationPath == null) {
throw IllegalArgumentException("Destination path is required")
}
downloadTasks[taskId] = DownloadTaskInfo(
url = urlString,
destinationPath = destinationPath
)
// Start foreground service if not running
startDownloadService()
downloadService?.startDownload()
Log.d(TAG, "Starting download: taskId=$taskId, url=$urlString")
// Send started event
sendEvent("onDownloadStarted", mapOf(
"taskId" to taskId,
"url" to urlString
))
// Start the download with OkHttp
downloadManager.startDownload(
taskId = taskId,
url = urlString,
destinationPath = destinationPath,
onProgress = { bytesWritten, totalBytes ->
handleProgress(taskId, bytesWritten, totalBytes)
},
onComplete = { filePath ->
handleDownloadComplete(taskId, filePath)
},
onError = { error ->
handleError(taskId, error)
}
)
return taskId
}
private fun processNextInQueue(): Int {
// Check if queue has items
if (downloadQueue.isEmpty()) {
Log.d(TAG, "Queue is empty")
return -1
}
// Check if there are active downloads (one at a time)
if (downloadTasks.isNotEmpty()) {
Log.d(TAG, "Active downloads in progress (${downloadTasks.size}), waiting...")
return -1
}
// Get next item from queue
val (url, destinationPath) = downloadQueue.removeAt(0)
Log.d(TAG, "Processing next in queue: $url")
return try {
startDownloadInternal(url, destinationPath)
} catch (e: Exception) {
Log.e(TAG, "Error processing queue item: ${e.message}", e)
// Try to process next item
processNextInQueue()
}
}
private fun handleProgress(taskId: Int, bytesWritten: Long, totalBytes: Long) {
val progress = if (totalBytes > 0) {
bytesWritten.toDouble() / totalBytes.toDouble()
} else {
0.0
}
// Update notification
val taskInfo = downloadTasks[taskId]
if (taskInfo != null) {
val progressPercent = (progress * 100).toInt()
downloadService?.updateProgress("Downloading video", progressPercent)
}
sendEvent("onDownloadProgress", mapOf(
"taskId" to taskId,
"bytesWritten" to bytesWritten,
"totalBytes" to totalBytes,
"progress" to progress
))
}
private fun handleDownloadComplete(taskId: Int, filePath: String) {
val taskInfo = downloadTasks[taskId]
if (taskInfo == null) {
Log.e(TAG, "Download completed but task info not found: taskId=$taskId")
return
}
Log.d(TAG, "Download completed: taskId=$taskId, filePath=$filePath")
sendEvent("onDownloadComplete", mapOf(
"taskId" to taskId,
"filePath" to filePath,
"url" to taskInfo.url
))
downloadTasks.remove(taskId)
downloadService?.stopDownload()
// Process next item in queue
processNextInQueue()
}
private fun handleError(taskId: Int, error: String) {
val taskInfo = downloadTasks[taskId]
Log.e(TAG, "Download error: taskId=$taskId, error=$error")
sendEvent("onDownloadError", mapOf(
"taskId" to taskId,
"error" to error
))
downloadTasks.remove(taskId)
downloadService?.stopDownload()
// Process next item in queue even on error
processNextInQueue()
}
private fun startDownloadService() {
if (!serviceBound) {
val intent = Intent(context, DownloadService::class.java)
context.startForegroundService(intent)
context.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
}
}
private fun stopDownloadService() {
if (serviceBound && downloadTasks.isEmpty()) {
try {
context.unbindService(serviceConnection)
serviceBound = false
downloadService = null
val intent = Intent(context, DownloadService::class.java)
context.stopService(intent)
} catch (e: Exception) {
Log.e(TAG, "Error stopping service: ${e.message}")
}
}
}
}

View File

@@ -0,0 +1,111 @@
package expo.modules.backgrounddownloader
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Intent
import android.os.Binder
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
class DownloadService : Service() {
private val TAG = "DownloadService"
private val NOTIFICATION_ID = 1001
private val CHANNEL_ID = "download_channel"
private val binder = DownloadServiceBinder()
private var activeDownloadCount = 0
private var currentDownloadTitle = "Preparing download..."
private var currentProgress = 0
inner class DownloadServiceBinder : Binder() {
fun getService(): DownloadService = this@DownloadService
}
override fun onCreate() {
super.onCreate()
Log.d(TAG, "DownloadService created")
createNotificationChannel()
}
override fun onBind(intent: Intent?): IBinder {
Log.d(TAG, "DownloadService bound")
return binder
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.d(TAG, "DownloadService started")
startForeground(NOTIFICATION_ID, createNotification())
return START_STICKY
}
override fun onDestroy() {
Log.d(TAG, "DownloadService destroyed")
super.onDestroy()
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"Downloads",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Video download progress"
setShowBadge(false)
}
val notificationManager = getSystemService(NotificationManager::class.java)
notificationManager.createNotificationChannel(channel)
}
}
private fun createNotification(): Notification {
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(currentDownloadTitle)
.setSmallIcon(android.R.drawable.stat_sys_download)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setOngoing(true)
.setOnlyAlertOnce(true)
if (currentProgress > 0) {
builder.setProgress(100, currentProgress, false)
.setContentText("$currentProgress% complete")
} else {
builder.setProgress(100, 0, true)
.setContentText("Starting...")
}
return builder.build()
}
fun startDownload() {
activeDownloadCount++
Log.d(TAG, "Download started, active count: $activeDownloadCount")
if (activeDownloadCount == 1) {
startForeground(NOTIFICATION_ID, createNotification())
}
}
fun stopDownload() {
activeDownloadCount = maxOf(0, activeDownloadCount - 1)
Log.d(TAG, "Download stopped, active count: $activeDownloadCount")
if (activeDownloadCount == 0) {
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
}
fun updateProgress(title: String, progress: Int) {
currentDownloadTitle = title
currentProgress = progress
val notificationManager = getSystemService(NotificationManager::class.java)
notificationManager.notify(NOTIFICATION_ID, createNotification())
}
}

View File

@@ -0,0 +1,151 @@
package expo.modules.backgrounddownloader
import android.util.Log
import okhttp3.Call
import okhttp3.Callback
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import java.io.File
import java.io.IOException
import java.util.concurrent.TimeUnit
class OkHttpDownloadManager {
private val TAG = "OkHttpDownloadManager"
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.callTimeout(0, TimeUnit.SECONDS) // No timeout for long transcodes
.build()
private val activeDownloads = mutableMapOf<Int, Call>()
fun startDownload(
taskId: Int,
url: String,
destinationPath: String,
onProgress: (bytesWritten: Long, totalBytes: Long) -> Unit,
onComplete: (filePath: String) -> Unit,
onError: (error: String) -> Unit
) {
Log.d(TAG, "Starting download: taskId=$taskId, url=$url")
val request = Request.Builder()
.url(url)
.build()
val call = client.newCall(request)
activeDownloads[taskId] = call
call.enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
Log.e(TAG, "Download failed: taskId=$taskId, error=${e.message}")
activeDownloads.remove(taskId)
if (call.isCanceled()) {
// Don't report cancellation as error
return
}
onError(e.message ?: "Download failed")
}
override fun onResponse(call: Call, response: Response) {
if (!response.isSuccessful) {
Log.e(TAG, "Download failed with HTTP code: ${response.code}")
activeDownloads.remove(taskId)
onError("HTTP error: ${response.code} ${response.message}")
return
}
try {
val totalBytes = response.body?.contentLength() ?: -1L
val inputStream = response.body?.byteStream()
if (inputStream == null) {
activeDownloads.remove(taskId)
onError("Failed to get response body")
return
}
// Create destination directory if needed
val destFile = File(destinationPath)
val destDir = destFile.parentFile
if (destDir != null && !destDir.exists()) {
destDir.mkdirs()
}
val outputStream = destFile.outputStream()
val buffer = ByteArray(8192)
var bytesWritten = 0L
var lastProgressUpdate = System.currentTimeMillis()
inputStream.use { input ->
outputStream.use { output ->
var bytes = input.read(buffer)
while (bytes >= 0) {
// Check if download was cancelled
if (call.isCanceled()) {
Log.d(TAG, "Download cancelled: taskId=$taskId")
destFile.delete()
activeDownloads.remove(taskId)
return
}
output.write(buffer, 0, bytes)
bytesWritten += bytes
// Throttle progress updates to every 500ms
val now = System.currentTimeMillis()
if (now - lastProgressUpdate >= 500) {
onProgress(bytesWritten, totalBytes)
lastProgressUpdate = now
}
bytes = input.read(buffer)
}
}
}
// Send final progress update
onProgress(bytesWritten, totalBytes)
Log.d(TAG, "Download completed: taskId=$taskId, bytes=$bytesWritten")
activeDownloads.remove(taskId)
onComplete(destinationPath)
} catch (e: Exception) {
Log.e(TAG, "Error during download: taskId=$taskId, error=${e.message}", e)
activeDownloads.remove(taskId)
// Clean up partial file
try {
File(destinationPath).delete()
} catch (deleteError: Exception) {
Log.e(TAG, "Failed to delete partial file: ${deleteError.message}")
}
if (!call.isCanceled()) {
onError(e.message ?: "Download failed")
}
}
}
})
}
fun cancelDownload(taskId: Int) {
Log.d(TAG, "Cancelling download: taskId=$taskId")
activeDownloads[taskId]?.cancel()
activeDownloads.remove(taskId)
}
fun cancelAllDownloads() {
Log.d(TAG, "Cancelling all downloads")
activeDownloads.values.forEach { it.cancel() }
activeDownloads.clear()
}
fun hasActiveDownloads(): Boolean {
return activeDownloads.isNotEmpty()
}
}

View File

@@ -0,0 +1,98 @@
import type {
DownloadCompleteEvent,
DownloadErrorEvent,
DownloadProgressEvent,
} from "@/modules";
import { BackgroundDownloader } from "@/modules";
export class DownloadManager {
private progressSubscription: any;
private completeSubscription: any;
private errorSubscription: any;
private activeDownloads = new Map<
number,
{ url: string; progress: number }
>();
constructor() {
this.setupListeners();
}
private setupListeners() {
this.progressSubscription = BackgroundDownloader.addProgressListener(
(event: DownloadProgressEvent) => {
const download = this.activeDownloads.get(event.taskId);
if (download) {
download.progress = event.progress;
console.log(
`Download ${event.taskId}: ${Math.floor(event.progress * 100)}%`,
);
}
},
);
this.completeSubscription = BackgroundDownloader.addCompleteListener(
(event: DownloadCompleteEvent) => {
console.log("Download complete:", event.filePath);
this.activeDownloads.delete(event.taskId);
},
);
this.errorSubscription = BackgroundDownloader.addErrorListener(
(event: DownloadErrorEvent) => {
console.error("Download error:", event.error);
this.activeDownloads.delete(event.taskId);
},
);
}
async startDownload(url: string, destinationPath?: string): Promise<number> {
const taskId = await BackgroundDownloader.startDownload(
url,
destinationPath,
);
this.activeDownloads.set(taskId, { url, progress: 0 });
return taskId;
}
cancelDownload(taskId: number): void {
BackgroundDownloader.cancelDownload(taskId);
this.activeDownloads.delete(taskId);
}
cancelAllDownloads(): void {
BackgroundDownloader.cancelAllDownloads();
this.activeDownloads.clear();
}
async getActiveDownloads() {
return await BackgroundDownloader.getActiveDownloads();
}
cleanup(): void {
this.progressSubscription?.remove();
this.completeSubscription?.remove();
this.errorSubscription?.remove();
}
}
const downloadManager = new DownloadManager();
export async function downloadFile(
url: string,
destinationPath?: string,
): Promise<number> {
return await downloadManager.startDownload(url, destinationPath);
}
export function cancelDownload(taskId: number): void {
downloadManager.cancelDownload(taskId);
}
export function cancelAllDownloads(): void {
downloadManager.cancelAllDownloads();
}
export async function getActiveDownloads() {
return await downloadManager.getActiveDownloads();
}

View File

@@ -0,0 +1,12 @@
{
"name": "background-downloader",
"version": "1.0.0",
"platforms": ["ios", "android"],
"ios": {
"modules": ["BackgroundDownloaderModule"],
"appDelegateSubscribers": ["BackgroundDownloaderAppDelegate"]
},
"android": {
"modules": ["expo.modules.backgrounddownloader.BackgroundDownloaderModule"]
}
}

View File

@@ -0,0 +1,109 @@
import type { EventSubscription } from "expo-modules-core";
import type {
ActiveDownload,
DownloadCompleteEvent,
DownloadErrorEvent,
DownloadProgressEvent,
DownloadStartedEvent,
} from "./src/BackgroundDownloader.types";
import BackgroundDownloaderModule from "./src/BackgroundDownloaderModule";
export interface BackgroundDownloader {
startDownload(url: string, destinationPath?: string): Promise<number>;
enqueueDownload(url: string, destinationPath?: string): Promise<number>;
cancelDownload(taskId: number): void;
cancelQueuedDownload(url: string): void;
cancelAllDownloads(): void;
getActiveDownloads(): Promise<ActiveDownload[]>;
addProgressListener(
listener: (event: DownloadProgressEvent) => void,
): EventSubscription;
addCompleteListener(
listener: (event: DownloadCompleteEvent) => void,
): EventSubscription;
addErrorListener(
listener: (event: DownloadErrorEvent) => void,
): EventSubscription;
addStartedListener(
listener: (event: DownloadStartedEvent) => void,
): EventSubscription;
}
const BackgroundDownloader: BackgroundDownloader = {
async startDownload(url: string, destinationPath?: string): Promise<number> {
return await BackgroundDownloaderModule.startDownload(url, destinationPath);
},
async enqueueDownload(
url: string,
destinationPath?: string,
): Promise<number> {
return await BackgroundDownloaderModule.enqueueDownload(
url,
destinationPath,
);
},
cancelDownload(taskId: number): void {
BackgroundDownloaderModule.cancelDownload(taskId);
},
cancelQueuedDownload(url: string): void {
BackgroundDownloaderModule.cancelQueuedDownload(url);
},
cancelAllDownloads(): void {
BackgroundDownloaderModule.cancelAllDownloads();
},
async getActiveDownloads(): Promise<ActiveDownload[]> {
return await BackgroundDownloaderModule.getActiveDownloads();
},
addProgressListener(
listener: (event: DownloadProgressEvent) => void,
): EventSubscription {
return BackgroundDownloaderModule.addListener(
"onDownloadProgress",
listener,
);
},
addCompleteListener(
listener: (event: DownloadCompleteEvent) => void,
): EventSubscription {
return BackgroundDownloaderModule.addListener(
"onDownloadComplete",
listener,
);
},
addErrorListener(
listener: (event: DownloadErrorEvent) => void,
): EventSubscription {
return BackgroundDownloaderModule.addListener("onDownloadError", listener);
},
addStartedListener(
listener: (event: DownloadStartedEvent) => void,
): EventSubscription {
return BackgroundDownloaderModule.addListener(
"onDownloadStarted",
listener,
);
},
};
export default BackgroundDownloader;
export type {
ActiveDownload,
DownloadCompleteEvent,
DownloadErrorEvent,
DownloadProgressEvent,
DownloadStartedEvent,
};

View File

@@ -0,0 +1,21 @@
Pod::Spec.new do |s|
s.name = 'BackgroundDownloader'
s.version = '1.0.0'
s.summary = 'Background file downloader for iOS'
s.description = 'Native iOS module for downloading large files in the background using NSURLSession'
s.author = ''
s.homepage = 'https://docs.expo.dev/modules/'
s.platforms = { :ios => '15.6', :tvos => '15.0' }
s.source = { git: '' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
'SWIFT_COMPILATION_MODE' => 'wholemodule'
}
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
end

View File

@@ -0,0 +1,15 @@
import ExpoModulesCore
import UIKit
public class BackgroundDownloaderAppDelegate: ExpoAppDelegateSubscriber {
public func application(
_ application: UIApplication,
handleEventsForBackgroundURLSession identifier: String,
completionHandler: @escaping () -> Void
) {
if identifier == "com.fredrikburmester.streamyfin.backgrounddownloader" {
BackgroundDownloaderModule.setBackgroundCompletionHandler(completionHandler)
}
}
}

View File

@@ -0,0 +1,397 @@
import ExpoModulesCore
import Foundation
enum DownloadError: Error {
case invalidURL
case fileOperationFailed
case downloadFailed
}
struct DownloadTaskInfo {
let url: String
let destinationPath: String?
}
// Separate delegate class to handle URLSession callbacks
class DownloadSessionDelegate: NSObject, URLSessionDownloadDelegate {
weak var module: BackgroundDownloaderModule?
init(module: BackgroundDownloaderModule) {
self.module = module
super.init()
}
func urlSession(
_ session: URLSession,
downloadTask: URLSessionDownloadTask,
didWriteData bytesWritten: Int64,
totalBytesWritten: Int64,
totalBytesExpectedToWrite: Int64
) {
module?.handleProgress(
taskId: downloadTask.taskIdentifier,
bytesWritten: totalBytesWritten,
totalBytes: totalBytesExpectedToWrite
)
}
func urlSession(
_ session: URLSession,
downloadTask: URLSessionDownloadTask,
didFinishDownloadingTo location: URL
) {
module?.handleDownloadComplete(
taskId: downloadTask.taskIdentifier,
location: location,
downloadTask: downloadTask
)
}
func urlSession(
_ session: URLSession,
task: URLSessionTask,
didCompleteWithError error: Error?
) {
if let error = error {
print("[BackgroundDownloader] Task \(task.taskIdentifier) error: \(error.localizedDescription)")
module?.handleError(taskId: task.taskIdentifier, error: error)
}
}
func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
DispatchQueue.main.async {
if let completion = BackgroundDownloaderModule.backgroundCompletionHandler {
completion()
BackgroundDownloaderModule.backgroundCompletionHandler = nil
}
}
}
}
public class BackgroundDownloaderModule: Module {
private var session: URLSession?
private var sessionDelegate: DownloadSessionDelegate?
fileprivate static var backgroundCompletionHandler: (() -> Void)?
private var downloadTasks: [Int: DownloadTaskInfo] = [:]
private var downloadQueue: [(url: String, destinationPath: String?)] = []
private var lastProgressTime: [Int: Date] = [:]
public func definition() -> ModuleDefinition {
Name("BackgroundDownloader")
Events(
"onDownloadProgress",
"onDownloadComplete",
"onDownloadError",
"onDownloadStarted"
)
OnCreate {
self.initializeSession()
}
AsyncFunction("startDownload") { (urlString: String, destinationPath: String?) -> Int in
guard let url = URL(string: urlString) else {
throw DownloadError.invalidURL
}
if self.session == nil {
self.initializeSession()
}
guard let session = self.session else {
throw DownloadError.downloadFailed
}
// Create a URLRequest to ensure proper handling
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.timeoutInterval = 300
let task = session.downloadTask(with: request)
let taskId = task.taskIdentifier
self.downloadTasks[taskId] = DownloadTaskInfo(
url: urlString,
destinationPath: destinationPath
)
task.resume()
self.sendEvent("onDownloadStarted", [
"taskId": taskId,
"url": urlString
])
return taskId
}
AsyncFunction("enqueueDownload") { (urlString: String, destinationPath: String?) -> Int in
// Add to queue
let wasEmpty = self.downloadQueue.isEmpty
self.downloadQueue.append((url: urlString, destinationPath: destinationPath))
// If queue was empty and no active downloads, start processing immediately
if wasEmpty {
return try await self.processNextInQueue()
}
// Return placeholder taskId for queued items
return -1
}
Function("cancelDownload") { (taskId: Int) in
self.session?.getAllTasks { tasks in
for task in tasks where task.taskIdentifier == taskId {
task.cancel()
self.downloadTasks.removeValue(forKey: taskId)
}
}
}
Function("cancelQueuedDownload") { (url: String) in
// Remove from queue by URL
self.downloadQueue.removeAll { queuedItem in
queuedItem.url == url
}
}
Function("cancelAllDownloads") {
self.session?.getAllTasks { tasks in
for task in tasks {
task.cancel()
}
self.downloadTasks.removeAll()
}
}
AsyncFunction("getActiveDownloads") { () -> [[String: Any]] in
return try await withCheckedThrowingContinuation { continuation in
let downloadTasks = self.downloadTasks
self.session?.getAllTasks { tasks in
let activeDownloads = tasks.compactMap { task -> [String: Any]? in
guard task is URLSessionDownloadTask,
let info = downloadTasks[task.taskIdentifier] else {
return nil
}
return [
"taskId": task.taskIdentifier,
"url": info.url
]
}
continuation.resume(returning: activeDownloads)
}
}
}
}
private func initializeSession() {
print("[BackgroundDownloader] Initializing URLSession")
let config = URLSessionConfiguration.background(
withIdentifier: "com.fredrikburmester.streamyfin.backgrounddownloader"
)
config.allowsCellularAccess = true
config.sessionSendsLaunchEvents = true
config.isDiscretionary = false
self.sessionDelegate = DownloadSessionDelegate(module: self)
self.session = URLSession(
configuration: config,
delegate: self.sessionDelegate,
delegateQueue: nil
)
print("[BackgroundDownloader] URLSession initialized with delegate: \(String(describing: self.sessionDelegate))")
print("[BackgroundDownloader] Session identifier: \(config.identifier ?? "nil")")
print("[BackgroundDownloader] Delegate queue: nil (uses default)")
// Verify delegate is connected
if let session = self.session, session.delegate != nil {
print("[BackgroundDownloader] ✅ Delegate successfully attached to session")
} else {
print("[BackgroundDownloader] ⚠️ DELEGATE NOT ATTACHED!")
}
}
// Handler methods called by the delegate
func handleProgress(taskId: Int, bytesWritten: Int64, totalBytes: Int64) {
let progress = totalBytes > 0
? Double(bytesWritten) / Double(totalBytes)
: 0.0
// Throttle progress updates: only send every 500ms
let lastTime = lastProgressTime[taskId] ?? Date.distantPast
let now = Date()
let timeDiff = now.timeIntervalSince(lastTime)
// Send if 500ms passed
if timeDiff >= 0.5 {
self.sendEvent("onDownloadProgress", [
"taskId": taskId,
"bytesWritten": bytesWritten,
"totalBytes": totalBytes,
"progress": progress
])
lastProgressTime[taskId] = now
}
}
func handleDownloadComplete(taskId: Int, location: URL, downloadTask: URLSessionDownloadTask) {
guard let taskInfo = downloadTasks[taskId] else {
self.sendEvent("onDownloadError", [
"taskId": taskId,
"error": "Download task info not found"
])
return
}
let fileManager = FileManager.default
do {
let destinationURL: URL
if let customPath = taskInfo.destinationPath {
destinationURL = URL(fileURLWithPath: customPath)
} else {
let documentsDir = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
let filename = downloadTask.response?.suggestedFilename
?? downloadTask.originalRequest?.url?.lastPathComponent
?? "download_\(taskId)"
destinationURL = documentsDir.appendingPathComponent(filename)
}
if fileManager.fileExists(atPath: destinationURL.path) {
try fileManager.removeItem(at: destinationURL)
}
let destinationDirectory = destinationURL.deletingLastPathComponent()
if !fileManager.fileExists(atPath: destinationDirectory.path) {
try fileManager.createDirectory(
at: destinationDirectory,
withIntermediateDirectories: true,
attributes: nil
)
}
try fileManager.moveItem(at: location, to: destinationURL)
self.sendEvent("onDownloadComplete", [
"taskId": taskId,
"filePath": destinationURL.path,
"url": taskInfo.url
])
downloadTasks.removeValue(forKey: taskId)
lastProgressTime.removeValue(forKey: taskId)
// Process next item in queue
Task {
do {
_ = try await self.processNextInQueue()
} catch {
print("[BackgroundDownloader] Error processing next: \(error)")
}
}
} catch {
self.sendEvent("onDownloadError", [
"taskId": taskId,
"error": "File operation failed: \(error.localizedDescription)"
])
// Process next item in queue even on error
Task {
do {
_ = try await self.processNextInQueue()
} catch {
print("[BackgroundDownloader] Error processing next: \(error)")
}
}
}
}
func handleError(taskId: Int, error: Error) {
let isCancelled = (error as NSError).code == NSURLErrorCancelled
if !isCancelled {
print("[BackgroundDownloader] Task \(taskId) error: \(error.localizedDescription)")
self.sendEvent("onDownloadError", [
"taskId": taskId,
"error": error.localizedDescription
])
}
downloadTasks.removeValue(forKey: taskId)
lastProgressTime.removeValue(forKey: taskId)
// Process next item in queue (whether cancelled or errored)
Task {
do {
_ = try await self.processNextInQueue()
} catch {
print("[BackgroundDownloader] Error processing next: \(error)")
}
}
}
private func processNextInQueue() async throws -> Int {
// Check if queue has items
guard !downloadQueue.isEmpty else {
return -1
}
// Check if there are active downloads
if !downloadTasks.isEmpty {
return -1
}
// Get next item from queue
let (url, destinationPath) = downloadQueue.removeFirst()
print("[BackgroundDownloader] Starting queued download")
// Start the download using existing startDownload logic
guard let urlObj = URL(string: url) else {
print("[BackgroundDownloader] Invalid URL in queue: \(url)")
return try await processNextInQueue()
}
if session == nil {
initializeSession()
}
guard let session = self.session else {
throw DownloadError.downloadFailed
}
var request = URLRequest(url: urlObj)
request.httpMethod = "GET"
request.timeoutInterval = 300
let task = session.downloadTask(with: request)
let taskId = task.taskIdentifier
downloadTasks[taskId] = DownloadTaskInfo(
url: url,
destinationPath: destinationPath
)
task.resume()
sendEvent("onDownloadStarted", [
"taskId": taskId,
"url": url
])
return taskId
}
static func setBackgroundCompletionHandler(_ handler: @escaping () -> Void) {
BackgroundDownloaderModule.backgroundCompletionHandler = handler
}
}

View File

@@ -0,0 +1,41 @@
import type { EventSubscription } from "expo-modules-core";
export interface DownloadProgressEvent {
taskId: number;
bytesWritten: number;
totalBytes: number;
progress: number;
}
export interface DownloadCompleteEvent {
taskId: number;
filePath: string;
url: string;
}
export interface DownloadErrorEvent {
taskId: number;
error: string;
}
export interface DownloadStartedEvent {
taskId: number;
url: string;
}
export interface ActiveDownload {
taskId: number;
url: string;
state: "running" | "suspended" | "canceling" | "completed" | "unknown";
}
export interface BackgroundDownloaderModuleType {
startDownload(url: string, destinationPath?: string): Promise<number>;
cancelDownload(taskId: number): void;
cancelAllDownloads(): void;
getActiveDownloads(): Promise<ActiveDownload[]>;
addListener(
eventName: string,
listener: (event: any) => void,
): EventSubscription;
}

View File

@@ -0,0 +1,7 @@
import { requireNativeModule } from "expo-modules-core";
import type { BackgroundDownloaderModuleType } from "./BackgroundDownloader.types";
const BackgroundDownloaderModule: BackgroundDownloaderModuleType =
requireNativeModule("BackgroundDownloader");
export default BackgroundDownloaderModule;

View File

@@ -12,6 +12,16 @@ import type {
} from "./VlcPlayer.types"; } from "./VlcPlayer.types";
import VlcPlayerView from "./VlcPlayerView"; import VlcPlayerView from "./VlcPlayerView";
export type {
ActiveDownload,
DownloadCompleteEvent,
DownloadErrorEvent,
DownloadProgressEvent,
DownloadStartedEvent,
} from "./background-downloader";
// Background Downloader
export { default as BackgroundDownloader } from "./background-downloader";
// Component // Component
export { VlcPlayerView }; export { VlcPlayerView };

View File

@@ -212,9 +212,7 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
fun setSource(source: Map<String, Any>) { fun setSource(source: Map<String, Any>) {
log.debug("setting source $source") log.debug("setting source $source")
if (hasSource) { if (hasSource) {
log.debug("Source already set. Resuming") log.debug("Source already set. Ignoring.")
mediaPlayer?.attachViews(videoLayout, null, false, false)
play()
return return
} }
val mediaOptions = source["mediaOptions"] as? Map<String, Any> ?: emptyMap() val mediaOptions = source["mediaOptions"] as? Map<String, Any> ?: emptyMap()

Some files were not shown because too many files have changed in this diff Show More