mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 23:59:08 +00:00
Compare commits
59 Commits
lostb1t-pa
...
feat/new-b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
744c35d71c | ||
|
|
f28d6ca56d | ||
|
|
23406b957d | ||
|
|
ec7954036e | ||
|
|
87716aff92 | ||
|
|
380f5cbf70 | ||
|
|
23c1c817a0 | ||
|
|
3a8fb0a5e5 | ||
|
|
06e19bd7e6 | ||
|
|
ac0f088ee3 | ||
|
|
930c98caec | ||
|
|
5894272149 | ||
|
|
0b39ab0212 | ||
|
|
e905737d5b | ||
|
|
4517fe354b | ||
|
|
d764e5f9d2 | ||
|
|
7fef2ed5e2 | ||
|
|
c36cd66e36 | ||
|
|
1363c3137e | ||
|
|
e55f2462e5 | ||
|
|
c88de0250f | ||
|
|
8d59065c49 | ||
|
|
ec622aba55 | ||
|
|
a39461e09a | ||
|
|
a0725d89a0 | ||
|
|
7e2cfb9790 | ||
|
|
38d1b513d4 | ||
|
|
cc54a3a71b | ||
|
|
6842ae03f9 | ||
|
|
a5ffbd6a4c | ||
|
|
02fa738cfd | ||
|
|
32c01c6f89 | ||
|
|
6fc4c33759 | ||
|
|
49ea64b0fd | ||
|
|
c866b105e7 | ||
|
|
1b42e61310 | ||
|
|
fb032fa973 | ||
|
|
a0a90e48d8 | ||
|
|
ab472bab6e | ||
|
|
8407124464 | ||
|
|
afe57d4c76 | ||
|
|
7a11f4a54b | ||
|
|
47c52e0739 | ||
|
|
e9effd5436 | ||
|
|
6ae655abf2 | ||
|
|
c74a394a6a | ||
|
|
5e6cd6bed6 | ||
|
|
dfb6bd03a9 | ||
|
|
eaf0a9fae4 | ||
|
|
f2bd10b1a6 | ||
|
|
dd03c2038d | ||
|
|
6af9d88a72 | ||
|
|
dfa3c06857 | ||
|
|
b0bb9d10e5 | ||
|
|
5d080664a0 | ||
|
|
cde205e762 | ||
|
|
c335a3269e | ||
|
|
ccf27284f6 | ||
|
|
a11b9f5875 |
5
.cursor/rules/external-terminal.mdc
Normal file
5
.cursor/rules/external-terminal.mdc
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
Don't run the development server or build anything. Assume the user has a separate terminal. Tell the user what to execute.
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -46,4 +46,5 @@ streamyfin-4fec1-firebase-adminsdk.json
|
||||
.env.local
|
||||
*.aab
|
||||
/version-backup-*
|
||||
bun.lockb
|
||||
bun.lockb
|
||||
modules/background-downloader/android/build/*
|
||||
|
||||
232
GLOBAL_MODAL_GUIDE.md
Normal file
232
GLOBAL_MODAL_GUIDE.md
Normal 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
|
||||
@@ -6,9 +6,6 @@ module.exports = ({ config }) => {
|
||||
"react-native-google-cast",
|
||||
{ useDefaultExpandedMediaControls: true },
|
||||
]);
|
||||
|
||||
// Add the background downloader plugin only for non-TV builds
|
||||
config.plugins.push("./plugins/withRNBackgroundDownloader.js");
|
||||
}
|
||||
return {
|
||||
android: {
|
||||
|
||||
5
app.json
5
app.json
@@ -8,6 +8,7 @@
|
||||
"scheme": "streamyfin",
|
||||
"userInterfaceStyle": "dark",
|
||||
"jsEngine": "hermes",
|
||||
"newArchEnabled": true,
|
||||
"assetBundlePatterns": ["**/*"],
|
||||
"ios": {
|
||||
"requireFullScreen": true,
|
||||
@@ -77,6 +78,7 @@
|
||||
"useFrameworks": "static"
|
||||
},
|
||||
"android": {
|
||||
"buildArchs": ["arm64-v8a", "x86_64"],
|
||||
"compileSdkVersion": 35,
|
||||
"targetSdkVersion": 35,
|
||||
"buildToolsVersion": "35.0.0",
|
||||
@@ -154,7 +156,6 @@
|
||||
},
|
||||
"updates": {
|
||||
"url": "https://u.expo.dev/e79219d1-797f-4fbe-9fa1-cfd360690a68"
|
||||
},
|
||||
"newArchEnabled": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ export default function CustomMenuLayout() {
|
||||
<Stack.Screen
|
||||
name='index'
|
||||
options={{
|
||||
headerShown: true,
|
||||
headerShown: Platform.OS !== "ios",
|
||||
headerLargeTitle: true,
|
||||
headerTitle: t("tabs.custom_links"),
|
||||
headerBlurEffect: "none",
|
||||
|
||||
@@ -22,6 +22,11 @@ export default function IndexLayout() {
|
||||
options={{
|
||||
headerShown: !Platform.isTV,
|
||||
headerTitle: t("tabs.home"),
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
@@ -43,48 +48,88 @@ export default function IndexLayout() {
|
||||
name='downloads/index'
|
||||
options={{
|
||||
title: t("home.downloads.downloads_title"),
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='downloads/[seriesId]'
|
||||
options={{
|
||||
title: t("home.downloads.tvseries"),
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='sessions/index'
|
||||
options={{
|
||||
title: t("home.sessions.title"),
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='settings'
|
||||
options={{
|
||||
title: t("home.settings.settings_title"),
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='settings/marlin-search/page'
|
||||
options={{
|
||||
title: "",
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='settings/jellyseerr/page'
|
||||
options={{
|
||||
title: "",
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='settings/hide-libraries/page'
|
||||
options={{
|
||||
title: "",
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='settings/logs/page'
|
||||
options={{
|
||||
title: "",
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
@@ -92,6 +137,11 @@ export default function IndexLayout() {
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
presentation: "modal",
|
||||
}}
|
||||
/>
|
||||
@@ -102,6 +152,11 @@ export default function IndexLayout() {
|
||||
name='collections/[collectionId]'
|
||||
options={{
|
||||
title: "",
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity onPress={() => _router.back()} className='pl-0.5'>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
headerShown: true,
|
||||
headerBlurEffect: "prominent",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
|
||||
@@ -23,12 +23,12 @@ export default function page() {
|
||||
const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>(
|
||||
{},
|
||||
);
|
||||
const { getDownloadedItems, deleteItems } = useDownload();
|
||||
const { downloadedItems, deleteItems } = useDownload();
|
||||
|
||||
const series = useMemo(() => {
|
||||
try {
|
||||
return (
|
||||
getDownloadedItems()
|
||||
downloadedItems
|
||||
?.filter((f) => f.item.SeriesId === seriesId)
|
||||
?.sort(
|
||||
(a, b) => a?.item.ParentIndexNumber! - b.item.ParentIndexNumber!,
|
||||
@@ -37,7 +37,7 @@ export default function page() {
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}, [getDownloadedItems]);
|
||||
}, [downloadedItems, seriesId]);
|
||||
|
||||
// Group episodes by season in a single pass
|
||||
const seasonGroups = useMemo(() => {
|
||||
@@ -91,7 +91,7 @@ export default function page() {
|
||||
title: series[0].item.SeriesName,
|
||||
});
|
||||
} else {
|
||||
storage.delete(seriesId);
|
||||
storage.remove(seriesId);
|
||||
router.back();
|
||||
}
|
||||
}, [series]);
|
||||
@@ -107,7 +107,12 @@ export default function page() {
|
||||
},
|
||||
{
|
||||
text: "Delete",
|
||||
onPress: () => deleteItems(groupBySeason),
|
||||
onPress: () =>
|
||||
deleteItems(
|
||||
groupBySeason
|
||||
.map((episode) => episode.Id!)
|
||||
.filter((id) => id !== undefined),
|
||||
),
|
||||
style: "destructive",
|
||||
},
|
||||
],
|
||||
@@ -140,7 +145,7 @@ export default function page() {
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
<ScrollView key={seasonIndex} className='px-4'>
|
||||
<ScrollView key={seasonIndex} style={{ paddingHorizontal: 16 }}>
|
||||
{groupBySeason.map((episode, index) => (
|
||||
<EpisodeCard key={index} item={episode} />
|
||||
))}
|
||||
|
||||
@@ -27,12 +27,8 @@ export default function page() {
|
||||
const navigation = useNavigation();
|
||||
const { t } = useTranslation();
|
||||
const [queue, setQueue] = useAtom(queueAtom);
|
||||
const {
|
||||
removeProcess,
|
||||
getDownloadedItems,
|
||||
deleteFileByType,
|
||||
deleteAllFiles,
|
||||
} = useDownload();
|
||||
const { removeProcess, downloadedItems, deleteFileByType, deleteAllFiles } =
|
||||
useDownload();
|
||||
const router = useRouter();
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
|
||||
@@ -62,7 +58,7 @@ export default function page() {
|
||||
);
|
||||
};
|
||||
|
||||
const downloadedFiles = getDownloadedItems();
|
||||
const downloadedFiles = downloadedItems;
|
||||
|
||||
const movies = useMemo(() => {
|
||||
try {
|
||||
@@ -106,7 +102,10 @@ export default function page() {
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerRight: () => (
|
||||
<TouchableOpacity onPress={bottomSheetModalRef.current?.present}>
|
||||
<TouchableOpacity
|
||||
onPress={bottomSheetModalRef.current?.present}
|
||||
className='px-2'
|
||||
>
|
||||
<DownloadSize items={downloadedFiles?.map((f) => f.item) || []} />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
@@ -143,23 +142,25 @@ export default function page() {
|
||||
});
|
||||
const deleteOtherMedia = () =>
|
||||
Promise.all(
|
||||
otherMedia.map((item) =>
|
||||
deleteFileByType(item.item.Type)
|
||||
.then(() =>
|
||||
toast.success(
|
||||
t("home.downloads.toasts.deleted_media_successfully", {
|
||||
type: item.item.Type,
|
||||
}),
|
||||
),
|
||||
)
|
||||
.catch((reason) => {
|
||||
writeToLog("ERROR", reason);
|
||||
toast.error(
|
||||
t("home.downloads.toasts.failed_to_delete_media", {
|
||||
type: item.item.Type,
|
||||
}),
|
||||
);
|
||||
}),
|
||||
otherMedia.map(
|
||||
(item) =>
|
||||
item.item.Type &&
|
||||
deleteFileByType(item.item.Type)
|
||||
.then(() =>
|
||||
toast.success(
|
||||
t("home.downloads.toasts.deleted_media_successfully", {
|
||||
type: item.item.Type,
|
||||
}),
|
||||
),
|
||||
)
|
||||
.catch((reason) => {
|
||||
writeToLog("ERROR", reason);
|
||||
toast.error(
|
||||
t("home.downloads.toasts.failed_to_delete_media", {
|
||||
type: item.item.Type,
|
||||
}),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -171,7 +172,7 @@ export default function page() {
|
||||
<View style={{ flex: 1 }}>
|
||||
<ScrollView showsVerticalScrollIndicator={false} className='flex-1'>
|
||||
<View className='py-4'>
|
||||
<View className='mb-4 flex flex-col space-y-4 px-4'>
|
||||
<View className='mb-4 flex flex-col gap-y-4 px-4'>
|
||||
<View className='bg-neutral-900 p-4 rounded-2xl'>
|
||||
<Text className='text-lg font-bold'>
|
||||
{t("home.downloads.queue")}
|
||||
@@ -179,7 +180,7 @@ export default function page() {
|
||||
<Text className='text-xs opacity-70 text-red-600'>
|
||||
{t("home.downloads.queue_hint")}
|
||||
</Text>
|
||||
<View className='flex flex-col space-y-2 mt-2'>
|
||||
<View className='flex flex-col gap-y-2 mt-2'>
|
||||
{queue.map((q, index) => (
|
||||
<TouchableOpacity
|
||||
onPress={() =>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { HomeIndex } from "@/components/settings/HomeIndex";
|
||||
import { HomeIndex } from "@/components/home/HomeIndex";
|
||||
|
||||
export default function page() {
|
||||
return <HomeIndex />;
|
||||
|
||||
@@ -258,7 +258,7 @@ const SessionCard = ({ session }: SessionCardProps) => {
|
||||
</View>
|
||||
|
||||
{/* Session controls */}
|
||||
<View className='flex flex-row mt-2 space-x-4 justify-center'>
|
||||
<View className='flex flex-row mt-2 gap-x-4 justify-center'>
|
||||
<TouchableOpacity
|
||||
onPress={handlePrevious}
|
||||
disabled={isControlLoading[PlaystateCommand.PreviousTrack]}
|
||||
|
||||
@@ -46,7 +46,7 @@ export default function settings() {
|
||||
logout();
|
||||
}}
|
||||
>
|
||||
<Text className='text-red-600'>
|
||||
<Text className='text-red-600 px-2'>
|
||||
{t("home.settings.log_out_button")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import { useNavigation } from "expo-router";
|
||||
import * as Sharing from "expo-sharing";
|
||||
import { useCallback, useEffect, useId, useMemo, useState } from "react";
|
||||
|
||||
@@ -393,7 +393,6 @@ const page: React.FC = () => {
|
||||
data={flatData}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemSize={255}
|
||||
numColumns={
|
||||
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5
|
||||
}
|
||||
|
||||
@@ -19,31 +19,29 @@ import { Text } from "@/components/common/Text";
|
||||
import { GenreTags } from "@/components/GenreTags";
|
||||
import Cast from "@/components/jellyseerr/Cast";
|
||||
import DetailFacts from "@/components/jellyseerr/DetailFacts";
|
||||
import RequestModal from "@/components/jellyseerr/RequestModal";
|
||||
import { OverviewText } from "@/components/OverviewText";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||
import { JellyserrRatings } from "@/components/Ratings";
|
||||
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
|
||||
import { ItemActions } from "@/components/series/SeriesActions";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
|
||||
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
|
||||
import {
|
||||
type IssueType,
|
||||
IssueTypeName,
|
||||
} from "@/utils/jellyseerr/server/constants/issue";
|
||||
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 {
|
||||
MovieResult,
|
||||
TvResult,
|
||||
} from "@/utils/jellyseerr/server/models/Search";
|
||||
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 insets = useSafeAreaInsets();
|
||||
const params = useLocalSearchParams();
|
||||
@@ -156,6 +154,24 @@ const Page: React.FC = () => {
|
||||
[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(() => {
|
||||
if (details) {
|
||||
navigation.setOptions({
|
||||
@@ -216,7 +232,7 @@ const Page: React.FC = () => {
|
||||
}
|
||||
>
|
||||
<View className='flex flex-col'>
|
||||
<View className='space-y-4'>
|
||||
<View className='gap-y-4'>
|
||||
<View className='px-4'>
|
||||
<View className='flex flex-row justify-between w-full'>
|
||||
<View className='flex flex-col w-56'>
|
||||
@@ -364,50 +380,23 @@ const Page: React.FC = () => {
|
||||
</Text>
|
||||
</View>
|
||||
<View className='flex flex-col space-y-2 items-start'>
|
||||
<View className='flex flex-col'>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className='flex flex-col'>
|
||||
<Text className='opacity-50 mb-1 text-xs'>
|
||||
{t("jellyseerr.issue_type")}
|
||||
<View className='flex flex-col w-full'>
|
||||
<Text className='opacity-50 mb-1 text-xs'>
|
||||
{t("jellyseerr.issue_type")}
|
||||
</Text>
|
||||
<PlatformDropdown
|
||||
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>
|
||||
<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>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={false}
|
||||
side='bottom'
|
||||
align='center'
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={0}
|
||||
sideOffset={0}
|
||||
>
|
||||
<DropdownMenu.Label>
|
||||
{t("jellyseerr.types")}
|
||||
</DropdownMenu.Label>
|
||||
{Object.entries(IssueTypeName)
|
||||
.reverse()
|
||||
.map(([key, value], _idx) => (
|
||||
<DropdownMenu.Item
|
||||
key={value}
|
||||
onSelect={() =>
|
||||
setIssueType(key as unknown as IssueType)
|
||||
}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{value}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
}
|
||||
title={t("jellyseerr.types")}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full'>
|
||||
|
||||
@@ -105,7 +105,7 @@ const page: React.FC = () => {
|
||||
/>
|
||||
}
|
||||
>
|
||||
<View className='flex flex-col space-y-4 my-4'>
|
||||
<View className='flex flex-col gap-y-4 my-4'>
|
||||
<View className='px-4 mb-4'>
|
||||
<MoviesTitleHeader item={item} className='mb-4' />
|
||||
<OverviewText text={item.Overview} />
|
||||
|
||||
@@ -1,85 +1,164 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Stack } from "expo-router";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, TouchableOpacity } from "react-native";
|
||||
import { LibraryOptionsSheet } from "@/components/settings/LibraryOptionsSheet";
|
||||
import { Platform } from "react-native";
|
||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function IndexLayout() {
|
||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||
const [optionsSheetOpen, setOptionsSheetOpen] = useState(false);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!settings?.libraryOptions) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
name='index'
|
||||
options={{
|
||||
headerShown: !Platform.isTV,
|
||||
headerTitle: t("tabs.library"),
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerRight: () =>
|
||||
!pluginSettings?.libraryOptions?.locked &&
|
||||
!Platform.isTV && (
|
||||
<TouchableOpacity
|
||||
onPress={() => setOptionsSheetOpen(true)}
|
||||
className='flex flex-row items-center justify-center w-9 h-9'
|
||||
>
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
name='index'
|
||||
options={{
|
||||
headerShown: !Platform.isTV,
|
||||
headerTitle: t("tabs.library"),
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerRight: () =>
|
||||
!pluginSettings?.libraryOptions?.locked &&
|
||||
!Platform.isTV && (
|
||||
<PlatformDropdown
|
||||
trigger={
|
||||
<Ionicons
|
||||
name='ellipsis-horizontal-outline'
|
||||
size={24}
|
||||
color='white'
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<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>
|
||||
<LibraryOptionsSheet
|
||||
open={optionsSheetOpen}
|
||||
setOpen={setOptionsSheetOpen}
|
||||
settings={settings.libraryOptions}
|
||||
updateSettings={(options) =>
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
...options,
|
||||
},
|
||||
})
|
||||
}
|
||||
disabled={pluginSettings?.libraryOptions?.locked}
|
||||
}
|
||||
title={t("library.options.display")}
|
||||
groups={[
|
||||
{
|
||||
title: t("library.options.display"),
|
||||
options: [
|
||||
{
|
||||
type: "radio",
|
||||
label: t("library.options.row"),
|
||||
value: "row",
|
||||
selected: settings.libraryOptions.display === "row",
|
||||
onPress: () =>
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
display: "row",
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: "radio",
|
||||
label: t("library.options.list"),
|
||||
value: "list",
|
||||
selected: settings.libraryOptions.display === "list",
|
||||
onPress: () =>
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
display: "list",
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t("library.options.image_style"),
|
||||
options: [
|
||||
{
|
||||
type: "radio",
|
||||
label: t("library.options.poster"),
|
||||
value: "poster",
|
||||
selected:
|
||||
settings.libraryOptions.imageStyle === "poster",
|
||||
onPress: () =>
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
imageStyle: "poster",
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: "radio",
|
||||
label: t("library.options.cover"),
|
||||
value: "cover",
|
||||
selected:
|
||||
settings.libraryOptions.imageStyle === "cover",
|
||||
onPress: () =>
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
imageStyle: "cover",
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Options",
|
||||
options: [
|
||||
{
|
||||
type: "toggle",
|
||||
label: t("library.options.show_titles"),
|
||||
value: settings.libraryOptions.showTitles,
|
||||
onToggle: () =>
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
showTitles: !settings.libraryOptions.showTitles,
|
||||
},
|
||||
}),
|
||||
disabled:
|
||||
settings.libraryOptions.imageStyle === "poster",
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
label: t("library.options.show_stats"),
|
||||
value: settings.libraryOptions.showStats,
|
||||
onToggle: () =>
|
||||
updateSettings({
|
||||
libraryOptions: {
|
||||
...settings.libraryOptions,
|
||||
showStats: !settings.libraryOptions.showStats,
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
<Stack.Screen
|
||||
name='[libraryId]'
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: !Platform.isTV,
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||
<Stack.Screen key={name} name={name} options={options} />
|
||||
))}
|
||||
<Stack.Screen
|
||||
name='collections/[collectionId]'
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: !Platform.isTV,
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -87,8 +87,8 @@ export default function index() {
|
||||
paddingTop: 17,
|
||||
paddingHorizontal: settings?.libraryOptions?.display === "row" ? 0 : 17,
|
||||
paddingBottom: 150,
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
paddingLeft: insets.left + 17,
|
||||
paddingRight: insets.right + 17,
|
||||
}}
|
||||
data={libraries}
|
||||
renderItem={({ item }) => <LibraryItemCard library={item} />}
|
||||
@@ -105,7 +105,6 @@ export default function index() {
|
||||
<View className='h-4' />
|
||||
)
|
||||
}
|
||||
estimatedItemSize={200}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,8 +24,6 @@ import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||
import { Input } from "@/components/common/Input";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import { FilterButton } from "@/components/filters/FilterButton";
|
||||
import { Tag } from "@/components/GenreTags";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import {
|
||||
JellyseerrSearchSort,
|
||||
@@ -33,8 +31,10 @@ import {
|
||||
} from "@/components/jellyseerr/JellyseerrIndexPage";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import SeriesPoster from "@/components/posters/SeriesPoster";
|
||||
import { DiscoverFilters } from "@/components/search/DiscoverFilters";
|
||||
import { LoadingSkeleton } from "@/components/search/LoadingSkeleton";
|
||||
import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
|
||||
import { SearchTabButtons } from "@/components/search/SearchTabButtons";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
@@ -282,69 +282,29 @@ export default function search() {
|
||||
maxLength={500}
|
||||
/>
|
||||
)}
|
||||
<View
|
||||
className='flex flex-col'
|
||||
style={{
|
||||
marginTop: Platform.OS === "android" ? 16 : 0,
|
||||
}}
|
||||
>
|
||||
<View className='flex flex-col'>
|
||||
{jellyseerrApi && (
|
||||
<ScrollView
|
||||
horizontal
|
||||
className='flex flex-row flex-wrap space-x-2 px-4 mb-2'
|
||||
>
|
||||
<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 className='pl-4 pr-4 pt-2 flex flex-row'>
|
||||
<SearchTabButtons
|
||||
searchType={searchType}
|
||||
setSearchType={setSearchType}
|
||||
t={t}
|
||||
/>
|
||||
{searchType === "Discover" &&
|
||||
!loading &&
|
||||
noResults &&
|
||||
debouncedSearch.length > 0 && (
|
||||
<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>
|
||||
<DiscoverFilters
|
||||
searchFilterId={searchFilterId}
|
||||
orderFilterId={orderFilterId}
|
||||
jellyseerrOrderBy={jellyseerrOrderBy}
|
||||
setJellyseerrOrderBy={setJellyseerrOrderBy}
|
||||
jellyseerrSortOrder={jellyseerrSortOrder}
|
||||
setJellyseerrSortOrder={setJellyseerrSortOrder}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className='mt-2'>
|
||||
|
||||
@@ -75,7 +75,10 @@ export default function page() {
|
||||
: require("react-native-volume-manager");
|
||||
|
||||
const downloadUtils = useDownload();
|
||||
const downloadedFiles = downloadUtils.getDownloadedItems();
|
||||
const downloadedFiles = useMemo(
|
||||
() => downloadUtils.getDownloadedItems(),
|
||||
[downloadUtils.getDownloadedItems],
|
||||
);
|
||||
|
||||
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
||||
|
||||
|
||||
142
app/_layout.tsx
142
app/_layout.tsx
@@ -1,9 +1,16 @@
|
||||
import "@/augmentations";
|
||||
import { ActionSheetProvider } from "@expo/react-native-action-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 { Paths } from "expo-file-system";
|
||||
import { Platform } from "react-native";
|
||||
import { GlobalModal } from "@/components/GlobalModal";
|
||||
import i18n from "@/i18n";
|
||||
import { DownloadProvider } from "@/providers/DownloadProvider";
|
||||
import { GlobalModalProvider } from "@/providers/GlobalModalProvider";
|
||||
import {
|
||||
apiAtom,
|
||||
getOrSetDeviceId,
|
||||
@@ -26,18 +33,6 @@ import {
|
||||
} from "@/utils/log";
|
||||
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;
|
||||
|
||||
import { getLocales } from "expo-localization";
|
||||
@@ -48,7 +43,7 @@ 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 { Appearance } 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";
|
||||
@@ -64,6 +59,7 @@ import { useAtom } from "jotai";
|
||||
import { Toaster } from "sonner-native";
|
||||
import { userAtom } from "@/providers/JellyfinProvider";
|
||||
import { store } from "@/utils/store";
|
||||
import "../global.css";
|
||||
|
||||
if (!Platform.isTV) {
|
||||
Notifications.setNotificationHandler({
|
||||
@@ -143,7 +139,7 @@ if (!Platform.isTV) {
|
||||
|
||||
const token = getTokenFromStorage();
|
||||
const deviceId = getOrSetDeviceId();
|
||||
const baseDirectory = FileSystem.documentDirectory;
|
||||
const baseDirectory = Paths.document.uri;
|
||||
|
||||
if (!token || !deviceId || !baseDirectory)
|
||||
return BackgroundTask.BackgroundTaskResult.Failed;
|
||||
@@ -226,7 +222,6 @@ function Layout() {
|
||||
const { settings } = useSettings();
|
||||
const [user] = useAtom(userAtom);
|
||||
const [api] = useAtom(apiAtom);
|
||||
const appState = useRef(AppState.currentState);
|
||||
const segments = useSegments();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -385,32 +380,6 @@ function Layout() {
|
||||
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 (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<JellyfinProvider>
|
||||
@@ -418,52 +387,55 @@ function Layout() {
|
||||
<LogProvider>
|
||||
<WebSocketProvider>
|
||||
<DownloadProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<SystemBars style='light' hidden={false} />
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<Stack initialRouteName='(auth)/(tabs)'>
|
||||
<Stack.Screen
|
||||
name='(auth)/(tabs)'
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
header: () => null,
|
||||
<GlobalModalProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<SystemBars style='light' hidden={false} />
|
||||
<Stack initialRouteName='(auth)/(tabs)'>
|
||||
<Stack.Screen
|
||||
name='(auth)/(tabs)'
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
header: () => null,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='(auth)/player'
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
header: () => null,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='login'
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: "",
|
||||
headerTransparent: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name='+not-found' />
|
||||
</Stack>
|
||||
<Toaster
|
||||
duration={4000}
|
||||
toastOptions={{
|
||||
style: {
|
||||
backgroundColor: "#262626",
|
||||
borderColor: "#363639",
|
||||
borderWidth: 1,
|
||||
},
|
||||
titleStyle: {
|
||||
color: "white",
|
||||
},
|
||||
}}
|
||||
closeButton
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='(auth)/player'
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
header: () => null,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='login'
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: "",
|
||||
headerTransparent: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name='+not-found' />
|
||||
</Stack>
|
||||
<Toaster
|
||||
duration={4000}
|
||||
toastOptions={{
|
||||
style: {
|
||||
backgroundColor: "#262626",
|
||||
borderColor: "#363639",
|
||||
borderWidth: 1,
|
||||
},
|
||||
titleStyle: {
|
||||
color: "white",
|
||||
},
|
||||
}}
|
||||
closeButton
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</BottomSheetModalProvider>
|
||||
<GlobalModal />
|
||||
</ThemeProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</GlobalModalProvider>
|
||||
</DownloadProvider>
|
||||
</WebSocketProvider>
|
||||
</LogProvider>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Image } from "expo-image";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { t } from "i18next";
|
||||
import { useAtomValue } from "jotai";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
@@ -82,10 +81,10 @@ const Login: React.FC = () => {
|
||||
onPress={() => {
|
||||
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} />
|
||||
<Text className='ml-2 text-purple-600'>
|
||||
<Text className=' ml-1 text-purple-600'>
|
||||
{t("login.change_server")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
@@ -376,7 +375,7 @@ const Login: React.FC = () => {
|
||||
{api?.basePath ? (
|
||||
<View className='flex flex-col h-full relative items-center justify-center'>
|
||||
<View className='px-4 -mt-20 w-full'>
|
||||
<View className='flex flex-col space-y-2'>
|
||||
<View className='flex flex-col gap-y-2'>
|
||||
<Text className='text-2xl font-bold -mb-2'>
|
||||
{serverName ? (
|
||||
<>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MMKV } from "react-native-mmkv";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
|
||||
declare module "react-native-mmkv" {
|
||||
interface MMKV {
|
||||
@@ -9,7 +9,7 @@ declare module "react-native-mmkv" {
|
||||
|
||||
// Add the augmentation methods directly to the MMKV prototype
|
||||
// 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 {
|
||||
const serializedItem = this.getString(key);
|
||||
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 {
|
||||
if (value === undefined) {
|
||||
this.delete(key);
|
||||
this.remove(key);
|
||||
} else {
|
||||
this.set(key, JSON.stringify(value));
|
||||
}
|
||||
|
||||
@@ -2,6 +2,6 @@ module.exports = (api) => {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ["babel-preset-expo"],
|
||||
plugins: ["nativewind/babel", "react-native-reanimated/plugin"],
|
||||
plugins: ["react-native-worklets/plugin"],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useMemo } from "react";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
|
||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { Text } from "./common/Text";
|
||||
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof View> {
|
||||
source?: MediaSourceInfo;
|
||||
@@ -20,6 +18,8 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
||||
...props
|
||||
}) => {
|
||||
const isTv = Platform.isTV;
|
||||
const [open, setOpen] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const audioStreams = useMemo(
|
||||
() => source?.MediaStreams?.filter((x) => x.Type === "Audio"),
|
||||
@@ -31,55 +31,58 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
||||
[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;
|
||||
|
||||
return (
|
||||
<View
|
||||
className='flex shrink'
|
||||
style={{
|
||||
minWidth: 50,
|
||||
<PlatformDropdown
|
||||
groups={optionGroups}
|
||||
trigger={trigger}
|
||||
title={t("item_card.audio")}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
onOptionSelect={handleOptionSelect}
|
||||
expoUIConfig={{
|
||||
hostStyle: { flex: 1 },
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Root>
|
||||
<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>
|
||||
bottomSheetConfig={{
|
||||
enablePanDownToClose: true,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
|
||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { Text } from "./common/Text";
|
||||
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||
|
||||
export type Bitrate = {
|
||||
key: string;
|
||||
@@ -61,6 +59,8 @@ export const BitrateSelector: React.FC<Props> = ({
|
||||
...props
|
||||
}) => {
|
||||
const isTv = Platform.isTV;
|
||||
const [open, setOpen] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
if (inverted)
|
||||
@@ -76,53 +76,59 @@ export const BitrateSelector: React.FC<Props> = ({
|
||||
);
|
||||
}, [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;
|
||||
|
||||
return (
|
||||
<View
|
||||
className='flex shrink'
|
||||
style={{
|
||||
minWidth: 60,
|
||||
maxWidth: 200,
|
||||
<PlatformDropdown
|
||||
groups={optionGroups}
|
||||
trigger={trigger}
|
||||
title={t("item_card.quality")}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
onOptionSelect={handleOptionSelect}
|
||||
expoUIConfig={{
|
||||
hostStyle: { flex: 1 },
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Root>
|
||||
<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>
|
||||
bottomSheetConfig={{
|
||||
enablePanDownToClose: true,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -69,27 +69,28 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
||||
return (
|
||||
<View
|
||||
className={`
|
||||
relative w-44 aspect-video rounded-lg overflow-hidden border border-neutral-800
|
||||
relative w-44 aspect-video rounded-xl overflow-hidden border border-neutral-800
|
||||
${size === "small" ? "w-32" : "w-44"}
|
||||
`}
|
||||
>
|
||||
<View className='w-full h-full flex items-center justify-center'>
|
||||
<Image
|
||||
key={item.Id}
|
||||
id={item.Id}
|
||||
source={{
|
||||
uri: url,
|
||||
}}
|
||||
cachePolicy={"memory-disk"}
|
||||
contentFit='cover'
|
||||
className='w-full h-full'
|
||||
/>
|
||||
{showPlayButton && (
|
||||
<View className='absolute inset-0 flex items-center justify-center'>
|
||||
<Ionicons name='play-circle' size={40} color='white' />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Image
|
||||
key={item.Id}
|
||||
id={item.Id}
|
||||
source={{
|
||||
uri: url,
|
||||
}}
|
||||
cachePolicy={"memory-disk"}
|
||||
contentFit='cover'
|
||||
style={{
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
}}
|
||||
/>
|
||||
{showPlayButton && (
|
||||
<View className='absolute inset-0 flex items-center justify-center'>
|
||||
<Ionicons name='play-circle' size={40} color='white' />
|
||||
</View>
|
||||
)}
|
||||
{!item.UserData?.Played && <WatchedIndicator item={item} />}
|
||||
<ProgressBar item={item} />
|
||||
</View>
|
||||
|
||||
@@ -64,9 +64,8 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
const { settings } = useSettings();
|
||||
const [downloadUnwatchedOnly, setDownloadUnwatchedOnly] = useState(false);
|
||||
|
||||
const { processes, startBackgroundDownload, getDownloadedItems } =
|
||||
useDownload();
|
||||
const downloadedFiles = getDownloadedItems();
|
||||
const { processes, startBackgroundDownload, downloadedItems } = useDownload();
|
||||
const downloadedFiles = downloadedItems;
|
||||
|
||||
const [selectedOptions, setSelectedOptions] = useState<
|
||||
SelectedOptions | undefined
|
||||
@@ -359,16 +358,18 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
})}
|
||||
</Text>
|
||||
</View>
|
||||
<View className='flex flex-col space-y-2 w-full items-start'>
|
||||
<BitrateSelector
|
||||
inverted
|
||||
onChange={(val) =>
|
||||
setSelectedOptions(
|
||||
(prev) => prev && { ...prev, bitrate: val },
|
||||
)
|
||||
}
|
||||
selected={selectedOptions?.bitrate}
|
||||
/>
|
||||
<View className='flex flex-col space-y-2 w-full'>
|
||||
<View className='items-start'>
|
||||
<BitrateSelector
|
||||
inverted
|
||||
onChange={(val) =>
|
||||
setSelectedOptions(
|
||||
(prev) => prev && { ...prev, bitrate: val },
|
||||
)
|
||||
}
|
||||
selected={selectedOptions?.bitrate}
|
||||
/>
|
||||
</View>
|
||||
{itemsNotDownloaded.length > 1 && (
|
||||
<View className='flex flex-row items-center justify-between w-full py-2'>
|
||||
<Text>{t("item_card.download.download_unwatched_only")}</Text>
|
||||
@@ -380,21 +381,23 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
)}
|
||||
{itemsNotDownloaded.length === 1 && (
|
||||
<View>
|
||||
<MediaSourceSelector
|
||||
item={items[0]}
|
||||
onChange={(val) =>
|
||||
setSelectedOptions(
|
||||
(prev) =>
|
||||
prev && {
|
||||
...prev,
|
||||
mediaSource: val,
|
||||
},
|
||||
)
|
||||
}
|
||||
selected={selectedOptions?.mediaSource}
|
||||
/>
|
||||
<View className='items-start'>
|
||||
<MediaSourceSelector
|
||||
item={items[0]}
|
||||
onChange={(val) =>
|
||||
setSelectedOptions(
|
||||
(prev) =>
|
||||
prev && {
|
||||
...prev,
|
||||
mediaSource: val,
|
||||
},
|
||||
)
|
||||
}
|
||||
selected={selectedOptions?.mediaSource}
|
||||
/>
|
||||
</View>
|
||||
{selectedOptions?.mediaSource && (
|
||||
<View className='flex flex-col space-y-2'>
|
||||
<View className='flex flex-col space-y-2 items-start'>
|
||||
<AudioTrackSelector
|
||||
source={selectedOptions.mediaSource}
|
||||
onChange={(val) => {
|
||||
@@ -427,11 +430,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Button
|
||||
className='mt-auto'
|
||||
onPress={acceptDownloadOptions}
|
||||
color='purple'
|
||||
>
|
||||
<Button onPress={acceptDownloadOptions} color='purple'>
|
||||
{t("item_card.download.download_button")}
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
203
components/ExampleGlobalModalUsage.tsx
Normal file
203
components/ExampleGlobalModalUsage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -22,9 +22,7 @@ export const Tag: React.FC<
|
||||
> = ({ text, textClass, textStyle, ...props }) => {
|
||||
return (
|
||||
<View className='bg-neutral-800 rounded-full px-2 py-1' {...props}>
|
||||
<Text className={textClass} style={textStyle}>
|
||||
{text}
|
||||
</Text>
|
||||
<Text className='text-white'>{text}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
71
components/GlobalModal.tsx
Normal file
71
components/GlobalModal.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
type BottomSheetBackdropProps,
|
||||
BottomSheetModal,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { useCallback } from "react";
|
||||
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||
|
||||
/**
|
||||
* GlobalModal Component
|
||||
*
|
||||
* This component renders a global bottom sheet modal that can be controlled
|
||||
* from anywhere in the app using the useGlobalModal hook.
|
||||
*
|
||||
* Place this component at the root level of your app (in _layout.tsx)
|
||||
* after BottomSheetModalProvider.
|
||||
*/
|
||||
export const GlobalModal = () => {
|
||||
const { hideModal, modalState, modalRef } = useGlobalModal();
|
||||
|
||||
const handleSheetChanges = useCallback(
|
||||
(index: number) => {
|
||||
if (index === -1) {
|
||||
hideModal();
|
||||
}
|
||||
},
|
||||
[hideModal],
|
||||
);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: BottomSheetBackdropProps) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
/>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const defaultOptions = {
|
||||
enableDynamicSizing: true,
|
||||
enablePanDownToClose: true,
|
||||
backgroundStyle: {
|
||||
backgroundColor: "#171717",
|
||||
},
|
||||
handleIndicatorStyle: {
|
||||
backgroundColor: "white",
|
||||
},
|
||||
};
|
||||
|
||||
// Merge default options with provided options
|
||||
const modalOptions = { ...defaultOptions, ...modalState.options };
|
||||
|
||||
return (
|
||||
<BottomSheetModal
|
||||
ref={modalRef}
|
||||
{...(modalOptions.snapPoints
|
||||
? { snapPoints: modalOptions.snapPoints }
|
||||
: { enableDynamicSizing: modalOptions.enableDynamicSizing })}
|
||||
onChange={handleSheetChanges}
|
||||
backdropComponent={renderBackdrop}
|
||||
handleIndicatorStyle={modalOptions.handleIndicatorStyle}
|
||||
backgroundStyle={modalOptions.backgroundStyle}
|
||||
enablePanDownToClose={modalOptions.enablePanDownToClose}
|
||||
enableDismissOnClose
|
||||
>
|
||||
{modalState.content}
|
||||
</BottomSheetModal>
|
||||
);
|
||||
};
|
||||
@@ -201,10 +201,10 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
}
|
||||
>
|
||||
<View className='flex flex-col bg-transparent shrink'>
|
||||
<View className='flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink'>
|
||||
<View className='flex flex-col px-4 w-full pt-2 mb-2 shrink'>
|
||||
<ItemHeader item={item} className='mb-2' />
|
||||
{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
|
||||
className='mr-1'
|
||||
onChange={(val) =>
|
||||
@@ -287,21 +287,21 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
{item.Type !== "Program" && (
|
||||
<>
|
||||
{item.Type === "Episode" && !isOffline && (
|
||||
<CurrentSeries item={item} className='mb-4' />
|
||||
<CurrentSeries item={item} className='' />
|
||||
)}
|
||||
|
||||
{!isOffline && (
|
||||
<CastAndCrew item={item} className='mb-4' loading={loading} />
|
||||
<CastAndCrew item={item} className='' loading={loading} />
|
||||
)}
|
||||
|
||||
{item.People && item.People.length > 0 && !isOffline && (
|
||||
<View className='mb-4'>
|
||||
<View className=''>
|
||||
{item.People.slice(0, 3).map((person, idx) => (
|
||||
<MoreMoviesWithActor
|
||||
currentItem={item}
|
||||
key={idx}
|
||||
actorId={person.Id!}
|
||||
className='mb-4'
|
||||
className=''
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
|
||||
@@ -29,7 +29,7 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source }) => {
|
||||
<View className='px-4 mt-2 mb-4'>
|
||||
<Text className='text-lg font-bold mb-4'>{t("item_card.video")}</Text>
|
||||
<TouchableOpacity onPress={() => bottomSheetModalRef.current?.present()}>
|
||||
<View className='flex flex-row space-x-2'>
|
||||
<View className='flex flex-row mb-2'>
|
||||
<VideoStreamInfo source={source} />
|
||||
</View>
|
||||
<Text className='text-purple-600'>{t("item_card.more_details")}</Text>
|
||||
|
||||
@@ -2,13 +2,11 @@ import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
|
||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { Text } from "./common/Text";
|
||||
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof View> {
|
||||
item: BaseItemDto;
|
||||
@@ -23,7 +21,7 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
||||
...props
|
||||
}) => {
|
||||
const isTv = Platform.isTV;
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getDisplayName = useCallback((source: MediaSourceInfo) => {
|
||||
@@ -46,50 +44,60 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
||||
return getDisplayName(selected);
|
||||
}, [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;
|
||||
|
||||
return (
|
||||
<View
|
||||
className='flex shrink'
|
||||
style={{
|
||||
minWidth: 50,
|
||||
<PlatformDropdown
|
||||
groups={optionGroups}
|
||||
trigger={trigger}
|
||||
title={t("item_card.video")}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
onOptionSelect={handleOptionSelect}
|
||||
expoUIConfig={{
|
||||
hostStyle: { flex: 1 },
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Root>
|
||||
<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>
|
||||
bottomSheetConfig={{
|
||||
enablePanDownToClose: true,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -82,7 +82,7 @@ export const MoreMoviesWithActor: React.FC<Props> = ({
|
||||
<HorizontalScroll
|
||||
data={items}
|
||||
loading={isLoading}
|
||||
height={247}
|
||||
height={218}
|
||||
renderItem={(item: BaseItemDto, idx: number) => (
|
||||
<TouchableItemRouter
|
||||
key={idx}
|
||||
|
||||
323
components/PlatformDropdown.tsx
Normal file
323
components/PlatformDropdown.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
import { Button, ContextMenu, Host, Picker } from "@expo/ui/swift-ui";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
||||
import React, { useEffect } from "react";
|
||||
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||
|
||||
// Option types
|
||||
export type RadioOption<T = any> = {
|
||||
type: "radio";
|
||||
label: string;
|
||||
value: T;
|
||||
selected: boolean;
|
||||
onPress: () => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export type ToggleOption = {
|
||||
type: "toggle";
|
||||
label: string;
|
||||
value: boolean;
|
||||
onToggle: () => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export type Option = RadioOption | ToggleOption;
|
||||
|
||||
// Option group structure
|
||||
export type OptionGroup = {
|
||||
title?: string;
|
||||
options: Option[];
|
||||
};
|
||||
|
||||
interface PlatformDropdownProps {
|
||||
trigger?: React.ReactNode;
|
||||
title?: string;
|
||||
groups: OptionGroup[];
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
onOptionSelect?: (value?: any) => void;
|
||||
expoUIConfig?: {
|
||||
hostStyle?: any;
|
||||
};
|
||||
bottomSheetConfig?: {
|
||||
enableDynamicSizing?: boolean;
|
||||
enablePanDownToClose?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const ToggleSwitch: React.FC<{ value: boolean }> = ({ value }) => (
|
||||
<View
|
||||
className={`w-12 h-7 rounded-full ${value ? "bg-purple-600" : "bg-neutral-600"} flex-row items-center`}
|
||||
>
|
||||
<View
|
||||
className={`w-5 h-5 rounded-full bg-white shadow-md transform transition-transform ${
|
||||
value ? "translate-x-6" : "translate-x-1"
|
||||
}`}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
const OptionItem: React.FC<{ option: Option; isLast?: boolean }> = ({
|
||||
option,
|
||||
isLast,
|
||||
}) => {
|
||||
const isToggle = option.type === "toggle";
|
||||
const handlePress = isToggle ? option.onToggle : option.onPress;
|
||||
|
||||
return (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
disabled={option.disabled}
|
||||
className={`px-4 py-3 flex flex-row items-center justify-between ${
|
||||
option.disabled ? "opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
<Text className='flex-1 text-white'>{option.label}</Text>
|
||||
{isToggle ? (
|
||||
<ToggleSwitch value={option.value} />
|
||||
) : option.selected ? (
|
||||
<Ionicons name='checkmark-circle' size={24} color='#9333ea' />
|
||||
) : (
|
||||
<Ionicons name='ellipse-outline' size={24} color='#6b7280' />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
{!isLast && (
|
||||
<View
|
||||
style={{
|
||||
height: StyleSheet.hairlineWidth,
|
||||
}}
|
||||
className='bg-neutral-700 mx-4'
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const OptionGroupComponent: React.FC<{ group: OptionGroup }> = ({ group }) => (
|
||||
<View className='mb-6'>
|
||||
{group.title && (
|
||||
<Text className='text-lg font-semibold mb-3 text-neutral-300'>
|
||||
{group.title}
|
||||
</Text>
|
||||
)}
|
||||
<View
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
className='bg-neutral-800 rounded-xl overflow-hidden'
|
||||
>
|
||||
{group.options.map((option, index) => (
|
||||
<OptionItem
|
||||
key={index}
|
||||
option={option}
|
||||
isLast={index === group.options.length - 1}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
const BottomSheetContent: React.FC<{
|
||||
title?: string;
|
||||
groups: OptionGroup[];
|
||||
onOptionSelect?: (value?: any) => void;
|
||||
}> = ({ title, groups, onOptionSelect }) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
// Wrap the groups to call onOptionSelect when an option is pressed
|
||||
const wrappedGroups = groups.map((group) => ({
|
||||
...group,
|
||||
options: group.options.map((option) => {
|
||||
if (option.type === "radio") {
|
||||
return {
|
||||
...option,
|
||||
onPress: () => {
|
||||
option.onPress();
|
||||
onOptionSelect?.(option.value);
|
||||
},
|
||||
};
|
||||
}
|
||||
if (option.type === "toggle") {
|
||||
return {
|
||||
...option,
|
||||
onToggle: () => {
|
||||
option.onToggle();
|
||||
onOptionSelect?.(option.value);
|
||||
},
|
||||
};
|
||||
}
|
||||
return option;
|
||||
}),
|
||||
}));
|
||||
|
||||
return (
|
||||
<BottomSheetScrollView
|
||||
className='px-4 pb-8 pt-2'
|
||||
style={{
|
||||
paddingLeft: Math.max(16, insets.left),
|
||||
paddingRight: Math.max(16, insets.right),
|
||||
}}
|
||||
>
|
||||
{title && <Text className='font-bold text-2xl mb-6'>{title}</Text>}
|
||||
{wrappedGroups.map((group, index) => (
|
||||
<OptionGroupComponent key={index} group={group} />
|
||||
))}
|
||||
</BottomSheetScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
const PlatformDropdownComponent = ({
|
||||
trigger,
|
||||
title,
|
||||
groups,
|
||||
open,
|
||||
onOpenChange,
|
||||
onOptionSelect,
|
||||
expoUIConfig,
|
||||
bottomSheetConfig,
|
||||
}: PlatformDropdownProps) => {
|
||||
const { showModal, hideModal } = useGlobalModal();
|
||||
|
||||
const handlePress = () => {
|
||||
if (Platform.OS === "android") {
|
||||
onOpenChange?.(true);
|
||||
showModal(
|
||||
<BottomSheetContent
|
||||
title={title}
|
||||
groups={groups}
|
||||
onOptionSelect={onOptionSelect}
|
||||
/>,
|
||||
{
|
||||
snapPoints: ["90%"],
|
||||
enablePanDownToClose: bottomSheetConfig?.enablePanDownToClose ?? true,
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Close modal when open prop changes to false
|
||||
useEffect(() => {
|
||||
if (Platform.OS === "android" && open === false) {
|
||||
hideModal();
|
||||
}
|
||||
}, [open, hideModal]);
|
||||
|
||||
if (Platform.OS === "ios") {
|
||||
return (
|
||||
<Host style={expoUIConfig?.hostStyle}>
|
||||
<ContextMenu>
|
||||
<ContextMenu.Trigger>
|
||||
<View className=''>
|
||||
{trigger || <Button variant='bordered'>Show Menu</Button>}
|
||||
</View>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Items>
|
||||
{groups.flatMap((group, groupIndex) => {
|
||||
// Check if this group has radio options
|
||||
const radioOptions = group.options.filter(
|
||||
(opt) => opt.type === "radio",
|
||||
) as RadioOption[];
|
||||
const toggleOptions = group.options.filter(
|
||||
(opt) => opt.type === "toggle",
|
||||
) as ToggleOption[];
|
||||
|
||||
const items = [];
|
||||
|
||||
// Add Picker for radio options ONLY if there's a group title
|
||||
// Otherwise render as individual buttons
|
||||
if (radioOptions.length > 0) {
|
||||
if (group.title) {
|
||||
// Use Picker for grouped options
|
||||
items.push(
|
||||
<Picker
|
||||
key={`picker-${groupIndex}`}
|
||||
label={group.title}
|
||||
options={radioOptions.map((opt) => opt.label)}
|
||||
variant='menu'
|
||||
selectedIndex={radioOptions.findIndex(
|
||||
(opt) => opt.selected,
|
||||
)}
|
||||
onOptionSelected={(event: any) => {
|
||||
const index = event.nativeEvent.index;
|
||||
const selectedOption = radioOptions[index];
|
||||
selectedOption?.onPress();
|
||||
onOptionSelect?.(selectedOption?.value);
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
} else {
|
||||
// Render radio options as direct buttons
|
||||
radioOptions.forEach((option, optionIndex) => {
|
||||
items.push(
|
||||
<Button
|
||||
key={`radio-${groupIndex}-${optionIndex}`}
|
||||
systemImage={
|
||||
option.selected ? "checkmark.circle.fill" : "circle"
|
||||
}
|
||||
onPress={() => {
|
||||
option.onPress();
|
||||
onOptionSelect?.(option.value);
|
||||
}}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
{option.label}
|
||||
</Button>,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add Buttons for toggle options
|
||||
toggleOptions.forEach((option, optionIndex) => {
|
||||
items.push(
|
||||
<Button
|
||||
key={`toggle-${groupIndex}-${optionIndex}`}
|
||||
systemImage={
|
||||
option.value ? "checkmark.circle.fill" : "circle"
|
||||
}
|
||||
onPress={() => {
|
||||
option.onToggle();
|
||||
onOptionSelect?.(option.value);
|
||||
}}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
{option.label}
|
||||
</Button>,
|
||||
);
|
||||
});
|
||||
|
||||
return items;
|
||||
})}
|
||||
</ContextMenu.Items>
|
||||
</ContextMenu>
|
||||
</Host>
|
||||
);
|
||||
}
|
||||
|
||||
// Android: Trigger button for bottom modal
|
||||
return (
|
||||
<TouchableOpacity onPress={handlePress}>
|
||||
{trigger || <Text className='text-white'>Open Menu</Text>}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
// Memoize to prevent unnecessary re-renders when parent re-renders
|
||||
export const PlatformDropdown = React.memo(
|
||||
PlatformDropdownComponent,
|
||||
(prevProps, nextProps) => {
|
||||
// Custom comparison - only re-render if these props actually change
|
||||
return (
|
||||
prevProps.title === nextProps.title &&
|
||||
prevProps.open === nextProps.open &&
|
||||
prevProps.groups === nextProps.groups && // Reference equality (works because we memoize groups in caller)
|
||||
prevProps.trigger === nextProps.trigger // Reference equality
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -1,11 +1,12 @@
|
||||
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 type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, TouchableOpacity, View } from "react-native";
|
||||
import { Alert, Platform, TouchableOpacity, View } from "react-native";
|
||||
import CastContext, {
|
||||
CastButton,
|
||||
PlayServicesState,
|
||||
@@ -33,10 +34,9 @@ import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { chromecast } from "@/utils/profiles/chromecast";
|
||||
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
|
||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||
import type { Button } from "./Button";
|
||||
import type { SelectedOptions } from "./ItemContent";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof Button> {
|
||||
interface Props extends React.ComponentProps<typeof TouchableOpacity> {
|
||||
item: BaseItemDto;
|
||||
selectedOptions: SelectedOptions;
|
||||
isOffline?: boolean;
|
||||
@@ -364,6 +364,46 @@ export const PlayButton: React.FC<Props> = ({
|
||||
* *********************
|
||||
*/
|
||||
|
||||
if (Platform.OS === "ios")
|
||||
return (
|
||||
<Host
|
||||
style={{
|
||||
height: 50,
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant='glassProminent'
|
||||
onPress={onPress}
|
||||
color={effectiveColors.primary}
|
||||
>
|
||||
<View className='flex flex-row items-center gap-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 (
|
||||
<TouchableOpacity
|
||||
disabled={!item}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
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 { tc } from "@/utils/textTools";
|
||||
|
||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text } from "./common/Text";
|
||||
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof View> {
|
||||
source?: MediaSourceInfo;
|
||||
@@ -21,6 +19,8 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
||||
...props
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const subtitleStreams = useMemo(() => {
|
||||
return source?.MediaStreams?.filter((x) => x.Type === "Subtitle");
|
||||
}, [source]);
|
||||
@@ -30,64 +30,83 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
||||
[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;
|
||||
|
||||
return (
|
||||
<View
|
||||
className='flex col shrink justify-start place-self-start items-start'
|
||||
style={{
|
||||
minWidth: 60,
|
||||
maxWidth: 200,
|
||||
<PlatformDropdown
|
||||
groups={optionGroups}
|
||||
trigger={trigger}
|
||||
title={t("item_card.subtitles")}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
onOptionSelect={handleOptionSelect}
|
||||
expoUIConfig={{
|
||||
hostStyle: { flex: 1 },
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Root>
|
||||
<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>
|
||||
bottomSheetConfig={{
|
||||
enablePanDownToClose: true,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -21,15 +21,16 @@ import Animated, {
|
||||
} from "react-native-reanimated";
|
||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
|
||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import { ItemImage } from "./common/ItemImage";
|
||||
import { getItemNavigation } from "./common/TouchableItemRouter";
|
||||
import type { SelectedOptions } from "./ItemContent";
|
||||
import { PlayButton } from "./PlayButton";
|
||||
import { PlayedStatus } from "./PlayedStatus";
|
||||
import { ItemImage } from "../common/ItemImage";
|
||||
import { getItemNavigation } from "../common/TouchableItemRouter";
|
||||
import type { SelectedOptions } from "../ItemContent";
|
||||
import { PlayButton } from "../PlayButton";
|
||||
import { MarkAsPlayedLargeButton } from "./MarkAsPlayedLargeButton";
|
||||
|
||||
interface AppleTVCarouselProps {
|
||||
initialIndex?: number;
|
||||
@@ -45,10 +46,11 @@ const GRADIENT_HEIGHT_BOTTOM = 150;
|
||||
const LOGO_HEIGHT = 80;
|
||||
|
||||
// Position Constants
|
||||
const LOGO_BOTTOM_POSITION = 210;
|
||||
const GENRES_BOTTOM_POSITION = 170;
|
||||
const CONTROLS_BOTTOM_POSITION = 100;
|
||||
const DOTS_BOTTOM_POSITION = 60;
|
||||
const LOGO_BOTTOM_POSITION = 260;
|
||||
const GENRES_BOTTOM_POSITION = 220;
|
||||
const OVERVIEW_BOTTOM_POSITION = 165;
|
||||
const CONTROLS_BOTTOM_POSITION = 80;
|
||||
const DOTS_BOTTOM_POSITION = 40;
|
||||
|
||||
// Size Constants
|
||||
const DOT_HEIGHT = 6;
|
||||
@@ -58,13 +60,15 @@ const PLAY_BUTTON_SKELETON_HEIGHT = 50;
|
||||
const PLAYED_STATUS_SKELETON_SIZE = 40;
|
||||
const TEXT_SKELETON_HEIGHT = 20;
|
||||
const TEXT_SKELETON_WIDTH = 250;
|
||||
const OVERVIEW_SKELETON_HEIGHT = 16;
|
||||
const OVERVIEW_SKELETON_WIDTH = 300;
|
||||
const _EMPTY_STATE_ICON_SIZE = 64;
|
||||
|
||||
// Spacing Constants
|
||||
const HORIZONTAL_PADDING = 40;
|
||||
const DOT_PADDING = 2;
|
||||
const DOT_GAP = 4;
|
||||
const CONTROLS_GAP = 20;
|
||||
const CONTROLS_GAP = 10;
|
||||
const _TEXT_MARGIN_TOP = 16;
|
||||
|
||||
// Border Radius Constants
|
||||
@@ -83,13 +87,16 @@ const VELOCITY_THRESHOLD = 400;
|
||||
|
||||
// Text Constants
|
||||
const GENRES_FONT_SIZE = 16;
|
||||
const OVERVIEW_FONT_SIZE = 14;
|
||||
const _EMPTY_STATE_FONT_SIZE = 18;
|
||||
const TEXT_SHADOW_RADIUS = 2;
|
||||
const MAX_GENRES_COUNT = 2;
|
||||
const MAX_BUTTON_WIDTH = 300;
|
||||
const OVERVIEW_MAX_LINES = 2;
|
||||
const OVERVIEW_MAX_WIDTH = "80%";
|
||||
|
||||
// Opacity Constants
|
||||
const OVERLAY_OPACITY = 0.4;
|
||||
const OVERLAY_OPACITY = 0.3;
|
||||
const DOT_INACTIVE_OPACITY = 0.6;
|
||||
const TEXT_OPACITY = 0.9;
|
||||
|
||||
@@ -168,7 +175,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
||||
userId: user.Id,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||
fields: ["Genres"],
|
||||
fields: ["Genres", "Overview"],
|
||||
limit: 2,
|
||||
});
|
||||
return response.data.Items || [];
|
||||
@@ -183,7 +190,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
||||
if (!api || !user?.Id) return [];
|
||||
const response = await getTvShowsApi(api).getNextUp({
|
||||
userId: user.Id,
|
||||
fields: ["MediaSourceCount", "Genres"],
|
||||
fields: ["MediaSourceCount", "Genres", "Overview"],
|
||||
limit: 2,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||
enableResumable: false,
|
||||
@@ -202,7 +209,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
||||
const response = await getUserLibraryApi(api).getLatestMedia({
|
||||
userId: user.Id,
|
||||
limit: 2,
|
||||
fields: ["PrimaryImageAspectRatio", "Path", "Genres"],
|
||||
fields: ["PrimaryImageAspectRatio", "Path", "Genres", "Overview"],
|
||||
imageTypeLimit: 1,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||
});
|
||||
@@ -348,6 +355,8 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
||||
};
|
||||
});
|
||||
|
||||
const togglePlayedStatus = useMarkAsPlayed(items);
|
||||
|
||||
const renderDots = () => {
|
||||
if (!hasItems || items.length <= 1) return null;
|
||||
|
||||
@@ -473,6 +482,36 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
||||
/>
|
||||
</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 */}
|
||||
<View
|
||||
style={{
|
||||
@@ -689,6 +728,39 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
||||
</TouchableOpacity>
|
||||
</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 */}
|
||||
<View
|
||||
style={{
|
||||
@@ -719,7 +791,10 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
||||
</View>
|
||||
|
||||
{/* Mark as Played */}
|
||||
<PlayedStatus items={[item]} size='large' />
|
||||
<MarkAsPlayedLargeButton
|
||||
isPlayed={item.UserData?.Played ?? false}
|
||||
onToggle={togglePlayedStatus}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
51
components/apple-tv-carousel/MarkAsPlayedLargeButton.tsx
Normal file
51
components/apple-tv-carousel/MarkAsPlayedLargeButton.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -26,7 +26,7 @@ export const HeaderBackButton: React.FC<Props> = ({
|
||||
className='flex items-center justify-center w-9 h-9'
|
||||
{...touchableOpacityProps}
|
||||
>
|
||||
<Ionicons name='arrow-back' size={24} color='white' />
|
||||
<Ionicons name='chevron-back' size={24} color='white' />
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,17 +3,12 @@ import React, { useImperativeHandle, useRef } from "react";
|
||||
import { View, type ViewStyle } from "react-native";
|
||||
import { Text } from "./Text";
|
||||
|
||||
type PartialExcept<T, K extends keyof T> = Partial<T> & Pick<T, K>;
|
||||
|
||||
export interface HorizontalScrollRef {
|
||||
scrollToIndex: (index: number, viewOffset: number) => void;
|
||||
}
|
||||
|
||||
interface HorizontalScrollProps<T>
|
||||
extends PartialExcept<
|
||||
Omit<FlashListProps<T>, "renderItem">,
|
||||
"estimatedItemSize"
|
||||
> {
|
||||
extends Omit<FlashListProps<T>, "renderItem" | "data"> {
|
||||
data?: T[] | null;
|
||||
renderItem: (item: T, index: number) => React.ReactNode;
|
||||
keyExtractor?: (item: T, index: number) => string;
|
||||
@@ -44,7 +39,7 @@ export const HorizontalScroll = <T,>(
|
||||
...restProps
|
||||
} = props;
|
||||
|
||||
const flashListRef = useRef<FlashList<T>>(null);
|
||||
const flashListRef = useRef<React.ComponentRef<typeof FlashList<T>>>(null);
|
||||
|
||||
useImperativeHandle(ref!, () => ({
|
||||
scrollToIndex: (index: number, viewOffset: number) => {
|
||||
@@ -78,7 +73,6 @@ export const HorizontalScroll = <T,>(
|
||||
extraData={extraData}
|
||||
renderItem={renderFlashListItem}
|
||||
horizontal
|
||||
estimatedItemSize={200}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 16,
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import { useRouter, useSegments } from "expo-router";
|
||||
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 * as ContextMenu from "zeego/context-menu";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
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 { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
|
||||
import type {
|
||||
@@ -38,90 +32,33 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const segments = useSegments();
|
||||
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
|
||||
|
||||
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)")
|
||||
return (
|
||||
<ContextMenu.Root>
|
||||
<ContextMenu.Trigger>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (!result) return;
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (!result) return;
|
||||
|
||||
router.push({
|
||||
pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`,
|
||||
// @ts-expect-error
|
||||
params: {
|
||||
...result,
|
||||
mediaTitle,
|
||||
releaseYear,
|
||||
canRequest: canRequest.toString(),
|
||||
posterSrc,
|
||||
mediaType,
|
||||
},
|
||||
});
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</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>
|
||||
router.push({
|
||||
pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`,
|
||||
// @ts-expect-error
|
||||
params: {
|
||||
...result,
|
||||
mediaTitle,
|
||||
releaseYear,
|
||||
canRequest: canRequest.toString(),
|
||||
posterSrc,
|
||||
mediaType,
|
||||
},
|
||||
});
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
import { Platform, Text as RNText, type TextProps } from "react-native";
|
||||
export function Text(props: TextProps) {
|
||||
const { style, ...otherProps } = props;
|
||||
|
||||
interface CustomTextProps extends TextProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Text({ className, ...props }: CustomTextProps) {
|
||||
if (Platform.isTV)
|
||||
return (
|
||||
<RNText
|
||||
allowFontScaling={false}
|
||||
style={[{ color: "white" }, style]}
|
||||
{...otherProps}
|
||||
/>
|
||||
<RNText allowFontScaling={false} className={clsx(className)} {...props} />
|
||||
);
|
||||
|
||||
return (
|
||||
<RNText
|
||||
allowFontScaling={false}
|
||||
style={[{ color: "white" }, style]}
|
||||
{...otherProps}
|
||||
/>
|
||||
<RNText allowFontScaling={false} className={clsx(className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
const clsx = (className?: string) => {
|
||||
const colorClassRegex = /\btext-[a-z]+-\d+\b/;
|
||||
const hasColorClass = className ? colorClassRegex.test(className) : false;
|
||||
const defaultClassName = "text-white";
|
||||
const classes = [
|
||||
...(hasColorClass ? [] : [defaultClassName]),
|
||||
...(className ? [className] : []),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
return classes;
|
||||
};
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function ActiveDownloads({ ...props }: ActiveDownloadsProps) {
|
||||
<Text className='text-lg font-bold mb-2'>
|
||||
{t("home.downloads.active_downloads")}
|
||||
</Text>
|
||||
<View className='space-y-2'>
|
||||
<View className='gap-y-2'>
|
||||
{processes?.map((p: JobStatus) => (
|
||||
<DownloadCard key={p.item.Id} process={p} />
|
||||
))}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { t } from "i18next";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Platform,
|
||||
TouchableOpacity,
|
||||
type TouchableOpacityProps,
|
||||
View,
|
||||
@@ -14,10 +13,10 @@ import {
|
||||
import { toast } from "sonner-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { calculateSmoothedETA } from "@/providers/Downloads/hooks/useDownloadSpeedCalculator";
|
||||
import { JobStatus } from "@/providers/Downloads/types";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { formatTimeString } from "@/utils/time";
|
||||
import { Button } from "../Button";
|
||||
|
||||
const bytesToMB = (bytes: number) => {
|
||||
return bytes / 1024 / 1024;
|
||||
@@ -28,31 +27,10 @@ interface DownloadCardProps extends TouchableOpacityProps {
|
||||
}
|
||||
|
||||
export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||
const { startDownload, pauseDownload, resumeDownload, removeProcess } =
|
||||
useDownload();
|
||||
const { removeProcess } = useDownload();
|
||||
const router = useRouter();
|
||||
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) => {
|
||||
try {
|
||||
await removeProcess(id);
|
||||
@@ -64,16 +42,23 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
const eta = (p: JobStatus) => {
|
||||
if (!p.speed || p.speed <= 0 || !p.estimatedTotalSizeBytes) return null;
|
||||
const eta = useMemo(() => {
|
||||
if (!process.estimatedTotalSizeBytes || !process.bytesDownloaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bytesRemaining = p.estimatedTotalSizeBytes - (p.bytesDownloaded || 0);
|
||||
if (bytesRemaining <= 0) return null;
|
||||
const secondsRemaining = calculateSmoothedETA(
|
||||
process.id,
|
||||
process.bytesDownloaded,
|
||||
process.estimatedTotalSizeBytes,
|
||||
);
|
||||
|
||||
const secondsRemaining = bytesRemaining / p.speed;
|
||||
if (!secondsRemaining || secondsRemaining <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return formatTimeString(secondsRemaining, "s");
|
||||
};
|
||||
}, [process.id, process.bytesDownloaded, process.estimatedTotalSizeBytes]);
|
||||
|
||||
const base64Image = useMemo(() => {
|
||||
return storage.getString(process.item.Id!);
|
||||
@@ -111,26 +96,10 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||
)}
|
||||
|
||||
{/* Action buttons in bottom right corner */}
|
||||
<View className='absolute bottom-2 right-2 flex flex-row items-center space-x-2 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>
|
||||
)}
|
||||
<View className='absolute bottom-2 right-2 flex flex-row items-center z-10'>
|
||||
<TouchableOpacity
|
||||
onPress={() => handleDelete(process.id)}
|
||||
className='p-1'
|
||||
className='p-2 bg-neutral-800 rounded-full'
|
||||
>
|
||||
<Ionicons name='close' size={20} color='red' />
|
||||
</TouchableOpacity>
|
||||
@@ -158,7 +127,7 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||
<Text className='text-xs opacity-50'>
|
||||
{process.item.ProductionYear}
|
||||
</Text>
|
||||
<View className='flex flex-row items-center space-x-2 mt-1 text-purple-600'>
|
||||
<View className='flex flex-row items-center gap-x-2 mt-1 text-purple-600'>
|
||||
{sanitizedProgress === 0 ? (
|
||||
<ActivityIndicator size={"small"} color={"white"} />
|
||||
) : (
|
||||
@@ -169,30 +138,18 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||
{bytesToMB(process.speed).toFixed(2)} MB/s
|
||||
</Text>
|
||||
)}
|
||||
{eta(process) && (
|
||||
{eta && (
|
||||
<Text className='text-xs'>
|
||||
{t("home.downloads.eta", { eta: eta(process) })}
|
||||
{t("home.downloads.eta", { eta: eta })}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className='flex flex-row items-center space-x-2 mt-1 text-purple-600'>
|
||||
<View className='flex flex-row items-center gap-x-2 mt-1 text-purple-600'>
|
||||
<Text className='text-xs capitalize'>{process.status}</Text>
|
||||
</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>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
@@ -13,14 +13,13 @@ export const DownloadSize: React.FC<DownloadSizeProps> = ({
|
||||
items,
|
||||
...props
|
||||
}) => {
|
||||
const { getDownloadedItemSize, getDownloadedItems } = useDownload();
|
||||
const downloadedFiles = getDownloadedItems();
|
||||
const { getDownloadedItemSize, downloadedItems } = useDownload();
|
||||
const [size, setSize] = useState<string | undefined>();
|
||||
|
||||
const itemIds = useMemo(() => items.map((i) => i.Id), [items]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!downloadedFiles) return;
|
||||
if (!downloadedItems) return;
|
||||
|
||||
let s = 0;
|
||||
|
||||
@@ -32,7 +31,7 @@ export const DownloadSize: React.FC<DownloadSizeProps> = ({
|
||||
}
|
||||
}
|
||||
setSize(s.bytesToReadable());
|
||||
}, [itemIds]);
|
||||
}, [itemIds, downloadedItems, getDownloadedItemSize]);
|
||||
|
||||
const sizeText = useMemo(() => {
|
||||
if (!size) return "...";
|
||||
|
||||
@@ -28,7 +28,7 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
|
||||
*/
|
||||
const handleDeleteFile = useCallback(() => {
|
||||
if (item.Id) {
|
||||
deleteFile(item.Id, "Episode");
|
||||
deleteFile(item.Id);
|
||||
successHapticFeedback();
|
||||
}
|
||||
}, [deleteFile, item.Id]);
|
||||
|
||||
@@ -19,7 +19,13 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
|
||||
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 options = ["Delete", "Cancel"];
|
||||
|
||||
@@ -37,7 +37,7 @@ import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { eventBus } from "@/utils/eventBus";
|
||||
import { AppleTVCarousel } from "../AppleTVCarousel";
|
||||
import { AppleTVCarousel } from "../apple-tv-carousel/AppleTVCarousel";
|
||||
|
||||
type ScrollingCollectionListSection = {
|
||||
type: "ScrollingCollectionList";
|
||||
@@ -72,7 +72,7 @@ export const HomeIndex = () => {
|
||||
|
||||
const scrollViewRef = useRef<ScrollView>(null);
|
||||
|
||||
const { getDownloadedItems, cleanCacheDirectory } = useDownload();
|
||||
const { downloadedItems, cleanCacheDirectory } = useDownload();
|
||||
const prevIsConnected = useRef<boolean | null>(false);
|
||||
const {
|
||||
isConnected,
|
||||
@@ -90,6 +90,11 @@ export const HomeIndex = () => {
|
||||
prevIsConnected.current = isConnected;
|
||||
}, [isConnected, invalidateCache]);
|
||||
|
||||
const hasDownloads = useMemo(() => {
|
||||
if (Platform.isTV) return false;
|
||||
return downloadedItems.length > 0;
|
||||
}, [downloadedItems]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.isTV) {
|
||||
navigation.setOptions({
|
||||
@@ -97,7 +102,6 @@ export const HomeIndex = () => {
|
||||
});
|
||||
return;
|
||||
}
|
||||
const hasDownloads = getDownloadedItems().length > 0;
|
||||
navigation.setOptions({
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity
|
||||
@@ -114,7 +118,7 @@ export const HomeIndex = () => {
|
||||
</TouchableOpacity>
|
||||
),
|
||||
});
|
||||
}, [navigation, router]);
|
||||
}, [navigation, router, hasDownloads]);
|
||||
|
||||
useEffect(() => {
|
||||
cleanCacheDirectory().catch((_e) =>
|
||||
@@ -468,7 +472,7 @@ export const HomeIndex = () => {
|
||||
paddingBottom: 16,
|
||||
}}
|
||||
>
|
||||
<View className='flex flex-col space-y-4'>
|
||||
<View className='flex flex-col gap-4'>
|
||||
{sections.map((section, index) => {
|
||||
if (section.type === "ScrollingCollectionList") {
|
||||
return (
|
||||
@@ -8,10 +8,10 @@ import type { BottomSheetModalMethods } from "@gorhom/bottom-sheet/lib/typescrip
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { forwardRef, useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
||||
import { Button } from "@/components/Button";
|
||||
import Dropdown from "@/components/common/Dropdown";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import type {
|
||||
QualityProfile,
|
||||
@@ -138,6 +138,115 @@ const RequestModal = forwardRef<
|
||||
});
|
||||
}, [requestBody?.seasons]);
|
||||
|
||||
const pathTitleExtractor = (item: RootFolder) =>
|
||||
`${item.path} (${item.freeSpace.bytesToReadable()})`;
|
||||
|
||||
const qualityProfileOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: t("jellyseerr.quality_profile"),
|
||||
options:
|
||||
defaultServiceDetails?.profiles.map((profile) => ({
|
||||
type: "radio" as const,
|
||||
label: profile.name,
|
||||
value: profile.id.toString(),
|
||||
selected:
|
||||
(requestOverrides.profileId || defaultProfile?.id) ===
|
||||
profile.id,
|
||||
onPress: () =>
|
||||
setRequestOverrides((prev) => ({
|
||||
...prev,
|
||||
profileId: profile.id,
|
||||
})),
|
||||
})) || [],
|
||||
},
|
||||
],
|
||||
[
|
||||
defaultServiceDetails?.profiles,
|
||||
defaultProfile,
|
||||
requestOverrides.profileId,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
const rootFolderOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: t("jellyseerr.root_folder"),
|
||||
options:
|
||||
defaultServiceDetails?.rootFolders.map((folder) => ({
|
||||
type: "radio" as const,
|
||||
label: pathTitleExtractor(folder),
|
||||
value: folder.id.toString(),
|
||||
selected:
|
||||
(requestOverrides.rootFolder || defaultFolder?.path) ===
|
||||
folder.path,
|
||||
onPress: () =>
|
||||
setRequestOverrides((prev) => ({
|
||||
...prev,
|
||||
rootFolder: folder.path,
|
||||
})),
|
||||
})) || [],
|
||||
},
|
||||
],
|
||||
[
|
||||
defaultServiceDetails?.rootFolders,
|
||||
defaultFolder,
|
||||
requestOverrides.rootFolder,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
const tagsOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: t("jellyseerr.tags"),
|
||||
options:
|
||||
defaultServiceDetails?.tags.map((tag) => ({
|
||||
type: "toggle" as const,
|
||||
label: tag.label,
|
||||
value:
|
||||
requestOverrides.tags?.includes(tag.id) ||
|
||||
defaultTags.some((dt) => dt.id === tag.id),
|
||||
onToggle: () =>
|
||||
setRequestOverrides((prev) => {
|
||||
const currentTags = prev.tags || defaultTags.map((t) => t.id);
|
||||
const hasTag = currentTags.includes(tag.id);
|
||||
return {
|
||||
...prev,
|
||||
tags: hasTag
|
||||
? currentTags.filter((id) => id !== tag.id)
|
||||
: [...currentTags, tag.id],
|
||||
};
|
||||
}),
|
||||
})) || [],
|
||||
},
|
||||
],
|
||||
[defaultServiceDetails?.tags, defaultTags, requestOverrides.tags, t],
|
||||
);
|
||||
|
||||
const usersOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: t("jellyseerr.request_as"),
|
||||
options:
|
||||
users?.map((user) => ({
|
||||
type: "radio" as const,
|
||||
label: user.displayName,
|
||||
value: user.id.toString(),
|
||||
selected:
|
||||
(requestOverrides.userId || jellyseerrUser?.id) === user.id,
|
||||
onPress: () =>
|
||||
setRequestOverrides((prev) => ({
|
||||
...prev,
|
||||
userId: user.id,
|
||||
})),
|
||||
})) || [],
|
||||
},
|
||||
],
|
||||
[users, jellyseerrUser, requestOverrides.userId, t],
|
||||
);
|
||||
|
||||
const request = useCallback(() => {
|
||||
const body = {
|
||||
is4k: defaultService?.is4k || defaultServiceDetails?.server.is4k,
|
||||
@@ -163,9 +272,6 @@ const RequestModal = forwardRef<
|
||||
defaultTags,
|
||||
]);
|
||||
|
||||
const pathTitleExtractor = (item: RootFolder) =>
|
||||
`${item.path} (${item.freeSpace.bytesToReadable()})`;
|
||||
|
||||
return (
|
||||
<BottomSheetModal
|
||||
ref={ref}
|
||||
@@ -199,70 +305,104 @@ const RequestModal = forwardRef<
|
||||
<View className='flex flex-col space-y-2'>
|
||||
{defaultService && defaultServiceDetails && users && (
|
||||
<>
|
||||
<Dropdown
|
||||
data={defaultServiceDetails.profiles}
|
||||
titleExtractor={(item) => item.name}
|
||||
placeholderText={
|
||||
requestOverrides.profileName || defaultProfile.name
|
||||
}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
label={t("jellyseerr.quality_profile")}
|
||||
onSelected={(item) =>
|
||||
item &&
|
||||
setRequestOverrides((prev) => ({
|
||||
...prev,
|
||||
profileId: item?.id,
|
||||
}))
|
||||
}
|
||||
title={t("jellyseerr.quality_profile")}
|
||||
/>
|
||||
<Dropdown
|
||||
data={defaultServiceDetails.rootFolders}
|
||||
titleExtractor={pathTitleExtractor}
|
||||
placeholderText={
|
||||
defaultFolder ? pathTitleExtractor(defaultFolder) : ""
|
||||
}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
label={t("jellyseerr.root_folder")}
|
||||
onSelected={(item) =>
|
||||
item &&
|
||||
setRequestOverrides((prev) => ({
|
||||
...prev,
|
||||
rootFolder: item.path,
|
||||
}))
|
||||
}
|
||||
title={t("jellyseerr.root_folder")}
|
||||
/>
|
||||
<Dropdown
|
||||
multiple
|
||||
data={defaultServiceDetails.tags}
|
||||
titleExtractor={(item) => item.label}
|
||||
placeholderText={defaultTags.map((t) => t.label).join(",")}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
label={t("jellyseerr.tags")}
|
||||
onSelected={(...selected) =>
|
||||
setRequestOverrides((prev) => ({
|
||||
...prev,
|
||||
tags: selected.map((i) => i.id),
|
||||
}))
|
||||
}
|
||||
title={t("jellyseerr.tags")}
|
||||
/>
|
||||
<Dropdown
|
||||
data={users}
|
||||
titleExtractor={(item) => item.displayName}
|
||||
placeholderText={jellyseerrUser!.displayName}
|
||||
keyExtractor={(item) => item.id.toString() || ""}
|
||||
label={t("jellyseerr.request_as")}
|
||||
onSelected={(item) =>
|
||||
item &&
|
||||
setRequestOverrides((prev) => ({
|
||||
...prev,
|
||||
userId: item?.id,
|
||||
}))
|
||||
}
|
||||
title={t("jellyseerr.request_as")}
|
||||
/>
|
||||
<View className='flex flex-col'>
|
||||
<Text className='opacity-50 mb-1 text-xs'>
|
||||
{t("jellyseerr.quality_profile")}
|
||||
</Text>
|
||||
<PlatformDropdown
|
||||
groups={qualityProfileOptions}
|
||||
trigger={
|
||||
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||
<Text numberOfLines={1}>
|
||||
{defaultServiceDetails.profiles.find(
|
||||
(p) =>
|
||||
p.id ===
|
||||
(requestOverrides.profileId ||
|
||||
defaultProfile?.id),
|
||||
)?.name || defaultProfile?.name}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
title={t("jellyseerr.quality_profile")}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className='flex flex-col'>
|
||||
<Text className='opacity-50 mb-1 text-xs'>
|
||||
{t("jellyseerr.root_folder")}
|
||||
</Text>
|
||||
<PlatformDropdown
|
||||
groups={rootFolderOptions}
|
||||
trigger={
|
||||
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||
<Text numberOfLines={1}>
|
||||
{defaultServiceDetails.rootFolders.find(
|
||||
(f) =>
|
||||
f.path ===
|
||||
(requestOverrides.rootFolder ||
|
||||
defaultFolder?.path),
|
||||
)
|
||||
? pathTitleExtractor(
|
||||
defaultServiceDetails.rootFolders.find(
|
||||
(f) =>
|
||||
f.path ===
|
||||
(requestOverrides.rootFolder ||
|
||||
defaultFolder?.path),
|
||||
)!,
|
||||
)
|
||||
: pathTitleExtractor(defaultFolder!)}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
title={t("jellyseerr.root_folder")}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className='flex flex-col'>
|
||||
<Text className='opacity-50 mb-1 text-xs'>
|
||||
{t("jellyseerr.tags")}
|
||||
</Text>
|
||||
<PlatformDropdown
|
||||
groups={tagsOptions}
|
||||
trigger={
|
||||
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||
<Text numberOfLines={1}>
|
||||
{requestOverrides.tags
|
||||
? defaultServiceDetails.tags
|
||||
.filter((t) =>
|
||||
requestOverrides.tags!.includes(t.id),
|
||||
)
|
||||
.map((t) => t.label)
|
||||
.join(", ") ||
|
||||
defaultTags.map((t) => t.label).join(", ")
|
||||
: defaultTags.map((t) => t.label).join(", ")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
title={t("jellyseerr.tags")}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className='flex flex-col'>
|
||||
<Text className='opacity-50 mb-1 text-xs'>
|
||||
{t("jellyseerr.request_as")}
|
||||
</Text>
|
||||
<PlatformDropdown
|
||||
groups={usersOptions}
|
||||
trigger={
|
||||
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||
<Text numberOfLines={1}>
|
||||
{users.find(
|
||||
(u) =>
|
||||
u.id ===
|
||||
(requestOverrides.userId || jellyseerrUser?.id),
|
||||
)?.displayName || jellyseerrUser!.displayName}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
title={t("jellyseerr.request_as")}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -31,7 +31,7 @@ const Discover: React.FC<Props> = ({ sliders }) => {
|
||||
if (!hasSliders) return null;
|
||||
|
||||
return (
|
||||
<View className='flex flex-col space-y-4 mb-8'>
|
||||
<View className='flex flex-col gap-y-4 mb-8'>
|
||||
{sortedSliders.map((slide) => {
|
||||
switch (slide.type) {
|
||||
case DiscoverSliderType.RECENT_REQUESTS:
|
||||
|
||||
@@ -27,18 +27,23 @@ export const ListGroup: React.FC<PropsWithChildren<Props>> = ({
|
||||
{title}
|
||||
</Text>
|
||||
<View
|
||||
style={[]}
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
}}
|
||||
className='flex flex-col rounded-xl overflow-hidden pl-0 bg-neutral-900'
|
||||
>
|
||||
{Children.map(childrenArray, (child, index) => {
|
||||
if (isValidElement<{ style?: ViewStyle }>(child)) {
|
||||
const isLastItem = index === childrenArray.length - 1;
|
||||
return cloneElement(child as any, {
|
||||
style: StyleSheet.compose(
|
||||
child.props.style,
|
||||
index < childrenArray.length - 1
|
||||
? styles.borderBottom
|
||||
: undefined,
|
||||
),
|
||||
...(isLastItem
|
||||
? {}
|
||||
: {
|
||||
style: StyleSheet.compose(
|
||||
child.props.style,
|
||||
styles.borderBottom,
|
||||
),
|
||||
}),
|
||||
});
|
||||
}
|
||||
return child;
|
||||
|
||||
@@ -107,7 +107,7 @@ const ListItemContent = ({
|
||||
</Text>
|
||||
{subtitle && (
|
||||
<Text
|
||||
className='text-[#9899A1] text-[12px] mt-0.5'
|
||||
className='text-neutral-500 text-[11px] mt-0.5'
|
||||
numberOfLines={2}
|
||||
>
|
||||
{subtitle}
|
||||
|
||||
115
components/search/DiscoverFilters.tsx
Normal file
115
components/search/DiscoverFilters.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
76
components/search/SearchTabButtons.tsx
Normal file
76
components/search/SearchTabButtons.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -25,7 +25,7 @@ export const CurrentSeries: React.FC<Props> = ({ item, ...props }) => {
|
||||
</Text>
|
||||
<HorizontalScroll
|
||||
data={[item]}
|
||||
height={247}
|
||||
height={218}
|
||||
renderItem={(item, _index) => (
|
||||
<TouchableOpacity
|
||||
key={item?.Id}
|
||||
@@ -38,7 +38,7 @@ export const CurrentSeries: React.FC<Props> = ({ item, ...props }) => {
|
||||
id={item?.Id}
|
||||
url={getPrimaryImageUrlById({ api, id: item?.ParentId })}
|
||||
/>
|
||||
<Text>{item?.SeriesName}</Text>
|
||||
<Text className='mt-2'>{item?.SeriesName}</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
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 { useEffect, useMemo } from "react";
|
||||
import { Platform, View } from "react-native";
|
||||
import { Text } from "../common/Text";
|
||||
import { PlatformDropdown } from "../PlatformDropdown";
|
||||
|
||||
type Props = {
|
||||
item: BaseItemDto;
|
||||
@@ -55,6 +53,32 @@ export const SeasonDropdown: React.FC<Props> = ({
|
||||
[state, item, keys],
|
||||
);
|
||||
|
||||
const sortByIndex = (a: BaseItemDto, b: BaseItemDto) =>
|
||||
Number(a[keys.index]) - Number(b[keys.index]);
|
||||
|
||||
const optionGroups = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: t("item_card.seasons"),
|
||||
options:
|
||||
seasons?.sort(sortByIndex).map((season: any) => {
|
||||
const title =
|
||||
season[keys.title] ||
|
||||
season.Name ||
|
||||
`Season ${season.IndexNumber}`;
|
||||
return {
|
||||
type: "radio" as const,
|
||||
label: title,
|
||||
value: season.Id || season.IndexNumber,
|
||||
selected: Number(season[keys.index]) === Number(seasonIndex),
|
||||
onPress: () => onSelect(season),
|
||||
};
|
||||
}) || [],
|
||||
},
|
||||
],
|
||||
[seasons, keys, seasonIndex, onSelect],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isTv) return;
|
||||
if (seasons && seasons.length > 0 && seasonIndex === undefined) {
|
||||
@@ -96,45 +120,19 @@ export const SeasonDropdown: React.FC<Props> = ({
|
||||
keys,
|
||||
]);
|
||||
|
||||
const sortByIndex = (a: BaseItemDto, b: BaseItemDto) =>
|
||||
Number(a[keys.index]) - Number(b[keys.index]);
|
||||
|
||||
if (isTv) return null;
|
||||
|
||||
return (
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className='flex flex-row'>
|
||||
<TouchableOpacity className='bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||
<Text>
|
||||
{t("item_card.season")} {seasonIndex}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<PlatformDropdown
|
||||
groups={optionGroups}
|
||||
trigger={
|
||||
<View className='bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||
<Text>
|
||||
{t("item_card.season")} {seasonIndex}
|
||||
</Text>
|
||||
</View>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
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>
|
||||
}
|
||||
title={t("item_card.seasons")}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -28,8 +28,7 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
||||
}) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const { getDownloadedItems } = useDownload();
|
||||
const downloadedFiles = getDownloadedItems();
|
||||
const { downloadedItems } = useDownload();
|
||||
|
||||
const scrollRef = useRef<HorizontalScrollRef>(null);
|
||||
|
||||
@@ -42,10 +41,10 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
||||
}, [item]);
|
||||
|
||||
const { data: episodes, isPending } = useQuery({
|
||||
queryKey: ["episodes", seasonId, isOffline],
|
||||
queryKey: ["episodes", seasonId, isOffline, downloadedItems],
|
||||
queryFn: async () => {
|
||||
if (isOffline) {
|
||||
return downloadedFiles
|
||||
return downloadedItems
|
||||
?.filter(
|
||||
(f) => f.item.Type === "Episode" && f.item.SeasonId === seasonId,
|
||||
)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||
|
||||
import { useMemo } from "react";
|
||||
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 { useSettings } from "@/utils/atoms/settings";
|
||||
import { Text } from "../common/Text";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
import { PlatformDropdown } from "../PlatformDropdown";
|
||||
|
||||
interface Props extends ViewProps {}
|
||||
|
||||
@@ -15,6 +15,31 @@ export const AppLanguageSelector: React.FC<Props> = () => {
|
||||
const { settings, updateSettings } = useSettings();
|
||||
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 (!settings) return null;
|
||||
|
||||
@@ -22,54 +47,19 @@ export const AppLanguageSelector: React.FC<Props> = () => {
|
||||
<View>
|
||||
<ListGroup title={t("home.settings.languages.title")}>
|
||||
<ListItem title={t("home.settings.languages.app_language")}>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<TouchableOpacity className='bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||
<PlatformDropdown
|
||||
groups={optionGroups}
|
||||
trigger={
|
||||
<View className='bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||
<Text>
|
||||
{APP_LANGUAGES.find(
|
||||
(l) => l.value === settings?.preferedLanguage,
|
||||
)?.label || t("home.settings.languages.system")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
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>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.languages.title")}
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
</View>
|
||||
|
||||
@@ -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 { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View, type ViewProps } from "react-native";
|
||||
import { Switch } from "react-native-gesture-handler";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { Text } from "../common/Text";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
import { PlatformDropdown } from "../PlatformDropdown";
|
||||
import { useMedia } from "./MediaContext";
|
||||
|
||||
interface Props extends ViewProps {}
|
||||
@@ -22,6 +21,39 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
|
||||
const cultures = media.cultures;
|
||||
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 (!settings) return null;
|
||||
|
||||
@@ -48,9 +80,10 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem title={t("home.settings.audio.audio_language")}>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3 '>
|
||||
<PlatformDropdown
|
||||
groups={optionGroups}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-3 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{settings?.defaultAudioLanguage?.DisplayName ||
|
||||
t("home.settings.audio.none")}
|
||||
@@ -60,48 +93,10 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
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>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.audio.language")}
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
</View>
|
||||
|
||||
@@ -5,10 +5,10 @@ import { TFunction } from "i18next";
|
||||
import type React from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Linking, Platform, Switch, TouchableOpacity } from "react-native";
|
||||
import { Linking, Platform, Switch, View } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { BITRATES } from "@/components/BitrateSelector";
|
||||
import Dropdown from "@/components/common/Dropdown";
|
||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
|
||||
@@ -89,6 +89,52 @@ export const OtherSettings: React.FC = () => {
|
||||
[],
|
||||
);
|
||||
|
||||
const orientationOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
options: orientations.map((orientation) => ({
|
||||
type: "radio" as const,
|
||||
label: t(ScreenOrientationEnum[orientation]),
|
||||
value: String(orientation),
|
||||
selected: orientation === settings?.defaultVideoOrientation,
|
||||
onPress: () =>
|
||||
updateSettings({ defaultVideoOrientation: orientation }),
|
||||
})),
|
||||
},
|
||||
],
|
||||
[orientations, settings?.defaultVideoOrientation, t, updateSettings],
|
||||
);
|
||||
|
||||
const bitrateOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
options: BITRATES.map((bitrate) => ({
|
||||
type: "radio" as const,
|
||||
label: bitrate.key,
|
||||
value: bitrate.key,
|
||||
selected: bitrate.key === settings?.defaultBitrate?.key,
|
||||
onPress: () => updateSettings({ defaultBitrate: bitrate }),
|
||||
})),
|
||||
},
|
||||
],
|
||||
[settings?.defaultBitrate?.key, t, updateSettings],
|
||||
);
|
||||
|
||||
const autoPlayEpisodeOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
options: AUTOPLAY_EPISODES_COUNT(t).map((item) => ({
|
||||
type: "radio" as const,
|
||||
label: item.key,
|
||||
value: item.key,
|
||||
selected: item.key === settings?.maxAutoPlayEpisodeCount?.key,
|
||||
onPress: () => updateSettings({ maxAutoPlayEpisodeCount: item }),
|
||||
})),
|
||||
},
|
||||
],
|
||||
[settings?.maxAutoPlayEpisodeCount?.key, t, updateSettings],
|
||||
);
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
return (
|
||||
@@ -114,16 +160,10 @@ export const OtherSettings: React.FC = () => {
|
||||
settings.followDeviceOrientation
|
||||
}
|
||||
>
|
||||
<Dropdown
|
||||
data={orientations}
|
||||
disabled={
|
||||
pluginSettings?.defaultVideoOrientation?.locked ||
|
||||
settings.followDeviceOrientation
|
||||
}
|
||||
keyExtractor={String}
|
||||
titleExtractor={(item) => t(ScreenOrientationEnum[item])}
|
||||
title={
|
||||
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
|
||||
<PlatformDropdown
|
||||
groups={orientationOptions}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-3 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{t(
|
||||
orientationTranslations[
|
||||
@@ -136,12 +176,9 @@ export const OtherSettings: React.FC = () => {
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
label={t("home.settings.other.orientation")}
|
||||
onSelected={(defaultVideoOrientation) =>
|
||||
updateSettings({ defaultVideoOrientation })
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.other.orientation")}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
@@ -214,13 +251,10 @@ export const OtherSettings: React.FC = () => {
|
||||
title={t("home.settings.other.default_quality")}
|
||||
disabled={pluginSettings?.defaultBitrate?.locked}
|
||||
>
|
||||
<Dropdown
|
||||
data={BITRATES}
|
||||
disabled={pluginSettings?.defaultBitrate?.locked}
|
||||
keyExtractor={(item) => item.key}
|
||||
titleExtractor={(item) => item.key}
|
||||
title={
|
||||
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
|
||||
<PlatformDropdown
|
||||
groups={bitrateOptions}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-3 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{settings.defaultBitrate?.key}
|
||||
</Text>
|
||||
@@ -229,10 +263,9 @@ export const OtherSettings: React.FC = () => {
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
}
|
||||
label={t("home.settings.other.default_quality")}
|
||||
onSelected={(defaultBitrate) => updateSettings({ defaultBitrate })}
|
||||
title={t("home.settings.other.default_quality")}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
@@ -248,12 +281,10 @@ export const OtherSettings: React.FC = () => {
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem title={t("home.settings.other.max_auto_play_episode_count")}>
|
||||
<Dropdown
|
||||
data={AUTOPLAY_EPISODES_COUNT(t)}
|
||||
keyExtractor={(item) => item.key}
|
||||
titleExtractor={(item) => item.key}
|
||||
title={
|
||||
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
|
||||
<PlatformDropdown
|
||||
groups={autoPlayEpisodeOptions}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-3 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{t(settings?.maxAutoPlayEpisodeCount.key)}
|
||||
</Text>
|
||||
@@ -262,12 +293,9 @@ export const OtherSettings: React.FC = () => {
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
label={t("home.settings.other.max_auto_play_episode_count")}
|
||||
onSelected={(maxAutoPlayEpisodeCount) =>
|
||||
updateSettings({ maxAutoPlayEpisodeCount })
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.other.max_auto_play_episode_count")}
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
|
||||
@@ -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 { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View, type ViewProps } from "react-native";
|
||||
import { Switch } from "react-native-gesture-handler";
|
||||
import Dropdown from "@/components/common/Dropdown";
|
||||
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 { Text } from "../common/Text";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
import { PlatformDropdown } from "../PlatformDropdown";
|
||||
import { useMedia } from "./MediaContext";
|
||||
|
||||
interface Props extends ViewProps {}
|
||||
|
||||
import { OUTLINE_THICKNESS, VLC_COLORS } from "@/constants/SubtitleConstants";
|
||||
|
||||
export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
||||
const isTv = Platform.isTV;
|
||||
|
||||
@@ -27,18 +29,6 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
||||
const cultures = media.cultures;
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Get VLC subtitle settings from the settings system
|
||||
const textColor = pluginSettings?.vlcTextColor ?? "White";
|
||||
const backgroundColor = pluginSettings?.vlcBackgroundColor ?? "Black";
|
||||
const outlineColor = pluginSettings?.vlcOutlineColor ?? "Black";
|
||||
const outlineThickness = pluginSettings?.vlcOutlineThickness ?? "Normal";
|
||||
const backgroundOpacity = pluginSettings?.vlcBackgroundOpacity ?? 128;
|
||||
const outlineOpacity = pluginSettings?.vlcOutlineOpacity ?? 255;
|
||||
const isBold = pluginSettings?.vlcIsBold ?? false;
|
||||
|
||||
if (isTv) return null;
|
||||
if (!settings) return null;
|
||||
|
||||
const subtitleModes = [
|
||||
SubtitlePlaybackMode.Default,
|
||||
SubtitlePlaybackMode.Smart,
|
||||
@@ -56,6 +46,133 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
||||
[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 (
|
||||
<View {...props}>
|
||||
<ListGroup
|
||||
@@ -67,20 +184,10 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
||||
}
|
||||
>
|
||||
<ListItem title={t("home.settings.subtitles.subtitle_language")}>
|
||||
<Dropdown
|
||||
data={[
|
||||
{
|
||||
DisplayName: t("home.settings.subtitles.none"),
|
||||
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'>
|
||||
<PlatformDropdown
|
||||
groups={subtitleLanguageOptionGroups}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-3 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{settings?.defaultSubtitleLanguage?.DisplayName ||
|
||||
t("home.settings.subtitles.none")}
|
||||
@@ -90,18 +197,9 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
label={t("home.settings.subtitles.language")}
|
||||
onSelected={(defaultSubtitleLanguage) =>
|
||||
updateSettings({
|
||||
defaultSubtitleLanguage:
|
||||
defaultSubtitleLanguage.DisplayName ===
|
||||
t("home.settings.subtitles.none")
|
||||
? null
|
||||
: defaultSubtitleLanguage,
|
||||
})
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.subtitles.language")}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
@@ -109,13 +207,10 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
||||
title={t("home.settings.subtitles.subtitle_mode")}
|
||||
disabled={pluginSettings?.subtitleMode?.locked}
|
||||
>
|
||||
<Dropdown
|
||||
data={subtitleModes}
|
||||
disabled={pluginSettings?.subtitleMode?.locked}
|
||||
keyExtractor={String}
|
||||
titleExtractor={(item) => t(subtitleModeKeys[item]) || String(item)}
|
||||
title={
|
||||
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
|
||||
<PlatformDropdown
|
||||
groups={subtitleModeOptionGroups}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-3 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{t(subtitleModeKeys[settings?.subtitleMode]) ||
|
||||
t("home.settings.subtitles.loading")}
|
||||
@@ -125,10 +220,9 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
}
|
||||
label={t("home.settings.subtitles.subtitle_mode")}
|
||||
onSelected={(subtitleMode) => updateSettings({ subtitleMode })}
|
||||
title={t("home.settings.subtitles.subtitle_mode")}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
@@ -159,144 +253,120 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem title={t("home.settings.subtitles.text_color")}>
|
||||
<Dropdown
|
||||
data={Object.keys(VLC_COLORS)}
|
||||
keyExtractor={(item) => item}
|
||||
titleExtractor={(item) =>
|
||||
t(`home.settings.subtitles.colors.${item}`)
|
||||
}
|
||||
title={
|
||||
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
|
||||
<PlatformDropdown
|
||||
groups={textColorOptionGroups}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-3 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{t(`home.settings.subtitles.colors.${textColor}`)}
|
||||
{t(
|
||||
`home.settings.subtitles.colors.${settings?.vlcTextColor || "White"}`,
|
||||
)}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
}
|
||||
label={t("home.settings.subtitles.text_color")}
|
||||
onSelected={(value) => updateSettings({ vlcTextColor: value })}
|
||||
title={t("home.settings.subtitles.text_color")}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem title={t("home.settings.subtitles.background_color")}>
|
||||
<Dropdown
|
||||
data={Object.keys(VLC_COLORS)}
|
||||
keyExtractor={(item) => item}
|
||||
titleExtractor={(item) =>
|
||||
t(`home.settings.subtitles.colors.${item}`)
|
||||
}
|
||||
title={
|
||||
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
|
||||
<PlatformDropdown
|
||||
groups={backgroundColorOptionGroups}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-3 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{t(`home.settings.subtitles.colors.${backgroundColor}`)}
|
||||
{t(
|
||||
`home.settings.subtitles.colors.${settings?.vlcBackgroundColor || "Black"}`,
|
||||
)}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
label={t("home.settings.subtitles.background_color")}
|
||||
onSelected={(value) =>
|
||||
updateSettings({ vlcBackgroundColor: value })
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.subtitles.background_color")}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem title={t("home.settings.subtitles.outline_color")}>
|
||||
<Dropdown
|
||||
data={Object.keys(VLC_COLORS)}
|
||||
keyExtractor={(item) => item}
|
||||
titleExtractor={(item) =>
|
||||
t(`home.settings.subtitles.colors.${item}`)
|
||||
}
|
||||
title={
|
||||
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
|
||||
<PlatformDropdown
|
||||
groups={outlineColorOptionGroups}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-3 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{t(`home.settings.subtitles.colors.${outlineColor}`)}
|
||||
{t(
|
||||
`home.settings.subtitles.colors.${settings?.vlcOutlineColor || "Black"}`,
|
||||
)}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
}
|
||||
label={t("home.settings.subtitles.outline_color")}
|
||||
onSelected={(value) => updateSettings({ vlcOutlineColor: value })}
|
||||
title={t("home.settings.subtitles.outline_color")}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem title={t("home.settings.subtitles.outline_thickness")}>
|
||||
<Dropdown
|
||||
data={Object.keys(OUTLINE_THICKNESS)}
|
||||
keyExtractor={(item) => item}
|
||||
titleExtractor={(item) =>
|
||||
t(`home.settings.subtitles.thickness.${item}`)
|
||||
}
|
||||
title={
|
||||
<TouchableOpacity className='flex flex-row items-center justify-between py-3 pl-3'>
|
||||
<PlatformDropdown
|
||||
groups={outlineThicknessOptionGroups}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-3 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{t(`home.settings.subtitles.thickness.${outlineThickness}`)}
|
||||
{t(
|
||||
`home.settings.subtitles.thickness.${settings?.vlcOutlineThickness || "Normal"}`,
|
||||
)}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
label={t("home.settings.subtitles.outline_thickness")}
|
||||
onSelected={(value) =>
|
||||
updateSettings({ vlcOutlineThickness: value })
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.subtitles.outline_thickness")}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem title={t("home.settings.subtitles.background_opacity")}>
|
||||
<Dropdown
|
||||
data={[0, 32, 64, 96, 128, 160, 192, 224, 255]}
|
||||
keyExtractor={String}
|
||||
titleExtractor={(item) => `${Math.round((item / 255) * 100)}%`}
|
||||
title={
|
||||
<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>
|
||||
<PlatformDropdown
|
||||
groups={backgroundOpacityOptionGroups}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-3 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>{`${Math.round(((settings?.vlcBackgroundOpacity ?? 128) / 255) * 100)}%`}</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
label={t("home.settings.subtitles.background_opacity")}
|
||||
onSelected={(value) =>
|
||||
updateSettings({ vlcBackgroundOpacity: value })
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.subtitles.background_opacity")}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem title={t("home.settings.subtitles.outline_opacity")}>
|
||||
<Dropdown
|
||||
data={[0, 32, 64, 96, 128, 160, 192, 224, 255]}
|
||||
keyExtractor={String}
|
||||
titleExtractor={(item) => `${Math.round((item / 255) * 100)}%`}
|
||||
title={
|
||||
<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>
|
||||
<PlatformDropdown
|
||||
groups={outlineOpacityOptionGroups}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-3 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>{`${Math.round(((settings?.vlcOutlineOpacity ?? 255) / 255) * 100)}%`}</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
}
|
||||
label={t("home.settings.subtitles.outline_opacity")}
|
||||
onSelected={(value) => updateSettings({ vlcOutlineOpacity: value })}
|
||||
title={t("home.settings.subtitles.outline_opacity")}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem title={t("home.settings.subtitles.bold_text")}>
|
||||
<Switch
|
||||
value={isBold}
|
||||
value={settings?.vlcIsBold ?? false}
|
||||
onValueChange={(value) => updateSettings({ vlcIsBold: value })}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
@@ -15,7 +15,7 @@ export const commonScreenOptions: ICommonScreenOptions = {
|
||||
headerShown: true,
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerBlurEffect: "none",
|
||||
headerBlurEffect: Platform.OS === "ios" ? "none" : undefined,
|
||||
headerLeft: () => <HeaderBackButton />,
|
||||
};
|
||||
|
||||
|
||||
@@ -55,8 +55,8 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const { getDownloadedItems } = useDownload();
|
||||
const downloadedFiles = getDownloadedItems();
|
||||
const { downloadedItems } = useDownload();
|
||||
const downloadedFiles = downloadedItems;
|
||||
|
||||
const seasonIndex = seasonIndexState[item.ParentId ?? ""];
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
||||
pointerEvents={showControls ? "auto" : "none"}
|
||||
className={"flex flex-row w-full pt-2"}
|
||||
>
|
||||
<View className='mr-auto'>
|
||||
<View className='mr-auto' pointerEvents='box-none'>
|
||||
{!Platform.isTV && (!offline || !mediaSource?.TranscodingUrl) && (
|
||||
<VideoProvider
|
||||
getAudioTracks={getAudioTracks}
|
||||
@@ -120,7 +120,9 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
||||
setSubtitleTrack={setSubtitleTrack}
|
||||
setSubtitleURL={setSubtitleURL}
|
||||
>
|
||||
<DropdownView />
|
||||
<View pointerEvents='auto'>
|
||||
<DropdownView />
|
||||
</View>
|
||||
</VideoProvider>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import React, { useState } from "react";
|
||||
import { Platform, TouchableOpacity } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { FilterSheet } from "@/components/filters/FilterSheet";
|
||||
import React, { useMemo } from "react";
|
||||
import { Platform, View } from "react-native";
|
||||
import {
|
||||
type OptionGroup,
|
||||
PlatformDropdown,
|
||||
} from "@/components/PlatformDropdown";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
|
||||
export type ScaleFactor =
|
||||
@@ -94,56 +96,51 @@ export const ScaleFactorSelector: React.FC<ScaleFactorSelectorProps> = ({
|
||||
disabled = false,
|
||||
}) => {
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// Hide on TV platforms
|
||||
if (Platform.isTV) return null;
|
||||
|
||||
const handleScaleSelect = (scale: ScaleFactor) => {
|
||||
onScaleChange(scale);
|
||||
lightHapticFeedback();
|
||||
};
|
||||
|
||||
const currentOption = SCALE_FACTOR_OPTIONS.find(
|
||||
(option) => option.id === currentScale,
|
||||
);
|
||||
const optionGroups = useMemo<OptionGroup[]>(() => {
|
||||
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 (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
disabled={disabled}
|
||||
const trigger = useMemo(
|
||||
() => (
|
||||
<View
|
||||
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
||||
style={{ opacity: disabled ? 0.5 : 1 }}
|
||||
onPress={() => setOpen(true)}
|
||||
>
|
||||
<Ionicons name='search-outline' size={24} color='white' />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
),
|
||||
[disabled],
|
||||
);
|
||||
|
||||
<FilterSheet
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
title='Scale Factor'
|
||||
data={SCALE_FACTOR_OPTIONS}
|
||||
values={currentOption ? [currentOption] : []}
|
||||
multiple={false}
|
||||
searchFilter={(item, query) => {
|
||||
const option = item as ScaleFactorOption;
|
||||
return (
|
||||
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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
// Hide on TV platforms
|
||||
if (Platform.isTV) return null;
|
||||
|
||||
return (
|
||||
<PlatformDropdown
|
||||
title='Scale Factor'
|
||||
groups={optionGroups}
|
||||
trigger={trigger}
|
||||
bottomSheetConfig={{
|
||||
enablePanDownToClose: true,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import React, { useState } from "react";
|
||||
import { Platform, TouchableOpacity } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { FilterSheet } from "@/components/filters/FilterSheet";
|
||||
import React, { useMemo } from "react";
|
||||
import { Platform, View } from "react-native";
|
||||
import {
|
||||
type OptionGroup,
|
||||
PlatformDropdown,
|
||||
} from "@/components/PlatformDropdown";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
|
||||
export type AspectRatio = "default" | "16:9" | "4:3" | "1:1" | "21:9";
|
||||
@@ -53,56 +55,51 @@ export const AspectRatioSelector: React.FC<AspectRatioSelectorProps> = ({
|
||||
disabled = false,
|
||||
}) => {
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// Hide on TV platforms
|
||||
if (Platform.isTV) return null;
|
||||
|
||||
const handleRatioSelect = (ratio: AspectRatio) => {
|
||||
onRatioChange(ratio);
|
||||
lightHapticFeedback();
|
||||
};
|
||||
|
||||
const currentOption = ASPECT_RATIO_OPTIONS.find(
|
||||
(option) => option.id === currentRatio,
|
||||
);
|
||||
const optionGroups = useMemo<OptionGroup[]>(() => {
|
||||
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 (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
disabled={disabled}
|
||||
const trigger = useMemo(
|
||||
() => (
|
||||
<View
|
||||
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
||||
style={{ opacity: disabled ? 0.5 : 1 }}
|
||||
onPress={() => setOpen(true)}
|
||||
>
|
||||
<Ionicons name='crop-outline' size={24} color='white' />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
),
|
||||
[disabled],
|
||||
);
|
||||
|
||||
<FilterSheet
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
title='Aspect Ratio'
|
||||
data={ASPECT_RATIO_OPTIONS}
|
||||
values={currentOption ? [currentOption] : []}
|
||||
multiple={false}
|
||||
searchFilter={(item, query) => {
|
||||
const option = item as AspectRatioOption;
|
||||
return (
|
||||
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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
// Hide on TV platforms
|
||||
if (Platform.isTV) return null;
|
||||
|
||||
return (
|
||||
<PlatformDropdown
|
||||
title='Aspect Ratio'
|
||||
groups={optionGroups}
|
||||
trigger={trigger}
|
||||
bottomSheetConfig={{
|
||||
enablePanDownToClose: true,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
type BottomSheetBackdropProps,
|
||||
BottomSheetModal,
|
||||
BottomSheetScrollView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import { Platform, View } from "react-native";
|
||||
import { BITRATES } from "@/components/BitrateSelector";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import {
|
||||
type OptionGroup,
|
||||
PlatformDropdown,
|
||||
} from "@/components/PlatformDropdown";
|
||||
import { useControlContext } from "../contexts/ControlContext";
|
||||
import { useVideoContext } from "../contexts/VideoContext";
|
||||
|
||||
@@ -23,10 +19,6 @@ const DropdownView = () => {
|
||||
ControlContext?.mediaSource,
|
||||
];
|
||||
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 } =
|
||||
useLocalSearchParams<{
|
||||
@@ -39,248 +31,127 @@ const DropdownView = () => {
|
||||
offline: string;
|
||||
}>();
|
||||
|
||||
// Use ref to track playbackPosition without causing re-renders
|
||||
const playbackPositionRef = useRef(playbackPosition);
|
||||
playbackPositionRef.current = playbackPosition;
|
||||
|
||||
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(
|
||||
(bitrate: string) => {
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: item.Id ?? "",
|
||||
itemId: itemIdRef.current ?? "",
|
||||
audioIndex: audioIndex?.toString() ?? "",
|
||||
subtitleIndex: subtitleIndex.toString() ?? "",
|
||||
mediaSourceId: mediaSource?.Id ?? "",
|
||||
subtitleIndex: subtitleIndex?.toString() ?? "",
|
||||
mediaSourceId: mediaSourceIdRef.current ?? "",
|
||||
bitrateValue: bitrate.toString(),
|
||||
playbackPosition: playbackPosition,
|
||||
playbackPosition: playbackPositionRef.current,
|
||||
}).toString();
|
||||
router.replace(`player/direct-player?${queryParams}` as any);
|
||||
},
|
||||
[item, mediaSource, subtitleIndex, audioIndex, playbackPosition],
|
||||
[audioIndex, subtitleIndex, router],
|
||||
);
|
||||
|
||||
const handleSheetChanges = useCallback((index: number) => {
|
||||
if (index === -1) {
|
||||
setOpen(false);
|
||||
}
|
||||
}, []);
|
||||
// Create stable identifiers for tracks
|
||||
const subtitleTracksKey = useMemo(
|
||||
() => subtitleTracks?.map((t) => `${t.index}-${t.name}`).join(",") ?? "",
|
||||
[subtitleTracks],
|
||||
);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: BottomSheetBackdropProps) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
/>
|
||||
const audioTracksKey = useMemo(
|
||||
() => audioTracks?.map((t) => `${t.index}-${t.name}`).join(",") ?? "",
|
||||
[audioTracks],
|
||||
);
|
||||
|
||||
// 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
|
||||
if (Platform.isTV) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
||||
onPress={handleOpen}
|
||||
>
|
||||
<Ionicons name='ellipsis-horizontal' size={24} color={"white"} />
|
||||
</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>
|
||||
</>
|
||||
<PlatformDropdown
|
||||
title='Playback Options'
|
||||
groups={optionGroups}
|
||||
trigger={trigger}
|
||||
bottomSheetConfig={{
|
||||
enablePanDownToClose: true,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ export const useControlsTimeout = ({
|
||||
isSliding,
|
||||
episodeView,
|
||||
onHideControls,
|
||||
timeout = 4000,
|
||||
timeout = 10000,
|
||||
}: UseControlsTimeoutProps) => {
|
||||
const controlsTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
|
||||
5
global.css
Normal file
5
global.css
Normal file
@@ -0,0 +1,5 @@
|
||||
@import "tailwindcss/theme.css" layer(theme);
|
||||
@import "tailwindcss/preflight.css" layer(base);
|
||||
@import "tailwindcss/utilities.css";
|
||||
|
||||
@import "nativewind/theme";
|
||||
@@ -66,8 +66,8 @@ const JELLYSEERR_USER = "JELLYSEERR_USER";
|
||||
const JELLYSEERR_COOKIES = "JELLYSEERR_COOKIES";
|
||||
|
||||
export const clearJellyseerrStorageData = () => {
|
||||
storage.delete(JELLYSEERR_USER);
|
||||
storage.delete(JELLYSEERR_COOKIES);
|
||||
storage.remove(JELLYSEERR_USER);
|
||||
storage.remove(JELLYSEERR_COOKIES);
|
||||
};
|
||||
|
||||
export enum Endpoints {
|
||||
|
||||
@@ -69,7 +69,7 @@ export const usePlaybackManager = ({
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const { isConnected } = useNetworkStatus();
|
||||
const { getDownloadedItemById, updateDownloadedItem, getDownloadedItems } =
|
||||
const { getDownloadedItemById, updateDownloadedItem, downloadedItems } =
|
||||
useDownload();
|
||||
|
||||
/** Whether the device is online. actually it's connected to the internet. */
|
||||
@@ -77,14 +77,20 @@ export const usePlaybackManager = ({
|
||||
|
||||
// Adjacent episodes logic
|
||||
const { data: adjacentItems } = useQuery({
|
||||
queryKey: ["adjacentItems", item?.Id, item?.SeriesId, isOffline],
|
||||
queryKey: [
|
||||
"adjacentItems",
|
||||
item?.Id,
|
||||
item?.SeriesId,
|
||||
isOffline,
|
||||
downloadedItems,
|
||||
],
|
||||
queryFn: async (): Promise<BaseItemDto[] | null> => {
|
||||
if (!item || !item.SeriesId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isOffline) {
|
||||
return getOfflineAdjacentItems(item, getDownloadedItems() || []);
|
||||
return getOfflineAdjacentItems(item, downloadedItems || []);
|
||||
}
|
||||
|
||||
if (!api) {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// Learn more https://docs.expo.io/guides/customizing-metro
|
||||
const { getDefaultConfig } = require("expo/metro-config");
|
||||
const { withNativewind } = require("nativewind/metro");
|
||||
|
||||
/** @type {import('expo/metro-config').MetroConfig} */
|
||||
const config = getDefaultConfig(__dirname); // eslint-disable-line no-undef
|
||||
const config = getDefaultConfig(__dirname);
|
||||
|
||||
// Add Hermes parser
|
||||
config.transformer.hermesParser = true;
|
||||
@@ -25,4 +26,4 @@ if (process.env?.EXPO_TV === "1") {
|
||||
|
||||
// config.resolver.unstable_enablePackageExports = false;
|
||||
|
||||
module.exports = config;
|
||||
module.exports = withNativewind(config, { input: "./global.css" });
|
||||
|
||||
258
modules/background-downloader/README.md
Normal file
258
modules/background-downloader/README.md
Normal 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
|
||||
46
modules/background-downloader/android/build.gradle
Normal file
46
modules/background-downloader/android/build.gradle
Normal 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"
|
||||
}
|
||||
|
||||
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
|
||||
kotlinOptions {
|
||||
freeCompilerArgs += ["-Xshow-kotlin-compiler-errors"]
|
||||
jvmTarget = "17"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<manifest>
|
||||
</manifest>
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
package expo.modules.backgrounddownloader
|
||||
|
||||
import android.app.DownloadManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import expo.modules.kotlin.Promise
|
||||
import expo.modules.kotlin.modules.Module
|
||||
import expo.modules.kotlin.modules.ModuleDefinition
|
||||
import java.io.File
|
||||
|
||||
class BackgroundDownloaderModule : Module() {
|
||||
companion object {
|
||||
private const val TAG = "BackgroundDownloader"
|
||||
}
|
||||
|
||||
private val context
|
||||
get() = requireNotNull(appContext.reactContext)
|
||||
|
||||
private val downloadManager: DownloadManager by lazy {
|
||||
context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
||||
}
|
||||
|
||||
private val downloadTasks = mutableMapOf<Long, DownloadTaskInfo>()
|
||||
private val progressHandler = Handler(Looper.getMainLooper())
|
||||
private val progressRunnables = mutableMapOf<Long, Runnable>()
|
||||
|
||||
private val downloadCompleteReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
val downloadId = intent?.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1) ?: -1
|
||||
Log.d(TAG, "Broadcast received for downloadId: $downloadId, action: ${intent?.action}")
|
||||
|
||||
if (downloadId != -1L && downloadTasks.containsKey(downloadId)) {
|
||||
Log.d(TAG, "Calling handleDownloadComplete for task: $downloadId")
|
||||
handleDownloadComplete(downloadId)
|
||||
} else if (downloadId != -1L) {
|
||||
Log.w(TAG, "Received broadcast for unknown downloadId: $downloadId (not in our task map)")
|
||||
} else {
|
||||
Log.w(TAG, "Received broadcast with invalid downloadId: $downloadId")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun definition() = ModuleDefinition {
|
||||
Name("BackgroundDownloader")
|
||||
|
||||
Events(
|
||||
"onDownloadProgress",
|
||||
"onDownloadComplete",
|
||||
"onDownloadError",
|
||||
"onDownloadStarted"
|
||||
)
|
||||
|
||||
OnCreate {
|
||||
registerDownloadReceiver()
|
||||
}
|
||||
|
||||
OnDestroy {
|
||||
unregisterDownloadReceiver()
|
||||
progressRunnables.values.forEach { progressHandler.removeCallbacks(it) }
|
||||
progressRunnables.clear()
|
||||
}
|
||||
|
||||
AsyncFunction("startDownload") { urlString: String, destinationPath: String?, promise: Promise ->
|
||||
try {
|
||||
val uri = Uri.parse(urlString)
|
||||
val request = DownloadManager.Request(uri).apply {
|
||||
setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE)
|
||||
setAllowedNetworkTypes(
|
||||
DownloadManager.Request.NETWORK_WIFI or DownloadManager.Request.NETWORK_MOBILE
|
||||
)
|
||||
setAllowedOverMetered(true)
|
||||
setAllowedOverRoaming(true)
|
||||
|
||||
if (destinationPath != null) {
|
||||
val file = File(destinationPath)
|
||||
val fileName = file.name
|
||||
|
||||
// Check if destination is in internal storage (starts with /data/data/ or /data/user/)
|
||||
// DownloadManager can't write to internal storage directly
|
||||
val isInternalPath = destinationPath.startsWith("/data/data/") ||
|
||||
destinationPath.startsWith("/data/user/")
|
||||
|
||||
if (isInternalPath) {
|
||||
// Download to external files dir, we'll move it later
|
||||
setDestinationInExternalFilesDir(
|
||||
context,
|
||||
null,
|
||||
fileName
|
||||
)
|
||||
} else {
|
||||
// External path - create directory and set destination
|
||||
val directory = file.parentFile
|
||||
if (directory != null && !directory.exists()) {
|
||||
directory.mkdirs()
|
||||
}
|
||||
setDestinationUri(Uri.fromFile(file))
|
||||
}
|
||||
} else {
|
||||
val fileName = uri.lastPathSegment ?: "download_${System.currentTimeMillis()}"
|
||||
setDestinationInExternalFilesDir(
|
||||
context,
|
||||
null,
|
||||
fileName
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val downloadId = downloadManager.enqueue(request)
|
||||
|
||||
downloadTasks[downloadId] = DownloadTaskInfo(
|
||||
url = urlString,
|
||||
destinationPath = destinationPath
|
||||
)
|
||||
|
||||
startProgressTracking(downloadId)
|
||||
|
||||
sendEvent("onDownloadStarted", mapOf(
|
||||
"taskId" to downloadId.toInt(),
|
||||
"url" to urlString
|
||||
))
|
||||
|
||||
promise.resolve(downloadId.toInt())
|
||||
} catch (e: Exception) {
|
||||
promise.reject("DOWNLOAD_ERROR", "Failed to start download: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
Function("cancelDownload") { taskId: Int ->
|
||||
val downloadId = taskId.toLong()
|
||||
if (downloadTasks.containsKey(downloadId)) {
|
||||
downloadManager.remove(downloadId)
|
||||
stopProgressTracking(downloadId)
|
||||
downloadTasks.remove(downloadId)
|
||||
}
|
||||
}
|
||||
|
||||
Function("cancelAllDownloads") {
|
||||
val downloadIds = downloadTasks.keys.toList()
|
||||
downloadIds.forEach { downloadId ->
|
||||
downloadManager.remove(downloadId)
|
||||
stopProgressTracking(downloadId)
|
||||
}
|
||||
downloadTasks.clear()
|
||||
}
|
||||
|
||||
AsyncFunction("getActiveDownloads") { promise: Promise ->
|
||||
try {
|
||||
val activeDownloads = mutableListOf<Map<String, Any>>()
|
||||
|
||||
downloadTasks.forEach { (downloadId, taskInfo) ->
|
||||
val query = DownloadManager.Query().setFilterById(downloadId)
|
||||
val cursor = downloadManager.query(query)
|
||||
|
||||
if (cursor.moveToFirst()) {
|
||||
val statusIndex = cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)
|
||||
val status = if (statusIndex >= 0) cursor.getInt(statusIndex) else -1
|
||||
|
||||
activeDownloads.add(mapOf(
|
||||
"taskId" to downloadId.toInt(),
|
||||
"url" to taskInfo.url,
|
||||
"state" to getStateString(status)
|
||||
))
|
||||
}
|
||||
cursor.close()
|
||||
}
|
||||
|
||||
promise.resolve(activeDownloads)
|
||||
} catch (e: Exception) {
|
||||
promise.reject("GET_DOWNLOADS_ERROR", "Failed to get active downloads: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerDownloadReceiver() {
|
||||
val filter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
ContextCompat.registerReceiver(
|
||||
context,
|
||||
downloadCompleteReceiver,
|
||||
filter,
|
||||
ContextCompat.RECEIVER_NOT_EXPORTED
|
||||
)
|
||||
} else {
|
||||
context.registerReceiver(downloadCompleteReceiver, filter)
|
||||
}
|
||||
}
|
||||
|
||||
private fun unregisterDownloadReceiver() {
|
||||
try {
|
||||
context.unregisterReceiver(downloadCompleteReceiver)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
// Receiver not registered, ignore
|
||||
}
|
||||
}
|
||||
|
||||
private fun startProgressTracking(downloadId: Long) {
|
||||
val runnable = object : Runnable {
|
||||
override fun run() {
|
||||
if (!downloadTasks.containsKey(downloadId)) {
|
||||
Log.d(TAG, "Task $downloadId no longer in map, stopping progress tracking")
|
||||
return
|
||||
}
|
||||
|
||||
val query = DownloadManager.Query().setFilterById(downloadId)
|
||||
val cursor = downloadManager.query(query)
|
||||
|
||||
if (cursor.moveToFirst()) {
|
||||
val statusIndex = cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)
|
||||
val status = if (statusIndex >= 0) cursor.getInt(statusIndex) else -1
|
||||
|
||||
val bytesDownloadedIndex = cursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)
|
||||
val bytesDownloaded = if (bytesDownloadedIndex >= 0) cursor.getLong(bytesDownloadedIndex) else 0L
|
||||
|
||||
val totalBytesIndex = cursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
|
||||
val totalBytes = if (totalBytesIndex >= 0) cursor.getLong(totalBytesIndex) else 0L
|
||||
|
||||
val statusString = when (status) {
|
||||
DownloadManager.STATUS_RUNNING -> "RUNNING"
|
||||
DownloadManager.STATUS_PAUSED -> "PAUSED"
|
||||
DownloadManager.STATUS_PENDING -> "PENDING"
|
||||
DownloadManager.STATUS_SUCCESSFUL -> "SUCCESSFUL"
|
||||
DownloadManager.STATUS_FAILED -> "FAILED"
|
||||
else -> "UNKNOWN($status)"
|
||||
}
|
||||
|
||||
// Log status periodically for debugging
|
||||
val progress = if (totalBytes > 0) (bytesDownloaded.toDouble() / totalBytes.toDouble() * 100).toInt() else 0
|
||||
if (progress % 10 == 0 || status != DownloadManager.STATUS_RUNNING) {
|
||||
Log.d(TAG, "Task $downloadId: status=$statusString, progress=$progress%, bytes=$bytesDownloaded/$totalBytes")
|
||||
}
|
||||
|
||||
if (status == DownloadManager.STATUS_RUNNING && totalBytes > 0) {
|
||||
val progressRatio = bytesDownloaded.toDouble() / totalBytes.toDouble()
|
||||
|
||||
sendEvent("onDownloadProgress", mapOf(
|
||||
"taskId" to downloadId.toInt(),
|
||||
"bytesWritten" to bytesDownloaded,
|
||||
"totalBytes" to totalBytes,
|
||||
"progress" to progressRatio
|
||||
))
|
||||
}
|
||||
|
||||
// Check if download completed but broadcast was missed
|
||||
if (status == DownloadManager.STATUS_SUCCESSFUL) {
|
||||
Log.w(TAG, "Task $downloadId: Download is SUCCESSFUL but completion handler wasn't called! Calling manually.")
|
||||
cursor.close()
|
||||
stopProgressTracking(downloadId)
|
||||
handleDownloadComplete(downloadId)
|
||||
return
|
||||
}
|
||||
|
||||
// Check for errors
|
||||
if (status == DownloadManager.STATUS_FAILED) {
|
||||
val reasonIndex = cursor.getColumnIndex(DownloadManager.COLUMN_REASON)
|
||||
val reason = if (reasonIndex >= 0) cursor.getInt(reasonIndex) else -1
|
||||
|
||||
Log.e(TAG, "Task $downloadId: Download FAILED with reason code: $reason")
|
||||
|
||||
cursor.close()
|
||||
stopProgressTracking(downloadId)
|
||||
|
||||
sendEvent("onDownloadError", mapOf(
|
||||
"taskId" to downloadId.toInt(),
|
||||
"error" to getErrorString(reason)
|
||||
))
|
||||
|
||||
downloadTasks.remove(downloadId)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if download is paused or pending for too long
|
||||
if (status == DownloadManager.STATUS_PAUSED || status == DownloadManager.STATUS_PENDING) {
|
||||
Log.w(TAG, "Task $downloadId: Download is $statusString")
|
||||
}
|
||||
} else {
|
||||
Log.e(TAG, "Task $downloadId: No cursor data found in DownloadManager")
|
||||
}
|
||||
|
||||
cursor.close()
|
||||
|
||||
// Continue tracking if still in progress
|
||||
if (downloadTasks.containsKey(downloadId)) {
|
||||
progressHandler.postDelayed(this, 500)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
progressRunnables[downloadId] = runnable
|
||||
progressHandler.post(runnable)
|
||||
}
|
||||
|
||||
private fun stopProgressTracking(downloadId: Long) {
|
||||
progressRunnables[downloadId]?.let { runnable ->
|
||||
progressHandler.removeCallbacks(runnable)
|
||||
progressRunnables.remove(downloadId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDownloadComplete(downloadId: Long) {
|
||||
stopProgressTracking(downloadId)
|
||||
|
||||
val taskInfo = downloadTasks[downloadId]
|
||||
if (taskInfo == null) {
|
||||
return
|
||||
}
|
||||
|
||||
val query = DownloadManager.Query().setFilterById(downloadId)
|
||||
val cursor = downloadManager.query(query)
|
||||
|
||||
if (cursor.moveToFirst()) {
|
||||
val statusIndex = cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)
|
||||
val status = if (statusIndex >= 0) cursor.getInt(statusIndex) else -1
|
||||
|
||||
if (status == DownloadManager.STATUS_SUCCESSFUL) {
|
||||
val uriIndex = cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI)
|
||||
val localUri = if (uriIndex >= 0) cursor.getString(uriIndex) else null
|
||||
|
||||
if (localUri != null) {
|
||||
val downloadedFilePath = Uri.parse(localUri).path ?: localUri
|
||||
|
||||
// If we have a custom destination path for internal storage, move the file in background
|
||||
if (taskInfo.destinationPath != null) {
|
||||
val isInternalPath = taskInfo.destinationPath.startsWith("/data/data/") ||
|
||||
taskInfo.destinationPath.startsWith("/data/user/")
|
||||
|
||||
if (isInternalPath) {
|
||||
Log.d(TAG, "Starting file move in background thread for taskId: ${downloadId.toInt()}")
|
||||
|
||||
// Move file in background thread to avoid blocking
|
||||
Thread {
|
||||
try {
|
||||
val sourceFile = File(downloadedFilePath)
|
||||
val destFile = File(taskInfo.destinationPath)
|
||||
|
||||
Log.d(TAG, "Moving file from $downloadedFilePath to ${taskInfo.destinationPath}")
|
||||
|
||||
// Create destination directory if needed
|
||||
val destDir = destFile.parentFile
|
||||
if (destDir != null && !destDir.exists()) {
|
||||
destDir.mkdirs()
|
||||
}
|
||||
|
||||
// Try to move file (fast if on same filesystem)
|
||||
val moveSuccessful = sourceFile.renameTo(destFile)
|
||||
|
||||
if (moveSuccessful) {
|
||||
Log.d(TAG, "File moved successfully via rename")
|
||||
|
||||
sendEvent("onDownloadComplete", mapOf(
|
||||
"taskId" to downloadId.toInt(),
|
||||
"filePath" to taskInfo.destinationPath,
|
||||
"url" to taskInfo.url
|
||||
))
|
||||
} else {
|
||||
// Rename failed (likely different filesystems), need to copy
|
||||
Log.d(TAG, "Rename failed, copying file (this may take a while for large files)")
|
||||
|
||||
sourceFile.inputStream().use { input ->
|
||||
destFile.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete source file after successful copy
|
||||
if (sourceFile.delete()) {
|
||||
Log.d(TAG, "File copied and source deleted successfully")
|
||||
} else {
|
||||
Log.w(TAG, "File copied but failed to delete source file")
|
||||
}
|
||||
|
||||
sendEvent("onDownloadComplete", mapOf(
|
||||
"taskId" to downloadId.toInt(),
|
||||
"filePath" to taskInfo.destinationPath,
|
||||
"url" to taskInfo.url
|
||||
))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to move file to internal storage: ${e.message}", e)
|
||||
sendEvent("onDownloadError", mapOf(
|
||||
"taskId" to downloadId.toInt(),
|
||||
"error" to "Failed to move file to destination: ${e.message}"
|
||||
))
|
||||
}
|
||||
}.start()
|
||||
|
||||
cursor.close()
|
||||
downloadTasks.remove(downloadId)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// No internal path or external path - send completion immediately
|
||||
sendEvent("onDownloadComplete", mapOf(
|
||||
"taskId" to downloadId.toInt(),
|
||||
"filePath" to downloadedFilePath,
|
||||
"url" to taskInfo.url
|
||||
))
|
||||
} else {
|
||||
sendEvent("onDownloadError", mapOf(
|
||||
"taskId" to downloadId.toInt(),
|
||||
"error" to "Could not retrieve downloaded file path"
|
||||
))
|
||||
}
|
||||
} else if (status == DownloadManager.STATUS_FAILED) {
|
||||
val reasonIndex = cursor.getColumnIndex(DownloadManager.COLUMN_REASON)
|
||||
val reason = if (reasonIndex >= 0) cursor.getInt(reasonIndex) else -1
|
||||
|
||||
sendEvent("onDownloadError", mapOf(
|
||||
"taskId" to downloadId.toInt(),
|
||||
"error" to getErrorString(reason)
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
cursor.close()
|
||||
downloadTasks.remove(downloadId)
|
||||
}
|
||||
|
||||
private fun getStateString(status: Int): String {
|
||||
return when (status) {
|
||||
DownloadManager.STATUS_RUNNING -> "running"
|
||||
DownloadManager.STATUS_PAUSED -> "suspended"
|
||||
DownloadManager.STATUS_PENDING -> "suspended"
|
||||
DownloadManager.STATUS_SUCCESSFUL -> "completed"
|
||||
DownloadManager.STATUS_FAILED -> "completed"
|
||||
else -> "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
private fun getErrorString(reason: Int): String {
|
||||
return when (reason) {
|
||||
DownloadManager.ERROR_CANNOT_RESUME -> "Cannot resume download"
|
||||
DownloadManager.ERROR_DEVICE_NOT_FOUND -> "No external storage device found"
|
||||
DownloadManager.ERROR_FILE_ALREADY_EXISTS -> "File already exists"
|
||||
DownloadManager.ERROR_FILE_ERROR -> "Storage error"
|
||||
DownloadManager.ERROR_HTTP_DATA_ERROR -> "HTTP data error"
|
||||
DownloadManager.ERROR_INSUFFICIENT_SPACE -> "Insufficient storage space"
|
||||
DownloadManager.ERROR_TOO_MANY_REDIRECTS -> "Too many redirects"
|
||||
DownloadManager.ERROR_UNHANDLED_HTTP_CODE -> "Unhandled HTTP response code"
|
||||
DownloadManager.ERROR_UNKNOWN -> "Unknown error"
|
||||
else -> "Download failed (code: $reason)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class DownloadTaskInfo(
|
||||
val url: String,
|
||||
val destinationPath: String?
|
||||
)
|
||||
|
||||
98
modules/background-downloader/example.ts
Normal file
98
modules/background-downloader/example.ts
Normal 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();
|
||||
}
|
||||
12
modules/background-downloader/expo-module.config.json
Normal file
12
modules/background-downloader/expo-module.config.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
91
modules/background-downloader/index.ts
Normal file
91
modules/background-downloader/index.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import type { Subscription } 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>;
|
||||
cancelDownload(taskId: number): void;
|
||||
cancelAllDownloads(): void;
|
||||
getActiveDownloads(): Promise<ActiveDownload[]>;
|
||||
|
||||
addProgressListener(
|
||||
listener: (event: DownloadProgressEvent) => void,
|
||||
): Subscription;
|
||||
|
||||
addCompleteListener(
|
||||
listener: (event: DownloadCompleteEvent) => void,
|
||||
): Subscription;
|
||||
|
||||
addErrorListener(listener: (event: DownloadErrorEvent) => void): Subscription;
|
||||
|
||||
addStartedListener(
|
||||
listener: (event: DownloadStartedEvent) => void,
|
||||
): Subscription;
|
||||
}
|
||||
|
||||
const BackgroundDownloader: BackgroundDownloader = {
|
||||
async startDownload(url: string, destinationPath?: string): Promise<number> {
|
||||
return await BackgroundDownloaderModule.startDownload(url, destinationPath);
|
||||
},
|
||||
|
||||
cancelDownload(taskId: number): void {
|
||||
BackgroundDownloaderModule.cancelDownload(taskId);
|
||||
},
|
||||
|
||||
cancelAllDownloads(): void {
|
||||
BackgroundDownloaderModule.cancelAllDownloads();
|
||||
},
|
||||
|
||||
async getActiveDownloads(): Promise<ActiveDownload[]> {
|
||||
return await BackgroundDownloaderModule.getActiveDownloads();
|
||||
},
|
||||
|
||||
addProgressListener(
|
||||
listener: (event: DownloadProgressEvent) => void,
|
||||
): Subscription {
|
||||
return BackgroundDownloaderModule.addListener(
|
||||
"onDownloadProgress",
|
||||
listener,
|
||||
);
|
||||
},
|
||||
|
||||
addCompleteListener(
|
||||
listener: (event: DownloadCompleteEvent) => void,
|
||||
): Subscription {
|
||||
return BackgroundDownloaderModule.addListener(
|
||||
"onDownloadComplete",
|
||||
listener,
|
||||
);
|
||||
},
|
||||
|
||||
addErrorListener(
|
||||
listener: (event: DownloadErrorEvent) => void,
|
||||
): Subscription {
|
||||
return BackgroundDownloaderModule.addListener("onDownloadError", listener);
|
||||
},
|
||||
|
||||
addStartedListener(
|
||||
listener: (event: DownloadStartedEvent) => void,
|
||||
): Subscription {
|
||||
return BackgroundDownloaderModule.addListener(
|
||||
"onDownloadStarted",
|
||||
listener,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default BackgroundDownloader;
|
||||
|
||||
export type {
|
||||
DownloadProgressEvent,
|
||||
DownloadCompleteEvent,
|
||||
DownloadErrorEvent,
|
||||
DownloadStartedEvent,
|
||||
ActiveDownload,
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
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()
|
||||
print("[DownloadSessionDelegate] Delegate initialized with module: \(String(describing: module))")
|
||||
}
|
||||
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
downloadTask: URLSessionDownloadTask,
|
||||
didWriteData bytesWritten: Int64,
|
||||
totalBytesWritten: Int64,
|
||||
totalBytesExpectedToWrite: Int64
|
||||
) {
|
||||
let progress = totalBytesExpectedToWrite > 0 ? Double(totalBytesWritten) / Double(totalBytesExpectedToWrite) : 0.0
|
||||
print("[BackgroundDownloader] Progress callback: taskId=\(downloadTask.taskIdentifier), written=\(totalBytesWritten), total=\(totalBytesExpectedToWrite), progress=\(progress)")
|
||||
module?.handleProgress(
|
||||
taskId: downloadTask.taskIdentifier,
|
||||
bytesWritten: totalBytesWritten,
|
||||
totalBytes: totalBytesExpectedToWrite
|
||||
)
|
||||
}
|
||||
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
downloadTask: URLSessionDownloadTask,
|
||||
didFinishDownloadingTo location: URL
|
||||
) {
|
||||
print("[BackgroundDownloader] Download finished callback: taskId=\(downloadTask.taskIdentifier)")
|
||||
module?.handleDownloadComplete(
|
||||
taskId: downloadTask.taskIdentifier,
|
||||
location: location,
|
||||
downloadTask: downloadTask
|
||||
)
|
||||
}
|
||||
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
task: URLSessionTask,
|
||||
didCompleteWithError error: Error?
|
||||
) {
|
||||
print("[BackgroundDownloader] Task completed: taskId=\(task.taskIdentifier), error=\(String(describing: error))")
|
||||
|
||||
if let httpResponse = task.response as? HTTPURLResponse {
|
||||
print("[BackgroundDownloader] HTTP Status: \(httpResponse.statusCode)")
|
||||
print("[BackgroundDownloader] Content-Length: \(httpResponse.expectedContentLength)")
|
||||
}
|
||||
|
||||
if let error = error {
|
||||
print("[BackgroundDownloader] Task 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] = [:]
|
||||
|
||||
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
|
||||
|
||||
print("[BackgroundDownloader] Starting download: taskId=\(taskId), url=\(urlString)")
|
||||
print("[BackgroundDownloader] Destination: \(destinationPath ?? "default")")
|
||||
|
||||
self.downloadTasks[taskId] = DownloadTaskInfo(
|
||||
url: urlString,
|
||||
destinationPath: destinationPath
|
||||
)
|
||||
|
||||
task.resume()
|
||||
|
||||
print("[BackgroundDownloader] Task resumed with state: \(self.taskStateString(task.state))")
|
||||
print("[BackgroundDownloader] Sending started event")
|
||||
|
||||
self.sendEvent("onDownloadStarted", [
|
||||
"taskId": taskId,
|
||||
"url": urlString
|
||||
])
|
||||
|
||||
// Check task state after a brief delay
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
session.getAllTasks { tasks in
|
||||
if let downloadTask = tasks.first(where: { $0.taskIdentifier == taskId }) {
|
||||
print("[BackgroundDownloader] === 0.5s CHECK ===")
|
||||
print("[BackgroundDownloader] Task state: \(self.taskStateString(downloadTask.state))")
|
||||
if let response = downloadTask.response as? HTTPURLResponse {
|
||||
print("[BackgroundDownloader] Response status: \(response.statusCode)")
|
||||
print("[BackgroundDownloader] Expected content length: \(response.expectedContentLength)")
|
||||
} else {
|
||||
print("[BackgroundDownloader] No HTTP response yet after 0.5s")
|
||||
}
|
||||
} else {
|
||||
print("[BackgroundDownloader] Task not found after 0.5s")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Additional diagnostics at 1s, 2s, and 3s
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
||||
session.getAllTasks { tasks in
|
||||
if let downloadTask = tasks.first(where: { $0.taskIdentifier == taskId }) {
|
||||
print("[BackgroundDownloader] === 1s CHECK ===")
|
||||
print("[BackgroundDownloader] Task state: \(self.taskStateString(downloadTask.state))")
|
||||
print("[BackgroundDownloader] Task error: \(String(describing: downloadTask.error))")
|
||||
print("[BackgroundDownloader] Current request URL: \(downloadTask.currentRequest?.url?.absoluteString ?? "nil")")
|
||||
print("[BackgroundDownloader] Original request URL: \(downloadTask.originalRequest?.url?.absoluteString ?? "nil")")
|
||||
|
||||
if let response = downloadTask.response as? HTTPURLResponse {
|
||||
print("[BackgroundDownloader] HTTP Status: \(response.statusCode)")
|
||||
print("[BackgroundDownloader] Expected content length: \(response.expectedContentLength)")
|
||||
print("[BackgroundDownloader] All headers: \(response.allHeaderFields)")
|
||||
} else {
|
||||
print("[BackgroundDownloader] ⚠️ STILL NO HTTP RESPONSE after 1s")
|
||||
}
|
||||
|
||||
let countOfBytesReceived = downloadTask.countOfBytesReceived
|
||||
if countOfBytesReceived > 0 {
|
||||
print("[BackgroundDownloader] Bytes received: \(countOfBytesReceived)")
|
||||
} else {
|
||||
print("[BackgroundDownloader] ⚠️ NO BYTES RECEIVED YET")
|
||||
}
|
||||
} else {
|
||||
print("[BackgroundDownloader] ⚠️ Task disappeared after 1s")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
|
||||
session.getAllTasks { tasks in
|
||||
if let downloadTask = tasks.first(where: { $0.taskIdentifier == taskId }) {
|
||||
print("[BackgroundDownloader] === 2s CHECK ===")
|
||||
print("[BackgroundDownloader] Task state: \(self.taskStateString(downloadTask.state))")
|
||||
let countOfBytesReceived = downloadTask.countOfBytesReceived
|
||||
print("[BackgroundDownloader] Bytes received: \(countOfBytesReceived)")
|
||||
if downloadTask.error != nil {
|
||||
print("[BackgroundDownloader] ⚠️ Task has error: \(String(describing: downloadTask.error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
|
||||
session.getAllTasks { tasks in
|
||||
if let downloadTask = tasks.first(where: { $0.taskIdentifier == taskId }) {
|
||||
print("[BackgroundDownloader] === 3s CHECK ===")
|
||||
print("[BackgroundDownloader] Task state: \(self.taskStateString(downloadTask.state))")
|
||||
let countOfBytesReceived = downloadTask.countOfBytesReceived
|
||||
print("[BackgroundDownloader] Bytes received: \(countOfBytesReceived)")
|
||||
if downloadTask.error != nil {
|
||||
print("[BackgroundDownloader] ⚠️ Task has error: \(String(describing: downloadTask.error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return taskId
|
||||
}
|
||||
|
||||
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("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
|
||||
let taskStateString = self.taskStateString
|
||||
|
||||
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,
|
||||
"state": taskStateString(task.state)
|
||||
]
|
||||
}
|
||||
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!")
|
||||
}
|
||||
}
|
||||
|
||||
private func taskStateString(_ state: URLSessionTask.State) -> String {
|
||||
switch state {
|
||||
case .running:
|
||||
return "running"
|
||||
case .suspended:
|
||||
return "suspended"
|
||||
case .canceling:
|
||||
return "canceling"
|
||||
case .completed:
|
||||
return "completed"
|
||||
@unknown default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// Handler methods called by the delegate
|
||||
func handleProgress(taskId: Int, bytesWritten: Int64, totalBytes: Int64) {
|
||||
let progress = totalBytes > 0
|
||||
? Double(bytesWritten) / Double(totalBytes)
|
||||
: 0.0
|
||||
|
||||
print("[BackgroundDownloader] Sending progress event: taskId=\(taskId), progress=\(progress)")
|
||||
|
||||
self.sendEvent("onDownloadProgress", [
|
||||
"taskId": taskId,
|
||||
"bytesWritten": bytesWritten,
|
||||
"totalBytes": totalBytes,
|
||||
"progress": progress
|
||||
])
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
} catch {
|
||||
self.sendEvent("onDownloadError", [
|
||||
"taskId": taskId,
|
||||
"error": "File operation failed: \(error.localizedDescription)"
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
func handleError(taskId: Int, error: Error) {
|
||||
let isCancelled = (error as NSError).code == NSURLErrorCancelled
|
||||
|
||||
if !isCancelled {
|
||||
self.sendEvent("onDownloadError", [
|
||||
"taskId": taskId,
|
||||
"error": error.localizedDescription
|
||||
])
|
||||
}
|
||||
|
||||
downloadTasks.removeValue(forKey: taskId)
|
||||
}
|
||||
|
||||
static func setBackgroundCompletionHandler(_ handler: @escaping () -> Void) {
|
||||
BackgroundDownloaderModule.backgroundCompletionHandler = handler
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
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[]>;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { requireNativeModule } from "expo-modules-core";
|
||||
import type { BackgroundDownloaderModuleType } from "./BackgroundDownloader.types";
|
||||
|
||||
const BackgroundDownloaderModule: BackgroundDownloaderModuleType =
|
||||
requireNativeModule("BackgroundDownloader");
|
||||
|
||||
export default BackgroundDownloaderModule;
|
||||
@@ -1,3 +1,11 @@
|
||||
import type {
|
||||
ActiveDownload,
|
||||
DownloadCompleteEvent,
|
||||
DownloadErrorEvent,
|
||||
DownloadProgressEvent,
|
||||
DownloadStartedEvent,
|
||||
} from "./background-downloader";
|
||||
import BackgroundDownloader from "./background-downloader";
|
||||
import {
|
||||
ChapterInfo,
|
||||
PlaybackStatePayload,
|
||||
@@ -12,8 +20,8 @@ import {
|
||||
} from "./VlcPlayer.types";
|
||||
import VlcPlayerView from "./VlcPlayerView";
|
||||
|
||||
export {
|
||||
VlcPlayerView,
|
||||
export { VlcPlayerView, BackgroundDownloader };
|
||||
export type {
|
||||
VlcPlayerViewProps,
|
||||
VlcPlayerViewRef,
|
||||
PlaybackStatePayload,
|
||||
@@ -24,4 +32,9 @@ export {
|
||||
VlcPlayerSource,
|
||||
TrackInfo,
|
||||
ChapterInfo,
|
||||
DownloadProgressEvent,
|
||||
DownloadCompleteEvent,
|
||||
DownloadErrorEvent,
|
||||
DownloadStartedEvent,
|
||||
ActiveDownload,
|
||||
};
|
||||
|
||||
3
nativewind-env.d.ts
vendored
3
nativewind-env.d.ts
vendored
@@ -1,2 +1,3 @@
|
||||
/// <reference types="nativewind/types" />
|
||||
/// <reference types="react-native-css/types" />
|
||||
|
||||
// NOTE: This file should not be edited and should be committed with your source code. It is generated by react-native-css. If you need to move or disable this file, please see the documentation.
|
||||
|
||||
119
package.json
119
package.json
@@ -22,82 +22,86 @@
|
||||
"test": "bun run typecheck && bun run lint && bun run format && bun run doctor"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bottom-tabs/react-navigation": "^0.11.2",
|
||||
"@expo/metro-runtime": "~5.0.5",
|
||||
"@bottom-tabs/react-navigation": "^0.12.2",
|
||||
"@expo/metro-runtime": "~6.1.1",
|
||||
"@expo/react-native-action-sheet": "^4.1.1",
|
||||
"@expo/vector-icons": "^14.1.0",
|
||||
"@expo/ui": "^0.2.0-beta.4",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@gorhom/bottom-sheet": "^5.1.0",
|
||||
"@jellyfin/sdk": "^0.11.0",
|
||||
"@kesha-antonov/react-native-background-downloader": "^3.2.6",
|
||||
"@react-native-community/netinfo": "^11.4.1",
|
||||
"@react-native-menu/menu": "1.2.3",
|
||||
"@react-navigation/material-top-tabs": "^7.2.14",
|
||||
"@react-navigation/native": "^7.0.14",
|
||||
"@shopify/flash-list": "^1.8.3",
|
||||
"@shopify/flash-list": "2.0.2",
|
||||
"@tanstack/react-query": "^5.66.0",
|
||||
"axios": "^1.7.9",
|
||||
"expo": "^53.0.23",
|
||||
"expo-application": "~6.1.4",
|
||||
"expo-asset": "~11.1.7",
|
||||
"expo-background-task": "~0.2.8",
|
||||
"expo-blur": "~14.1.4",
|
||||
"expo-brightness": "~13.1.4",
|
||||
"expo-build-properties": "~0.14.6",
|
||||
"expo-constants": "~17.1.5",
|
||||
"expo-dev-client": "^5.2.0",
|
||||
"expo-device": "~7.1.4",
|
||||
"expo-font": "~13.3.1",
|
||||
"expo-haptics": "~14.1.4",
|
||||
"expo-image": "~2.4.0",
|
||||
"expo-linear-gradient": "~14.1.4",
|
||||
"expo-linking": "~7.1.4",
|
||||
"expo-localization": "~16.1.5",
|
||||
"expo-notifications": "~0.31.2",
|
||||
"expo-router": "~5.1.7",
|
||||
"expo-screen-orientation": "~8.1.6",
|
||||
"expo-sensors": "~14.1.4",
|
||||
"expo-sharing": "~13.1.5",
|
||||
"expo-splash-screen": "~0.30.8",
|
||||
"expo-status-bar": "~2.2.3",
|
||||
"expo-system-ui": "~5.0.11",
|
||||
"expo-task-manager": "~13.1.6",
|
||||
"expo-web-browser": "~14.2.0",
|
||||
"expo": "^54.0.10",
|
||||
"expo-application": "~7.0.5",
|
||||
"expo-asset": "~12.0.6",
|
||||
"expo-background-task": "~1.0.5",
|
||||
"expo-blur": "~15.0.5",
|
||||
"expo-brightness": "~14.0.5",
|
||||
"expo-build-properties": "~1.0.6",
|
||||
"expo-constants": "~18.0.6",
|
||||
"expo-dev-client": "~6.0.7",
|
||||
"expo-device": "~8.0.5",
|
||||
"expo-font": "~14.0.6",
|
||||
"expo-haptics": "~15.0.5",
|
||||
"expo-image": "~3.0.5",
|
||||
"expo-linear-gradient": "~15.0.5",
|
||||
"expo-linking": "~8.0.6",
|
||||
"expo-localization": "~17.0.5",
|
||||
"expo-notifications": "~0.32.7",
|
||||
"expo-router": "~6.0.0-preview.12",
|
||||
"expo-screen-orientation": "~9.0.5",
|
||||
"expo-sensors": "~15.0.5",
|
||||
"expo-sharing": "~14.0.5",
|
||||
"expo-splash-screen": "~31.0.7",
|
||||
"expo-status-bar": "~3.0.6",
|
||||
"expo-system-ui": "~6.0.5",
|
||||
"expo-task-manager": "~14.0.5",
|
||||
"expo-web-browser": "~15.0.5",
|
||||
"i18next": "^25.0.0",
|
||||
"jotai": "^2.12.5",
|
||||
"lodash": "^4.17.21",
|
||||
"nativewind": "^2.0.11",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"nativewind": "^5.0.0-preview.1",
|
||||
"patch-package": "^8.0.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-i18next": "^15.4.0",
|
||||
"react-native": "npm:react-native-tvos@0.79.5-0",
|
||||
"react-native": "npm:react-native-tvos@0.81.4-0",
|
||||
"react-native-awesome-slider": "^2.9.0",
|
||||
"react-native-bottom-tabs": "^0.11.2",
|
||||
"react-native-bottom-tabs": "^0.12.2",
|
||||
"react-native-circular-progress": "^1.4.1",
|
||||
"react-native-collapsible": "^1.6.2",
|
||||
"react-native-country-flag": "^2.0.2",
|
||||
"react-native-css": "^3.0.0",
|
||||
"react-native-device-info": "^14.0.4",
|
||||
"react-native-gesture-handler": "~2.24.0",
|
||||
"react-native-edge-to-edge": "^1.7.0",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-google-cast": "^4.9.0",
|
||||
"react-native-image-colors": "^2.4.0",
|
||||
"react-native-ios-context-menu": "^3.1.0",
|
||||
"react-native-ios-utilities": "5.1.8",
|
||||
"react-native-mmkv": "2.12.2",
|
||||
"react-native-ios-context-menu": "^3.2.1",
|
||||
"react-native-ios-utilities": "5.2.0",
|
||||
"react-native-mmkv": "4.0.0-beta.12",
|
||||
"react-native-nitro-modules": "^0.29.1",
|
||||
"react-native-pager-view": "^6.9.1",
|
||||
"react-native-reanimated": "~3.19.1",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
"react-native-reanimated-carousel": "4.0.2",
|
||||
"react-native-safe-area-context": "5.4.0",
|
||||
"react-native-screens": "~4.11.1",
|
||||
"react-native-svg": "15.11.2",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-svg": "15.12.1",
|
||||
"react-native-udp": "^4.1.7",
|
||||
"react-native-url-polyfill": "^2.0.0",
|
||||
"react-native-uuid": "^2.0.3",
|
||||
"react-native-video": "6.14.1",
|
||||
"react-native-video": "6.16.1",
|
||||
"react-native-volume-manager": "^2.0.8",
|
||||
"react-native-web": "^0.20.0",
|
||||
"react-native-web": "^0.21.0",
|
||||
"react-native-worklets": "0.5.1",
|
||||
"sonner-native": "^0.21.0",
|
||||
"tailwindcss": "3.3.2",
|
||||
"@tailwindcss/postcss": "^4.1.14",
|
||||
"postcss": "^8.5.6",
|
||||
"use-debounce": "^10.0.4",
|
||||
"zeego": "^3.0.6",
|
||||
"zod": "^4.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -105,25 +109,23 @@
|
||||
"@biomejs/biome": "^2.2.4",
|
||||
"@react-native-community/cli": "^20.0.0",
|
||||
"@react-native-tvos/config-tv": "^0.1.1",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/lodash": "^4.17.15",
|
||||
"@types/react": "~19.0.10",
|
||||
"@types/react": "~19.1.10",
|
||||
"@types/react-test-renderer": "^19.0.0",
|
||||
"expo-doctor": "^1.17.0",
|
||||
"cross-env": "^10.0.0",
|
||||
"expo-doctor": "^1.17.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.1.5",
|
||||
"postinstall-postinstall": "^2.1.0",
|
||||
"react-test-renderer": "19.1.1",
|
||||
"typescript": "~5.8.3"
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"expo": {
|
||||
"install": {
|
||||
"exclude": [
|
||||
"react-native",
|
||||
"@shopify/flash-list",
|
||||
"react-native-reanimated",
|
||||
"react-native-pager-view"
|
||||
"react-native"
|
||||
]
|
||||
},
|
||||
"doctor": {
|
||||
@@ -151,5 +153,8 @@
|
||||
"trustedDependencies": [
|
||||
"postinstall-postinstall",
|
||||
"unrs-resolver"
|
||||
]
|
||||
],
|
||||
"overrides": {
|
||||
"lightningcss": "1.30.1"
|
||||
}
|
||||
}
|
||||
|
||||
58
patches/@react-native-menu+menu+1.2.4.patch
Normal file
58
patches/@react-native-menu+menu+1.2.4.patch
Normal file
@@ -0,0 +1,58 @@
|
||||
diff --git a/node_modules/@react-native-menu/menu/android/.DS_Store b/node_modules/@react-native-menu/menu/android/.DS_Store
|
||||
new file mode 100644
|
||||
index 0000000..5008ddf
|
||||
Binary files /dev/null and b/node_modules/@react-native-menu/menu/android/.DS_Store differ
|
||||
diff --git a/node_modules/@react-native-menu/menu/android/src/main/java/com/reactnativemenu/MenuView.kt b/node_modules/@react-native-menu/menu/android/src/main/java/com/reactnativemenu/MenuView.kt
|
||||
index 17ed7c6..c45f5cc 100644
|
||||
--- a/node_modules/@react-native-menu/menu/android/src/main/java/com/reactnativemenu/MenuView.kt
|
||||
+++ b/node_modules/@react-native-menu/menu/android/src/main/java/com/reactnativemenu/MenuView.kt
|
||||
@@ -24,6 +24,11 @@ class MenuView(private val mContext: ReactContext) : ReactViewGroup(mContext) {
|
||||
private var mIsOnLongPress = false
|
||||
private var mGestureDetector: GestureDetector
|
||||
private var mHitSlopRect: Rect? = null
|
||||
+ set(value) {
|
||||
+ super.hitSlopRect = value
|
||||
+ mHitSlopRect = value
|
||||
+ updateTouchDelegate()
|
||||
+ }
|
||||
|
||||
init {
|
||||
mGestureDetector = GestureDetector(mContext, object : GestureDetector.SimpleOnGestureListener() {
|
||||
@@ -47,12 +52,6 @@ class MenuView(private val mContext: ReactContext) : ReactViewGroup(mContext) {
|
||||
prepareMenu()
|
||||
}
|
||||
|
||||
- override fun setHitSlopRect(rect: Rect?) {
|
||||
- super.setHitSlopRect(rect)
|
||||
- mHitSlopRect = rect
|
||||
- updateTouchDelegate()
|
||||
- }
|
||||
-
|
||||
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
|
||||
return true
|
||||
}
|
||||
diff --git a/node_modules/@react-native-menu/menu/android/src/main/java/com/reactnativemenu/MenuViewManagerBase.kt b/node_modules/@react-native-menu/menu/android/src/main/java/com/reactnativemenu/MenuViewManagerBase.kt
|
||||
index 4731e1a..e4d2743 100644
|
||||
--- a/node_modules/@react-native-menu/menu/android/src/main/java/com/reactnativemenu/MenuViewManagerBase.kt
|
||||
+++ b/node_modules/@react-native-menu/menu/android/src/main/java/com/reactnativemenu/MenuViewManagerBase.kt
|
||||
@@ -123,9 +123,9 @@ abstract class MenuViewManagerBase : ReactClippingViewManager<MenuView>() {
|
||||
fun setHitSlop(view: ReactViewGroup, @Nullable hitSlop: ReadableMap?) {
|
||||
if (hitSlop == null) {
|
||||
// We should keep using setters as `Val cannot be reassigned`
|
||||
- view.setHitSlopRect(null)
|
||||
+ view.hitSlopRect = null
|
||||
} else {
|
||||
- view.setHitSlopRect(
|
||||
+ view.hitSlopRect = (
|
||||
Rect(
|
||||
if (hitSlop.hasKey("left"))
|
||||
PixelUtil.toPixelFromDIP(hitSlop.getDouble("left")).toInt()
|
||||
@@ -206,7 +206,7 @@ abstract class MenuViewManagerBase : ReactClippingViewManager<MenuView>() {
|
||||
|
||||
@ReactProp(name = ViewProps.OVERFLOW)
|
||||
fun setOverflow(view: ReactViewGroup, overflow: String?) {
|
||||
- view.setOverflow(overflow)
|
||||
+ view.overflow = overflow
|
||||
}
|
||||
|
||||
@ReactProp(name = "backfaceVisibility")
|
||||
@@ -1,68 +0,0 @@
|
||||
const { withAppDelegate, withXcodeProject } = require("expo/config-plugins");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
/** @param {import("expo/config-plugins").ExpoConfig} config */
|
||||
function withRNBackgroundDownloader(config) {
|
||||
/* 1️⃣ Add handleEventsForBackgroundURLSession to AppDelegate.swift */
|
||||
config = withAppDelegate(config, (mod) => {
|
||||
const tag = "handleEventsForBackgroundURLSession";
|
||||
if (!mod.modResults.contents.includes(tag)) {
|
||||
mod.modResults.contents = mod.modResults.contents.replace(
|
||||
/\}\s*$/, // insert before final }
|
||||
`
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
handleEventsForBackgroundURLSession identifier: String,
|
||||
completionHandler: @escaping () -> Void
|
||||
) {
|
||||
RNBackgroundDownloader.setCompletionHandlerWithIdentifier(identifier, completionHandler: completionHandler)
|
||||
}
|
||||
}`,
|
||||
);
|
||||
}
|
||||
return mod;
|
||||
});
|
||||
|
||||
/* 2️⃣ Ensure bridging header exists & is attached to *every* app target */
|
||||
config = withXcodeProject(config, (mod) => {
|
||||
const project = mod.modResults;
|
||||
const projectName = config.name || "App";
|
||||
// Fix: Go up one more directory to get to ios/, not ios/ProjectName.xcodeproj/
|
||||
const iosDir = path.dirname(path.dirname(project.filepath));
|
||||
const headerRel = `${projectName}/${projectName}-Bridging-Header.h`;
|
||||
const headerAbs = path.join(iosDir, headerRel);
|
||||
|
||||
// create / append import if missing
|
||||
let headerText = "";
|
||||
try {
|
||||
headerText = fs.readFileSync(headerAbs, "utf8");
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (!headerText.includes("RNBackgroundDownloader.h")) {
|
||||
fs.mkdirSync(path.dirname(headerAbs), { recursive: true });
|
||||
fs.appendFileSync(headerAbs, '#import "RNBackgroundDownloader.h"\n');
|
||||
}
|
||||
|
||||
// Expo 53's xcode‑js doesn't expose pbxTargets().
|
||||
// Setting the property once at the project level is sufficient.
|
||||
["Debug", "Release"].forEach((cfg) => {
|
||||
// Use the detected projectName to set the bridging header path instead of a hardcoded value
|
||||
const bridgingHeaderPath = `${projectName}/${projectName}-Bridging-Header.h`;
|
||||
project.updateBuildProperty(
|
||||
"SWIFT_OBJC_BRIDGING_HEADER",
|
||||
bridgingHeaderPath,
|
||||
cfg,
|
||||
);
|
||||
});
|
||||
|
||||
return mod;
|
||||
});
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
module.exports = withRNBackgroundDownloader;
|
||||
5
postcss.config.mjs
Normal file
5
postcss.config.mjs
Normal file
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
188
providers/Downloads/MIGRATION.md
Normal file
188
providers/Downloads/MIGRATION.md
Normal file
@@ -0,0 +1,188 @@
|
||||
# Download Provider Migration Guide
|
||||
|
||||
## Overview
|
||||
|
||||
The DownloadProvider has been completely rewritten to use the new native `BackgroundDownloader` module instead of the third-party `@kesha-antonov/react-native-background-downloader` library.
|
||||
|
||||
## What Changed
|
||||
|
||||
### New Implementation
|
||||
|
||||
- **Native Module**: Uses our custom `BackgroundDownloader` Expo module built with NSURLSession
|
||||
- **Simplified**: Focuses only on downloading video files
|
||||
- **Background Support**: True iOS background downloads with system integration
|
||||
- **Event-Driven**: Uses native event emitters for progress, completion, and errors
|
||||
|
||||
### Removed Features (For Now)
|
||||
|
||||
The following features from the old implementation have been temporarily removed to simplify the initial version:
|
||||
|
||||
- ✗ Trickplay image downloads
|
||||
- ✗ Subtitle downloads
|
||||
- ✗ Series primary image caching
|
||||
- ✗ Intro/credit segment fetching
|
||||
- ✗ Download queue management with concurrent limits
|
||||
- ✗ Pause/Resume functionality
|
||||
- ✗ Speed calculation and ETA
|
||||
- ✗ Cache directory management
|
||||
|
||||
### Maintained Features
|
||||
|
||||
- ✓ Download video files with progress tracking
|
||||
- ✓ Database persistence (same structure)
|
||||
- ✓ Movies and Episodes support
|
||||
- ✓ Download notifications
|
||||
- ✓ File deletion and management
|
||||
- ✓ Downloaded items listing
|
||||
- ✓ Same context API
|
||||
|
||||
## API Compatibility
|
||||
|
||||
The public API remains mostly the same to avoid breaking existing code:
|
||||
|
||||
### Working Methods
|
||||
|
||||
```typescript
|
||||
const {
|
||||
// Core functionality
|
||||
startBackgroundDownload,
|
||||
cancelDownload,
|
||||
|
||||
// Database operations
|
||||
getDownloadedItems,
|
||||
getDownloadsDatabase,
|
||||
getDownloadedItemById,
|
||||
getDownloadedItemSize,
|
||||
|
||||
// File management
|
||||
deleteFile,
|
||||
deleteItems,
|
||||
deleteAllFiles,
|
||||
|
||||
// State
|
||||
processes,
|
||||
APP_CACHE_DOWNLOAD_DIRECTORY,
|
||||
appSizeUsage,
|
||||
} = useDownload();
|
||||
```
|
||||
|
||||
### Deprecated (No-op) Methods
|
||||
|
||||
These methods exist but do nothing in the new version:
|
||||
|
||||
- `startDownload()` - Use `startBackgroundDownload()` instead
|
||||
- `pauseDownload()` - Not supported yet
|
||||
- `resumeDownload()` - Not supported yet
|
||||
- `deleteFileByType()` - Not needed (only video files)
|
||||
- `cleanCacheDirectory()` - Not needed
|
||||
- `updateDownloadedItem()` - Not needed
|
||||
- `dumpDownloadDiagnostics()` - Not needed
|
||||
|
||||
## Migration Steps
|
||||
|
||||
### For Developers
|
||||
|
||||
1. **No code changes needed** if you're using `startBackgroundDownload()` and basic file management
|
||||
2. **Remove calls** to deprecated methods (they won't break but do nothing)
|
||||
3. **Test downloads** to ensure they work in your workflows
|
||||
|
||||
### For Users
|
||||
|
||||
- **No action required** - the new system uses the same database format
|
||||
- **Existing downloads** will still be accessible
|
||||
- **New downloads** will use the improved background system
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Planned features to add back:
|
||||
|
||||
1. **Pause/Resume**: Using NSURLSession's built-in pause/resume
|
||||
2. **Queue Management**: Better control over concurrent downloads
|
||||
3. **Trickplay**: Re-add trickplay image downloading
|
||||
4. **Subtitles**: Download and link subtitle files
|
||||
5. **Progress Persistence**: Resume downloads after app restart
|
||||
6. **Cellular Control**: Respect cellular data settings
|
||||
7. **Speed/ETA**: Better download metrics
|
||||
|
||||
## Database Structure
|
||||
|
||||
The database structure remains unchanged:
|
||||
|
||||
```typescript
|
||||
interface DownloadsDatabase {
|
||||
movies: Record<string, DownloadedItem>;
|
||||
series: Record<string, DownloadedSeries>;
|
||||
other: Record<string, DownloadedItem>;
|
||||
}
|
||||
|
||||
interface DownloadedItem {
|
||||
item: BaseItemDto;
|
||||
mediaSource: MediaSourceInfo;
|
||||
videoFilePath: string;
|
||||
videoFileSize: number;
|
||||
videoFileName?: string;
|
||||
trickPlayData?: TrickPlayData;
|
||||
introSegments?: MediaTimeSegment[];
|
||||
creditSegments?: MediaTimeSegment[];
|
||||
userData: UserData;
|
||||
}
|
||||
```
|
||||
|
||||
## Known Differences
|
||||
|
||||
1. **Progress Updates**: More frequent and accurate with native module
|
||||
2. **Background Handling**: Better iOS background download support
|
||||
3. **Error Messages**: Different error format from native module
|
||||
4. **File Paths**: Uses `Paths.document` instead of cache directory
|
||||
5. **No Queue**: Downloads start immediately (no queuing system yet)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Downloads not starting
|
||||
|
||||
- Check that the iOS app has been rebuilt with the new native module
|
||||
- Verify network permissions
|
||||
- Check console logs for errors
|
||||
|
||||
### Progress not updating
|
||||
|
||||
- Ensure event listeners are properly registered
|
||||
- Check that the task ID mapping is correct
|
||||
- Verify the download is still active
|
||||
|
||||
### Files not found
|
||||
|
||||
- Old downloads might be in a different location
|
||||
- Re-download content if files are missing
|
||||
- Check file permissions
|
||||
|
||||
## Old Implementation
|
||||
|
||||
The old implementation has been preserved at:
|
||||
- `providers/DownloadProvider.deprecated.tsx`
|
||||
|
||||
You can reference it if needed, but it should not be used in production.
|
||||
|
||||
## Testing
|
||||
|
||||
After migration, test these scenarios:
|
||||
|
||||
- [ ] Download a movie
|
||||
- [ ] Download an episode
|
||||
- [ ] Download multiple items
|
||||
- [ ] Cancel a download
|
||||
- [ ] Delete a downloaded item
|
||||
- [ ] View downloaded items list
|
||||
- [ ] Background app during download
|
||||
- [ ] Force quit and restart app
|
||||
- [ ] Verify notifications appear
|
||||
- [ ] Check file sizes are correct
|
||||
|
||||
## Questions?
|
||||
|
||||
If you encounter issues with the migration, please:
|
||||
1. Check the console logs
|
||||
2. Verify the native module is installed
|
||||
3. Review the old implementation for reference
|
||||
4. Open an issue with details
|
||||
|
||||
149
providers/Downloads/README.md
Normal file
149
providers/Downloads/README.md
Normal file
@@ -0,0 +1,149 @@
|
||||
# Downloads Module
|
||||
|
||||
This module handles all download functionality for the Streamyfin app, including video downloads, subtitles, trickplay images, and cover images.
|
||||
|
||||
## Architecture
|
||||
|
||||
The downloads module is structured with a clean separation of concerns:
|
||||
|
||||
### Core Files
|
||||
|
||||
- **`database.ts`** - Pure functions for MMKV database operations
|
||||
- **`fileOperations.ts`** - Pure functions for file system operations
|
||||
- **`utils.ts`** - Pure utility functions (filename generation, URI conversion)
|
||||
- **`additionalDownloads.ts`** - Pure functions for downloading additional assets
|
||||
- **`notifications.ts`** - Pure functions for notification handling
|
||||
- **`types.ts`** - TypeScript type definitions
|
||||
|
||||
### Hooks
|
||||
|
||||
- **`useDownloadOperations.ts`** - Hook providing download operations (start, cancel, delete)
|
||||
- **`useDownloadEventHandlers.ts`** - Hook setting up native download event listeners
|
||||
|
||||
### Main Provider
|
||||
|
||||
- **`DownloadProvider.tsx`** - React context provider that orchestrates all download functionality
|
||||
|
||||
## Features
|
||||
|
||||
### Video Downloads
|
||||
- Background download support using native module
|
||||
- Progress tracking and reporting
|
||||
- Pause/resume capability (future enhancement)
|
||||
- Download queue management
|
||||
|
||||
### Additional Assets (Automatic)
|
||||
When a video download completes, the following are automatically downloaded:
|
||||
|
||||
1. **Trickplay Images** - Preview thumbnail sheets for video scrubbing
|
||||
2. **Subtitles** - External subtitle files (for non-transcoded content)
|
||||
3. **Cover Images** - Primary item images and series images
|
||||
4. **Segments** - Intro and credit skip timestamps
|
||||
|
||||
### File Management
|
||||
- Automatic cleanup of all associated files (video, subtitles, trickplay)
|
||||
- Size calculation including all assets
|
||||
- Batch delete operations
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Pure Functions
|
||||
All core logic is implemented as pure functions that:
|
||||
- Take explicit parameters
|
||||
- Return explicit values
|
||||
- Have no side effects
|
||||
- Are easily testable
|
||||
|
||||
### Imperative Design
|
||||
The module uses imperative function calls rather than reactive patterns:
|
||||
- Direct function invocation
|
||||
- Explicit error handling
|
||||
- Clear control flow
|
||||
- Minimal side effects
|
||||
|
||||
### Storage
|
||||
- **MMKV** - Used for persistent database storage
|
||||
- **expo-file-system** - Used for file operations
|
||||
- **Native module** - Used for background downloads
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import { useDownload } from '@/providers/DownloadProvider';
|
||||
|
||||
function MyComponent() {
|
||||
const {
|
||||
startBackgroundDownload,
|
||||
cancelDownload,
|
||||
deleteFile,
|
||||
getDownloadedItems,
|
||||
processes,
|
||||
} = useDownload();
|
||||
|
||||
// Start a download
|
||||
await startBackgroundDownload(url, item, mediaSource, bitrate);
|
||||
|
||||
// Cancel a download
|
||||
await cancelDownload(itemId);
|
||||
|
||||
// Delete a download
|
||||
await deleteFile(itemId);
|
||||
|
||||
// Get all downloads
|
||||
const items = getDownloadedItems();
|
||||
}
|
||||
```
|
||||
|
||||
## Event Flow
|
||||
|
||||
1. **Start Download**
|
||||
- Pre-download cover images
|
||||
- Start video download via native module
|
||||
- Track progress via event listeners
|
||||
|
||||
2. **Download Progress**
|
||||
- Native module emits progress events
|
||||
- React state updated with progress percentage
|
||||
- UI reflects current download state
|
||||
|
||||
3. **Download Complete**
|
||||
- Video file saved to disk
|
||||
- Additional assets downloaded in parallel:
|
||||
- Trickplay images
|
||||
- Subtitles (if applicable)
|
||||
- Segments data
|
||||
- Item saved to database
|
||||
- Notification sent
|
||||
- Process removed from queue
|
||||
|
||||
4. **Delete**
|
||||
- Item removed from database
|
||||
- All associated files deleted:
|
||||
- Video file
|
||||
- Subtitle files
|
||||
- Trickplay directory
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
providers/Downloads/
|
||||
├── additionalDownloads.ts # Trickplay, subtitles, cover images
|
||||
├── database.ts # MMKV operations
|
||||
├── fileOperations.ts # File system operations
|
||||
├── notifications.ts # Notification helpers
|
||||
├── types.ts # TypeScript types
|
||||
├── utils.ts # Utility functions
|
||||
├── index.ts # Module exports
|
||||
├── hooks/
|
||||
│ ├── useDownloadEventHandlers.ts
|
||||
│ └── useDownloadOperations.ts
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- Background download scheduling
|
||||
- Network condition awareness
|
||||
- Download priority management
|
||||
- Automatic cleanup of old downloads
|
||||
- Series season download management
|
||||
272
providers/Downloads/additionalDownloads.ts
Normal file
272
providers/Downloads/additionalDownloads.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Directory, File, Paths } from "expo-file-system";
|
||||
import { getItemImage } from "@/utils/getItemImage";
|
||||
import { fetchAndParseSegments } from "@/utils/segments";
|
||||
import { generateTrickplayUrl, getTrickplayInfo } from "@/utils/trickplay";
|
||||
import type { MediaTimeSegment, TrickPlayData } from "./types";
|
||||
import { generateFilename } from "./utils";
|
||||
|
||||
/**
|
||||
* Downloads trickplay images for an item
|
||||
* @returns TrickPlayData with path and size, or undefined if not available
|
||||
*/
|
||||
export async function downloadTrickplayImages(
|
||||
item: BaseItemDto,
|
||||
): Promise<TrickPlayData | undefined> {
|
||||
const trickplayInfo = getTrickplayInfo(item);
|
||||
if (!trickplayInfo || !item.Id) {
|
||||
console.log(`[TRICKPLAY] No trickplay info available for ${item.Name}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const filename = generateFilename(item);
|
||||
const trickplayDir = new Directory(Paths.document, `${filename}_trickplay`);
|
||||
|
||||
// Create directory if it doesn't exist
|
||||
if (!trickplayDir.exists) {
|
||||
trickplayDir.create({ intermediates: true });
|
||||
}
|
||||
|
||||
let totalSize = 0;
|
||||
const downloadPromises: Promise<void>[] = [];
|
||||
|
||||
console.log(
|
||||
`[TRICKPLAY] Downloading ${trickplayInfo.totalImageSheets} sheets for ${item.Name}`,
|
||||
);
|
||||
|
||||
for (let index = 0; index < trickplayInfo.totalImageSheets; index++) {
|
||||
const url = generateTrickplayUrl(item, index);
|
||||
if (!url) continue;
|
||||
|
||||
const destination = new File(trickplayDir, `${index}.jpg`);
|
||||
|
||||
// Skip if already exists
|
||||
if (destination.exists) {
|
||||
totalSize += destination.size;
|
||||
continue;
|
||||
}
|
||||
|
||||
downloadPromises.push(
|
||||
File.downloadFileAsync(url, destination)
|
||||
.then(() => {
|
||||
totalSize += destination.size;
|
||||
console.log(
|
||||
`[TRICKPLAY] Downloaded sheet ${index + 1}/${trickplayInfo.totalImageSheets}`,
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
`[TRICKPLAY] Failed to download sheet ${index}:`,
|
||||
error,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(downloadPromises);
|
||||
|
||||
console.log(
|
||||
`[TRICKPLAY] Completed download for ${item.Name}, total size: ${totalSize} bytes`,
|
||||
);
|
||||
|
||||
return {
|
||||
path: trickplayDir.uri,
|
||||
size: totalSize,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads external subtitle files and updates their delivery URLs to local paths
|
||||
* @returns Updated media source with local subtitle paths
|
||||
*/
|
||||
export async function downloadSubtitles(
|
||||
mediaSource: MediaSourceInfo,
|
||||
item: BaseItemDto,
|
||||
apiBasePath: string,
|
||||
): Promise<MediaSourceInfo> {
|
||||
const externalSubtitles = mediaSource.MediaStreams?.filter(
|
||||
(stream) =>
|
||||
stream.Type === "Subtitle" && stream.DeliveryMethod === "External",
|
||||
);
|
||||
|
||||
if (!externalSubtitles || externalSubtitles.length === 0) {
|
||||
console.log(`[SUBTITLES] No external subtitles for ${item.Name}`);
|
||||
return mediaSource;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[SUBTITLES] Downloading ${externalSubtitles.length} subtitle files for ${item.Name}`,
|
||||
);
|
||||
|
||||
const filename = generateFilename(item);
|
||||
const downloadPromises = externalSubtitles.map(async (subtitle) => {
|
||||
if (!subtitle.DeliveryUrl) return;
|
||||
|
||||
const url = apiBasePath + subtitle.DeliveryUrl;
|
||||
const extension = subtitle.Codec || "srt";
|
||||
const destination = new File(
|
||||
Paths.document,
|
||||
`${filename}_subtitle_${subtitle.Index}.${extension}`,
|
||||
);
|
||||
|
||||
// Skip if already exists
|
||||
if (destination.exists) {
|
||||
subtitle.DeliveryUrl = destination.uri;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await File.downloadFileAsync(url, destination);
|
||||
subtitle.DeliveryUrl = destination.uri;
|
||||
console.log(
|
||||
`[SUBTITLES] Downloaded subtitle ${subtitle.DisplayTitle || subtitle.Language}`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[SUBTITLES] Failed to download subtitle ${subtitle.Index}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(downloadPromises);
|
||||
console.log(`[SUBTITLES] Completed subtitle downloads for ${item.Name}`);
|
||||
|
||||
return mediaSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads and saves the cover image for an item
|
||||
* @returns Path to the saved image, or undefined if failed
|
||||
*/
|
||||
export async function downloadCoverImage(
|
||||
item: BaseItemDto,
|
||||
api: Api,
|
||||
saveImageFn: (itemId: string, url?: string) => Promise<void>,
|
||||
): Promise<string | undefined> {
|
||||
if (!item.Id) {
|
||||
console.log(`[COVER] No item ID for cover image`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const itemImage = getItemImage({
|
||||
item,
|
||||
api,
|
||||
variant: "Primary",
|
||||
quality: 90,
|
||||
width: 500,
|
||||
});
|
||||
|
||||
if (!itemImage?.uri) {
|
||||
console.log(`[COVER] No cover image available for ${item.Name}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
await saveImageFn(item.Id, itemImage.uri);
|
||||
console.log(`[COVER] Saved cover image for ${item.Name}`);
|
||||
|
||||
return itemImage.uri;
|
||||
} catch (error) {
|
||||
console.error(`[COVER] Failed to download cover image:`, error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads and saves the series primary image for an episode
|
||||
* @returns Path to the saved image, or undefined if failed
|
||||
*/
|
||||
export async function downloadSeriesImage(
|
||||
item: BaseItemDto,
|
||||
saveSeriesImageFn: (item: BaseItemDto) => Promise<void>,
|
||||
): Promise<void> {
|
||||
if (item.Type !== "Episode" || !item.SeriesId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await saveSeriesImageFn(item);
|
||||
console.log(`[COVER] Saved series image for ${item.SeriesName}`);
|
||||
} catch (error) {
|
||||
console.error(`[COVER] Failed to download series image:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches intro and credit segments for an item
|
||||
*/
|
||||
export async function fetchSegments(
|
||||
itemId: string,
|
||||
api: Api,
|
||||
): Promise<{
|
||||
introSegments?: MediaTimeSegment[];
|
||||
creditSegments?: MediaTimeSegment[];
|
||||
}> {
|
||||
try {
|
||||
const segments = await fetchAndParseSegments(itemId, api);
|
||||
console.log(`[SEGMENTS] Fetched segments for item ${itemId}`);
|
||||
return segments;
|
||||
} catch (error) {
|
||||
console.error(`[SEGMENTS] Failed to fetch segments:`, error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Orchestrates all additional downloads for a completed item
|
||||
* Called after main video download completes
|
||||
*/
|
||||
export async function downloadAdditionalAssets(params: {
|
||||
item: BaseItemDto;
|
||||
mediaSource: MediaSourceInfo;
|
||||
api: Api;
|
||||
saveImageFn: (itemId: string, url?: string) => Promise<void>;
|
||||
saveSeriesImageFn: (item: BaseItemDto) => Promise<void>;
|
||||
}): Promise<{
|
||||
trickPlayData?: TrickPlayData;
|
||||
updatedMediaSource: MediaSourceInfo;
|
||||
introSegments?: MediaTimeSegment[];
|
||||
creditSegments?: MediaTimeSegment[];
|
||||
}> {
|
||||
const { item, mediaSource, api, saveImageFn, saveSeriesImageFn } = params;
|
||||
|
||||
console.log(`[ADDITIONAL] Starting additional downloads for ${item.Name}`);
|
||||
|
||||
// Run all downloads in parallel for speed
|
||||
const [
|
||||
trickPlayData,
|
||||
updatedMediaSource,
|
||||
segments,
|
||||
// Cover images (fire and forget, errors are logged)
|
||||
] = await Promise.all([
|
||||
downloadTrickplayImages(item),
|
||||
// Only download subtitles for non-transcoded streams
|
||||
mediaSource.TranscodingUrl
|
||||
? Promise.resolve(mediaSource)
|
||||
: downloadSubtitles(mediaSource, item, api.basePath || ""),
|
||||
item.Id ? fetchSegments(item.Id, api) : Promise.resolve({}),
|
||||
// Cover image downloads (run but don't wait for results)
|
||||
downloadCoverImage(item, api, saveImageFn).catch((err) => {
|
||||
console.error("[COVER] Error downloading cover:", err);
|
||||
return undefined;
|
||||
}),
|
||||
downloadSeriesImage(item, saveSeriesImageFn).catch((err) => {
|
||||
console.error("[COVER] Error downloading series image:", err);
|
||||
return undefined;
|
||||
}),
|
||||
]);
|
||||
|
||||
console.log(`[ADDITIONAL] Completed additional downloads for ${item.Name}`);
|
||||
|
||||
return {
|
||||
trickPlayData,
|
||||
updatedMediaSource,
|
||||
introSegments: segments.introSegments,
|
||||
creditSegments: segments.creditSegments,
|
||||
};
|
||||
}
|
||||
189
providers/Downloads/database.ts
Normal file
189
providers/Downloads/database.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import type { DownloadedItem, DownloadsDatabase } from "./types";
|
||||
|
||||
const DOWNLOADS_DATABASE_KEY = "downloads.v2.json";
|
||||
|
||||
/**
|
||||
* Get the downloads database from storage
|
||||
*/
|
||||
export function getDownloadsDatabase(): DownloadsDatabase {
|
||||
const file = storage.getString(DOWNLOADS_DATABASE_KEY);
|
||||
if (file) {
|
||||
return JSON.parse(file) as DownloadsDatabase;
|
||||
}
|
||||
return { movies: {}, series: {}, other: {} };
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the downloads database to storage
|
||||
*/
|
||||
export function saveDownloadsDatabase(db: DownloadsDatabase): void {
|
||||
storage.set(DOWNLOADS_DATABASE_KEY, JSON.stringify(db));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all downloaded items as a flat array
|
||||
*/
|
||||
export function getAllDownloadedItems(): DownloadedItem[] {
|
||||
const db = getDownloadsDatabase();
|
||||
const items: DownloadedItem[] = [];
|
||||
|
||||
for (const movie of Object.values(db.movies)) {
|
||||
items.push(movie);
|
||||
}
|
||||
|
||||
for (const series of Object.values(db.series)) {
|
||||
for (const season of Object.values(series.seasons)) {
|
||||
for (const episode of Object.values(season.episodes)) {
|
||||
items.push(episode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (db.other) {
|
||||
for (const item of Object.values(db.other)) {
|
||||
items.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a downloaded item by its ID
|
||||
*/
|
||||
export function getDownloadedItemById(id: string): DownloadedItem | undefined {
|
||||
const db = getDownloadsDatabase();
|
||||
|
||||
if (db.movies[id]) {
|
||||
return db.movies[id];
|
||||
}
|
||||
|
||||
for (const series of Object.values(db.series)) {
|
||||
for (const season of Object.values(series.seasons)) {
|
||||
for (const episode of Object.values(season.episodes)) {
|
||||
if (episode.item.Id === id) {
|
||||
return episode;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (db.other?.[id]) {
|
||||
return db.other[id];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a downloaded item to the database
|
||||
*/
|
||||
export function addDownloadedItem(item: DownloadedItem): void {
|
||||
const db = getDownloadsDatabase();
|
||||
const baseItem = item.item;
|
||||
|
||||
if (baseItem.Type === "Movie" && baseItem.Id) {
|
||||
db.movies[baseItem.Id] = item;
|
||||
} else if (
|
||||
baseItem.Type === "Episode" &&
|
||||
baseItem.SeriesId &&
|
||||
baseItem.ParentIndexNumber !== undefined &&
|
||||
baseItem.ParentIndexNumber !== null &&
|
||||
baseItem.IndexNumber !== undefined &&
|
||||
baseItem.IndexNumber !== null
|
||||
) {
|
||||
// Ensure series exists
|
||||
if (!db.series[baseItem.SeriesId]) {
|
||||
const seriesInfo: Partial<BaseItemDto> = {
|
||||
Id: baseItem.SeriesId,
|
||||
Name: baseItem.SeriesName,
|
||||
Type: "Series",
|
||||
};
|
||||
db.series[baseItem.SeriesId] = {
|
||||
seriesInfo: seriesInfo as BaseItemDto,
|
||||
seasons: {},
|
||||
};
|
||||
}
|
||||
|
||||
// Ensure season exists
|
||||
const seasonNumber = baseItem.ParentIndexNumber;
|
||||
if (!db.series[baseItem.SeriesId].seasons[seasonNumber]) {
|
||||
db.series[baseItem.SeriesId].seasons[seasonNumber] = {
|
||||
episodes: {},
|
||||
};
|
||||
}
|
||||
|
||||
// Add episode
|
||||
const episodeNumber = baseItem.IndexNumber;
|
||||
db.series[baseItem.SeriesId].seasons[seasonNumber].episodes[episodeNumber] =
|
||||
item;
|
||||
} else if (baseItem.Id) {
|
||||
if (!db.other) db.other = {};
|
||||
db.other[baseItem.Id] = item;
|
||||
}
|
||||
|
||||
saveDownloadsDatabase(db);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a downloaded item from the database
|
||||
* Returns the removed item if found, undefined otherwise
|
||||
*/
|
||||
export function removeDownloadedItem(id: string): DownloadedItem | undefined {
|
||||
const db = getDownloadsDatabase();
|
||||
let itemToDelete: DownloadedItem | undefined;
|
||||
|
||||
// Check movies
|
||||
if (db.movies[id]) {
|
||||
itemToDelete = db.movies[id];
|
||||
delete db.movies[id];
|
||||
} else {
|
||||
// Check series episodes
|
||||
for (const seriesId in db.series) {
|
||||
const series = db.series[seriesId];
|
||||
for (const seasonNum in series.seasons) {
|
||||
const season = series.seasons[seasonNum];
|
||||
for (const episodeNum in season.episodes) {
|
||||
const episode = season.episodes[episodeNum];
|
||||
if (episode.item.Id === id) {
|
||||
itemToDelete = episode;
|
||||
delete season.episodes[episodeNum];
|
||||
|
||||
// Clean up empty season
|
||||
if (Object.keys(season.episodes).length === 0) {
|
||||
delete series.seasons[seasonNum];
|
||||
}
|
||||
|
||||
// Clean up empty series
|
||||
if (Object.keys(series.seasons).length === 0) {
|
||||
delete db.series[seriesId];
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check other items
|
||||
if (!itemToDelete && db.other?.[id]) {
|
||||
itemToDelete = db.other[id];
|
||||
delete db.other[id];
|
||||
}
|
||||
}
|
||||
|
||||
if (itemToDelete) {
|
||||
saveDownloadsDatabase(db);
|
||||
}
|
||||
|
||||
return itemToDelete;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all downloaded items from the database
|
||||
*/
|
||||
export function clearAllDownloadedItems(): void {
|
||||
saveDownloadsDatabase({ movies: {}, series: {}, other: {} });
|
||||
}
|
||||
99
providers/Downloads/fileOperations.ts
Normal file
99
providers/Downloads/fileOperations.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { Directory, File, Paths } from "expo-file-system";
|
||||
import { getAllDownloadedItems, getDownloadedItemById } from "./database";
|
||||
import type { DownloadedItem } from "./types";
|
||||
|
||||
/**
|
||||
* Delete a video file and all associated files (subtitles, trickplay, etc.)
|
||||
*/
|
||||
export function deleteVideoFile(filePath: string): void {
|
||||
try {
|
||||
const videoFile = new File("", filePath);
|
||||
if (videoFile.exists) {
|
||||
videoFile.delete();
|
||||
console.log(`[DELETE] Video file deleted: ${filePath}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to delete video file:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all associated files for a downloaded item
|
||||
* Includes: video, subtitles, trickplay images
|
||||
*/
|
||||
export function deleteAllAssociatedFiles(item: DownloadedItem): void {
|
||||
try {
|
||||
// Delete video file
|
||||
if (item.videoFilePath) {
|
||||
deleteVideoFile(item.videoFilePath);
|
||||
}
|
||||
|
||||
// Delete subtitle files
|
||||
if (item.mediaSource?.MediaStreams) {
|
||||
for (const stream of item.mediaSource.MediaStreams) {
|
||||
if (
|
||||
stream.Type === "Subtitle" &&
|
||||
stream.DeliveryMethod === "External" &&
|
||||
stream.DeliveryUrl
|
||||
) {
|
||||
try {
|
||||
const subtitleFilename = stream.DeliveryUrl.split("/").pop();
|
||||
if (subtitleFilename) {
|
||||
const subtitleFile = new File(Paths.document, subtitleFilename);
|
||||
if (subtitleFile.exists) {
|
||||
subtitleFile.delete();
|
||||
console.log(`[DELETE] Subtitle deleted: ${subtitleFilename}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[DELETE] Failed to delete subtitle:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete trickplay directory
|
||||
if (item.trickPlayData?.path) {
|
||||
try {
|
||||
const trickplayDirName = item.trickPlayData.path.split("/").pop();
|
||||
if (trickplayDirName) {
|
||||
const trickplayDir = new Directory(Paths.document, trickplayDirName);
|
||||
if (trickplayDir.exists) {
|
||||
trickplayDir.delete();
|
||||
console.log(
|
||||
`[DELETE] Trickplay directory deleted: ${trickplayDirName}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[DELETE] Failed to delete trickplay directory:", error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[DELETE] Error deleting associated files:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the size of a downloaded item by ID
|
||||
* Includes video file size and trickplay data size
|
||||
*/
|
||||
export function getDownloadedItemSize(id: string): number {
|
||||
const item = getDownloadedItemById(id);
|
||||
if (!item) return 0;
|
||||
|
||||
const videoSize = item.videoFileSize || 0;
|
||||
const trickplaySize = item.trickPlayData?.size || 0;
|
||||
|
||||
return videoSize + trickplaySize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total size of all downloaded items
|
||||
*/
|
||||
export function calculateTotalDownloadedSize(): number {
|
||||
const items = getAllDownloadedItems();
|
||||
return items.reduce((sum, item) => sum + (item.videoFileSize || 0), 0);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user