mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-04-24 17:44:45 +01:00
Merge branch 'develop' into feat/refresh-metadata
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import type { FC } from "react";
|
||||
import { Platform, View, type ViewProps } from "react-native";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { RoundButton } from "@/components/RoundButton";
|
||||
import { useFavorite } from "@/hooks/useFavorite";
|
||||
|
||||
@@ -11,24 +11,11 @@ interface Props extends ViewProps {
|
||||
export const AddToFavorites: FC<Props> = ({ item, ...props }) => {
|
||||
const { isFavorite, toggleFavorite } = useFavorite(item);
|
||||
|
||||
if (Platform.OS === "ios") {
|
||||
return (
|
||||
<View {...props}>
|
||||
<RoundButton
|
||||
size='large'
|
||||
icon={isFavorite ? "heart" : "heart-outline"}
|
||||
onPress={toggleFavorite}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<RoundButton
|
||||
size='large'
|
||||
icon={isFavorite ? "heart" : "heart-outline"}
|
||||
fillColor={isFavorite ? "primary" : undefined}
|
||||
onPress={toggleFavorite}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
@@ -90,11 +89,8 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
bottomSheetModalRef.current?.present();
|
||||
}, []);
|
||||
|
||||
const handleSheetChanges = useCallback((index: number) => {
|
||||
// Ensure modal is fully dismissed when index is -1
|
||||
if (index === -1) {
|
||||
// Modal is fully closed
|
||||
}
|
||||
const handleSheetChanges = useCallback((_index: number) => {
|
||||
// Modal state tracking handled by BottomSheetModal
|
||||
}, []);
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
@@ -157,6 +153,13 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
itemsNotDownloaded.every((p) => queue.some((q) => p.Id === q.item.Id))
|
||||
);
|
||||
}, [queue, itemsNotDownloaded]);
|
||||
|
||||
const itemsInProgressOrQueued = useMemo(() => {
|
||||
const inProgress = itemsProcesses.length;
|
||||
const inQueue = queue.filter((q) => itemIds.includes(q.item.Id)).length;
|
||||
return inProgress + inQueue;
|
||||
}, [itemsProcesses, queue, itemIds]);
|
||||
|
||||
const navigateToDownloads = () => router.push("/downloads");
|
||||
|
||||
const onDownloadedPress = () => {
|
||||
@@ -256,13 +259,12 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
throw new Error("No item id");
|
||||
}
|
||||
|
||||
// Ensure modal is dismissed before starting download
|
||||
await closeModal();
|
||||
closeModal();
|
||||
|
||||
// Small delay to ensure modal is fully dismissed
|
||||
setTimeout(() => {
|
||||
// Wait for modal dismiss animation to complete
|
||||
requestAnimationFrame(() => {
|
||||
initiateDownload(...itemsToDownload);
|
||||
}, 100);
|
||||
});
|
||||
} else {
|
||||
toast.error(
|
||||
t("home.downloads.toasts.you_are_not_allowed_to_download_files"),
|
||||
@@ -282,7 +284,14 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
);
|
||||
|
||||
const renderButtonContent = () => {
|
||||
if (processes.length > 0 && itemsProcesses.length > 0) {
|
||||
// For single item downloads, show progress if item is being processed
|
||||
// For multi-item downloads (season/series), show progress only if 2+ items are in progress or queued
|
||||
const shouldShowProgress =
|
||||
itemIds.length === 1
|
||||
? itemsProcesses.length > 0
|
||||
: itemsInProgressOrQueued > 1;
|
||||
|
||||
if (processes.length > 0 && shouldShowProgress) {
|
||||
return progress === 0 ? (
|
||||
<Loader />
|
||||
) : (
|
||||
@@ -336,9 +345,6 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
backgroundColor: "#171717",
|
||||
}}
|
||||
onChange={handleSheetChanges}
|
||||
onDismiss={() => {
|
||||
// Ensure any pending state is cleared when modal is dismissed
|
||||
}}
|
||||
backdropComponent={renderBackdrop}
|
||||
enablePanDownToClose
|
||||
enableDismissOnClose
|
||||
@@ -359,16 +365,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 +388,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 +437,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>
|
||||
);
|
||||
};
|
||||
73
components/GlobalModal.tsx
Normal file
73
components/GlobalModal.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
type BottomSheetBackdropProps,
|
||||
BottomSheetModal,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { useCallback } from "react";
|
||||
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||
|
||||
/**
|
||||
* GlobalModal Component
|
||||
*
|
||||
* This component renders a global bottom sheet modal that can be controlled
|
||||
* from anywhere in the app using the useGlobalModal hook.
|
||||
*
|
||||
* Place this component at the root level of your app (in _layout.tsx)
|
||||
* after BottomSheetModalProvider.
|
||||
*/
|
||||
export const GlobalModal = () => {
|
||||
const { hideModal, modalState, modalRef } = useGlobalModal();
|
||||
|
||||
const handleSheetChanges = useCallback(
|
||||
(index: number) => {
|
||||
if (index === -1) {
|
||||
hideModal();
|
||||
}
|
||||
},
|
||||
[hideModal],
|
||||
);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: BottomSheetBackdropProps) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
/>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const defaultOptions = {
|
||||
enableDynamicSizing: true,
|
||||
enablePanDownToClose: true,
|
||||
backgroundStyle: {
|
||||
backgroundColor: "#171717",
|
||||
},
|
||||
handleIndicatorStyle: {
|
||||
backgroundColor: "white",
|
||||
},
|
||||
};
|
||||
|
||||
// Merge default options with provided options
|
||||
const modalOptions = { ...defaultOptions, ...modalState.options };
|
||||
|
||||
return (
|
||||
<BottomSheetModal
|
||||
ref={modalRef}
|
||||
{...(modalOptions.snapPoints
|
||||
? { snapPoints: modalOptions.snapPoints }
|
||||
: { enableDynamicSizing: modalOptions.enableDynamicSizing })}
|
||||
onChange={handleSheetChanges}
|
||||
backdropComponent={renderBackdrop}
|
||||
handleIndicatorStyle={modalOptions.handleIndicatorStyle}
|
||||
backgroundStyle={modalOptions.backgroundStyle}
|
||||
enablePanDownToClose={modalOptions.enablePanDownToClose}
|
||||
enableDismissOnClose
|
||||
stackBehavior='push'
|
||||
style={{ zIndex: 1000 }}
|
||||
>
|
||||
{modalState.content}
|
||||
</BottomSheetModal>
|
||||
);
|
||||
};
|
||||
@@ -211,7 +211,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
<View className='flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink'>
|
||||
<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) =>
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -99,7 +99,7 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
|
||||
style={{
|
||||
top: -50,
|
||||
}}
|
||||
className='relative flex-1 bg-transparent pb-24'
|
||||
className='relative flex-1 bg-transparent pb-4'
|
||||
>
|
||||
<LinearGradient
|
||||
// Background Linear Gradient
|
||||
|
||||
337
components/PlatformDropdown.tsx
Normal file
337
components/PlatformDropdown.tsx
Normal file
@@ -0,0 +1,337 @@
|
||||
import { Button, ContextMenu, Host, Picker } from "@expo/ui/swift-ui";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
||||
import React, { useEffect } from "react";
|
||||
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||
|
||||
// Option types
|
||||
export type RadioOption<T = any> = {
|
||||
type: "radio";
|
||||
label: string;
|
||||
value: T;
|
||||
selected: boolean;
|
||||
onPress: () => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export type ToggleOption = {
|
||||
type: "toggle";
|
||||
label: string;
|
||||
value: boolean;
|
||||
onToggle: () => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export type Option = RadioOption | ToggleOption;
|
||||
|
||||
// Option group structure
|
||||
export type OptionGroup = {
|
||||
title?: string;
|
||||
options: Option[];
|
||||
};
|
||||
|
||||
interface PlatformDropdownProps {
|
||||
trigger?: React.ReactNode;
|
||||
title?: string;
|
||||
groups: OptionGroup[];
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
onOptionSelect?: (value?: any) => void;
|
||||
expoUIConfig?: {
|
||||
hostStyle?: any;
|
||||
};
|
||||
bottomSheetConfig?: {
|
||||
enableDynamicSizing?: boolean;
|
||||
enablePanDownToClose?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const ToggleSwitch: React.FC<{ value: boolean }> = ({ value }) => (
|
||||
<View
|
||||
className={`w-12 h-7 rounded-full ${value ? "bg-purple-600" : "bg-neutral-600"} flex-row items-center`}
|
||||
>
|
||||
<View
|
||||
className={`w-5 h-5 rounded-full bg-white shadow-md transform transition-transform ${
|
||||
value ? "translate-x-6" : "translate-x-1"
|
||||
}`}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
const OptionItem: React.FC<{ option: Option; isLast?: boolean }> = ({
|
||||
option,
|
||||
isLast,
|
||||
}) => {
|
||||
const isToggle = option.type === "toggle";
|
||||
const handlePress = isToggle ? option.onToggle : option.onPress;
|
||||
|
||||
return (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
disabled={option.disabled}
|
||||
className={`px-4 py-3 flex flex-row items-center justify-between ${
|
||||
option.disabled ? "opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
<Text className='flex-1 text-white'>{option.label}</Text>
|
||||
{isToggle ? (
|
||||
<ToggleSwitch value={option.value} />
|
||||
) : option.selected ? (
|
||||
<Ionicons name='checkmark-circle' size={24} color='#9333ea' />
|
||||
) : (
|
||||
<Ionicons name='ellipse-outline' size={24} color='#6b7280' />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
{!isLast && (
|
||||
<View
|
||||
style={{
|
||||
height: StyleSheet.hairlineWidth,
|
||||
}}
|
||||
className='bg-neutral-700 mx-4'
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const OptionGroupComponent: React.FC<{ group: OptionGroup }> = ({ group }) => (
|
||||
<View className='mb-6'>
|
||||
{group.title && (
|
||||
<Text className='text-lg font-semibold mb-3 text-neutral-300'>
|
||||
{group.title}
|
||||
</Text>
|
||||
)}
|
||||
<View
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
className='bg-neutral-800 rounded-xl overflow-hidden'
|
||||
>
|
||||
{group.options.map((option, index) => (
|
||||
<OptionItem
|
||||
key={index}
|
||||
option={option}
|
||||
isLast={index === group.options.length - 1}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
const BottomSheetContent: React.FC<{
|
||||
title?: string;
|
||||
groups: OptionGroup[];
|
||||
onOptionSelect?: (value?: any) => void;
|
||||
onClose?: () => void;
|
||||
}> = ({ title, groups, onOptionSelect, onClose }) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
// Wrap the groups to call onOptionSelect when an option is pressed
|
||||
const wrappedGroups = groups.map((group) => ({
|
||||
...group,
|
||||
options: group.options.map((option) => {
|
||||
if (option.type === "radio") {
|
||||
return {
|
||||
...option,
|
||||
onPress: () => {
|
||||
option.onPress();
|
||||
onOptionSelect?.(option.value);
|
||||
onClose?.();
|
||||
},
|
||||
};
|
||||
}
|
||||
if (option.type === "toggle") {
|
||||
return {
|
||||
...option,
|
||||
onToggle: () => {
|
||||
option.onToggle();
|
||||
onOptionSelect?.(option.value);
|
||||
},
|
||||
};
|
||||
}
|
||||
return option;
|
||||
}),
|
||||
}));
|
||||
|
||||
return (
|
||||
<BottomSheetScrollView
|
||||
className='px-4 pb-8 pt-2'
|
||||
style={{
|
||||
paddingLeft: Math.max(16, insets.left),
|
||||
paddingRight: Math.max(16, insets.right),
|
||||
}}
|
||||
>
|
||||
{title && <Text className='font-bold text-2xl mb-6'>{title}</Text>}
|
||||
{wrappedGroups.map((group, index) => (
|
||||
<OptionGroupComponent key={index} group={group} />
|
||||
))}
|
||||
</BottomSheetScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
const PlatformDropdownComponent = ({
|
||||
trigger,
|
||||
title,
|
||||
groups,
|
||||
open: controlledOpen,
|
||||
onOpenChange: controlledOnOpenChange,
|
||||
onOptionSelect,
|
||||
expoUIConfig,
|
||||
bottomSheetConfig,
|
||||
}: PlatformDropdownProps) => {
|
||||
const { showModal, hideModal } = useGlobalModal();
|
||||
|
||||
// Handle controlled open state for Android
|
||||
useEffect(() => {
|
||||
if (Platform.OS === "android" && controlledOpen === true) {
|
||||
showModal(
|
||||
<BottomSheetContent
|
||||
title={title}
|
||||
groups={groups}
|
||||
onOptionSelect={onOptionSelect}
|
||||
onClose={() => {
|
||||
hideModal();
|
||||
controlledOnOpenChange?.(false);
|
||||
}}
|
||||
/>,
|
||||
{
|
||||
snapPoints: ["90%"],
|
||||
enablePanDownToClose: bottomSheetConfig?.enablePanDownToClose ?? true,
|
||||
},
|
||||
);
|
||||
}
|
||||
}, [controlledOpen]);
|
||||
|
||||
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: Direct modal trigger
|
||||
const handlePress = () => {
|
||||
showModal(
|
||||
<BottomSheetContent
|
||||
title={title}
|
||||
groups={groups}
|
||||
onOptionSelect={onOptionSelect}
|
||||
onClose={hideModal}
|
||||
/>,
|
||||
{
|
||||
snapPoints: ["90%"],
|
||||
enablePanDownToClose: bottomSheetConfig?.enablePanDownToClose ?? true,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity onPress={handlePress} activeOpacity={0.7}>
|
||||
{trigger || <Text className='text-white'>Open Menu</Text>}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
// Memoize to prevent unnecessary re-renders when parent re-renders
|
||||
export const PlatformDropdown = React.memo(
|
||||
PlatformDropdownComponent,
|
||||
(prevProps, nextProps) => {
|
||||
// Custom comparison - only re-render if these props actually change
|
||||
return (
|
||||
prevProps.title === nextProps.title &&
|
||||
prevProps.open === nextProps.open &&
|
||||
prevProps.groups === nextProps.groups && // Reference equality (works because we memoize groups in caller)
|
||||
prevProps.trigger === nextProps.trigger // Reference equality
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -33,10 +33,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;
|
||||
@@ -51,7 +50,6 @@ export const PlayButton: React.FC<Props> = ({
|
||||
selectedOptions,
|
||||
isOffline,
|
||||
colors,
|
||||
...props
|
||||
}: Props) => {
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
const client = useRemoteMediaClient();
|
||||
@@ -165,7 +163,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
api,
|
||||
item,
|
||||
deviceProfile: enableH265 ? chromecasth265 : chromecast,
|
||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks ?? 0,
|
||||
userId: user.Id,
|
||||
audioStreamIndex: selectedOptions.audioIndex,
|
||||
maxStreamingBitrate: selectedOptions.bitrate?.value,
|
||||
@@ -364,6 +362,52 @@ export const PlayButton: React.FC<Props> = ({
|
||||
* *********************
|
||||
*/
|
||||
|
||||
// if (Platform.OS === "ios")
|
||||
// return (
|
||||
// <Host
|
||||
// style={{
|
||||
// height: 50,
|
||||
// flex: 1,
|
||||
// flexShrink: 0,
|
||||
// }}
|
||||
// >
|
||||
// <Button
|
||||
// variant='glassProminent'
|
||||
// onPress={onPress}
|
||||
// color={effectiveColors.primary}
|
||||
// modifiers={[fixedSize()]}
|
||||
// >
|
||||
// <View className='flex flex-row items-center space-x-2 h-full w-full justify-center -mb-3.5 '>
|
||||
// <Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
|
||||
// {runtimeTicksToMinutes(
|
||||
// (item?.RunTimeTicks || 0) -
|
||||
// (item?.UserData?.PlaybackPositionTicks || 0),
|
||||
// )}
|
||||
// {(item?.UserData?.PlaybackPositionTicks || 0) > 0 && " left"}
|
||||
// </Animated.Text>
|
||||
// <Animated.Text style={animatedTextStyle}>
|
||||
// <Ionicons name='play-circle' size={24} />
|
||||
// </Animated.Text>
|
||||
// {client && (
|
||||
// <Animated.Text style={animatedTextStyle}>
|
||||
// <Feather name='cast' size={22} />
|
||||
// <CastButton tintColor='transparent' />
|
||||
// </Animated.Text>
|
||||
// )}
|
||||
// {!client && settings?.openInVLC && (
|
||||
// <Animated.Text style={animatedTextStyle}>
|
||||
// <MaterialCommunityIcons
|
||||
// name='vlc'
|
||||
// size={18}
|
||||
// color={animatedTextStyle.color}
|
||||
// />
|
||||
// </Animated.Text>
|
||||
// )}
|
||||
// </View>
|
||||
// </Button>
|
||||
// </Host>
|
||||
// );
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
disabled={!item}
|
||||
@@ -371,7 +415,6 @@ export const PlayButton: React.FC<Props> = ({
|
||||
accessibilityHint='Tap to play the media'
|
||||
onPress={onPress}
|
||||
className={"relative"}
|
||||
{...props}
|
||||
>
|
||||
<View className='absolute w-full h-full top-0 left-0 rounded-full z-10 overflow-hidden'>
|
||||
<Animated.View
|
||||
@@ -399,7 +442,11 @@ export const PlayButton: React.FC<Props> = ({
|
||||
>
|
||||
<View className='flex flex-row items-center space-x-2'>
|
||||
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
|
||||
{runtimeTicksToMinutes(item?.RunTimeTicks)}
|
||||
{runtimeTicksToMinutes(
|
||||
(item?.RunTimeTicks || 0) -
|
||||
(item?.UserData?.PlaybackPositionTicks || 0),
|
||||
)}
|
||||
{(item?.UserData?.PlaybackPositionTicks || 0) > 0 && " left"}
|
||||
</Animated.Text>
|
||||
<Animated.Text style={animatedTextStyle}>
|
||||
<Ionicons name='play-circle' size={24} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import type React from "react";
|
||||
import { Platform, View, type ViewProps } from "react-native";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||
import { RoundButton } from "./RoundButton";
|
||||
|
||||
@@ -14,25 +14,10 @@ export const PlayedStatus: React.FC<Props> = ({ items, ...props }) => {
|
||||
const allPlayed = items.every((item) => item.UserData?.Played);
|
||||
const toggle = useMarkAsPlayed(items);
|
||||
|
||||
if (Platform.OS === "ios") {
|
||||
return (
|
||||
<View {...props}>
|
||||
<RoundButton
|
||||
color={allPlayed ? "purple" : "white"}
|
||||
icon={allPlayed ? "checkmark" : "checkmark"}
|
||||
onPress={async () => {
|
||||
await toggle(!allPlayed);
|
||||
}}
|
||||
size={props.size}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<RoundButton
|
||||
fillColor={allPlayed ? "primary" : undefined}
|
||||
color={allPlayed ? "purple" : "white"}
|
||||
icon={allPlayed ? "checkmark" : "checkmark"}
|
||||
onPress={async () => {
|
||||
await toggle(!allPlayed);
|
||||
|
||||
@@ -96,7 +96,7 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
className={`rounded-full ${buttonSize} flex items-center justify-center ${
|
||||
fillColor ? fillColorClass : "bg-neutral-800/80"
|
||||
fillColor ? fillColorClass : "bg-transparent"
|
||||
}`}
|
||||
{...(viewProps as any)}
|
||||
>
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -28,15 +28,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;
|
||||
@@ -50,10 +51,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;
|
||||
@@ -63,13 +65,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 = 400;
|
||||
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
|
||||
@@ -88,13 +92,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;
|
||||
|
||||
@@ -180,7 +187,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 || [];
|
||||
@@ -195,7 +202,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,
|
||||
@@ -214,7 +221,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"],
|
||||
});
|
||||
@@ -374,6 +381,8 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
||||
};
|
||||
});
|
||||
|
||||
const togglePlayedStatus = useMarkAsPlayed(items);
|
||||
|
||||
const headerAnimatedStyle = useAnimatedStyle(() => {
|
||||
if (!scrollOffset) return {};
|
||||
return {
|
||||
@@ -521,6 +530,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={{
|
||||
@@ -747,6 +786,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={{
|
||||
@@ -777,7 +849,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;
|
||||
@@ -55,7 +55,7 @@ export const HeaderBackButton: React.FC<Props> = ({
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => router.back()}
|
||||
className=' bg-neutral-800/80 rounded-full p-2'
|
||||
className=' rounded-full p-2'
|
||||
{...touchableOpacityProps}
|
||||
>
|
||||
<Ionicons
|
||||
|
||||
@@ -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" | "estimatedItemSize" | "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,
|
||||
|
||||
@@ -59,6 +59,7 @@ export function InfiniteHorizontalScroll({
|
||||
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||
queryKey,
|
||||
queryFn,
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
getNextPageParam: (lastPage, pages) => {
|
||||
if (
|
||||
!lastPage?.Items ||
|
||||
@@ -119,7 +120,6 @@ export function InfiniteHorizontalScroll({
|
||||
renderItem={({ item, index }) => (
|
||||
<View className='mr-2'>{renderItem(item, index)}</View>
|
||||
)}
|
||||
estimatedItemSize={height}
|
||||
horizontal
|
||||
onEndReached={() => {
|
||||
if (hasNextPage) {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,49 +13,36 @@ 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 { estimateDownloadSize } from "@/utils/download";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { formatTimeString } from "@/utils/time";
|
||||
import { Button } from "../Button";
|
||||
|
||||
const bytesToMB = (bytes: number) => {
|
||||
return bytes / 1024 / 1024;
|
||||
};
|
||||
|
||||
const formatBytes = (bytes: number): string => {
|
||||
if (bytes >= 1024 * 1024 * 1024) {
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
return `${(bytes / (1024 * 1024)).toFixed(0)} MB`;
|
||||
};
|
||||
|
||||
interface DownloadCardProps extends TouchableOpacityProps {
|
||||
process: JobStatus;
|
||||
}
|
||||
|
||||
export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||
const { startDownload, pauseDownload, resumeDownload, removeProcess } =
|
||||
useDownload();
|
||||
const { cancelDownload } = 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);
|
||||
toast.success(t("home.downloads.toasts.download_deleted"));
|
||||
await cancelDownload(id);
|
||||
// cancelDownload already shows a toast, so don't show another one
|
||||
queryClient.invalidateQueries({ queryKey: ["downloads"] });
|
||||
} catch (error) {
|
||||
console.error("Error deleting download:", error);
|
||||
@@ -64,16 +50,48 @@ 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 estimatedSize = useMemo(() => {
|
||||
if (process.estimatedTotalSizeBytes) return process.estimatedTotalSizeBytes;
|
||||
|
||||
// Calculate from bitrate + duration (only if bitrate value is defined)
|
||||
if (process.maxBitrate.value) {
|
||||
return estimateDownloadSize(
|
||||
process.maxBitrate.value,
|
||||
process.item.RunTimeTicks,
|
||||
);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [
|
||||
process.maxBitrate.value,
|
||||
process.item.RunTimeTicks,
|
||||
process.estimatedTotalSizeBytes,
|
||||
]);
|
||||
|
||||
const isTranscoding = process.isTranscoding || false;
|
||||
|
||||
const downloadedAmount = useMemo(() => {
|
||||
if (!process.bytesDownloaded) return null;
|
||||
return formatBytes(process.bytesDownloaded);
|
||||
}, [process.bytesDownloaded]);
|
||||
|
||||
const base64Image = useMemo(() => {
|
||||
return storage.getString(process.item.Id!);
|
||||
@@ -98,9 +116,7 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||
>
|
||||
{process.status === "downloading" && (
|
||||
<View
|
||||
className={`
|
||||
bg-purple-600 h-1 absolute bottom-0 left-0
|
||||
`}
|
||||
className={`bg-purple-600 h-1 absolute bottom-0 left-0 ${isTranscoding ? "animate-pulse" : ""}`}
|
||||
style={{
|
||||
width:
|
||||
sanitizedProgress > 0
|
||||
@@ -111,26 +127,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>
|
||||
@@ -152,47 +152,53 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<View className='shrink mb-1 flex-1'>
|
||||
<View className='shrink mb-1 flex-1 pr-12'>
|
||||
<Text className='text-xs opacity-50'>{process.item.Type}</Text>
|
||||
<Text className='font-semibold shrink'>{process.item.Name}</Text>
|
||||
<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'>
|
||||
|
||||
{isTranscoding && (
|
||||
<View className='bg-purple-600/20 px-2 py-0.5 rounded-md mt-1 self-start'>
|
||||
<Text className='text-xs text-purple-400'>Transcoding</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Row 1: Progress + Downloaded/Total */}
|
||||
<View className='flex flex-row items-center gap-x-2 mt-1.5'>
|
||||
{sanitizedProgress === 0 ? (
|
||||
<ActivityIndicator size={"small"} color={"white"} />
|
||||
) : (
|
||||
<Text className='text-xs'>{sanitizedProgress.toFixed(0)}%</Text>
|
||||
)}
|
||||
{process.speed && process.speed > 0 && (
|
||||
<Text className='text-xs'>
|
||||
{bytesToMB(process.speed).toFixed(2)} MB/s
|
||||
<Text className='text-xs font-semibold'>
|
||||
{sanitizedProgress.toFixed(0)}%
|
||||
</Text>
|
||||
)}
|
||||
{eta(process) && (
|
||||
<Text className='text-xs'>
|
||||
{t("home.downloads.eta", { eta: eta(process) })}
|
||||
{downloadedAmount && (
|
||||
<Text className='text-xs opacity-75'>
|
||||
{downloadedAmount}
|
||||
{estimatedSize
|
||||
? ` / ${isTranscoding ? "~" : ""}${formatBytes(estimatedSize)}`
|
||||
: ""}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className='flex flex-row items-center space-x-2 mt-1 text-purple-600'>
|
||||
<Text className='text-xs capitalize'>{process.status}</Text>
|
||||
{/* Row 2: Speed + ETA */}
|
||||
<View className='flex flex-row items-center gap-x-2 mt-0.5'>
|
||||
{process.speed && process.speed > 0 && (
|
||||
<Text className='text-xs text-purple-400'>
|
||||
{bytesToMB(process.speed).toFixed(2)} MB/s
|
||||
</Text>
|
||||
)}
|
||||
{eta && (
|
||||
<Text className='text-xs text-green-400'>
|
||||
{t("home.downloads.eta", { eta: eta })}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
{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]);
|
||||
|
||||
@@ -29,15 +29,15 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
|
||||
const base64Image = useMemo(() => {
|
||||
return storage.getString(item?.Id!);
|
||||
}, []);
|
||||
return item?.Id ? storage.getString(item.Id) : undefined;
|
||||
}, [item?.Id]);
|
||||
|
||||
/**
|
||||
* Handles deleting the file with haptic feedback.
|
||||
*/
|
||||
const handleDeleteFile = useCallback(() => {
|
||||
if (item.Id) {
|
||||
deleteFile(item.Id, item.Type);
|
||||
deleteFile(item.Id);
|
||||
}
|
||||
}, [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"];
|
||||
|
||||
510
components/home/Home.tsx
Normal file
510
components/home/Home.tsx
Normal file
@@ -0,0 +1,510 @@
|
||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||
import type {
|
||||
BaseItemDto,
|
||||
BaseItemDtoQueryResult,
|
||||
BaseItemKind,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import {
|
||||
getItemsApi,
|
||||
getSuggestionsApi,
|
||||
getTvShowsApi,
|
||||
getUserLibraryApi,
|
||||
getUserViewsApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { type QueryFunction, useQuery } from "@tanstack/react-query";
|
||||
import { useNavigation, useRouter, useSegments } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Platform,
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { eventBus } from "@/utils/eventBus";
|
||||
|
||||
type InfiniteScrollingCollectionListSection = {
|
||||
type: "InfiniteScrollingCollectionList";
|
||||
title?: string;
|
||||
queryKey: (string | undefined | null)[];
|
||||
queryFn: QueryFunction<BaseItemDto[], any, number>;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
pageSize?: number;
|
||||
};
|
||||
|
||||
type MediaListSectionType = {
|
||||
type: "MediaListSection";
|
||||
queryKey: (string | undefined)[];
|
||||
queryFn: QueryFunction<BaseItemDto>;
|
||||
};
|
||||
|
||||
type Section = InfiniteScrollingCollectionListSection | MediaListSectionType;
|
||||
|
||||
export const Home = () => {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const insets = useSafeAreaInsets();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { settings, refreshStreamyfinPluginSettings } = useSettings();
|
||||
const navigation = useNavigation();
|
||||
const scrollRef = useRef<ScrollView>(null);
|
||||
const { downloadedItems, cleanCacheDirectory } = useDownload();
|
||||
const prevIsConnected = useRef<boolean | null>(false);
|
||||
const {
|
||||
isConnected,
|
||||
serverConnected,
|
||||
loading: retryLoading,
|
||||
retryCheck,
|
||||
} = useNetworkStatus();
|
||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||
const [scrollY, setScrollY] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
console.log("scrollY", scrollY);
|
||||
}, [scrollY]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isConnected && !prevIsConnected.current) {
|
||||
invalidateCache();
|
||||
}
|
||||
prevIsConnected.current = isConnected;
|
||||
}, [isConnected, invalidateCache]);
|
||||
|
||||
const hasDownloads = useMemo(() => {
|
||||
if (Platform.isTV) return false;
|
||||
return downloadedItems.length > 0;
|
||||
}, [downloadedItems]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.isTV) {
|
||||
navigation.setOptions({
|
||||
headerLeft: () => null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
navigation.setOptions({
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push("/(auth)/downloads");
|
||||
}}
|
||||
className='ml-1.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather
|
||||
name='download'
|
||||
color={hasDownloads ? Colors.primary : "white"}
|
||||
size={24}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
),
|
||||
});
|
||||
}, [navigation, router, hasDownloads]);
|
||||
|
||||
useEffect(() => {
|
||||
cleanCacheDirectory().catch((_e) =>
|
||||
console.error("Something went wrong cleaning cache directory"),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const segments = useSegments();
|
||||
useEffect(() => {
|
||||
const unsubscribe = eventBus.on("scrollToTop", () => {
|
||||
if ((segments as string[])[2] === "(home)")
|
||||
scrollRef.current?.scrollTo({
|
||||
y: Platform.isTV ? -152 : -100,
|
||||
animated: true,
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [segments]);
|
||||
|
||||
const {
|
||||
data,
|
||||
isError: e1,
|
||||
isLoading: l1,
|
||||
} = useQuery({
|
||||
queryKey: ["home", "userViews", user?.Id],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await getUserViewsApi(api).getUserViews({
|
||||
userId: user.Id,
|
||||
});
|
||||
|
||||
return response.data.Items || null;
|
||||
},
|
||||
enabled: !!api && !!user?.Id,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
const userViews = useMemo(
|
||||
() => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
|
||||
[data, settings?.hiddenLibraries],
|
||||
);
|
||||
|
||||
const collections = useMemo(() => {
|
||||
const allow = ["movies", "tvshows"];
|
||||
return (
|
||||
userViews?.filter(
|
||||
(c) => c.CollectionType && allow.includes(c.CollectionType),
|
||||
) || []
|
||||
);
|
||||
}, [userViews]);
|
||||
|
||||
const refetch = async () => {
|
||||
setLoading(true);
|
||||
await refreshStreamyfinPluginSettings();
|
||||
await invalidateCache();
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const createCollectionConfig = useCallback(
|
||||
(
|
||||
title: string,
|
||||
queryKey: string[],
|
||||
includeItemTypes: BaseItemKind[],
|
||||
parentId: string | undefined,
|
||||
pageSize: number = 10,
|
||||
): InfiniteScrollingCollectionListSection => ({
|
||||
title,
|
||||
queryKey,
|
||||
queryFn: async ({ pageParam = 0 }) => {
|
||||
if (!api) return [];
|
||||
// getLatestMedia doesn't support startIndex, so we fetch all and slice client-side
|
||||
const allData =
|
||||
(
|
||||
await getUserLibraryApi(api).getLatestMedia({
|
||||
userId: user?.Id,
|
||||
limit: 100, // Fetch a larger set for pagination
|
||||
fields: ["PrimaryImageAspectRatio", "Path", "Genres"],
|
||||
imageTypeLimit: 1,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||
includeItemTypes,
|
||||
parentId,
|
||||
})
|
||||
).data || [];
|
||||
|
||||
// Simulate pagination by slicing
|
||||
return allData.slice(pageParam, pageParam + pageSize);
|
||||
},
|
||||
type: "InfiniteScrollingCollectionList",
|
||||
pageSize,
|
||||
}),
|
||||
[api, user?.Id],
|
||||
);
|
||||
|
||||
const defaultSections = useMemo(() => {
|
||||
if (!api || !user?.Id) return [];
|
||||
|
||||
const latestMediaViews = collections.map((c) => {
|
||||
const includeItemTypes: BaseItemKind[] =
|
||||
c.CollectionType === "tvshows" || c.CollectionType === "movies"
|
||||
? []
|
||||
: ["Movie"];
|
||||
const title = t("home.recently_added_in", { libraryName: c.Name });
|
||||
const queryKey: string[] = [
|
||||
"home",
|
||||
`recentlyAddedIn${c.CollectionType}`,
|
||||
user.Id!,
|
||||
c.Id!,
|
||||
];
|
||||
return createCollectionConfig(
|
||||
title || "",
|
||||
queryKey,
|
||||
includeItemTypes,
|
||||
c.Id,
|
||||
10,
|
||||
);
|
||||
});
|
||||
|
||||
const ss: Section[] = [
|
||||
{
|
||||
title: t("home.continue_watching"),
|
||||
queryKey: ["home", "resumeItems"],
|
||||
queryFn: async ({ pageParam = 0 }) =>
|
||||
(
|
||||
await getItemsApi(api).getResumeItems({
|
||||
userId: user.Id,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||
fields: ["Genres"],
|
||||
startIndex: pageParam,
|
||||
limit: 10,
|
||||
})
|
||||
).data.Items || [],
|
||||
type: "InfiniteScrollingCollectionList",
|
||||
orientation: "horizontal",
|
||||
pageSize: 10,
|
||||
},
|
||||
{
|
||||
title: t("home.next_up"),
|
||||
queryKey: ["home", "nextUp-all"],
|
||||
queryFn: async ({ pageParam = 0 }) =>
|
||||
(
|
||||
await getTvShowsApi(api).getNextUp({
|
||||
userId: user?.Id,
|
||||
fields: ["MediaSourceCount", "Genres"],
|
||||
startIndex: pageParam,
|
||||
limit: 10,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||
enableResumable: false,
|
||||
})
|
||||
).data.Items || [],
|
||||
type: "InfiniteScrollingCollectionList",
|
||||
orientation: "horizontal",
|
||||
pageSize: 10,
|
||||
},
|
||||
...latestMediaViews,
|
||||
{
|
||||
title: t("home.suggested_movies"),
|
||||
queryKey: ["home", "suggestedMovies", user?.Id],
|
||||
queryFn: async ({ pageParam = 0 }) =>
|
||||
(
|
||||
await getSuggestionsApi(api).getSuggestions({
|
||||
userId: user?.Id,
|
||||
startIndex: pageParam,
|
||||
limit: 10,
|
||||
mediaType: ["Video"],
|
||||
type: ["Movie"],
|
||||
})
|
||||
).data.Items || [],
|
||||
type: "InfiniteScrollingCollectionList",
|
||||
orientation: "vertical",
|
||||
pageSize: 10,
|
||||
},
|
||||
];
|
||||
return ss;
|
||||
}, [api, user?.Id, collections, t, createCollectionConfig]);
|
||||
|
||||
const customSections = useMemo(() => {
|
||||
if (!api || !user?.Id || !settings?.home?.sections) return [];
|
||||
const ss: Section[] = [];
|
||||
settings.home.sections.forEach((section, index) => {
|
||||
const id = section.title || `section-${index}`;
|
||||
const pageSize = 10;
|
||||
ss.push({
|
||||
title: t(`${id}`),
|
||||
queryKey: ["home", "custom", String(index), section.title ?? null],
|
||||
queryFn: async ({ pageParam = 0 }) => {
|
||||
if (section.items) {
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user?.Id,
|
||||
startIndex: pageParam,
|
||||
limit: section.items?.limit || pageSize,
|
||||
recursive: true,
|
||||
includeItemTypes: section.items?.includeItemTypes,
|
||||
sortBy: section.items?.sortBy,
|
||||
sortOrder: section.items?.sortOrder,
|
||||
filters: section.items?.filters,
|
||||
parentId: section.items?.parentId,
|
||||
});
|
||||
return response.data.Items || [];
|
||||
}
|
||||
if (section.nextUp) {
|
||||
const response = await getTvShowsApi(api).getNextUp({
|
||||
userId: user?.Id,
|
||||
fields: ["MediaSourceCount", "Genres"],
|
||||
startIndex: pageParam,
|
||||
limit: section.nextUp?.limit || pageSize,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||
enableResumable: section.nextUp?.enableResumable,
|
||||
enableRewatching: section.nextUp?.enableRewatching,
|
||||
});
|
||||
return response.data.Items || [];
|
||||
}
|
||||
if (section.latest) {
|
||||
// getLatestMedia doesn't support startIndex, so we fetch all and slice client-side
|
||||
const allData =
|
||||
(
|
||||
await getUserLibraryApi(api).getLatestMedia({
|
||||
userId: user?.Id,
|
||||
includeItemTypes: section.latest?.includeItemTypes,
|
||||
limit: section.latest?.limit || 100, // Fetch larger set
|
||||
isPlayed: section.latest?.isPlayed,
|
||||
groupItems: section.latest?.groupItems,
|
||||
})
|
||||
).data || [];
|
||||
|
||||
// Simulate pagination by slicing
|
||||
return allData.slice(pageParam, pageParam + pageSize);
|
||||
}
|
||||
if (section.custom) {
|
||||
const response = await api.get<BaseItemDtoQueryResult>(
|
||||
section.custom.endpoint,
|
||||
{
|
||||
params: {
|
||||
...(section.custom.query || {}),
|
||||
userId: user?.Id,
|
||||
startIndex: pageParam,
|
||||
limit: pageSize,
|
||||
},
|
||||
headers: section.custom.headers || {},
|
||||
},
|
||||
);
|
||||
return response.data.Items || [];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
type: "InfiniteScrollingCollectionList",
|
||||
orientation: section?.orientation || "vertical",
|
||||
pageSize,
|
||||
});
|
||||
});
|
||||
return ss;
|
||||
}, [api, user?.Id, settings?.home?.sections, t]);
|
||||
|
||||
const sections = settings?.home?.sections ? customSections : defaultSections;
|
||||
|
||||
if (!isConnected || serverConnected !== true) {
|
||||
let title = "";
|
||||
let subtitle = "";
|
||||
|
||||
if (!isConnected) {
|
||||
title = t("home.no_internet");
|
||||
subtitle = t("home.no_internet_message");
|
||||
} else if (serverConnected === null) {
|
||||
title = t("home.checking_server_connection");
|
||||
subtitle = t("home.checking_server_connection_message");
|
||||
} else if (!serverConnected) {
|
||||
title = t("home.server_unreachable");
|
||||
subtitle = t("home.server_unreachable_message");
|
||||
}
|
||||
return (
|
||||
<View className='flex flex-col items-center justify-center h-full -mt-6 px-8'>
|
||||
<Text className='text-3xl font-bold mb-2'>{title}</Text>
|
||||
<Text className='text-center opacity-70'>{subtitle}</Text>
|
||||
|
||||
<View className='mt-4'>
|
||||
{!Platform.isTV && (
|
||||
<Button
|
||||
color='purple'
|
||||
onPress={() => router.push("/(auth)/downloads")}
|
||||
justify='center'
|
||||
iconRight={
|
||||
<Ionicons name='arrow-forward' size={20} color='white' />
|
||||
}
|
||||
>
|
||||
{t("home.go_to_downloads")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
color='black'
|
||||
onPress={retryCheck}
|
||||
justify='center'
|
||||
className='mt-2'
|
||||
iconRight={
|
||||
retryLoading ? null : (
|
||||
<Ionicons name='refresh' size={20} color='white' />
|
||||
)
|
||||
}
|
||||
>
|
||||
{retryLoading ? (
|
||||
<ActivityIndicator size='small' color='white' />
|
||||
) : (
|
||||
t("home.retry")
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (e1)
|
||||
return (
|
||||
<View className='flex flex-col items-center justify-center h-full -mt-6'>
|
||||
<Text className='text-3xl font-bold mb-2'>{t("home.oops")}</Text>
|
||||
<Text className='text-center opacity-70'>
|
||||
{t("home.error_message")}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
if (l1)
|
||||
return (
|
||||
<View className='justify-center items-center h-full'>
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
ref={scrollRef}
|
||||
nestedScrollEnabled
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
onScroll={(event) => {
|
||||
setScrollY(event.nativeEvent.contentOffset.y - 500);
|
||||
}}
|
||||
scrollEventThrottle={16}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={loading}
|
||||
onRefresh={refetch}
|
||||
tintColor='white'
|
||||
colors={["white"]}
|
||||
/>
|
||||
}
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: 16,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
className='flex flex-col space-y-4'
|
||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||
>
|
||||
{sections.map((section, index) => {
|
||||
if (section.type === "InfiniteScrollingCollectionList") {
|
||||
return (
|
||||
<InfiniteScrollingCollectionList
|
||||
key={index}
|
||||
title={section.title}
|
||||
queryKey={section.queryKey}
|
||||
queryFn={section.queryFn}
|
||||
orientation={section.orientation}
|
||||
hideIfEmpty
|
||||
pageSize={section.pageSize}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (section.type === "MediaListSection") {
|
||||
return (
|
||||
<MediaListSection
|
||||
key={index}
|
||||
queryKey={section.queryKey}
|
||||
queryFn={section.queryFn}
|
||||
scrollY={scrollY}
|
||||
enableLazyLoading={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type {
|
||||
BaseItemDto,
|
||||
BaseItemDtoQueryResult,
|
||||
@@ -12,11 +11,7 @@ import {
|
||||
getUserLibraryApi,
|
||||
getUserViewsApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import {
|
||||
type QueryFunction,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { type QueryFunction, useQuery } from "@tanstack/react-query";
|
||||
import { useNavigation, useRouter, useSegments } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
@@ -24,7 +19,6 @@ import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Platform,
|
||||
RefreshControl,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
@@ -35,7 +29,7 @@ import Animated, {
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
||||
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
@@ -45,14 +39,15 @@ 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";
|
||||
type InfiniteScrollingCollectionListSection = {
|
||||
type: "InfiniteScrollingCollectionList";
|
||||
title?: string;
|
||||
queryKey: (string | undefined | null)[];
|
||||
queryFn: QueryFunction<BaseItemDto[]>;
|
||||
queryFn: QueryFunction<BaseItemDto[], any, number>;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
pageSize?: number;
|
||||
};
|
||||
|
||||
type MediaListSectionType = {
|
||||
@@ -61,34 +56,21 @@ type MediaListSectionType = {
|
||||
queryFn: QueryFunction<BaseItemDto>;
|
||||
};
|
||||
|
||||
type Section = ScrollingCollectionListSection | MediaListSectionType;
|
||||
type Section = InfiniteScrollingCollectionListSection | MediaListSectionType;
|
||||
|
||||
export const HomeIndex = () => {
|
||||
export const HomeWithCarousel = () => {
|
||||
const router = useRouter();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [_loading, setLoading] = useState(false);
|
||||
const { settings, refreshStreamyfinPluginSettings } = useSettings();
|
||||
const showLargeHomeCarousel = settings.showLargeHomeCarousel ?? true;
|
||||
const queryClient = useQueryClient();
|
||||
const headerOverlayOffset = Platform.isTV
|
||||
? 0
|
||||
: showLargeHomeCarousel
|
||||
? 60
|
||||
: 0;
|
||||
|
||||
const headerOverlayOffset = Platform.isTV ? 0 : 60;
|
||||
const navigation = useNavigation();
|
||||
|
||||
const animatedScrollRef = useAnimatedRef<Animated.ScrollView>();
|
||||
const scrollOffset = useScrollViewOffset(animatedScrollRef);
|
||||
|
||||
const { getDownloadedItems, cleanCacheDirectory } = useDownload();
|
||||
const { downloadedItems, cleanCacheDirectory } = useDownload();
|
||||
const prevIsConnected = useRef<boolean | null>(false);
|
||||
const {
|
||||
isConnected,
|
||||
@@ -97,15 +79,20 @@ export const HomeIndex = () => {
|
||||
retryCheck,
|
||||
} = useNetworkStatus();
|
||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||
const [scrollY, setScrollY] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
// Only invalidate cache when transitioning from offline to online
|
||||
if (isConnected && !prevIsConnected.current) {
|
||||
invalidateCache();
|
||||
}
|
||||
// Update the ref to the current state for the next render
|
||||
prevIsConnected.current = isConnected;
|
||||
}, [isConnected, invalidateCache]);
|
||||
|
||||
const hasDownloads = useMemo(() => {
|
||||
if (Platform.isTV) return false;
|
||||
return downloadedItems.length > 0;
|
||||
}, [downloadedItems]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.isTV) {
|
||||
navigation.setOptions({
|
||||
@@ -113,7 +100,6 @@ export const HomeIndex = () => {
|
||||
});
|
||||
return;
|
||||
}
|
||||
const hasDownloads = getDownloadedItems().length > 0;
|
||||
navigation.setOptions({
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity
|
||||
@@ -121,6 +107,7 @@ export const HomeIndex = () => {
|
||||
router.push("/(auth)/downloads");
|
||||
}}
|
||||
className='ml-1.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather
|
||||
name='download'
|
||||
@@ -130,7 +117,7 @@ export const HomeIndex = () => {
|
||||
</TouchableOpacity>
|
||||
),
|
||||
});
|
||||
}, [navigation, router]);
|
||||
}, [navigation, router, hasDownloads]);
|
||||
|
||||
useEffect(() => {
|
||||
cleanCacheDirectory().catch((_e) =>
|
||||
@@ -188,55 +175,48 @@ export const HomeIndex = () => {
|
||||
);
|
||||
}, [userViews]);
|
||||
|
||||
const refetch = async () => {
|
||||
const _refetch = async () => {
|
||||
setLoading(true);
|
||||
await refreshStreamyfinPluginSettings();
|
||||
await queryClient.clear();
|
||||
await invalidateCache();
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = eventBus.on("refreshHome", () => {
|
||||
refetch();
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [refetch]);
|
||||
|
||||
const createCollectionConfig = useCallback(
|
||||
(
|
||||
title: string,
|
||||
queryKey: string[],
|
||||
includeItemTypes: BaseItemKind[],
|
||||
parentId: string | undefined,
|
||||
): ScrollingCollectionListSection => ({
|
||||
pageSize: number = 10,
|
||||
): InfiniteScrollingCollectionListSection => ({
|
||||
title,
|
||||
queryKey,
|
||||
queryFn: async () => {
|
||||
queryFn: async ({ pageParam = 0 }) => {
|
||||
if (!api) return [];
|
||||
return (
|
||||
// getLatestMedia doesn't support startIndex, so we fetch all and slice client-side
|
||||
const allData =
|
||||
(
|
||||
await getUserLibraryApi(api).getLatestMedia({
|
||||
userId: user?.Id,
|
||||
limit: 20,
|
||||
limit: 100, // Fetch a larger set for pagination
|
||||
fields: ["PrimaryImageAspectRatio", "Path", "Genres"],
|
||||
imageTypeLimit: 1,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||
includeItemTypes,
|
||||
parentId,
|
||||
})
|
||||
).data || []
|
||||
);
|
||||
).data || [];
|
||||
|
||||
// Simulate pagination by slicing
|
||||
return allData.slice(pageParam, pageParam + pageSize);
|
||||
},
|
||||
type: "ScrollingCollectionList",
|
||||
type: "InfiniteScrollingCollectionList",
|
||||
pageSize,
|
||||
}),
|
||||
[api, user?.Id],
|
||||
);
|
||||
|
||||
// Always call useMemo() at the top-level, using computed dependencies for both "default"/custom sections
|
||||
const defaultSections = useMemo(() => {
|
||||
if (!api || !user?.Id) return [];
|
||||
|
||||
@@ -246,10 +226,10 @@ export const HomeIndex = () => {
|
||||
? []
|
||||
: ["Movie"];
|
||||
const title = t("home.recently_added_in", { libraryName: c.Name });
|
||||
const queryKey = [
|
||||
const queryKey: string[] = [
|
||||
"home",
|
||||
`recentlyAddedIn${c.CollectionType}`,
|
||||
user?.Id!,
|
||||
user.Id!,
|
||||
c.Id!,
|
||||
];
|
||||
return createCollectionConfig(
|
||||
@@ -257,6 +237,7 @@ export const HomeIndex = () => {
|
||||
queryKey,
|
||||
includeItemTypes,
|
||||
c.Id,
|
||||
10,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -264,79 +245,56 @@ export const HomeIndex = () => {
|
||||
{
|
||||
title: t("home.continue_watching"),
|
||||
queryKey: ["home", "resumeItems"],
|
||||
queryFn: async () =>
|
||||
queryFn: async ({ pageParam = 0 }) =>
|
||||
(
|
||||
await getItemsApi(api).getResumeItems({
|
||||
userId: user.Id,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||
fields: ["Genres"],
|
||||
startIndex: pageParam,
|
||||
limit: 10,
|
||||
})
|
||||
).data.Items || [],
|
||||
type: "ScrollingCollectionList",
|
||||
type: "InfiniteScrollingCollectionList",
|
||||
orientation: "horizontal",
|
||||
pageSize: 10,
|
||||
},
|
||||
{
|
||||
title: t("home.next_up"),
|
||||
queryKey: ["home", "nextUp-all"],
|
||||
queryFn: async () =>
|
||||
queryFn: async ({ pageParam = 0 }) =>
|
||||
(
|
||||
await getTvShowsApi(api).getNextUp({
|
||||
userId: user?.Id,
|
||||
fields: ["MediaSourceCount", "Genres"],
|
||||
limit: 20,
|
||||
startIndex: pageParam,
|
||||
limit: 10,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||
enableResumable: false,
|
||||
})
|
||||
).data.Items || [],
|
||||
type: "ScrollingCollectionList",
|
||||
type: "InfiniteScrollingCollectionList",
|
||||
orientation: "horizontal",
|
||||
pageSize: 10,
|
||||
},
|
||||
...latestMediaViews,
|
||||
// ...(mediaListCollections?.map(
|
||||
// (ml) =>
|
||||
// ({
|
||||
// title: ml.Name,
|
||||
// queryKey: ["home", "mediaList", ml.Id!],
|
||||
// queryFn: async () => ml,
|
||||
// type: "MediaListSection",
|
||||
// orientation: "vertical",
|
||||
// } as Section)
|
||||
// ) || []),
|
||||
{
|
||||
title: t("home.suggested_movies"),
|
||||
queryKey: ["home", "suggestedMovies", user?.Id],
|
||||
queryFn: async () =>
|
||||
queryFn: async ({ pageParam = 0 }) =>
|
||||
(
|
||||
await getSuggestionsApi(api).getSuggestions({
|
||||
userId: user?.Id,
|
||||
startIndex: pageParam,
|
||||
limit: 10,
|
||||
mediaType: ["Video"],
|
||||
type: ["Movie"],
|
||||
})
|
||||
).data.Items || [],
|
||||
type: "ScrollingCollectionList",
|
||||
type: "InfiniteScrollingCollectionList",
|
||||
orientation: "vertical",
|
||||
},
|
||||
{
|
||||
title: t("home.suggested_episodes"),
|
||||
queryKey: ["home", "suggestedEpisodes", user?.Id],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const suggestions = await getSuggestions(api, user.Id);
|
||||
const nextUpPromises = suggestions.map((series) =>
|
||||
getNextUp(api, user.Id, series.Id),
|
||||
);
|
||||
const nextUpResults = await Promise.all(nextUpPromises);
|
||||
|
||||
return nextUpResults.filter((item) => item !== null) || [];
|
||||
} catch (error) {
|
||||
console.error("Error fetching data:", error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
type: "ScrollingCollectionList",
|
||||
orientation: "horizontal",
|
||||
pageSize: 10,
|
||||
},
|
||||
];
|
||||
return ss;
|
||||
@@ -347,14 +305,16 @@ export const HomeIndex = () => {
|
||||
const ss: Section[] = [];
|
||||
settings.home.sections.forEach((section, index) => {
|
||||
const id = section.title || `section-${index}`;
|
||||
const pageSize = 10;
|
||||
ss.push({
|
||||
title: t(`${id}`),
|
||||
queryKey: ["home", "custom", String(index), section.title ?? null],
|
||||
queryFn: async () => {
|
||||
queryFn: async ({ pageParam = 0 }) => {
|
||||
if (section.items) {
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user?.Id,
|
||||
limit: section.items?.limit || 25,
|
||||
startIndex: pageParam,
|
||||
limit: section.items?.limit || pageSize,
|
||||
recursive: true,
|
||||
includeItemTypes: section.items?.includeItemTypes,
|
||||
sortBy: section.items?.sortBy,
|
||||
@@ -368,7 +328,8 @@ export const HomeIndex = () => {
|
||||
const response = await getTvShowsApi(api).getNextUp({
|
||||
userId: user?.Id,
|
||||
fields: ["MediaSourceCount", "Genres"],
|
||||
limit: section.nextUp?.limit || 25,
|
||||
startIndex: pageParam,
|
||||
limit: section.nextUp?.limit || pageSize,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||
enableResumable: section.nextUp?.enableResumable,
|
||||
enableRewatching: section.nextUp?.enableRewatching,
|
||||
@@ -376,20 +337,31 @@ export const HomeIndex = () => {
|
||||
return response.data.Items || [];
|
||||
}
|
||||
if (section.latest) {
|
||||
const response = await getUserLibraryApi(api).getLatestMedia({
|
||||
userId: user?.Id,
|
||||
includeItemTypes: section.latest?.includeItemTypes,
|
||||
limit: section.latest?.limit || 25,
|
||||
isPlayed: section.latest?.isPlayed,
|
||||
groupItems: section.latest?.groupItems,
|
||||
});
|
||||
return response.data || [];
|
||||
// getLatestMedia doesn't support startIndex, so we fetch all and slice client-side
|
||||
const allData =
|
||||
(
|
||||
await getUserLibraryApi(api).getLatestMedia({
|
||||
userId: user?.Id,
|
||||
includeItemTypes: section.latest?.includeItemTypes,
|
||||
limit: section.latest?.limit || 100, // Fetch larger set
|
||||
isPlayed: section.latest?.isPlayed,
|
||||
groupItems: section.latest?.groupItems,
|
||||
})
|
||||
).data || [];
|
||||
|
||||
// Simulate pagination by slicing
|
||||
return allData.slice(pageParam, pageParam + pageSize);
|
||||
}
|
||||
if (section.custom) {
|
||||
const response = await api.get<BaseItemDtoQueryResult>(
|
||||
section.custom.endpoint,
|
||||
{
|
||||
params: { ...(section.custom.query || {}), userId: user?.Id },
|
||||
params: {
|
||||
...(section.custom.query || {}),
|
||||
userId: user?.Id,
|
||||
startIndex: pageParam,
|
||||
limit: pageSize,
|
||||
},
|
||||
headers: section.custom.headers || {},
|
||||
},
|
||||
);
|
||||
@@ -397,12 +369,13 @@ export const HomeIndex = () => {
|
||||
}
|
||||
return [];
|
||||
},
|
||||
type: "ScrollingCollectionList",
|
||||
type: "InfiniteScrollingCollectionList",
|
||||
orientation: section?.orientation || "vertical",
|
||||
pageSize,
|
||||
});
|
||||
});
|
||||
return ss;
|
||||
}, [api, user?.Id, settings?.home?.sections]);
|
||||
}, [api, user?.Id, settings?.home?.sections, t]);
|
||||
|
||||
const sections = settings?.home?.sections ? customSections : defaultSections;
|
||||
|
||||
@@ -411,15 +384,12 @@ export const HomeIndex = () => {
|
||||
let subtitle = "";
|
||||
|
||||
if (!isConnected) {
|
||||
// No network connection
|
||||
title = t("home.no_internet");
|
||||
subtitle = t("home.no_internet_message");
|
||||
} else if (serverConnected === null) {
|
||||
// Network is up, but server is being checked
|
||||
title = t("home.checking_server_connection");
|
||||
subtitle = t("home.checking_server_connection_message");
|
||||
} else if (!serverConnected) {
|
||||
// Network is up, but server is unreachable
|
||||
title = t("home.server_unreachable");
|
||||
subtitle = t("home.server_unreachable_message");
|
||||
}
|
||||
@@ -488,48 +458,35 @@ export const HomeIndex = () => {
|
||||
nestedScrollEnabled
|
||||
contentInsetAdjustmentBehavior='never'
|
||||
scrollEventThrottle={16}
|
||||
bounces={!showLargeHomeCarousel}
|
||||
overScrollMode={showLargeHomeCarousel ? "never" : "auto"}
|
||||
refreshControl={
|
||||
showLargeHomeCarousel ? undefined : (
|
||||
<RefreshControl
|
||||
refreshing={loading}
|
||||
onRefresh={refetch}
|
||||
tintColor='white'
|
||||
colors={["white"]}
|
||||
progressViewOffset={100}
|
||||
/>
|
||||
)
|
||||
}
|
||||
bounces={false}
|
||||
overScrollMode='never'
|
||||
style={{ marginTop: -headerOverlayOffset }}
|
||||
contentContainerStyle={{ paddingTop: headerOverlayOffset }}
|
||||
onScroll={(event) => {
|
||||
setScrollY(event.nativeEvent.contentOffset.y);
|
||||
}}
|
||||
>
|
||||
{showLargeHomeCarousel && (
|
||||
<AppleTVCarousel initialIndex={0} scrollOffset={scrollOffset} />
|
||||
)}
|
||||
<AppleTVCarousel initialIndex={0} scrollOffset={scrollOffset} />
|
||||
<View
|
||||
style={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: 16,
|
||||
paddingTop: Platform.isTV
|
||||
? 0
|
||||
: showLargeHomeCarousel
|
||||
? 0
|
||||
: insets.top + 60,
|
||||
paddingTop: 0,
|
||||
}}
|
||||
>
|
||||
<View className='flex flex-col space-y-4'>
|
||||
{sections.map((section, index) => {
|
||||
if (section.type === "ScrollingCollectionList") {
|
||||
if (section.type === "InfiniteScrollingCollectionList") {
|
||||
return (
|
||||
<ScrollingCollectionList
|
||||
<InfiniteScrollingCollectionList
|
||||
key={index}
|
||||
title={section.title}
|
||||
queryKey={section.queryKey}
|
||||
queryFn={section.queryFn}
|
||||
orientation={section.orientation}
|
||||
hideIfEmpty
|
||||
pageSize={section.pageSize}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -539,6 +496,8 @@ export const HomeIndex = () => {
|
||||
key={index}
|
||||
queryKey={section.queryKey}
|
||||
queryFn={section.queryFn}
|
||||
scrollY={scrollY}
|
||||
enableLazyLoading={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -550,30 +509,3 @@ export const HomeIndex = () => {
|
||||
</Animated.ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
// Function to get suggestions
|
||||
async function getSuggestions(api: Api, userId: string | undefined) {
|
||||
if (!userId) return [];
|
||||
const response = await getSuggestionsApi(api).getSuggestions({
|
||||
userId,
|
||||
limit: 10,
|
||||
mediaType: ["Unknown"],
|
||||
type: ["Series"],
|
||||
});
|
||||
return response.data.Items ?? [];
|
||||
}
|
||||
|
||||
// Function to get the next up TV show for a series
|
||||
async function getNextUp(
|
||||
api: Api,
|
||||
userId: string | undefined,
|
||||
seriesId: string | undefined,
|
||||
) {
|
||||
if (!userId || !seriesId) return null;
|
||||
const response = await getTvShowsApi(api).getNextUp({
|
||||
userId,
|
||||
seriesId,
|
||||
limit: 1,
|
||||
});
|
||||
return response.data.Items?.[0] ?? null;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import { Colors } from "../../constants/Colors";
|
||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||
import { ItemCardText } from "../ItemCardText";
|
||||
@@ -35,7 +36,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
queryFn,
|
||||
queryKey,
|
||||
hideIfEmpty = false,
|
||||
pageSize = 20,
|
||||
pageSize = 10,
|
||||
...props
|
||||
}) => {
|
||||
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
|
||||
@@ -52,9 +53,9 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
return allPages.length * pageSize;
|
||||
},
|
||||
initialPageParam: 0,
|
||||
staleTime: 0,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: true,
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: true,
|
||||
});
|
||||
|
||||
@@ -179,8 +180,13 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
))}
|
||||
{/* Loading indicator for next page */}
|
||||
{isFetchingNextPage && (
|
||||
<View className='justify-center items-center w-16'>
|
||||
<ActivityIndicator size='small' color='#6366f1' />
|
||||
<View
|
||||
style={{
|
||||
marginLeft: 8,
|
||||
marginTop: orientation === "horizontal" ? 37 : 70,
|
||||
}}
|
||||
>
|
||||
<ActivityIndicator size='small' color={Colors.primary} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { ScrollView, View, type ViewProps } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import { useInView } from "@/hooks/useInView";
|
||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||
import { ItemCardText } from "../ItemCardText";
|
||||
@@ -21,6 +22,8 @@ interface Props extends ViewProps {
|
||||
queryFn: QueryFunction<BaseItemDto[]>;
|
||||
hideIfEmpty?: boolean;
|
||||
isOffline?: boolean;
|
||||
scrollY?: number; // For lazy loading
|
||||
enableLazyLoading?: boolean; // Enable/disable lazy loading
|
||||
}
|
||||
|
||||
export const ScrollingCollectionList: React.FC<Props> = ({
|
||||
@@ -31,33 +34,44 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
||||
queryKey,
|
||||
hideIfEmpty = false,
|
||||
isOffline = false,
|
||||
scrollY = 0,
|
||||
enableLazyLoading = false,
|
||||
...props
|
||||
}) => {
|
||||
const { ref, isInView, onLayout } = useInView(scrollY, {
|
||||
enabled: enableLazyLoading,
|
||||
});
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: queryKey,
|
||||
queryFn,
|
||||
staleTime: 0,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: true,
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: true,
|
||||
enabled: enableLazyLoading ? isInView : true,
|
||||
});
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (hideIfEmpty === true && data?.length === 0) return null;
|
||||
// Show skeleton if loading OR if lazy loading is enabled and not in view yet
|
||||
const shouldShowSkeleton = isLoading || (enableLazyLoading && !isInView);
|
||||
|
||||
if (hideIfEmpty === true && data?.length === 0 && !shouldShowSkeleton)
|
||||
return null;
|
||||
if (disabled || !title) return null;
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<View ref={ref} onLayout={onLayout} {...props}>
|
||||
<Text className='px-4 text-lg font-bold mb-2 text-neutral-100'>
|
||||
{title}
|
||||
</Text>
|
||||
{isLoading === false && data?.length === 0 && (
|
||||
{!shouldShowSkeleton && data?.length === 0 && (
|
||||
<View className='px-4'>
|
||||
<Text className='text-neutral-500'>{t("home.no_items")}</Text>
|
||||
</View>
|
||||
)}
|
||||
{isLoading ? (
|
||||
{shouldShowSkeleton ? (
|
||||
<View
|
||||
className={`
|
||||
flex flex-row gap-2 px-4
|
||||
|
||||
@@ -23,7 +23,6 @@ const CastSlide: React.FC<
|
||||
showsHorizontalScrollIndicator={false}
|
||||
data={details?.credits.cast}
|
||||
ItemSeparatorComponent={() => <View className='w-2' />}
|
||||
estimatedItemSize={15}
|
||||
keyExtractor={(item) => item?.id?.toString()}
|
||||
contentContainerStyle={{ paddingHorizontal: 16 }}
|
||||
renderItem={({ item }) => (
|
||||
|
||||
@@ -143,7 +143,6 @@ const ParallaxSlideShow = <T,>({
|
||||
renderItem={({ item, index }) => renderItem(item, index)}
|
||||
keyExtractor={keyExtractor}
|
||||
numColumns={3}
|
||||
estimatedItemSize={214}
|
||||
ItemSeparatorComponent={() => <View className='h-2 w-2' />}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -10,8 +10,8 @@ import { forwardRef, useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { 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,
|
||||
@@ -48,8 +48,22 @@ const RequestModal = forwardRef<
|
||||
userId: jellyseerrUser?.id,
|
||||
});
|
||||
|
||||
const [qualityProfileOpen, setQualityProfileOpen] = useState(false);
|
||||
const [rootFolderOpen, setRootFolderOpen] = useState(false);
|
||||
const [tagsOpen, setTagsOpen] = useState(false);
|
||||
const [usersOpen, setUsersOpen] = useState(false);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Reset all dropdown states when modal closes
|
||||
const handleDismiss = useCallback(() => {
|
||||
setQualityProfileOpen(false);
|
||||
setRootFolderOpen(false);
|
||||
setTagsOpen(false);
|
||||
setUsersOpen(false);
|
||||
onDismiss?.();
|
||||
}, [onDismiss]);
|
||||
|
||||
const { data: serviceSettings } = useQuery({
|
||||
queryKey: ["jellyseerr", "request", type, "service"],
|
||||
queryFn: async () =>
|
||||
@@ -138,6 +152,109 @@ const RequestModal = forwardRef<
|
||||
});
|
||||
}, [requestBody?.seasons]);
|
||||
|
||||
const pathTitleExtractor = (item: RootFolder) =>
|
||||
`${item.path} (${item.freeSpace.bytesToReadable()})`;
|
||||
|
||||
const qualityProfileOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
options:
|
||||
defaultServiceDetails?.profiles.map((profile) => ({
|
||||
type: "radio" as const,
|
||||
label: profile.name,
|
||||
value: profile.id.toString(),
|
||||
selected:
|
||||
(requestOverrides.profileId || defaultProfile?.id) ===
|
||||
profile.id,
|
||||
onPress: () =>
|
||||
setRequestOverrides((prev) => ({
|
||||
...prev,
|
||||
profileId: profile.id,
|
||||
})),
|
||||
})) || [],
|
||||
},
|
||||
],
|
||||
[
|
||||
defaultServiceDetails?.profiles,
|
||||
defaultProfile,
|
||||
requestOverrides.profileId,
|
||||
],
|
||||
);
|
||||
|
||||
const rootFolderOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
options:
|
||||
defaultServiceDetails?.rootFolders.map((folder) => ({
|
||||
type: "radio" as const,
|
||||
label: pathTitleExtractor(folder),
|
||||
value: folder.id.toString(),
|
||||
selected:
|
||||
(requestOverrides.rootFolder || defaultFolder?.path) ===
|
||||
folder.path,
|
||||
onPress: () =>
|
||||
setRequestOverrides((prev) => ({
|
||||
...prev,
|
||||
rootFolder: folder.path,
|
||||
})),
|
||||
})) || [],
|
||||
},
|
||||
],
|
||||
[
|
||||
defaultServiceDetails?.rootFolders,
|
||||
defaultFolder,
|
||||
requestOverrides.rootFolder,
|
||||
],
|
||||
);
|
||||
|
||||
const tagsOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
options:
|
||||
defaultServiceDetails?.tags.map((tag) => ({
|
||||
type: "toggle" as const,
|
||||
label: tag.label,
|
||||
value:
|
||||
requestOverrides.tags?.includes(tag.id) ||
|
||||
defaultTags.some((dt) => dt.id === tag.id),
|
||||
onToggle: () =>
|
||||
setRequestOverrides((prev) => {
|
||||
const currentTags = prev.tags || defaultTags.map((t) => t.id);
|
||||
const hasTag = currentTags.includes(tag.id);
|
||||
return {
|
||||
...prev,
|
||||
tags: hasTag
|
||||
? currentTags.filter((id) => id !== tag.id)
|
||||
: [...currentTags, tag.id],
|
||||
};
|
||||
}),
|
||||
})) || [],
|
||||
},
|
||||
],
|
||||
[defaultServiceDetails?.tags, defaultTags, requestOverrides.tags],
|
||||
);
|
||||
|
||||
const usersOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
options:
|
||||
users?.map((user) => ({
|
||||
type: "radio" as const,
|
||||
label: user.displayName,
|
||||
value: user.id.toString(),
|
||||
selected:
|
||||
(requestOverrides.userId || jellyseerrUser?.id) === user.id,
|
||||
onPress: () =>
|
||||
setRequestOverrides((prev) => ({
|
||||
...prev,
|
||||
userId: user.id,
|
||||
})),
|
||||
})) || [],
|
||||
},
|
||||
],
|
||||
[users, jellyseerrUser, requestOverrides.userId],
|
||||
);
|
||||
|
||||
const request = useCallback(() => {
|
||||
const body = {
|
||||
is4k: defaultService?.is4k || defaultServiceDetails?.server.is4k,
|
||||
@@ -163,15 +280,12 @@ const RequestModal = forwardRef<
|
||||
defaultTags,
|
||||
]);
|
||||
|
||||
const pathTitleExtractor = (item: RootFolder) =>
|
||||
`${item.path} (${item.freeSpace.bytesToReadable()})`;
|
||||
|
||||
return (
|
||||
<BottomSheetModal
|
||||
ref={ref}
|
||||
enableDynamicSizing
|
||||
enableDismissOnClose
|
||||
onDismiss={onDismiss}
|
||||
onDismiss={handleDismiss}
|
||||
handleIndicatorStyle={{
|
||||
backgroundColor: "white",
|
||||
}}
|
||||
@@ -185,6 +299,7 @@ const RequestModal = forwardRef<
|
||||
appearsOnIndex={0}
|
||||
/>
|
||||
)}
|
||||
stackBehavior='push'
|
||||
>
|
||||
<BottomSheetView>
|
||||
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
||||
@@ -199,70 +314,112 @@ const RequestModal = forwardRef<
|
||||
<View className='flex flex-col space-y-2'>
|
||||
{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={
|
||||
<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}>
|
||||
{defaultServiceDetails.profiles.find(
|
||||
(p) =>
|
||||
p.id ===
|
||||
(requestOverrides.profileId ||
|
||||
defaultProfile?.id),
|
||||
)?.name || defaultProfile?.name}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
title={t("jellyseerr.quality_profile")}
|
||||
open={qualityProfileOpen}
|
||||
onOpenChange={setQualityProfileOpen}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className='flex flex-col'>
|
||||
<Text className='opacity-50 mb-1 text-xs'>
|
||||
{t("jellyseerr.root_folder")}
|
||||
</Text>
|
||||
<PlatformDropdown
|
||||
groups={rootFolderOptions}
|
||||
trigger={
|
||||
<View className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||
<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>
|
||||
</View>
|
||||
}
|
||||
title={t("jellyseerr.root_folder")}
|
||||
open={rootFolderOpen}
|
||||
onOpenChange={setRootFolderOpen}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className='flex flex-col'>
|
||||
<Text className='opacity-50 mb-1 text-xs'>
|
||||
{t("jellyseerr.tags")}
|
||||
</Text>
|
||||
<PlatformDropdown
|
||||
groups={tagsOptions}
|
||||
trigger={
|
||||
<View className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||
<Text numberOfLines={1}>
|
||||
{requestOverrides.tags
|
||||
? defaultServiceDetails.tags
|
||||
.filter((t) =>
|
||||
requestOverrides.tags!.includes(t.id),
|
||||
)
|
||||
.map((t) => t.label)
|
||||
.join(", ") ||
|
||||
defaultTags.map((t) => t.label).join(", ")
|
||||
: defaultTags.map((t) => t.label).join(", ")}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
title={t("jellyseerr.tags")}
|
||||
open={tagsOpen}
|
||||
onOpenChange={setTagsOpen}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className='flex flex-col'>
|
||||
<Text className='opacity-50 mb-1 text-xs'>
|
||||
{t("jellyseerr.request_as")}
|
||||
</Text>
|
||||
<PlatformDropdown
|
||||
groups={usersOptions}
|
||||
trigger={
|
||||
<View className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||
<Text numberOfLines={1}>
|
||||
{users.find(
|
||||
(u) =>
|
||||
u.id ===
|
||||
(requestOverrides.userId || jellyseerrUser?.id),
|
||||
)?.displayName || jellyseerrUser!.displayName}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
title={t("jellyseerr.request_as")}
|
||||
open={usersOpen}
|
||||
onOpenChange={setUsersOpen}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import type { ContentStyle } from "@shopify/flash-list/src/FlashListProps";
|
||||
import { t } from "i18next";
|
||||
import type React from "react";
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { View, type ViewProps, type ViewStyle } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||
import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
|
||||
|
||||
export interface SlideProps {
|
||||
slide: DiscoverSlider;
|
||||
contentContainerStyle?: ContentStyle;
|
||||
contentContainerStyle?: ViewStyle;
|
||||
}
|
||||
|
||||
interface Props<T> extends SlideProps {
|
||||
@@ -45,7 +44,6 @@ const Slide = <T,>({
|
||||
}}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemSize={250}
|
||||
data={data}
|
||||
onEndReachedThreshold={1}
|
||||
onEndReached={onEndReached}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback } from "react";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { useInView } from "@/hooks/useInView";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { InfiniteHorizontalScroll } from "../common/InfiniteHorizontalScroll";
|
||||
import { Text } from "../common/Text";
|
||||
@@ -21,20 +22,29 @@ import MoviePoster from "../posters/MoviePoster";
|
||||
interface Props extends ViewProps {
|
||||
queryKey: QueryKey;
|
||||
queryFn: QueryFunction<BaseItemDto>;
|
||||
scrollY?: number; // For lazy loading
|
||||
enableLazyLoading?: boolean; // Enable/disable lazy loading
|
||||
}
|
||||
|
||||
export const MediaListSection: React.FC<Props> = ({
|
||||
queryFn,
|
||||
queryKey,
|
||||
scrollY = 0,
|
||||
enableLazyLoading = false,
|
||||
...props
|
||||
}) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const { ref, isInView, onLayout } = useInView(scrollY, {
|
||||
enabled: enableLazyLoading,
|
||||
});
|
||||
|
||||
const { data: collection } = useQuery({
|
||||
queryKey,
|
||||
queryFn,
|
||||
staleTime: 0,
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
enabled: enableLazyLoading ? isInView : true,
|
||||
});
|
||||
|
||||
const fetchItems = useCallback(
|
||||
@@ -60,7 +70,7 @@ export const MediaListSection: React.FC<Props> = ({
|
||||
if (!collection) return null;
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<View ref={ref} onLayout={onLayout} {...props}>
|
||||
<Text className='px-4 text-lg font-bold mb-2 text-neutral-100'>
|
||||
{collection.Name}
|
||||
</Text>
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -34,7 +34,6 @@ export const SearchItemWrapper = <T,>({
|
||||
}}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
keyExtractor={(_, index) => index.toString()}
|
||||
estimatedItemSize={250}
|
||||
data={items}
|
||||
onEndReachedThreshold={1}
|
||||
onEndReached={onEndReached}
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -47,7 +47,6 @@ const JellyseerrSeasonEpisodes: React.FC<{
|
||||
horizontal
|
||||
loading={isLoading}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
estimatedItemSize={50}
|
||||
data={seasonWithEpisodes?.episodes}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
renderItem={(item, index) => (
|
||||
@@ -284,7 +283,6 @@ const JellyseerrSeasons: React.FC<{
|
||||
</View>
|
||||
)}
|
||||
ItemSeparatorComponent={() => <View className='h-2' />}
|
||||
estimatedItemSize={250}
|
||||
renderItem={({ item: season }) => (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
|
||||
@@ -49,7 +49,6 @@ export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
|
||||
<FlashList
|
||||
contentContainerStyle={{ paddingLeft: 16 }}
|
||||
horizontal
|
||||
estimatedItemSize={172}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
data={items}
|
||||
renderItem={({ item, index }) => (
|
||||
|
||||
@@ -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, useState } from "react";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { Text } from "../common/Text";
|
||||
import { PlatformDropdown } from "../PlatformDropdown";
|
||||
|
||||
type Props = {
|
||||
item: BaseItemDto;
|
||||
@@ -33,6 +31,7 @@ export const SeasonDropdown: React.FC<Props> = ({
|
||||
onSelect,
|
||||
}) => {
|
||||
const isTv = Platform.isTV;
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const keys = useMemo<SeasonKeys>(
|
||||
() =>
|
||||
@@ -55,6 +54,31 @@ 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(
|
||||
() => [
|
||||
{
|
||||
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,23 @@ 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'>
|
||||
<PlatformDropdown
|
||||
groups={optionGroups}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
trigger={
|
||||
<TouchableOpacity onPress={() => setOpen(true)}>
|
||||
<View className='bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||
<Text>
|
||||
{t("item_card.season")} {seasonIndex}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</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>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
title={t("item_card.seasons")}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { router } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { TouchableOpacity, type ViewProps } from "react-native";
|
||||
import { TouchableOpacity, type ViewStyle } from "react-native";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||
@@ -14,22 +14,28 @@ import {
|
||||
} from "../common/HorizontalScroll";
|
||||
import { ItemCardText } from "../ItemCardText";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
interface Props {
|
||||
item?: BaseItemDto | null;
|
||||
loading?: boolean;
|
||||
isOffline?: boolean;
|
||||
style?: ViewStyle;
|
||||
containerStyle?: ViewStyle;
|
||||
}
|
||||
|
||||
export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
||||
item,
|
||||
loading,
|
||||
isOffline,
|
||||
...props
|
||||
style,
|
||||
containerStyle,
|
||||
}) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const { getDownloadedItems } = useDownload();
|
||||
const downloadedFiles = getDownloadedItems();
|
||||
const downloadedFiles = useMemo(
|
||||
() => getDownloadedItems(),
|
||||
[getDownloadedItems],
|
||||
);
|
||||
|
||||
const scrollRef = useRef<HorizontalScrollRef>(null);
|
||||
|
||||
@@ -87,6 +93,8 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
||||
data={episodes}
|
||||
extraData={item}
|
||||
loading={loading || isPending}
|
||||
style={style}
|
||||
containerStyle={containerStyle}
|
||||
renderItem={(_item, _idx) => (
|
||||
<TouchableOpacity
|
||||
key={_item.Id}
|
||||
@@ -101,7 +109,6 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
||||
<ItemCardText item={_item} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -86,7 +86,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
||||
seasonId: selectedSeasonId,
|
||||
enableUserData: true,
|
||||
// Note: Including trick play is necessary to enable trick play downloads
|
||||
fields: ["Overview", "Trickplay"],
|
||||
fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
|
||||
});
|
||||
|
||||
if (res.data.TotalRecordCount === 0)
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||
|
||||
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||
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 +16,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 +48,24 @@ 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'>
|
||||
<Text>
|
||||
<PlatformDropdown
|
||||
groups={optionGroups}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-2'>
|
||||
{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>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.languages.title")}
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
</View>
|
||||
|
||||
63
components/settings/AppearanceSettings.tsx
Normal file
63
components/settings/AppearanceSettings.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { useRouter } from "expo-router";
|
||||
import type React from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Linking, Switch } from "react-native";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
|
||||
export const AppearanceSettings: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const disabled = useMemo(
|
||||
() =>
|
||||
pluginSettings?.showCustomMenuLinks?.locked === true &&
|
||||
pluginSettings?.hiddenLibraries?.locked === true,
|
||||
[pluginSettings],
|
||||
);
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
return (
|
||||
<DisabledSetting disabled={disabled}>
|
||||
<ListGroup title={t("home.settings.appearance.title")} className=''>
|
||||
<ListItem
|
||||
title={t("home.settings.other.show_custom_menu_links")}
|
||||
disabled={pluginSettings?.showCustomMenuLinks?.locked}
|
||||
onPress={() =>
|
||||
Linking.openURL(
|
||||
"https://jellyfin.org/docs/general/clients/web-config/#custom-menu-links",
|
||||
)
|
||||
}
|
||||
>
|
||||
<Switch
|
||||
value={settings.showCustomMenuLinks}
|
||||
disabled={pluginSettings?.showCustomMenuLinks?.locked}
|
||||
onValueChange={(value) =>
|
||||
updateSettings({ showCustomMenuLinks: value })
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem title={t("home.settings.other.show_large_home_carousel")}>
|
||||
<Switch
|
||||
value={settings.showLargeHomeCarousel}
|
||||
onValueChange={(value) =>
|
||||
updateSettings({ showLargeHomeCarousel: value })
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
onPress={() =>
|
||||
router.push("/settings/appearance/hide-libraries/page")
|
||||
}
|
||||
title={t("home.settings.other.hide_libraries")}
|
||||
showArrow
|
||||
/>
|
||||
</ListGroup>
|
||||
</DisabledSetting>
|
||||
);
|
||||
};
|
||||
@@ -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-1.5 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>
|
||||
|
||||
@@ -1,44 +1,3 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Stepper } from "@/components/inputs/Stepper";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import { type Settings, useSettings } from "@/utils/atoms/settings";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
|
||||
export default function DownloadSettings({ ...props }) {
|
||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const allDisabled = useMemo(
|
||||
() =>
|
||||
pluginSettings?.remuxConcurrentLimit?.locked === true &&
|
||||
pluginSettings?.autoDownload?.locked === true,
|
||||
[pluginSettings],
|
||||
);
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
return (
|
||||
<DisabledSetting disabled={allDisabled} {...props} className='mb-4'>
|
||||
<ListGroup title={t("home.settings.downloads.downloads_title")}>
|
||||
<ListItem
|
||||
title={t("home.settings.downloads.remux_max_download")}
|
||||
disabled={pluginSettings?.remuxConcurrentLimit?.locked}
|
||||
>
|
||||
<Stepper
|
||||
value={settings.remuxConcurrentLimit}
|
||||
step={1}
|
||||
min={1}
|
||||
max={4}
|
||||
onUpdate={(value) =>
|
||||
updateSettings({
|
||||
remuxConcurrentLimit: value as Settings["remuxConcurrentLimit"],
|
||||
})
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
</DisabledSetting>
|
||||
);
|
||||
export default function DownloadSettings() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,22 +1,15 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useRouter } from "expo-router";
|
||||
import * as TaskManager from "expo-task-manager";
|
||||
import { TFunction } from "i18next";
|
||||
import type React from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Linking, Platform, Switch, TouchableOpacity } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { Linking, Switch, View } from "react-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";
|
||||
import {
|
||||
BACKGROUND_FETCH_TASK,
|
||||
registerBackgroundFetchAsync,
|
||||
unregisterBackgroundFetchAsync,
|
||||
} from "@/utils/background-tasks";
|
||||
import { Text } from "../common/Text";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
@@ -27,39 +20,8 @@ export const OtherSettings: React.FC = () => {
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
/********************
|
||||
* Background task
|
||||
*******************/
|
||||
const checkStatusAsync = async () => {
|
||||
if (Platform.isTV) return false;
|
||||
return TaskManager.isTaskRegisteredAsync(BACKGROUND_FETCH_TASK);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const registered = await checkStatusAsync();
|
||||
|
||||
if (settings?.autoDownload === true && !registered) {
|
||||
registerBackgroundFetchAsync();
|
||||
toast.success(t("home.settings.toasts.background_downloads_enabled"));
|
||||
} else if (settings?.autoDownload === false && registered) {
|
||||
unregisterBackgroundFetchAsync();
|
||||
toast.info(t("home.settings.toasts.background_downloads_disabled"));
|
||||
} else if (settings?.autoDownload === true && registered) {
|
||||
// Don't to anything
|
||||
} else if (settings?.autoDownload === false && !registered) {
|
||||
// Don't to anything
|
||||
} else {
|
||||
updateSettings({ autoDownload: false });
|
||||
}
|
||||
})();
|
||||
}, [settings?.autoDownload]);
|
||||
/**********************
|
||||
*********************/
|
||||
|
||||
const disabled = useMemo(
|
||||
() =>
|
||||
pluginSettings?.followDeviceOrientation?.locked === true &&
|
||||
pluginSettings?.defaultVideoOrientation?.locked === true &&
|
||||
pluginSettings?.safeAreaInControlsEnabled?.locked === true &&
|
||||
pluginSettings?.showCustomMenuLinks?.locked === true &&
|
||||
@@ -89,41 +51,65 @@ export const OtherSettings: React.FC = () => {
|
||||
[],
|
||||
);
|
||||
|
||||
const orientationOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
options: orientations.map((orientation) => ({
|
||||
type: "radio" as const,
|
||||
label: t(ScreenOrientationEnum[orientation]),
|
||||
value: String(orientation),
|
||||
selected: orientation === settings?.defaultVideoOrientation,
|
||||
onPress: () =>
|
||||
updateSettings({ defaultVideoOrientation: orientation }),
|
||||
})),
|
||||
},
|
||||
],
|
||||
[orientations, settings?.defaultVideoOrientation, t, updateSettings],
|
||||
);
|
||||
|
||||
const bitrateOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
options: BITRATES.map((bitrate) => ({
|
||||
type: "radio" as const,
|
||||
label: bitrate.key,
|
||||
value: bitrate.key,
|
||||
selected: bitrate.key === settings?.defaultBitrate?.key,
|
||||
onPress: () => updateSettings({ defaultBitrate: bitrate }),
|
||||
})),
|
||||
},
|
||||
],
|
||||
[settings?.defaultBitrate?.key, t, updateSettings],
|
||||
);
|
||||
|
||||
const autoPlayEpisodeOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
options: AUTOPLAY_EPISODES_COUNT(t).map((item) => ({
|
||||
type: "radio" as const,
|
||||
label: item.key,
|
||||
value: item.key,
|
||||
selected: item.key === settings?.maxAutoPlayEpisodeCount?.key,
|
||||
onPress: () => updateSettings({ maxAutoPlayEpisodeCount: item }),
|
||||
})),
|
||||
},
|
||||
],
|
||||
[settings?.maxAutoPlayEpisodeCount?.key, t, updateSettings],
|
||||
);
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
return (
|
||||
<DisabledSetting disabled={disabled}>
|
||||
<ListGroup title={t("home.settings.other.other_title")} className=''>
|
||||
<ListItem
|
||||
title={t("home.settings.other.follow_device_orientation")}
|
||||
disabled={pluginSettings?.followDeviceOrientation?.locked}
|
||||
>
|
||||
<Switch
|
||||
value={settings.followDeviceOrientation}
|
||||
disabled={pluginSettings?.followDeviceOrientation?.locked}
|
||||
onValueChange={(value) =>
|
||||
updateSettings({ followDeviceOrientation: value })
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={t("home.settings.other.video_orientation")}
|
||||
disabled={
|
||||
pluginSettings?.defaultVideoOrientation?.locked ||
|
||||
settings.followDeviceOrientation
|
||||
}
|
||||
disabled={pluginSettings?.defaultVideoOrientation?.locked}
|
||||
>
|
||||
<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-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{t(
|
||||
orientationTranslations[
|
||||
@@ -136,12 +122,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>
|
||||
|
||||
@@ -169,7 +152,7 @@ export const OtherSettings: React.FC = () => {
|
||||
keyExtractor={String}
|
||||
titleExtractor={(item) => t(`home.settings.other.video_players.${VideoPlayer[item]}`)}
|
||||
title={
|
||||
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
|
||||
<TouchableOpacity className="flex flex-row items-center justify-between py-1.5 pl-3">
|
||||
<Text className="mr-1 text-[#8E8D91]">
|
||||
{t(`home.settings.other.video_players.${VideoPlayer[settings.defaultPlayer]}`)}
|
||||
</Text>
|
||||
@@ -222,13 +205,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-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{settings.defaultBitrate?.key}
|
||||
</Text>
|
||||
@@ -237,10 +217,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
|
||||
@@ -256,12 +235,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-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{t(settings?.maxAutoPlayEpisodeCount.key)}
|
||||
</Text>
|
||||
@@ -270,12 +247,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>
|
||||
|
||||
211
components/settings/PlaybackControlsSettings.tsx
Normal file
211
components/settings/PlaybackControlsSettings.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { TFunction } from "i18next";
|
||||
import type React from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Switch, View } from "react-native";
|
||||
import { BITRATES } from "@/components/BitrateSelector";
|
||||
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";
|
||||
import { Text } from "../common/Text";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
|
||||
export const PlaybackControlsSettings: React.FC = () => {
|
||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const disabled = useMemo(
|
||||
() =>
|
||||
pluginSettings?.defaultVideoOrientation?.locked === true &&
|
||||
pluginSettings?.safeAreaInControlsEnabled?.locked === true &&
|
||||
pluginSettings?.disableHapticFeedback?.locked === true,
|
||||
[pluginSettings],
|
||||
);
|
||||
|
||||
const orientations = [
|
||||
ScreenOrientation.OrientationLock.DEFAULT,
|
||||
ScreenOrientation.OrientationLock.PORTRAIT_UP,
|
||||
ScreenOrientation.OrientationLock.LANDSCAPE_LEFT,
|
||||
ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT,
|
||||
];
|
||||
|
||||
const orientationTranslations = useMemo(
|
||||
() => ({
|
||||
[ScreenOrientation.OrientationLock.DEFAULT]:
|
||||
"home.settings.other.orientations.DEFAULT",
|
||||
[ScreenOrientation.OrientationLock.PORTRAIT_UP]:
|
||||
"home.settings.other.orientations.PORTRAIT_UP",
|
||||
[ScreenOrientation.OrientationLock.LANDSCAPE_LEFT]:
|
||||
"home.settings.other.orientations.LANDSCAPE_LEFT",
|
||||
[ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT]:
|
||||
"home.settings.other.orientations.LANDSCAPE_RIGHT",
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
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, 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 (
|
||||
<DisabledSetting disabled={disabled}>
|
||||
<ListGroup title={t("home.settings.other.other_title")} className=''>
|
||||
<ListItem
|
||||
title={t("home.settings.other.video_orientation")}
|
||||
disabled={pluginSettings?.defaultVideoOrientation?.locked}
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={orientationOptions}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{t(
|
||||
orientationTranslations[
|
||||
settings.defaultVideoOrientation as keyof typeof orientationTranslations
|
||||
],
|
||||
) || "Unknown Orientation"}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.other.orientation")}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={t("home.settings.other.safe_area_in_controls")}
|
||||
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
||||
>
|
||||
<Switch
|
||||
value={settings.safeAreaInControlsEnabled}
|
||||
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
||||
onValueChange={(value) =>
|
||||
updateSettings({ safeAreaInControlsEnabled: value })
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={t("home.settings.other.default_quality")}
|
||||
disabled={pluginSettings?.defaultBitrate?.locked}
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={bitrateOptions}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between pl-3 py-1.5 '>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{settings.defaultBitrate?.key}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.other.default_quality")}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={t("home.settings.other.disable_haptic_feedback")}
|
||||
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
||||
>
|
||||
<Switch
|
||||
value={settings.disableHapticFeedback}
|
||||
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
||||
onValueChange={(disableHapticFeedback) =>
|
||||
updateSettings({ disableHapticFeedback })
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem title={t("home.settings.other.max_auto_play_episode_count")}>
|
||||
<PlatformDropdown
|
||||
groups={autoPlayEpisodeOptions}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{t(settings?.maxAutoPlayEpisodeCount.key)}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.other.max_auto_play_episode_count")}
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
</DisabledSetting>
|
||||
);
|
||||
};
|
||||
|
||||
const AUTOPLAY_EPISODES_COUNT = (
|
||||
t: TFunction<"translation", undefined>,
|
||||
): {
|
||||
key: string;
|
||||
value: number;
|
||||
}[] => [
|
||||
{ key: t("home.settings.other.disabled"), value: -1 },
|
||||
{ key: "1", value: 1 },
|
||||
{ key: "2", value: 2 },
|
||||
{ key: "3", value: 3 },
|
||||
{ key: "4", value: 4 },
|
||||
{ key: "5", value: 5 },
|
||||
{ key: "6", value: 6 },
|
||||
{ key: "7", value: 7 },
|
||||
];
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useRouter } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
@@ -13,23 +12,22 @@ export const PluginSettings = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
return (
|
||||
<View>
|
||||
<ListGroup
|
||||
title={t("home.settings.plugins.plugins_title")}
|
||||
className='mb-4'
|
||||
>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/jellyseerr/page")}
|
||||
title={"Jellyseerr"}
|
||||
showArrow
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/marlin-search/page")}
|
||||
title='Marlin Search'
|
||||
showArrow
|
||||
/>
|
||||
</ListGroup>
|
||||
</View>
|
||||
<ListGroup
|
||||
title={t("home.settings.plugins.plugins_title")}
|
||||
className='mb-4'
|
||||
>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/plugins/jellyseerr/page")}
|
||||
title={"Jellyseerr"}
|
||||
showArrow
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/plugins/marlin-search/page")}
|
||||
title='Marlin Search'
|
||||
showArrow
|
||||
/>
|
||||
</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 = settings?.vlcTextColor ?? "White";
|
||||
const backgroundColor = settings?.vlcBackgroundColor ?? "Black";
|
||||
const outlineColor = settings?.vlcOutlineColor ?? "Black";
|
||||
const outlineThickness = settings?.vlcOutlineThickness ?? "Normal";
|
||||
const backgroundOpacity = settings?.vlcBackgroundOpacity ?? 128;
|
||||
const outlineOpacity = settings?.vlcOutlineOpacity ?? 255;
|
||||
const isBold = settings?.vlcIsBold ?? false;
|
||||
|
||||
if (isTv) return null;
|
||||
if (!settings) return null;
|
||||
|
||||
const subtitleModes = [
|
||||
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-1.5 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-1.5 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-1.5 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-1.5 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-1.5 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-1.5 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-1.5 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-1.5 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>
|
||||
|
||||
@@ -114,10 +114,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "column",
|
||||
alignSelf: "flex-end",
|
||||
}}
|
||||
className='flex flex-col items-start shrink'
|
||||
pointerEvents={showControls ? "box-none" : "none"}
|
||||
>
|
||||
{item?.Type === "Episode" && (
|
||||
@@ -133,7 +130,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
<Text className='text-xs opacity-50'>{item?.Album}</Text>
|
||||
)}
|
||||
</View>
|
||||
<View className='flex flex-row space-x-2'>
|
||||
<View className='flex flex-row space-x-2 shrink-0'>
|
||||
<SkipButton
|
||||
showButton={showSkipButton}
|
||||
onPress={skipIntro}
|
||||
|
||||
@@ -321,7 +321,7 @@ export const Controls: FC<Props> = ({
|
||||
}>();
|
||||
|
||||
const { showSkipButton, skipIntro } = useIntroSkipper(
|
||||
item?.Id!,
|
||||
item.Id!,
|
||||
currentTime,
|
||||
seek,
|
||||
play,
|
||||
@@ -332,7 +332,7 @@ export const Controls: FC<Props> = ({
|
||||
);
|
||||
|
||||
const { showSkipCreditButton, skipCredit } = useCreditSkipper(
|
||||
item?.Id!,
|
||||
item.Id!,
|
||||
currentTime,
|
||||
seek,
|
||||
play,
|
||||
|
||||
@@ -56,7 +56,10 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
}, []);
|
||||
|
||||
const { getDownloadedItems } = useDownload();
|
||||
const downloadedFiles = getDownloadedItems();
|
||||
const downloadedFiles = useMemo(
|
||||
() => getDownloadedItems(),
|
||||
[getDownloadedItems],
|
||||
);
|
||||
|
||||
const seasonIndex = seasonIndexState[item.ParentId ?? ""];
|
||||
|
||||
@@ -68,13 +71,13 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
const seriesEpisodes = downloadedFiles?.filter(
|
||||
(f: DownloadedItem) => f.item.SeriesId === item.SeriesId,
|
||||
);
|
||||
const seasonNumbers = [
|
||||
...new Set(
|
||||
const seasonNumbers = Array.from(
|
||||
new Set(
|
||||
seriesEpisodes
|
||||
?.map((f: DownloadedItem) => f.item.ParentIndexNumber)
|
||||
.filter(Boolean),
|
||||
),
|
||||
];
|
||||
);
|
||||
// Create fake season objects
|
||||
return seasonNumbers.map((seasonNumber) => ({
|
||||
Id: seasonNumber?.toString(),
|
||||
@@ -268,7 +271,6 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
</View>
|
||||
)}
|
||||
keyExtractor={(e: BaseItemDto) => e.Id ?? ""}
|
||||
estimatedItemSize={200}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,6 +13,12 @@ const SkipButton: React.FC<SkipButtonProps> = ({
|
||||
buttonText,
|
||||
...props
|
||||
}) => {
|
||||
console.log(`[SKIP_BUTTON] Render:`, {
|
||||
buttonText,
|
||||
showButton,
|
||||
className: showButton ? "flex" : "hidden",
|
||||
});
|
||||
|
||||
return (
|
||||
<View className={showButton ? "flex" : "hidden"} {...props}>
|
||||
<TouchableOpacity
|
||||
|
||||
@@ -89,10 +89,10 @@ const SliderScrubber: React.FC<SliderScrubberProps> = ({
|
||||
<Image
|
||||
cachePolicy={"memory-disk"}
|
||||
style={{
|
||||
width: 150 * trickplayInfo?.data.TileWidth!,
|
||||
width: 150 * trickplayInfo.data.TileWidth,
|
||||
height:
|
||||
(150 / trickplayInfo.aspectRatio!) *
|
||||
trickplayInfo?.data.TileHeight!,
|
||||
(150 / trickplayInfo.aspectRatio) *
|
||||
trickplayInfo.data.TileHeight,
|
||||
transform: [
|
||||
{ translateX: -x * tileWidth },
|
||||
{ translateY: -y * tileHeight },
|
||||
|
||||
@@ -63,10 +63,10 @@ export const TrickplayBubble: FC<TrickplayBubbleProps> = ({
|
||||
<Image
|
||||
cachePolicy={"memory-disk"}
|
||||
style={{
|
||||
width: tileWidth * trickplayInfo?.data.TileWidth!,
|
||||
width: tileWidth * (trickplayInfo.data.TileWidth ?? 1),
|
||||
height:
|
||||
(tileWidth / trickplayInfo.aspectRatio!) *
|
||||
trickplayInfo?.data.TileHeight!,
|
||||
(tileWidth / (trickplayInfo.aspectRatio ?? 1)) *
|
||||
(trickplayInfo.data.TileHeight ?? 1),
|
||||
transform: [
|
||||
{ translateX: -x * tileWidth },
|
||||
{ translateY: -y * tileHeight },
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTVEventHandler } from "react-native";
|
||||
import { Platform } from "react-native";
|
||||
import { type SharedValue, useSharedValue } from "react-native-reanimated";
|
||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||
import { CONTROLS_CONSTANTS } from "../constants";
|
||||
|
||||
// TV event handler with fallback for non-TV platforms
|
||||
let useTVEventHandler: (callback: (evt: any) => void) => void;
|
||||
if (Platform.isTV) {
|
||||
try {
|
||||
useTVEventHandler = require("react-native").useTVEventHandler;
|
||||
} catch {
|
||||
// Fallback for non-TV platforms
|
||||
useTVEventHandler = () => {};
|
||||
}
|
||||
} else {
|
||||
// No-op hook for non-TV platforms
|
||||
useTVEventHandler = () => {};
|
||||
}
|
||||
|
||||
interface UseRemoteControlProps {
|
||||
progress: SharedValue<number>;
|
||||
min: SharedValue<number>;
|
||||
@@ -63,6 +77,7 @@ export function useRemoteControl({
|
||||
[isVlc],
|
||||
);
|
||||
|
||||
// TV remote control handling (no-op on non-TV platforms)
|
||||
useTVEventHandler((evt) => {
|
||||
if (!evt) return;
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ export const useControlsTimeout = ({
|
||||
isSliding,
|
||||
episodeView,
|
||||
onHideControls,
|
||||
timeout = 4000,
|
||||
timeout = 10000,
|
||||
}: UseControlsTimeoutProps) => {
|
||||
const controlsTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user