mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 15:48:05 +00:00
Co-authored-by: Alex Kim <alexkim@Alexs-MacBook-Pro.local> Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com> Co-authored-by: Simon-Eklundh <simon.eklundh@proton.me>
319 lines
9.3 KiB
TypeScript
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>
|
|
);
|
|
},
|
|
);
|