Files
streamyfin/components/watchlists/WatchlistSheet.tsx
2026-01-11 17:38:41 +01:00

319 lines
9.3 KiB
TypeScript

import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import React, {
forwardRef,
useCallback,
useImperativeHandle,
useMemo,
useRef,
} from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
StyleSheet,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import useRouter from "@/hooks/useAppRouter";
import {
useAddToWatchlist,
useRemoveFromWatchlist,
} from "@/hooks/useWatchlistMutations";
import {
useItemInWatchlists,
useMyWatchlistsQuery,
} from "@/hooks/useWatchlists";
import type { StreamystatsWatchlist } from "@/utils/streamystats/types";
export interface WatchlistSheetRef {
open: (item: BaseItemDto) => void;
close: () => void;
}
interface WatchlistRowProps {
watchlist: StreamystatsWatchlist;
isInWatchlist: boolean;
isCompatible: boolean;
onToggle: () => void;
isLoading: boolean;
}
const WatchlistRow: React.FC<WatchlistRowProps> = ({
watchlist,
isInWatchlist,
isCompatible,
onToggle,
isLoading,
}) => {
const disabled = !isCompatible && !isInWatchlist;
return (
<TouchableOpacity
onPress={onToggle}
disabled={disabled || isLoading}
className={`bg-neutral-800 px-4 py-3 flex-row items-center justify-between ${disabled ? "opacity-40" : ""}`}
activeOpacity={0.7}
>
<View className='flex-1 mr-4'>
<View className='flex-row items-center gap-2'>
<Text className='text-base font-medium flex-shrink' numberOfLines={1}>
{watchlist.name}
</Text>
{watchlist.allowedItemType && (
<View className='bg-neutral-700 px-1.5 py-0.5 rounded'>
<Text className='text-xs text-neutral-400'>
{watchlist.allowedItemType}
</Text>
</View>
)}
</View>
{watchlist.description && (
<Text className='text-sm text-neutral-400 mt-0.5' numberOfLines={1}>
{watchlist.description}
</Text>
)}
<Text className='text-xs text-neutral-500 mt-1'>
{watchlist.itemCount ?? 0} items
</Text>
</View>
<View className='w-8 h-8 items-center justify-center'>
{isLoading ? (
<ActivityIndicator size='small' color='#a78bfa' />
) : isInWatchlist ? (
<Ionicons name='checkmark-circle' size={26} color='#a78bfa' />
) : isCompatible ? (
<Ionicons name='add-circle-outline' size={26} color='#9ca3af' />
) : (
<Ionicons name='ban-outline' size={22} color='#525252' />
)}
</View>
</TouchableOpacity>
);
};
interface WatchlistSheetContentProps {
item: BaseItemDto;
onClose: () => void;
}
const WatchlistSheetContent: React.FC<WatchlistSheetContentProps> = ({
item,
onClose,
}) => {
const { t } = useTranslation();
const router = useRouter();
const insets = useSafeAreaInsets();
const { data: myWatchlists, isLoading: watchlistsLoading } =
useMyWatchlistsQuery();
const { data: watchlistsContainingItem, isLoading: checkingLoading } =
useItemInWatchlists(item.Id);
const addToWatchlist = useAddToWatchlist();
const removeFromWatchlist = useRemoveFromWatchlist();
const isLoading = watchlistsLoading || checkingLoading;
// Sort watchlists: ones containing item first, then compatible ones, then incompatible
const sortedWatchlists = useMemo(() => {
if (!myWatchlists) return [];
return [...myWatchlists].sort((a, b) => {
const aInWatchlist = watchlistsContainingItem?.includes(a.id) ?? false;
const bInWatchlist = watchlistsContainingItem?.includes(b.id) ?? false;
const aCompatible = !a.allowedItemType || a.allowedItemType === item.Type;
const bCompatible = !b.allowedItemType || b.allowedItemType === item.Type;
// Items in watchlist first
if (aInWatchlist && !bInWatchlist) return -1;
if (!aInWatchlist && bInWatchlist) return 1;
// Then compatible items
if (aCompatible && !bCompatible) return -1;
if (!aCompatible && bCompatible) return 1;
// Then alphabetically
return a.name.localeCompare(b.name);
});
}, [myWatchlists, watchlistsContainingItem, item.Type]);
const handleToggle = useCallback(
async (watchlist: StreamystatsWatchlist) => {
if (!item.Id) return;
const isInWatchlist = watchlistsContainingItem?.includes(watchlist.id);
if (isInWatchlist) {
await removeFromWatchlist.mutateAsync({
watchlistId: watchlist.id,
itemId: item.Id,
watchlistName: watchlist.name,
});
} else {
await addToWatchlist.mutateAsync({
watchlistId: watchlist.id,
itemId: item.Id,
watchlistName: watchlist.name,
});
}
},
[item.Id, watchlistsContainingItem, addToWatchlist, removeFromWatchlist],
);
const handleCreateNew = useCallback(() => {
onClose();
router.push("/(auth)/(tabs)/(watchlists)/create");
}, [onClose, router]);
const isItemCompatible = useCallback(
(watchlist: StreamystatsWatchlist) => {
if (!watchlist.allowedItemType) return true;
return watchlist.allowedItemType === item.Type;
},
[item.Type],
);
if (isLoading) {
return (
<View className='py-12 items-center justify-center'>
<ActivityIndicator size='large' color='#a78bfa' />
<Text className='text-neutral-400 mt-4'>{t("watchlists.loading")}</Text>
</View>
);
}
return (
<View
className='flex-1'
style={{
paddingLeft: Math.max(16, insets.left),
paddingRight: Math.max(16, insets.right),
}}
>
{/* Header */}
<View className='mb-4'>
<Text className='font-bold text-2xl'>
{t("watchlists.select_watchlist")}
</Text>
<Text className='text-neutral-400 mt-1' numberOfLines={1}>
{item.Name}
</Text>
</View>
{/* Watchlist List */}
{sortedWatchlists.length === 0 ? (
<View className='py-8 items-center'>
<Ionicons name='list-outline' size={48} color='#4b5563' />
<Text className='text-neutral-400 text-center mt-4'>
{t("watchlists.empty_title")}
</Text>
<Text className='text-neutral-500 text-center text-sm mt-1'>
{t("watchlists.empty_description")}
</Text>
</View>
) : (
<View className='rounded-xl overflow-hidden mb-4'>
{sortedWatchlists.map((watchlist, index) => (
<React.Fragment key={watchlist.id}>
<WatchlistRow
watchlist={watchlist}
isInWatchlist={
watchlistsContainingItem?.includes(watchlist.id) ?? false
}
isCompatible={isItemCompatible(watchlist)}
onToggle={() => handleToggle(watchlist)}
isLoading={
addToWatchlist.isPending || removeFromWatchlist.isPending
}
/>
{index < sortedWatchlists.length - 1 && (
<View
style={{ height: StyleSheet.hairlineWidth }}
className='bg-neutral-700'
/>
)}
</React.Fragment>
))}
</View>
)}
{/* Create New Button */}
<TouchableOpacity
onPress={handleCreateNew}
className='flex-row items-center justify-center py-4 bg-neutral-800 rounded-xl'
activeOpacity={0.7}
>
<Ionicons name='add' size={20} color='#a78bfa' />
<Text className='text-purple-400 font-medium'>
{t("watchlists.create_new")}
</Text>
</TouchableOpacity>
</View>
);
};
export const WatchlistSheet = forwardRef<WatchlistSheetRef, object>(
(_props, ref) => {
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const [currentItem, setCurrentItem] = React.useState<BaseItemDto | null>(
null,
);
const insets = useSafeAreaInsets();
useImperativeHandle(ref, () => ({
open: (item: BaseItemDto) => {
setCurrentItem(item);
bottomSheetModalRef.current?.present();
},
close: () => {
bottomSheetModalRef.current?.dismiss();
},
}));
const handleClose = useCallback(() => {
bottomSheetModalRef.current?.dismiss();
}, []);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
return (
<BottomSheetModal
ref={bottomSheetModalRef}
enableDynamicSizing
maxDynamicContentSize={600}
backdropComponent={renderBackdrop}
handleIndicatorStyle={{
backgroundColor: "white",
}}
backgroundStyle={{
backgroundColor: "#171717",
}}
>
<BottomSheetView style={{ paddingBottom: insets.bottom }}>
{currentItem && (
<WatchlistSheetContent item={currentItem} onClose={handleClose} />
)}
</BottomSheetView>
</BottomSheetModal>
);
},
);